mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 22:01:58 -05:00
feat: add import locale from file option
This commit is contained in:
6 files changed
+245
-4
No files matched your search
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user