feat: add page locale relations + various fixes

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

@ -46,7 +46,7 @@ async function routes(app: FastifyInstance) {
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.',
"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.\n\n`en` is never fetched: it is the locale the interface is written in and ships with the wiki, loaded from `locales/en.json` on every boot. It counts as unchanged.",
tags: ['Locales'],
response: {
200: {
@ -86,7 +86,7 @@ async function routes(app: FastifyInstance) {
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.',
"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.\n\nRefused for `en`, which ships with the wiki and is always installed.",
tags: ['Locales'],
params: {
type: 'object',

@ -413,6 +413,79 @@ async function routes(app: FastifyInstance) {
}
)
/**
* GET LOCALE RELATIONS OF A PAGE, BY PATH
*
* No route-level permissions: this answers about one page, so what governs it is `read:pages` on
* that page rather than anything site-wide.
*/
app.get<{ Params: { siteId: string }; Querystring: { path: string; locale?: string } }>(
'/sites/:siteId/pages/locale-relations',
{
schema: {
summary: 'Get the translation set a page belongs to',
description:
'The pages that are the same page as this one, in other locales, addressed by path rather than by ID — which is what a page picker has in hand.\n\nWhat the page properties panel asks before it accepts a chosen page. A page that is already part of a set answers with the rest of it, so the panel can fill its other rows in and say what set is being joined; a page with no counterparts answers with an empty list. Saving is what actually joins them — see `localeRelations` on `PageInput`.\n\nThe set comes back whole, unfiltered by publish state: an author choosing a translation has to be told about a draft one, or they would be shown an empty row and refused on save.',
tags: ['Pages'],
params: siteIdParam,
querystring: {
type: 'object',
required: ['path'],
properties: {
path: {
type: 'string',
maxLength: 255,
description:
'Slash-separated path of the page to ask about. The home page when empty.'
},
locale: {
type: 'string',
maxLength: 10,
description: "The site's primary locale when absent."
}
}
},
response: {
200: {
description: 'The page, and the rest of its translation set',
type: 'object',
properties: {
page: { $ref: 'PageLocaleRelation#' },
relations: {
type: 'array',
description: 'Every other page of the set. Empty when the page is in none.',
items: { $ref: 'PageLocaleRelation#' }
}
}
}
}
}
},
async (req, reply) => {
const path = normalizePagePath(req.query.path)
const group = await WIKI.models.pages.localeGroupAt(req.params.siteId, {
locale: req.query.locale,
path
})
if (!group) {
return reply.notFound('This page does not exist.')
}
if (!mayOnPage(req, 'read:pages', { path: group.page.path, locale: group.page.locale })) {
return reply.forbidden('You are not allowed to read this page.')
}
return {
page: { locale: group.page.locale, path: group.page.path, title: group.page.title },
/*
Filtered by what the asker may read, one page at a time: the set is a list of pages, and a
page they have no access to is not one to name at them even to explain a refusal.
*/
relations: group.relations.filter((rel) =>
mayOnPage(req, 'read:pages', { path: rel.path, locale: rel.locale })
)
}
}
)
/**
* GET PAGE
*/

@ -92,6 +92,27 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
additionalProperties: true
}
},
localeRelations: {
type: 'array',
description:
"This page's counterparts in other locales — the same page in another language, which is what the locale selector sends a reader to.\n\nThe list states the WHOLE set rather than adding to it: a locale left out has no counterpart, and a page that was in the set and is not listed leaves it. Leave the field out to keep the set as it is.\n\nNaming a page that already belongs to a set joins that set, bringing its other members along. It is refused with a 409 when that set already holds a page for a locale this list speaks for — including this page's own locale, which is the case of a page that is already another page's translation.",
items: {
type: 'object',
required: ['locale', 'path'],
properties: {
locale: {
type: 'string',
maxLength: 10,
description: 'The locale this counterpart is written in.'
},
path: {
type: 'string',
maxLength: 255,
description: 'Path of the page in that locale, without a leading slash.'
}
}
}
},
tags: {
type: 'array',
items: {
@ -171,6 +192,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
type: 'array',
items: { type: 'object', additionalProperties: true }
},
localeRelations: {
type: 'array',
description:
'The same page in other locales, one entry per locale, as far as this requester may see them — a draft translation is not offered to a reader who could not open it. Empty for a page with no counterparts.',
items: { $ref: 'PageLocaleRelation#' }
},
tags: { type: 'array', items: { type: 'string' } },
toc: {
type: 'array',
@ -272,6 +299,29 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
}
})
/**
* PAGE LOCALE RELATION - One page of a translation set: this page, in another language
*/
app.addSchema({
$id: 'PageLocaleRelation',
type: 'object',
properties: {
locale: {
type: 'string',
description: 'The locale this counterpart is written in.'
},
path: {
type: 'string',
description:
"Slash-separated path of the page in that locale, without a leading slash and without the locale's URL prefix."
},
title: {
type: 'string',
description: 'Its title, for a surface that lists the set rather than navigating to it.'
}
}
})
/**
* INCLUDED PAGE - Another page's render, as an include block draws it inside the page being read
*/

@ -519,45 +519,39 @@ export default {
.toString({ smallestUnit: 'millisecond' }),
tz: 'UTC'
})
// -> Add a maximum of 10 future iterations for a single task
let addedFutureJobs = 0
while (true) {
try {
// FIXME: pre-existing bug — cron-parser v5's `next()` returns a `CronDate`, not an
// ES iterator result, so `next.value` and `next.done` below are both `undefined`.
// `next.value.getTime()` therefore throws (swallowed by the `catch { break }`)
// whenever `existingJobs` is non-empty, and `next.done` is never true so the loop
// only ever stops at the 10-iteration cap. Cast to `any` to keep the migration
// behavior-neutral; the fix is `next.getTime()` + `plannedIterations.hasNext()`.
const next = plannedIterations.next() as any
// -> Ensure this iteration isn't already scheduled
if (
!existingJobs.some(
(j: any) =>
j.task === job.task && j.waitUntil.getTime() === next.value.getTime()
)
) {
// FIXME: `useWorker` is not an `addJob` option (it is derived inside `addJob`)
// and `waitUntil` is handed an ISO string rather than a Date. Cast preserves
// the existing call verbatim.
this.addJob({
task: job.task,
useWorker: !(typeof this.tasks![job.task] === 'function'),
payload: job.payload,
isScheduled: true,
waitUntil: next.toISOString(),
notify: false
} as any)
addedFutureJobs++
totalAdded++
}
// -> No more iterations for this period or max iterations count reached
if (next.done || addedFutureJobs >= 10) {
break
}
} catch {
break
/*
At most 6 iterations of a task are queued ahead, and `take` is what stops there --
and stops early where the window above holds fewer, since it gives back what it
reached rather than throwing at the end of it.
The cap is a horizon rather than a quota: an iteration already queued still spends
one of the six, so what is pending for a task never runs further ahead than its next
six runs however often this runs. Counting only the ones added would push that
horizon out on every pass, until a minute-by-minute task had the whole window queued.
*/
for (const iteration of plannedIterations.take(6)) {
const waitUntil = iteration.toDate()
// -> Ensure this iteration isn't already scheduled
if (
existingJobs.some(
(j) => j.task === job.task && j.waitUntil?.getTime() === waitUntil.getTime()
)
) {
continue
}
/*
Awaited, because `totalAdded` is reported as what was scheduled: left to run on its
own, the line below says so before the rows exist, and a failure to insert one is
swallowed inside `addJob` with nothing to correct the count.
*/
await this.addJob({
task: job.task,
payload: job.payload,
isScheduled: true,
waitUntil,
notify: false
})
totalAdded++
}
}
if (totalAdded > 0) {

@ -243,6 +243,7 @@ CREATE TABLE "pageWatching" (
CREATE TABLE "pages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
"locale" varchar(255) NOT NULL,
"localeGroupId" uuid,
"path" varchar(255) NOT NULL,
"hash" varchar(255) NOT NULL,
"alias" varchar(255),
@ -412,6 +413,7 @@ CREATE INDEX "pages_siteId_idx" ON "pages" ("siteId");--> statement-breakpoint
CREATE INDEX "pages_ts_idx" ON "pages" USING gin ("ts");--> statement-breakpoint
CREATE INDEX "pages_tags_idx" ON "pages" USING gin ("tags");--> statement-breakpoint
CREATE INDEX "pages_isSearchableComputed_idx" ON "pages" ("isSearchableComputed");--> statement-breakpoint
CREATE UNIQUE INDEX "pages_localeGroupId_locale_idx" ON "pages" ("localeGroupId","locale");
CREATE INDEX "rateLimits_updatedAt_idx" ON "rateLimits" ("updatedAt");--> statement-breakpoint
CREATE INDEX "sessions_userId_idx" ON "sessions" ("userId");--> statement-breakpoint
CREATE UNIQUE INDEX "storage_composite_idx" ON "storage" ("siteId","module");--> statement-breakpoint
@ -460,4 +462,4 @@ ALTER TABLE "tags" ADD CONSTRAINT "tags_siteId_sites_id_fkey" FOREIGN KEY ("site
ALTER TABLE "tree" ADD CONSTRAINT "tree_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");--> statement-breakpoint
ALTER TABLE "userGroups" ADD CONSTRAINT "userGroups_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "userGroups" ADD CONSTRAINT "userGroups_groupId_groups_id_fkey" FOREIGN KEY ("groupId") REFERENCES "groups"("id") ON DELETE CASCADE;--> statement-breakpoint
ALTER TABLE "userKeys" ADD CONSTRAINT "userKeys_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id");
ALTER TABLE "userKeys" ADD CONSTRAINT "userKeys_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id");

@ -2744,6 +2744,19 @@
"schema": "public",
"table": "pages"
},
{
"type": "uuid",
"typeSchema": null,
"notNull": false,
"dimensions": 0,
"default": null,
"generated": null,
"identity": null,
"name": "localeGroupId",
"entityType": "columns",
"schema": "public",
"table": "pages"
},
{
"type": "text",
"typeSchema": null,
@ -4521,6 +4534,34 @@
"schema": "public",
"table": "pages"
},
{
"nameExplicit": true,
"columns": [
{
"value": "localeGroupId",
"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": "pages_localeGroupId_locale_idx",
"entityType": "indexes",
"schema": "public",
"table": "pages"
},
{
"nameExplicit": true,
"columns": [

@ -366,6 +366,15 @@ export const pages = pgTable(
publishEndDate: timestamp(),
config: jsonb().notNull().default({}),
relations: jsonb().notNull().default([]),
/**
* The set of pages this one is a translation of: every page sharing this id is the same page in
* another locale, and the locale selector uses it to send a reader to the right one.
*
* Null for a page with no counterparts, which is most of them a null is what makes the unique
* index below tolerate any number of unrelated pages, since postgres counts nulls as distinct.
* The group has no row of its own: it is an identity, and its membership IS this column.
*/
localeGroupId: uuid(),
content: text(),
render: text(),
searchContent: text(),
@ -411,7 +420,10 @@ export const pages = pgTable(
index('pages_siteId_idx').on(table.siteId),
index('pages_ts_idx').using('gin', table.ts),
index('pages_tags_idx').using('gin', table.tags),
index('pages_isSearchableComputed_idx').on(table.isSearchableComputed)
index('pages_isSearchableComputed_idx').on(table.isSearchableComputed),
// -> One page per locale in a group, enforced here rather than in the model: a group is edited
// from any of its members, so two saves racing each other are two writers of the same set
uniqueIndex('pages_localeGroupId_locale_idx').on(table.localeGroupId, table.locale)
]
)

@ -1720,6 +1720,18 @@
"editor.emoji.smileysEmotion": "Smileys & Emotion",
"editor.emoji.symbols": "Symbols",
"editor.emoji.travelPlaces": "Travel & Places",
"editor.localeRel.appliesOnSave": "Locale relations are stored when the page is saved.",
"editor.localeRel.checkFailed": "Could not check the existing relations of that page.",
"editor.localeRel.clear": "Remove this relation",
"editor.localeRel.conflictOwnLocale": "{path} is already related to {other}, which is in this pages locale. A page can only belong to one set of locale relations.",
"editor.localeRel.conflictRow": "{path} is already related to {other} for {locale}, which this page relates to a different page. A page can only belong to one set of locale relations.",
"editor.localeRel.intro": "Define the alternate versions of this page in other locales. A reader switching language is taken to it instead of to the same path.",
"editor.localeRel.joinedSet": "Added {count} more page(s) already related to that one.",
"editor.localeRel.notSet": "No related page",
"editor.localeRel.pickerTitle": "Select the {locale} page",
"editor.localeRel.selectPage": "Select Page",
"editor.localeRel.thisPage": "This page",
"editor.localeRel.title": "Locale Relations",
"editor.markup.admonitionDanger": "Caution Admonition",
"editor.markup.admonitionImportant": "Important Admonition",
"editor.markup.admonitionInfo": "Note Admonition",
@ -1831,13 +1843,15 @@
"editor.props.jsLoadHint": "Execute javascript once the page is loaded",
"editor.props.jsUnload": "Javascript - On Unload",
"editor.props.jsUnloadHint": "Execute javascript before the page content is destroyed",
"editor.props.localeRelations": "Set Locale Relations",
"editor.props.localeRelationsHint": "Link this page to the same page in other locales, so a reader switching language lands on it",
"editor.props.pageProperties": "Page Properties",
"editor.props.password": "Password",
"editor.props.passwordHint": "The page must be published and the user must have read access rights.",
"editor.props.publishState": "Publishing State",
"editor.props.published": "Published",
"editor.props.publishedHint": "Visible to all users with read access.",
"editor.props.relationAdd": "Add Relation...",
"editor.props.relationAdd": "Add Page Relation...",
"editor.props.relationAddHint": "Add links to other pages in the footer (e.g. as part of a series of articles)",
"editor.props.relations": "Relations",
"editor.props.requirePassword": "Require Password",

@ -6,6 +6,21 @@ 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'
/**
* The locale this wiki's strings are WRITTEN in, as opposed to translated into.
*
* `locales/en.json` is the source: every string in the interface is added there first, and every
* other locale is a translation of it. That makes it the one locale the copy on disk is more
* authoritative than anything else so it is loaded on every boot rather than only when the file
* looks newer, and it is never fetched from upstream, where it would be whatever was published for
* the last release rather than what this build actually says.
*
* The practical failure this avoids: a string added to `en.json` in a build was invisible to a wiki
* whose `en` row had been touched more recently by an earlier update run, or by an administrator
* naming it leaving the interface showing raw keys for anything new.
*/
const SOURCE_LOCALE = 'en'
/** One entry of the remote `metadata.json`: a strings file and the hash of its contents. */
interface RemoteLocale {
file: string
@ -111,6 +126,10 @@ function resolveDisplayNames(locales: NameableLocale[]) {
* **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.
*
* Two sources fill those rows, and they do not overlap: `locales/en.json` on disk, which is where the
* interface's strings are written, and the published packages every translation comes from. See
* `SOURCE_LOCALE` for why the first is never asked of the second.
*/
class Locales {
/**
@ -125,6 +144,10 @@ class Locales {
* 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.
*
* **`en` is exempt and always loaded**, because it is not a translation of anything see
* `SOURCE_LOCALE`. Only the strings are written, so an administrator's `customName` or `customCode`
* survives the reload.
*/
async refreshFromDisk({ force = false }: { force?: boolean } = {}): Promise<false | void> {
try {
@ -152,13 +175,19 @@ class Locales {
continue
}
// -> Skip a locale that was updated in the DB after the file was last written
/*
Skip a locale that was updated in the DB after the file was last written -- except the
source locale, whose file is the authority on what the interface says and is therefore
loaded every time. Its mtime is not even read: a checkout, a container build or a copy can
leave the shipped file older than a row it must still replace.
*/
const flPath = path.join(localesPath, localeFile)
const flUpdatedAt = (await stat(flPath)).mtime.toTemporalInstant()
const dbLang = dbLocales.find((l) => l.code === code)
if (
dbLang &&
!force &&
code !== SOURCE_LOCALE &&
Temporal.Instant.compare(dbLang.updatedAt.toTemporalInstant(), flUpdatedAt) >= 0
) {
WIKI.logger.info(`Locale ${code} is newer in the DB. Skipping disk version. [ OK ]`)
@ -244,6 +273,12 @@ class Locales {
*
* 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.
*
* **The source locale is left alone entirely** see `SOURCE_LOCALE`. Upstream publishes an `en`
* package like any other, and it holds the strings of whatever release it was built from; taking it
* would overwrite the ones this build ships with, which is how an interface ends up missing the
* strings for its own features. It is counted as unchanged, because from the run's point of view
* there was nothing to do.
*/
async updateFromRemote(): Promise<LocaleUpdateResult> {
WIKI.logger.info('Fetching latest localization data...')
@ -260,6 +295,10 @@ class Locales {
const result: LocaleUpdateResult = { added: 0, updated: 0, unchanged: 0, failed: 0 }
for (const entry of metadata) {
const code = path.basename(entry.file, '.json')
if (code === SOURCE_LOCALE) {
result.unchanged++
continue
}
try {
const localeInfo = localeInfoFor(code)
const dbLang = dbLocales.find((l) => l.code === code)
@ -303,6 +342,11 @@ class Locales {
* hash recorded is the one the downloaded file was published with.
*/
async install(code: string): Promise<void> {
// -> There is nothing to install: it ships with the wiki and is loaded from disk on every boot,
// and downloading over it would replace this build's strings with an older release's
if (code === SOURCE_LOCALE) {
throw new Error(`Locale ${code} ships with the wiki and cannot be downloaded.`)
}
const metadata = await this.fetchRemoteMetadata()
const entry = metadata.find((e) => path.basename(e.file, '.json') === code)
if (!entry) {

@ -1,4 +1,4 @@
import { and, eq, inArray, ne, sql } from 'drizzle-orm'
import { and, eq, inArray, ne, notInArray, sql } from 'drizzle-orm'
import { pages as pagesTable, tree as treeTable, users as usersTable } from '../db/schema.ts'
import {
CustomError,
@ -82,6 +82,31 @@ const CONFIG_FIELDS = [
'tocDepth'
] as const
/**
* One counterpart of a page in another locale: the same page, written in a different language.
*
* A page holds at most one per locale, and the whole set is what `pages.localeGroupId` names. What is
* carried is what a reader being sent there needs the locale to prefix with and the path to go to
* plus the title, which is what an editing surface lists.
*/
export interface PageLocaleRelation {
locale: string
path: string
title: string
}
/**
* A counterpart as it is asked for: a locale and the path of the page in it.
*
* By path rather than by id because that is what a page picker answers with, and because a path is
* what an API client writing a translation set already has in hand. Resolved to a page on the way in
* see `applyLocaleRelations`.
*/
export interface PageLocaleRelationInput {
locale: string
path: string
}
/** A page as the API exposes it: the columns and both blobs, flattened into one object. */
export interface Page {
id: string
@ -108,6 +133,11 @@ export interface Page {
/** Whether the body was withheld because the page is password protected. See `getPage`. */
isLocked: boolean
relations: any[]
/**
* This page in the other locales, as far as the requester may see them. Empty for a page that is
* not part of a locale group, and for one whose whole group is pages this requester may not read.
*/
localeRelations: PageLocaleRelation[]
tags: string[]
toc: TocNode[]
render: string
@ -153,6 +183,13 @@ export interface PageInput {
isSearchable?: boolean
password?: string
relations?: any[]
/**
* The page's counterparts in other locales, stating the whole set rather than adding to it: a locale
* left out of the list is a locale this page has no counterpart in, and a page that was in the group
* and is not in the list leaves it. Absent means "leave the group alone", which is what a save that
* is not about translations sends.
*/
localeRelations?: PageLocaleRelationInput[]
tags?: string[]
allowComments?: boolean
allowContributions?: boolean
@ -314,6 +351,9 @@ class Pages {
...(withPassword ? { password: row.password } : {}),
isLocked: locked,
relations: locked ? [] : (row.relations ?? []),
// -> Not withheld from a locked page: which languages a page exists in is not what a password
// covers, and the lock screen is where a reader most needs to be able to switch to one
localeRelations: row.localeRelations ?? [],
tags: row.tags ?? [],
toc: locked ? [] : (row.toc ?? []),
render: locked ? '' : (row.render ?? ''),
@ -339,6 +379,295 @@ class Pages {
}
}
/**
* The other pages of a locale group: this page, written in every other language it exists in.
*
* Answered from the group id the page already carries, so a page that is not part of one costs no
* query at all which is nearly every page on nearly every site.
*
* `publicOnly` narrows it the same way it narrows the page itself: a draft translation is not
* something to offer a reader who could not open it. It is what makes the locale selector's list of
* languages the list of languages this reader can actually reach.
*/
async localeRelationsFor(
siteId: string,
{
localeGroupId,
id,
publicOnly = false
}: { localeGroupId: string | null; id: string; publicOnly?: boolean }
): Promise<PageLocaleRelation[]> {
if (!localeGroupId) {
return []
}
const conditions = [
eq(pagesTable.siteId, siteId),
eq(pagesTable.localeGroupId, localeGroupId),
ne(pagesTable.id, id)
]
if (publicOnly) {
conditions.push(eq(pagesTable.publishState, 'published'))
}
return await WIKI.db
.select({ locale: pagesTable.locale, path: pagesTable.path, title: pagesTable.title })
.from(pagesTable)
.where(and(...conditions))
.orderBy(pagesTable.locale)
}
/**
* The locale group a page addressed by path belongs to, for a client about to join it.
*
* What the page properties panel asks before it accepts a chosen page: a page that is already the
* French version of something comes back with the rest of that set, which is what the panel fills
* its other rows in from and what tells it, before anything is saved, that the set already has a
* page for a locale it has spoken for.
*
* @returns The whole group INCLUDING the page asked about, or null when there is no page there.
* A page with no counterparts answers with just itself.
*/
async localeGroupAt(
siteId: string,
{ locale, path }: { locale?: string; path: string }
): Promise<{
page: PageLocaleRelation & { id: string }
relations: PageLocaleRelation[]
} | null> {
// -> The site's own default when the caller did not say, as everything else addressing a page by
// path does: a path alone names the primary locale's page
const page = await this.findByPath(siteId, locale || this.defaultLocale(siteId), path)
if (!page) {
return null
}
return {
page: { id: page.id, locale: page.locale, path: page.path, title: page.title },
relations: await this.localeRelationsFor(siteId, {
localeGroupId: page.localeGroupId,
id: page.id
})
}
}
/** One page of a site, by the locale and path that address it. */
private async findByPath(
siteId: string,
locale: string,
path: string
): Promise<{
id: string
locale: string
path: string
title: string
localeGroupId: string | null
} | null> {
const rows = await WIKI.db
.select({
id: pagesTable.id,
locale: pagesTable.locale,
path: pagesTable.path,
title: pagesTable.title,
localeGroupId: pagesTable.localeGroupId
})
.from(pagesTable)
.where(
and(
eq(pagesTable.siteId, siteId),
eq(pagesTable.locale, locale),
// -> By hash, which is the indexed way a page is addressed by path everywhere else here
eq(pagesTable.hash, generatePathHash(path || 'home'))
)
)
.limit(1)
return rows[0] ?? null
}
/**
* Set the locale group a page belongs to: which pages are this page in another language.
*
* **The list states the whole group, not this page's half of it.** The panel that sends it shows
* every active locale with the page filling that slot, so a locale left empty is a locale the set
* has no page for and a page that was in the group and is not in the list leaves it. There is one
* group per set of translations and any member edits it, which is what makes "the same page in
* French" mean the same thing read from either side.
*
* **Picking a page that already belongs to a set joins that set**, bringing its other members with
* it: attaching an English page to an existing French/German pair is how the third language gets
* added, and requiring the pair to be broken up first would be a worse way to say it. The client is
* expected to have asked `localeGroupAt` and shown the author what they are joining.
*
* Which leaves one thing that cannot be reconciled and is refused: a set being joined that already
* holds a page for a locale this request speaks for most importantly for the saving page's OWN
* locale, which is the case of a French page that is already some other English page's translation.
*
* @param page The page being saved, as it stands in the database.
* @param wanted Its counterparts. Empty dissolves the group, leaving every member unrelated.
*/
private async applyLocaleRelations(
siteId: string,
page: { id: string; locale: string; localeGroupId: string | null },
wanted: PageLocaleRelationInput[]
): Promise<void> {
/*
The group as it will stand, by locale. Seeded with the saving page, which occupies its own
locale's slot and is why nothing else may claim it.
*/
const desired = new Map<string, { id: string; path: string; localeGroupId: string | null }>()
desired.set(page.locale, { id: page.id, path: '', localeGroupId: page.localeGroupId })
for (const entry of wanted ?? []) {
const locale = (entry?.locale ?? '').trim()
// -> A page is its own entry for its own locale, so a row naming it is agreement rather than an
// instruction. The panel sends that row read-only for exactly this reason.
if (!locale || locale === page.locale) {
continue
}
const path = normalizePagePath(entry?.path ?? '')
const target = await this.findByPath(siteId, locale, path)
if (!target) {
throw new CustomError(
'pageLocaleRelationNotFound',
`There is no page at "${path}" in locale ${locale} to relate this page to.`
)
}
if (target.id === page.id) {
continue
}
const claimed = desired.get(locale)
if (claimed && claimed.id !== target.id) {
throw new CustomError(
'pageLocaleRelationDuplicate',
`Two different pages were given for locale ${locale}. A page has one counterpart per locale.`
)
}
desired.set(locale, { id: target.id, path: target.path, localeGroupId: target.localeGroupId })
}
/*
Every group this touches: the one the page is in, and the one behind each page it names. They are
about to become one group, or where the page is leaving none.
Held against the page that brought each one in, so a set that cannot be joined is refused by
naming the page the author actually chose rather than some third page they have never heard of.
Empty for the group this page is already in, which nobody chose.
*/
const involved = new Map<string, string>()
if (page.localeGroupId) {
involved.set(page.localeGroupId, '')
}
for (const member of desired.values()) {
if (member.localeGroupId && !involved.has(member.localeGroupId)) {
involved.set(member.localeGroupId, member.path)
}
}
for (const [groupId, chosenPath] of involved) {
const members = await WIKI.db
.select({
id: pagesTable.id,
locale: pagesTable.locale,
path: pagesTable.path,
localeGroupId: pagesTable.localeGroupId
})
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.localeGroupId, groupId)))
for (const member of members) {
const claimed = desired.get(member.locale)
if (claimed?.id === member.id) {
continue
}
if (groupId === page.localeGroupId) {
// -> A member of this page's own group that the request does not name: the author took it
// out of the set, or pointed that locale at a different page, so it is dropped below
continue
}
if (claimed) {
throw new CustomError(
'pageLocaleRelationConflict',
`"${chosenPath}" already belongs to a set of translations whose ${member.locale} page is "${member.path}". A page can belong to only one set.`,
409
)
}
// -> The rest of the set being joined comes along: these pages and this one are the same page
desired.set(member.locale, {
id: member.id,
path: member.path,
localeGroupId: member.localeGroupId
})
}
}
/*
A group of one is no group. Written as null rather than left standing so that "this page has no
counterparts" is one state in the database instead of two, and so the unique index above never
has to hold a row nothing else can join.
*/
const dissolve = desired.size < 2
const groupId = dissolve
? null
: (page.localeGroupId ??
[...desired.values()].find((m) => m.localeGroupId)?.localeGroupId ??
crypto.randomUUID())
const memberIds = [...desired.values()].map((m) => m.id)
// -> Everything that was in one of these groups and is not in the set any more, first: the unique
// index is on (group, locale), so a page has to vacate a slot before another can take it
const evicted = [...involved.keys()]
if (evicted.length > 0) {
await WIKI.db
.update(pagesTable)
.set({ localeGroupId: null })
.where(
and(
eq(pagesTable.siteId, siteId),
inArray(pagesTable.localeGroupId, evicted),
memberIds.length > 0 && !dissolve ? notInArray(pagesTable.id, memberIds) : sql`true`
)
)
}
if (!dissolve) {
await WIKI.db
.update(pagesTable)
.set({ localeGroupId: groupId })
.where(and(eq(pagesTable.siteId, siteId), inArray(pagesTable.id, memberIds)))
}
}
/**
* Take one page out of its locale group, leaving the rest of the set related to each other.
*
* For a page that stops being the version it was: a move across locales, where what it is the
* translation OF is no longer a question this group answers. Distinct from setting the relations to
* nothing, which is the group being dissolved by whoever was editing it.
*/
private async detachFromLocaleGroup(siteId: string, id: string): Promise<void> {
const rows = await WIKI.db
.select({ localeGroupId: pagesTable.localeGroupId })
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.id, id)))
.limit(1)
const groupId = rows[0]?.localeGroupId
if (!groupId) {
return
}
await WIKI.db
.update(pagesTable)
.set({ localeGroupId: null })
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.id, id)))
// -> And the group goes with it if this was the last relation anybody had, for the same reason
// `applyLocaleRelations` never writes a group of one
const remaining = await WIKI.db
.select({ id: pagesTable.id })
.from(pagesTable)
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.localeGroupId, groupId)))
if (remaining.length < 2) {
await WIKI.db
.update(pagesTable)
.set({ localeGroupId: null })
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.localeGroupId, groupId)))
}
}
/**
* A single page, by ID or by the hash of its path.
*
@ -423,7 +752,13 @@ class Pages {
...row.page,
authorName: row.authorName,
navigationId: row.navigationId,
navigationMode: row.navigationMode
navigationMode: row.navigationMode,
// -> A second query only for a page that is part of a set, which is a column read away
localeRelations: await this.localeRelationsFor(siteId, {
localeGroupId: row.page.localeGroupId,
id: row.page.id,
publicOnly
})
},
{ withContent, withPassword, locked: Boolean(row.page.password) && !isUnlocked }
)
@ -583,6 +918,18 @@ class Pages {
const page = inserted[0]
try {
/*
Before the tree entry, so that both live inside the same rollback: a translation set that
cannot be joined -- the French page is already somebody else's French version -- has to refuse
the whole save, and an author told their page was not created must not find it created.
*/
if (input.localeRelations !== undefined) {
await this.applyLocaleRelations(
siteId,
{ id: page.id, locale, localeGroupId: null },
input.localeRelations
)
}
await WIKI.models.tree.addPage({
id: page.id,
parentPath: pathParts.slice(0, -1).join('/'),
@ -643,6 +990,19 @@ class Pages {
return null
}
/*
First, before a single column is written: joining a set can be refused, and a save that is going
to fail has to fail before it has changed the page. The two are independent otherwise -- a locale
group is not a field of the page it relates.
*/
if (patch.localeRelations !== undefined) {
await this.applyLocaleRelations(
siteId,
{ id: existing.id, locale: existing.locale, localeGroupId: existing.localeGroupId },
patch.localeRelations
)
}
const values: Record<string, any> = { updatedAt: sql`now()` }
let treeTitle: string | null = null
// -> Which editor authored a page is not something a save may change, so the row is the authority
@ -835,6 +1195,16 @@ class Pages {
})
}
/*
A page that changes locale stops being the version it was, so it leaves its translation set --
and has to, before the write: the set holds one page per locale, and the page arriving in a
locale another member already covers is a pair the unique index would refuse. The rest of the
set stays related to each other.
*/
if (newLocale !== page.locale) {
await this.detachFromLocaleGroup(siteId, id)
}
await WIKI.db
.update(pagesTable)
.set({

@ -338,6 +338,7 @@ import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/compo
import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { useMinWidth } from '@/composables/screen'
import { isVisible } from '@/helpers/anchors'
import { assetPath } from '@/helpers/assets'
import { blockMarkdown } from '@/helpers/blocks'
import { blockOpeningLine, blockValues, findBlocks } from '@/helpers/markdownBlocks'
@ -1085,29 +1086,80 @@ function markDisabledBlock(el) {
}
/**
* Open the tabset panel the caret is in.
* Open the tabset panel the caret is in, and every panel that one sits inside.
*
* Which is the useful answer, and a different question from "which panel was open before": an author
* writing inside the second panel of a tabset is telling us plainly which one they are looking at. The
* source line is matched against the panel ranges of the same parse that built the preview -- see
* `getTabAtLine` -- and the panel is opened through the block's own `active` property.
* `getTabsAtLine` -- and each panel is opened through its block's own `active` property.
*
* Silent about everything it does not find: a caret outside every tabset leaves them all as they were,
* and so does a render that has not landed yet.
*
* @returns A promise settling once the blocks have drawn the panels. A block applies which panel is
* open on its own update, so a caller about to scroll to something inside one has to wait:
* until then the panel is still `display: none` and there is no box to scroll to.
*/
function syncPreviewTabs() {
const container = editorPreviewContainerRef.value
if (!container) {
return
return Promise.resolve()
}
const at = md.getTabAtLine(editor.getPosition().lineNumber)
if (!at) {
return
const tabsets = container.querySelectorAll('block-tabs')
const drawn = []
for (const at of md.getTabsAtLine(editor.getPosition().lineNumber)) {
const tabset = tabsets[at.tabset]
if (tabset) {
tabset.active = at.tab
// -> Absent until the component has been fetched and the element upgraded; setting `active` on
// one that has not been is still worth doing, and is why `processContent` asks again later
drawn.push(tabset.updateComplete ?? Promise.resolve())
}
}
const tabset = container.querySelectorAll('block-tabs')[at.tabset]
if (tabset) {
tabset.active = at.tab
return Promise.all(drawn)
}
/**
* The element in the preview to scroll to for a source line.
*
* Read off the DOM rather than from a map collected during the render, because what matters is what
* was actually drawn and where it ended up: a token can be parsed and then not rendered (a tight
* list's paragraphs), and a footnote's definition is rendered at the foot of the page rather than on
* the line it was written on. Lines are compared as numbers for the same reason -- the greatest line
* at or before the caret, wherever in the page its element happens to sit.
*
* Two things narrow the search, and both are about tabs:
*
* - **Scoped to the panel the line is in**, where it is in one. Every panel of a tabset covers the
* same lines of the preview, and the panels that are closed hold most of the page's lines -- so a
* search across the whole preview would answer a line inside a panel with an element outside the
* tabset entirely, and scrolling there is what took the author away from what they were writing.
* - **Only what has a box.** A closed panel's content is stamped with its lines like everything else
* and cannot be scrolled to; picking it would mean scrolling nowhere at all, having passed over the
* element that could have been reached.
*
* The panel itself stands in when nothing inside it carries a line -- a lone paragraph, a table, a
* list -- which puts the top of the tabset in view, and is as close as there is to get.
*/
function previewAnchorFor(container, line) {
// -> The last of the chain is the panel the line is written in; the ones before it are its ancestors,
// which `syncPreviewTabs` has already opened
const at = md.getTabsAtLine(line).at(-1)
const tabset = at ? container.querySelectorAll('block-tabs')[at.tabset] : null
const panel = tabset?.querySelectorAll(':scope > block-tab')[at.tab] ?? null
let best = null
let bestLine = 0
for (const el of (panel ?? container).querySelectorAll('[data-line]')) {
const elLine = Number(el.dataset.line)
// -> Strictly greater, so that of two elements starting on the same line -- a blockquote and the
// paragraph opening it -- the outer one wins, which is the one whose top is the section's top
if (elLine <= line && elLine > bestLine && isVisible(el)) {
best = el
bestLine = elLine
}
}
return best ?? panel
}
function processContent(newContent) {
@ -1496,33 +1548,28 @@ onMounted(async () => {
// -> Handle cursor movement
editor.onDidChangeCursorPosition(
debounce((ev) => {
debounce(async (ev) => {
if (!state.previewScrollSync || !state.previewShown) {
return
}
// -> Moving the caret into another panel opens it, the same as typing in one does
syncPreviewTabs()
/*
Moving the caret into another panel opens it, the same as typing in one does -- and is AWAITED,
because until the block has drawn it the panel is still `display: none`, and everything below
aims at an element inside it. Scrolling to something with no box does nothing whatsoever, which
is what made the sync appear to ignore a caret inside a tab that was not already open.
*/
await syncPreviewTabs()
// -> Read again rather than reused from before the await: the caret is where it is now
const currentLine = editor.getPosition().lineNumber
const container = editorPreviewContainerRef.value
if (!container) {
return
}
if (currentLine < 3) {
editorPreviewContainerRef.value.scrollTo({ top: 0, behavior: 'smooth' })
} else {
const exactEl = editorPreviewContainerRef.value.querySelector(
`[data-line='${currentLine}']`
)
if (exactEl) {
exactEl.scrollIntoView(SYNC_SCROLL)
} else {
const closestLine = md.getClosestPreviewLine(currentLine)
if (closestLine) {
const closestEl = editorPreviewContainerRef.value.querySelector(
`[data-line='${closestLine}']`
)
if (closestEl) {
closestEl.scrollIntoView(SYNC_SCROLL)
}
}
}
container.scrollTo({ top: 0, behavior: 'smooth' })
return
}
previewAnchorFor(container, currentLine)?.scrollIntoView(SYNC_SCROLL)
}, 500)
)

@ -6,9 +6,10 @@
<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 -->
that can only say `en` is noise on all of them -- and never when the caller has asked
for one locale, where the picker is filling a slot that belongs to it -->
<w-btn
v-if="siteStore.locales.active.length > 1"
v-if="siteStore.locales.active.length > 1 && !props.lockLocale"
class="acrylic-btn -my-2"
flat
dense
@ -201,6 +202,15 @@ const props = defineProps({
locale: {
type: String,
default: null
},
/**
* Whether the locale above is fixed. For a caller picking the page that fills one locale's slot
* the translation set in the page properties panel where a page from another locale is not a
* different answer but a wrong one, so the switcher is not offered at all.
*/
lockLocale: {
type: Boolean,
default: false
}
})
@ -404,6 +414,13 @@ function selectItem(item) {
function submit() {
onDialogOK({
href: href.value,
/*
The page as the wiki addresses it, beside the href built from it: a caller storing a relation to
a page needs the path and the locale as two values, and taking them back apart from an href means
knowing which prefixes are locales. Empty for the URL tab, which names no page of this wiki.
*/
path: state.currentTab === 'page' ? state.path : '',
locale: state.currentTab === 'page' ? state.locale : '',
/*
Which tab answered, so a caller that stores the two kinds differently does not have to work it
out from the string afterwards. It cannot be worked out reliably: `/help` is a page of this wiki

@ -38,6 +38,7 @@ import { useRoute, useRouter } from 'vue-router'
import { splitLocalePath } from '@/helpers/pagePaths'
import { useCommonStore } from '@/stores/common'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
// PROPS
@ -81,6 +82,7 @@ const emit = defineEmits(['select'])
// STORES
const commonStore = useCommonStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
// ROUTER
@ -96,6 +98,18 @@ const { t } = useI18n()
const currentLocale = computed(() => props.selected ?? commonStore.locale)
/**
* Whether the route is the page the store holds, which is what makes its relations this reader's.
*
* Compared as the wiki stores a path rather than as the router hands it over: the URL carries a locale
* prefix and a leading slash, and the site root is the `home` page.
*/
const onCurrentPage = computed(() => {
const current = splitLocalePath(route.path, siteStore.localePrefixes)
const path = (current?.path ?? route.path).replace(/^\/+/, '').replace(/\/+$/, '')
return (path || 'home') === (pageStore.path || 'home')
})
// METHODS
/**
@ -106,6 +120,12 @@ const currentLocale = computed(() => props.selected ?? commonStore.locale)
* 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.
*
* **Where the page says which page it is in that locale, that is where the reader goes.** A
* translation rarely lives at the same path as its original -- a wiki writes its French pages in
* French -- so the path is only a guess, and one the author can correct: see `localeRelations` on the
* page store, set from the page properties panel. Without one the path is carried across unchanged,
* which is the best guess available and is what a site whose translations are filed alike relies on.
*
* 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.
@ -121,8 +141,21 @@ function pick(code) {
// -> 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)
/*
Only when the reader is on the page the relations belong to. The menu is in the shell, so it is on
screen over the search results and the profile as well, where the store still holds whichever page
was read last -- and being sent to that page's translation instead of to this screen in the other
language would be the wrong kind of helpful.
*/
const related = onCurrentPage.value
? pageStore.localeRelations.find((rel) => rel.locale === code)
: null
router.push({
path: prefix ? `${prefix}${path === '/' ? '' : path}` : path,
path: related
? `${prefix}/${related.path}`
: prefix
? `${prefix}${path === '/' ? '' : path}`
: path,
query: route.query,
hash: route.hash
})

@ -58,7 +58,7 @@
<router-link
v-if="item.isPage"
class="browse-menu-target"
:to="`/${item.path}`"
:to="`${localePrefix}/${item.path}`"
@click="menu?.hide()">
<w-icon :name="item.icon || `la:file-alt`" size="xs" class="shrink-0 opacity-70" />
<span class="truncate">{{ item.title }}</span>
@ -152,6 +152,11 @@ const EMPTY_LEVEL = { title: '', items: [], truncated: false }
const state = reactive({
/** Slash-separated path of the folder being listed. Empty at the site root. */
path: '',
/**
* The locale the levels held here were listed in. Captured when the menu opens rather than read per
* row: a level is one locale's tree, so what it lists and where its rows lead have to agree on which.
*/
locale: '',
/** Which way the next level slides in from. */
direction: 'forward',
isLoading: false,
@ -165,12 +170,23 @@ const level = computed(() => state.levels[state.path] ?? EMPTY_LEVEL)
const isRoot = computed(() => !state.path)
/**
* What a row's link starts with, for the locale this level was listed in.
*
* `tree/browse` answers for one locale, so every page here is read at that locale's address which on
* a site that brackets its URLs by locale is a prefixed one. Without it each row pointed at the
* primary locale's copy of the path: a reader browsing the French tree was sent to the English page,
* or to a path that has no page in the primary locale at all.
*/
const localePrefix = computed(() => siteStore.localeUrlPrefix(state.locale))
// METHODS
/** Opens on the folder holding the current page, with whatever was cached from last time dropped. */
function onShow() {
state.levels = {}
state.direction = 'forward'
state.locale = pageStore.locale
state.path = pageStore.folderPath
load(state.path)
}
@ -192,7 +208,7 @@ async function load(path) {
const data = await API_CLIENT.get(`sites/${siteStore.id}/tree/browse`, {
searchParams: {
path,
locale: pageStore.locale
locale: state.locale
}
}).json()
state.levels[path] = {

@ -648,6 +648,9 @@ function branchFrom(version) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
notify({ type: 'positive', message: t('history.branchSuccess') })
// -> This page went up with the version's tags, so a tag no page carried any more is back; the
// tag fields have to hear about it, the same as after a save
siteStore.staleTags()
close()
router.push(`/${page.path}`)
} catch (err) {

@ -0,0 +1,319 @@
<template>
<w-card class="page-locale-relations" style="width: 620px; max-width: 92vw">
<w-toolbar class="bg-primary text-white">
<div class="text-subtitle2">{{ t('editor.localeRel.title') }}</div>
</w-toolbar>
<w-card-section>
<div class="text-caption pb-4 text-black/60 dark:text-white/70">
{{ t('editor.localeRel.intro') }}
</div>
<w-list class="rounded bg-white dark:bg-black/20" separator bordered>
<w-item v-for="row of state.rows" :key="`loc-` + row.locale">
<w-item-section side>
<!-- -> The short code as an avatar, as the locale selector draws it, so a row here is
recognisable as the same locale the reader picks in the sidebar -->
<w-avatar
rounded
:color="row.isSelf ? `secondary` : `primary`"
text-color="white"
size="sm">
<div class="text-caption uppercase">
<strong>{{ row.language }}</strong>
</div>
</w-avatar>
</w-item-section>
<w-item-section>
<w-item-label
><strong>{{ row.displayName }}</strong></w-item-label
>
<!-- -> The page's own row says which slot it fills rather than offering to change it: it
IS the entry for its locale, and pointing that slot elsewhere would be a move -->
<w-item-label caption v-if="row.isSelf">{{
t('editor.localeRel.thisPage')
}}</w-item-label>
<w-item-label caption v-else-if="row.title">{{ row.title }}</w-item-label>
<w-item-label caption v-else>{{ t('editor.localeRel.notSet') }}</w-item-label>
</w-item-section>
<w-item-section side>
<div class="text-caption font-robotomono max-w-56 truncate">
{{ row.path ? `/${row.path}` : '—' }}
</div>
</w-item-section>
<w-item-section side v-if="!row.isSelf">
<div class="flex flex-nowrap items-center gap-1">
<w-btn
:label="t(`editor.localeRel.selectPage`)"
color="primary"
outline
dense
no-caps
padding="xs sm"
:loading="state.checking === row.locale"
@click="selectPage(row)" />
<!-- -> Only where there is something to take away, so the control appears with the
relation it clears rather than sitting dead on every empty row -->
<w-btn
v-if="row.path"
icon="la:times"
dense
flat
padding="none"
:aria-label="t(`editor.localeRel.clear`)"
@click="clearRow(row)">
<w-tooltip>{{ t('editor.localeRel.clear') }}</w-tooltip>
</w-btn>
</div>
</w-item-section>
</w-item>
</w-list>
<div class="text-caption pt-4 text-black/60 dark:text-white/70">
{{ t('editor.localeRel.appliesOnSave') }}
</div>
</w-card-section>
<w-card-actions class="card-actions">
<w-space />
<w-btn
class="acrylic-btn"
icon="la:times"
:label="t(`common.actions.discard`)"
color="grey-7"
padding="xs md"
flat
@click="$emit('close')" />
<w-btn
icon="la:check"
:label="t(`common.actions.apply`)"
unelevated
color="primary"
padding="xs md"
@click="saveAndClose" />
</w-card-actions>
</w-card>
</template>
<script setup>
import { useI18n } from 'vue-i18n'
import { onMounted, reactive } from 'vue'
import { dialog } from '@/composables/dialog'
import { notify } from '@/composables/notify'
import { apiErrorMessage } from '@/helpers/apiError'
import { useEditorStore } from '@/stores/editor'
import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import LinkPickerDialog from './LinkPickerDialog.vue'
/**
* The set of pages this page belongs to: itself, and the same page written in the other locales.
*
* One row per active locale, because the question is asked of the locales a site HAS rather than of
* the relations it happens to hold -- an empty row is a language this page has no counterpart in, and
* is how one is added. The row for the page's own locale is the page itself and is not a choice.
*
* Nothing here writes to the server. The set is staged on the page store and goes up with the page,
* so discarding the edit discards the relations with it -- which is what every other field in the
* properties panel does. What IS asked of the server is whether a chosen page is free to be related:
* see `selectPage`.
*/
// STORES
const editorStore = useEditorStore()
const pageStore = usePageStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
/** One row per locale: `{ locale, language, displayName, isSelf, path, title }`. */
rows: [],
/** The locale whose row is waiting on the server, which is what puts that button in a spinner. */
checking: null
})
// METHODS
/** The row for a locale, or undefined for a locale this site does not have. */
function rowFor(locale) {
return state.rows.find((r) => r.locale === locale)
}
/**
* Put a page in a locale's row.
*
* The picker is opened ON that locale and locked to it: a page from another language is not a
* different answer to "which page is the French one", it is a wrong one.
*/
function selectPage(row) {
dialog({
component: LinkPickerDialog,
componentProps: {
title: t('editor.localeRel.pickerTitle', { locale: row.displayName }),
okLabel: t('common.actions.select'),
locale: row.locale,
lockLocale: true,
newTabOption: false,
initialHref: row.path ? `/${row.path}` : ''
}
}).onOk(({ path, locale }) => {
if (!path || locale !== row.locale) {
return
}
adopt(row, path)
})
}
/**
* Take a chosen page, and with it whatever set it already belongs to.
*
* The server is asked before the row is filled in, because a page that is already some other page's
* counterpart cannot become this one's: a page belongs to one set. Where the set it is in can be
* joined, its other members are filled in here too -- they are the same page as well, and the author
* should see what they are joining before they save rather than discover it afterwards.
*/
async function adopt(row, path) {
state.checking = row.locale
let group
try {
group = await API_CLIENT.get(`sites/${siteStore.id}/pages/locale-relations`, {
searchParams: { path, locale: row.locale }
}).json()
} catch (err) {
notify({
type: 'negative',
message: t('editor.localeRel.checkFailed'),
caption: apiErrorMessage(err)
})
return
} finally {
state.checking = null
}
/*
Every page of the set being joined, checked against what this dialog already says before anything
is changed: a set that cannot be joined must leave the rows as they were, or the author would be
left holding a selection the save is going to refuse.
*/
const joining = group?.relations ?? []
for (const rel of joining) {
if (rel.locale === pageStore.locale && rel.path !== pageStore.path) {
// -> The case this check exists for: the chosen page is already the translation of another page
// in THIS page's language, so there is no slot here for this page to take
notify({
type: 'negative',
message: t('editor.localeRel.conflictOwnLocale', {
path: `/${path}`,
locale: row.displayName,
other: `/${rel.path}`
})
})
return
}
const existing = rowFor(rel.locale)
if (existing && !existing.isSelf && existing.path && existing.path !== rel.path) {
notify({
type: 'negative',
message: t('editor.localeRel.conflictRow', {
path: `/${path}`,
locale: existing.displayName,
other: `/${rel.path}`
})
})
return
}
}
row.path = path
row.title = group?.page?.title ?? ''
// -> The rest of the set, into the rows that have nothing in them. A locale the set covers and this
// site no longer has is left out: there is no row to put it in, and the save states rows.
let adopted = 0
for (const rel of joining) {
const target = rowFor(rel.locale)
if (!target || target.isSelf || target.path === rel.path) {
continue
}
target.path = rel.path
target.title = rel.title ?? ''
adopted++
}
if (adopted > 0) {
notify({
type: 'info',
message: t('editor.localeRel.joinedSet', { count: adopted })
})
}
}
function clearRow(row) {
row.path = ''
row.title = ''
}
/**
* Hand the set to the page store, which is what the save sends.
*
* Every row that names a page, the page's own excepted: it occupies its own locale's slot by being the
* page being saved, and sending it back would be sending the server its own answer.
*
* And the editor is told it has an unsaved change, which is not automatic: `hasPendingChanges` is what
* enables Save Changes, and writing the store alone leaves that button disabled -- so the set was
* staged, could not be saved, and was thrown away when the editor closed. The same two steps the icon
* and the title take in `PageHeader`.
*/
function saveAndClose() {
pageStore.localeRelations = state.rows
.filter((row) => !row.isSelf && row.path)
.map((row) => ({ locale: row.locale, path: row.path, title: row.title }))
editorStore.lastChangeTimestamp = Temporal.Now.instant()
emit('close')
}
// EMITS
const emit = defineEmits(['close'])
// MOUNTED
onMounted(() => {
const held = pageStore.localeRelations ?? []
const rows = siteStore.locales.active.map((locale) => {
const relation = held.find((rel) => rel.locale === locale.code)
const isSelf = locale.code === pageStore.locale
return {
locale: locale.code,
language: locale.language,
displayName: locale.displayName,
isSelf,
path: isSelf ? pageStore.path : (relation?.path ?? ''),
title: isSelf ? pageStore.title : (relation?.title ?? '')
}
})
/*
And a relation to a locale the site no longer has active, which would otherwise be dropped by the
save without anybody saying so -- the rows ARE the set. It gets a row like any other, so it can be
seen and taken off deliberately.
*/
for (const relation of held) {
if (!rows.some((row) => row.locale === relation.locale)) {
rows.push({
locale: relation.locale,
language: relation.locale.split('-')[0],
displayName: relation.locale,
isSelf: false,
path: relation.path,
title: relation.title ?? ''
})
}
}
state.rows = rows
})
</script>

@ -150,6 +150,21 @@
@click="newRelation">
<w-tooltip>{{ t('editor.props.relationAddHint') }}</w-tooltip>
</w-btn>
<!--
A different kind of relation, which is why it is its own button rather than another position
in the one above: a page relation is a link this page draws in its footer, while a locale
relation says that another page IS this page, written in another language.
-->
<w-btn
class="mt-2 w-full"
:label="t(`editor.props.localeRelations`)"
icon="la:language"
no-caps
unelevated
color="secondary"
@click="state.showLocaleRelationsDialog = true">
<w-tooltip>{{ t('editor.props.localeRelationsHint') }}</w-tooltip>
</w-btn>
</w-card-section>
<w-card-section class="alt-card" id="refCardScripts">
<div class="w-section-header">{{ t('editor.props.scripts') }}</div>
@ -318,6 +333,9 @@
</w-form>
</w-card-section>
</w-scroll-area>
<w-dialog v-model="state.showLocaleRelationsDialog">
<page-locale-relations-dialog @close="state.showLocaleRelationsDialog = false" />
</w-dialog>
<w-dialog v-model="state.showRelationDialog">
<page-relation-dialog
:edit-id="state.editRelationId"
@ -338,6 +356,7 @@ import { usePageStore } from '@/stores/page'
import { useSiteStore } from '@/stores/site'
import IconPickerDialog from './IconPickerDialog.vue'
import PageLocaleRelationsDialog from './PageLocaleRelationsDialog.vue'
import PageRelationDialog from './PageRelationDialog.vue'
import PageScriptsDialog from './PageScriptsDialog.vue'
import PageTags from './PageTags.vue'
@ -356,6 +375,7 @@ const { t } = useI18n()
const state = reactive({
showRelationDialog: false,
showLocaleRelationsDialog: false,
showScriptsDialog: false,
requirePassword: false,
editRelationId: null,

@ -26,7 +26,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { scrollToAnchor } from '@/helpers/anchors'
import { isVisible, scrollToAnchor } from '@/helpers/anchors'
import { flattenToc } from '@/helpers/toc'
/**
@ -81,6 +81,12 @@ const SPY_LINE = 120
*/
const CLICK_SETTLE_MS = 1200
/*
The tab block, named here because the spy has to leave its rows alone see `spyableRows`. Uppercase
because `tagName` is, for an HTML element in an HTML document.
*/
const TAB_TAG = 'BLOCK-TAB'
let spyFrame = null
let spySuspendedUntil = 0
@ -134,6 +140,34 @@ function scrollportTop(heading) {
return 0
}
/**
* The rows the spy may mark, paired with the element each one points at.
*
* Two kinds are left out, both because where they sit says nothing about what is being read:
*
* - **A row pointing at a tab.** A tab is somewhere a reader is sent, not somewhere they arrive by
* scrolling: every panel of a block starts at the same offset, so a reader passing the block passes
* all of them at once and the marker has no reason to prefer any one. `block-tab` is also the only
* row whose target is the container of a whole section of page rather than a line at the top of one.
* - **Anything inside a panel that is not showing.** A closed tab is `display: none`, so its contents
* measure nothing at all `top: 0`, which reads as a heading the reader has long since passed
* rather than as one that is not on the page. Since the last match in document order wins, the
* deepest hidden row held the marker whatever the reader did.
*
* Neither is excluded from the list itself: both are still drawn, and clicking either still opens the
* panel and scrolls to it, which is `scrollToAnchor`'s job rather than the spy's.
*/
function spyableRows() {
const rows = []
for (const item of visibleItems.value) {
const heading = headingFor(item.key)
if (heading && heading.tagName !== TAB_TAG && isVisible(heading)) {
rows.push({ key: item.key, heading })
}
}
return rows
}
/**
* Mark whichever heading the reader has reached.
*
@ -141,25 +175,27 @@ function scrollportTop(heading) {
* editing, and images settling in shift every heading below them.
*/
function syncSpy() {
if (performance.now() < spySuspendedUntil || visibleItems.value.length === 0) {
if (performance.now() < spySuspendedUntil) {
return
}
const rows = spyableRows()
if (rows.length === 0) {
// -> Nothing on the page to measure against a page that is all tabs, or a render still arriving.
// The marker stays where it was, rather than moving to a row nobody is reading.
return
}
const line = scrollportTop(rows[0].heading) + SPY_LINE
let current = null
let line = null
for (const item of visibleItems.value) {
const heading = headingFor(item.key)
if (!heading) {
continue
}
line ??= scrollportTop(heading) + SPY_LINE
if (heading.getBoundingClientRect().top <= line) {
current = item.key
for (const row of rows) {
if (row.heading.getBoundingClientRect().top <= line) {
current = row.key
}
}
// -> Above the first heading, the first section is still the one being read
const next = current ?? visibleItems.value[0].key
const next = current ?? rows[0].key
if (next !== props.selected) {
emit('update:selected', next)
}

@ -58,8 +58,14 @@ export function anchorTarget(hash) {
return id ? document.getElementById(id) : null
}
/** Whether an element has a box on the page — false while it sits in a panel that is not showing. */
function isVisible(el) {
/**
* Whether an element has a box on the page false while it sits in a panel that is not showing.
*
* Exported because the contents list asks the same question of a heading before measuring where it is:
* one inside a closed tab measures nothing at all, which reads as the top of the page rather than as
* an absence. See `PageToc`.
*/
export function isVisible(el) {
return Boolean(el.offsetParent ?? el.getClientRects().length)
}

@ -184,7 +184,7 @@
>
</div>
<w-list separator>
<w-item v-for="item of state.results" clickable :to="`/` + item.path">
<w-item v-for="item of state.results" clickable :to="pageUrl(item)">
<w-item-section avatar>
<w-avatar color="primary" text-color="white" rounded>
<w-icon :name="item.icon || defaultPageIcon" size="24px" />
@ -193,7 +193,9 @@
<w-item-section>
<w-item-label>{{ item.title }}</w-item-label>
<w-item-label v-if="item.description" caption>{{ item.description }}</w-item-label>
<w-item-label class="text-grey" caption>/{{ item.path }}</w-item-label>
<!-- -> The address it leads to, not the bare tree path: on a site with locales
the prefix is what tells two hits on the same path apart -->
<w-item-label class="text-grey" caption>{{ pageUrl(item) }}</w-item-label>
<w-item-label class="text-highlight" v-if="item.highlight" caption>
<span v-html="item.highlight" />
</w-item-label>
@ -368,6 +370,18 @@ watch(() => state.params, debounce(performSearch, 500), { deep: true })
// METHODS
/**
* Where a result leads.
*
* Worked out per row rather than once for the list: a search covers every locale unless one is
* filtered for, so two hits in the same set can be in different languages -- and a bare path is the
* primary locale's address, which is either the wrong page or no page at all. Each result carries the
* locale it was found in, so that is what the prefix comes from.
*/
function pageUrl(item) {
return `${siteStore.localeUrlPrefix(item.locale)}/${item.path}`
}
function humanizeDate(val) {
return userStore.formatDateTime(t, val)
}

@ -466,14 +466,24 @@ export class MarkdownRenderer {
// Inject line numbers for preview scroll sync
// --------------------------------
this.linesMap = []
/*
Stamped at every depth, not only on the tokens sitting at the top level of the document. The
editor finds the line the caret is on by this attribute and nothing else, so a token without one
is a line the preview cannot be scrolled to -- and everything written inside a block was such a
line, `::block-tab` panels most of all. What that looked like: the nearest line the editor could
find for a caret inside a panel was the last paragraph ABOVE the whole tabset, so the preview
scrolled up out of the panel being written in, however long its content was.
A hidden token is skipped. A tight list's paragraphs are parsed and then not rendered, and a
single-paragraph block body is unwrapped the same way, so stamping one would leave the editor
looking for an element that was never drawn -- worse than not knowing the line, since it would
stop looking for something it could have scrolled to instead.
*/
const injectLineNumbers = (tokens, idx, options, env, slf) => {
let line
if (tokens[idx].map && tokens[idx].level === 0) {
line = tokens[idx].map[0] + 1
tokens[idx].attrJoin('class', 'line')
tokens[idx].attrSet('data-line', String(line))
this.linesMap.push(line)
const token = tokens[idx]
if (token.map && !token.hidden) {
token.attrJoin('class', 'line')
token.attrSet('data-line', String(token.map[0] + 1))
}
return slf.renderToken(tokens, idx, options, env, slf)
}
@ -481,6 +491,29 @@ export class MarkdownRenderer {
this.md.renderer.rules.heading_open = injectLineNumbers
this.md.renderer.rules.blockquote_open = injectLineNumbers
/*
A fence carries its line in the markup rather than on the token, because the `highlight` option
above returns the whole `<pre>` and markdown-it hands a highlighted fence straight back: the
token's attributes are rendered by nobody, so `attrSet` would reach nothing.
Worth the string surgery for the case this whole mechanism exists for. A tabset whose panels each
hold one long code sample -- per language, per platform -- is the commonest tabset there is, and
it is exactly the one where the caret has no anchor of its own to be scrolled to.
The attribute only, without the `line` class the tokens above join: the class would have to go on
a tag that already carries one, and two `class` attributes on the same tag is not markup.
*/
const renderFence =
this.md.renderer.rules.fence ??
((tokens, idx, options, env, slf) => slf.renderToken(tokens, idx, options, env, slf))
this.md.renderer.rules.fence = (tokens, idx, options, env, slf) => {
const html = renderFence(tokens, idx, options, env, slf)
const map = tokens[idx].map
// -> Every branch of `highlight` opens with `<pre`, and so does the wrapper markdown-it puts
// around a fence it did not highlight
return map ? html.replace(/^<pre/, `<pre data-line="${map[0] + 1}"`) : html
}
// --------------------------------
// Where the tabsets are, for the editor's preview
// --------------------------------
@ -527,28 +560,28 @@ export class MarkdownRenderer {
* a relative image resolves against -- see `fileSrc`.
*/
render(src, { pagePath = '' } = {}) {
this.linesMap = []
// -> A fresh env every time, whatever the caller passed: markdown-it keeps per-render state in it
// (footnotes and references), and one shared between renders would carry the last one's
return this.md.render(src, { pagePath })
}
getClosestPreviewLine(line) {
return this.linesMap.findLast((n) => n <= line)
}
/**
* Which tabset panel a source line is inside, as the pair of indices that finds it in the render.
* Which tabset panels a source line is inside, as the pairs of indices that find them in the render.
*
* Every panel that contains the line, outermost first, rather than only the one it is written in: a
* tabset nested in another one is drawn inside a panel, and opening the inner panel while the panel
* holding it stays closed reveals nothing at all. The last entry is therefore the panel the line is
* actually in, and the ones before it are what has to be opened for it to be on the page.
*
* The innermost panel wins, so a tabset within a tabset answers for its own lines: the map is built
* outermost-first, and a later match is therefore a deeper one.
* Ordered by construction: `tabsMap` is built in document order, so a tabset that encloses another
* is always the earlier of the two.
*
* @param {number} line A 1-based editor line, as Monaco counts them.
* @returns {{tabset: number, tab: number}|null} Indices among the document's tabsets and that
* tabset's panels, or null when the line is not inside one.
* @returns {Array<{tabset: number, tab: number}>} Indices among the document's tabsets and each
* tabset's panels, outermost first. Empty when the line is not inside one.
*/
getTabAtLine(line) {
let found = null
getTabsAtLine(line) {
const found = []
for (const [tabset, tabs] of this.tabsMap.entries()) {
for (const [tab, map] of tabs.entries()) {
/*
@ -557,7 +590,7 @@ export class MarkdownRenderer {
a panel still counts as being in it.
*/
if (line - 1 >= map[0] && line - 1 <= map[1]) {
found = { tabset, tab }
found.push({ tabset, tab })
}
}
}

@ -61,6 +61,12 @@ export const usePageStore = defineStore('page', {
publishStartDate: '',
publishState: '',
relations: [],
/**
* The same page in other locales: `{ locale, path, title }`, one entry per locale, as the server
* knows them. What the locale selector sends a reader to instead of guessing at the same path in
* another language, and what the page properties panel edits as one set.
*/
localeRelations: [],
render: '',
scriptJsLoad: '',
scriptJsUnload: '',
@ -174,6 +180,9 @@ export const usePageStore = defineStore('page', {
relations: pageData.relations.map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
),
localeRelations: (pageData.localeRelations ?? []).map((r) =>
pick(r, ['locale', 'path', 'title'])
),
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
this.applyViewerState(pageData.viewer)
@ -226,6 +235,9 @@ export const usePageStore = defineStore('page', {
relations: pageData.relations.map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
),
localeRelations: (pageData.localeRelations ?? []).map((r) =>
pick(r, ['locale', 'path', 'title'])
),
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
},
@ -312,6 +324,7 @@ export const usePageStore = defineStore('page', {
toc: [],
tags: [],
relations: [],
localeRelations: [],
scriptJsLoad: '',
scriptJsUnload: '',
scriptCss: '',
@ -416,6 +429,9 @@ export const usePageStore = defineStore('page', {
alias: '',
publishState: 'published',
relations: [],
// -> A page being created is in no translation set yet, whatever the page it was started from
// belonged to
localeRelations: [],
tags: [],
content: content ?? '',
// -> A page being created has no stored source to lose: whatever it starts with IS the source
@ -654,6 +670,7 @@ export const usePageStore = defineStore('page', {
'icon',
'isBrowsable',
'isSearchable',
'localeRelations',
'password',
'publishEndDate',
'publishStartDate',
@ -729,9 +746,26 @@ export const usePageStore = defineStore('page', {
relations: (pageData.relations ?? []).map((r) =>
pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])
),
/*
What the server made of the set, not what was sent: joining another page's translations
brings its other members along, so the panel's own list is a request and this is the answer.
*/
localeRelations: (pageData.localeRelations ?? []).map((r) =>
pick(r, ['locale', 'path', 'title'])
),
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
})
/*
The site's tags are what its pages carry, so this save is what just changed them -- a tag
typed into the properties panel exists from here on, and one taken off the last page carrying
it does not. Unconditional: the tags that went up cannot be compared with the ones that were
on the page before, since the store held the author's edits long before the save.
Marked rather than fetched, so the cost falls on the next thing that actually wants the list.
*/
siteStore.staleTags()
if (editorStore.mode === 'create') {
editorStore.$patch({ mode: 'edit' })
/*

@ -123,7 +123,13 @@ export const useSiteStore = defineStore('site', {
}
]
},
/**
* Every tag any page of this site carries, most used first the suggestions a tag field offers
* and the list the search page filters by. Derived from the pages rather than stored, so it goes
* out of date whenever one is saved; see `fetchTags` and `staleTags`.
*/
tags: [],
/** Whether `tags` holds a list that is known to be current. */
tagsLoaded: false,
theme: {
dark: false,
@ -301,6 +307,13 @@ export const useSiteStore = defineStore('site', {
}
})
},
/**
* Load the site's tags, unless a current list is already held.
*
* Cached because it is not a cheap answer the server counts every tag over every page the
* asker may read, page rules and all and because the surfaces that want it (a tag field, the
* search filter, the header's popular tags) are opened over and over in one session.
*/
async fetchTags(forceRefresh = false) {
if (this.tagsLoaded && !forceRefresh) {
return
@ -316,6 +329,24 @@ export const useSiteStore = defineStore('site', {
throw err
}
},
/**
* Say that the tag list is out of date, so the next thing that wants it asks the server again.
*
* A tag is not a record anybody creates: it exists because a page carries it, so saving a page is
* what brings one into being and the list cached here was fetched before that happened. Without
* this, a tag invented in the page properties panel was missing from every tag field for the rest
* of the session, and only a full reload of the application brought it back.
*
* The tags themselves are left in place rather than emptied: what is held is the last known list,
* which is a better thing to show than nothing while the next fetch is in flight.
*
* Deleting a page, or a folder of them, moves this list too and does not say so. That direction is
* survivable in a way this one is not: a tag no page carries any more is a suggestion that leads to
* an empty search, where a tag that cannot be suggested at all cannot be picked.
*/
staleTags() {
this.tagsLoaded = false
},
/**
* Load the sidebar menu a page resolves to.
*

Loading…
Cancel
Save