feat: add import locale from file option

This commit is contained in:
NGPixel committed 2026-09-15 04:14:04 -04:00
1 parent 54dd821aa0
commit 7b42568d8d
6 files changed
+245 -4

No files matched your search

+61
View File
@@ -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
*/
+7 -2
View File
@@ -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",
+1
View File
@@ -96,6 +96,7 @@ export const AUDIT_ACTIONS = {
'flushIconCache',
'fetchLocales',
'installLocale',
'uploadLocale',
'updateLocale',
'updateMailConfig',
'sendTestEmail',
+101
View File
@@ -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.
*