feat: add locale support + various fixes

scarlett
NGPixel 3 weeks ago
parent 5ff4f1dda7
commit fdd7945a6d
No known key found for this signature in database

@ -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 * 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. * 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 * None of them touches the database: the site configurations, the flags and the locale list are in
* session carries the user. So what this saves is the round trips, which is the whole cost. * 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) { async function routes(app: FastifyInstance) {
app.get<{ Querystring: { hostname?: string } }>( app.get<{ Querystring: { hostname?: string } }>(
@ -46,6 +47,12 @@ async function routes(app: FastifyInstance) {
description: description:
'As `users/whoami` answers it: `authenticated: false` alone for a guest, otherwise the account and its group-wide permissions.', 'As `users/whoami` answers it: `authenticated: false` alone for a guest, otherwise the account and its group-wide permissions.',
additionalProperties: true 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 isEnabled: site.isEnabled
}, },
flags: WIKI.models.flags.getFlags(), flags: WIKI.models.flags.getFlags(),
user: whoAmI(req) user: whoAmI(req),
locales: await WIKI.models.locales.getInstalledLocales()
} }
} }
) )

@ -15,6 +15,7 @@ async function routes(app: FastifyInstance) {
await import('./schemas/group.ts').then((m) => m.registerSchemas(app)) await import('./schemas/group.ts').then((m) => m.registerSchemas(app))
await import('./schemas/hook.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/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/mail.ts').then((m) => m.registerSchemas(app))
await import('./schemas/page.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)) await import('./schemas/scheduler.ts').then((m) => m.registerSchemas(app))

@ -12,7 +12,16 @@ async function routes(app: FastifyInstance) {
}, },
schema: { schema: {
summary: 'List all locales', 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 () => { 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 } }>( app.get<{ Params: { code: string } }>(
'/:code/strings', '/:code/strings',
{ {

@ -724,7 +724,7 @@ async function routes(app: FastifyInstance) {
*/ */
app.put<{ app.put<{
Params: { siteId: string; pageId: string } Params: { siteId: string; pageId: string }
Body: { path: string; title?: string } Body: { path: string; locale?: string; title?: string }
}>( }>(
'/sites/:siteId/pages/:pageId/path', '/sites/:siteId/pages/:pageId/path',
{ {
@ -736,7 +736,7 @@ async function routes(app: FastifyInstance) {
schema: { schema: {
summary: 'Move a page to another path', summary: 'Move a page to another path',
description: 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'], tags: ['Pages'],
params: pageIdParam, params: pageIdParam,
body: { body: {
@ -748,6 +748,11 @@ async function routes(app: FastifyInstance) {
maxLength: 255, maxLength: 255,
pattern: '^/?[a-zA-Z0-9-_/]*$' pattern: '^/?[a-zA-Z0-9-_/]*$'
}, },
locale: {
type: 'string',
maxLength: 255,
description: 'The locale to move it to. Stays in its own when absent.'
},
title: { title: {
type: 'string', type: 'string',
minLength: 1, minLength: 1,
@ -783,6 +788,22 @@ async function routes(app: FastifyInstance) {
if (!mayOnPage(req, 'manage:pages', target)) { if (!mayOnPage(req, 'manage:pages', target)) {
return reply.forbidden('You are not allowed to move this page.') 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( const page = await WIKI.models.pages.movePage(
req.params.siteId, req.params.siteId,
req.params.pageId, req.params.pageId,
@ -1062,7 +1083,7 @@ async function routes(app: FastifyInstance) {
/** /**
* PAGE USER PERMISSIONS * 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', '/sites/:siteId/pages/userPermissions',
{ {
schema: { schema: {
@ -1079,11 +1100,18 @@ async function routes(app: FastifyInstance) {
type: 'string', type: 'string',
minLength: 1, minLength: 1,
maxLength: 255 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: [ examples: [
{ {
path: 'foo/bar' path: 'foo/bar',
locale: 'en'
} }
] ]
}, },
@ -1097,7 +1125,10 @@ async function routes(app: FastifyInstance) {
} }
}, },
async (req) => { async (req) => {
return pagePermissionsFor(req, { path: req.body.path.replace(/^\/+/, '') }) return pagePermissionsFor(req, {
path: req.body.path.replace(/^\/+/, ''),
locale: req.body.locale
})
} }
) )
} }

@ -0,0 +1,78 @@
import type { FastifyInstance } from 'fastify'
export async function registerSchemas(app: FastifyInstance): Promise<void> {
/**
* 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'
}
}
})
}

@ -434,7 +434,9 @@ async function routes(app: FastifyInstance) {
// -> Validate locales against the installed ones, and against what the site ends up with once // -> 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 // the patch is merged, so that a partial update cannot leave the primary locale inactive
if (req.body.locales) { 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 active = req.body.locales.active ?? site.config.locales?.active ?? []
const primary = req.body.locales.primary ?? site.config.locales?.primary const primary = req.body.locales.primary ?? site.config.locales?.primary

@ -73,5 +73,10 @@ export default {
WIKI.events.inbound.on('flushCaches', async () => { WIKI.events.inbound.on('flushCaches', async () => {
await this.flushCaches() 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()
})
} }
} }

@ -174,6 +174,10 @@ CREATE TABLE "locales" (
"region" varchar(3) NOT NULL, "region" varchar(3) NOT NULL,
"script" varchar(4) NOT NULL, "script" varchar(4) NOT NULL,
"isRTL" boolean DEFAULT false 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, "strings" jsonb DEFAULT '[]' NOT NULL,
"completeness" integer DEFAULT 0 NOT NULL, "completeness" integer DEFAULT 0 NOT NULL,
"createdAt" timestamp DEFAULT now() NOT NULL, "createdAt" timestamp DEFAULT now() NOT NULL,
@ -183,6 +187,7 @@ CREATE TABLE "locales" (
CREATE TABLE "navigation" ( CREATE TABLE "navigation" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"items" jsonb DEFAULT '[]' NOT NULL, "items" jsonb DEFAULT '[]' NOT NULL,
"locale" varchar(255),
"siteId" uuid NOT NULL "siteId" uuid NOT NULL
); );
--> statement-breakpoint --> 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 "blocks_siteId_idx" ON "blocks" ("siteId");--> statement-breakpoint
CREATE INDEX "locales_language_idx" ON "locales" ("language");--> statement-breakpoint CREATE INDEX "locales_language_idx" ON "locales" ("language");--> statement-breakpoint
CREATE INDEX "navigation_siteId_idx" ON "navigation" ("siteId");--> 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_pageId_idx" ON "pageEditSubmissions" ("pageId");--> statement-breakpoint
CREATE INDEX "pageEditSubmissions_siteId_idx" ON "pageEditSubmissions" ("siteId");--> statement-breakpoint CREATE INDEX "pageEditSubmissions_siteId_idx" ON "pageEditSubmissions" ("siteId");--> statement-breakpoint
CREATE INDEX "pageEditSubmissions_authorId_idx" ON "pageEditSubmissions" ("authorId");--> 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 "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_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 "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");

@ -1938,6 +1938,58 @@
"schema": "public", "schema": "public",
"table": "locales" "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", "type": "jsonb",
"typeSchema": null, "typeSchema": null,
@ -2016,6 +2068,19 @@
"schema": "public", "schema": "public",
"table": "navigation" "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", "type": "uuid",
"typeSchema": null, "typeSchema": null,
@ -4022,6 +4087,34 @@
"schema": "public", "schema": "public",
"table": "navigation" "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, "nameExplicit": true,
"columns": [ "columns": [
@ -5685,6 +5778,17 @@
"table": "users", "table": "users",
"entityType": "pks" "entityType": "pks"
}, },
{
"nameExplicit": false,
"columns": [
"customCode"
],
"nullsNotDistinct": false,
"name": "locales_customCode_key",
"schema": "public",
"table": "locales",
"entityType": "uniques"
},
{ {
"nameExplicit": false, "nameExplicit": false,
"columns": [ "columns": [

@ -289,6 +289,31 @@ export const locales = pgTable(
region: varchar({ length: 3 }).notNull(), // Unicode region subtag region: varchar({ length: 3 }).notNull(), // Unicode region subtag
script: varchar({ length: 4 }).notNull(), // Unicode script subtag script: varchar({ length: 4 }).notNull(), // Unicode script subtag
isRTL: boolean().notNull().default(false), 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([]), strings: jsonb().notNull().default([]),
completeness: integer().notNull().default(0), completeness: integer().notNull().default(0),
createdAt: timestamp().notNull().defaultNow(), createdAt: timestamp().notNull().defaultNow(),
@ -303,11 +328,21 @@ export const navigation = pgTable(
{ {
id: uuid().primaryKey().defaultRandom(), id: uuid().primaryKey().defaultRandom(),
items: jsonb().notNull().default([]), 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() siteId: uuid()
.notNull() .notNull()
.references(() => sites.id) .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 ------------------------------ // PAGES ------------------------------

@ -130,6 +130,34 @@ export function stripPageExtension(urlPath: string, extensions?: string[] | null
return urlPath.slice(0, dot) 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<string, string>
): { 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 * Generate SHA-1 Hash of a string
* *

@ -200,11 +200,11 @@ export function objectStorageModule(client: ObjectStoreClient): StorageModule {
} }
}, },
async movePage(target, ref, previousPath) { async movePage(target, ref, previous) {
await moveObject( await moveObject(
client, client,
target, target,
pageKey(target, { ...ref, path: previousPath }), pageKey(target, { ...ref, ...previous }),
pageKey(target, ref) pageKey(target, ref)
) )
}, },

@ -33,7 +33,7 @@ import configSvc from './core/config.ts'
import dbManager from './core/db.ts' import dbManager from './core/db.ts'
import logger from './core/logger.ts' import logger from './core/logger.ts'
import scheduler from './core/scheduler.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' import { corsOrigin, parseCspDirectives } from './helpers/security.ts'
const nanoid = customAlphabet('1234567890abcdef', 10) const nanoid = customAlphabet('1234567890abcdef', 10)
@ -80,6 +80,26 @@ function isPageUrl(urlPath: string): boolean {
return !firstSegment.startsWith('_') && !RESERVED_ROOT_FILES.has(firstSegment.toLowerCase()) 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<string, string> {
const prefixes = new Map<string, string>()
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')) { if (!semver.satisfies(process.version, '>=26')) {
console.error('ERROR: Node.js 26.x or later required!') console.error('ERROR: Node.js 26.x or later required!')
process.exit(1) process.exit(1)
@ -612,6 +632,24 @@ async function initHTTPServer() {
reply.redirect(withQuery(withoutExtension), 302) reply.redirect(withQuery(withoutExtension), 302)
return 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) { if (trimmed !== urlPath) {

@ -497,6 +497,7 @@
"admin.instances.subtitle": "View a list of active instances", "admin.instances.subtitle": "View a list of active instances",
"admin.instances.title": "Instances", "admin.instances.title": "Instances",
"admin.locale.active": "Active Locales", "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.activeNamespaces": "Active Namespaces",
"admin.locale.autoUpdate.hint": "Automatically download updates to this locale as they become available.", "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.", "admin.locale.autoUpdate.hintWithNS": "Automatically download updates to all namespaced locales enabled below.",
@ -509,8 +510,14 @@
"admin.locale.download": "Download", "admin.locale.download": "Download",
"admin.locale.downloadNew": "Install New Locale", "admin.locale.downloadNew": "Install New Locale",
"admin.locale.downloadTitle": "Download 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.forcePrefix": "Force Locale Prefix",
"admin.locale.forcePrefixHint": "Paths without a locale code will always be redirected to the primary locale.", "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.loadFailed": "Failed to fetch locale settings.",
"admin.locale.name": "Name", "admin.locale.name": "Name",
"admin.locale.namespaces.hint": "Enables multiple language versions of the same page.", "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.emptyFolder": "There are no pages in this folder.",
"linkPicker.linkUrl": "Link URL", "linkPicker.linkUrl": "Link URL",
"linkPicker.loadFailed": "Failed to load the page tree.", "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.openInNewTab": "Open in a new tab",
"linkPicker.page": "Page", "linkPicker.page": "Page",
"linkPicker.selection": "Link target", "linkPicker.selection": "Link target",
"linkPicker.title": "Insert Link", "linkPicker.title": "Insert Link",
"linkPicker.url": "URL", "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.clearItems": "Clear All Items",
"navEdit.editMenuItems": "Edit Menu Items", "navEdit.editMenuItems": "Edit Menu Items",
"navEdit.editingInherited": "Inherited menu — shared with every page using it", "navEdit.editingInherited": "Inherited menu — shared with every page using it",
@ -2179,6 +2204,7 @@
"pageSaveDialog.displayModePath": "Browse Using Paths", "pageSaveDialog.displayModePath": "Browse Using Paths",
"pageSaveDialog.displayModeTitle": "Browse Using Titles", "pageSaveDialog.displayModeTitle": "Browse Using Titles",
"pageSaveDialog.loadFailed": "Failed to load folder tree.", "pageSaveDialog.loadFailed": "Failed to load folder tree.",
"pageSaveDialog.localeHint": "Which locale the page belongs to.",
"pageSaveDialog.pageTitle": "Page Title", "pageSaveDialog.pageTitle": "Page Title",
"pageSaveDialog.pathInvalid": "Invalid Characters in Page Path Name. Lowercase alphanumerical and hyphen characters only.", "pageSaveDialog.pathInvalid": "Invalid Characters in Page Path Name. Lowercase alphanumerical and hyphen characters only.",
"pageSaveDialog.pathName": "Path Name", "pageSaveDialog.pathName": "Path Name",

@ -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

@ -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;

@ -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 path from 'node:path'
import { locales as localesTable } from '../db/schema.ts' import { locales as localesTable } from '../db/schema.ts'
import { eq, sql } from 'drizzle-orm' 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<string, number>()
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 * 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 { class Locales {
/**
* Load every locale strings file shipped in `locales/` into the db.
*
* The directory is the list: a `<tag>.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<false | void> { async refreshFromDisk({ force = false }: { force?: boolean } = {}): Promise<false | void> {
try { try {
const localesMeta = (await import('../locales/metadata.js')).default const localesPath = path.join(WIKI.SERVERPATH, 'locales')
WIKI.logger.info(`Found ${localesMeta.languages.length} locales [ OK ]`) const localeFiles = (await readdir(localesPath)).filter((fl) => fl.endsWith('.json'))
WIKI.logger.info(`Found ${localeFiles.length} locales [ OK ]`)
const dbLocales = await WIKI.db const dbLocales = await WIKI.db
.select({ .select({
@ -20,68 +140,52 @@ class Locales {
.from(localesTable) .from(localesTable)
.orderBy(localesTable.code) .orderBy(localesTable.code)
let localFilesSkipped = 0 for (const localeFile of localeFiles) {
for (const lang of localesMeta.languages) { const code = path.basename(localeFile, '.json')
// -> 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)
// -> Get File version // -> Read the tag off the file name
const flPath = path.join(WIKI.SERVERPATH, `locales/${langFilename}.json`) let localeInfo: ReturnType<typeof localeInfoFor>
try { try {
const flStat = await stat(flPath) localeInfo = localeInfoFor(code)
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 ]`
)
}
} catch { } catch {
localFilesSkipped++ WIKI.logger.warn(`Locale file ${localeFile} is not a valid language tag. [ SKIPPED ]`)
WIKI.logger.warn( continue
`Locale ${langFilename} not found on disk. Missing strings file. [ SKIPPED ]`
)
} }
}
if (localFilesSkipped > 0) { // -> Skip a locale that was updated in the DB after the file was last written
WIKI.logger.warn( const flPath = path.join(localesPath, localeFile)
`${localFilesSkipped} locales were defined in the metadata file but not found on disk. [ SKIPPED ]` 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) { } catch (err: any) {
WIKI.logger.warn('Failed to load locales from disk: [ FAILED ]') 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<RemoteLocale[]> {
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<void> {
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<LocaleUpdateResult> {
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<void> {
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<void> {
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<any[]> { async getLocales({ cache = true }: { cache?: boolean } = {}): Promise<any[]> {
if (!WIKI.cache.has('locales') || !cache) { if (!WIKI.cache.has('locales') || !cache) {
const locales = await WIKI.db const locales = await WIKI.db
.select({ .select({
code: localesTable.code, code: localesTable.code,
isRTL: localesTable.isRTL, isRTL: localesTable.isRTL,
isInstalled: localesTable.isInstalled,
language: localesTable.language, language: localesTable.language,
name: localesTable.name, name: localesTable.name,
nativeName: localesTable.nativeName, nativeName: localesTable.nativeName,
customCode: localesTable.customCode,
customName: localesTable.customName,
createdAt: localesTable.createdAt, createdAt: localesTable.createdAt,
updatedAt: localesTable.updatedAt, updatedAt: localesTable.updatedAt,
completeness: localesTable.completeness completeness: localesTable.completeness
}) })
.from(localesTable) .from(localesTable)
.orderBy(localesTable.code) .orderBy(localesTable.code)
resolveDisplayNames(locales)
WIKI.cache.set('locales', locales) WIKI.cache.set('locales', locales)
for (const locale of locales) { for (const locale of locales) {
WIKI.cache.set(`locale:${locale.code}`, locale) WIKI.cache.set(`locale:${locale.code}`, locale)
@ -113,6 +452,11 @@ class Locales {
return WIKI.cache.get('locales') as any[] 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<any[]> {
return (await this.getLocales({ cache })).filter((lc) => lc.isInstalled)
}
async getStrings(locale: string) { async getStrings(locale: string) {
const results = await WIKI.db const results = await WIKI.db
.select({ strings: localesTable.strings }) .select({ strings: localesTable.strings })

@ -38,10 +38,14 @@ function isVisibleTo(item: NavigationItem, userGroups: string[]): boolean {
/** /**
* Navigation model * Navigation model
* *
* A navigation menu is a row of `items` keyed by the id of whatever it belongs to: a tree entry that * A navigation menu is a row of `items` belonging either to a tree entry that overrides the menu below
* overrides the menu below it, or for the site-wide menu every page falls back to the site's own * it keyed by that entry's id, which is why an id alone is enough to fetch a menu or to a site AND
* id. That double use of the key is why the id alone is enough to fetch a menu, and why the home page * A LOCALE, which is the menu every page in that locale falls back to and what the locale's home page
* edits the site menu rather than one of its own. * 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: * 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. * 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, * Created empty on demand rather than with the site: a locale is activated long after, and the first
* has nothing stored, and an absent menu is an empty one rather than an error. * 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<void> { async siteNavId(siteId: string, locale: string): Promise<string> {
await WIKI.db 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) .insert(navigationTable)
.values({ id: siteId, siteId, items: [] }) .values({ siteId, locale, items: [] })
.onConflictDoNothing() .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. * 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 * 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 * otherwise leave its menu behind with nothing able to reach it. A site's own menus are identified
* site id and is never a tree entry, so it is not at risk here. * 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 * @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 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 * @param folderPath Encoded ltree path of the folder holding the entry, empty at the site root
*/ */
private async ancestorNavId(siteId: string, folderPath: string): Promise<string | null> { private async ancestorNavId(
siteId: string,
locale: string,
folderPath: string
): Promise<string | null> {
if (!folderPath) { 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` const result = await WIKI.db.execute(sql`
SELECT "navigationId" SELECT "navigationId"
FROM tree FROM tree
WHERE "siteId" = ${siteId} WHERE "siteId" = ${siteId}
AND "locale" = ${locale}
AND ("folderPath" || "fileName") @> ${folderPath}::ltree AND ("folderPath" || "fileName") @> ${folderPath}::ltree
AND "navigationMode" IN ('override', 'hide') AND "navigationMode" IN ('override', 'hide')
ORDER BY nlevel("folderPath" || "fileName") DESC ORDER BY nlevel("folderPath" || "fileName") DESC
LIMIT 1 LIMIT 1
`) `)
const rows = (result.rows ?? result) as any[] 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<string | null> { async inheritedNavId(siteId: string, pageId: string): Promise<string | null> {
const entry = await this.getEntry(siteId, pageId) 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<UpdateNavigationResult> { }): Promise<UpdateNavigationResult> {
const entry = await this.getEntry(siteId, pageId) 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 ?? '' const folderPath = entry.folderPath ?? ''
// -> The home page at the root edits the site-wide menu rather than one of its own, which is what // -> The home page at the root edits the site-wide menu FOR ITS LOCALE rather than one of its own,
// makes it the menu every other page inherits // which is what makes it the menu every other page in that locale inherits
const isSiteRoot = folderPath === '' && entry.fileName === 'home' 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 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) { if (items) {
/* /*
@ -215,6 +243,8 @@ class Navigation {
.insert(navigationTable) .insert(navigationTable)
.values({ id: targetNavId, siteId, items }) .values({ id: targetNavId, siteId, items })
.onConflictDoUpdate({ target: navigationTable.id, set: { 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 // -> A mode that stops applying below this entry hands its descendants back to the ancestor
@ -269,6 +299,7 @@ class Navigation {
UPDATE tree tt UPDATE tree tt
SET "navigationId" = ${cascadeTo} SET "navigationId" = ${cascadeTo}
WHERE tt."siteId" = ${siteId} WHERE tt."siteId" = ${siteId}
AND tt."locale" = ${entry.locale}
AND tt.tree IN ('page', 'folder') AND tt.tree IN ('page', 'folder')
AND tt."folderPath" <@ ${fullPath}::ltree AND tt."folderPath" <@ ${fullPath}::ltree
AND tt."navigationMode" = 'inherit' AND tt."navigationMode" = 'inherit'
@ -276,6 +307,7 @@ class Navigation {
SELECT 1 SELECT 1
FROM tree tc FROM tree tc
WHERE tc."siteId" = ${siteId} WHERE tc."siteId" = ${siteId}
AND tc."locale" = ${entry.locale}
AND tc.tree IN ('page', 'folder') AND tc.tree IN ('page', 'folder')
AND tc."folderPath" <@ ${fullPath}::ltree AND tc."folderPath" <@ ${fullPath}::ltree
AND (tc."folderPath" || tc."fileName") @> tt."folderPath" AND (tc."folderPath" || tc."fileName") @> tt."folderPath"

@ -788,7 +788,7 @@ class Pages {
async movePage( async movePage(
siteId: string, siteId: string,
id: string, id: string,
{ path, title }: { path: string; title?: string }, { path, locale, title }: { path: string; locale?: string; title?: string },
actor: PageActor actor: PageActor
): Promise<Page | null> { ): Promise<Page | null> {
// -> With the source, which the move itself does not need: it is what the copy kept by a storage // -> 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 existingContent = page.content
const newPath = normalizePath(path) 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 return page
} }
if (newPath !== page.path) { if (isRelocated) {
const duplicate = await WIKI.db const duplicate = await WIKI.db
.select({ id: pagesTable.id }) .select({ id: pagesTable.id })
.from(pagesTable) .from(pagesTable)
@ -811,7 +818,7 @@ class Pages {
and( and(
ne(pagesTable.id, id), ne(pagesTable.id, id),
eq(pagesTable.siteId, siteId), eq(pagesTable.siteId, siteId),
eq(pagesTable.locale, page.locale), eq(pagesTable.locale, newLocale),
eq(pagesTable.path, newPath) eq(pagesTable.path, newPath)
) )
) )
@ -821,7 +828,7 @@ class Pages {
} }
await this.guardAgainstAssetCollision({ await this.guardAgainstAssetCollision({
siteId, siteId,
locale: page.locale, locale: newLocale,
parentPath: newPath.split('/').slice(0, -1).join('/'), parentPath: newPath.split('/').slice(0, -1).join('/'),
fileName: newPath.split('/').at(-1)!, fileName: newPath.split('/').at(-1)!,
contentType: page.contentType contentType: page.contentType
@ -832,6 +839,7 @@ class Pages {
.update(pagesTable) .update(pagesTable)
.set({ .set({
path: newPath, path: newPath,
locale: newLocale,
hash: generatePathHash(newPath), hash: generatePathHash(newPath),
...(title !== undefined ? { title: title.trim() } : {}), ...(title !== undefined ? { title: title.trim() } : {}),
authorId: actor.id, authorId: actor.id,
@ -848,10 +856,10 @@ class Pages {
parentPath: pathParts.slice(0, -1).join('/'), parentPath: pathParts.slice(0, -1).join('/'),
fileName: pathParts.at(-1)!, fileName: pathParts.at(-1)!,
title: title !== undefined ? title.trim() : page.title, title: title !== undefined ? title.trim() : page.title,
locale: page.locale, locale: newLocale,
siteId, siteId,
tags: page.tags, 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 const moved = (await this.getPage({ siteId, id })) as Page
@ -865,15 +873,23 @@ class Pages {
authorId: actor.id, authorId: actor.id,
changedFields: [ changedFields: [
...(newPath !== page.path ? ['path'] : []), ...(newPath !== page.path ? ['path'] : []),
...(newLocale !== page.locale ? ['locale'] : []),
...(title !== undefined && title.trim() !== page.title ? ['title'] : []) ...(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 // -> 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 // 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 // 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 ?? '') 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.storage.mirrorPage(stored.ref, stored.content)
await WIKI.models.hooks.emit('page:rename', { await WIKI.models.hooks.emit('page:rename', {

@ -209,10 +209,11 @@ class Sites {
const newSite = result[0] const newSite = result[0]
// -> The menu every page of the site inherits, keyed by the site id. Empty to begin with, but it // -> The menu every page of the site inherits, one per locale. Empty to begin with, but it has to
// has to exist before a page can point at it // 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}`) 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 // -> Site lookups by id / hostname are served from cache, which must know about the new site
await WIKI.models.sites.reloadCache() await WIKI.models.sites.reloadCache()

@ -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 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 { export interface StoragePageRef {
id: string id: string
siteId: string siteId: string
@ -425,8 +431,18 @@ export interface StorageModule {
putPage: (target: StorageTarget, ref: StoragePageRef, page: StoragePageContent) => Promise<void> putPage: (target: StorageTarget, ref: StoragePageRef, page: StoragePageContent) => Promise<void>
/** Drop its copy. Must not fail over a copy that is not there. */ /** Drop its copy. Must not fail over a copy that is not there. */
deletePage: (target: StorageTarget, ref: StoragePageRef) => Promise<void> deletePage: (target: StorageTarget, ref: StoragePageRef) => Promise<void>
/** Follow a move, `ref` being where the page now is. */ /**
movePage: (target: StorageTarget, ref: StoragePageRef, previousPath: string) => Promise<void> * 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<void>
/** /**
* A URL a reader can fetch this asset from directly, signed by the store. * 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 * 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. * 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 * @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 * 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). * 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 layout = this.pathLayoutFor(siteId)
const prefix = layout.sitePrefix ? [siteId] : [] const prefix = layout.sitePrefix ? [siteId] : []
if (layout.localePrefix) { if (layout.localePrefix) {
return [...prefix, locale] return [...prefix, WIKI.models.locales.shortCodeFor(locale)]
} }
return locale === layout.primaryLocale ? prefix : null return locale === layout.primaryLocale ? prefix : null
} }
@ -1057,7 +1077,9 @@ class Storage {
if (rest.length < 2) { if (rest.length < 2) {
return null 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) rest = rest.slice(1)
} }
return rest.length > 0 ? { locale, segments: rest } : null 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. * Move every page-keeping target's copy of a page, `ref` being where it now is.
*/ */
async relocatePage(ref: StoragePageRef, previousPath: string): Promise<void> { async relocatePage(ref: StoragePageRef, previous: StoragePageLocation): Promise<void> {
if (previousPath === ref.path) { if (previous.path === ref.path && previous.locale === ref.locale) {
return return
} }
const label = (loc: StoragePageLocation) => `${loc.locale}/${loc.path}`
await this.eachPageTarget( await this.eachPageTarget(
ref.siteId, ref.siteId,
`move the page from ${previousPath} to ${ref.path}`, `move the page from ${label(previous)} to ${label(ref)}`,
(mod, target) => mod.movePage(target, ref, previousPath) (mod, target) => mod.movePage(target, ref, previous)
) )
} }

@ -944,7 +944,8 @@ class Tree {
path: page.path, path: page.path,
contentType: page.contentType 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, siteId,
tags, tags,
meta, meta,
// -> Pages inherit the site's navigation until something says otherwise // -> Pages inherit the navigation of the site AND LOCALE they are in until something says
navigationId: siteId, // 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 // -> 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 // something to report rather than something to work around
onConflict: 'error' onConflict: 'error'

@ -162,11 +162,11 @@ const diskStorage: StorageModule = {
await pruneEmptyDirs(root, path.dirname(filePath)) 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 // -> Which editor wrote it does not change when a page moves, so both ends share an extension
await moveStored( await moveStored(
baseDir(target), baseDir(target),
pageRelPath(target, { ...ref, path: previousPath }), pageRelPath(target, { ...ref, ...previous }),
pageRelPath(target, ref) pageRelPath(target, ref)
) )
}, },

@ -687,8 +687,8 @@ const gitStorage: StorageModule = {
) )
}, },
async movePage(target, ref, previousPath) { async movePage(target, ref, previous) {
const from = pageRelPath(target, { ...ref, path: previousPath }) const from = pageRelPath(target, { ...ref, ...previous })
const to = pageRelPath(target, ref) const to = pageRelPath(target, ref)
await withRepo(target, async (repo) => { await withRepo(target, async (repo) => {
const outcome = await moveStored(repo.root, from, to) const outcome = await moveStored(repo.root, from, to)
@ -704,8 +704,8 @@ const gitStorage: StorageModule = {
target, target,
paths, paths,
outcome === 'moved' outcome === 'moved'
? `docs: rename ${pageLabel({ ...ref, path: previousPath })} to ${pageLabel(ref)}` ? `docs: rename ${pageLabel({ ...ref, ...previous })} to ${pageLabel(ref)}`
: `docs: delete ${pageLabel({ ...ref, path: previousPath })}`, : `docs: delete ${pageLabel({ ...ref, ...previous })}`,
ref.actorId ref.actorId
) )
}) })

@ -376,12 +376,12 @@ const sftpStorage: StorageModule = {
await withClient(target, (client) => removeRemote(client, target, relPath)) await withClient(target, (client) => removeRemote(client, target, relPath))
}, },
async movePage(target, ref, previousPath) { async movePage(target, ref, previous) {
await withClient(target, (client) => await withClient(target, (client) =>
moveRemote( moveRemote(
client, client,
target, target,
pageRelPath(target, { ...ref, path: previousPath }), pageRelPath(target, { ...ref, ...previous }),
pageRelPath(target, ref) pageRelPath(target, ref)
) )
) )

@ -1,63 +1,17 @@
import { setTimeout } from 'node:timers/promises' /**
* Bring the locale list in step with what is published upstream.
export async function task(): Promise<void> { *
if (WIKI.config.update?.locales === false) { * 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<void> {
if (!payload?.force && WIKI.config.update?.locales === false) {
return return
} }
WIKI.logger.info('Fetching latest localization data...')
try { try {
interface LocaleMetadata { await WIKI.models.locales.updateFromRemote()
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<LocaleMetadata>)
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 ]')
} catch (err: any) { } catch (err: any) {
WIKI.logger.error('Fetching latest localization data: [ FAILED ]') WIKI.logger.error('Fetching latest localization data: [ FAILED ]')
WIKI.logger.error(err.message) WIKI.logger.error(err.message)

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="Fly~vIrnzLNx4sqnHqi3Qa" x1="41" x2="5" y1="44" y2="44" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fed100"/><stop offset="1" stop-color="#eb6001"/></linearGradient><path fill="url(#Fly~vIrnzLNx4sqnHqi3Qa)" d="M5,43h35c0.552,0,1,0.448,1,1l0,0c0,0.552-0.448,1-1,1H5V43z"/><linearGradient id="Fly~vIrnzLNx4sqnHqi3Qb" x1="10.559" x2="36.15" y1="4.748" y2="30.339" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ffd747"/><stop offset=".482" stop-color="#ffd645"/><stop offset="1" stop-color="#f5bc00"/></linearGradient><path fill="url(#Fly~vIrnzLNx4sqnHqi3Qb)" d="M44.419,14.798L33.203,3.581c-0.774-0.774-2.03-0.774-2.805,0L9,24.98L15,33l7.964,6 l21.455-21.398C45.194,16.828,45.194,15.572,44.419,14.798z"/><linearGradient id="Fly~vIrnzLNx4sqnHqi3Qc" x1="5.083" x2="13.328" y1="34.215" y2="48.495" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fed100"/><stop offset="1" stop-color="#e36001"/></linearGradient><path fill="url(#Fly~vIrnzLNx4sqnHqi3Qc)" d="M5,45c-0.521,0-1.032-0.204-1.414-0.586C3.021,43.849,2.846,43,3.188,42.143l4-10L13,35 l2.8,5.8L5.743,44.857C5.502,44.953,5.25,45,5,45z"/><linearGradient id="Fly~vIrnzLNx4sqnHqi3Qd" x1="6.75" x2="20.75" y1="27.25" y2="41.25" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#889097"/><stop offset="1" stop-color="#4c5963"/></linearGradient><polygon fill="url(#Fly~vIrnzLNx4sqnHqi3Qd)" points="7.2,32.2 9,25 23,39 15.8,40.8"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -13,7 +13,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { setCssVar } from '@/helpers/cssVars' import { setCssVar } from '@/helpers/cssVars'
import { stripPageExtension } from '@/helpers/pagePaths' import { splitLocalePath, stripPageExtension } from '@/helpers/pagePaths'
import { useDark } from '@/composables/dark' import { useDark } from '@/composables/dark'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
@ -104,6 +104,13 @@ async function applyLocale(locale) {
} }
} }
i18n.locale.value = 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 // THEME
@ -202,6 +209,8 @@ async function loadBootstrap() {
searchParams: { hostname: window.location.hostname }, searchParams: { hostname: window.location.hostname },
cache: 'no-store' cache: 'no-store'
}).json() }).json()
// -> Before the site: `applySiteInfo` resolves the site's active locale codes against this
siteStore.installedLocales = data.locales ?? []
siteStore.applySiteInfo(data.site) siteStore.applySiteInfo(data.site)
flagsStore.apply(data.flags) flagsStore.apply(data.flags)
userStore.applyProfile(data.user) 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 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. itself rather than a page, and is left alone as it is by the server.
*/ */
const withoutExtension = to.path.startsWith('/_') const isPagePath = !to.path.startsWith('/_')
? null const withoutExtension = isPagePath ? stripPageExtension(to.path, siteStore.pageExtensions) : null
: stripPageExtension(to.path, siteStore.pageExtensions)
if (withoutExtension) { if (withoutExtension) {
return { path: withoutExtension, query: to.query, hash: to.hash, replace: true } 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 ( 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 || !commonStore.desiredLocale ||
!siteStore.locales.active.some((l) => l.code === commonStore.desiredLocale) !siteStore.locales.active.some((l) => l.code === commonStore.desiredLocale)
) { ) {

@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or 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. removing an icon; `check-icons.mjs` fails the build if this drifts.
268 icons. 269 icons.
*/ */
export const BUNDLED_ICONS = { export const BUNDLED_ICONS = {
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32}, "la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
@ -36,6 +36,7 @@ export const BUNDLED_ICONS = {
"la:clipboard-check": {"body":"<path fill=\"currentColor\" d=\"M16 2c-1.258 0-2.152.89-2.594 2H5v25h22V4h-8.406C18.152 2.89 17.258 2 16 2m0 2c.555 0 1 .445 1 1v1h3v2h-8V6h3V5c0-.555.445-1 1-1M7 6h3v4h12V6h3v21H7zm14.281 7.281L15 19.562l-3.281-3.28l-1.438 1.437l4 4l.719.687l.719-.687l7-7z\"/>","width":32,"height":32}, "la:clipboard-check": {"body":"<path fill=\"currentColor\" d=\"M16 2c-1.258 0-2.152.89-2.594 2H5v25h22V4h-8.406C18.152 2.89 17.258 2 16 2m0 2c.555 0 1 .445 1 1v1h3v2h-8V6h3V5c0-.555.445-1 1-1M7 6h3v4h12V6h3v21H7zm14.281 7.281L15 19.562l-3.281-3.28l-1.438 1.437l4 4l.719.687l.719-.687l7-7z\"/>","width":32,"height":32},
"la:clipboard-list": {"body":"<path fill=\"currentColor\" d=\"M16 2c-1.26 0-2.15.89-2.59 2H5v25h22V4h-8.41c-.44-1.11-1.33-2-2.59-2m0 2c.55 0 1 .45 1 1v1h3v2h-8V6h3V5c0-.55.45-1 1-1M7 6h3v4h12V6h3v21H7zm2 7v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2z\"/>","width":32,"height":32}, "la:clipboard-list": {"body":"<path fill=\"currentColor\" d=\"M16 2c-1.26 0-2.15.89-2.59 2H5v25h22V4h-8.41c-.44-1.11-1.33-2-2.59-2m0 2c.55 0 1 .45 1 1v1h3v2h-8V6h3V5c0-.55.45-1 1-1M7 6h3v4h12V6h3v21H7zm2 7v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2zm-4 4v2h2v-2zm4 0v2h10v-2z\"/>","width":32,"height":32},
"la:clock": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m-1 2v9h7v-2h-5V8z\"/>","width":32,"height":32}, "la:clock": {"body":"<path fill=\"currentColor\" d=\"M16 4C9.383 4 4 9.383 4 16s5.383 12 12 12s12-5.383 12-12S22.617 4 16 4m0 2c5.535 0 10 4.465 10 10s-4.465 10-10 10S6 21.535 6 16S10.465 6 16 6m-1 2v9h7v-2h-5V8z\"/>","width":32,"height":32},
"la:cloud-download-alt": {"body":"<path fill=\"currentColor\" d=\"M16 6c-2.648 0-4.95 1.238-6.594 3.063C9.27 9.046 9.148 9 9 9c-2.2 0-4 1.8-4 4c-1.73 1.055-3 2.836-3 5c0 3.3 2.7 6 6 6h5v-2H8c-2.219 0-4-1.781-4-4a4.01 4.01 0 0 1 2.438-3.688l.687-.28l-.094-.75A6 6 0 0 1 7 13a1.984 1.984 0 0 1 2.469-1.938l.625.157l.375-.5A7 7 0 0 1 16 8c3.277 0 6.012 2.254 6.781 5.281l.188.781l.843-.03c.211-.012.258-.032.188-.032c2.219 0 4 1.781 4 4s-1.781 4-4 4h-5v2h5c3.3 0 6-2.7 6-6c0-3.156-2.488-5.684-5.594-5.906C23.184 8.574 19.926 6 16 6m-1 12v8h-3l4 4l4-4h-3v-8z\"/>","width":32,"height":32},
"la:cloud-upload-alt": {"body":"<path fill=\"currentColor\" d=\"M16 7c-2.648 0-4.95 1.238-6.594 3.063C9.27 10.046 9.148 10 9 10c-2.2 0-4 1.8-4 4c-1.73 1.055-3 2.836-3 5c0 3.3 2.7 6 6 6h5v-2H8c-2.219 0-4-1.781-4-4a4.01 4.01 0 0 1 2.438-3.688l.687-.28l-.094-.75A6 6 0 0 1 7 14a1.984 1.984 0 0 1 2.469-1.938l.625.157l.375-.5A7 7 0 0 1 16 9c3.277 0 6.012 2.254 6.781 5.281l.188.781l.843-.03c.211-.012.258-.032.188-.032c2.219 0 4 1.781 4 4s-1.781 4-4 4h-5v2h5c3.3 0 6-2.7 6-6c0-3.156-2.488-5.684-5.594-5.906C23.184 9.574 19.926 7 16 7m0 8l-4 4h3v8h2v-8h3z\"/>","width":32,"height":32}, "la:cloud-upload-alt": {"body":"<path fill=\"currentColor\" d=\"M16 7c-2.648 0-4.95 1.238-6.594 3.063C9.27 10.046 9.148 10 9 10c-2.2 0-4 1.8-4 4c-1.73 1.055-3 2.836-3 5c0 3.3 2.7 6 6 6h5v-2H8c-2.219 0-4-1.781-4-4a4.01 4.01 0 0 1 2.438-3.688l.687-.28l-.094-.75A6 6 0 0 1 7 14a1.984 1.984 0 0 1 2.469-1.938l.625.157l.375-.5A7 7 0 0 1 16 9c3.277 0 6.012 2.254 6.781 5.281l.188.781l.843-.03c.211-.012.258-.032.188-.032c2.219 0 4 1.781 4 4s-1.781 4-4 4h-5v2h5c3.3 0 6-2.7 6-6c0-3.156-2.488-5.684-5.594-5.906C23.184 9.574 19.926 7 16 7m0 8l-4 4h3v8h2v-8h3z\"/>","width":32,"height":32},
"la:code": {"body":"<path fill=\"currentColor\" d=\"m18 5l-6 22h2l6-22zM7.937 6.406l-6.75 9L.75 16l.438.594l6.75 9l1.625-1.188L3.25 16l6.313-8.406zm16.125 0l-1.625 1.188L28.75 16l-6.313 8.406l1.625 1.188l6.75-9L31.25 16l-.438-.594z\"/>","width":32,"height":32}, "la:code": {"body":"<path fill=\"currentColor\" d=\"m18 5l-6 22h2l6-22zM7.937 6.406l-6.75 9L.75 16l.438.594l6.75 9l1.625-1.188L3.25 16l6.313-8.406zm16.125 0l-1.625 1.188L28.75 16l-6.313 8.406l1.625 1.188l6.75-9L31.25 16l-.438-.594z\"/>","width":32,"height":32},
"la:code-branch": {"body":"<path fill=\"currentColor\" d=\"M11 4C9.355 4 8 5.355 8 7c0 1.293.844 2.395 2 2.813v12.374c-1.156.418-2 1.52-2 2.813c0 1.645 1.355 3 3 3s3-1.355 3-3c0-1.27-.816-2.344-1.938-2.781c.145-1.23.622-1.836 1.376-2.344c.898-.605 2.277-.965 3.78-1.313c1.505-.347 3.118-.707 4.47-1.656c1.187-.832 2.085-2.195 2.28-4.093C25.142 12.402 26 11.3 26 10c0-1.645-1.355-3-3-3s-3 1.355-3 3c0 1.277.832 2.352 1.969 2.781c-.137 1.313-.645 1.965-1.407 2.5c-.898.63-2.285 1-3.78 1.344c-1.497.344-3.118.648-4.47 1.563c-.109.074-.21.167-.312.25V9.813c1.156-.418 2-1.52 2-2.813c0-1.645-1.355-3-3-3m0 2c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1m12 3c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1M11 24c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1\"/>","width":32,"height":32}, "la:code-branch": {"body":"<path fill=\"currentColor\" d=\"M11 4C9.355 4 8 5.355 8 7c0 1.293.844 2.395 2 2.813v12.374c-1.156.418-2 1.52-2 2.813c0 1.645 1.355 3 3 3s3-1.355 3-3c0-1.27-.816-2.344-1.938-2.781c.145-1.23.622-1.836 1.376-2.344c.898-.605 2.277-.965 3.78-1.313c1.505-.347 3.118-.707 4.47-1.656c1.187-.832 2.085-2.195 2.28-4.093C25.142 12.402 26 11.3 26 10c0-1.645-1.355-3-3-3s-3 1.355-3 3c0 1.277.832 2.352 1.969 2.781c-.137 1.313-.645 1.965-1.407 2.5c-.898.63-2.285 1-3.78 1.344c-1.497.344-3.118.648-4.47 1.563c-.109.074-.21.167-.312.25V9.813c1.156-.418 2-1.52 2-2.813c0-1.645-1.355-3-3-3m0 2c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1m12 3c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1M11 24c.563 0 1 .438 1 1s-.438 1-1 1s-1-.438-1-1s.438-1 1-1\"/>","width":32,"height":32},

@ -19,10 +19,12 @@
class="fileman-locale mr-2 acrylic-btn" class="fileman-locale mr-2 acrylic-btn"
flat flat
color="white" color="white"
:label="commonStore.locale" :label="siteStore.localeAlias(state.locale)"
:aria-label="commonStore.locale" :aria-label="siteStore.localeAlias(state.locale)"
style="height: 40px"> style="height: 40px">
<locale-selector-menu /> <!-- -> Chooses which locale's content is LISTED, so it filters rather than navigates: going
to another page would close the overlay the reader is working in -->
<locale-selector-menu :selected="state.locale" :navigate="false" @select="switchLocale" />
</w-btn> </w-btn>
<!-- <!--
The same pill the site header uses, rather than a `w-input`. The same pill the site header uses, rather than a `w-input`.
@ -310,7 +312,8 @@
:show-new-folder="true" :show-new-folder="true"
@new-folder="() => newFolder(state.currentFolderId)" @new-folder="() => newFolder(state.currentFolderId)"
@new-page="() => close()" @new-page="() => close()"
:base-path="folderPath" /> :base-path="folderPath"
:locale="state.locale" />
</w-btn> </w-btn>
<w-btn <w-btn
flat flat
@ -590,6 +593,12 @@ function storedViewOptions() {
const state = reactive({ const state = reactive({
loading: 0, loading: 0,
isFetching: false, isFetching: false,
/*
Which locale's content is listed. Its own state rather than the interface language: the manager is
a view of the tree, and the tree holds every translation side by side -- unfiltered, it listed all
of them at once. Starts on the locale of the page it was opened from.
*/
locale: pageStore.locale,
search: '', search: '',
/** Drives the search pill's inversion, as HeaderSearch does it. */ /** Drives the search pill's inversion, as HeaderSearch does it. */
searchIsFocused: false, searchIsFocused: false,
@ -883,6 +892,7 @@ async function loadTree({ parentId = null, parentPath = null, types, initLoad =
try { try {
const items = await API_CLIENT.get(`sites/${siteStore.id}/tree`, { const items = await API_CLIENT.get(`sites/${siteStore.id}/tree`, {
searchParams: { searchParams: {
locale: state.locale,
...(parentId ? { parentId } : {}), ...(parentId ? { parentId } : {}),
...(parentPath ? { parentPath } : {}), ...(parentPath ? { parentPath } : {}),
...(types?.length > 0 ? { types: types.join(',') } : {}), ...(types?.length > 0 ? { types: types.join(',') } : {}),
@ -993,6 +1003,29 @@ async function loadTree({ parentId = null, parentPath = null, types, initLoad =
state.isFetching = false state.isFetching = false
} }
/**
* Browse another locale.
*
* Back to the root of it rather than the folder that was open: the tree holds each locale separately,
* so the folder being left may not exist in the one being entered and a folder id from the other
* tree would load somebody else's contents under this locale's name.
*/
async function switchLocale(locale) {
if (locale === state.locale) {
return
}
state.locale = locale
state.treeNodes = {}
state.treeRoots = []
state.currentFolderId = null
state.currentFileId = null
state.fileList = []
// -> The tree marks which folders it has fetched children for, by id. None of them are in the tree
// being entered, so the marks would only ever be wrong about it
treeComp.value?.resetLoaded()
await loadTree({ initLoad: true })
}
function treeContextAction(nodeId, action) { function treeContextAction(nodeId, action) {
switch (action) { switch (action) {
case 'newFolder': { case 'newFolder': {
@ -1018,7 +1051,8 @@ function newFolder(parentId) {
dialog({ dialog({
component: FolderCreateDialog, component: FolderCreateDialog,
componentProps: { componentProps: {
parentId parentId,
locale: state.locale
} }
}).onOk(() => { }).onOk(() => {
loadTree({ parentId }) loadTree({ parentId })
@ -1106,14 +1140,16 @@ function duplicatePage(item) {
itemId: item.id, itemId: item.id,
itemTitle: item.title, itemTitle: item.title,
folderPath: item.folderPath, folderPath: item.folderPath,
itemFileName: item.fileName itemFileName: item.fileName,
locale: state.locale
} }
}).onOk(async (opts) => { }).onOk(async (opts) => {
try { try {
await pageStore.pageDuplicate({ await pageStore.pageDuplicate({
sourcePageId: item.id, sourcePageId: item.id,
path: opts.path, path: opts.path,
title: opts.title title: opts.title,
locale: opts.locale
}) })
// -> The editor is now underneath this overlay, as it is after opening a page to edit // -> The editor is now underneath this overlay, as it is after opening a page to edit
close() close()
@ -1143,18 +1179,26 @@ function renameMovePage(item) {
itemId: item.id, itemId: item.id,
itemTitle: item.title, itemTitle: item.title,
folderPath: item.folderPath, folderPath: item.folderPath,
itemFileName: item.fileName itemFileName: item.fileName,
locale: state.locale
} }
}).onOk(async (opts) => { }).onOk(async (opts) => {
try { try {
if (opts.path === currentPath) { // -> Only the destination decides which of the two endpoints this is, and the destination is a
// locale as well as a path: the same path in another locale is a move, not a rename
if (opts.path === currentPath && opts.locale === state.locale) {
await pageStore.pageRename({ id: item.id, title: opts.title }) await pageStore.pageRename({ id: item.id, title: opts.title })
notify({ notify({
type: 'positive', type: 'positive',
message: 'Page renamed successfully.' message: 'Page renamed successfully.'
}) })
} else { } else {
await pageStore.pageMove({ id: item.id, path: opts.path, title: opts.title }) await pageStore.pageMove({
id: item.id,
path: opts.path,
title: opts.title,
locale: opts.locale
})
notify({ notify({
type: 'positive', type: 'positive',
message: 'Page moved successfully.' message: 'Page moved successfully.'
@ -1247,11 +1291,13 @@ async function uploadNewFiles() {
} }
idx++ idx++
state.uploadPercentage = totalFiles > 1 ? Math.round((idx / totalFiles) * 100) : 90 state.uploadPercentage = totalFiles > 1 ? Math.round((idx / totalFiles) * 100) : 90
// -> The body is the file itself rather than a multipart form, and the locale is left to the // -> The body is the file itself rather than a multipart form. The locale is the one being
// server, which uses the site's primary one // browsed: a file dropped into a French folder belongs to the French tree, and left to
// the server it would have been filed under the site's primary locale instead
const resp = await API_CLIENT.post(`sites/${siteStore.id}/assets`, { const resp = await API_CLIENT.post(`sites/${siteStore.id}/assets`, {
searchParams: { searchParams: {
fileName: fileToUpload.name, fileName: fileToUpload.name,
locale: state.locale,
...(state.currentFolderId ? { folderId: state.currentFolderId } : {}) ...(state.currentFolderId ? { folderId: state.currentFolderId } : {})
}, },
headers: { headers: {
@ -1314,14 +1360,24 @@ function doubleClickItem(item) {
} }
} }
/**
* Where a page in this listing lives, in the locale being browsed.
*
* The prefix is not optional decoration: the manager lists whichever locale the picker is on, so a
* path without one addresses the PRIMARY locale's page of that name -- a different page, or none.
*/
function pageUrl(item) {
const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
return `${siteStore.localeUrlPrefix(state.locale)}/${pagePath}`
}
function openItem(item) { function openItem(item) {
switch (item.type) { switch (item.type) {
case 'folder': { case 'folder': {
return return
} }
case 'page': { case 'page': {
const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName router.push(pageUrl(item))
router.push(`/${pagePath}`)
close() close()
break break
} }
@ -1337,8 +1393,7 @@ async function copyItemURL(item) {
try { try {
switch (item.type) { switch (item.type) {
case 'page': { case 'page': {
const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName await navigator.clipboard.writeText(`${window.location.origin}${pageUrl(item)}`)
await navigator.clipboard.writeText(`${window.location.origin}/${pagePath}`)
break break
} }
case 'asset': { case 'asset': {
@ -1367,9 +1422,10 @@ async function copyItemURL(item) {
} }
async function editItem(item) { async function editItem(item) {
router.push( const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
item.folderPath ? `/_edit/${item.folderPath}/${item.fileName}` : `/_edit/${item.fileName}` // -> `/_edit` is an app route and carries no prefix, so the locale travels as a query the way it
) // already does for `/_create` -- without it the editor opens the primary locale's page instead
router.push({ path: `/_edit/${pagePath}`, query: { locale: state.locale } })
close() close()
} }

@ -83,6 +83,15 @@ const props = defineProps({
parentId: { parentId: {
type: String, type: String,
default: null default: null
},
/**
* The locale to create it in. Only consulted at the root of the tree: a folder inside another one
* takes its parent's locale, which the server settles rather than trusting the caller for. Absent,
* the server falls back to the site's primary locale.
*/
locale: {
type: String,
default: null
} }
}) })
@ -153,12 +162,15 @@ async function create() {
if (!isFormValid) { if (!isFormValid) {
throw new Error(t('fileman.createFolderInvalidData')) throw new Error(t('fileman.createFolderInvalidData'))
} }
// -> No locale is sent: the server puts the folder in the site's primary one
const resp = await API_CLIENT.post(`sites/${siteStore.id}/tree/folders`, { const resp = await API_CLIENT.post(`sites/${siteStore.id}/tree/folders`, {
json: { json: {
parentId: props.parentId, parentId: props.parentId,
pathName: state.path, pathName: state.path,
title: state.title title: state.title,
// -> Which the server only reads for a folder at the ROOT -- one created inside another takes
// that one's locale. Left out, a folder made while browsing French landed in the primary
// locale, where nothing looking at the French tree would ever see it again
...(props.locale && { locale: props.locale })
} }
}).json() }).json()
// -> The API client does not throw on 400, so a refused name comes back as a parsed error // -> The API client does not throw on 400, so a refused name comes back as a parsed error

@ -2,7 +2,7 @@
<div class="site-header bg-header text-white"> <div class="site-header bg-header text-white">
<div class="flex flex-nowrap"> <div class="flex flex-nowrap">
<w-toolbar style="height: 64px"> <w-toolbar style="height: 64px">
<w-btn dense flat to="/"> <w-btn dense flat :to="homePath">
<w-avatar v-if="siteStore.logoText" size="34px" square> <w-avatar v-if="siteStore.logoText" size="34px" square>
<img :src="`/_site/current/logo`" /> <img :src="`/_site/current/logo`" />
</w-avatar> </w-avatar>
@ -137,6 +137,7 @@ import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMinWidth } from '@/composables/screen' import { useMinWidth } from '@/composables/screen'
import { splitLocalePath } from '@/helpers/pagePaths'
import { useCommonStore } from '@/stores/common' import { useCommonStore } from '@/stores/common'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -185,6 +186,17 @@ const searchRowIsOpen = ref(false)
* site title and the actions and becomes a button that opens a row of its own. * site title and the actions and becomes a button that opens a row of its own.
*/ */
const isAtLeastSm = useMinWidth(600) const isAtLeastSm = useMinWidth(600)
/*
Home, in the locale being read: `/fr/...` goes back to `/fr`, not to the English site root. Taken
off the route rather than off the page store, because the logo is the way out of a screen where
there may be no page -- a path with nothing behind it, a locale whose home page is not written yet.
Empty prefix on a site that does not bracket its URLs, which leaves the root as it was.
*/
const homePath = computed(() => {
const current = splitLocalePath(route.path, siteStore.localePrefixes)
return (current && siteStore.localeUrlPrefix(current.locale)) || '/'
})
const isSearchCollapsed = computed(() => !isAtLeastSm.value) const isSearchCollapsed = computed(() => !isAtLeastSm.value)
/** /**

@ -4,6 +4,26 @@
<w-card-section class="card-header"> <w-card-section class="card-header">
<w-icon name="la:link" size="sm" class="mr-2" /> <w-icon name="la:link" size="sm" class="mr-2" />
<span>{{ props.title ?? t('linkPicker.title') }}</span> <span>{{ props.title ?? t('linkPicker.title') }}</span>
<w-space />
<!-- -> Only where there is a choice to make: one active locale is most wikis, and a button
that can only say `en` is noise on all of them -->
<w-btn
v-if="siteStore.locales.active.length > 1"
class="acrylic-btn -my-2"
flat
dense
padding="xs md"
color="white"
:label="siteStore.localeAlias(state.locale)"
:aria-label="siteStore.localeAlias(state.locale)">
<w-tooltip>{{ t(`linkPicker.localeHint`) }}</w-tooltip>
<locale-selector-menu
:selected="state.locale"
:navigate="false"
anchor="bottom right"
self="top right"
@select="switchLocale" />
</w-btn>
</w-card-section> </w-card-section>
<!-- -> Inset from the card's edges, as in the icon picker: the strip is a segmented control with <!-- -> Inset from the card's edges, as in the icon picker: the strip is a segmented control with
a track of its own, so it sits ON the card rather than spanning it edge to edge --> a track of its own, so it sits ON the card rather than spanning it edge to edge -->
@ -128,7 +148,9 @@ import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
import fileTypes from '@/helpers/fileTypes' import fileTypes from '@/helpers/fileTypes'
import { splitLocalePath } from '@/helpers/pagePaths'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
import Tree from '@/components/TreeNav.vue' import Tree from '@/components/TreeNav.vue'
import { usePageStore } from '@/stores/page' import { usePageStore } from '@/stores/page'
@ -170,6 +192,15 @@ const props = defineProps({
newTabOption: { newTabOption: {
type: Boolean, type: Boolean,
default: true 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({ const state = reactive({
currentTab: 'page', 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. */ /** Folder whose contents the right-hand pane lists. Null is the site root. */
currentFolderId: null, currentFolderId: null,
treeNodes: {}, treeNodes: {},
@ -215,9 +252,14 @@ const state = reactive({
// COMPUTED // COMPUTED
const href = computed(() => const href = computed(() => {
state.currentTab === 'page' ? (state.path ? `/${state.path}` : '') : state.url.trim() 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(() => { const canSubmit = computed(() => {
if (state.currentTab === 'page') { if (state.currentTab === 'page') {
@ -236,6 +278,27 @@ watch(
// METHODS // 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. * 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 { try {
const entries = await API_CLIENT.get(`sites/${siteStore.id}/tree`, { const entries = await API_CLIENT.get(`sites/${siteStore.id}/tree`, {
searchParams: { searchParams: {
locale: state.locale,
...(parentId ? { parentId } : {}), ...(parentId ? { parentId } : {}),
...(parentPath ? { parentPath } : {}), ...(parentPath ? { parentPath } : {}),
types: 'folder,page', types: 'folder,page',
@ -365,7 +429,15 @@ onMounted(async () => {
state.currentTab = 'url' state.currentTab = 'url'
state.url = props.initialHref state.url = props.initialHref
} else { } 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(/^\/+/, '')
} }
} }

@ -0,0 +1,165 @@
<template>
<w-dialog v-model="dialogVisible" max-width="480px" @hide="onDialogHide">
<w-card style="min-width: 420px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-crayon.svg" size="sm" class="mr-2" />
<span>{{ t('localeAliasesDialog.title') }}</span>
</w-card-section>
<w-card-section>
<div class="text-body2">
{{ t('localeAliasesDialog.hint', { name: props.locale.name }) }}
</div>
</w-card-section>
<!--
`self-start` on both icons: each field shows a hint line underneath, so the row is taller than
the field and the icon belongs against the first of them, not the middle of both. See the note
in `ApiKeyCreateDialog`.
The 6px with it lands the icon where the New Site dialog's puts it. What an icon lines up with
is the field's TEXT LINE, not its outline: a floating label takes the top of the box, so the
text sits about 3px below the outline's middle and an icon centred on the outline reads as
riding high. The fields there get that for free -- they pass `hide-bottom-space`, so the
section is exactly the field and plain centring finds the text -- while these have a hint line
under them and have to be told.
-->
<div class="pb-2">
<w-item>
<blueprint-icon icon="rename" class="self-start mt-1.5" />
<w-item-section>
<w-input
outlined
dense
v-model="state.customName"
:label="t('localeAliasesDialog.nameLabel')"
:placeholder="props.locale.nativeName"
:hint="t('localeAliasesDialog.nameHint')"
@keyup.enter="save" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="code" class="self-start mt-1.5" />
<w-item-section>
<w-input
outlined
dense
v-model="state.customCode"
:label="t('localeAliasesDialog.codeLabel')"
:placeholder="props.locale.derivedCode"
:hint="t('localeAliasesDialog.codeHint')"
@keyup.enter="save" />
</w-item-section>
</w-item>
</div>
<w-card-section class="pt-0">
<div class="text-body2 text-negative">
{{ t('localeAliasesDialog.warning') }}
</div>
</w-card-section>
<w-card-actions class="card-actions">
<!-- -> Only offered when there is something stored to clear; emptying either field already
saves as "use the derived one" -->
<w-btn
v-if="props.locale.customName || props.locale.customCode"
flat
color="grey"
padding="xs md"
:label="t(`localeAliasesDialog.reset`)"
:disabled="state.isSaving"
@click="reset" />
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.cancel`)"
color="grey"
padding="xs md"
:disabled="state.isSaving"
@click="onDialogCancel" />
<w-btn
unelevated
:label="t(`common.actions.save`)"
color="primary"
padding="xs md"
:loading="state.isSaving"
@click="save" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { reactive } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
// PROPS
const props = defineProps({
locale: {
type: Object,
required: true
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// I18N
const { t } = useI18n()
// DATA
// -> What the locale actually goes by, override or not, so each field starts from what the admin
// sees rather than from a blank they have to guess the meaning of. Saving one back unchanged
// stores no override -- the server takes "the derived form" as "no override".
const state = reactive({
customName: props.locale.displayName ?? '',
customCode: props.locale.displayCode ?? '',
isSaving: false
})
// METHODS
async function save() {
await submit(state.customName, state.customCode)
}
async function reset() {
await submit('', '')
}
async function submit(customName, customCode) {
if (state.isSaving) {
return
}
state.isSaving = true
try {
await API_CLIENT.put(`locales/${props.locale.code}/aliases`, {
json: {
customName: customName.trim() || null,
customCode: customCode.trim() || null
}
})
notify({
type: 'positive',
message: t('localeAliasesDialog.saveSuccess')
})
onDialogOK()
} catch (err) {
notify({
type: 'negative',
message: apiErrorMessage(err)
})
}
state.isSaving = false
}
</script>

@ -0,0 +1,136 @@
<template>
<w-dialog v-model="dialogVisible" max-width="450px" @hide="onDialogHide">
<w-card style="min-width: 350px">
<w-card-section class="card-header">
<w-icon name="img:/_assets/icons/fluent-language.svg" size="sm" class="mr-2" />
<span>{{ t('localeFetchDialog.title') }}</span>
</w-card-section>
<w-card-section>
<div class="p-4 text-center">
<img src="/_assets/illustrations/undraw_world.svg" class="mx-auto" style="width: 150px" />
</div>
<template v-if="state.isLoading">
<w-linear-progress indeterminate size="lg" rounded />
<div class="mt-2 text-center text-caption">{{ t('localeFetchDialog.loading') }}</div>
</template>
<div v-else-if="state.result" class="text-center">
<strong v-if="isUpToDate" class="text-positive">{{
t('localeFetchDialog.resultNone')
}}</strong>
<template v-else>
<div v-if="state.result.added > 0" class="text-body2">
{{
t(
'localeFetchDialog.resultAdded',
{ count: state.result.added },
state.result.added
)
}}
</div>
<div v-if="state.result.updated > 0" class="text-body2">
{{
t(
'localeFetchDialog.resultUpdated',
{ count: state.result.updated },
state.result.updated
)
}}
</div>
<div class="text-body2 text-grey">
{{ t('localeFetchDialog.resultUnchanged', { count: state.result.unchanged }) }}
</div>
<div v-if="state.result.failed > 0" class="text-body2 text-negative">
{{ t('localeFetchDialog.resultFailed', { count: state.result.failed }) }}
</div>
</template>
</div>
</w-card-section>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
flat
:label="t(`common.actions.close`)"
color="grey"
padding="xs md"
:disabled="state.isLoading"
@click="close" />
</w-card-actions>
</w-card>
</w-dialog>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { computed, onMounted, reactive } from 'vue'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
isLoading: true,
result: null
})
// COMPUTED
const isUpToDate = computed(
() => state.result && state.result.added < 1 && state.result.updated < 1
)
// METHODS
async function fetchLocales() {
state.isLoading = true
try {
const resp = await API_CLIENT.post('locales/fetch').json()
state.result = {
added: resp?.added ?? 0,
updated: resp?.updated ?? 0,
unchanged: resp?.unchanged ?? 0,
failed: resp?.failed ?? 0
}
} catch (err) {
notify({
type: 'negative',
message: t('localeFetchDialog.failed'),
caption: apiErrorMessage(err)
})
onDialogCancel()
}
state.isLoading = false
}
// MOUNTED
// -> No confirmation step: the button in the header IS the decision, and the run is cheap one
// small document, and only an installed locale whose hash moved is downloaded at all
onMounted(() => {
fetchLocales()
})
// -> Anything downloaded has to reach the list behind the dialog, so a run that changed something
// resolves rather than cancels even when the administrator closes it with the same button
function close() {
if (state.result && (state.result.added > 0 || state.result.updated > 0)) {
onDialogOK()
} else {
onDialogCancel()
}
}
</script>

@ -5,24 +5,25 @@
:anchor="props.anchor" :anchor="props.anchor"
:self="props.self" :self="props.self"
:offset="props.offset"> :offset="props.offset">
<w-list padding style="min-width: 200px;"> <w-list padding style="min-width: 200px">
<w-item <w-item
v-for="lang of siteStore.locales.active" v-for="lang of siteStore.locales.active"
:key="lang.code" :key="lang.code"
clickable clickable
@click="commonStore.setLocale(lang.code)"> @click="pick(lang.code)">
<w-item-section side> <w-item-section side>
<w-avatar <w-avatar
rounded rounded
:color="lang.code === commonStore.locale ? `secondary` : `primary`" :color="lang.code === currentLocale ? `secondary` : `primary`"
text-color="white" text-color="white"
size="sm"> size="sm">
<div class="text-caption uppercase"><strong>{{ lang.language }}</strong></div> <div class="text-caption uppercase">
<strong>{{ lang.language }}</strong>
</div>
</w-avatar> </w-avatar>
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-item-label>{{ lang.nativeName }}</w-item-label> <w-item-label>{{ lang.displayName }}</w-item-label>
<w-item-label caption>{{ lang.name }}</w-item-label>
</w-item-section> </w-item-section>
</w-item> </w-item>
</w-list> </w-list>
@ -31,6 +32,10 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { splitLocalePath } from '@/helpers/pagePaths'
import { useCommonStore } from '@/stores/common' import { useCommonStore } from '@/stores/common'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -38,6 +43,23 @@ import { useSiteStore } from '@/stores/site'
// PROPS // PROPS
const props = defineProps({ const props = defineProps({
/**
* The locale to tick, when the menu is choosing for something other than the site: the file manager
* browses a locale of its own. The interface locale otherwise.
*/
selected: {
type: String,
default: null
},
/**
* Whether picking a locale takes the reader to the same page in it. Off where the menu is choosing
* within a screen rather than moving between them navigating out of the file manager would close
* the overlay the reader is working in.
*/
navigate: {
type: Boolean,
default: true
},
anchor: { anchor: {
type: String, type: String,
default: 'bottom left' default: 'bottom left'
@ -52,15 +74,57 @@ const props = defineProps({
} }
}) })
// EMITS
const emit = defineEmits(['select'])
// STORES // STORES
const commonStore = useCommonStore() const commonStore = useCommonStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
// ROUTER
const route = useRoute()
const router = useRouter()
// I18N // I18N
const { t } = useI18n() const { t } = useI18n()
// COMPUTED
const currentLocale = computed(() => props.selected ?? commonStore.locale)
// METHODS // METHODS
/**
* Switch to a locale: the content as well as the interface.
*
* The same page in another locale is another URL, so picking one navigates -- setting the interface
* language alone left the reader on the English page with a French menu. `forcePrefix` has no say in
* it: that setting decides whether the PRIMARY locale's pages carry a prefix, not whether the others
* can be reached, and with it off switching to French did nothing at all.
*
* A menu choosing WITHIN a screen says `navigate: false` and listens for `select` instead: there the
* locale is what a listing is filtered by rather than where the reader is going, and the interface
* language is not the picker's to change.
*/
function pick(code) {
emit('select', code)
if (!props.navigate) {
return
}
commonStore.setLocale(code)
const current = splitLocalePath(route.path, siteStore.localePrefixes)
const path = current?.path ?? route.path
// -> An empty prefix is the primary locale on a site that does not bracket its URLs, where the path
// alone IS the address; anything else carries its short code in front
const prefix = siteStore.localeUrlPrefix(code)
router.push({
path: prefix ? `${prefix}${path === '/' ? '' : path}` : path,
query: route.query,
hash: route.hash
})
}
</script> </script>

@ -0,0 +1,95 @@
<template>
<!--
A `w-menu` and nothing else, so that the placeholder the menu uses to locate its trigger lands
directly inside the row this is written into. A wrapper element of its own would become the anchor
instead, and the rows of the editor are drawn by sibling selectors (the nesting elbows) that an
extra element between them would break.
-->
<w-menu class="translucent-menu" context-menu auto-close>
<!--
The file manager's right-click menu, to the class and the padding: the blurred translucent panel
comes from `translucent-menu`, and the inset card is what keeps the rows off its edges the
stylesheet clears that card's own background so the blur still shows through it.
-->
<w-card class="p-2">
<w-list dense style="min-width: 150px">
<w-item v-for="action of actions" :key="action.key" clickable @click="action.handler">
<w-item-section side>
<w-icon :name="action.icon" :color="action.color" />
</w-item-section>
<w-item-section :class="action.labelClass">{{ action.label }}</w-item-section>
</w-item>
</w-list>
</w-card>
</w-menu>
</template>
<script setup>
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
/**
* Right-click menu for one row of the navigation editor.
*
* The row markup differs per item type a header and a separator are plain divs, a link is a
* `w-item` but the menu offered on all three is the same, which is why it lives here rather than
* being written into each branch of the list.
*
* Acting on the row is the parent's business: this component knows what is on offer, the overlay
* holding the list knows how to carry it out.
*/
const props = defineProps({
/** The item the menu acts on, as held in the editor's flat list. */
item: {
type: Object,
required: true
}
})
const emit = defineEmits(['duplicate', 'toggleNesting', 'delete'])
// I18N
const { t } = useI18n()
// COMPUTED
const actions = computed(() => {
const list = [
{
key: 'duplicate',
icon: 'la:copy',
color: 'teal',
label: t('common.actions.duplicate'),
handler: () => emit('duplicate', props.item)
}
]
/*
Nesting is a link's affair alone. `isNested` says a row belongs to the link above it, and the
sidebar reads it off links only a nested header or separator is not a shape it can draw, which
is also why the properties panel offers this same pair of actions there and nowhere else.
Offered even where the result would be invalid (a first row, or one following a header), matching
that panel: the list shows an unparented child in red and says why, rather than quietly refusing
a step that is on the way to somewhere valid.
*/
if (props.item.type === 'link') {
list.push({
key: 'nesting',
icon: props.item.isNested ? 'mdi:format-indent-decrease' : 'mdi:format-indent-increase',
color: 'teal',
label: props.item.isNested ? t('navEdit.unnestItem') : t('navEdit.nestItem'),
handler: () => emit('toggleNesting', props.item)
})
}
list.push({
key: 'delete',
icon: 'la:trash-alt',
color: 'negative',
labelClass: 'text-negative',
label: t('common.actions.delete'),
handler: () => emit('delete', props.item)
})
return list
})
</script>

@ -63,12 +63,18 @@
class="nav-edit-item nav-edit-item-header" class="nav-edit-item nav-edit-item-header"
v-if="element.type === `header`" v-if="element.type === `header`"
:class="state.selected === element.id ? `is-active` : ``" :class="state.selected === element.id ? `is-active` : ``"
@click="setItem(element)"> @click="setItem(element)"
@contextmenu="setItem(element)">
<w-item-label class="text-caption" header>{{ element.label }}</w-item-label> <w-item-label class="text-caption" header>{{ element.label }}</w-item-label>
<w-space /> <w-space />
<w-item-section side> <w-item-section side>
<w-icon class="handle" name="mdi:drag-horizontal" size="sm" /> <w-icon class="handle" name="mdi:drag-horizontal" size="sm" />
</w-item-section> </w-item-section>
<nav-edit-item-menu
:item="element"
@duplicate="duplicateItem"
@toggle-nesting="toggleNesting"
@delete="removeItem" />
</div> </div>
<w-item <w-item
class="nav-edit-item nav-edit-item-link" class="nav-edit-item nav-edit-item-link"
@ -76,22 +82,34 @@
dense dense
:class="{ 'is-active': state.selected === element.id, 'is-nested': element.isNested }" :class="{ 'is-active': state.selected === element.id, 'is-nested': element.isNested }"
@click="setItem(element)" @click="setItem(element)"
@contextmenu="setItem(element)"
clickable> clickable>
<w-item-section side><w-icon :name="element.icon" color="white" /></w-item-section> <w-item-section side><w-icon :name="element.icon" color="white" /></w-item-section>
<w-item-section class="text-wordbreak-all">{{ element.label }}</w-item-section> <w-item-section class="text-wordbreak-all">{{ element.label }}</w-item-section>
<w-item-section side> <w-item-section side>
<w-icon class="handle" name="mdi:drag-horizontal" size="sm" /> <w-icon class="handle" name="mdi:drag-horizontal" size="sm" />
</w-item-section> </w-item-section>
<nav-edit-item-menu
:item="element"
@duplicate="duplicateItem"
@toggle-nesting="toggleNesting"
@delete="removeItem" />
</w-item> </w-item>
<div <div
class="nav-edit-item nav-edit-item-separator" class="nav-edit-item nav-edit-item-separator"
v-else v-else
:class="state.selected === element.id ? `is-active` : ``" :class="state.selected === element.id ? `is-active` : ``"
@click="setItem(element)"> @click="setItem(element)"
@contextmenu="setItem(element)">
<w-separator dark inset style="flex: 1; margin-top: 11px" /> <w-separator dark inset style="flex: 1; margin-top: 11px" />
<w-item-section side> <w-item-section side>
<w-icon class="handle" name="mdi:drag-horizontal" size="sm" /> <w-icon class="handle" name="mdi:drag-horizontal" size="sm" />
</w-item-section> </w-item-section>
<nav-edit-item-menu
:item="element"
@duplicate="duplicateItem"
@toggle-nesting="toggleNesting"
@delete="removeItem" />
</div> </div>
</template> </template>
</sortable> </sortable>
@ -104,27 +122,29 @@
:label="t(`common.actions.add`)" :label="t(`common.actions.add`)"
:aria-label="t(`common.actions.add`)" :aria-label="t(`common.actions.add`)"
icon="la:plus-circle"> icon="la:plus-circle">
<w-menu fit :offset="[0, 10]" auto-close> <w-menu class="translucent-menu" fit :offset="[0, 10]" auto-close>
<w-list separator> <w-card class="p-2">
<w-item clickable @click="addItem(`header`)"> <w-list dense style="min-width: 150px">
<w-item-section side><w-icon name="la:heading" /></w-item-section> <w-item clickable @click="addItem(`header`)">
<w-item-section> <w-item-section side><w-icon name="la:heading" /></w-item-section>
<w-item-label>{{ t('navEdit.header') }}</w-item-label> <w-item-section>
</w-item-section> <w-item-label>{{ t('navEdit.header') }}</w-item-label>
</w-item> </w-item-section>
<w-item clickable @click="addItem(`link`)"> </w-item>
<w-item-section side><w-icon name="la:link" /></w-item-section> <w-item clickable @click="addItem(`link`)">
<w-item-section> <w-item-section side><w-icon name="la:link" /></w-item-section>
<w-item-label>{{ t('navEdit.link') }}</w-item-label> <w-item-section>
</w-item-section> <w-item-label>{{ t('navEdit.link') }}</w-item-label>
</w-item> </w-item-section>
<w-item clickable @click="addItem(`separator`)"> </w-item>
<w-item-section side><w-icon name="la:minus" /></w-item-section> <w-item clickable @click="addItem(`separator`)">
<w-item-section> <w-item-section side><w-icon name="la:minus" /></w-item-section>
<w-item-label>{{ t('navEdit.separator') }}</w-item-label> <w-item-section>
</w-item-section> <w-item-label>{{ t('navEdit.separator') }}</w-item-label>
</w-item> </w-item-section>
</w-list> </w-item>
</w-list>
</w-card>
</w-menu> </w-menu>
</w-btn> </w-btn>
<w-btn <w-btn
@ -134,22 +154,29 @@
:aria-label="t(`common.actions.add`)" :aria-label="t(`common.actions.add`)"
icon="la:ellipsis-v" icon="la:ellipsis-v"
padding="xs sm"> padding="xs sm">
<w-menu :offset="[0, 10]" anchor="bottom right" self="top right" auto-close> <w-menu
<w-list separator> class="translucent-menu"
<w-item clickable @click="clearItems" :disable="state.items.length < 1"> :offset="[0, 10]"
<w-item-section side> anchor="bottom right"
<w-icon name="la:trash-alt" color="negative" /> self="top right"
</w-item-section> auto-close>
<w-item-section> <w-card class="p-2">
<w-item-label>{{ t('navEdit.clearItems') }}</w-item-label> <w-list dense style="min-width: 150px">
</w-item-section> <w-item clickable @click="clearItems" :disable="state.items.length < 1">
</w-item> <w-item-section side>
<!-- q-item(clickable) --> <w-icon name="la:trash-alt" color="negative" />
<!-- q-item-section(side) --> </w-item-section>
<!-- q-icon(name='mdi:import') --> <w-item-section>
<!-- q-item-section --> <w-item-label>{{ t('navEdit.clearItems') }}</w-item-label>
<!-- q-item-label Copy from... --> </w-item-section>
</w-list> </w-item>
<!-- q-item(clickable) -->
<!-- q-item-section(side) -->
<!-- q-icon(name='mdi:import') -->
<!-- q-item-section -->
<!-- q-item-label Copy from... -->
</w-list>
</w-card>
</w-menu> </w-menu>
</w-btn> </w-btn>
</div> </div>
@ -235,7 +262,7 @@
:label="t(`common.actions.delete`)" :label="t(`common.actions.delete`)"
color="negative" color="negative"
padding="xs md" padding="xs md"
@click="removeItem(state.current.id)" /> @click="removeItem(state.current)" />
</w-card> </w-card>
</template> </template>
<template v-if="state.current.type === `link`"> <template v-if="state.current.type === `link`">
@ -434,7 +461,7 @@
:label="t(`common.actions.delete`)" :label="t(`common.actions.delete`)"
color="negative" color="negative"
padding="xs md" padding="xs md"
@click="removeItem(state.current.id)" /> @click="removeItem(state.current)" />
</w-card> </w-card>
</template> </template>
<template v-if="state.current.type === `separator`"> <template v-if="state.current.type === `separator`">
@ -484,7 +511,7 @@
:label="t(`common.actions.delete`)" :label="t(`common.actions.delete`)"
color="negative" color="negative"
padding="xs md" padding="xs md"
@click="removeItem(state.current.id)" /> @click="removeItem(state.current)" />
</w-card> </w-card>
</template> </template>
</w-page> </w-page>
@ -507,6 +534,7 @@ import { v4 as uuid } from 'uuid'
import { pick } from 'es-toolkit/object' import { pick } from 'es-toolkit/object'
import { Sortable } from 'sortablejs-vue3' import { Sortable } from 'sortablejs-vue3'
import IconPickerDialog from '@/components/IconPickerDialog.vue' import IconPickerDialog from '@/components/IconPickerDialog.vue'
import NavEditItemMenu from '@/components/NavEditItemMenu.vue'
import { apiErrorMessage } from '@/helpers/apiError' import { apiErrorMessage } from '@/helpers/apiError'
// STORES // STORES
@ -664,10 +692,53 @@ function addItem(type) {
state.current = newItem state.current = newItem
} }
function removeItem(id) { function removeItem(item) {
state.items = state.items.filter((item) => item.id !== id) state.items = state.items.filter((it) => it.id !== item.id)
state.selected = null // -> Only the row that went gives up the panel: a delete from another row's context menu leaves
state.current = {} // 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() { function clearItems() {

@ -327,13 +327,15 @@ function duplicatePage() {
folderPath: '', folderPath: '',
itemId: pageStore.id, itemId: pageStore.id,
itemTitle: pageStore.title, itemTitle: pageStore.title,
itemFileName: pageStore.path itemFileName: pageStore.path,
locale: pageStore.locale
} }
}).onOk((newPageOpts) => { }).onOk((newPageOpts) => {
pageStore.pageDuplicate({ pageStore.pageDuplicate({
sourcePageId: pageStore.id, sourcePageId: pageStore.id,
path: newPageOpts.path, path: newPageOpts.path,
title: newPageOpts.title title: newPageOpts.title,
locale: newPageOpts.locale
}) })
}) })
} }
@ -346,11 +348,13 @@ function renamePage() {
folderPath: '', folderPath: '',
itemId: pageStore.id, itemId: pageStore.id,
itemTitle: pageStore.title, itemTitle: pageStore.title,
itemFileName: pageStore.path itemFileName: pageStore.path,
locale: pageStore.locale
} }
}).onOk(async (renamedPageOpts) => { }).onOk(async (renamedPageOpts) => {
try { 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 }) await pageStore.pageRename({ id: pageStore.id, title: renamedPageOpts.title })
notify({ notify({
type: 'positive', type: 'positive',
@ -360,7 +364,8 @@ function renamePage() {
await pageStore.pageMove({ await pageStore.pageMove({
id: pageStore.id, id: pageStore.id,
path: renamedPageOpts.path, path: renamedPageOpts.path,
title: renamedPageOpts.title title: renamedPageOpts.title,
locale: renamedPageOpts.locale
}) })
notify({ notify({
type: 'positive', type: 'positive',

@ -539,12 +539,17 @@ async function discardChanges() {
editor: '' 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' siteStore.overlay = 'Welcome'
} }
router.replace('/') router.replace(localeRoot)
return return
} }
@ -655,7 +660,9 @@ async function createPage() {
editorStore.$patch({ editorStore.$patch({
isActive: false 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) { } catch (err) {
notify({ notify({
type: 'negative', type: 'negative',
@ -674,16 +681,20 @@ async function createPage() {
mode: 'savePage', mode: 'savePage',
folderPath: '', folderPath: '',
itemTitle: pageStore.title, itemTitle: pageStore.title,
itemFileName: pageStore.path itemFileName: pageStore.path,
locale: pageStore.locale
} }
}).onOk(async ({ path, title }) => { }).onOk(async ({ path, title, locale }) => {
await processPendingAssets() await processPendingAssets()
loading.show() loading.show()
try { try {
pageStore.$patch({ pageStore.$patch({
title, 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() await pageStore.pageSave()
notify({ notify({

@ -615,7 +615,8 @@ function branchFrom(version) {
folderPath: '', folderPath: '',
itemId: pageStore.id, itemId: pageStore.id,
itemTitle: version.title, itemTitle: version.title,
itemFileName: pageStore.path itemFileName: pageStore.path,
locale: pageStore.locale
} }
}).onOk(async (target) => { }).onOk(async (target) => {
const full = await withVersion(version) const full = await withVersion(version)

@ -78,6 +78,15 @@ const props = defineProps({
basePath: { basePath: {
type: String, type: String,
default: null 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) { async function create(editor) {
loading.show() loading.show()
emit('newPage') emit('newPage')
await pageStore.pageCreate({ editor, basePath: props.basePath }) await pageStore.pageCreate({ editor, basePath: props.basePath, locale: props.locale })
loading.hide() loading.hide()
} }

@ -1,17 +1,33 @@
<template> <template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide"> <w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card class="page-save-dialog" style="width: 860px; max-width: 90vw"> <w-card class="page-save-dialog" style="width: 860px; max-width: 90vw">
<w-card-section v-if="props.mode === `savePage`" class="card-header"> <!--
<w-icon name="img:/_assets/icons/fluent-save-as.svg" size="sm" class="mr-2" /> One header rather than one per mode: the locale picker sits in it, and three copies of the
<span>{{ t('pageSaveDialog.title') }}</span> same button is three places to keep in step.
</w-card-section> -->
<w-card-section v-else-if="props.mode === `duplicatePage`" class="card-header"> <w-card-section class="card-header">
<w-icon name="img:/_assets/icons/color-documents.svg" size="sm" class="mr-2" /> <w-icon :name="header.icon" size="sm" class="mr-2" />
<span>{{ t('pageDuplicateDialog.title') }}</span> <span>{{ t(header.title) }}</span>
</w-card-section> <w-space />
<w-card-section v-else-if="props.mode === `renamePage`" class="card-header"> <!-- -> Only where there is a choice to make: one active locale is most wikis, and a button
<w-icon name="img:/_assets/icons/fluent-rename.svg" size="sm" class="mr-2" /> that can only say `en` is noise on all of them -->
<span>{{ t('pageRenameDialog.title') }}</span> <w-btn
v-if="siteStore.locales.active.length > 1"
class="acrylic-btn -my-2"
flat
dense
padding="xs md"
color="white"
:label="siteStore.localeAlias(state.locale)"
:aria-label="siteStore.localeAlias(state.locale)">
<w-tooltip>{{ t(`pageSaveDialog.localeHint`) }}</w-tooltip>
<locale-selector-menu
:selected="state.locale"
:navigate="false"
anchor="bottom right"
self="top right"
@select="switchLocale" />
</w-btn>
</w-card-section> </w-card-section>
<div class="page-save-dialog-browser flex flex-nowrap"> <div class="page-save-dialog-browser flex flex-nowrap">
<div class="page-save-dialog-tree w-1/3"> <div class="page-save-dialog-tree w-1/3">
@ -151,6 +167,7 @@ import slugify from 'slugify'
import fileTypes from '../helpers/fileTypes' import fileTypes from '../helpers/fileTypes'
import FolderCreateDialog from '@/components/FolderCreateDialog.vue' import FolderCreateDialog from '@/components/FolderCreateDialog.vue'
import LocaleSelectorMenu from '@/components/LocaleSelectorMenu.vue'
import Tree from '@/components/TreeNav.vue' import Tree from '@/components/TreeNav.vue'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -184,6 +201,16 @@ const props = defineProps({
type: String, type: String,
required: false, required: false,
default: '' default: ''
},
/**
* The locale to browse, and where the mode allows it the one the page ends up in. The caller's
* to give: the page view means the page on screen, the file manager means whichever locale it is
* listing. The site's primary when absent.
*/
locale: {
type: String,
required: false,
default: null
} }
}) })
@ -207,6 +234,7 @@ const { t } = useI18n()
const state = reactive({ const state = reactive({
displayMode: 'title', displayMode: 'title',
locale: props.locale || siteStore.locales.primary,
currentFolderId: null, currentFolderId: null,
currentFileId: null, currentFileId: null,
isFetching: false, isFetching: false,
@ -225,6 +253,20 @@ const treeComp = ref(null)
// COMPUTED // COMPUTED
const header = computed(() => {
switch (props.mode) {
case 'duplicatePage': {
return { icon: 'img:/_assets/icons/color-documents.svg', title: 'pageDuplicateDialog.title' }
}
case 'renamePage': {
return { icon: 'img:/_assets/icons/fluent-rename.svg', title: 'pageRenameDialog.title' }
}
default: {
return { icon: 'img:/_assets/icons/fluent-save-as.svg', title: 'pageSaveDialog.title' }
}
}
})
const currentFolderPath = computed(() => { const currentFolderPath = computed(() => {
const folderNode = state.currentFolderId ? state.treeNodes[state.currentFolderId] : null const folderNode = state.currentFolderId ? state.treeNodes[state.currentFolderId] : null
if (!folderNode?.fileName) { if (!folderNode?.fileName) {
@ -300,6 +342,7 @@ async function save() {
} }
onDialogOK({ onDialogOK({
title: state.title.trim(), title: state.title.trim(),
locale: state.locale,
path: path:
currentFolderPath.value.length > 1 currentFolderPath.value.length > 1
? `${currentFolderPath.value.substring(1)}${state.path}` ? `${currentFolderPath.value.substring(1)}${state.path}`
@ -307,6 +350,26 @@ async function save() {
}) })
} }
/**
* Browse another locale.
*
* Back to its root: the folder that was open belongs to the tree being left, and a folder id from it
* would list somebody else's contents under this locale's name.
*/
async function switchLocale(locale) {
if (locale === state.locale) {
return
}
state.locale = locale
state.treeNodes = {}
state.treeRoots = []
state.currentFolderId = null
state.currentFileId = null
state.fileList = []
treeComp.value?.resetLoaded()
await loadTree({ initLoad: true })
}
async function treeLazyLoad(nodeId, isCurrent, { done }) { async function treeLazyLoad(nodeId, isCurrent, { done }) {
await loadTree({ parentId: nodeId }) await loadTree({ parentId: nodeId })
done() done()
@ -335,6 +398,7 @@ async function loadTree({ parentId = null, parentPath = null, initLoad = false }
try { try {
const items = await API_CLIENT.get(`sites/${siteStore.id}/tree`, { const items = await API_CLIENT.get(`sites/${siteStore.id}/tree`, {
searchParams: { searchParams: {
locale: state.locale,
...(parentId ? { parentId } : {}), ...(parentId ? { parentId } : {}),
...(parentPath ? { parentPath } : {}), ...(parentPath ? { parentPath } : {}),
...(state.typesToFetch?.length > 0 ? { types: state.typesToFetch.join(',') } : {}), ...(state.typesToFetch?.length > 0 ? { types: state.typesToFetch.join(',') } : {}),
@ -448,7 +512,8 @@ function newFolder(parentId) {
dialog({ dialog({
component: FolderCreateDialog, component: FolderCreateDialog,
componentProps: { componentProps: {
parentId parentId,
locale: state.locale
} }
}).onOk(() => { }).onOk(() => {
loadTree({ parentId }) loadTree({ parentId })

@ -2,9 +2,11 @@
<ul class="treeview-level"> <ul class="treeview-level">
<!-- ROOT NODE --> <!-- ROOT NODE -->
<li class="treeview-node" v-if="!props.parentId"> <li class="treeview-node" v-if="!props.parentId">
<div class="treeview-label" @click="setRoot" :class='{ "active": !selection }'> <div class="treeview-label" @click="setRoot" :class="{ active: !selection }">
<w-icon name="img:/_assets/icons/fluent-ftp.svg" size="sm" /> <w-icon name="img:/_assets/icons/fluent-ftp.svg" size="sm" />
<div class="treeview-label-text" :class="dark.isActive ? `text-purple-4` : `text-purple`">root</div> <div class="treeview-label-text" :class="dark.isActive ? `text-purple-4` : `text-purple`">
root
</div>
<w-menu <w-menu
v-if="rootContextActionList.length > 0" v-if="rootContextActionList.length > 0"
touch-position touch-position
@ -13,7 +15,7 @@
transition-show="jump-down" transition-show="jump-down"
transition-hide="jump-up"> transition-hide="jump-up">
<w-card class="p-2"> <w-card class="p-2">
<w-list dense style="min-width: 150px;"> <w-list dense style="min-width: 150px">
<w-item <w-item
v-for="action of rootContextActionList" v-for="action of rootContextActionList"
:key="action.key" :key="action.key"
@ -22,7 +24,9 @@
<w-item-section side> <w-item-section side>
<w-icon :name="action.icon" :color="action.iconColor" /> <w-icon :name="action.icon" :color="action.iconColor" />
</w-item-section> </w-item-section>
<w-item-section :class="action.labelColor && (`text-` + action.labelColor)">{{action.label}}</w-item-section> <w-item-section :class="action.labelColor && `text-` + action.labelColor">{{
action.label
}}</w-item-section>
</w-item> </w-item>
</w-list> </w-list>
</w-card> </w-card>
@ -63,7 +67,6 @@ const props = defineProps({
} }
}) })
// INJECT // INJECT
const roots = inject('roots') const roots = inject('roots')
@ -90,14 +93,14 @@ const level = computed(() => {
for (const root of roots.value) { for (const root of roots.value) {
items.push({ items.push({
id: root, id: root,
...nodes[root] ...nodes.value[root]
}) })
} }
} else { } else {
for (const node of nodes[props.parentId].children) { for (const node of nodes.value[props.parentId]?.children ?? []) {
items.push({ items.push({
id: node, id: node,
...nodes[node] ...nodes.value[node]
}) })
} }
} }

@ -133,7 +133,13 @@ function resetLoaded() {
// PROVIDE // PROVIDE
provide('roots', toRef(props, 'roots')) provide('roots', toRef(props, 'roots'))
provide('nodes', props.nodes) /*
A ref, not `props.nodes`. Providing the object itself hands the levels below the map as it stood at
setup, so a parent that REPLACES its map -- which is how starting a tree over reads -- leaves them
looking up new ids in the old one. The rows still appeared, since `roots` was a ref and updated:
each drew its icon and no label at all, `{ ...undefined }` having nothing to spread.
*/
provide('nodes', toRef(props, 'nodes'))
provide('loaded', state.loaded) provide('loaded', state.loaded)
provide('opened', state.opened) provide('opened', state.opened)
provide('displayMode', toRef(props, 'displayMode')) provide('displayMode', toRef(props, 'displayMode'))

@ -95,8 +95,10 @@ async function createHomePage(editor) {
siteStore.overlay = '' siteStore.overlay = ''
try { try {
await pageStore.pageCreate({ await pageStore.pageCreate({
// -> No locale: the one being written is the one the reader is looking at, which the store
// already holds. Pinning it to the site's primary meant that arriving at `/fr` with no
// French home page yet offered to create one and then tried to write the English one
editor, editor,
locale: siteStore.locales.primary,
path: 'home', path: 'home',
title: t('welcome.homeDefault.title'), title: t('welcome.homeDefault.title'),
description: t('welcome.homeDefault.description'), description: t('welcome.homeDefault.description'),

@ -38,3 +38,26 @@ export function stripPageExtension(urlPath, extensions) {
} }
return urlPath.slice(0, dot) 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. The server redirects a request that reaches it, but a link inside a page is
* followed by the router alone, so this mirrors `splitLocalePath` in the backend's `helpers/common.ts`
* and the two have to read a path the same way.
*
* @param prefixes Map of short code to the locale it names, from `siteStore.localePrefixes`
* @returns The locale and the path below it, or null when no segment names a locale
*/
export function splitLocalePath(urlPath, prefixes) {
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) }
}

@ -30,7 +30,8 @@
flat flat
dense dense
icon="la:language" icon="la:language"
:label="commonStore.locale" :label="siteStore.localeAlias(commonStore.locale)"
:aria-label="siteStore.localeAlias(commonStore.locale)"
color="grey-4"> color="grey-4">
<w-menu <w-menu
content-class="translucent-menu" content-class="translucent-menu"
@ -55,8 +56,7 @@
</w-avatar> </w-avatar>
</w-item-section> </w-item-section>
<w-item-section> <w-item-section>
<w-item-label>{{ lang.nativeName }}</w-item-label> <w-item-label>{{ lang.displayName }}</w-item-label>
<w-item-label caption>{{ lang.name }}</w-item-label>
</w-item-section> </w-item-section>
</w-item> </w-item>
</w-list> </w-list>

@ -59,8 +59,8 @@
flat flat
dense dense
icon="la:globe" icon="la:globe"
:label="commonStore.locale" :label="siteStore.localeAlias(commonStore.locale)"
:aria-label="commonStore.locale" :aria-label="siteStore.localeAlias(commonStore.locale)"
size="sm"> size="sm">
<locale-selector-menu :offset="[-5, 5]" /> <locale-selector-menu :offset="[-5, 5]" />
</w-btn> </w-btn>

@ -11,13 +11,23 @@
</div> </div>
</div> </div>
<div class="flex-none flex"> <div class="flex-none flex">
<w-btn
class="mr-2 acrylic-btn"
flat
icon="la:cloud-download-alt"
color="purple"
:label="t(`admin.locale.fetch`)"
@click="fetchLocales">
<w-tooltip>{{ t(`admin.locale.fetchHint`) }}</w-tooltip>
</w-btn>
<w-separator class="mr-2" vertical />
<w-btn <w-btn
class="mr-2 acrylic-btn" class="mr-2 acrylic-btn"
icon="la:question-circle" icon="la:question-circle"
flat flat
color="grey" color="grey"
:aria-label="t(`common.actions.viewDocs`)" :aria-label="t(`common.actions.viewDocs`)"
:href="siteStore.docsBase + `/admin/localisation`" :href="siteStore.docsBase + `/admin/locale`"
target="_blank"> target="_blank">
<w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip> <w-tooltip>{{ t(`common.actions.viewDocs`) }}</w-tooltip>
</w-btn> </w-btn>
@ -58,7 +68,7 @@
<w-select <w-select
outlined outlined
v-model="state.primary" v-model="state.primary"
:options="state.locales" :options="primaryOptions"
option-value="code" option-value="code"
option-label="name" option-label="name"
emit-value emit-value
@ -98,30 +108,55 @@
<w-card class="pb-2 mt-4"> <w-card class="pb-2 mt-4">
<w-card-header> <w-card-header>
{{ t('admin.locale.active') }} {{ t('admin.locale.active') }}
<template #hint>Select the locales that can be used on this site.</template> <template #hint>{{ t('admin.locale.activeHint') }}</template>
</w-card-header> </w-card-header>
<w-item <template v-for="(lc, idx) of orderedLocales" :key="lc.code">
v-for="lc of state.locales" <w-separator v-if="idx === dividerIndex" class="my-2" inset />
:key="lc.code" <!-- -> Only an installed row is a label: there is no toggle behind the Install button
:tag="lc.code !== state.selectedLocale ? `label` : null"> for the click to be forwarded to -->
<blueprint-icon :text="lc.language" /> <w-item :tag="lc.isInstalled ? `label` : null">
<w-item-section> <blueprint-icon :text="lc.language" />
<w-item-label>{{ lc.nativeName }}</w-item-label> <w-item-section>
<w-item-label caption>{{ lc.name }} ({{ lc.code }})</w-item-label> <w-item-label>{{ lc.name }}</w-item-label>
</w-item-section> <w-item-label caption>{{ lc.nativeName }} ({{ lc.displayCode }})</w-item-label>
<w-item-section avatar> </w-item-section>
<w-toggle <w-item-section v-if="lc.isInstalled" side>
:disable="lc.code === state.primary" <w-btn
v-model="state.active" flat
:val="lc.code" dense
:aria-label="lc.name" /> color="grey"
</w-item-section> icon="la:pen"
</w-item> :aria-label="t(`admin.locale.editAliases`)"
@click="editAliases(lc)">
<w-tooltip>{{ t(`admin.locale.editAliases`) }}</w-tooltip>
</w-btn>
</w-item-section>
<w-item-section avatar>
<w-toggle
v-if="lc.isInstalled"
:disable="lc.code === state.primary"
v-model="state.active"
:val="lc.code"
:aria-label="lc.name" />
<w-btn
v-else
flat
color="primary"
icon="la:download"
:label="t(`admin.locale.install`)"
:loading="state.installing === lc.code"
:disabled="state.loading > 0 || Boolean(state.installing)"
@click="install(lc.code)" />
</w-item-section>
</w-item>
</template>
</w-card> </w-card>
</div> </div>
<div class="col-span-12 lg:col-span-5"> <div class="col-span-12 lg:col-span-5">
<div class="p-4 text-center"> <div class="p-4">
<img src="/_assets/illustrations/undraw_world.svg" style="width: 80%" /> <!-- -> `mx-auto`, not the `text-center` that was here: preflight makes an img a block, so
centring it is a margin question rather than a text-align one -->
<img src="/_assets/illustrations/undraw_world.svg" class="mx-auto" style="width: 80%" />
</div> </div>
</div> </div>
</div> </div>
@ -130,13 +165,18 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { onMounted, reactive, watch } from 'vue' import { computed, onMounted, reactive, watch } from 'vue'
import { useDark } from '@/composables/dark' import { useDark } from '@/composables/dark'
import { dialog } from '@/composables/dialog'
import { useMeta } from '@/composables/meta' import { useMeta } from '@/composables/meta'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import LocaleAliasesDialog from '@/components/LocaleAliasesDialog.vue'
import LocaleFetchDialog from '@/components/LocaleFetchDialog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
import { useAdminStore } from '@/stores/admin' import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site' import { useSiteStore } from '@/stores/site'
@ -165,6 +205,7 @@ useMeta({
const state = reactive({ const state = reactive({
loading: 0, loading: 0,
installing: null,
locales: [], locales: [],
primary: 'en', primary: 'en',
forcePrefix: false, forcePrefix: false,
@ -172,6 +213,29 @@ const state = reactive({
active: [] active: []
}) })
// COMPUTED
// -> Installed first, so that the handful of locales this site can actually use is not buried among
// the fifty-odd it merely could install. Each half keeps the alphabetical order `load` sorted it
// into.
// -> A locale can only be the fallback for pages this site actually serves, so the choice is the
// active ones which are installed by definition, since only an installed locale may be activated
const primaryOptions = computed(() =>
state.locales.filter((lc) => lc.isInstalled && state.active.includes(lc.code))
)
const orderedLocales = computed(() => [
...state.locales.filter((lc) => lc.isInstalled),
...state.locales.filter((lc) => !lc.isInstalled)
])
// -> The row the divider sits above; -1 when one of the two halves is empty and there is nothing to
// divide
const dividerIndex = computed(() => {
const idx = orderedLocales.value.findIndex((lc) => !lc.isInstalled)
return idx > 0 ? idx : -1
})
// WATCHERS // WATCHERS
watch( watch(
@ -200,7 +264,7 @@ async function load() {
API_CLIENT.get('locales').json(), API_CLIENT.get('locales').json(),
API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json() API_CLIENT.get(`sites/${adminStore.currentSiteId}?strict=true`).json()
]) ])
state.locales = sortBy(locales ?? [], ['nativeName', 'name']) state.locales = sortBy(locales ?? [], ['name', 'nativeName'])
state.primary = site?.locales?.primary ?? 'en' state.primary = site?.locales?.primary ?? 'en'
state.forcePrefix = site?.locales?.forcePrefix ?? false state.forcePrefix = site?.locales?.forcePrefix ?? false
state.showMenu = site?.locales?.showMenu ?? true state.showMenu = site?.locales?.showMenu ?? true
@ -265,6 +329,45 @@ async function save() {
state.loading-- state.loading--
} }
function fetchLocales() {
dialog({
component: LocaleFetchDialog
}).onOk(() => {
load()
})
}
function editAliases(locale) {
dialog({
component: LocaleAliasesDialog,
componentProps: { locale }
}).onOk(() => {
load()
})
}
async function install(code) {
if (state.installing) {
return
}
state.installing = code
try {
await API_CLIENT.post(`locales/${code}/install`)
notify({
type: 'positive',
message: t('admin.locale.installSuccess')
})
await load()
} catch (err) {
notify({
type: 'negative',
message: t('admin.locale.installFailed'),
caption: apiErrorMessage(err)
})
}
state.installing = null
}
// MOUNTED // MOUNTED
onMounted(() => { onMounted(() => {

@ -349,6 +349,7 @@ import { useMinWidth } from '@/composables/screen'
import { notify } from '@/composables/notify' import { notify } from '@/composables/notify'
import { loading } from '@/composables/loading' import { loading } from '@/composables/loading'
import { scrollToAnchor, scrollToAnchorWhenReady } from '@/helpers/anchors' import { scrollToAnchor, scrollToAnchorWhenReady } from '@/helpers/anchors'
import { splitLocalePath } from '@/helpers/pagePaths'
import { enhanceRenderedContent, routableHref, sameDocumentHash } from '@/helpers/renderedContent' import { enhanceRenderedContent, routableHref, sameDocumentHash } from '@/helpers/renderedContent'
import { flattenToc } from '@/helpers/toc' import { flattenToc } from '@/helpers/toc'
@ -659,7 +660,11 @@ watch(
return router.replace('/') return router.replace('/')
} }
loading.show() loading.show()
await pageStore.pageEdit({ path: route.params.pagePath, fromNavigate: true }) await pageStore.pageEdit({
path: route.params.pagePath,
locale: route.query.locale,
fromNavigate: true
})
loading.hide() loading.hide()
return return
} }
@ -669,11 +674,20 @@ watch(
return return
} }
/*
-> Split the locale off the URL. A site that brackets its paths by locale addresses
`notes/one` in French as `/fr/notes/one`, so the page to load is what sits below the prefix.
No prefix means the site's primary locale, which is what the API assumes when none is given.
*/
const localePath = splitLocalePath(newValue, siteStore.localePrefixes)
const pagePath = localePath?.path ?? newValue
const pageLocale = localePath?.locale
// -> Load Page. The contents panel belongs to the page being left, so it goes with it // -> Load Page. The contents panel belongs to the page being left, so it goes with it
state.tocPanelOpen = false state.tocPanelOpen = false
scrollPageToTop() scrollPageToTop()
try { try {
await pageStore.pageLoad({ path: newValue }) await pageStore.pageLoad({ path: pagePath, locale: pageLocale })
if (editorStore.isActive) { if (editorStore.isActive) {
/* /*
Walking away from the editor closes it, and `mode` describes the editor that was open so Walking away from the editor closes it, and `mode` describes the editor that was open so
@ -705,24 +719,31 @@ watch(
}) })
} catch (err) { } catch (err) {
if (err.message === 'ERR_PAGE_NOT_FOUND') { if (err.message === 'ERR_PAGE_NOT_FOUND') {
if (newValue === '/') { if (pagePath === '/') {
if (!userStore.authenticated) { if (!userStore.authenticated) {
router.push('/login') router.push('/login')
} else if (!userStore.can('write:pages')) { } else if (!userStore.can('write:pages')) {
router.replace('/_error/unauthorized') router.replace('/_error/unauthorized')
} else { } else {
/*
The home page that is missing is the one in the locale being viewed, and that is what
the welcome screen's create button writes. This branch draws instead of calling
`pageNotFound`, so it has to say so itself -- left standing, the previous page's locale
sent an arrival at `/fr` off to write the ENGLISH home page, and the save came back 409.
*/
pageStore.$patch({ locale: pageLocale || siteStore.locales.primary })
siteStore.overlay = 'Welcome' siteStore.overlay = 'Welcome'
} }
} else { } else {
// -> Not a notification over the page the reader came from: that page is still on screen // -> Not a notification over the page the reader came from: that page is still on screen
// behind it, at a URL that is not its own. The view draws the missing page instead. // behind it, at a URL that is not its own. The view draws the missing page instead.
pageStore.pageNotFound({ path: newValue }) pageStore.pageNotFound({ path: pagePath, locale: pageLocale })
/* /*
The one place the page permissions have to be asked for on their own: everywhere else they The one place the page permissions have to be asked for on their own: everywhere else they
arrive with the page, and here there is no page to carry them while the screen about to arrive with the page, and here there is no page to carry them while the screen about to
be drawn offers to create one, which is a permission question. be drawn offers to create one, which is a permission question.
*/ */
await userStore.fetchPagePermissions(newValue) await userStore.fetchPagePermissions(pagePath, pageLocale)
} }
} else if (err.message === 'ERR_PAGE_UNAUTHORIZED') { } else if (err.message === 'ERR_PAGE_UNAUTHORIZED') {
// -> `replace`, so the back button leaves the wiki the way it came rather than bouncing off // -> `replace`, so the back button leaves the wiki the way it came rather than bouncing off

@ -59,7 +59,13 @@ export const useAdminStore = defineStore('admin', {
actions: { actions: {
async fetchLocales() { async fetchLocales() {
const resp = await API_CLIENT.get('locales').json() const resp = await API_CLIENT.get('locales').json()
this.locales = sortBy(cloneDeep(resp ?? []), ['nativeName', 'name']) // -> Installed only: everything reading this offers a locale to *use* — the interface language
// menu, a group's page rules — and one with no strings downloaded has nothing to offer.
// The locale admin page fetches the full list itself, since installing is what it is for.
this.locales = sortBy(
cloneDeep(resp ?? []).filter((lc) => lc.isInstalled),
['nativeName', 'name']
)
}, },
async fetchInfo() { async fetchInfo() {
const resp = await API_CLIENT.get('system/info').json() const resp = await API_CLIENT.get('system/info').json()

@ -98,7 +98,7 @@ export const usePageStore = defineStore('page', {
getters: { getters: {
breadcrumbs: (state) => { breadcrumbs: (state) => {
const siteStore = useSiteStore() const siteStore = useSiteStore()
const pathPrefix = siteStore.useLocales ? `/${state.locale}` : '' const pathPrefix = siteStore.localeUrlPrefix(state.locale)
return state.path.split('/').reduce((result, value, key) => { return state.path.split('/').reduce((result, value, key) => {
result.push({ result.push({
id: key, id: key,
@ -124,14 +124,20 @@ export const usePageStore = defineStore('page', {
* what holds it see `PageRedirect.vue` and the screen it lands on offers to follow it. * what holds it see `PageRedirect.vue` and the screen it lands on offers to follow it.
*/ */
editorExitPath: (state) => { editorExitPath: (state) => {
return `/${state.path}${state.editor === 'redirect' ? '?redirect=no' : ''}` // -> Prefixed, on a site that brackets its URLs by locale: an unprefixed path is sent to the
// PRIMARY locale, so leaving the editor on a page just written in another one landed the
// author on the English page instead of the French one they had made
const siteStore = useSiteStore()
return `${siteStore.localeUrlPrefix(state.locale)}/${state.path}${
state.editor === 'redirect' ? '?redirect=no' : ''
}`
} }
}, },
actions: { actions: {
/** /**
* PAGE - LOAD * PAGE - LOAD
*/ */
async pageLoad({ path, id, withContent = false }) { async pageLoad({ path, id, locale, withContent = false }) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const siteStore = useSiteStore() const siteStore = useSiteStore()
/* /*
@ -150,7 +156,9 @@ export const usePageStore = defineStore('page', {
`sites/${siteStore.id}/pages/${id ?? fastHash(normalizePath(path))}`, `sites/${siteStore.id}/pages/${id ?? fastHash(normalizePath(path))}`,
{ {
searchParams: { searchParams: {
withContent withContent,
// -> Absent means the site's primary locale, which is what an unprefixed URL addresses
...(locale && { locale })
} }
} }
).json() ).json()
@ -285,10 +293,15 @@ export const usePageStore = defineStore('page', {
* is actually known, and what the create button goes on to make a page at. * is actually known, and what the create button goes on to make a page at.
* *
* @param {string} path The path that was requested, with or without its leading slash. * @param {string} path The path that was requested, with or without its leading slash.
* @param {string} [locale] The locale it was requested in. A page that does not exist still has
* one it is what a create button started from here writes the page in and leaving the
* previous page's locale standing is how creating the French home page tried to write the
* English one and was refused as a duplicate.
*/ */
pageNotFound({ path }) { pageNotFound({ path, locale }) {
this.$patch({ this.$patch({
id: '', id: '',
locale: locale || this.locale,
path: (path ?? '').replace(/^\/+/, ''), path: (path ?? '').replace(/^\/+/, ''),
title: '', title: '',
description: '', description: '',
@ -424,7 +437,7 @@ export const usePageStore = defineStore('page', {
/** /**
* PAGE - DUPLICATE * PAGE - DUPLICATE
*/ */
async pageDuplicate({ sourcePageId, title, path }) { async pageDuplicate({ sourcePageId, title, path, locale }) {
const siteStore = useSiteStore() const siteStore = useSiteStore()
try { try {
const pageData = await API_CLIENT.get( const pageData = await API_CLIENT.get(
@ -438,6 +451,9 @@ export const usePageStore = defineStore('page', {
editor: pageData.editor, editor: pageData.editor,
title, title,
path, path,
// -> A copy may be made in another locale, which is how a translation starts: the same page
// at the same path, in a locale that does not have it yet
locale,
content: pageData.content, content: pageData.content,
description: pageData.description description: pageData.description
}) })
@ -508,7 +524,7 @@ export const usePageStore = defineStore('page', {
/** /**
* PAGE - EDIT * PAGE - EDIT
*/ */
async pageEdit({ path, id, fromNavigate = false } = {}) { async pageEdit({ path, id, locale, fromNavigate = false } = {}) {
const editorStore = useEditorStore() const editorStore = useEditorStore()
const loadArgs = { const loadArgs = {
@ -519,6 +535,10 @@ export const usePageStore = defineStore('page', {
loadArgs.id = id loadArgs.id = id
} else if (path) { } else if (path) {
loadArgs.path = path loadArgs.path = path
// -> A path only names a page within a locale; absent, the API answers with the primary one
if (locale) {
loadArgs.locale = locale
}
} else { } else {
loadArgs.id = this.id loadArgs.id = this.id
} }
@ -577,20 +597,23 @@ export const usePageStore = defineStore('page', {
/** /**
* PAGE - MOVE * PAGE - MOVE
*/ */
async pageMove({ id, title, path } = {}) { async pageMove({ id, title, path, locale } = {}) {
const siteStore = useSiteStore() const siteStore = useSiteStore()
unwrap( unwrap(
await API_CLIENT.put(`sites/${siteStore.id}/pages/${id}/path`, { await API_CLIENT.put(`sites/${siteStore.id}/pages/${id}/path`, {
json: { json: {
path, path,
...(title ? { title } : {}) ...(title ? { title } : {}),
// -> A move may cross locales, which is the same page translated rather than a new one
...(locale ? { locale } : {})
} }
}).json() }).json()
) )
// -> Following the page only makes sense when it is the one being viewed. Moved from the file // -> Following the page only makes sense when it is the one being viewed. Moved from the file
// manager, it is some other page, and the reader is still on theirs. // manager, it is some other page, and the reader is still on theirs.
if (id === this.id) { if (id === this.id) {
this.router.replace(`/${path}`) this.$patch({ path, ...(locale ? { locale } : {}) })
this.router.replace(this.editorExitPath)
} }
}, },
/** /**

@ -14,19 +14,28 @@ import { useUserStore } from './user'
* were each reading `.code` / `.language` / `.nativeName` off a string, so every one of them rendered * were each reading `.code` / `.language` / `.nativeName` off a string, so every one of them rendered
* blank -- the locale menu showed an empty row rather than "English". * blank -- the locale menu showed an empty row rather than "English".
* *
* Resolved here rather than server-side so the write shape stays a plain list of codes, and with * The descriptors come from the server -- `bootstrap` hands the installed list over with the site,
* `Intl.DisplayNames` rather than a table, which gives the name in the reader's own language for * so it costs no request of its own -- because half of what they say cannot be worked out from a
* free. Asking for a code's name IN that code is what produces the native spelling. * code here: whether the short form is `fr` or `fr-FR` depends on which OTHER locales exist, and an
* administrator can rename either. `Intl` still answers for a code the server did not, which is a
* locale the site has active and the wiki no longer has installed.
*/ */
function describeLocales(codes) { function describeLocales(codes, installed) {
const localized = new Intl.DisplayNames(undefined, { type: 'language' }) const known = new Map((installed ?? []).map((lc) => [lc.code, lc]))
// -> `languageDisplay: 'standard'` names the language first -- "Portuguese (Brazil)" rather than
// Intl's default "Brazilian Portuguese" -- matching how the server names them
const nameOptions = { type: 'language', languageDisplay: 'standard' }
const localized = new Intl.DisplayNames(undefined, nameOptions)
return (codes ?? []).map((code) => { return (codes ?? []).map((code) => {
if (known.has(code)) {
return known.get(code)
}
let name = code let name = code
let nativeName = code let nativeName = code
try { try {
name = localized.of(code) ?? code name = localized.of(code) ?? code
nativeName = new Intl.DisplayNames([code], { type: 'language' }).of(code) ?? code nativeName = new Intl.DisplayNames([code], nameOptions).of(code) ?? code
} catch { } catch {
// -> An unregistered or malformed tag throws rather than returning nothing; show the code // -> An unregistered or malformed tag throws rather than returning nothing; show the code
} }
@ -35,7 +44,9 @@ function describeLocales(codes) {
// -> The bare language, for the two-letter badge beside each entry // -> The bare language, for the two-letter badge beside each entry
language: code.split('-')[0], language: code.split('-')[0],
name, name,
nativeName nativeName,
displayCode: code,
displayName: nativeName
} }
}) })
} }
@ -98,6 +109,8 @@ export const useSiteStore = defineStore('site', {
markdown: false, markdown: false,
wysiwyg: false wysiwyg: false
}, },
/** Every installed locale, as this wiki refers to it. Empty until the app has bootstrapped. */
installedLocales: [],
locales: { locales: {
primary: 'en', primary: 'en',
showMenu: true, showMenu: true,
@ -136,6 +149,32 @@ export const useSiteStore = defineStore('site', {
} }
}), }),
getters: { getters: {
/** How a locale is referred to — `fr` for `fr-FR` — falling back to the code until read. */
localeAlias: (state) => (code) =>
state.installedLocales.find((lc) => lc.code === code)?.displayCode ?? code,
/**
* The segments a locale-prefixed URL 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 404s them has broken them. Mirrors `localePrefixesFor` on the server.
*
* Recognised whatever `forcePrefix` says. That setting decides only whether an UNPREFIXED path is
* sent to the primary locale; a prefix is how any other locale is addressed at all, so a site with
* it off still has to answer `/fr/...`. The cost is that a site with a locale active cannot also
* have a page whose first path segment is that locale's short code.
*/
localePrefixes: (state) => {
const prefixes = new Map()
for (const lc of state.locales.active) {
for (const segment of [lc.displayCode, lc.derivedCode, lc.code]) {
if (segment) {
prefixes.set(segment, lc.code)
}
}
}
return prefixes
},
overlayIsShown: (state) => Boolean(state.overlay), overlayIsShown: (state) => Boolean(state.overlay),
sideNavIsDisabled: (state) => Boolean(state.theme.sidebarPosition === 'off'), sideNavIsDisabled: (state) => Boolean(state.theme.sidebarPosition === 'off'),
scrollStyle: (state) => { scrollStyle: (state) => {
@ -161,8 +200,24 @@ export const useSiteStore = defineStore('site', {
} }
} }
}, },
useLocales: (state) => { /**
return state.locales?.active?.length > 1 * The leading segment a page URL in this locale carries, empty where it needs none.
*
* The one place that answers it: the router reads a prefix back with `localePrefixes`, and
* everything that builds a page URL a breadcrumb, the way out of the editor, the logo, the
* locale selector has to write the same one. The short code, as the prefix and the storage
* folder both are.
*
* A locale that is not the site's primary always carries one, `forcePrefix` or not: there is no
* other way to address it. The primary carries one only when the setting is on, which is what
* that setting is with it off, `/notes/one` is the canonical address of the primary locale's
* page and prefixing it would be noise on the single-locale wikis that are most of them.
*/
localeUrlPrefix() {
return (code) =>
this.locales.forcePrefix || code !== this.locales.primary
? `/${this.localeAlias(code)}`
: ''
} }
}, },
actions: { actions: {
@ -176,10 +231,21 @@ export const useSiteStore = defineStore('site', {
}, },
async loadSite(hostname) { async loadSite(hostname) {
try { try {
const siteInfo = await API_CLIENT.get(`sites/${hostname}`).json() // -> The locale descriptors come alongside rather than after, so the selector's label is
// right on the first paint instead of flicking from `fr-FR` to `fr`
const [siteInfo, locales] = await Promise.all([
API_CLIENT.get(`sites/${hostname}`).json(),
// -> Not worth failing a page load over; `describeLocales` falls back to `Intl`
API_CLIENT.get('locales')
.json()
.catch(() => null)
])
if (!siteInfo) { if (!siteInfo) {
throw new Error('Invalid Site') throw new Error('Invalid Site')
} }
if (locales) {
this.installedLocales = locales.filter((lc) => lc.isInstalled)
}
this.applySiteInfo(siteInfo) this.applySiteInfo(siteInfo)
} catch (err) { } catch (err) {
console.warn(err.message) console.warn(err.message)
@ -223,7 +289,9 @@ export const useSiteStore = defineStore('site', {
locales: { locales: {
...this.locales, ...this.locales,
...siteInfo.locales, ...siteInfo.locales,
active: sortBy(describeLocales(siteInfo.locales.active), ['nativeName', 'name']) active: sortBy(describeLocales(siteInfo.locales.active, this.installedLocales), [
'displayName'
])
}, },
tags: [], tags: [],
tagsLoaded: false, tagsLoaded: false,

@ -179,7 +179,7 @@ export const useUserStore = defineStore('user', {
} }
return false return false
}, },
async fetchPagePermissions(path) { async fetchPagePermissions(path, locale) {
if (path.startsWith('/_')) { if (path.startsWith('/_')) {
this.pagePermissions = [] this.pagePermissions = []
return return
@ -190,7 +190,9 @@ export const useUserStore = defineStore('user', {
`sites/${siteStore.id}/pages/userPermissions`, `sites/${siteStore.id}/pages/userPermissions`,
{ {
json: { json: {
path path,
// -> Absent means the site's primary locale, as it does everywhere a path is resolved
...(locale && { locale })
} }
} }
).json() ).json()

@ -1,13 +0,0 @@
{
"upload": {
"folder": "server/locales",
"files": "en.json",
"type": "json"
},
"download": {
"folder": "server/locales",
"files": "${lang}.json",
"metadataFileJs": "metadata.mjs"
}
}
Loading…
Cancel
Save