mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 22:01:58 -05:00
feat: view page backlinks
This commit is contained in:
25 files changed
+8651
-42
No files matched your search
@@ -1614,6 +1614,198 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* LIST BACKLINKS
|
||||
*
|
||||
* What links to this page.
|
||||
*
|
||||
* Asked by the page's ADDRESS rather than by its id, which is the whole reason the table stores an
|
||||
* address: a link says where it points, so the pages pointing at one that has moved still point at
|
||||
* where it was. The two short links are folded in by id and by alias, since those two survive a move
|
||||
* by design and belong in the same list.
|
||||
*/
|
||||
app.get<{ Params: { siteId: string; pageId: string } }>(
|
||||
'/sites/:siteId/pages/:pageId/backlinks',
|
||||
{
|
||||
/*
|
||||
No route-level `permissions`: reading a page's backlinks is `read:pages` ON THAT PAGE, and on
|
||||
every page in the answer — see below.
|
||||
*/
|
||||
schema: {
|
||||
summary: 'List the pages linking to a page',
|
||||
description:
|
||||
"Every page of this instance whose content, redirection target or sidebar relations point at this one, with the href each of them wrote.\n\nAnswers 404 where the site has `features.backlinks` off, which is the switch under General → Features. A page that has turned its own Links tab off with `allowBacklinks` still answers here — that flag hides the tab, and what it hides is not a secret.\n\nFiltered by what the caller may read, one page at a time: a backlink names a page, its title and where it sits, so listing one the caller has no `read:pages` rule for would hand them the existence of a page they cannot open. A link written on another SITE of this instance is included, since a move here breaks it just the same.\n\nNote that links pointing at a page's OLD path are not listed here — after a move they address a path this page no longer has, which is what makes them findable as the links that move broke.",
|
||||
tags: ['Pages'],
|
||||
params: pageIdParam,
|
||||
response: {
|
||||
200: {
|
||||
description: 'Pages linking here',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
siteId: { type: 'string', format: 'uuid' },
|
||||
locale: { type: 'string' },
|
||||
path: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
icon: {
|
||||
type: 'string',
|
||||
description:
|
||||
"The page's own icon as an Iconify reference, or empty where it has none."
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
description: 'Where that page is, as a path on its own site.'
|
||||
},
|
||||
hostname: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description:
|
||||
'The host its site answers on, for a page on another site of this instance. Null when it is on the site being read, or on the catch-all site.'
|
||||
},
|
||||
href: {
|
||||
type: 'string',
|
||||
description: 'The link as that page writes it, which is what a repair would edit.'
|
||||
},
|
||||
kind: {
|
||||
type: 'string',
|
||||
description: 'How the link addresses this page: by path, by alias or by id.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
// -> The site-wide switch, as `requireBuiltInPage` checks the comments one: with the feature
|
||||
// off there is nothing here to answer, however the page itself is set
|
||||
if (!WIKI.models.pageLinks.isAllowed(req.params.siteId)) {
|
||||
return reply.notFound('This site does not show what links to a page.')
|
||||
}
|
||||
const page = await loadReadablePage(req, req.params.siteId, req.params.pageId)
|
||||
if (!page) {
|
||||
return reply.notFound('This page does not exist.')
|
||||
}
|
||||
// -> As the history route does: a page withheld until its password is entered withholds what is
|
||||
// derived from it too, and its link graph names it in one direction and reads its content in
|
||||
// the other
|
||||
if (page.isLocked) {
|
||||
return reply.forbidden('This page is password protected.')
|
||||
}
|
||||
|
||||
const backlinks = await WIKI.models.pageLinks.backlinksFor(
|
||||
{ siteId: req.params.siteId, locale: page.locale, path: page.path },
|
||||
{ pageId: page.id, alias: page.alias }
|
||||
)
|
||||
|
||||
return backlinks
|
||||
.filter((link) =>
|
||||
mayOnPage(req, 'read:pages', {
|
||||
siteId: link.siteId,
|
||||
locale: link.locale,
|
||||
path: link.path,
|
||||
tags: link.tags
|
||||
})
|
||||
)
|
||||
.map((link) => ({
|
||||
id: link.pageId,
|
||||
siteId: link.siteId,
|
||||
locale: link.locale,
|
||||
path: link.path,
|
||||
title: link.title,
|
||||
description: link.description ?? '',
|
||||
icon: link.icon ?? '',
|
||||
url: link.url,
|
||||
hostname: link.hostname,
|
||||
href: link.href,
|
||||
kind: link.kind
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* LIST OUTBOUND LINKS
|
||||
*
|
||||
* What this page links to, and whether anything is there.
|
||||
*
|
||||
* The red-link question. Resolution is a join rather than a stored flag, so a page created at a path
|
||||
* somebody had already linked to makes that link good without anything having to notice.
|
||||
*/
|
||||
app.get<{ Params: { siteId: string; pageId: string } }>(
|
||||
'/sites/:siteId/pages/:pageId/links',
|
||||
{
|
||||
// -> No route-level `permissions`: `read:pages` on the page itself, as above
|
||||
schema: {
|
||||
summary: 'List the links written on a page',
|
||||
description:
|
||||
"Every link this page's content, redirection target or sidebar relations point at, each with the href as written and what it resolves to. `exists` is false for a link addressing a page that is not there — the state a wiki draws as a red link.\n\nLinks leaving the wiki are not recorded and are not listed. Uploaded files are listed but never resolved: they are tracked so a moved file can be traced back to what pointed at it, and answering whether one exists is a different lookup.",
|
||||
tags: ['Pages'],
|
||||
params: pageIdParam,
|
||||
response: {
|
||||
200: {
|
||||
description: 'Links written on this page',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
href: { type: 'string' },
|
||||
kind: { type: 'string' },
|
||||
targetSiteId: { type: 'string', format: 'uuid' },
|
||||
targetLocale: { type: 'string', nullable: true },
|
||||
targetPath: { type: 'string', nullable: true },
|
||||
targetId: { type: 'string', format: 'uuid', nullable: true },
|
||||
targetTitle: { type: 'string', nullable: true },
|
||||
exists: { type: 'boolean' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const page = await loadReadablePage(req, req.params.siteId, req.params.pageId)
|
||||
if (!page) {
|
||||
return reply.notFound('This page does not exist.')
|
||||
}
|
||||
// -> These come out of the page's own body, which is the half a password withholds
|
||||
if (page.isLocked) {
|
||||
return reply.forbidden('This page is password protected.')
|
||||
}
|
||||
|
||||
const links = await WIKI.models.pageLinks.outboundFor(page.id)
|
||||
|
||||
return links.map((link) => ({
|
||||
href: link.href,
|
||||
kind: link.kind,
|
||||
targetSiteId: link.targetSiteId,
|
||||
targetLocale: link.targetLocale,
|
||||
targetPath: link.targetPath,
|
||||
targetId: link.targetPageId,
|
||||
/*
|
||||
Only what the caller may read, for the reason the backlinks route filters: whether a page
|
||||
exists at a path is something a rule decides they may know. A link to a page they may not
|
||||
read reads as a link to nothing, which is also what clicking it would give them.
|
||||
|
||||
Judged on where the RESOLVED page sits rather than on what the link said about it — an alias
|
||||
or an id link names no address at all — and with its tags, since a rule may be written
|
||||
against those.
|
||||
*/
|
||||
...(link.targetPageId &&
|
||||
!mayOnPage(req, 'read:pages', {
|
||||
siteId: link.targetSiteId,
|
||||
locale: link.targetPageLocale ?? undefined,
|
||||
path: link.targetPagePath ?? '',
|
||||
tags: link.targetPageTags ?? []
|
||||
})
|
||||
? { targetTitle: null, exists: false }
|
||||
: { targetTitle: link.targetTitle, exists: Boolean(link.targetPageId) })
|
||||
}))
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* PAGE USER PERMISSIONS
|
||||
*/
|
||||
|
||||
@@ -119,6 +119,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
type: 'string'
|
||||
}
|
||||
},
|
||||
allowBacklinks: { type: 'boolean' },
|
||||
allowComments: { type: 'boolean' },
|
||||
allowContributions: { type: 'boolean' },
|
||||
allowRatings: { type: 'boolean' },
|
||||
@@ -217,6 +218,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
description:
|
||||
'Only present when the request asked for it — except on a redirection, whose content is where it sends its reader rather than a body, and comes back either way.'
|
||||
},
|
||||
allowBacklinks: { type: 'boolean' },
|
||||
allowComments: { type: 'boolean' },
|
||||
allowContributions: { type: 'boolean' },
|
||||
allowRatings: { type: 'boolean' },
|
||||
|
||||
@@ -80,6 +80,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
features: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
backlinks: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether a page shows what links to it. Off hides the Links tab across the site and closes the route behind it; a page can still opt out on its own with `allowBacklinks`. Nothing about how links are recorded changes either way — the table is written by every save regardless, so turning this back on shows a list rather than an empty tab.'
|
||||
},
|
||||
browse: {
|
||||
type: 'boolean'
|
||||
},
|
||||
|
||||
@@ -1439,6 +1439,64 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* REBUILD PAGE LINKS
|
||||
*
|
||||
* What every page points at, worked out again from the render each one already stores.
|
||||
*
|
||||
* Not a repair anybody should need after an ordinary edit: a page's links are rewritten every time
|
||||
* its render is. It is for the two cases where there was nothing to derive them from at the time —
|
||||
* content that predates the table, and a site whose locale prefixes or page extensions changed,
|
||||
* which changes what a link already written in a page addresses without touching the page.
|
||||
*/
|
||||
app.post(
|
||||
'/page-links/rebuild',
|
||||
{
|
||||
config: {
|
||||
permissions: ['manage:system']
|
||||
},
|
||||
schema: {
|
||||
summary: 'Rebuild the page link index',
|
||||
description:
|
||||
'Queues a job that re-reads the stored render of every page on every site and records what each one links to. Runs in a worker thread because it touches all the content in the instance, so the response only says the job was queued.',
|
||||
tags: ['System'],
|
||||
response: {
|
||||
200: {
|
||||
description: 'Rebuild queued successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: {
|
||||
type: 'boolean'
|
||||
},
|
||||
message: {
|
||||
type: 'string'
|
||||
},
|
||||
id: {
|
||||
type: 'string',
|
||||
format: 'uuid',
|
||||
description: 'ID of the queued job, which the scheduler view lists.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const added = await WIKI.scheduler.addJob({ task: 'rebuildPageLinks' })
|
||||
if (!added?.id) {
|
||||
return reply.internalServerError('The scheduler could not queue the rebuild.')
|
||||
}
|
||||
|
||||
await audit(req, 'admin', 'rebuildPageLinks', { jobId: added.id })
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: 'Page link rebuild queued successfully.',
|
||||
id: added.id
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* CHECK FOR UPDATE
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE "pageLinks" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"kind" varchar(16) NOT NULL,
|
||||
"href" varchar(2048) NOT NULL,
|
||||
"targetSiteId" uuid NOT NULL,
|
||||
"targetLocale" varchar(255),
|
||||
"targetPath" varchar(255),
|
||||
"targetRef" varchar(255),
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"pageId" uuid NOT NULL,
|
||||
"siteId" uuid NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "pageLinks_target_idx" ON "pageLinks" ("targetSiteId","targetLocale","targetPath");--> statement-breakpoint
|
||||
CREATE INDEX "pageLinks_targetRef_idx" ON "pageLinks" ("targetSiteId","kind","targetRef");--> statement-breakpoint
|
||||
CREATE INDEX "pageLinks_pageId_idx" ON "pageLinks" ("pageId");--> statement-breakpoint
|
||||
CREATE INDEX "pageLinks_siteId_idx" ON "pageLinks" ("siteId");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "pageLinks_pageId_href_idx" ON "pageLinks" ("pageId","href");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "pages_siteId_locale_path_idx" ON "pages" ("siteId","locale","path");--> statement-breakpoint
|
||||
ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_targetSiteId_sites_id_fkey" FOREIGN KEY ("targetSiteId") REFERENCES "sites"("id");--> statement-breakpoint
|
||||
ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_pageId_pages_id_fkey" FOREIGN KEY ("pageId") REFERENCES "pages"("id") ON DELETE CASCADE;--> statement-breakpoint
|
||||
ALTER TABLE "pageLinks" ADD CONSTRAINT "pageLinks_siteId_sites_id_fkey" FOREIGN KEY ("siteId") REFERENCES "sites"("id");
|
||||
File diff suppressed because it is too large.
Load diff
+98
-1
@@ -556,7 +556,15 @@ export const pages = pgTable(
|
||||
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)
|
||||
uniqueIndex('pages_localeGroupId_locale_idx').on(table.localeGroupId, table.locale),
|
||||
/*
|
||||
Where a page sits, which is what a path addresses it by. Unique because two pages at one path in
|
||||
one locale is the thing `createPage` and `movePage` both check for and neither can actually
|
||||
prevent -- their check and their write are two statements, so two saves racing each other both
|
||||
see a clear path. It is also the index the link table resolves against: "is there a page at this
|
||||
address" is asked once per link on a page.
|
||||
*/
|
||||
uniqueIndex('pages_siteId_locale_path_idx').on(table.siteId, table.locale, table.path)
|
||||
]
|
||||
)
|
||||
|
||||
@@ -634,6 +642,95 @@ export const pageHistory = pgTable(
|
||||
]
|
||||
)
|
||||
|
||||
// PAGE LINKS --------------------------
|
||||
/**
|
||||
* One row per distinct link written on a page, resolved to what it addresses.
|
||||
*
|
||||
* Derived from the stored render the way `toc` and `searchContent` are, and rewritten wholesale
|
||||
* whenever that render is — see `models/pageLinks.ts`. Three questions are asked of it: what links to
|
||||
* the page being read, whether what a page links to exists, and which pages have to be revisited when
|
||||
* something they point at moves.
|
||||
*
|
||||
* **The address is what is stored, not a resolved page id.** A link is written as a path, and after a
|
||||
* move the pages pointing at the old one still say the old one — which is precisely the thing worth
|
||||
* knowing, and precisely what a foreign key would erase by following the page. It also lets a link to
|
||||
* a page that does not exist yet be a row like any other, which is what a red link is. Whether a
|
||||
* target resolves is a join against `pages` on `(siteId, locale, path)` at read time.
|
||||
*
|
||||
* `/a/<alias>` and `/i/<id>` are the exception and are stored as the reference they carry: those two
|
||||
* survive a move by design, so resolving them to a path at write time would record the opposite of
|
||||
* what they mean.
|
||||
*/
|
||||
export const pageLinks = pgTable(
|
||||
'pageLinks',
|
||||
{
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
/**
|
||||
* What the link addresses: a page by path (`page`), by alias (`alias`) or by id (`pageId`), or an
|
||||
* uploaded file (`asset`).
|
||||
*
|
||||
* A varchar rather than an enum, for the reason `pageHistory.action` is one — naming another kind
|
||||
* of target later should not need a migration. Links leaving the wiki are not recorded at all:
|
||||
* nothing asks a question about them until there is a link checker to answer it, and they would
|
||||
* be the bulk of the rows on a wiki that cites its sources.
|
||||
*/
|
||||
kind: varchar({ length: 16 }).notNull(),
|
||||
/**
|
||||
* The href exactly as it appears in the page.
|
||||
*
|
||||
* Kept because resolving is lossy and the source is what a repair would have to edit: `../two`,
|
||||
* `/en/one/two` and `/one/two.md` are one target and three strings, and only the string that was
|
||||
* written can be found in the markdown again.
|
||||
*
|
||||
* Bounded rather than `text` because it is half of a unique btree index below, and a btree entry
|
||||
* has a hard ceiling of about 2700 bytes — an href past it would fail the INSERT, and that INSERT
|
||||
* is part of saving a page. 2048 is the conventional cap for a URL and far past anything a link
|
||||
* in a wiki page is; `resolveLink` drops the ones that would not fit rather than truncating them
|
||||
* into a different address.
|
||||
*/
|
||||
href: varchar({ length: 2048 }).notNull(),
|
||||
/**
|
||||
* Which site the target belongs to. Usually the source's own, and another one for a link written
|
||||
* as an absolute URL to a second site of this instance — those are worth following rather than
|
||||
* writing off as external, since a move on either site breaks them just the same.
|
||||
*/
|
||||
targetSiteId: uuid()
|
||||
.notNull()
|
||||
.references(() => sites.id),
|
||||
// -> Both null for `alias` and `pageId`, which address a page without saying where it is
|
||||
targetLocale: varchar({ length: 255 }),
|
||||
targetPath: varchar({ length: 255 }),
|
||||
/** The alias or the page id, for the two kinds that carry one. Null for the rest. */
|
||||
targetRef: varchar({ length: 255 }),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
// -> The page the link is written on. Its rows go with it: a link is part of a page's content,
|
||||
// and nothing is left to point at once the page is gone
|
||||
pageId: uuid()
|
||||
.notNull()
|
||||
.references(() => pages.id, { onDelete: 'cascade' }),
|
||||
siteId: uuid()
|
||||
.notNull()
|
||||
.references(() => sites.id)
|
||||
},
|
||||
(table) => [
|
||||
// -> "What links here", and "what points at this path" for a page about to be moved. Leading with
|
||||
// the site because every one of those questions is asked within one
|
||||
index('pageLinks_target_idx').on(table.targetSiteId, table.targetLocale, table.targetPath),
|
||||
// -> The same question for the two kinds that address a page without a path
|
||||
index('pageLinks_targetRef_idx').on(table.targetSiteId, table.kind, table.targetRef),
|
||||
// -> Every link on one page, which is both the read for "do these targets exist" and the delete
|
||||
// half of rewriting a page's links
|
||||
index('pageLinks_pageId_idx').on(table.pageId),
|
||||
index('pageLinks_siteId_idx').on(table.siteId),
|
||||
/*
|
||||
One row per spelling, not per target: a page linking to the same place as `../two` and as
|
||||
`/one/two` has two links to repair and two rows saying so. Two identical hrefs on one page are
|
||||
one row, since there is nothing to tell them apart and nothing that would ask.
|
||||
*/
|
||||
uniqueIndex('pageLinks_pageId_href_idx').on(table.pageId, table.href)
|
||||
]
|
||||
)
|
||||
|
||||
// PAGE EDIT SUBMISSIONS ---------------
|
||||
/**
|
||||
* An edit suggested by somebody who may read a page but not change it, waiting to be reviewed.
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import * as cheerio from 'cheerio'
|
||||
import { isPageUrl, normalizePagePath, splitLocalePath, stripPageExtension } from './common.ts'
|
||||
|
||||
/**
|
||||
* Reading a link the way the reader's browser will.
|
||||
*
|
||||
* A page stores the HTML its editor produced, and that HTML carries the href the author wrote:
|
||||
* `fileSrc` rewrites image sources and deliberately leaves links alone, because a relative link means
|
||||
* exactly what it says. So `../two`, `/en/one/two`, `/one/two.md` and
|
||||
* `https://wiki.example.com/en/one/two` can all be the same page, and something has to say so before
|
||||
* any question about links can be answered.
|
||||
*
|
||||
* That makes this the THIRD copy of the rules for reading a page URL, after `helpers/common.ts` —
|
||||
* which it composes rather than reimplements — and `frontend/src/helpers/pagePaths.js`. It has to
|
||||
* agree with both: a link this reads differently from the router is a backlink pointing somewhere the
|
||||
* reader does not land.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Every href in a render, exactly as written and with repeats left in.
|
||||
*
|
||||
* A page's links are read from its stored HTML rather than from its source, because there is no
|
||||
* markdown parser on this side at all: the render is produced in the editor's browser and sent up,
|
||||
* and it is the one settled form of a page the server holds. It is also what makes rebuilding a
|
||||
* wiki's links cost a parse per page instead of a headless browser per page.
|
||||
*
|
||||
* @param $ A document already loaded, for the caller that has one — `postProcess` is holding the very
|
||||
* tree this would otherwise re-parse.
|
||||
*/
|
||||
export function hrefsFrom($: cheerio.CheerioAPI): string[] {
|
||||
const hrefs: string[] = []
|
||||
for (const el of $('a[href]')) {
|
||||
const href = $(el).attr('href')
|
||||
if (href) {
|
||||
hrefs.push(href)
|
||||
}
|
||||
}
|
||||
return hrefs
|
||||
}
|
||||
|
||||
/** The same, for a stored render with nothing loaded — which is every page but the one being saved. */
|
||||
export function linksFromRender(html?: string | null): string[] {
|
||||
if (!html) {
|
||||
return []
|
||||
}
|
||||
return hrefsFrom(cheerio.load(html, null, false))
|
||||
}
|
||||
|
||||
/** What a link addresses. See the `kind` column. */
|
||||
export type PageLinkKind = 'page' | 'alias' | 'pageId' | 'asset'
|
||||
|
||||
export interface ResolvedLink {
|
||||
kind: PageLinkKind
|
||||
/** The href as written, which is what a repair has to find in the source again. */
|
||||
href: string
|
||||
/** Which site the target is on — the source's own, unless the href named another one. */
|
||||
targetSiteId: string
|
||||
/** Where the target sits, for the two kinds that say. Null for `alias` and `pageId`. */
|
||||
targetLocale: string | null
|
||||
targetPath: string | null
|
||||
/** The alias or the page id, for the two kinds that carry one. */
|
||||
targetRef: string | null
|
||||
}
|
||||
|
||||
/** The page a link is written on, which is what a relative href resolves against. */
|
||||
export interface LinkSource {
|
||||
siteId: string
|
||||
locale: string
|
||||
path: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin every href is resolved against.
|
||||
*
|
||||
* Nothing here cares what the host is, only whether two of them are the same, and a render is
|
||||
* processed outside any request — so there is no real origin to reach for. A placeholder that cannot
|
||||
* collide with a hostname anybody has configured is what makes `new URL()` usable on a relative href,
|
||||
* and comparing what comes out against this is how "did this link stay on this site" is asked.
|
||||
*/
|
||||
const LOCAL_ORIGIN = 'https://page-links.invalid'
|
||||
|
||||
/** Where uploaded files are served from, and the one non-page path worth recording. */
|
||||
const FILES_PREFIX = '/_files/'
|
||||
|
||||
/** The short links to a page that survive a move, and the segment each is addressed by. */
|
||||
const ALIAS_PREFIX = '/a/'
|
||||
const PAGE_ID_PREFIX = '/i/'
|
||||
|
||||
/**
|
||||
* The longest href that gets a row, matching the column it is stored in.
|
||||
*
|
||||
* Not a judgement about what a link may be — it is what a unique btree index can hold, and the insert
|
||||
* is part of saving a page. Past it the link is not recorded, which costs a row in a derived table
|
||||
* and never the save.
|
||||
*/
|
||||
const MAX_HREF_LENGTH = 2048
|
||||
|
||||
/**
|
||||
* Which site an href ended up on, or null when it left the instance.
|
||||
*
|
||||
* A link written as an absolute URL to another site of this wiki is followed rather than written off:
|
||||
* `WIKI.sitesMappings` is the same lookup the request hooks do, so a second site's hostname resolves
|
||||
* to that site and a move there breaks the link just as visibly.
|
||||
*
|
||||
* **The catch-all mapping is deliberately not consulted.** `*` answers for every hostname that
|
||||
* reaches this server, so honouring it here would make every external link a page link — every URL a
|
||||
* page cites would resolve to a path on the site that happens to be bound to `*`. The cost is the
|
||||
* other half of that: on a site with no hostname of its own, a link somebody wrote by pasting the
|
||||
* address out of their browser is not recognised as internal, because there is nothing configured to
|
||||
* recognise it against. Links written as paths — which is every link the link picker produces — never
|
||||
* reach this at all, and are unaffected.
|
||||
*/
|
||||
function siteForHost(hostname: string, sourceSiteId: string): string | null {
|
||||
if (hostname === new URL(LOCAL_ORIGIN).hostname) {
|
||||
return sourceSiteId
|
||||
}
|
||||
return WIKI.sitesMappings?.[hostname] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one href as the address of something in this wiki, or null when it is not one.
|
||||
*
|
||||
* Null covers rather a lot, and all of it on purpose: an empty href, a bare fragment, a `mailto:` or
|
||||
* `tel:`, a link to another server, and every path the wiki serves for itself — `/_admin`, `/login`,
|
||||
* `/_api` — none of which is content this can be asked a question about. Links leaving the wiki are
|
||||
* not recorded at all; see the `kind` column.
|
||||
*
|
||||
* @param source The page the link is written on. A relative href resolves against the URL that page
|
||||
* is served at, locale prefix and all, which is what the browser following it does.
|
||||
*/
|
||||
export function resolveLink(href: string, source: LinkSource): ResolvedLink | null {
|
||||
const raw = (href ?? '').trim()
|
||||
if (raw.length < 1 || raw.length > MAX_HREF_LENGTH || raw.startsWith('#')) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
A scheme that is not http(s) is not a page under any reading -- `mailto:`, `tel:`, `data:`, and
|
||||
the `javascript:` a sanitised render should not be carrying anyway. Tested before `new URL()`
|
||||
rather than after, since those parse perfectly well and would otherwise have to be excluded by
|
||||
protocol afterwards.
|
||||
*/
|
||||
if (/^[a-z][a-z\d+.-]*:/i.test(raw) && !/^https?:/i.test(raw)) {
|
||||
return null
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
// -> The page's own address as the base, so `../two` from `one/deep/three` lands where a reader
|
||||
// clicking it lands. `urlFor` is what puts the site's locale prefix on it, if it uses one
|
||||
url = new URL(
|
||||
raw,
|
||||
`${LOCAL_ORIGIN}${WIKI.models.pages.urlFor(source.siteId, source.locale, source.path)}`
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const targetSiteId = siteForHost(url.hostname, source.siteId)
|
||||
if (!targetSiteId) {
|
||||
return null
|
||||
}
|
||||
|
||||
// -> The query and the fragment address a part of the target, never a different one, so what is
|
||||
// left is the whole of where the link goes
|
||||
const urlPath = url.pathname
|
||||
|
||||
if (urlPath.startsWith(ALIAS_PREFIX)) {
|
||||
const alias = decodeURIComponent(urlPath.slice(ALIAS_PREFIX.length)).replace(/\/+$/, '')
|
||||
return alias.length > 0
|
||||
? {
|
||||
kind: 'alias',
|
||||
href: raw,
|
||||
targetSiteId,
|
||||
targetLocale: null,
|
||||
targetPath: null,
|
||||
targetRef: alias
|
||||
}
|
||||
: null
|
||||
}
|
||||
if (urlPath.startsWith(PAGE_ID_PREFIX)) {
|
||||
const id = decodeURIComponent(urlPath.slice(PAGE_ID_PREFIX.length)).replace(/\/+$/, '')
|
||||
return id.length > 0
|
||||
? {
|
||||
kind: 'pageId',
|
||||
href: raw,
|
||||
targetSiteId,
|
||||
targetLocale: null,
|
||||
targetPath: null,
|
||||
targetRef: id
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
if (urlPath.startsWith(FILES_PREFIX)) {
|
||||
return resolveFile(raw, urlPath, targetSiteId)
|
||||
}
|
||||
|
||||
if (!isPageUrl(urlPath)) {
|
||||
return null
|
||||
}
|
||||
return resolvePage(raw, urlPath, targetSiteId)
|
||||
}
|
||||
|
||||
/**
|
||||
* A page address, as the SEO hook and the router between them read one.
|
||||
*
|
||||
* Same order as `index.ts`: the extension comes off first, since `/en/one/two.md` carries both, and
|
||||
* the locale prefix second. A path arriving without a prefix is the site's primary locale, which is
|
||||
* where the server redirects it and what the app loads for it.
|
||||
*/
|
||||
function resolvePage(href: string, urlPath: string, targetSiteId: string): ResolvedLink | null {
|
||||
const site = WIKI.sites?.[targetSiteId]
|
||||
const trimmed = urlPath.length > 1 && urlPath.endsWith('/') ? urlPath.slice(0, -1) : urlPath
|
||||
|
||||
const withoutExtension = stripPageExtension(trimmed, site?.config?.pageExtensions) ?? trimmed
|
||||
|
||||
const locales = site?.config?.locales
|
||||
const split = splitLocalePath(
|
||||
withoutExtension,
|
||||
WIKI.models.locales.urlPrefixesFor(locales?.active)
|
||||
)
|
||||
|
||||
return {
|
||||
kind: 'page',
|
||||
href,
|
||||
targetSiteId,
|
||||
// -> A path with no prefix names the primary locale, which is where the SEO hook redirects it
|
||||
// and what the app loads for it. Same fallback as `pages.defaultLocale`
|
||||
targetLocale: split?.locale ?? locales?.primary ?? 'en',
|
||||
// -> The site root is the page stored at the empty path, which is what `normalizePagePath` makes
|
||||
// of `/` -- and of `/fr` once its prefix has come off above
|
||||
targetPath: normalizePagePath(split?.path ?? withoutExtension),
|
||||
targetRef: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An uploaded file, addressed the way `controllers/files.ts` serves one.
|
||||
*
|
||||
* `/_files/<folders…>/<name.ext>`, and no locale segment: a file URL names a path and nothing else,
|
||||
* and `getAssetByPath` picks the site's primary locale among the translations filed under it. So
|
||||
* unlike a page link there is no locale to record — the path IS the address.
|
||||
*
|
||||
* Lowercased whole, which is how the lookup reads it: the tree stores a file name in lower case and
|
||||
* `resolveAssetPath` keys its cache on the path in lower case, so two spellings of one file must not
|
||||
* become two targets here.
|
||||
*
|
||||
* Recorded for the same reason a page link is: a file moves when the folder holding it is renamed,
|
||||
* and what pointed at it is the thing worth knowing.
|
||||
*/
|
||||
function resolveFile(href: string, urlPath: string, targetSiteId: string): ResolvedLink | null {
|
||||
const rest = decodeURIComponent(urlPath.slice(FILES_PREFIX.length))
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
.toLowerCase()
|
||||
if (rest.length < 1) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
kind: 'asset',
|
||||
href,
|
||||
targetSiteId,
|
||||
targetLocale: null,
|
||||
targetPath: rest,
|
||||
targetRef: null
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,7 @@
|
||||
"admin.audit.actions.purgeEmptyFolders": "Deleted the empty folders",
|
||||
"admin.audit.actions.purgePageHistory": "Purged page history",
|
||||
"admin.audit.actions.purgeSampleContent": "Purged the sample content",
|
||||
"admin.audit.actions.rebuildPageLinks": "Rebuilt the page link index",
|
||||
"admin.audit.actions.rebuildSearchIndex": "Rebuilt the search index",
|
||||
"admin.audit.actions.refreshIconSets": "Refreshed the icon sets",
|
||||
"admin.audit.actions.regenerateCertificates": "Regenerated the API key certificates",
|
||||
@@ -464,6 +465,8 @@
|
||||
"admin.flags.title": "Flags",
|
||||
"admin.flags.warn.hint": "Doing so may result in data loss, performance issues or a broken installation!",
|
||||
"admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!",
|
||||
"admin.general.allowBacklinks": "Allow Backlinks",
|
||||
"admin.general.allowBacklinksHint": "Give every page a Links tab listing the pages that point at it. A page can still hide its own from its properties, and links keep being recorded either way.",
|
||||
"admin.general.allowBrowse": "Allow Browsing",
|
||||
"admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?",
|
||||
"admin.general.allowCollaborativeEditing": "Allow Collaborative Editing",
|
||||
@@ -1445,6 +1448,12 @@
|
||||
"admin.utilities.purgeSampleFailed": "The sample content could not be deleted.",
|
||||
"admin.utilities.purgeSampleHint": "Delete every page on this site tagged \"test\", which is what Generate Sample Content tags the pages it writes.",
|
||||
"admin.utilities.purgeSampleSuccess": "There was no sample content to delete. | Deleted 1 page. | Deleted {count} pages.",
|
||||
"admin.utilities.rebuildPageLinks": "Rebuild Page Links",
|
||||
"admin.utilities.rebuildPageLinksConfirm": "Every page on every site will be read again to work out what it links to.",
|
||||
"admin.utilities.rebuildPageLinksConfirmWarn": "Nothing about your content is changed — only the record of which page points at which. This normally keeps itself up to date, so it is worth running after importing content written elsewhere, or after changing a site's locale prefixes or page extensions.",
|
||||
"admin.utilities.rebuildPageLinksFailed": "Failed to queue a page link rebuild.",
|
||||
"admin.utilities.rebuildPageLinksHint": "Work out again what every page links to, from the content already stored. Runs in the background.",
|
||||
"admin.utilities.rebuildPageLinksSuccess": "A page link rebuild has been initiated and will start shortly.",
|
||||
"admin.utilities.scanPageProblems": "Scan for Page Problems",
|
||||
"admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.",
|
||||
"admin.utilities.subtitle": "Maintenance and miscellaneous tools",
|
||||
@@ -1793,6 +1802,12 @@
|
||||
"common.license.ccbynd": "Creative Commons Attribution-NoDerivs License",
|
||||
"common.license.ccbysa": "Creative Commons Attribution-ShareAlike License",
|
||||
"common.license.none": "None",
|
||||
"common.links.count": "1 page links here | {count} pages link here",
|
||||
"common.links.loadFailed": "Could not load the pages linking here.",
|
||||
"common.links.loading": "Loading links…",
|
||||
"common.links.none": "No page links here yet.",
|
||||
"common.links.noneHint": "Pages that link to this one will be listed here.",
|
||||
"common.links.title": "Pages linking here",
|
||||
"common.loading": "Loading...",
|
||||
"common.modernBrowser": "modern browser",
|
||||
"common.newpage.create": "Create Page",
|
||||
@@ -1833,6 +1848,7 @@
|
||||
"common.page.suggestSubmitted": "Your suggested edits have been submitted.",
|
||||
"common.page.suggestSubmittedHint": "They are pending review. You will be able to keep editing them until a reviewer accepts or declines them.",
|
||||
"common.page.suggestSubmittedHintGuest": "They are pending review by this site's editors.",
|
||||
"common.page.tabLinks": "Links",
|
||||
"common.page.tags": "Tags",
|
||||
"common.page.tagsMatching": "Pages matching tags",
|
||||
"common.page.toc": "Table of Contents",
|
||||
@@ -2108,6 +2124,7 @@
|
||||
"editor.pendingAssetsNotInSuggestions": "Images and files cannot be attached to a suggested edit.",
|
||||
"editor.pendingAssetsUploading": "Uploading assets...",
|
||||
"editor.props.alias": "Alias",
|
||||
"editor.props.allowBacklinks": "Show Links Tab",
|
||||
"editor.props.allowComments": "Allow Comments",
|
||||
"editor.props.allowCommentsHint": "Enable commenting abilities on this page.",
|
||||
"editor.props.allowContributions": "Allow Contributions",
|
||||
|
||||
@@ -117,6 +117,7 @@ export const AUDIT_ACTIONS = {
|
||||
'updateSecurity',
|
||||
'updateSearchConfig',
|
||||
'rebuildSearchIndex',
|
||||
'rebuildPageLinks',
|
||||
'installExtension',
|
||||
'updateApiState',
|
||||
'updateMetricsState',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { mail } from './mail.ts'
|
||||
import { metrics } from './metrics.ts'
|
||||
import { navigation } from './navigation.ts'
|
||||
import { pageHistory } from './pageHistory.ts'
|
||||
import { pageLinks } from './pageLinks.ts'
|
||||
import { pages } from './pages.ts'
|
||||
import { pageWatching } from './pageWatching.ts'
|
||||
import { passkeys } from './passkeys.ts'
|
||||
@@ -52,6 +53,7 @@ export default {
|
||||
metrics,
|
||||
navigation,
|
||||
pageHistory,
|
||||
pageLinks,
|
||||
pages,
|
||||
pageWatching,
|
||||
passkeys,
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
import { and, eq, ne, or, sql } from 'drizzle-orm'
|
||||
import { pageLinks as pageLinksTable, pages as pagesTable } from '../db/schema.ts'
|
||||
import { linksFromRender, resolveLink } from '../helpers/pageLinks.ts'
|
||||
import type { LinkSource, PageLinkKind, ResolvedLink } from '../helpers/pageLinks.ts'
|
||||
|
||||
/**
|
||||
* Page links model
|
||||
*
|
||||
* What every page points at, kept in step with the page.
|
||||
*
|
||||
* The rows are derived, exactly as `toc` and `searchContent` are, and from the same thing: the stored
|
||||
* render. There is no markdown parser on this side — a page's HTML is produced in the editor's
|
||||
* browser and sent up — so the render is the only settled form of a page the server has, and
|
||||
* `rendering.linksFromRender` is what reads the anchors out of it. Two link sources are not in the
|
||||
* render at all and are collected by the pages model instead: a redirection's target lives in its
|
||||
* content, and a page's relations are stored as their own column.
|
||||
*
|
||||
* Nothing here decides what a link MEANS — `helpers/pageLinks.ts` does that, and is where the rules
|
||||
* for reading a page URL live. This model is the storage half: swap a page's rows, and answer the
|
||||
* three questions the table exists for.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The editor whose content IS a link.
|
||||
*
|
||||
* Spelled out here rather than imported from `models/pages.ts`, which pulls in the storage, search and
|
||||
* tree models behind it — this model is one of the few a worker thread loads, and the rebuild utility
|
||||
* runs in one. See `worker.ts` on what an import costs there.
|
||||
*/
|
||||
const REDIRECT_EDITOR = 'redirect'
|
||||
|
||||
/** The columns of a page that say what it links to. */
|
||||
export interface PageLinkSource {
|
||||
id: string
|
||||
siteId: string
|
||||
locale: string
|
||||
path: string
|
||||
editor: string
|
||||
content: string | null
|
||||
relations: unknown
|
||||
render: string | null
|
||||
}
|
||||
|
||||
/** A link on a page, with the page it was found on. */
|
||||
export interface PageLinkRow {
|
||||
id: string
|
||||
pageId: string
|
||||
kind: PageLinkKind
|
||||
href: string
|
||||
targetSiteId: string
|
||||
targetLocale: string | null
|
||||
targetPath: string | null
|
||||
targetRef: string | null
|
||||
}
|
||||
|
||||
/** One page linking to another, as a "what links here" list shows it. */
|
||||
export interface Backlink {
|
||||
pageId: string
|
||||
siteId: string
|
||||
locale: string
|
||||
path: string
|
||||
title: string
|
||||
description: string | null
|
||||
/** The page's own icon, as an Iconify reference. Empty where it has none. */
|
||||
icon: string | null
|
||||
/** How the link is written, since the same page may point at a target more than one way. */
|
||||
href: string
|
||||
kind: PageLinkKind
|
||||
tags: string[]
|
||||
publishState: string
|
||||
/** Where that page is, as a path on ITS OWN site — locale prefix included only where that site
|
||||
brackets its URLs by one. */
|
||||
url: string
|
||||
/**
|
||||
* The host that site answers on, so a reader looking at one site can still follow a link back to a
|
||||
* page on another. Null for the catch-all site, which has no host of its own to name.
|
||||
*/
|
||||
hostname: string | null
|
||||
}
|
||||
|
||||
/** A link on a page, and whether anything is actually at the other end. */
|
||||
export interface OutboundLink extends PageLinkRow {
|
||||
/** The page it resolves to, or null when nothing is there — a red link. */
|
||||
targetPageId: string | null
|
||||
targetTitle: string | null
|
||||
/*
|
||||
Where the resolved page actually sits, and what it is tagged with, rather than what the link said
|
||||
about it. Both are here so the caller can ask whether this reader may know the page exists: an
|
||||
alias or an id link carries no address at all, and a page rule can be written against tags.
|
||||
*/
|
||||
targetPageLocale: string | null
|
||||
targetPagePath: string | null
|
||||
targetPageTags: string[] | null
|
||||
}
|
||||
|
||||
/** Where a page sits, which is how a link addresses it. */
|
||||
export interface LinkTarget {
|
||||
siteId: string
|
||||
locale: string
|
||||
path: string
|
||||
}
|
||||
|
||||
class PageLinks {
|
||||
/**
|
||||
* Whether this site shows what links to a page.
|
||||
*
|
||||
* **Display only.** Off, the Links tab is not drawn and the route behind it answers 404 — and every
|
||||
* save still records what it links to, exactly as before. That is the whole point of putting the
|
||||
* switch here rather than around `refreshForPage`: a site that turns this off for a year and back
|
||||
* on again has a complete answer waiting, where one that stopped writing the table would have a
|
||||
* year of pages to rebuild before the tab said anything true.
|
||||
*
|
||||
* `!== false` rather than a truth test, so a site whose config blob has never been written with the
|
||||
* key reads as on — which is what the default in `sites.createSite` says it is.
|
||||
*/
|
||||
isAllowed(siteId: string | undefined): boolean {
|
||||
return siteId ? WIKI.sites[siteId]?.config?.features?.backlinks !== false : false
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out everything a page points at, and store it.
|
||||
*
|
||||
* Three sources, because a link is not only a thing in an article:
|
||||
*
|
||||
* - **The render**, which is where an author's own links are, and where nearly all of them are.
|
||||
* - **A redirection's target**, which is not in a render at all — a redirect page has no body, it
|
||||
* has a destination, and it is the strongest link in the wiki: a reader following one never sees
|
||||
* that it broke, they just land nowhere.
|
||||
* - **The page's relations**, the sidebar links its properties dialog collects. Stored as their own
|
||||
* column, written by the same link picker that writes one into content, and just as breakable.
|
||||
*
|
||||
* @param renderHrefs What `postProcess` already read out of the render it just produced, for a save
|
||||
* that is storing one. Absent, the stored render is parsed instead — which is the
|
||||
* case for a page that MOVED: nothing about it changed, but every relative link
|
||||
* on it now resolves somewhere else.
|
||||
*/
|
||||
async refreshForPage(page: PageLinkSource, renderHrefs?: string[]): Promise<void> {
|
||||
const hrefs = renderHrefs ?? linksFromRender(page.render)
|
||||
|
||||
if (page.editor === REDIRECT_EDITOR && page.content) {
|
||||
try {
|
||||
const redirect = JSON.parse(page.content)
|
||||
// -> `kind: 'url'` is a destination off this wiki, which `resolveLink` would discard anyway.
|
||||
// Passed through regardless, so that the one place deciding what is a link stays the one
|
||||
// place deciding it
|
||||
if (typeof redirect?.target === 'string') {
|
||||
hrefs.push(redirect.target)
|
||||
}
|
||||
} catch {
|
||||
// -> A redirection whose content will not parse points nowhere, which is no links rather than
|
||||
// a failed save. `normalizeRedirectContent` is what stops one being written in the first
|
||||
// place
|
||||
}
|
||||
}
|
||||
|
||||
for (const relation of (Array.isArray(page.relations) ? page.relations : []) as any[]) {
|
||||
if (typeof relation?.target === 'string') {
|
||||
hrefs.push(relation.target)
|
||||
}
|
||||
}
|
||||
|
||||
await this.replaceFor(
|
||||
{ pageId: page.id, siteId: page.siteId, locale: page.locale, path: page.path },
|
||||
hrefs
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, for a caller holding an id rather than a row.
|
||||
*
|
||||
* What everything but a create reaches for: a save, a move, a folder rename and the rebuild utility
|
||||
* all need the page as it stands AFTER their write, so reading it back is the point rather than an
|
||||
* overhead.
|
||||
*/
|
||||
async refreshById(siteId: string, pageId: string, renderHrefs?: string[]): Promise<void> {
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
id: pagesTable.id,
|
||||
siteId: pagesTable.siteId,
|
||||
locale: pagesTable.locale,
|
||||
path: pagesTable.path,
|
||||
editor: pagesTable.editor,
|
||||
content: pagesTable.content,
|
||||
relations: pagesTable.relations,
|
||||
render: pagesTable.render
|
||||
})
|
||||
.from(pagesTable)
|
||||
.where(and(eq(pagesTable.id, pageId), eq(pagesTable.siteId, siteId)))
|
||||
.limit(1)
|
||||
|
||||
// -> Gone while the save that asked for this was in flight. Its rows went with it
|
||||
if (rows[0]) {
|
||||
await this.refreshForPage(rows[0] as PageLinkSource, renderHrefs)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every link recorded for a page.
|
||||
*
|
||||
* Wholesale rather than differential: a save rewrites the page, and working out which links
|
||||
* survived it costs more than the handful of rows it would save. In one transaction so that a page
|
||||
* is never momentarily linkless — the backlinks of everything it points at would flicker.
|
||||
*
|
||||
* Resolution happens here rather than at extraction time because it needs the page's own address:
|
||||
* `../two` means a different page depending on where the page holding it sits, which is also why
|
||||
* this has to run again when a page MOVES even though nothing about its content changed.
|
||||
*
|
||||
* Unresolvable hrefs are dropped silently — an external link, a `mailto:`, a bare fragment, an
|
||||
* admin path. See `resolveLink` for the whole list; none of them is a link this can be asked a
|
||||
* question about.
|
||||
*
|
||||
* @param source The page the links were found on
|
||||
* @param hrefs Every href the page carries, in any order and with repeats
|
||||
*/
|
||||
async replaceFor(source: LinkSource & { pageId: string }, hrefs: string[]): Promise<void> {
|
||||
const resolved = new Map<string, ResolvedLink>()
|
||||
for (const href of hrefs) {
|
||||
const link = resolveLink(href, source)
|
||||
if (link) {
|
||||
// -> One row per spelling, so the same href written twice on a page is one link. Keyed on the
|
||||
// href as written, which is what the unique index holds
|
||||
resolved.set(link.href, link)
|
||||
}
|
||||
}
|
||||
|
||||
await WIKI.db.transaction(async (trx: any) => {
|
||||
await trx.delete(pageLinksTable).where(eq(pageLinksTable.pageId, source.pageId))
|
||||
if (resolved.size < 1) {
|
||||
return
|
||||
}
|
||||
await trx.insert(pageLinksTable).values(
|
||||
[...resolved.values()].map((link) => ({
|
||||
pageId: source.pageId,
|
||||
siteId: source.siteId,
|
||||
kind: link.kind,
|
||||
href: link.href,
|
||||
targetSiteId: link.targetSiteId,
|
||||
targetLocale: link.targetLocale,
|
||||
targetPath: link.targetPath,
|
||||
targetRef: link.targetRef
|
||||
}))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Forget everything a page pointed at, for a page that is going. */
|
||||
async removeFor(pageId: string): Promise<void> {
|
||||
await WIKI.db.delete(pageLinksTable).where(eq(pageLinksTable.pageId, pageId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every page linking to a target, as "what links here" asks it.
|
||||
*
|
||||
* Addressed by locale and path rather than by page id, and deliberately: a link says where it
|
||||
* points, so the pages pointing at a page that has moved are still pointing at where it WAS. Asking
|
||||
* by address is what lets a move ask about the path it is leaving, which is the only time that
|
||||
* question has an answer.
|
||||
*
|
||||
* The two id-addressed kinds are folded in by page id, since those survive a move by design and
|
||||
* would otherwise be missing from the one list anybody reads.
|
||||
*
|
||||
* Nothing here filters by what the caller may read: a backlink names a page, and which of them this
|
||||
* reader is allowed to know about is a page rule the route resolves — see `api/pages.ts`.
|
||||
*/
|
||||
async backlinksFor(
|
||||
target: LinkTarget,
|
||||
{ pageId, alias }: { pageId?: string; alias?: string | null } = {}
|
||||
): Promise<Backlink[]> {
|
||||
const addressedByPath = and(
|
||||
eq(pageLinksTable.kind, 'page'),
|
||||
eq(pageLinksTable.targetLocale, target.locale),
|
||||
eq(pageLinksTable.targetPath, target.path)
|
||||
)
|
||||
const addressedById = pageId
|
||||
? and(eq(pageLinksTable.kind, 'pageId'), eq(pageLinksTable.targetRef, pageId))
|
||||
: undefined
|
||||
const addressedByAlias = alias
|
||||
? and(eq(pageLinksTable.kind, 'alias'), eq(pageLinksTable.targetRef, alias))
|
||||
: undefined
|
||||
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
pageId: pageLinksTable.pageId,
|
||||
href: pageLinksTable.href,
|
||||
kind: pageLinksTable.kind,
|
||||
siteId: pagesTable.siteId,
|
||||
locale: pagesTable.locale,
|
||||
path: pagesTable.path,
|
||||
title: pagesTable.title,
|
||||
description: pagesTable.description,
|
||||
icon: pagesTable.icon,
|
||||
tags: pagesTable.tags,
|
||||
publishState: pagesTable.publishState
|
||||
})
|
||||
.from(pageLinksTable)
|
||||
.innerJoin(pagesTable, eq(pagesTable.id, pageLinksTable.pageId))
|
||||
.where(
|
||||
and(
|
||||
eq(pageLinksTable.targetSiteId, target.siteId),
|
||||
// -> A page linking to itself is not a backlink, it is a page mentioning where it already is
|
||||
...(pageId ? [ne(pageLinksTable.pageId, pageId)] : []),
|
||||
or(addressedByPath, addressedById, addressedByAlias)
|
||||
)
|
||||
)
|
||||
.orderBy(pagesTable.locale, pagesTable.path)
|
||||
|
||||
return rows.map((row: any) => ({
|
||||
...row,
|
||||
tags: row.tags ?? [],
|
||||
// -> A backlink may be written on another site of this instance, and a bare path would resolve
|
||||
// against the host the reader is already on. Same pair as `getRecentlyEdited`, for the same
|
||||
// reason: `*` is the catch-all rather than a host, and a link to it is wherever you are
|
||||
url: WIKI.models.pages.urlFor(row.siteId, row.locale, row.path),
|
||||
hostname:
|
||||
WIKI.sites[row.siteId]?.hostname && WIKI.sites[row.siteId].hostname !== '*'
|
||||
? WIKI.sites[row.siteId].hostname
|
||||
: null
|
||||
})) as Backlink[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link written on a page, with whether the far end exists.
|
||||
*
|
||||
* The join is what answers the red-link question, and it is a join rather than a stored flag for
|
||||
* the reason the whole table is addressed by path: a page created at a path somebody had already
|
||||
* linked to makes that link good, without anything having to notice.
|
||||
*
|
||||
* Assets are left unresolved here — they are recorded so that a moved file can be traced back to
|
||||
* what pointed at it, and answering whether one exists means a tree lookup per link.
|
||||
*/
|
||||
async outboundFor(pageId: string): Promise<OutboundLink[]> {
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
id: pageLinksTable.id,
|
||||
pageId: pageLinksTable.pageId,
|
||||
kind: pageLinksTable.kind,
|
||||
href: pageLinksTable.href,
|
||||
targetSiteId: pageLinksTable.targetSiteId,
|
||||
targetLocale: pageLinksTable.targetLocale,
|
||||
targetPath: pageLinksTable.targetPath,
|
||||
targetRef: pageLinksTable.targetRef,
|
||||
targetPageId: pagesTable.id,
|
||||
targetTitle: pagesTable.title,
|
||||
targetPageLocale: pagesTable.locale,
|
||||
targetPagePath: pagesTable.path,
|
||||
targetPageTags: pagesTable.tags
|
||||
})
|
||||
.from(pageLinksTable)
|
||||
.leftJoin(
|
||||
pagesTable,
|
||||
or(
|
||||
and(
|
||||
eq(pageLinksTable.kind, 'page'),
|
||||
eq(pagesTable.siteId, pageLinksTable.targetSiteId),
|
||||
eq(pagesTable.locale, pageLinksTable.targetLocale),
|
||||
eq(pagesTable.path, pageLinksTable.targetPath)
|
||||
),
|
||||
and(
|
||||
eq(pageLinksTable.kind, 'pageId'),
|
||||
sql`${pagesTable.id}::text = ${pageLinksTable.targetRef}`
|
||||
),
|
||||
and(
|
||||
eq(pageLinksTable.kind, 'alias'),
|
||||
eq(pagesTable.siteId, pageLinksTable.targetSiteId),
|
||||
eq(pagesTable.alias, pageLinksTable.targetRef)
|
||||
)
|
||||
)
|
||||
)
|
||||
.where(eq(pageLinksTable.pageId, pageId))
|
||||
.orderBy(pageLinksTable.href)
|
||||
|
||||
return rows as OutboundLink[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The pages that will be left pointing at nothing if these targets move or go.
|
||||
*
|
||||
* The bulk form of `backlinksFor`, for a folder rename — which moves every page under it at once —
|
||||
* and for anything else that has to warn about a change before making it. One query for the lot,
|
||||
* since a rename can move hundreds of pages and asking per page would be hundreds of round trips.
|
||||
*
|
||||
* Only path-addressed links: the id and alias kinds survive a move, so they are not affected by
|
||||
* one and do not belong in a list of what it breaks.
|
||||
*
|
||||
* @returns The id of every page holding such a link, without repeats
|
||||
*/
|
||||
async dependentsOf(
|
||||
siteId: string,
|
||||
targets: { locale: string; path: string }[]
|
||||
): Promise<string[]> {
|
||||
if (targets.length < 1) {
|
||||
return []
|
||||
}
|
||||
const rows = await WIKI.db
|
||||
.selectDistinct({ pageId: pageLinksTable.pageId })
|
||||
.from(pageLinksTable)
|
||||
.where(
|
||||
and(
|
||||
eq(pageLinksTable.targetSiteId, siteId),
|
||||
eq(pageLinksTable.kind, 'page'),
|
||||
or(
|
||||
...targets.map((target) =>
|
||||
and(
|
||||
eq(pageLinksTable.targetLocale, target.locale),
|
||||
eq(pageLinksTable.targetPath, target.path)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return rows.map((row: any) => row.pageId as string)
|
||||
}
|
||||
}
|
||||
|
||||
export const pageLinks = new PageLinks()
|
||||
+50
-6
@@ -134,6 +134,7 @@ const reAlias = /^[a-zA-Z0-9-_]*$/
|
||||
|
||||
/** Fields kept in the `config` blob rather than as columns, and flattened again on the way out. */
|
||||
const CONFIG_FIELDS = [
|
||||
'allowBacklinks',
|
||||
'allowComments',
|
||||
'allowContributions',
|
||||
'allowRatings',
|
||||
@@ -207,6 +208,8 @@ export interface Page {
|
||||
password?: string | null
|
||||
/** Whether the body was withheld because the page is password protected. See `getPage`. */
|
||||
isLocked: boolean
|
||||
/** Whether this page shows what links to it. The site-wide switch is `features.backlinks`. */
|
||||
allowBacklinks: boolean
|
||||
relations: any[]
|
||||
/**
|
||||
* This page in the other locales, as far as the requester may see them. Empty for a page that is
|
||||
@@ -257,6 +260,7 @@ export interface PageInput {
|
||||
isBrowsable?: boolean
|
||||
isSearchable?: boolean
|
||||
password?: string
|
||||
allowBacklinks?: boolean
|
||||
relations?: any[]
|
||||
/**
|
||||
* The page's counterparts in other locales, stating the whole set rather than adding to it: a locale
|
||||
@@ -525,6 +529,12 @@ class Pages {
|
||||
...((withContent || row.editor === REDIRECT_EDITOR) && !locked
|
||||
? { content: row.content ?? '' }
|
||||
: {}),
|
||||
/*
|
||||
Absent means yes, as every one of these does: the key is only written once somebody has
|
||||
edited the page's properties, and a page nobody has touched shows what links to it. The
|
||||
site-wide switch is `features.backlinks` -- see `pageLinks.isAllowed`.
|
||||
*/
|
||||
allowBacklinks: config.allowBacklinks ?? true,
|
||||
allowComments: config.allowComments ?? true,
|
||||
allowContributions: config.allowContributions ?? true,
|
||||
allowRatings: config.allowRatings ?? true,
|
||||
@@ -1436,7 +1446,7 @@ class Pages {
|
||||
})
|
||||
|
||||
const alias = await this.validateAlias(siteId, input.alias)
|
||||
const { render, toc, text } = await WIKI.models.rendering.postProcess(
|
||||
const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
|
||||
siteId,
|
||||
input.render ?? '',
|
||||
{
|
||||
@@ -1518,6 +1528,10 @@ class Pages {
|
||||
throw err
|
||||
}
|
||||
|
||||
// -> What this page points at, which is only knowable once it has an id and an address of its
|
||||
// own: a relative link resolves against the page holding it
|
||||
await WIKI.models.pageLinks.refreshForPage(page, links)
|
||||
|
||||
const versionId = await WIKI.models.pageHistory.record({
|
||||
siteId,
|
||||
pageId: page.id,
|
||||
@@ -1646,14 +1660,20 @@ class Pages {
|
||||
}
|
||||
|
||||
// -> A render only means anything next to the content it came from, so the two move together
|
||||
let renderHrefs: string[] | undefined
|
||||
if (patch.render !== undefined) {
|
||||
const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, patch.render, {
|
||||
scripts: hasPermission(actor, 'write:scripts'),
|
||||
styles: hasPermission(actor, 'write:styles')
|
||||
})
|
||||
const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
|
||||
siteId,
|
||||
patch.render,
|
||||
{
|
||||
scripts: hasPermission(actor, 'write:scripts'),
|
||||
styles: hasPermission(actor, 'write:styles')
|
||||
}
|
||||
)
|
||||
values.render = render
|
||||
values.toc = toc
|
||||
values.searchContent = text
|
||||
renderHrefs = links
|
||||
}
|
||||
|
||||
if (CONFIG_FIELDS.some((field) => patch[field] !== undefined)) {
|
||||
@@ -1678,6 +1698,14 @@ class Pages {
|
||||
|
||||
const updated = (await this.getPage({ siteId, id })) as Page
|
||||
|
||||
/*
|
||||
Unconditionally, and not only for a save that carried a render: a redirection's destination is
|
||||
its content, and the sidebar relations are a column of their own, so a save touching either of
|
||||
those changes what the page links to without the render moving at all. Read back rather than
|
||||
assembled from the patch, for the same reason `changedFields` is worked out against the row.
|
||||
*/
|
||||
await WIKI.models.pageLinks.refreshById(siteId, id, renderHrefs)
|
||||
|
||||
const versionId = await WIKI.models.pageHistory.record({
|
||||
siteId,
|
||||
pageId: id,
|
||||
@@ -1906,6 +1934,14 @@ class Pages {
|
||||
*/
|
||||
await WIKI.models.search.indexPage(id, newLocale)
|
||||
|
||||
/*
|
||||
Not the pages pointing AT this one -- those still say where it was, which is the thing worth
|
||||
knowing and exactly what the table is for. This is the other direction: every relative link the
|
||||
moved page itself carries resolved against the folder it used to sit in, and now resolves
|
||||
against a different one. Nothing about its content changed and every one of its links may have.
|
||||
*/
|
||||
await WIKI.models.pageLinks.refreshById(siteId, id)
|
||||
|
||||
// -> Moved and then rewritten, rather than deleted and written afresh: the move is what keeps a
|
||||
// versioned target's history of the file attached to it, and the rewrite is because a move may
|
||||
// carry a new title and always carries a new modification time, both of which are in the copy
|
||||
@@ -2432,7 +2468,11 @@ class Pages {
|
||||
html: string,
|
||||
permissions: RenderPermissions
|
||||
): Promise<void> {
|
||||
const { render, toc, text } = await WIKI.models.rendering.postProcess(siteId, html, permissions)
|
||||
const { render, toc, text, links } = await WIKI.models.rendering.postProcess(
|
||||
siteId,
|
||||
html,
|
||||
permissions
|
||||
)
|
||||
|
||||
const updated = await WIKI.db
|
||||
.update(pagesTable)
|
||||
@@ -2443,6 +2483,9 @@ class Pages {
|
||||
// -> Nothing was updated when the page went while it sat in the queue
|
||||
if (updated[0]) {
|
||||
await WIKI.models.search.indexPage(id, updated[0].locale)
|
||||
// -> An imported page is saved with no render at all and gets one from here, so this is where
|
||||
// its links first appear
|
||||
await WIKI.models.pageLinks.refreshById(siteId, id, links)
|
||||
// -> This is the column the injected copy of a page IS, so a re-render changes it
|
||||
invalidateAppShellCache()
|
||||
}
|
||||
@@ -2547,6 +2590,7 @@ class Pages {
|
||||
): Record<string, any> {
|
||||
const defaults = WIKI.sites[siteId]?.config?.defaults ?? {}
|
||||
return {
|
||||
allowBacklinks: input.allowBacklinks ?? existing.allowBacklinks ?? true,
|
||||
allowComments: input.allowComments ?? existing.allowComments ?? true,
|
||||
allowContributions: input.allowContributions ?? existing.allowContributions ?? true,
|
||||
allowRatings: input.allowRatings ?? existing.allowRatings ?? true,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { eq, inArray, sql } from 'drizzle-orm'
|
||||
import { flipFromString, rotateFromString } from '@iconify/utils'
|
||||
import { jobs as jobsTable, pageRenderQueue as renderQueueTable } from '../db/schema.ts'
|
||||
import { CustomError } from '../helpers/common.ts'
|
||||
import { hrefsFrom } from '../helpers/pageLinks.ts'
|
||||
import type { IconifyIcon } from '@iconify/types'
|
||||
import type { IconifyIconCustomisations } from '@iconify/utils'
|
||||
|
||||
@@ -64,6 +65,15 @@ export interface PostProcessResult {
|
||||
toc: TocNode[]
|
||||
/** Plain text, for the search index. */
|
||||
text: string
|
||||
/**
|
||||
* Every href the page carries, exactly as written and with repeats left in.
|
||||
*
|
||||
* Raw on purpose: what an href ADDRESSES depends on where the page holding it sits, on the site's
|
||||
* locale prefixes and on its page extensions, none of which is rendering's business. See
|
||||
* `helpers/pageLinks.ts`, which reads them, and `models/pageLinks.ts`, which stores what it makes
|
||||
* of them.
|
||||
*/
|
||||
links: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -376,7 +386,10 @@ class Rendering {
|
||||
return {
|
||||
render: $.html(),
|
||||
toc,
|
||||
text: this.extractText($)
|
||||
text: this.extractText($),
|
||||
// -> Read off the tree that is already loaded rather than by re-parsing the output, which is
|
||||
// what `linksFromRender` has to do for a page nobody is saving
|
||||
links: hrefsFrom($)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,11 @@ class Sites {
|
||||
}
|
||||
},
|
||||
features: {
|
||||
// -> On, because what a wiki is for is pages that point at each other, and what points at
|
||||
// the page in front of you is worth knowing by default. It gates the TAB and the route
|
||||
// behind it; links are recorded either way, so turning it off and on again shows the
|
||||
// same list rather than an empty one.
|
||||
backlinks: true,
|
||||
browse: true,
|
||||
collaborativeEditing: true,
|
||||
ratings: false,
|
||||
@@ -421,6 +426,7 @@ class Sites {
|
||||
}
|
||||
},
|
||||
features: {
|
||||
backlinks: true,
|
||||
browse: true,
|
||||
collaborativeEditing: true,
|
||||
ratings: false,
|
||||
|
||||
@@ -1001,6 +1001,13 @@ class Tree {
|
||||
// -> A folder rename never crosses locales, so the page's own is where it came from too
|
||||
{ locale: page.locale, path: page.previousPath }
|
||||
)
|
||||
/*
|
||||
And the links each page WRITES, which is the other thing a path carries: a relative link
|
||||
resolved against the folder these pages used to sit in, and resolves against the renamed one
|
||||
now. The links pointing at them are deliberately left alone -- they say where the pages were,
|
||||
which is what makes them findable as the ones this rename has broken.
|
||||
*/
|
||||
await WIKI.models.pageLinks.refreshById(folder.siteId, page.id)
|
||||
}
|
||||
|
||||
// -> A storage target that lays its content out by path has every one of those files to move.
|
||||
@@ -1205,6 +1212,7 @@ class Tree {
|
||||
password: page.password ?? '',
|
||||
relations: page.relations,
|
||||
tags: page.tags,
|
||||
allowBacklinks: page.allowBacklinks,
|
||||
allowComments: page.allowComments,
|
||||
allowContributions: page.allowContributions,
|
||||
allowRatings: page.allowRatings,
|
||||
@@ -1470,6 +1478,9 @@ class Tree {
|
||||
},
|
||||
{ locale: folder.locale, path: page.previousPath }
|
||||
)
|
||||
// -> As in `renameFolder`: every relative link these pages carry resolved against where they
|
||||
// were. This move can cross locales as well, which changes what a link's prefix means too
|
||||
await WIKI.models.pageLinks.refreshById(siteId, page.id)
|
||||
}
|
||||
|
||||
// -> A storage target that lays its content out by path has every one of those files to move.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import NodeCache from 'node-cache'
|
||||
import { asc, gt } from 'drizzle-orm'
|
||||
import { pages as pagesTable } from '../../db/schema.ts'
|
||||
import { locales } from '../../models/locales.ts'
|
||||
import { pageLinks } from '../../models/pageLinks.ts'
|
||||
import { pages } from '../../models/pages.ts'
|
||||
import { settings } from '../../models/settings.ts'
|
||||
import { sites } from '../../models/sites.ts'
|
||||
|
||||
/**
|
||||
* Work out what every page on the wiki links to, from scratch.
|
||||
*
|
||||
* The links of a page are derived from its stored render and rewritten whenever that render is, so
|
||||
* ordinary editing keeps them current on its own. This is for the cases where there was nothing to
|
||||
* derive them from at the time: pages that predate the table, and a wiki whose locale prefixes or
|
||||
* page extensions changed, which silently changes what a link that was already written ADDRESSES.
|
||||
*
|
||||
* Offered under Admin → Utilities and never run on its own. It is not a migration and not a boot step:
|
||||
* a wiki with no links recorded works, it simply has no backlinks to show yet, and a rebuild that ran
|
||||
* itself on every start would re-read every render in the wiki to produce what is usually the same
|
||||
* rows.
|
||||
*
|
||||
* **In a worker thread**, which is what this file being here means — `addJob` sends a task with no
|
||||
* in-process implementation to the pool. A wiki's pages are its whole content, and parsing every
|
||||
* render in the instance is minutes of CPU on a large one: on the main thread that is the event loop
|
||||
* not serving pages for the duration.
|
||||
*
|
||||
* The price is that a worker starts with nothing but config and a logger (see `worker.ts`), and
|
||||
* resolving a link needs rather more than that — which is what `prepare` below is for.
|
||||
*/
|
||||
|
||||
/** How many pages are read at once. A render is a whole page of HTML, so this is a memory ceiling. */
|
||||
const BATCH_SIZE = 50
|
||||
|
||||
/**
|
||||
* Put back the parts of the `WIKI` global that reading a link needs.
|
||||
*
|
||||
* A worker thread is deliberately bare, and what an href means is decided against a good deal of
|
||||
* instance state: which hostname belongs to which site, what a site's locale prefixes and page
|
||||
* extensions are, and what short code each locale answers to. All of it is loaded here exactly as
|
||||
* `postBoot` loads it on the main thread.
|
||||
*
|
||||
* Only the four models involved, rather than the whole registry — `WIKI.models` is what `reloadCache`
|
||||
* and the resolver reach through, and importing the rest would pull the storage, search and mail
|
||||
* models into a thread that is reading HTML.
|
||||
*/
|
||||
async function prepare(): Promise<void> {
|
||||
await WIKI.ensureDb!()
|
||||
WIKI.cache = new NodeCache({ checkperiod: 0 })
|
||||
WIKI.models = { settings, locales, pages, pageLinks } as typeof WIKI.models
|
||||
// -> Locales first: the site cache reads nothing from them, but a site's URL prefixes are resolved
|
||||
// through the locale cache the moment the first link is read
|
||||
await locales.reloadCache()
|
||||
await sites.reloadCache()
|
||||
}
|
||||
|
||||
export async function task(): Promise<void> {
|
||||
await prepare()
|
||||
|
||||
let cursor: string | null = null
|
||||
let processed = 0
|
||||
|
||||
WIKI.logger.info('Rebuilding page links...')
|
||||
for (;;) {
|
||||
/*
|
||||
Walked by id rather than by offset: this reads every page in the wiki one batch at a time, and a
|
||||
paged read that re-counts its way to each batch gets slower as it goes. Nothing is being written
|
||||
to `pages` here, so the set is stable underneath it.
|
||||
*/
|
||||
const rows = await WIKI.db
|
||||
.select({
|
||||
id: pagesTable.id,
|
||||
siteId: pagesTable.siteId,
|
||||
locale: pagesTable.locale,
|
||||
path: pagesTable.path,
|
||||
editor: pagesTable.editor,
|
||||
content: pagesTable.content,
|
||||
relations: pagesTable.relations,
|
||||
render: pagesTable.render
|
||||
})
|
||||
.from(pagesTable)
|
||||
.where(cursor ? gt(pagesTable.id, cursor) : undefined)
|
||||
.orderBy(asc(pagesTable.id))
|
||||
.limit(BATCH_SIZE)
|
||||
|
||||
if (rows.length < 1) {
|
||||
break
|
||||
}
|
||||
for (const row of rows) {
|
||||
/*
|
||||
One page at a time, and one failure does not stop the rest: this is a repair, and stopping at
|
||||
the first page whose render will not parse would leave the wiki with the half of its links it
|
||||
already had plus however far this got.
|
||||
*/
|
||||
try {
|
||||
await pageLinks.refreshForPage(row)
|
||||
} catch (err: any) {
|
||||
WIKI.logger.warn(`Could not rebuild the links of page ${row.id}: ${err.message}`)
|
||||
}
|
||||
processed++
|
||||
}
|
||||
cursor = rows.at(-1)!.id
|
||||
}
|
||||
|
||||
WIKI.logger.info(`Rebuilt the links of ${processed} page(s) [ COMPLETED ]`)
|
||||
}
|
||||
Reference in new issue
Block a user