diff --git a/backend/api/bootstrap.ts b/backend/api/bootstrap.ts index 5fe2a3616..644696b5e 100644 --- a/backend/api/bootstrap.ts +++ b/backend/api/bootstrap.ts @@ -9,8 +9,9 @@ import type { FastifyInstance } from 'fastify' * the login flow asks who is logged in once that has changed — but a full load needs all three at * once, and asking for them one at a time is three round trips before the first pixel. * - * None of them touches the database: the site configurations and the flags are in memory, and the - * session carries the user. So what this saves is the round trips, which is the whole cost. + * None of them touches the database: the site configurations, the flags and the locale list are in + * memory, and the session carries the user. So what this saves is the round trips, which is the whole + * cost. */ async function routes(app: FastifyInstance) { app.get<{ Querystring: { hostname?: string } }>( @@ -46,6 +47,12 @@ async function routes(app: FastifyInstance) { description: 'As `users/whoami` answers it: `authenticated: false` alone for a guest, otherwise the account and its group-wide permissions.', additionalProperties: true + }, + locales: { + type: 'array', + description: + 'Every installed locale, named and coded as this wiki refers to it. None of it can be worked out from a code alone: the short forms depend on which other locales exist, and an administrator can override either. Sent here because the locale selector needs it to label itself on the first paint.', + items: { $ref: 'Locale#' } } } } @@ -69,7 +76,8 @@ async function routes(app: FastifyInstance) { isEnabled: site.isEnabled }, flags: WIKI.models.flags.getFlags(), - user: whoAmI(req) + user: whoAmI(req), + locales: await WIKI.models.locales.getInstalledLocales() } } ) diff --git a/backend/api/index.ts b/backend/api/index.ts index 375236fe3..612f12eff 100644 --- a/backend/api/index.ts +++ b/backend/api/index.ts @@ -15,6 +15,7 @@ async function routes(app: FastifyInstance) { await import('./schemas/group.ts').then((m) => m.registerSchemas(app)) await import('./schemas/hook.ts').then((m) => m.registerSchemas(app)) await import('./schemas/icon.ts').then((m) => m.registerSchemas(app)) + await import('./schemas/locale.ts').then((m) => m.registerSchemas(app)) await import('./schemas/mail.ts').then((m) => m.registerSchemas(app)) await import('./schemas/page.ts').then((m) => m.registerSchemas(app)) await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app)) diff --git a/backend/api/locales.ts b/backend/api/locales.ts index a4eef1408..f974ca068 100644 --- a/backend/api/locales.ts +++ b/backend/api/locales.ts @@ -12,7 +12,16 @@ async function routes(app: FastifyInstance) { }, schema: { summary: 'List all locales', - tags: ['Locales'] + description: + 'Every locale this wiki knows of, installed or merely published upstream, named and coded as this wiki refers to them.', + tags: ['Locales'], + response: { + 200: { + description: 'The locale list', + type: 'array', + items: { $ref: 'Locale#' } + } + } } }, async () => { @@ -20,6 +29,156 @@ async function routes(app: FastifyInstance) { } ) + /** + * FETCH LOCALES FROM UPSTREAM + * + * Runs the update to completion rather than queueing it, because the caller is a dialog waiting + * for a count to show. It is the same work the nightly `updateLocales` job does, and cheap for the + * same reason: the metadata is one small document, and only an installed locale whose hash moved + * is actually downloaded. + */ + app.post( + '/fetch', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Fetch the latest locales from the Wiki.js repository', + description: + 'Reads the published locale metadata and records any locale not seen before as available. An installed locale is re-downloaded only when its published hash differs from the one stored, so a run that finds nothing new costs a single request.', + tags: ['Locales'], + response: { + 200: { + description: 'Locales fetched successfully', + type: 'object', + properties: { + ok: { type: 'boolean' }, + added: { + type: 'integer', + description: 'Locales newly available, whose strings were not downloaded.' + }, + updated: { + type: 'integer', + description: 'Installed locales whose strings changed upstream and were refreshed.' + }, + unchanged: { type: 'integer' }, + failed: { type: 'integer' } + } + } + } + } + }, + async () => { + return { ok: true, ...(await WIKI.models.locales.updateFromRemote()) } + } + ) + + /** + * INSTALL A LOCALE + */ + app.post<{ Params: { code: string } }>( + '/:code/install', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Download the strings of an available locale', + description: + 'Downloads the published strings file for a locale that has a row but no strings, making it installable on a site. Fetch the locale list first: a locale nobody has heard of yet has no row to install.', + tags: ['Locales'], + params: { + type: 'object', + properties: { + code: { type: 'string', description: 'The locale code, e.g. `fr-FR`.' } + } + }, + response: { + 200: { + description: 'Locale installed successfully', + type: 'object', + properties: { + ok: { type: 'boolean' }, + message: { type: 'string' } + } + } + } + } + }, + async (req, reply) => { + try { + await WIKI.models.locales.install(req.params.code) + } catch (err: any) { + return reply.badRequest(err.message) + } + return { ok: true, message: 'Locale installed successfully.' } + } + ) + + /** + * SET A LOCALE'S ALIASES + */ + app.put<{ + Params: { code: string } + Body: { customName?: string | null; customCode?: string | null } + }>( + '/:code/aliases', + { + config: { + permissions: ['manage:system'] + }, + schema: { + summary: 'Set what a locale is called and addressed as', + description: + 'Overrides the name and the short code derived from the language tag — `zh` for `zh-CN`. The locale is still identified everywhere by its `code`, so nothing already recorded against it moves. An empty value puts the derived form back, and so does the derived form itself.', + tags: ['Locales'], + params: { + type: 'object', + properties: { + code: { type: 'string', description: 'The locale code, e.g. `zh-CN`.' } + } + }, + body: { + type: 'object', + properties: { + customName: { + type: ['string', 'null'], + maxLength: 255, + description: 'The name to show, or empty to go back to the derived one.' + }, + customCode: { + type: ['string', 'null'], + maxLength: 255, + description: 'The short code to show, or empty to go back to the derived one.' + } + } + }, + response: { + 200: { + description: 'Aliases updated successfully', + type: 'object', + properties: { + ok: { type: 'boolean' }, + message: { type: 'string' } + } + } + } + } + }, + async (req, reply) => { + try { + await WIKI.models.locales.setAliases(req.params.code, { + customName: req.body?.customName ?? null, + customCode: req.body?.customCode ?? null + }) + } catch (err: any) { + return reply.badRequest(err.message) + } + return { ok: true, message: 'Aliases updated successfully.' } + } + ) + app.get<{ Params: { code: string } }>( '/:code/strings', { diff --git a/backend/api/pages.ts b/backend/api/pages.ts index b94ce71f7..c569a3c7d 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -724,7 +724,7 @@ async function routes(app: FastifyInstance) { */ app.put<{ Params: { siteId: string; pageId: string } - Body: { path: string; title?: string } + Body: { path: string; locale?: string; title?: string } }>( '/sites/:siteId/pages/:pageId/path', { @@ -736,7 +736,7 @@ async function routes(app: FastifyInstance) { schema: { summary: 'Move a page to another path', description: - 'Also renames it when a title is given. The tree entry moves with it, and any folder the new path needs is created.', + 'Also renames it when a title is given, and moves it to another locale when one is given. The tree entry moves with it, any folder the new path needs is created, and the copy on every storage target follows.\n\nMoving between locales needs `manage:pages` at the destination as well as at the source, since page rules are granted per locale.', tags: ['Pages'], params: pageIdParam, body: { @@ -748,6 +748,11 @@ async function routes(app: FastifyInstance) { maxLength: 255, pattern: '^/?[a-zA-Z0-9-_/]*$' }, + locale: { + type: 'string', + maxLength: 255, + description: 'The locale to move it to. Stays in its own when absent.' + }, title: { type: 'string', minLength: 1, @@ -783,6 +788,22 @@ async function routes(app: FastifyInstance) { if (!mayOnPage(req, 'manage:pages', target)) { return reply.forbidden('You are not allowed to move this page.') } + /* + And at the destination, when that is somewhere else: rules are granted per path AND per + locale, so a move is a write to a place the mover may have no say over — which without this + is a way to put a page somewhere they could not have created one. + */ + const destination = { + path: req.body.path.replace(/^\/+/, ''), + locale: req.body.locale || target.locale, + tags: target.tags + } + if ( + (destination.path !== target.path || destination.locale !== target.locale) && + !mayOnPage(req, 'manage:pages', destination) + ) { + return reply.forbidden('You are not allowed to move this page there.') + } const page = await WIKI.models.pages.movePage( req.params.siteId, req.params.pageId, @@ -1062,7 +1083,7 @@ async function routes(app: FastifyInstance) { /** * PAGE USER PERMISSIONS */ - app.post<{ Params: { siteId: string }; Body: { path: string } }>( + app.post<{ Params: { siteId: string }; Body: { path: string; locale?: string } }>( '/sites/:siteId/pages/userPermissions', { schema: { @@ -1079,11 +1100,18 @@ async function routes(app: FastifyInstance) { type: 'string', minLength: 1, maxLength: 255 + }, + locale: { + type: 'string', + maxLength: 255, + description: + "The locale the path is in. Rules are granted per locale, so a path answers differently in each. The site's primary one when absent." } }, examples: [ { - path: 'foo/bar' + path: 'foo/bar', + locale: 'en' } ] }, @@ -1097,7 +1125,10 @@ async function routes(app: FastifyInstance) { } }, async (req) => { - return pagePermissionsFor(req, { path: req.body.path.replace(/^\/+/, '') }) + return pagePermissionsFor(req, { + path: req.body.path.replace(/^\/+/, ''), + locale: req.body.locale + }) } ) } diff --git a/backend/api/schemas/locale.ts b/backend/api/schemas/locale.ts new file mode 100644 index 000000000..d4fb09998 --- /dev/null +++ b/backend/api/schemas/locale.ts @@ -0,0 +1,78 @@ +import type { FastifyInstance } from 'fastify' + +export async function registerSchemas(app: FastifyInstance): Promise { + /** + * LOCALE + * + * One row of the locale list, as `locales` and `bootstrap` hand it out. `code` is the identity — + * the language tag the strings file is published under, what a page's `locale` holds, and what a + * storage target files it under. Everything beside it says how this wiki refers to that locale, + * and is resolved on the way out rather than stored, since it depends on which other locales exist. + */ + app.addSchema({ + $id: 'Locale', + type: 'object', + properties: { + code: { + type: 'string', + description: 'The language tag identifying this locale, e.g. `zh-CN`.' + }, + language: { + type: 'string', + description: 'The bare language subtag, e.g. `zh`.' + }, + name: { + type: 'string', + description: + 'The name in English, qualified by region only where a second locale shares the language: "German", but "Chinese (China)" beside "Chinese (Taiwan)".' + }, + nativeName: { + type: 'string', + description: 'The same name, in the locale itself.' + }, + customName: { + type: ['string', 'null'], + description: 'The name an administrator set instead, or null.' + }, + customCode: { + type: ['string', 'null'], + description: 'The short code an administrator set instead, or null.' + }, + derivedCode: { + type: 'string', + description: + 'The short code the tag gives on its own: the language subtag where nothing else shares it, the whole tag where something does. What clearing `customCode` goes back to.' + }, + displayCode: { + type: 'string', + description: 'The short code to show: `customCode`, or `derivedCode`.' + }, + displayName: { + type: 'string', + description: + 'The single line to show wherever the locale is offered rather than described: `customName`, or the native name.' + }, + isRTL: { + type: 'boolean', + description: 'Whether the script runs right to left.' + }, + isInstalled: { + type: 'boolean', + description: + 'Whether the strings have been downloaded. A locale that is merely published upstream has a row so it can be offered, but nothing to serve until it is installed.' + }, + completeness: { + type: 'integer', + description: 'How much of the string set is translated, as a percentage.' + }, + createdAt: { + type: 'string', + format: 'date-time' + }, + updatedAt: { + type: 'string', + format: 'date-time' + } + } + }) +} diff --git a/backend/api/sites.ts b/backend/api/sites.ts index 6bb02c290..69aca60f1 100644 --- a/backend/api/sites.ts +++ b/backend/api/sites.ts @@ -434,7 +434,9 @@ async function routes(app: FastifyInstance) { // -> Validate locales against the installed ones, and against what the site ends up with once // the patch is merged, so that a partial update cannot leave the primary locale inactive if (req.body.locales) { - const installedCodes = (await WIKI.models.locales.getLocales()).map((lc: any) => lc.code) + const installedCodes = (await WIKI.models.locales.getInstalledLocales()).map( + (lc: any) => lc.code + ) const active = req.body.locales.active ?? site.config.locales?.active ?? [] const primary = req.body.locales.primary ?? site.config.locales?.primary diff --git a/backend/core/maintenance.ts b/backend/core/maintenance.ts index 8ac345e2b..a732f6d04 100644 --- a/backend/core/maintenance.ts +++ b/backend/core/maintenance.ts @@ -73,5 +73,10 @@ export default { WIKI.events.inbound.on('flushCaches', async () => { await this.flushCaches() }) + // -> The locale list is cached per instance, so an install or an update on one of them is only + // visible everywhere once the others read it back + WIKI.events.inbound.on('reloadLocales', async () => { + await WIKI.models.locales.reloadCache() + }) } } diff --git a/backend/db/migrations/20260809235619_init/migration.sql b/backend/db/migrations/20260809235619_init/migration.sql index 441bde36f..d173753f7 100644 --- a/backend/db/migrations/20260809235619_init/migration.sql +++ b/backend/db/migrations/20260809235619_init/migration.sql @@ -174,6 +174,10 @@ CREATE TABLE "locales" ( "region" varchar(3) NOT NULL, "script" varchar(4) NOT NULL, "isRTL" boolean DEFAULT false NOT NULL, + "isInstalled" boolean DEFAULT false NOT NULL, + "hash" varchar(64) DEFAULT '' NOT NULL, + "customCode" varchar(255) UNIQUE, + "customName" varchar(255), "strings" jsonb DEFAULT '[]' NOT NULL, "completeness" integer DEFAULT 0 NOT NULL, "createdAt" timestamp DEFAULT now() NOT NULL, @@ -183,6 +187,7 @@ CREATE TABLE "locales" ( CREATE TABLE "navigation" ( "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "items" jsonb DEFAULT '[]' NOT NULL, + "locale" varchar(255), "siteId" uuid NOT NULL ); --> statement-breakpoint @@ -389,6 +394,7 @@ CREATE INDEX "assets_siteId_idx" ON "assets" ("siteId");--> statement-breakpoint CREATE INDEX "blocks_siteId_idx" ON "blocks" ("siteId");--> statement-breakpoint CREATE INDEX "locales_language_idx" ON "locales" ("language");--> statement-breakpoint CREATE INDEX "navigation_siteId_idx" ON "navigation" ("siteId");--> statement-breakpoint +CREATE UNIQUE INDEX "navigation_siteId_locale_key" ON "navigation" ("siteId","locale");--> statement-breakpoint CREATE INDEX "pageEditSubmissions_pageId_idx" ON "pageEditSubmissions" ("pageId");--> statement-breakpoint CREATE INDEX "pageEditSubmissions_siteId_idx" ON "pageEditSubmissions" ("siteId");--> statement-breakpoint CREATE INDEX "pageEditSubmissions_authorId_idx" ON "pageEditSubmissions" ("authorId");--> statement-breakpoint @@ -454,4 +460,4 @@ ALTER TABLE "tags" ADD CONSTRAINT "tags_siteId_sites_id_fkey" FOREIGN KEY ("site ALTER TABLE "tree" ADD CONSTRAINT "tree_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint ALTER TABLE "userGroups" ADD CONSTRAINT "userGroups_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;--> statement-breakpoint ALTER TABLE "userGroups" ADD CONSTRAINT "userGroups_groupId_groups_id_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE;--> statement-breakpoint -ALTER TABLE "userKeys" ADD CONSTRAINT "userKeys_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id"); +ALTER TABLE "userKeys" ADD CONSTRAINT "userKeys_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id"); \ No newline at end of file diff --git a/backend/db/migrations/20260809235619_init/snapshot.json b/backend/db/migrations/20260809235619_init/snapshot.json index 622ffad33..8e66f4891 100644 --- a/backend/db/migrations/20260809235619_init/snapshot.json +++ b/backend/db/migrations/20260809235619_init/snapshot.json @@ -1938,6 +1938,58 @@ "schema": "public", "table": "locales" }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "isInstalled", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "hash", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customCode", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "customName", + "entityType": "columns", + "schema": "public", + "table": "locales" + }, { "type": "jsonb", "typeSchema": null, @@ -2016,6 +2068,19 @@ "schema": "public", "table": "navigation" }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "navigation" + }, { "type": "uuid", "typeSchema": null, @@ -4022,6 +4087,34 @@ "schema": "public", "table": "navigation" }, + { + "nameExplicit": true, + "columns": [ + { + "value": "siteId", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "locale", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "navigation_siteId_locale_key", + "entityType": "indexes", + "schema": "public", + "table": "navigation" + }, { "nameExplicit": true, "columns": [ @@ -5685,6 +5778,17 @@ "table": "users", "entityType": "pks" }, + { + "nameExplicit": false, + "columns": [ + "customCode" + ], + "nullsNotDistinct": false, + "name": "locales_customCode_key", + "schema": "public", + "table": "locales", + "entityType": "uniques" + }, { "nameExplicit": false, "columns": [ diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 00927a85f..26433465e 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -289,6 +289,31 @@ export const locales = pgTable( region: varchar({ length: 3 }).notNull(), // Unicode region subtag script: varchar({ length: 4 }).notNull(), // Unicode script subtag isRTL: boolean().notNull().default(false), + /** + * Whether `strings` holds a real string set. A locale the update task has only seen in the + * remote metadata gets a row so that it can be offered, but has nothing to serve until it is + * installed. + */ + isInstalled: boolean().notNull().default(false), + /** + * The remote metadata's hash of the strings file this row was installed from, so that an update + * only downloads the locales that actually changed. Empty for a locale that came off disk and + * for one that is not installed yet -- which is exactly what makes the next update fetch it. + */ + hash: varchar({ length: 64 }).notNull().default(''), + /** + * The short code an administrator would rather this locale be shown as -- `zh` for `zh-CN` -- + * overriding the one derived from the tag. An alias and nothing more: `code` stays the identity, + * so nothing a page, an asset or a storage target already records has to move for this. + * Null when the derived form is fine, which is the usual case. + */ + customCode: varchar({ length: 255 }).unique(), + /** + * The name an administrator would rather this locale be shown as, overriding the one `Intl` + * gives for the tag. Display only, and not unique: two locales reading alike in a menu is a + * choice somebody made, not a collision. Null when the derived name is fine. + */ + customName: varchar({ length: 255 }), strings: jsonb().notNull().default([]), completeness: integer().notNull().default(0), createdAt: timestamp().notNull().defaultNow(), @@ -303,11 +328,21 @@ export const navigation = pgTable( { id: uuid().primaryKey().defaultRandom(), items: jsonb().notNull().default([]), + /** + * Set only on a site-wide menu, naming the locale it is the menu for — the sidebar a page in that + * locale falls back to when nothing above it overrides one. Null on a menu belonging to a tree + * entry, which is identified by that entry's id instead. Postgres lets a unique index hold any + * number of nulls, which is what lets both kinds share the table. + */ + locale: varchar({ length: 255 }), siteId: uuid() .notNull() .references(() => sites.id) }, - (table) => [index('navigation_siteId_idx').on(table.siteId)] + (table) => [ + index('navigation_siteId_idx').on(table.siteId), + uniqueIndex('navigation_siteId_locale_key').on(table.siteId, table.locale) + ] ) // PAGES ------------------------------ diff --git a/backend/helpers/common.ts b/backend/helpers/common.ts index 183883201..e2c1d2aae 100644 --- a/backend/helpers/common.ts +++ b/backend/helpers/common.ts @@ -130,6 +130,34 @@ export function stripPageExtension(urlPath: string, extensions?: string[] | null return urlPath.slice(0, dot) } +/** + * Which locale a page URL is addressed in, and what the path under it is. + * + * A site that brackets its URLs by locale reads `/fr/notes/one` as the page `notes/one` in French — + * the first segment being the locale's SHORT code, the same one its content is filed under on a + * storage target. Everything the wiki serves itself is under a `/_` segment and never reaches here. + * + * Mirrored on the frontend as `splitLocalePath` in `frontend/src/helpers/pagePaths.js`: the server + * redirects a request that reaches it, but a link inside a page is followed by the router alone, so + * both have to read a path the same way. + * + * @param prefixes The short code of each locale the site offers, mapped to the locale it names + * @returns The locale and the path below it, or null when no segment names a locale + */ +export function splitLocalePath( + urlPath: string, + prefixes: Map +): { locale: string; path: string } | null { + const slash = urlPath.indexOf('/', 1) + const first = slash < 0 ? urlPath.slice(1) : urlPath.slice(1, slash) + const locale = prefixes.get(first) + if (!locale) { + return null + } + // -> `/fr` alone is the French home page, which is `/` under the prefix + return { locale, path: slash < 0 ? '/' : urlPath.slice(slash) } +} + /** * Generate SHA-1 Hash of a string * diff --git a/backend/helpers/storageObjects.ts b/backend/helpers/storageObjects.ts index 55eedb742..453753612 100644 --- a/backend/helpers/storageObjects.ts +++ b/backend/helpers/storageObjects.ts @@ -200,11 +200,11 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule { } }, - async movePage(target, ref, previousPath) { + async movePage(target, ref, previous) { await moveObject( client, target, - pageKey(target, { ...ref, path: previousPath }), + pageKey(target, { ...ref, ...previous }), pageKey(target, ref) ) }, diff --git a/backend/index.ts b/backend/index.ts index c35fb2185..fb1544b09 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -33,7 +33,7 @@ import configSvc from './core/config.ts' import dbManager from './core/db.ts' import logger from './core/logger.ts' import scheduler from './core/scheduler.ts' -import { stripPageExtension } from './helpers/common.ts' +import { splitLocalePath, stripPageExtension } from './helpers/common.ts' import { corsOrigin, parseCspDirectives } from './helpers/security.ts' const nanoid = customAlphabet('1234567890abcdef', 10) @@ -80,6 +80,26 @@ function isPageUrl(urlPath: string): boolean { return !firstSegment.startsWith('_') && !RESERVED_ROOT_FILES.has(firstSegment.toLowerCase()) } +/** + * The segments a site's locale-prefixed URLs may start with, mapped to the locale each names. + * + * Every code a locale answers to, not only the short one it is addressed by now: an alias an + * administrator changed leaves the links people have already saved pointing at the old segment, and + * a wiki that answers 404 to them has broken them. `localeForShortCode` is what knows the set. + */ +function localePrefixesFor(activeCodes?: string[] | null): Map { + const prefixes = new Map() + for (const code of activeCodes ?? []) { + const locale = WIKI.cache?.get(`locale:${code}`) as any + for (const segment of [locale?.displayCode, locale?.derivedCode, code]) { + if (segment) { + prefixes.set(segment, code) + } + } + } + return prefixes +} + if (!semver.satisfies(process.version, '>=26')) { console.error('ERROR: Node.js 26.x or later required!') process.exit(1) @@ -612,6 +632,24 @@ async function initHTTPServer() { reply.redirect(withQuery(withoutExtension), 302) return } + + /* + A site that brackets its URLs by locale sends a path arriving without one to its primary + locale, so that every page has a single address. The prefix is the locale's SHORT code — `/fr` + for `fr-FR` — which is the same segment its content is filed under on a storage target. + + 302 for the same reason as the extension above: it is a setting, and a browser holding a + permanent redirect would go on applying it after an administrator had turned it off. + */ + const siteLocales = WIKI.sites[siteId]?.config?.locales + if (siteLocales?.forcePrefix) { + const prefixes = localePrefixesFor(siteLocales.active) + if (!splitLocalePath(trimmed, prefixes)) { + const primary = WIKI.models.locales.shortCodeFor(siteLocales.primary) + reply.redirect(withQuery(`/${primary}${trimmed === '/' ? '' : trimmed}`), 302) + return + } + } } if (trimmed !== urlPath) { diff --git a/backend/locales/en.json b/backend/locales/en.json index 8880ae8e9..ff606b2f6 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -497,6 +497,7 @@ "admin.instances.subtitle": "View a list of active instances", "admin.instances.title": "Instances", "admin.locale.active": "Active Locales", + "admin.locale.activeHint": "Select the locales that can be used on this site. A locale has to be installed before it can be activated.", "admin.locale.activeNamespaces": "Active Namespaces", "admin.locale.autoUpdate.hint": "Automatically download updates to this locale as they become available.", "admin.locale.autoUpdate.hintWithNS": "Automatically download updates to all namespaced locales enabled below.", @@ -509,8 +510,14 @@ "admin.locale.download": "Download", "admin.locale.downloadNew": "Install New Locale", "admin.locale.downloadTitle": "Download Locale", + "admin.locale.editAliases": "Edit Locale Aliases", + "admin.locale.fetch": "Fetch Locales", + "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.installSuccess": "Locale installed successfully.", "admin.locale.loadFailed": "Failed to fetch locale settings.", "admin.locale.name": "Name", "admin.locale.namespaces.hint": "Enables multiple language versions of the same page.", @@ -2135,11 +2142,29 @@ "linkPicker.emptyFolder": "There are no pages in this folder.", "linkPicker.linkUrl": "Link URL", "linkPicker.loadFailed": "Failed to load the page tree.", + "linkPicker.localeHint": "Which locale to pick a page from.", "linkPicker.openInNewTab": "Open in a new tab", "linkPicker.page": "Page", "linkPicker.selection": "Link target", "linkPicker.title": "Insert Link", "linkPicker.url": "URL", + "localeAliasesDialog.codeHint": "Used in the path and when storing files in storage targets.", + "localeAliasesDialog.codeLabel": "Short Code", + "localeAliasesDialog.hint": "Choose how {name} is referred to in this wiki. These settings are global and apply to all sites.", + "localeAliasesDialog.nameHint": "For display purposes only.", + "localeAliasesDialog.nameLabel": "Name Alias", + "localeAliasesDialog.reset": "Reset to Default", + "localeAliasesDialog.saveSuccess": "Locale aliases saved successfully.", + "localeAliasesDialog.title": "Edit Locale Aliases", + "localeAliasesDialog.warning": "Changing the short code with existing content may cause duplication issues in storage targets.", + "localeFetchDialog.failed": "Failed to fetch locales.", + "localeFetchDialog.loading": "Fetching localization data...", + "localeFetchDialog.resultAdded": "No new locale | {count} new locale available | {count} new locales available", + "localeFetchDialog.resultFailed": "{count} could not be downloaded", + "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", "navEdit.clearItems": "Clear All Items", "navEdit.editMenuItems": "Edit Menu Items", "navEdit.editingInherited": "Inherited menu — shared with every page using it", @@ -2179,6 +2204,7 @@ "pageSaveDialog.displayModePath": "Browse Using Paths", "pageSaveDialog.displayModeTitle": "Browse Using Titles", "pageSaveDialog.loadFailed": "Failed to load folder tree.", + "pageSaveDialog.localeHint": "Which locale the page belongs to.", "pageSaveDialog.pageTitle": "Page Title", "pageSaveDialog.pathInvalid": "Invalid Characters in Page Path Name. Lowercase alphanumerical and hyphen characters only.", "pageSaveDialog.pathName": "Path Name", diff --git a/backend/locales/metadata.d.ts b/backend/locales/metadata.d.ts deleted file mode 100644 index c44affc4c..000000000 --- a/backend/locales/metadata.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Type declaration for the Localazy-generated `metadata.js` in this directory. - * - * `metadata.js` itself is generated output and stays JavaScript (see `localazy.json`), so this - * sibling declaration is what lets the rest of the backend import it with `allowJs` disabled. - * Keep it in sync if the Localazy export shape changes. - */ - -export interface LocalazyLanguage { - language: string - region: string - script: string - isRtl: boolean - name: string - localizedName: string - pluralType: (n: number) => string -} - -export interface LocalazyMetadata { - projectUrl: string - baseLocale: string - languages: LocalazyLanguage[] -} - -declare const localazyMetadata: LocalazyMetadata -export default localazyMetadata diff --git a/backend/locales/metadata.js b/backend/locales/metadata.js deleted file mode 100644 index a3c8cc7c6..000000000 --- a/backend/locales/metadata.js +++ /dev/null @@ -1,81 +0,0 @@ -const localazyMetadata = { - projectUrl: "https://localazy.com/p/wiki", - baseLocale: "en", - languages: [ - { - language: "de", - region: "", - script: "", - isRtl: false, - name: "German", - localizedName: "Deutsch", - pluralType: (n) => { return (n===1) ? "one" : "other"; } - }, - { - language: "en", - region: "", - script: "", - isRtl: false, - name: "English", - localizedName: "English", - pluralType: (n) => { return (n===1) ? "one" : "other"; } - }, - { - language: "fr", - region: "", - script: "", - isRtl: false, - name: "French", - localizedName: "Français", - pluralType: (n) => { return (n===0 || n===1) ? "one" : "other"; } - }, - { - language: "pt", - region: "BR", - script: "", - isRtl: false, - name: "Brazilian Portuguese", - localizedName: "Português (Brasil)", - pluralType: (n) => { return (n>=0 && n<=1) ? "one" : "other"; } - }, - { - language: "ru", - region: "", - script: "", - isRtl: false, - name: "Russian", - localizedName: "Русский", - pluralType: (n) => { return ((n%10===1) && (n%100!==11)) ? "one" : ((n%10>=2 && n%10<=4) && ((n%100<12 || n%100>14))) ? "few" : "many"; } - }, - { - language: "zh", - region: "", - script: "Hans", - isRtl: false, - name: "Simplified Chinese", - localizedName: "简体中文", - pluralType: (n) => { return "other"; } - } - ], - files: [ - { - cdnHash: "54b977214afbffe2ffeb07d0ccb03558e75e4408", - file: "file.json", - path: "", - library: "", - module: "", - buildType: "", - productFlavors: [], - cdnFiles: { - "de#": "https://delivery.localazy.com/_a7797965569058078203416ae5aa/_e0/54b977214afbffe2ffeb07d0ccb03558e75e4408/de/file.json", - "en#": "https://delivery.localazy.com/_a7797965569058078203416ae5aa/_e0/54b977214afbffe2ffeb07d0ccb03558e75e4408/en/file.json", - "fr#": "https://delivery.localazy.com/_a7797965569058078203416ae5aa/_e0/54b977214afbffe2ffeb07d0ccb03558e75e4408/fr/file.json", - "pt_BR#": "https://delivery.localazy.com/_a7797965569058078203416ae5aa/_e0/54b977214afbffe2ffeb07d0ccb03558e75e4408/pt-BR/file.json", - "ru#": "https://delivery.localazy.com/_a7797965569058078203416ae5aa/_e0/54b977214afbffe2ffeb07d0ccb03558e75e4408/ru/file.json", - "zh#Hans": "https://delivery.localazy.com/_a7797965569058078203416ae5aa/_e0/54b977214afbffe2ffeb07d0ccb03558e75e4408/zh-Hans/file.json" - } - } - ] -}; - -export default localazyMetadata; diff --git a/backend/models/locales.ts b/backend/models/locales.ts index 18117ad29..1d1cc7681 100644 --- a/backend/models/locales.ts +++ b/backend/models/locales.ts @@ -1,16 +1,136 @@ -import { stat, readFile } from 'node:fs/promises' +import { readdir, stat, readFile } from 'node:fs/promises' import path from 'node:path' import { locales as localesTable } from '../db/schema.ts' import { eq, sql } from 'drizzle-orm' +/** Where the locale packages published for this major version live. */ +const REMOTE_BASE_URL = 'https://github.com/requarks/wiki-locales/raw/main' + +/** One entry of the remote `metadata.json`: a strings file and the hash of its contents. */ +interface RemoteLocale { + file: string + hash: string +} + +/** What an update run did, for the admin area to report. */ +export interface LocaleUpdateResult { + added: number + updated: number + unchanged: number + failed: number +} + +/** + * How every locale name in the wiki is built. + * + * `languageDisplay: 'standard'` is what puts the language first — "Portuguese (Brazil)" rather than + * Intl's default "Brazilian Portuguese" — so that every variant of a language sorts together in an + * alphabetical list instead of hiding under whatever adjective happens to name it. It changes only + * the handful of tags Intl has a dialect name for: `pt-BR`, `pt-PT` and `en-US`. `zh-TW` was already + * "Chinese (Taiwan)". + */ +const NAME_OPTIONS = { type: 'language', languageDisplay: 'standard' } as const + +/** + * Everything a locale row needs but its strings, derived from the language tag it is named for. + * + * The file name IS the identity — `en`, `en-US`, `zh-Hant` — and is used verbatim as the row's + * `code`, so that it round-trips to the remote file the strings came from. Deliberately not + * canonicalized through `Intl.Locale.baseName`: upstream ships `sr-CS.json`, which canonicalizes to + * `sr-RS`, and a code that no longer names a file cannot be fetched again. + * + * Throws `RangeError` for a name that is not a structurally valid language tag. + */ +function localeInfoFor(code: string) { + const locale = new Intl.Locale(code) + return { + name: new Intl.DisplayNames(['en'], NAME_OPTIONS).of(code) ?? code, + nativeName: new Intl.DisplayNames([code], NAME_OPTIONS).of(code) ?? code, + language: locale.language, + region: locale.region ?? '', + script: locale.script ?? '', + isRTL: locale.getTextInfo().direction === 'rtl' + } +} + +/** A locale row, as far as naming it for a list is concerned. */ +interface NameableLocale { + code: string + language: string + name: string + nativeName: string + customCode?: string | null + customName?: string | null + derivedCode?: string + displayCode?: string + displayName?: string +} + +/** + * Describe each locale of a list as precisely as that list requires, in place. + * + * `Intl` names a tag as precisely as the tag itself is, so `de-DE` is "German (Germany)" — a + * qualifier that is pure noise on a list where German appears once. It stops being noise the moment + * a second locale shares the base language: `zh-CN` beside `zh-TW`, `pt-BR` beside `pt-PT`. So a + * language that appears once is described by its language subtag alone — "German", `de` — and one + * that appears more than once by the whole tag. + * + * That covers the code shown beside the name as well, which is why `displayCode` is resolved here + * rather than stored: `code` is the identity — the primary key, what a site's active locales name, + * what a page's `locale` holds, and what round-trips to the remote strings file — and it cannot + * shorten, because installing `fr-CA` next to `fr-FR` would have to rename it and take every page + * and URL with it. An administrator's `customCode` overrides the derived form and nothing else: the + * names still come from the tag, so calling `zh-CN` "cn" does not make it Cantonese. + * + * `displayName` is the single line to show wherever a locale is offered rather than described — a + * selector, as opposed to the admin list that names it three ways. It is the native name, because a + * reader picking their own language should meet it spelled the way they spell it, unless an + * administrator named it something else. + */ +function resolveDisplayNames(locales: NameableLocale[]) { + const perLanguage = new Map() + for (const lc of locales) { + perLanguage.set(lc.language, (perLanguage.get(lc.language) ?? 0) + 1) + } + const englishNames = new Intl.DisplayNames(['en'], NAME_OPTIONS) + for (const lc of locales) { + const subject = perLanguage.get(lc.language) === 1 ? lc.language : lc.code + // -> Both, because the admin area has to be able to say what clearing the override would leave + lc.derivedCode = subject + lc.displayCode = lc.customCode || subject + lc.name = englishNames.of(subject) ?? lc.name + lc.nativeName = new Intl.DisplayNames([lc.code], NAME_OPTIONS).of(subject) ?? lc.nativeName + lc.displayName = lc.customName || lc.nativeName + } +} + /** * Locales model + * + * A locale row is either **installed** — it holds a string set and can be served — or merely + * **available**, which is a row the update task created from the remote metadata so that the locale + * can be offered without its strings having been downloaded. `isInstalled` is the difference, and + * only an installed locale may be activated on a site. */ class Locales { + /** + * Load every locale strings file shipped in `locales/` into the db. + * + * The directory is the list: a `.json` in it is a locale the wiki has, and there is no + * manifest to keep in step with it. Everything the row needs but the strings comes off the file + * name through `Intl` — see `localeInfoFor`. A file whose name is not a valid language tag is + * skipped rather than failing the run. + * + * A file is only loaded when it is newer than the row, unless `force` is set: a locale that was + * updated in the db — by the update task, or by an administrator — must not be overwritten by the + * copy that shipped with the release. The `hash` is left empty either way, since these strings did + * not come from the remote metadata; that is what makes the first update run consider them stale. + */ async refreshFromDisk({ force = false }: { force?: boolean } = {}): Promise { try { - const localesMeta = (await import('../locales/metadata.js')).default - WIKI.logger.info(`Found ${localesMeta.languages.length} locales [ OK ]`) + const localesPath = path.join(WIKI.SERVERPATH, 'locales') + const localeFiles = (await readdir(localesPath)).filter((fl) => fl.endsWith('.json')) + WIKI.logger.info(`Found ${localeFiles.length} locales [ OK ]`) const dbLocales = await WIKI.db .select({ @@ -20,68 +140,52 @@ class Locales { .from(localesTable) .orderBy(localesTable.code) - let localFilesSkipped = 0 - for (const lang of localesMeta.languages) { - // -> Build filename - const langFilenameParts = [lang.language] - if (lang.region) { - langFilenameParts.push(lang.region) - } - if (lang.script) { - langFilenameParts.push(lang.script) - } - const langFilename = langFilenameParts.join('-') - - // -> Get DB version - const dbLang = dbLocales.find((l: any) => l.code === langFilename) + for (const localeFile of localeFiles) { + const code = path.basename(localeFile, '.json') - // -> Get File version - const flPath = path.join(WIKI.SERVERPATH, `locales/${langFilename}.json`) + // -> Read the tag off the file name + let localeInfo: ReturnType try { - const flStat = await stat(flPath) - const flUpdatedAt = flStat.mtime.toTemporalInstant() - - // -> Load strings - if ( - !dbLang || - Temporal.Instant.compare(dbLang.updatedAt.toTemporalInstant(), flUpdatedAt) < 0 || - force - ) { - WIKI.logger.info(`Loading locale ${langFilename} into DB...`) - const flStrings = JSON.parse(await readFile(flPath, 'utf8')) - await WIKI.db - .insert(localesTable) - .values({ - code: langFilename, - name: lang.name, - nativeName: lang.localizedName, - language: lang.language, - region: lang.region, - script: lang.script, - isRTL: lang.isRtl, - strings: flStrings - }) - .onConflictDoUpdate({ - target: localesTable.code, - set: { strings: flStrings, updatedAt: sql`now()` } - }) - WIKI.logger.info(`Locale ${langFilename} loaded successfully. [ OK ]`) - } else { - WIKI.logger.info( - `Locale ${langFilename} is newer in the DB. Skipping disk version. [ OK ]` - ) - } + localeInfo = localeInfoFor(code) } catch { - localFilesSkipped++ - WIKI.logger.warn( - `Locale ${langFilename} not found on disk. Missing strings file. [ SKIPPED ]` - ) + WIKI.logger.warn(`Locale file ${localeFile} is not a valid language tag. [ SKIPPED ]`) + continue } - } - if (localFilesSkipped > 0) { - WIKI.logger.warn( - `${localFilesSkipped} locales were defined in the metadata file but not found on disk. [ SKIPPED ]` - ) + + // -> Skip a locale that was updated in the DB after the file was last written + const flPath = path.join(localesPath, localeFile) + const flUpdatedAt = (await stat(flPath)).mtime.toTemporalInstant() + const dbLang = dbLocales.find((l) => l.code === code) + if ( + dbLang && + !force && + Temporal.Instant.compare(dbLang.updatedAt.toTemporalInstant(), flUpdatedAt) >= 0 + ) { + WIKI.logger.info(`Locale ${code} is newer in the DB. Skipping disk version. [ OK ]`) + continue + } + + // -> Load strings + WIKI.logger.info(`Loading locale ${code} into DB...`) + const flStrings = JSON.parse(await readFile(flPath, 'utf8')) + await WIKI.db + .insert(localesTable) + .values({ + code, + ...localeInfo, + isInstalled: true, + strings: flStrings + }) + .onConflictDoUpdate({ + target: localesTable.code, + /* + The hash is cleared, not kept: these strings did not come from the remote file it was + recorded for, so leaving it would tell the next update run that a locale it has never + actually delivered is already up to date. + */ + set: { strings: flStrings, isInstalled: true, hash: '', updatedAt: sql`now()` } + }) + WIKI.logger.info(`Locale ${code} loaded successfully. [ OK ]`) } } catch (err: any) { WIKI.logger.warn('Failed to load locales from disk: [ FAILED ]') @@ -90,21 +194,256 @@ class Locales { } } + /** + * Read the list of locale packages published upstream. + */ + async fetchRemoteMetadata(): Promise { + const resp = await fetch(`${REMOTE_BASE_URL}/metadata.json`) + if (!resp.ok) { + throw new Error(`Remote locale metadata could not be fetched (HTTP ${resp.status}).`) + } + const metadata = (await resp.json()) as RemoteLocale[] + if (!Array.isArray(metadata)) { + throw new Error('Remote locale metadata is not in the expected format.') + } + return metadata + } + + /** + * Download one locale's strings and store them against its remote hash. + */ + async #installRemote(entry: RemoteLocale, code: string): Promise { + const resp = await fetch(`${REMOTE_BASE_URL}/${entry.file}`) + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}`) + } + const strings = await resp.json() + await WIKI.db + .insert(localesTable) + .values({ + code, + ...localeInfoFor(code), + isInstalled: true, + hash: entry.hash, + strings + }) + .onConflictDoUpdate({ + target: localesTable.code, + set: { strings, isInstalled: true, hash: entry.hash, updatedAt: sql`now()` } + }) + } + + /** + * Bring the locale list in step with what is published upstream. + * + * Two different things happen, and the split is what keeps this cheap: a locale nobody has + * installed gets a **row only**, so that it can be offered in the admin area, while an installed + * one has its strings re-downloaded — but only when the remote hash differs from the one stored, + * which is the whole point of keeping it. A locale that came off disk has an empty hash, so its + * first update run fetches it and records the hash from then on. + * + * A locale that fails is counted and logged rather than taking the rest of the run down with it: + * one unreachable file should not leave the other fifty stale. + */ + async updateFromRemote(): Promise { + WIKI.logger.info('Fetching latest localization data...') + const metadata = await this.fetchRemoteMetadata() + + const dbLocales = await WIKI.db + .select({ + code: localesTable.code, + hash: localesTable.hash, + isInstalled: localesTable.isInstalled + }) + .from(localesTable) + + const result: LocaleUpdateResult = { added: 0, updated: 0, unchanged: 0, failed: 0 } + for (const entry of metadata) { + const code = path.basename(entry.file, '.json') + try { + const localeInfo = localeInfoFor(code) + const dbLang = dbLocales.find((l) => l.code === code) + + // -> Not seen before: record it as available, without paying for strings nobody asked for + if (!dbLang) { + await WIKI.db.insert(localesTable).values({ code, ...localeInfo }) + result.added++ + continue + } + + // -> Available but not installed, or installed and already holding this exact file + if (!dbLang.isInstalled || dbLang.hash === entry.hash) { + result.unchanged++ + continue + } + + WIKI.logger.info(`Updating locale ${code}...`) + await this.#installRemote(entry, code) + result.updated++ + } catch (err: any) { + WIKI.logger.warn(`Failed to update locale ${code}: ${err.message} [ FAILED ]`) + result.failed++ + } + } + + if (result.added > 0 || result.updated > 0) { + await this.reloadCache() + WIKI.events.outbound.emit('reloadLocales') + } + WIKI.logger.info( + `Fetched latest localization data: ${result.added} added, ${result.updated} updated, ${result.unchanged} unchanged, ${result.failed} failed. [ COMPLETED ]` + ) + return result + } + + /** + * Download the strings of an available locale, making it installable on a site. + * + * The remote metadata is read again rather than trusted from the last update run, so that the + * hash recorded is the one the downloaded file was published with. + */ + async install(code: string): Promise { + const metadata = await this.fetchRemoteMetadata() + const entry = metadata.find((e) => path.basename(e.file, '.json') === code) + if (!entry) { + throw new Error(`Locale ${code} is not published upstream.`) + } + WIKI.logger.info(`Installing locale ${code}...`) + await this.#installRemote(entry, code) + await this.reloadCache() + WIKI.events.outbound.emit('reloadLocales') + WIKI.logger.info(`Locale ${code} installed successfully. [ OK ]`) + } + + /** + * Set — or, with empty values, clear — what a locale is called and what it is addressed as. + * + * The two are held to different standards because they answer to different things. A name is a + * label: anything non-empty will do, and two locales reading alike is somebody's choice. A code is + * an identifier: it has to be a language tag, and it is refused when it is already how some other + * locale is addressed, since an alias colliding with another row's `code` or shown code makes the + * two indistinguishable in a list and a locale-prefixed path ambiguous. + * + * Clearing either one puts its derived form back. + */ + async setAliases( + code: string, + { customName, customCode }: { customName?: string | null; customCode?: string | null } + ): Promise { + const locales = await this.getLocales() + const target = locales.find((lc: any) => lc.code === code) + if (!target) { + throw new Error(`Locale ${code} does not exist.`) + } + + let nextCode = customCode?.trim() || null + if (nextCode) { + try { + new Intl.Locale(nextCode) + } catch { + throw new Error(`"${nextCode}" is not a valid language code.`) + } + if (nextCode === target.derivedCode) { + /* + Storing what would be derived anyway pins it. `fr-FR` shows as `fr` on its own, but + installing `fr-CA` has to lengthen it back to `fr-FR` — which it cannot do with `fr` + written into the row. Asking for the default is asking for no override, so this is how + the admin area can offer the derived form as the field's starting value without a save + that changes nothing quietly freezing it. + */ + nextCode = null + } else if ( + locales.some( + (lc: any) => + lc.code !== code && + (lc.code === nextCode || + lc.displayCode === nextCode || + // -> Its derived code too, which is a folder its content may still be sitting in even + // though nothing shows that code any more. Taking the name would make a stored path + // ambiguous between the two locales. + lc.derivedCode === nextCode) + ) + ) { + throw new Error(`"${nextCode}" is already used by another locale.`) + } + } + + // -> Same reasoning as the code: the derived name written into the row is an override that + // stops following the tag, so asking for it is asking for none + let nextName = customName?.trim() || null + if (nextName === target.nativeName) { + nextName = null + } + + await WIKI.db + .update(localesTable) + .set({ customCode: nextCode, customName: nextName }) + .where(eq(localesTable.code, code)) + + await this.reloadCache() + WIKI.events.outbound.emit('reloadLocales') + } + + /** + * The locale list as the cache holds it, for the callers that cannot await. + * + * Empty before `reloadCache` has run, which for the storage layout means falling back to the raw + * code — the same path the wiki wrote before aliases existed, rather than a wrong one. + */ + #cachedLocales(): any[] { + return (WIKI.cache?.get('locales') as any[]) ?? [] + } + + /** + * The short code a locale is addressed by: its alias where it has one. + * + * The segment a storage target files its content under and the one a locale-prefixed URL starts + * with are the same answer, which is why this is not named for either. Sync, because both callers + * are: `pathPrefixFor` is not async, and neither is the request hook that redirects a page URL. + */ + shortCodeFor(code: string): string { + return (WIKI.cache?.get(`locale:${code}`) as any)?.displayCode ?? code + } + + /** + * The locale a short code names, whichever of its codes was used. + * + * All three are accepted because all three can be in play at once: an alias set after content was + * written leaves the old folder exactly where it was, so `fr-FR` aliased to `fra` may have a `fra/` + * beside a `fr/` it filled while the short code was still derived, and a `fr-FR/` from before short + * codes. Reading each of them back to the same locale is what keeps a later import from adopting + * the old folder as a locale of its own — which is how `notes/trois` ends up existing twice — and + * what keeps a link someone saved from breaking when the alias changes. `setAliases` keeps the + * three sets disjoint, so a segment names at most one locale, and one that names none is passed + * through: a folder the wiki has never heard of reads as it always did. + */ + localeForShortCode(segment: string): string { + const locales = this.#cachedLocales() + const match = + locales.find((lc) => lc.code === segment) ?? + locales.find((lc) => lc.displayCode === segment || lc.derivedCode === segment) + return match?.code ?? segment + } + async getLocales({ cache = true }: { cache?: boolean } = {}): Promise { if (!WIKI.cache.has('locales') || !cache) { const locales = await WIKI.db .select({ code: localesTable.code, isRTL: localesTable.isRTL, + isInstalled: localesTable.isInstalled, language: localesTable.language, name: localesTable.name, nativeName: localesTable.nativeName, + customCode: localesTable.customCode, + customName: localesTable.customName, createdAt: localesTable.createdAt, updatedAt: localesTable.updatedAt, completeness: localesTable.completeness }) .from(localesTable) .orderBy(localesTable.code) + resolveDisplayNames(locales) WIKI.cache.set('locales', locales) for (const locale of locales) { WIKI.cache.set(`locale:${locale.code}`, locale) @@ -113,6 +452,11 @@ class Locales { return WIKI.cache.get('locales') as any[] } + /** The locales that hold a string set, which are the only ones a site may activate. */ + async getInstalledLocales({ cache = true }: { cache?: boolean } = {}): Promise { + return (await this.getLocales({ cache })).filter((lc) => lc.isInstalled) + } + async getStrings(locale: string) { const results = await WIKI.db .select({ strings: localesTable.strings }) diff --git a/backend/models/navigation.ts b/backend/models/navigation.ts index 5d76abaf2..ff11dca20 100644 --- a/backend/models/navigation.ts +++ b/backend/models/navigation.ts @@ -38,10 +38,14 @@ function isVisibleTo(item: NavigationItem, userGroups: string[]): boolean { /** * Navigation model * - * A navigation menu is a row of `items` keyed by the id of whatever it belongs to: a tree entry that - * overrides the menu below it, or — for the site-wide menu every page falls back to — the site's own - * id. That double use of the key is why the id alone is enough to fetch a menu, and why the home page - * edits the site menu rather than one of its own. + * A navigation menu is a row of `items` belonging either to a tree entry that overrides the menu below + * it — keyed by that entry's id, which is why an id alone is enough to fetch a menu — or to a site AND + * A LOCALE, which is the menu every page in that locale falls back to and what the locale's home page + * edits rather than one of its own. + * + * Per locale because a sidebar is written in a language: a French page showing the English menu is the + * one thing a translated wiki cannot do. Which is also why the ancestor walk below is locale-scoped — + * an override on the English `/guides` says nothing about the French one. * * Which menu a page gets is decided when the mode is saved rather than when the page is rendered: * every tree entry carries the resolved `navigationId`, so drawing a sidebar is one lookup. @@ -80,24 +84,45 @@ class Navigation { } /** - * The menu the site as a whole uses, which is the one every page inherits by default. + * The menu a site uses for one locale, which is the one every page in it inherits by default. * - * Created empty on demand: a site made before this row existed, or one whose menu was never edited, - * has nothing stored, and an absent menu is an empty one rather than an error. + * Created empty on demand rather than with the site: a locale is activated long after, and the first + * page written in it has to have a sidebar to inherit. An absent menu is an empty one, never an + * error. */ - async ensureSiteNav(siteId: string): Promise { - await WIKI.db + async siteNavId(siteId: string, locale: string): Promise { + const existing = await WIKI.db + .select({ id: navigationTable.id }) + .from(navigationTable) + .where(and(eq(navigationTable.siteId, siteId), eq(navigationTable.locale, locale))) + .limit(1) + if (existing[0]) { + return existing[0].id + } + // -> Two pages created in a new locale at once both find nothing and both insert; the unique + // index settles it and the loser reads back what the winner wrote + const inserted = await WIKI.db .insert(navigationTable) - .values({ id: siteId, siteId, items: [] }) - .onConflictDoNothing() + .values({ siteId, locale, items: [] }) + .onConflictDoNothing({ target: [navigationTable.siteId, navigationTable.locale] }) + .returning({ id: navigationTable.id }) + if (inserted[0]) { + return inserted[0].id + } + const raced = await WIKI.db + .select({ id: navigationTable.id }) + .from(navigationTable) + .where(and(eq(navigationTable.siteId, siteId), eq(navigationTable.locale, locale))) + .limit(1) + return raced[0]!.id } /** * Drop the menus belonging to tree entries that no longer exist. * * A menu is keyed by the id of the entry that owns it, so deleting a page or a folder would - * otherwise leave its menu behind with nothing able to reach it. The site's own menu is keyed by the - * site id and is never a tree entry, so it is not at risk here. + * otherwise leave its menu behind with nothing able to reach it. A site's own menus are identified + * by site and locale rather than by an id borrowed from the tree, so they are not at risk here. * * @param ids Tree entry ids being removed */ @@ -129,21 +154,28 @@ class Navigation { * @param siteId Site the entry belongs to, since paths are only unique within one * @param folderPath Encoded ltree path of the folder holding the entry, empty at the site root */ - private async ancestorNavId(siteId: string, folderPath: string): Promise { + private async ancestorNavId( + siteId: string, + locale: string, + folderPath: string + ): Promise { if (!folderPath) { - return siteId + return this.siteNavId(siteId, locale) } + // -> Within the locale: the tree holds every translation side by side, so an override on the + // English `/guides` would otherwise decide what the French one below it shows const result = await WIKI.db.execute(sql` SELECT "navigationId" FROM tree WHERE "siteId" = ${siteId} + AND "locale" = ${locale} AND ("folderPath" || "fileName") @> ${folderPath}::ltree AND "navigationMode" IN ('override', 'hide') ORDER BY nlevel("folderPath" || "fileName") DESC LIMIT 1 `) const rows = (result.rows ?? result) as any[] - return rows.length > 0 ? (rows[0].navigationId ?? null) : siteId + return rows.length > 0 ? (rows[0].navigationId ?? null) : this.siteNavId(siteId, locale) } /** @@ -157,7 +189,7 @@ class Navigation { */ async inheritedNavId(siteId: string, pageId: string): Promise { const entry = await this.getEntry(siteId, pageId) - return this.ancestorNavId(siteId, entry.folderPath ?? '') + return this.ancestorNavId(siteId, entry.locale, entry.folderPath ?? '') } /** @@ -183,18 +215,14 @@ class Navigation { }): Promise { const entry = await this.getEntry(siteId, pageId) - // -> Whatever this change resolves to, `inherit` ultimately falls back to the site menu, and a - // site created before that row existed does not have one yet - await this.ensureSiteNav(siteId) - const folderPath = entry.folderPath ?? '' - // -> The home page at the root edits the site-wide menu rather than one of its own, which is what - // makes it the menu every other page inherits + // -> The home page at the root edits the site-wide menu FOR ITS LOCALE rather than one of its own, + // which is what makes it the menu every other page in that locale inherits const isSiteRoot = folderPath === '' && entry.fileName === 'home' - const ownNavId = isSiteRoot ? siteId : entry.id + const ownNavId = isSiteRoot ? await this.siteNavId(siteId, entry.locale) : entry.id const fullPath = folderPath ? `${folderPath}.${entry.fileName}` : entry.fileName - const ancestorId = await this.ancestorNavId(siteId, folderPath) + const ancestorId = await this.ancestorNavId(siteId, entry.locale, folderPath) if (items) { /* @@ -215,6 +243,8 @@ class Navigation { .insert(navigationTable) .values({ id: targetNavId, siteId, items }) .onConflictDoUpdate({ target: navigationTable.id, set: { items } }) + // NOTE: a site menu already exists by the time it is named here — `siteNavId` created it — so + // this insert only ever creates one for a tree entry, whose id is the key } // -> A mode that stops applying below this entry hands its descendants back to the ancestor @@ -269,6 +299,7 @@ class Navigation { UPDATE tree tt SET "navigationId" = ${cascadeTo} WHERE tt."siteId" = ${siteId} + AND tt."locale" = ${entry.locale} AND tt.tree IN ('page', 'folder') AND tt."folderPath" <@ ${fullPath}::ltree AND tt."navigationMode" = 'inherit' @@ -276,6 +307,7 @@ class Navigation { SELECT 1 FROM tree tc WHERE tc."siteId" = ${siteId} + AND tc."locale" = ${entry.locale} AND tc.tree IN ('page', 'folder') AND tc."folderPath" <@ ${fullPath}::ltree AND (tc."folderPath" || tc."fileName") @> tt."folderPath" diff --git a/backend/models/pages.ts b/backend/models/pages.ts index f21ddd9ab..a4841ac67 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -788,7 +788,7 @@ class Pages { async movePage( siteId: string, id: string, - { path, title }: { path: string; title?: string }, + { path, locale, title }: { path: string; locale?: string; title?: string }, actor: PageActor ): Promise { // -> With the source, which the move itself does not need: it is what the copy kept by a storage @@ -799,11 +799,18 @@ class Pages { } const existingContent = page.content const newPath = normalizePath(path) - if (newPath === page.path && (title === undefined || title === page.title)) { + /* + A move may cross locales — the same page, translated, is the same page moved — so the + destination is a locale AND a path, and everything below asks about the pair rather than about + the path alone. Absent, it stays where it is: a rename is a move that changes neither. + */ + const newLocale = locale || page.locale + const isRelocated = newPath !== page.path || newLocale !== page.locale + if (!isRelocated && (title === undefined || title === page.title)) { return page } - if (newPath !== page.path) { + if (isRelocated) { const duplicate = await WIKI.db .select({ id: pagesTable.id }) .from(pagesTable) @@ -811,7 +818,7 @@ class Pages { and( ne(pagesTable.id, id), eq(pagesTable.siteId, siteId), - eq(pagesTable.locale, page.locale), + eq(pagesTable.locale, newLocale), eq(pagesTable.path, newPath) ) ) @@ -821,7 +828,7 @@ class Pages { } await this.guardAgainstAssetCollision({ siteId, - locale: page.locale, + locale: newLocale, parentPath: newPath.split('/').slice(0, -1).join('/'), fileName: newPath.split('/').at(-1)!, contentType: page.contentType @@ -832,6 +839,7 @@ class Pages { .update(pagesTable) .set({ path: newPath, + locale: newLocale, hash: generatePathHash(newPath), ...(title !== undefined ? { title: title.trim() } : {}), authorId: actor.id, @@ -848,10 +856,10 @@ class Pages { parentPath: pathParts.slice(0, -1).join('/'), fileName: pathParts.at(-1)!, title: title !== undefined ? title.trim() : page.title, - locale: page.locale, + locale: newLocale, siteId, tags: page.tags, - meta: this.treeMeta({ ...page, path: newPath }) + meta: this.treeMeta({ ...page, path: newPath, locale: newLocale }) }) const moved = (await this.getPage({ siteId, id })) as Page @@ -865,15 +873,23 @@ class Pages { authorId: actor.id, changedFields: [ ...(newPath !== page.path ? ['path'] : []), + ...(newLocale !== page.locale ? ['locale'] : []), ...(title !== undefined && title.trim() !== page.title ? ['title'] : []) ] }) + /* + The search vector is built with the dictionary of the page's locale and holds its title, so + both halves of a move can invalidate it. Rebuilt here rather than left to the next edit, which + for a page nobody edits again is never. + */ + await WIKI.models.search.indexPage(id, newLocale) + // -> Moved and then rewritten, rather than deleted and written afresh: the move is what keeps a // versioned target's history of the file attached to it, and the rewrite is because a move may // carry a new title and always carries a new modification time, both of which are in the copy const stored = this.toStoragePage(siteId, actor.id, moved, existingContent ?? '') - await WIKI.models.storage.relocatePage(stored.ref, page.path) + await WIKI.models.storage.relocatePage(stored.ref, { locale: page.locale, path: page.path }) await WIKI.models.storage.mirrorPage(stored.ref, stored.content) await WIKI.models.hooks.emit('page:rename', { diff --git a/backend/models/sites.ts b/backend/models/sites.ts index c40e4b1f5..d75fd426e 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -209,10 +209,11 @@ class Sites { const newSite = result[0] - // -> The menu every page of the site inherits, keyed by the site id. Empty to begin with, but it - // has to exist before a page can point at it + // -> The menu every page of the site inherits, one per locale. Empty to begin with, but it has to + // exist before a page can point at it, and a site starts with its primary locale — the rest get + // one the first time a page is written in them WIKI.logger.debug(`Creating new root navigation for site ${newSite.id}`) - await WIKI.models.navigation.ensureSiteNav(newSite.id) + await WIKI.models.navigation.siteNavId(newSite.id, config.locales.primary) // -> Site lookups by id / hostname are served from cache, which must know about the new site await WIKI.models.sites.reloadCache() diff --git a/backend/models/storage.ts b/backend/models/storage.ts index 2b21bcee1..855ece1c9 100644 --- a/backend/models/storage.ts +++ b/backend/models/storage.ts @@ -355,6 +355,12 @@ export interface StorageAssetLocation { } /** Where a page sits, which is all a target needs in order to find its copy of one. */ +/** Where a page's copy sits on a target, which takes a locale as well as a path to say. */ +export interface StoragePageLocation { + locale: string + path: string +} + export interface StoragePageRef { id: string siteId: string @@ -425,8 +431,18 @@ export interface StorageModule { putPage: (target: StorageTarget, ref: StoragePageRef, page: StoragePageContent) => Promise /** Drop its copy. Must not fail over a copy that is not there. */ deletePage: (target: StorageTarget, ref: StoragePageRef) => Promise - /** Follow a move, `ref` being where the page now is. */ - movePage: (target: StorageTarget, ref: StoragePageRef, previousPath: string) => Promise + /** + * Follow a move, `ref` being where the page now is. + * + * `previous` carries the locale as well as the path, because a page can be moved between locales + * and the two together are what locate the old file: the locale decides the folder the tree is + * bracketed by, the path decides the rest. + */ + movePage: ( + target: StorageTarget, + ref: StoragePageRef, + previous: StoragePageLocation + ) => Promise /** * A URL a reader can fetch this asset from directly, signed by the store. * @@ -1014,6 +1030,10 @@ class Storage { * that wrote it put it. What follows is the target's own business: the folders of the tree, and then * a file name each kind of content decides for itself. * + * The locale segment is the locale's SHORT code — `fr` rather than `fr-FR`, or whatever an + * administrator aliased it to. It is what the wiki calls that locale, and a folder tree read by + * people is where that matters most. + * * @returns Null for content the layout has no place for — a secondary locale on a site storing only * its primary one. Not an error: it is what the site asked for, and each operation decides what * that means for it. A write is the one that cannot shrug (`putAsset` in the disk module). @@ -1022,7 +1042,7 @@ class Storage { const layout = this.pathLayoutFor(siteId) const prefix = layout.sitePrefix ? [siteId] : [] if (layout.localePrefix) { - return [...prefix, locale] + return [...prefix, WIKI.models.locales.shortCodeFor(locale)] } return locale === layout.primaryLocale ? prefix : null } @@ -1057,7 +1077,9 @@ class Storage { if (rest.length < 2) { return null } - locale = rest[0] + // -> The segment is normally the locale's short code, but a folder written before an alias was + // set still holds the plain one, so either is read back to the locale it names + locale = WIKI.models.locales.localeForShortCode(rest[0]) rest = rest.slice(1) } return rest.length > 0 ? { locale, segments: rest } : null @@ -1580,14 +1602,15 @@ class Storage { /** * Move every page-keeping target's copy of a page, `ref` being where it now is. */ - async relocatePage(ref: StoragePageRef, previousPath: string): Promise { - if (previousPath === ref.path) { + async relocatePage(ref: StoragePageRef, previous: StoragePageLocation): Promise { + if (previous.path === ref.path && previous.locale === ref.locale) { return } + const label = (loc: StoragePageLocation) => `${loc.locale}/${loc.path}` await this.eachPageTarget( ref.siteId, - `move the page from ${previousPath} to ${ref.path}`, - (mod, target) => mod.movePage(target, ref, previousPath) + `move the page from ${label(previous)} to ${label(ref)}`, + (mod, target) => mod.movePage(target, ref, previous) ) } diff --git a/backend/models/tree.ts b/backend/models/tree.ts index 12d9f9a98..353f5af3f 100644 --- a/backend/models/tree.ts +++ b/backend/models/tree.ts @@ -944,7 +944,8 @@ class Tree { path: page.path, contentType: page.contentType }, - page.previousPath + // -> A folder rename never crosses locales, so the page's own is where it came from too + { locale: page.locale, path: page.previousPath } ) } @@ -1149,8 +1150,9 @@ class Tree { siteId, tags, meta, - // -> Pages inherit the site's navigation until something says otherwise - navigationId: siteId, + // -> Pages inherit the navigation of the site AND LOCALE they are in until something says + // otherwise; the first page written in a locale is what creates that menu + navigationId: await WIKI.models.navigation.siteNavId(siteId, locale), // -> A page's file name is its URL, chosen deliberately by whoever wrote it, so a clash is // something to report rather than something to work around onConflict: 'error' diff --git a/backend/modules/storage/disk/storage.ts b/backend/modules/storage/disk/storage.ts index f5e6e7c61..9bca9244a 100644 --- a/backend/modules/storage/disk/storage.ts +++ b/backend/modules/storage/disk/storage.ts @@ -162,11 +162,11 @@ const diskStorage: StorageModule = { await pruneEmptyDirs(root, path.dirname(filePath)) }, - async movePage(target, ref, previousPath) { + async movePage(target, ref, previous) { // -> Which editor wrote it does not change when a page moves, so both ends share an extension await moveStored( baseDir(target), - pageRelPath(target, { ...ref, path: previousPath }), + pageRelPath(target, { ...ref, ...previous }), pageRelPath(target, ref) ) }, diff --git a/backend/modules/storage/git/storage.ts b/backend/modules/storage/git/storage.ts index b0916e823..545c52377 100644 --- a/backend/modules/storage/git/storage.ts +++ b/backend/modules/storage/git/storage.ts @@ -687,8 +687,8 @@ const gitStorage: StorageModule = { ) }, - async movePage(target, ref, previousPath) { - const from = pageRelPath(target, { ...ref, path: previousPath }) + async movePage(target, ref, previous) { + const from = pageRelPath(target, { ...ref, ...previous }) const to = pageRelPath(target, ref) await withRepo(target, async (repo) => { const outcome = await moveStored(repo.root, from, to) @@ -704,8 +704,8 @@ const gitStorage: StorageModule = { target, paths, outcome === 'moved' - ? `docs: rename ${pageLabel({ ...ref, path: previousPath })} to ${pageLabel(ref)}` - : `docs: delete ${pageLabel({ ...ref, path: previousPath })}`, + ? `docs: rename ${pageLabel({ ...ref, ...previous })} to ${pageLabel(ref)}` + : `docs: delete ${pageLabel({ ...ref, ...previous })}`, ref.actorId ) }) diff --git a/backend/modules/storage/sftp/storage.ts b/backend/modules/storage/sftp/storage.ts index 8086ea8b2..19156b6a9 100644 --- a/backend/modules/storage/sftp/storage.ts +++ b/backend/modules/storage/sftp/storage.ts @@ -376,12 +376,12 @@ const sftpStorage: StorageModule = { await withClient(target, (client) => removeRemote(client, target, relPath)) }, - async movePage(target, ref, previousPath) { + async movePage(target, ref, previous) { await withClient(target, (client) => moveRemote( client, target, - pageRelPath(target, { ...ref, path: previousPath }), + pageRelPath(target, { ...ref, ...previous }), pageRelPath(target, ref) ) ) diff --git a/backend/tasks/simple/update-locales.ts b/backend/tasks/simple/update-locales.ts index 3127e7e45..95e79584a 100644 --- a/backend/tasks/simple/update-locales.ts +++ b/backend/tasks/simple/update-locales.ts @@ -1,63 +1,17 @@ -import { setTimeout } from 'node:timers/promises' - -export async function task(): Promise { - if (WIKI.config.update?.locales === false) { +/** + * Bring the locale list in step with what is published upstream. + * + * Scheduled nightly, and run on demand from the admin area's Fetch Locales action — which passes + * `force`, since `update.locales: false` is there to stop the wiki phoning home on its own, not to + * refuse an administrator who asked for this explicitly. + */ +export async function task(payload?: { force?: boolean }): Promise { + if (!payload?.force && WIKI.config.update?.locales === false) { return } - WIKI.logger.info('Fetching latest localization data...') - try { - interface LocaleMetadata { - languages: { - language: string - region?: string - script?: string - name: string - localizedName: string - isRtl: boolean - }[] - } - const metadata = await fetch( - 'https://github.com/requarks/wiki-locales/raw/main/locales/metadata.json' - ).then((r) => r.json() as Promise) - for (const lang of metadata.languages) { - // -> Build filename - const langFilenameParts = [lang.language] - if (lang.region) { - langFilenameParts.push(lang.region) - } - if (lang.script) { - langFilenameParts.push(lang.script) - } - const langFilename = langFilenameParts.join('-') - - WIKI.logger.debug(`Fetching updates for language ${langFilename}...`) - - // TODO: Adapt for v3 - // const strings = await fetch(`https://github.com/requarks/wiki-locales/raw/main/locales/${langFilename}.json`).then(r => r.json()) - // if (strings) { - // await WIKI.db.knex('locales').insert({ - // code: langFilename, - // name: lang.name, - // nativeName: lang.localizedName, - // language: lang.language, - // region: lang.region, - // script: lang.script, - // isRTL: lang.isRtl, - // strings - // }).onConflict('code').merge({ - // strings, - // updatedAt: new Date() - // }) - // } - - WIKI.logger.debug(`Updated strings for language ${langFilename}.`) - - await setTimeout(100) - } - - WIKI.logger.info('Fetched latest localization data: [ COMPLETED ]') + await WIKI.models.locales.updateFromRemote() } catch (err: any) { WIKI.logger.error('Fetching latest localization data: [ FAILED ]') WIKI.logger.error(err.message) diff --git a/frontend/public/_assets/icons/fluent-crayon.svg b/frontend/public/_assets/icons/fluent-crayon.svg new file mode 100644 index 000000000..25297cbc6 --- /dev/null +++ b/frontend/public/_assets/icons/fluent-crayon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 1400f1b8a..84fadb4eb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -13,7 +13,7 @@ import { useRouter, useRoute } from 'vue-router' import { useI18n } from 'vue-i18n' import { setCssVar } from '@/helpers/cssVars' -import { stripPageExtension } from '@/helpers/pagePaths' +import { splitLocalePath, stripPageExtension } from '@/helpers/pagePaths' import { useDark } from '@/composables/dark' import { notify } from '@/composables/notify' @@ -104,6 +104,13 @@ async function applyLocale(locale) { } } i18n.locale.value = locale + /* + The document says what language it is in too, not just the strings in it. `index.html` ships + `lang="en"` because that is all a static shell can say, and it stayed that way however the + interface was switched -- so a French page announced itself as English to a screen reader, and to + anything else reading the document for its language. + */ + document.documentElement.lang = locale } // THEME @@ -202,6 +209,8 @@ async function loadBootstrap() { searchParams: { hostname: window.location.hostname }, cache: 'no-store' }).json() + // -> Before the site: `applySiteInfo` resolves the site's active locale codes against this + siteStore.installedLocales = data.locales ?? [] siteStore.applySiteInfo(data.site) flagsStore.apply(data.flags) userStore.applyProfile(data.user) @@ -233,15 +242,53 @@ router.beforeEach(async (to, from) => { bootstrap above, since that is where the site's extensions come from. A `/_` route is the app itself rather than a page, and is left alone as it is by the server. */ - const withoutExtension = to.path.startsWith('/_') - ? null - : stripPageExtension(to.path, siteStore.pageExtensions) + const isPagePath = !to.path.startsWith('/_') + const withoutExtension = isPagePath ? stripPageExtension(to.path, siteStore.pageExtensions) : null if (withoutExtension) { return { path: withoutExtension, query: to.query, hash: to.hash, replace: true } } - // -> Locale + /* + -> Locale prefix + A site that brackets its URLs by locale sends a path arriving without one to its primary locale, so + that every page has a single address. The server does this for a request that reaches it; this is + the same rule for a link inside a page, which the router follows on its own. The prefix is the + locale's short code -- `/fr` for `fr-FR` -- the same segment its content is filed under. + */ if ( + isPagePath && + siteStore.locales.forcePrefix && + !splitLocalePath(to.path, siteStore.localePrefixes) + ) { + const primary = siteStore.localeAlias(siteStore.locales.primary) + return { + path: `/${primary}${to.path === '/' ? '' : to.path}`, + query: to.query, + hash: to.hash, + replace: true + } + } + + /* + -> Locale + On a page, the interface speaks whatever the page is written in -- the prefix in the URL when + there is one, and the site's PRIMARY locale when there is not, because that is what an unprefixed + path resolves to. Falling back to the stored choice instead is what left `/` showing the English + home page with a French interface and French in the picker, after a detour through `/fr/...`. + + It replaces the stored choice rather than shadowing it, which is what carries the switch on to a + screen with no locale in its path: the admin area and the profile are not pages, and keep it. The + site's primary is also the fallback for a first visit, and for a stored locale the site no longer + offers. + */ + const pageLocale = isPagePath + ? (splitLocalePath(to.path, siteStore.localePrefixes)?.locale ?? siteStore.locales.primary) + : null + if (pageLocale) { + if (pageLocale !== commonStore.desiredLocale) { + commonStore.setLocale(pageLocale) + } + } else if ( !commonStore.desiredLocale || !siteStore.locales.active.some((l) => l.code === commonStore.desiredLocale) ) { diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 4a3cf6065..e14a5a8ad 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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. - 268 icons. + 269 icons. */ export const BUNDLED_ICONS = { "la:angle-right": {"body":"","width":32,"height":32}, @@ -36,6 +36,7 @@ export const BUNDLED_ICONS = { "la:clipboard-check": {"body":"","width":32,"height":32}, "la:clipboard-list": {"body":"","width":32,"height":32}, "la:clock": {"body":"","width":32,"height":32}, + "la:cloud-download-alt": {"body":"","width":32,"height":32}, "la:cloud-upload-alt": {"body":"","width":32,"height":32}, "la:code": {"body":"","width":32,"height":32}, "la:code-branch": {"body":"","width":32,"height":32}, diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index 37e20f5e9..198f4bd0d 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -19,10 +19,12 @@ class="fileman-locale mr-2 acrylic-btn" flat color="white" - :label="commonStore.locale" - :aria-label="commonStore.locale" + :label="siteStore.localeAlias(state.locale)" + :aria-label="siteStore.localeAlias(state.locale)" style="height: 40px"> - + + + + {{ t(`linkPicker.localeHint`) }} + + @@ -128,7 +148,9 @@ import { notify } from '@/composables/notify' import { apiErrorMessage } from '@/helpers/apiError' import fileTypes from '@/helpers/fileTypes' +import { splitLocalePath } from '@/helpers/pagePaths' +import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue' import Tree from '@/components/TreeNav.vue' import { usePageStore } from '@/stores/page' @@ -170,6 +192,15 @@ const props = defineProps({ newTabOption: { type: Boolean, default: true + }, + /** + * The locale to browse, and the one a chosen page's link is prefixed for. The page the picker was + * opened from by default, which is what every caller is editing — its content, its relations, its + * redirect target, its sidebar. + */ + locale: { + type: String, + default: null } }) @@ -199,6 +230,12 @@ const iptUrl = ref(null) const state = reactive({ currentTab: 'page', + /* + Which locale's pages are listed, and what a chosen one's link is prefixed for. The tree holds + every translation side by side, so unfiltered this listed all of them at once and linked to + whichever page of that name the PRIMARY locale had. + */ + locale: props.locale || pageStore.locale, /** Folder whose contents the right-hand pane lists. Null is the site root. */ currentFolderId: null, treeNodes: {}, @@ -215,9 +252,14 @@ const state = reactive({ // COMPUTED -const href = computed(() => - state.currentTab === 'page' ? (state.path ? `/${state.path}` : '') : state.url.trim() -) +const href = computed(() => { + if (state.currentTab !== 'page') { + return state.url.trim() + } + // -> Prefixed for the locale being browsed: an unprefixed path addresses the PRIMARY locale's page + // of that name, which for a link picked out of the French tree is a different page or none + return state.path ? `${siteStore.localeUrlPrefix(state.locale)}/${state.path}` : '' +}) const canSubmit = computed(() => { if (state.currentTab === 'page') { @@ -236,6 +278,27 @@ watch( // METHODS +/** + * Browse another locale. + * + * Back to its root, and the chosen page is dropped with it: the tree being left and the one being + * entered share no ids, and a path picked out of one names a different page — or none — in the other. + */ +async function switchLocale(locale) { + if (locale === state.locale) { + return + } + state.locale = locale + state.treeNodes = {} + state.treeRoots = [] + state.currentFolderId = null + state.items = [] + state.path = '' + state.pageTitle = '' + treeComp.value?.resetLoaded() + await loadTree({ initLoad: true }) +} + /** * Loads one folder into the tree, and — when that folder is the selected one — into the list beside it. * @@ -255,6 +318,7 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false } try { const entries = await API_CLIENT.get(`sites/${siteStore.id}/tree`, { searchParams: { + locale: state.locale, ...(parentId ? { parentId } : {}), ...(parentPath ? { parentPath } : {}), types: 'folder,page', @@ -365,7 +429,15 @@ onMounted(async () => { state.currentTab = 'url' state.url = props.initialHref } else { - state.path = props.initialHref.replace(/^\/+/, '') + /* + Re-opening on a link that already carries a prefix: the locale comes off it, so the picker + starts in the tree the link points into rather than in the page's own. + */ + const split = splitLocalePath(props.initialHref, siteStore.localePrefixes) + if (split) { + state.locale = split.locale + } + state.path = (split?.path ?? props.initialHref).replace(/^\/+/, '') } } diff --git a/frontend/src/components/LocaleAliasesDialog.vue b/frontend/src/components/LocaleAliasesDialog.vue new file mode 100644 index 000000000..3ee17b639 --- /dev/null +++ b/frontend/src/components/LocaleAliasesDialog.vue @@ -0,0 +1,165 @@ + + + diff --git a/frontend/src/components/LocaleFetchDialog.vue b/frontend/src/components/LocaleFetchDialog.vue new file mode 100644 index 000000000..6d9915c52 --- /dev/null +++ b/frontend/src/components/LocaleFetchDialog.vue @@ -0,0 +1,136 @@ + + + diff --git a/frontend/src/components/LocaleSelectorMenu.vue b/frontend/src/components/LocaleSelectorMenu.vue index 4edd76400..2a6872f71 100644 --- a/frontend/src/components/LocaleSelectorMenu.vue +++ b/frontend/src/components/LocaleSelectorMenu.vue @@ -5,24 +5,25 @@ :anchor="props.anchor" :self="props.self" :offset="props.offset"> - + + @click="pick(lang.code)"> -
{{ lang.language }}
+
+ {{ lang.language }} +
- {{ lang.nativeName }} - {{ lang.name }} + {{ lang.displayName }}
@@ -31,6 +32,10 @@ diff --git a/frontend/src/components/NavEditItemMenu.vue b/frontend/src/components/NavEditItemMenu.vue new file mode 100644 index 000000000..5d12d591c --- /dev/null +++ b/frontend/src/components/NavEditItemMenu.vue @@ -0,0 +1,95 @@ + + + diff --git a/frontend/src/components/NavEditOverlay.vue b/frontend/src/components/NavEditOverlay.vue index 691e5b04b..bbd60d35e 100644 --- a/frontend/src/components/NavEditOverlay.vue +++ b/frontend/src/components/NavEditOverlay.vue @@ -63,12 +63,18 @@ class="nav-edit-item nav-edit-item-header" v-if="element.type === `header`" :class="state.selected === element.id ? `is-active` : ``" - @click="setItem(element)"> + @click="setItem(element)" + @contextmenu="setItem(element)"> {{ element.label }} + {{ element.label }} + @@ -104,27 +122,29 @@ :label="t(`common.actions.add`)" :aria-label="t(`common.actions.add`)" icon="la:plus-circle"> - - - - - - {{ t('navEdit.header') }} - - - - - - {{ t('navEdit.link') }} - - - - - - {{ t('navEdit.separator') }} - - - + + + + + + + {{ t('navEdit.header') }} + + + + + + {{ t('navEdit.link') }} + + + + + + {{ t('navEdit.separator') }} + + + + - - - - - - - - {{ t('navEdit.clearItems') }} - - - - - - - - + + + + + + + + + {{ t('navEdit.clearItems') }} + + + + + + + + + @@ -235,7 +262,7 @@ :label="t(`common.actions.delete`)" color="negative" padding="xs md" - @click="removeItem(state.current.id)" /> + @click="removeItem(state.current)" /> @@ -507,6 +534,7 @@ import { v4 as uuid } from 'uuid' import { pick } from 'es-toolkit/object' import { Sortable } from 'sortablejs-vue3' import IconPickerDialog from '@/components/IconPickerDialog.vue' +import NavEditItemMenu from '@/components/NavEditItemMenu.vue' import { apiErrorMessage } from '@/helpers/apiError' // STORES @@ -664,10 +692,53 @@ function addItem(type) { state.current = newItem } -function removeItem(id) { - state.items = state.items.filter((item) => item.id !== id) - state.selected = null - state.current = {} +function removeItem(item) { + state.items = state.items.filter((it) => it.id !== item.id) + // -> Only the row that went gives up the panel: a delete from another row's context menu leaves + // whatever was being edited on screen + if (state.selected === item.id) { + state.selected = null + state.current = {} + } +} + +/** + * Copies a row and drops the copy directly beneath it. + * + * A top-level link takes its nested children with it. This list is flat and `isNested` binds a child to + * whatever link precedes it, so a copy inserted immediately after its original would slide between the + * original and its children and inherit them — leaving the original childless and the copy holding a + * submenu it was never given. Copying the whole branch and landing it after the last child is the only + * reading that leaves the original as it was. + */ +function duplicateItem(item) { + const idx = state.items.findIndex((it) => it.id === item.id) + if (idx < 0) { + return + } + // -> Only a top-level link has children to take along; a nested row, a header and a separator never do + let end = idx + if (item.type === 'link' && !item.isNested) { + while (state.items[end + 1]?.isNested) { + end++ + } + } + const copies = state.items.slice(idx, end + 1).map((it) => ({ + ...it, + id: uuid(), + // -> A fresh array, or editing one copy's group list would edit the other's + visibilityGroups: [...(it.visibilityGroups ?? [])] + })) + state.items.splice(end + 1, 0, ...copies) + setItem(state.items[end + 1]) +} + +/** Nests a link under the one above it, or takes it back out — the panel's pair of buttons, on the row. */ +function toggleNesting(item) { + const target = state.items.find((it) => it.id === item.id) + if (target) { + target.isNested = !target.isNested + } } function clearItems() { diff --git a/frontend/src/components/PageActionsCol.vue b/frontend/src/components/PageActionsCol.vue index ad33ff325..71242b0f3 100644 --- a/frontend/src/components/PageActionsCol.vue +++ b/frontend/src/components/PageActionsCol.vue @@ -327,13 +327,15 @@ function duplicatePage() { folderPath: '', itemId: pageStore.id, itemTitle: pageStore.title, - itemFileName: pageStore.path + itemFileName: pageStore.path, + locale: pageStore.locale } }).onOk((newPageOpts) => { pageStore.pageDuplicate({ sourcePageId: pageStore.id, path: newPageOpts.path, - title: newPageOpts.title + title: newPageOpts.title, + locale: newPageOpts.locale }) }) } @@ -346,11 +348,13 @@ function renamePage() { folderPath: '', itemId: pageStore.id, itemTitle: pageStore.title, - itemFileName: pageStore.path + itemFileName: pageStore.path, + locale: pageStore.locale } }).onOk(async (renamedPageOpts) => { try { - if (renamedPageOpts.path === pageStore.path) { + // -> The destination is a locale as well as a path: the same path in another locale is a move + if (renamedPageOpts.path === pageStore.path && renamedPageOpts.locale === pageStore.locale) { await pageStore.pageRename({ id: pageStore.id, title: renamedPageOpts.title }) notify({ type: 'positive', @@ -360,7 +364,8 @@ function renamePage() { await pageStore.pageMove({ id: pageStore.id, path: renamedPageOpts.path, - title: renamedPageOpts.title + title: renamedPageOpts.title, + locale: renamedPageOpts.locale }) notify({ type: 'positive', diff --git a/frontend/src/components/PageHeader.vue b/frontend/src/components/PageHeader.vue index 7cbfad58e..0e445ede2 100644 --- a/frontend/src/components/PageHeader.vue +++ b/frontend/src/components/PageHeader.vue @@ -539,12 +539,17 @@ async function discardChanges() { editor: '' }) - // Is it the home page in create mode? - if ((pageStore.path === '' || pageStore.path === 'home') && pageStore.locale === 'en') { + /* + Is it the home page in create mode? In whichever locale it was being written -- the test used to + name `en`, which meant abandoning the FRENCH home page dropped the reader onto the English site + root with no welcome screen and no explanation. + */ + const localeRoot = siteStore.localeUrlPrefix(pageStore.locale) || '/' + if (pageStore.path === '' || pageStore.path === 'home') { siteStore.overlay = 'Welcome' } - router.replace('/') + router.replace(localeRoot) return } @@ -655,7 +660,9 @@ async function createPage() { editorStore.$patch({ isActive: false }) - router.replace('/') + // -> The home page that was just written, not the site root: unprefixed, the router sends it to + // the PRIMARY locale, so creating the French home page landed on the English one + router.replace(pageStore.editorExitPath) } catch (err) { notify({ type: 'negative', @@ -674,16 +681,20 @@ async function createPage() { mode: 'savePage', folderPath: '', itemTitle: pageStore.title, - itemFileName: pageStore.path + itemFileName: pageStore.path, + locale: pageStore.locale } - }).onOk(async ({ path, title }) => { + }).onOk(async ({ path, title, locale }) => { await processPendingAssets() loading.show() try { pageStore.$patch({ title, - path + path, + // -> The dialog is where the locale is settled for a page that has none yet, so what it + // hands back is what the page is written in + locale }) await pageStore.pageSave() notify({ diff --git a/frontend/src/components/PageHistoryOverlay.vue b/frontend/src/components/PageHistoryOverlay.vue index dd50f067c..3d6940482 100644 --- a/frontend/src/components/PageHistoryOverlay.vue +++ b/frontend/src/components/PageHistoryOverlay.vue @@ -615,7 +615,8 @@ function branchFrom(version) { folderPath: '', itemId: pageStore.id, itemTitle: version.title, - itemFileName: pageStore.path + itemFileName: pageStore.path, + locale: pageStore.locale } }).onOk(async (target) => { const full = await withVersion(version) diff --git a/frontend/src/components/PageNewMenu.vue b/frontend/src/components/PageNewMenu.vue index 7fbc1fbd7..6383124dc 100644 --- a/frontend/src/components/PageNewMenu.vue +++ b/frontend/src/components/PageNewMenu.vue @@ -78,6 +78,15 @@ const props = defineProps({ basePath: { type: String, default: null + }, + /** + * The locale to write the new page in. The page store's current one when absent, which is right + * from the page view and wrong from the file manager -- there the reader is looking at whichever + * locale the picker is on, not at the page behind the overlay. + */ + locale: { + type: String, + default: null } }) @@ -101,7 +110,7 @@ const { t } = useI18n() async function create(editor) { loading.show() emit('newPage') - await pageStore.pageCreate({ editor, basePath: props.basePath }) + await pageStore.pageCreate({ editor, basePath: props.basePath, locale: props.locale }) loading.hide() } diff --git a/frontend/src/components/TreeBrowserDialog.vue b/frontend/src/components/TreeBrowserDialog.vue index dc0f02903..1baea0207 100644 --- a/frontend/src/components/TreeBrowserDialog.vue +++ b/frontend/src/components/TreeBrowserDialog.vue @@ -1,17 +1,33 @@