feat: add import locale from file option

pull/8104/head
NGPixel 2 weeks ago
parent 54dd821aa0
commit 7b42568d8d
No known key found for this signature in database

@ -123,6 +123,67 @@ async function routes(app: FastifyInstance) {
}
)
/**
* INSTALL A LOCALE FROM AN UPLOADED FILE
*
* The way in for a wiki that cannot reach github at all: the same published package, carried in by
* hand instead of downloaded. The body is the strings document itself rather than a multipart form
* — one file, no fields — and the file name arrives in the query string because it is what says
* which locale this is.
*/
app.post<{ Querystring: { fileName: string } }>(
'/upload',
{
config: {
permissions: ['manage:system']
},
schema: {
summary: 'Install a locale from an uploaded strings file',
description:
'The body is the locale package itself — one of the `<tag>.json` files published at `requarks/wiki-locales` — sent as `application/json` rather than as a multipart form. For an installation that cannot reach the internet, where `/locales/fetch` has nothing to read and a locale has no row to be installed from; this creates the row as well as filling it.\n\nThe file name is the identity, exactly as it is upstream: `fr-FR.json` installs `fr-FR`, so a renamed file installs the wrong locale and a name that is not a language tag is refused. So is a body that is not one flat object of strings, and so is `en`, which ships with the wiki.\n\nNo hash is recorded, since nothing was downloaded — a later run of `/locales/fetch` on an instance that does reach upstream will therefore re-download the locale.',
tags: ['Locales'],
consumes: ['application/json'],
querystring: {
type: 'object',
properties: {
fileName: {
type: 'string',
minLength: 1,
maxLength: 255,
description: 'The name of the uploaded file, e.g. `fr-FR.json`.'
}
},
required: ['fileName']
},
response: {
200: {
description: 'Locale installed successfully',
type: 'object',
properties: {
ok: { type: 'boolean' },
code: {
type: 'string',
description: 'The locale the file was installed as, read off its name.'
},
message: { type: 'string' }
}
}
}
}
},
async (req, reply) => {
let code: string
try {
code = await WIKI.models.locales.installFromFile(req.query.fileName, req.body)
} catch (err: any) {
return reply.badRequest(err.message)
}
await audit(req, 'admin', 'uploadLocale', { code, fileName: req.query.fileName })
return { ok: true, code, message: 'Locale installed successfully.' }
}
)
/**
* SET A LOCALE'S ALIASES
*/

@ -232,6 +232,7 @@
"admin.audit.actions.updateUser": "Updated a user",
"admin.audit.actions.updateUserDefaults": "Changed the user defaults",
"admin.audit.actions.uploadAsset": "Uploaded a file",
"admin.audit.actions.uploadLocale": "Installed a locale from a file",
"admin.audit.actions.verifyEmail": "Confirmed an email address",
"admin.audit.actions.watchPage": "Started watching a page",
"admin.audit.allActions": "Any action",
@ -693,12 +694,16 @@
"admin.locale.downloadNew": "Install New Locale",
"admin.locale.downloadTitle": "Download Locale",
"admin.locale.editAliases": "Edit Locale Aliases",
"admin.locale.fetch": "Fetch Locales",
"admin.locale.fetch": "Fetch Updates",
"admin.locale.fetchHint": "Check for new and updated locales.",
"admin.locale.forcePrefix": "Force Locale Prefix",
"admin.locale.forcePrefixHint": "Paths without a locale code will always be redirected to the primary locale.",
"admin.locale.install": "Install",
"admin.locale.installFailed": "Failed to install the locale.",
"admin.locale.installFile": "Install from file...",
"admin.locale.installFileFailed": "Failed to install the locale from this file.",
"admin.locale.installFileHint": "Install a locale package (.json) downloaded from the Wiki.js locales repository, for a wiki that cannot reach the internet.",
"admin.locale.installFileSuccess": "Locale {code} installed successfully.",
"admin.locale.installSuccess": "Locale installed successfully.",
"admin.locale.loadFailed": "Failed to fetch locale settings.",
"admin.locale.name": "Name",
@ -2557,7 +2562,7 @@
"localeFetchDialog.resultNone": "Everything is already up to date.",
"localeFetchDialog.resultUnchanged": "{count} already up to date",
"localeFetchDialog.resultUpdated": "No locale updated | {count} locale updated | {count} locales updated",
"localeFetchDialog.title": "Fetch Locales",
"localeFetchDialog.title": "Fetch Updates",
"navEdit.clearItems": "Clear All Items",
"navEdit.editMenuItems": "Edit Menu Items",
"navEdit.editingInherited": "Inherited menu — shared with every page using it",

@ -96,6 +96,7 @@ export const AUDIT_ACTIONS = {
'flushIconCache',
'fetchLocales',
'installLocale',
'uploadLocale',
'updateLocale',
'updateMailConfig',
'sendTestEmail',

@ -68,6 +68,22 @@ function localeInfoFor(code: string) {
}
}
/**
* Whether a parsed document is a locale string set.
*
* Every locale package is one flat object of key to string — `locales/en.json` is the shape, and the
* published packages are translations of it. Anything else is a JSON file that is not a locale, and
* the two that would otherwise get this far are worth naming: the repository's own `metadata.json`
* is an array, and a nested object is a namespaced format this wiki does not read.
*/
function isStringsDocument(value: unknown): value is Record<string, string> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false
}
const entries = Object.entries(value)
return entries.length > 0 && entries.every(([, str]) => typeof str === 'string')
}
/** A locale row, as far as naming it for a list is concerned. */
interface NameableLocale {
code: string
@ -359,6 +375,91 @@ class Locales {
WIKI.logger.info(`Locale ${code} installed successfully. [ OK ]`)
}
/**
* Install a locale from a strings file an administrator uploaded, rather than from upstream.
*
* The whole point is the wiki that cannot reach github: an air-gapped instance has no metadata to
* read and therefore not even a row to install from, so this creates the row as well as filling
* it — it is `install` and the `added` half of `updateFromRemote` at once. The file is one of the
* packages published at `requarks/wiki-locales`, carried in by hand.
*
* **The file name is the identity**, exactly as it is for the remote packages and the files in
* `locales/`: `fr-FR.json` is the locale `fr-FR`, and nothing else in the upload says which locale
* it is. So a file somebody renamed installs the wrong locale, which is why the name is held to
* being a structurally valid language tag rather than just non-empty.
*
* It is read verbatim, case and all — `refreshFromDisk` reads the files in `locales/` the same way,
* and `localeInfoFor` says why neither canonicalizes. The extension is therefore matched exactly
* too: taking `FR-FR.JSON` would file the strings under a code that names no published package and
* sits beside the `fr-FR` a later fetch would create, so it is refused as the renamed file it is.
*
* **The hash is left empty**, as it is for a locale that came off disk: no upstream file was
* downloaded, so there is nothing a later update run could compare against. That makes the first
* run that does reach upstream re-download it, which is the right answer for strings of unknown
* provenance — and costs an air-gapped wiki nothing, since it never has such a run.
*
* @returns The code the file was installed as.
*/
async installFromFile(fileName: string, strings: unknown): Promise<string> {
const name = (fileName ?? '').trim()
if (!name.endsWith('.json')) {
throw new Error(
`"${name}" is not a locale package: it must be a .json file named for its locale, e.g. "fr-FR.json".`
)
}
const code = name.slice(0, -'.json'.length)
// -> Same reasoning as `install`: it ships with the wiki and this build's strings are the
// authority on what the interface says
if (code === SOURCE_LOCALE) {
throw new Error(`Locale ${code} ships with the wiki and cannot be uploaded.`)
}
let localeInfo: ReturnType<typeof localeInfoFor> | null = null
try {
localeInfo = localeInfoFor(code)
} catch {
// -> Not a structurally valid tag. Reported with the rest of what the name can be wrong about
}
/*
Parsing is not enough on its own. BCP 47 allows a primary language subtag of five to eight
letters, for subtags nobody ever registered, so `Intl.Locale` happily accepts `french` and
`passwd` -- and an upload named either would install a locale called that, sitting in the
admin list for ever with nothing to say what it is. Every language strings are published for is
ISO 639, which is two or three letters, and that is what makes a name a language tag here.
A path rather than a bare name fails the same check, which is why nothing is stripped off the
front of it: `../../etc/passwd` does not parse as a tag, and a name that is not just a name is
not a locale package whatever it ends in.
*/
if (!localeInfo || localeInfo.language.length > 3) {
throw new Error(`"${name}" is not named for a valid language tag.`)
}
if (!isStringsDocument(strings)) {
throw new Error(`"${name}" does not hold a locale string set.`)
}
WIKI.logger.info(`Installing locale ${code} from an uploaded file...`)
await WIKI.db
.insert(localesTable)
.values({
code,
...localeInfo,
isInstalled: true,
hash: '',
strings
})
.onConflictDoUpdate({
target: localesTable.code,
set: { strings, isInstalled: true, hash: '', updatedAt: sql`now()` }
})
await this.reloadCache()
WIKI.events.outbound.emit('reloadLocales')
WIKI.logger.info(`Locale ${code} installed successfully. [ OK ]`)
return code
}
/**
* Set — or, with empty values, clear — what a locale is called and what it is addressed as.
*

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
272 icons.
273 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -65,6 +65,7 @@ export const BUNDLED_ICONS = {
"la:file-image": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.594l-.281-.313l-6-6L19.406 3zm2 2h10v6h6v16H8zm12 1.438L22.563 9H20zM21.094 14c-.551 0-1 .45-1 1s.449 1 1 1s1-.45 1-1s-.45-1-1-1M14 15.594l-.719.687l-4 4l1.438 1.438L14 18.437l2.281 2.282l.719.687l.719-.687L19 19.437l2.281 2.282l1.438-1.438l-3-3l-.719-.687l-.719.687L17 18.563l-2.281-2.282z\"/>","width":32,"height":32},
"la:file-import": {"body":"<path fill=\"currentColor\" d=\"M6 4v24h20v-9h-2v7H8V6h16v7h2V4zm11.5 7l-4.313 4.281L12.5 16l.688.719L17.5 21l1.406-1.406L16.313 17H28v-2H16.312l2.594-2.594z\"/>","width":32,"height":32},
"la:file-invoice": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM10 13v2h12v-2zm0 5v2h7v-2zm9 0v2h3v-2zm-9 4v2h7v-2zm9 0v2h3v-2z\"/>","width":32,"height":32},
"la:file-upload": {"body":"<path fill=\"currentColor\" d=\"M6 3v26h20V9.6l-.3-.3l-6-6l-.3-.3zm2 2h10v6h6v16H8zm12 1.4L22.6 9H20zM16 13l-4 4h3v5h2v-5h3zm-4 10v2h8v-2z\"/>","width":32,"height":32},
"la:fill": {"body":"<path fill=\"currentColor\" d=\"M11.313 3.281L9.905 4.72l1.782 1.78l-6.906 6.906a3.063 3.063 0 0 0 0 4.313l.063.062l6.343 6.313a3.063 3.063 0 0 0 4.313 0l7.594-7.594l.718-.688l-9.718-9.718l-.781-.813l-.22-.187zm1.812 4.656L21 15.813l-6.906 6.876a1.054 1.054 0 0 1-1.5 0L6.219 16.28a1.017 1.017 0 0 1 0-1.468zM25 19.25l-.813 1.188s-.539.753-1.062 1.656c-.262.453-.508.926-.719 1.406S22 24.422 22 25c0 1.645 1.355 3 3 3s3-1.355 3-3c0-.578-.195-1.02-.406-1.5s-.457-.953-.719-1.406c-.523-.903-1.063-1.657-1.063-1.657zm0 3.625c.066.11.059.102.125.219c.238.41.492.847.656 1.218c.164.372.219.715.219.688c0 .555-.445 1-1 1s-1-.445-1-1c0 .027.055-.316.219-.688c.164-.37.418-.808.656-1.218c.066-.117.059-.11.125-.219\"/>","width":32,"height":32},
"la:fingerprint": {"body":"<path fill=\"currentColor\" d=\"M16 4c-.262 0-.496.016-.75.031a13 13 0 0 0-4.063.875l.75 1.875a10.8 10.8 0 0 1 3.407-.75C15.55 6.02 15.774 6 16 6c1.883 0 3.664.477 5.219 1.313l.937-1.75A13 13 0 0 0 16 4M9.5 5.719a13 13 0 0 0-3.188 2.593c-.414.461-.777.981-1.125 1.5c-.382.57-.714 1.168-1 1.782L6 12.406a11.2 11.2 0 0 1 1.813-2.75A11 11 0 0 1 10.5 7.47zm14.469 1L22.75 8.312a10.93 10.93 0 0 1 4.219 8.094c.004.063.047.61 0 1.532l2 .125c.05-1.004.008-1.665 0-1.782a12.94 12.94 0 0 0-5-9.562M16 7v2c4.25 0 7.77 3.313 8 7.563c.008.113.129 3.066-1 6.625l1.906.593c1.239-3.902 1.11-7.031 1.094-7.312C25.715 11.176 21.293 7 16 7m-1.844.156a9.9 9.9 0 0 0-5.594 3.157c-.32.355-.636.753-.906 1.156h.032v.031C6.52 13.262 5.902 15.3 6 17.406v.563l2 .062v-.656c-.09-1.715.383-3.375 1.344-4.813c.21-.32.433-.624.687-.906a7.96 7.96 0 0 1 4.5-2.531zM15.594 10a6.9 6.9 0 0 0-4.25 1.781l1.312 1.5A5 5 0 0 1 15.72 12c.105-.008.183 0 .281 0c.582 0 1.14.098 1.656.281l.688-1.875A7.1 7.1 0 0 0 16 10c-.145 0-.27-.008-.406 0m4.281 1.156l-1.094 1.688A4.95 4.95 0 0 1 21 16.719l2-.094a7.05 7.05 0 0 0-3.125-5.469M15.781 13a4 4 0 0 0-2.75 1.344A3.98 3.98 0 0 0 12 17.219c0-.004.05 1.125-.406 2.437c-.457 1.313-1.371 2.793-3.344 3.75l-.625.282c-.332.148-.75.32-.844.343l.438 1.938c.445-.102.875-.301 1.25-.469s.656-.313.656-.313c2.5-1.21 3.762-3.207 4.344-4.875c.582-1.667.539-2.996.531-3.187v-.031a1.93 1.93 0 0 1 .5-1.438A1.95 1.95 0 0 1 15.875 15c.05-.004.09 0 .125 0v-2c-.082 0-.148-.004-.219 0m-5.625.125A6.96 6.96 0 0 0 9 17.344v.031c.004.082.09 2.266-2.063 3.313C6.891 20.706 6.146 21 5 21v2c1.566 0 2.75-.469 2.75-.469h.031l.032-.031c3.222-1.563 3.19-5.04 3.187-5.219v-.031c-.059-1.09.25-2.11.844-3zm7.75.344l-.968 1.781c.593.32 1.023.902 1.062 1.625c.008.164.285 6.387-4.625 10.344l1.25 1.562c5.719-4.605 5.402-11.531 5.375-12a4 4 0 0 0-2.094-3.312M16 16c-.55 0-1 .45-1 1v.063s.117 2.058-.906 4.375l1.812.812C17.09 19.574 17.008 17.172 17 17v-.063A1.004 1.004 0 0 0 16 16m4.969 1.938c-.125 2.03-.766 6.195-3.719 9.687l1.5 1.281c3.363-3.972 4.078-8.558 4.219-10.843zM13.562 22.5c-.8 1.348-2.039 2.645-4 3.594l.876 1.812c2.32-1.125 3.87-2.77 4.843-4.406z\"/>","width":32,"height":32},
"la:folder-open": {"body":"<path fill=\"currentColor\" d=\"M5 3v24.813l.781.156l12 2.5l1.219.25V28h6V15.437l1.719-1.718l.281-.313V3zm9.125 2H25v7.563l-1.719 1.718l-.281.313V26h-4v-8.906l-.281-.313L17 15.063V5.719zM7 5.281l8 2v8.625l.281.313L17 17.937v10.344L7 26.188z\"/>","width":32,"height":32},

@ -22,6 +22,17 @@
@click="fetchLocales">
<w-tooltip>{{ t(`admin.locale.fetchHint`) }}</w-tooltip>
</w-btn>
<w-btn
class="mr-2 acrylic-btn"
flat
icon="la:file-upload"
:color="dark.isActive ? `indigo-4` : `indigo`"
:label="t(`admin.locale.installFile`)"
:loading="state.uploading"
:disabled="state.loading > 0 || state.uploading || Boolean(state.installing)"
@click="promptInstallFile">
<w-tooltip>{{ t(`admin.locale.installFileHint`) }}</w-tooltip>
</w-btn>
<w-separator class="mr-2" vertical />
<w-btn
class="mr-2 acrylic-btn"
@ -162,12 +173,18 @@
</div>
</div>
</div>
<input
type="file"
ref="localeFileIpt"
accept=".json,application/json"
style="display: none"
@change="installFromFile" />
</w-page>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, reactive, watch } from 'vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useDark } from '@/composables/dark'
import { dialog } from '@/composables/dialog'
@ -208,6 +225,7 @@ useMeta(() => ({
const state = reactive({
loading: 0,
installing: null,
uploading: false,
locales: [],
primary: 'en',
forcePrefix: false,
@ -215,6 +233,10 @@ const state = reactive({
active: []
})
// REFS
const localeFileIpt = ref(null)
// COMPUTED
// -> Installed first, so that the handful of locales this site can actually use is not buried among
@ -370,6 +392,56 @@ async function install(code) {
state.installing = null
}
function promptInstallFile() {
if (state.uploading) {
return
}
// -> Cleared before rather than only after, so that picking the same file twice still fires
// `change` -- a failed upload the administrator wants to retry unchanged is the case
localeFileIpt.value.value = null
localeFileIpt.value.click()
}
/**
* Install a locale from one of the published package files, carried in by hand.
*
* For a wiki that cannot reach github, where Fetch Updates has nothing to read. The body is the file
* itself rather than a multipart form, and its NAME travels beside it in the query string because
* that is what says which locale the strings are: `fr-FR.json` is `fr-FR`. The server is what judges
* both -- `accept` on the input is a hint to the file picker and nothing more.
*/
async function installFromFile() {
const file = localeFileIpt.value?.files?.[0]
if (!file || state.uploading) {
return
}
state.uploading = true
try {
const resp = await API_CLIENT.post('locales/upload', {
searchParams: { fileName: file.name },
headers: { 'content-type': 'application/json' },
body: file
}).json()
// -> The API client does not throw on 400, so a refused file comes back as a parsed error
if (resp?.ok === false) {
throw new Error(resp.message || 'An unexpected error occured.')
}
notify({
type: 'positive',
message: t('admin.locale.installFileSuccess', { code: resp.code })
})
await load()
} catch (err) {
notify({
type: 'negative',
message: t('admin.locale.installFileFailed'),
caption: apiErrorMessage(err)
})
}
localeFileIpt.value.value = null
state.uploading = false
}
// MOUNTED
onMounted(() => {

Loading…
Cancel
Save