From b16402ae0f23dd398ac39a43a022a5d794376fd1 Mon Sep 17 00:00:00 2001 From: NGPixel Date: Mon, 24 Aug 2026 03:38:53 -0400 Subject: [PATCH] feat: add page locale relations + various fixes --- backend/api/locales.ts | 4 +- backend/api/pages.ts | 73 ++++ backend/api/schemas/page.ts | 50 +++ backend/core/scheduler.ts | 70 ++-- .../20260809235619_init/migration.sql | 4 +- .../20260809235619_init/snapshot.json | 41 ++ backend/db/schema.ts | 14 +- backend/locales/en.json | 16 +- backend/models/locales.ts | 46 ++- backend/models/pages.ts | 374 +++++++++++++++++- frontend/src/components/EditorMarkdown.vue | 107 +++-- frontend/src/components/LinkPickerDialog.vue | 21 +- .../src/components/LocaleSelectorMenu.vue | 35 +- frontend/src/components/NavBrowseMenu.vue | 20 +- .../src/components/PageHistoryOverlay.vue | 3 + .../components/PageLocaleRelationsDialog.vue | 319 +++++++++++++++ .../src/components/PagePropertiesDialog.vue | 20 + frontend/src/components/PageToc.vue | 60 ++- frontend/src/helpers/anchors.js | 10 +- frontend/src/pages/Search.vue | 18 +- frontend/src/renderers/markdown.js | 73 +++- frontend/src/stores/page.js | 34 ++ frontend/src/stores/site.js | 31 ++ 23 files changed, 1326 insertions(+), 117 deletions(-) create mode 100644 frontend/src/components/PageLocaleRelationsDialog.vue diff --git a/backend/api/locales.ts b/backend/api/locales.ts index f974ca068..c0a6c54e7 100644 --- a/backend/api/locales.ts +++ b/backend/api/locales.ts @@ -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', diff --git a/backend/api/pages.ts b/backend/api/pages.ts index c569a3c7d..5bfc8d14f 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -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 */ diff --git a/backend/api/schemas/page.ts b/backend/api/schemas/page.ts index 490c124ba..03faa5093 100644 --- a/backend/api/schemas/page.ts +++ b/backend/api/schemas/page.ts @@ -92,6 +92,27 @@ export async function registerSchemas(app: FastifyInstance): Promise { 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 { 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 { } }) + /** + * 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 */ diff --git a/backend/core/scheduler.ts b/backend/core/scheduler.ts index cc7c81b08..9d0ce6f36 100644 --- a/backend/core/scheduler.ts +++ b/backend/core/scheduler.ts @@ -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) { diff --git a/backend/db/migrations/20260809235619_init/migration.sql b/backend/db/migrations/20260809235619_init/migration.sql index d173753f7..cc1bd771a 100644 --- a/backend/db/migrations/20260809235619_init/migration.sql +++ b/backend/db/migrations/20260809235619_init/migration.sql @@ -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"); \ No newline at end of file +ALTER TABLE "userKeys" ADD CONSTRAINT "userKeys_userId_users_id_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id"); diff --git a/backend/db/migrations/20260809235619_init/snapshot.json b/backend/db/migrations/20260809235619_init/snapshot.json index 8e66f4891..1a94bb7b6 100644 --- a/backend/db/migrations/20260809235619_init/snapshot.json +++ b/backend/db/migrations/20260809235619_init/snapshot.json @@ -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": [ diff --git a/backend/db/schema.ts b/backend/db/schema.ts index 26433465e..741c3a758 100644 --- a/backend/db/schema.ts +++ b/backend/db/schema.ts @@ -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) ] ) diff --git a/backend/locales/en.json b/backend/locales/en.json index ff606b2f6..b88ff119d 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -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 page’s 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", diff --git a/backend/models/locales.ts b/backend/models/locales.ts index 1d1cc7681..dafd9fd1a 100644 --- a/backend/models/locales.ts +++ b/backend/models/locales.ts @@ -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 { 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 { 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 { + // -> 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) { diff --git a/backend/models/pages.ts b/backend/models/pages.ts index a4841ac67..a32d8b787 100644 --- a/backend/models/pages.ts +++ b/backend/models/pages.ts @@ -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 { + 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 { + /* + 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() + 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() + 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 { + 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 = { 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({ diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index 55a023773..4bff86bf6 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -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) ) diff --git a/frontend/src/components/LinkPickerDialog.vue b/frontend/src/components/LinkPickerDialog.vue index 5d8baa5d6..55f11310a 100644 --- a/frontend/src/components/LinkPickerDialog.vue +++ b/frontend/src/components/LinkPickerDialog.vue @@ -6,9 +6,10 @@ {{ props.title ?? t('linkPicker.title') }} + 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 --> 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 }) diff --git a/frontend/src/components/NavBrowseMenu.vue b/frontend/src/components/NavBrowseMenu.vue index c254989d0..67cde8f58 100644 --- a/frontend/src/components/NavBrowseMenu.vue +++ b/frontend/src/components/NavBrowseMenu.vue @@ -58,7 +58,7 @@ {{ item.title }} @@ -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] = { diff --git a/frontend/src/components/PageHistoryOverlay.vue b/frontend/src/components/PageHistoryOverlay.vue index 3d6940482..2d7d17494 100644 --- a/frontend/src/components/PageHistoryOverlay.vue +++ b/frontend/src/components/PageHistoryOverlay.vue @@ -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) { diff --git a/frontend/src/components/PageLocaleRelationsDialog.vue b/frontend/src/components/PageLocaleRelationsDialog.vue new file mode 100644 index 000000000..32affc272 --- /dev/null +++ b/frontend/src/components/PageLocaleRelationsDialog.vue @@ -0,0 +1,319 @@ + + + diff --git a/frontend/src/components/PagePropertiesDialog.vue b/frontend/src/components/PagePropertiesDialog.vue index db11f30e7..739747d87 100644 --- a/frontend/src/components/PagePropertiesDialog.vue +++ b/frontend/src/components/PagePropertiesDialog.vue @@ -150,6 +150,21 @@ @click="newRelation"> {{ t('editor.props.relationAddHint') }} + + + {{ t('editor.props.localeRelationsHint') }} +
{{ t('editor.props.scripts') }}
@@ -318,6 +333,9 @@
+ + + 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) } diff --git a/frontend/src/helpers/anchors.js b/frontend/src/helpers/anchors.js index 9ae464476..20980dd4a 100644 --- a/frontend/src/helpers/anchors.js +++ b/frontend/src/helpers/anchors.js @@ -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) } diff --git a/frontend/src/pages/Search.vue b/frontend/src/pages/Search.vue index 74bfd27e7..0b7ce2f47 100644 --- a/frontend/src/pages/Search.vue +++ b/frontend/src/pages/Search.vue @@ -184,7 +184,7 @@ > - + @@ -193,7 +193,9 @@ {{ item.title }} {{ item.description }} - /{{ item.path }} + + {{ pageUrl(item) }} @@ -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) } diff --git a/frontend/src/renderers/markdown.js b/frontend/src/renderers/markdown.js index f43b2f7fc..0f34863ee 100644 --- a/frontend/src/renderers/markdown.js +++ b/frontend/src/renderers/markdown.js @@ -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 `
` 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 ` 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 })
         }
       }
     }
diff --git a/frontend/src/stores/page.js b/frontend/src/stores/page.js
index 1ff650304..bd1ed2f4f 100644
--- a/frontend/src/stores/page.js
+++ b/frontend/src/stores/page.js
@@ -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' })
           /*
diff --git a/frontend/src/stores/site.js b/frontend/src/stores/site.js
index 82528e171..e6aa7f6e5 100644
--- a/frontend/src/stores/site.js
+++ b/frontend/src/stores/site.js
@@ -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.
      *