diff --git a/backend/api/system.ts b/backend/api/system.ts
index 7bad19fc2..3b29c68ec 100644
--- a/backend/api/system.ts
+++ b/backend/api/system.ts
@@ -12,6 +12,7 @@ import {
} from '../db/schema.ts'
import maintenance from '../core/maintenance.ts'
import { audit } from '../helpers/audit.ts'
+import { PAGE_PROBLEM_CHECKS } from '../models/pageProblems.ts'
import { purgeTimeframes } from '../models/pageHistory.ts'
import type { PurgeTimeframe } from '../models/pageHistory.ts'
import type { FastifyInstance } from 'fastify'
@@ -1554,6 +1555,183 @@ async function routes(app: FastifyInstance) {
}
)
+ /**
+ * REBUILD PAGE RATINGS
+ *
+ * The totals cached on every page, counted again from the ratings themselves.
+ *
+ * A page's cache is rewritten whenever one of its ratings is, so this is for the rows that changed
+ * without that happening — a deleted account's ratings going with it by cascade, most often. One
+ * statement per direction rather than a job: it is an aggregate over one table, not a read of every
+ * page's content.
+ */
+ app.post(
+ '/page-ratings/rebuild',
+ {
+ config: {
+ permissions: ['manage:system']
+ },
+ schema: {
+ summary: 'Recount the ratings cached on every page',
+ description:
+ 'On every site. Rewrites the rating totals each page carries from the individual ratings recorded for it, and clears them on a page nobody has rated. Every page is locked for the length of the recount, so a rating given meanwhile waits for it rather than being left out.',
+ tags: ['System'],
+ response: {
+ 200: {
+ description: 'Page ratings rebuilt successfully',
+ type: 'object',
+ properties: {
+ ok: {
+ type: 'boolean'
+ },
+ message: {
+ type: 'string'
+ },
+ count: {
+ type: 'number',
+ description: 'Pages whose cached totals were wrong and have been corrected.'
+ }
+ }
+ }
+ }
+ }
+ },
+ async (req) => {
+ const count = await WIKI.models.pageRatings.rebuildAll()
+ await audit(req, 'admin', 'rebuildPageRatings', { count })
+
+ return {
+ ok: true,
+ message: `Corrected the ratings of ${count} page(s).`,
+ count
+ }
+ }
+ )
+
+ /**
+ * SCAN FOR PAGE PROBLEMS
+ *
+ * One batch of a read-only scan of every page on every site — see `models/pageProblems.ts` for the
+ * checks. The admin screen calls this until `cursor` comes back null, which is what gives it a
+ * progress bar and a way to stop without a job to cancel. A GET, and not audited: nothing is written.
+ */
+ app.get<{ Querystring: { cursor?: string } }>(
+ '/page-problems',
+ {
+ config: {
+ permissions: ['manage:system']
+ },
+ schema: {
+ summary: 'Scan pages for problems, one batch at a time',
+ description:
+ 'Checks every page on every site for broken or out-of-step data: empty content or render, unreadable settings documents, unknown or disabled editors, broken redirections, tree entries that are missing, misplaced, stale or orphaned, path hashes and aliases, inactive locales, orphaned translation groups, publishing dates, the search index and sidebar menus. Nothing is changed.\n\nCall without a cursor to start; the first reply also says how many pages there are and how many rows the whole scan will read. Call again with the `cursor` each reply returns until it is null. `GET /system/page-problems/checks` lists what is checked.',
+ tags: ['System'],
+ querystring: {
+ type: 'object',
+ properties: {
+ cursor: {
+ type: 'string',
+ pattern: '^[pt]:([0-9a-f-]{36})?$',
+ description: 'Where the previous batch stopped. Omit to start a scan.'
+ }
+ }
+ },
+ response: {
+ 200: {
+ description: 'One batch of the scan',
+ type: 'object',
+ properties: {
+ ok: { type: 'boolean' },
+ pages: {
+ type: 'number',
+ description: 'Pages the scan checks, on every site. First batch only.'
+ },
+ total: {
+ type: 'number',
+ description:
+ 'Rows the whole scan reads — the pages, then every page entry in the file tree — which is what progress is measured against. First batch only.'
+ },
+ scanned: { type: 'number', description: 'Rows this batch read.' },
+ cursor: {
+ type: ['string', 'null'],
+ description: 'What to send for the next batch; null once the scan is complete.'
+ },
+ problems: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ check: { type: 'string' },
+ severity: { type: 'string', enum: ['error', 'warning'] },
+ pageId: { type: ['string', 'null'], format: 'uuid' },
+ siteId: { type: 'string', format: 'uuid' },
+ locale: { type: 'string' },
+ path: { type: 'string' },
+ title: { type: 'string' },
+ url: {
+ type: ['string', 'null'],
+ description: 'Where to open the page; null for a tree entry with no page.'
+ },
+ params: {
+ type: 'object',
+ additionalProperties: { type: ['string', 'number'] },
+ description: 'Details filled into the problem’s message.'
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ async (req) => {
+ const batch = await WIKI.models.pageProblems.scan(req.query.cursor)
+ return {
+ ok: true,
+ ...(req.query.cursor ? {} : await WIKI.models.pageProblems.size()),
+ ...batch
+ }
+ }
+ )
+
+ /**
+ * What the page problem scan checks, so the admin screen can list them before a scan has run —
+ * and so that the list, the severities and the grouping are the server's alone.
+ */
+ app.get(
+ '/page-problems/checks',
+ {
+ config: {
+ permissions: ['manage:system']
+ },
+ schema: {
+ summary: 'List the checks the page problem scan runs',
+ tags: ['System'],
+ response: {
+ 200: {
+ type: 'object',
+ properties: {
+ ok: { type: 'boolean' },
+ checks: {
+ type: 'array',
+ items: {
+ type: 'object',
+ properties: {
+ key: { type: 'string' },
+ group: { type: 'string' },
+ severity: { type: 'string', enum: ['error', 'warning'] }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ async () => ({ ok: true, checks: PAGE_PROBLEM_CHECKS })
+ )
+
/**
* REBUILD PAGE LINKS
*
diff --git a/backend/locales/en.json b/backend/locales/en.json
index da2f9c16a..e2a5fda46 100644
--- a/backend/locales/en.json
+++ b/backend/locales/en.json
@@ -184,6 +184,7 @@
"admin.audit.actions.purgeSampleContent": "Purged the sample content",
"admin.audit.actions.ratePage": "Rated a page",
"admin.audit.actions.rebuildPageLinks": "Rebuilt the page link index",
+ "admin.audit.actions.rebuildPageRatings": "Recounted the page ratings",
"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",
@@ -1145,7 +1146,7 @@
"admin.system.browser": "Browser",
"admin.system.browserHint": "The browser name and version.",
"admin.system.checkForUpdates": "Check",
- "admin.system.checkUpdate": "Check / Upgrade",
+ "admin.system.checkUpdate": "Check for Updates",
"admin.system.checkingForUpdates": "Checking for Updates...",
"admin.system.client": "Client",
"admin.system.clientCookies": "Cookies Support",
@@ -1181,6 +1182,7 @@
"admin.system.published": "Published",
"admin.system.ramUsage": "RAM Usage: {used} / {total}",
"admin.system.refreshSuccess": "System Info has been refreshed.",
+ "admin.system.releaseNotes": "View Release Notes",
"admin.system.runningLatestVersion": "You're running the latest version.",
"admin.system.subtitle": "Information about your server / client",
"admin.system.title": "System Info",
@@ -1480,6 +1482,74 @@
"admin.utilities.invalidSessionSecretConfirmWarn": "Everyone is logged out immediately, you included — you will have to sign in again. The new secret is only used for signing once each server has been restarted. API keys are unaffected.",
"admin.utilities.invalidSessionSecretFailed": "Failed to rotate the user sessions secret.",
"admin.utilities.invalidSessionSecretHint": "Rotate the secret used to sign session cookies and end every open session. Everyone is logged out.",
+ "admin.utilities.pageProblems.checksTitle": "Checklist",
+ "admin.utilities.pageProblems.logClean": "Scan complete: no problems found.",
+ "admin.utilities.pageProblems.logFinished": "Scan complete: {errors} error(s) and {warnings} warning(s) found.",
+ "admin.utilities.pageProblems.logStarted": "Scan started: {pages} page(s) to check, on every site.",
+ "admin.utilities.pageProblems.logStopped": "Scan stopped after {scanned} of {total}.",
+ "admin.utilities.pageProblems.noProblems": "No problems found.",
+ "admin.utilities.pageProblems.progress": "Progress",
+ "admin.utilities.pageProblems.progressEmpty": "Start a scan to check every page on every site. Nothing is changed: problems are only reported.",
+ "admin.utilities.pageProblems.start": "Start Scan",
+ "admin.utilities.pageProblems.stop": "Stop Scan",
+ "admin.utilities.pageProblems.subtitle": "Check every page on every site for broken or out-of-step data",
+ "admin.utilities.pageProblems.groups.content": "Content",
+ "admin.utilities.pageProblems.groups.editor": "Editor",
+ "admin.utilities.pageProblems.groups.redirects": "Redirections",
+ "admin.utilities.pageProblems.groups.tree": "File Tree",
+ "admin.utilities.pageProblems.groups.address": "Address",
+ "admin.utilities.pageProblems.groups.locales": "Locales",
+ "admin.utilities.pageProblems.groups.publishing": "Publishing",
+ "admin.utilities.pageProblems.groups.search": "Search",
+ "admin.utilities.pageProblems.groups.navigation": "Navigation",
+ "admin.utilities.pageProblems.checks.contentEmpty": "Empty content",
+ "admin.utilities.pageProblems.checks.renderEmpty": "Empty render",
+ "admin.utilities.pageProblems.checks.renderPending": "Render still pending after an import",
+ "admin.utilities.pageProblems.checks.contentInvalidJson": "Unreadable settings on a redirection or blog",
+ "admin.utilities.pageProblems.checks.contentInvalid": "Settings a save would refuse",
+ "admin.utilities.pageProblems.checks.editorUnknown": "Unknown editor",
+ "admin.utilities.pageProblems.checks.editorDisabled": "Editor turned off for the site",
+ "admin.utilities.pageProblems.checks.redirectTargetMissing": "Target page does not exist",
+ "admin.utilities.pageProblems.checks.redirectSelf": "Redirects to itself",
+ "admin.utilities.pageProblems.checks.redirectLoop": "Redirection loop",
+ "admin.utilities.pageProblems.checks.redirectChainTooLong": "Chain too long for readers to follow",
+ "admin.utilities.pageProblems.checks.redirectChain": "Redirects to another redirection",
+ "admin.utilities.pageProblems.checks.treeEntryMissing": "Page missing from the file tree",
+ "admin.utilities.pageProblems.checks.treeEntryMismatch": "File tree entry in the wrong place",
+ "admin.utilities.pageProblems.checks.treeEntryOrphaned": "File tree entry with no page",
+ "admin.utilities.pageProblems.checks.treeEntryStale": "File tree entry out of date",
+ "admin.utilities.pageProblems.checks.hashMismatch": "Path hash does not match the path",
+ "admin.utilities.pageProblems.checks.aliasDuplicate": "Alias used by more than one page",
+ "admin.utilities.pageProblems.checks.localeInactive": "Locale not active on the site",
+ "admin.utilities.pageProblems.checks.localeGroupOrphaned": "Set of translations with a single page",
+ "admin.utilities.pageProblems.checks.scheduledWithoutDates": "Scheduled with no dates",
+ "admin.utilities.pageProblems.checks.publishDatesInverted": "Publishing window ends before it starts",
+ "admin.utilities.pageProblems.checks.searchIndexMissing": "Missing from the search index",
+ "admin.utilities.pageProblems.checks.navigationMenuMissing": "Sidebar menu missing",
+ "admin.utilities.pageProblems.problems.contentEmpty": "Its content is empty.",
+ "admin.utilities.pageProblems.problems.renderEmpty": "Its rendered HTML is empty, so readers see a blank page. Saving it again from the {editor} editor renders it.",
+ "admin.utilities.pageProblems.problems.renderPending": "It still shows the import placeholder: the page was never rendered after it was imported.",
+ "admin.utilities.pageProblems.problems.contentInvalidJson": "Its {editor} settings are not a valid JSON object, so the editor opens blank.",
+ "admin.utilities.pageProblems.problems.contentInvalid": "Its settings would be refused if it were saved: {reason}",
+ "admin.utilities.pageProblems.problems.editorUnknown": "It uses an editor this wiki does not have ({editor}), so nobody can edit it.",
+ "admin.utilities.pageProblems.problems.editorDisabled": "It uses the {editor} editor, which is turned off for this site.",
+ "admin.utilities.pageProblems.problems.redirectTargetMissing": "It redirects to {target}, where there is no page.",
+ "admin.utilities.pageProblems.problems.redirectSelf": "It redirects to itself.",
+ "admin.utilities.pageProblems.problems.redirectLoop": "It redirects to {target}, which leads round in a loop.",
+ "admin.utilities.pageProblems.problems.redirectChainTooLong": "It redirects to {target}, starting a chain of more than {max} redirections. Readers are stopped partway.",
+ "admin.utilities.pageProblems.problems.redirectChain": "It redirects to {target}, which is itself a redirection: readers pass through {hops} in a row.",
+ "admin.utilities.pageProblems.problems.treeEntryMissing": "It has no file tree entry, so it is missing from the file browser and the sidebar.",
+ "admin.utilities.pageProblems.problems.treeEntryMismatch": "Its file tree entry is filed at {treePath} instead.",
+ "admin.utilities.pageProblems.problems.treeEntryOrphaned": "It is listed in the file tree, but there is no page behind the entry.",
+ "admin.utilities.pageProblems.problems.treeEntryStale": "Its file tree entry has out-of-date values for: {fields}.",
+ "admin.utilities.pageProblems.problems.hashMismatch": "Its path hash does not match its path, so it cannot be found at its own address.",
+ "admin.utilities.pageProblems.problems.aliasDuplicate": "It shares the alias \"{alias}\" with another page on the same site.",
+ "admin.utilities.pageProblems.problems.localeInactive": "It is in the {locale} locale, which is not active on its site.",
+ "admin.utilities.pageProblems.problems.localeGroupOrphaned": "It belongs to a set of translations with no other page in it.",
+ "admin.utilities.pageProblems.problems.scheduledWithoutDates": "It is scheduled, but has neither a start nor an end date.",
+ "admin.utilities.pageProblems.problems.publishDatesInverted": "Its publishing window ends before it starts.",
+ "admin.utilities.pageProblems.problems.searchIndexMissing": "It is searchable, but missing from the search index. Rebuilding the search index adds it.",
+ "admin.utilities.pageProblems.problems.navigationMenuMissing": "Its sidebar points at a menu that does not exist, so it shows no menu (navigation mode: {mode}).",
"admin.utilities.purgeEmptyFolders": "Delete Empty Folders",
"admin.utilities.purgeEmptyFoldersConfirm": "Every folder holding no page and no asset will be deleted, on every site.",
"admin.utilities.purgeEmptyFoldersConfirmWarn": "A folder containing only empty folders goes too, and so does the branch above it once its last folder is gone. Nothing but folders is deleted: a folder holding a page, an asset or a draft is left exactly as it is.",
@@ -1511,8 +1581,14 @@
"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.rebuildPageRatings": "Rebuild Page Ratings",
+ "admin.utilities.rebuildPageRatingsConfirm": "The ratings shown on every page, on every site, will be counted again from the individual ratings readers gave.",
+ "admin.utilities.rebuildPageRatingsConfirmWarn": "No rating is added or removed — only the totals each page displays. These normally keep themselves up to date, so it is worth running when a page shows a count that does not match its ratings, such as after accounts that had rated pages were deleted.",
+ "admin.utilities.rebuildPageRatingsFailed": "The page ratings could not be rebuilt.",
+ "admin.utilities.rebuildPageRatingsHint": "Recalculate the rating totals shown on every page from the ratings actually recorded, in case they have fallen out of step.",
+ "admin.utilities.rebuildPageRatingsSuccess": "Every page's ratings were already correct. | Corrected the ratings of 1 page. | Corrected the ratings of {count} pages.",
"admin.utilities.scanPageProblems": "Scan for Page Problems",
- "admin.utilities.scanPageProblemsHint": "Scan all pages for invalid, missing or corrupted data.",
+ "admin.utilities.scanPageProblemsHint": "Check every page on every site for empty or unreadable content, broken redirections, file tree entries out of step and more. Nothing is changed.",
"admin.utilities.subtitle": "Maintenance and miscellaneous tools",
"admin.utilities.telemetrySubtitle": "Enable/Disable telemetry or reset the client ID",
"admin.utilities.telemetryTitle": "Telemetry",
diff --git a/backend/models/auditLog.ts b/backend/models/auditLog.ts
index 01e5b1815..7489a139b 100644
--- a/backend/models/auditLog.ts
+++ b/backend/models/auditLog.ts
@@ -122,6 +122,7 @@ export const AUDIT_ACTIONS = {
'updateSearchConfig',
'rebuildSearchIndex',
'rebuildPageLinks',
+ 'rebuildPageRatings',
'installExtension',
'startImport',
'finishImport',
diff --git a/backend/models/import.ts b/backend/models/import.ts
index 76c879cbb..d72e2d49d 100644
--- a/backend/models/import.ts
+++ b/backend/models/import.ts
@@ -280,7 +280,7 @@ const MD_IMAGE_WITH_ATTRS = /!\[[^\]]*\]\(\s*([^)\s]+)[^)]*\)\{([^}]*)\}/
const HTML_IMAGE = /
]*>/i
/** What a page's `render` says until the render queue gets to it — `dev/specs/wkbackup.md` §8. */
-const PENDING_RENDER_HTML =
+export const PENDING_RENDER_HTML =
'
This page has been imported and is waiting to be rendered.
'
export interface ImportSessionSite {
diff --git a/backend/models/index.ts b/backend/models/index.ts
index 41b71518d..6558d6bc6 100644
--- a/backend/models/index.ts
+++ b/backend/models/index.ts
@@ -20,6 +20,7 @@ import { metrics } from './metrics.ts'
import { navigation } from './navigation.ts'
import { pageHistory } from './pageHistory.ts'
import { pageLinks } from './pageLinks.ts'
+import { pageProblems } from './pageProblems.ts'
import { pageRatings } from './pageRatings.ts'
import { pages } from './pages.ts'
import { pageWatching } from './pageWatching.ts'
@@ -60,6 +61,7 @@ export default {
navigation,
pageHistory,
pageLinks,
+ pageProblems,
pageRatings,
pages,
pageWatching,
diff --git a/backend/models/pageProblems.ts b/backend/models/pageProblems.ts
new file mode 100644
index 000000000..5a7f2d6cd
--- /dev/null
+++ b/backend/models/pageProblems.ts
@@ -0,0 +1,612 @@
+import { and, asc, eq, gt, sql } from 'drizzle-orm'
+import { alias } from 'drizzle-orm/pg-core'
+import {
+ navigation as navigationTable,
+ pages as pagesTable,
+ tree as treeTable
+} from '../db/schema.ts'
+import { decodeTreePath, generatePathHash } from '../helpers/common.ts'
+import { resolveLink } from '../helpers/pageLinks.ts'
+import type { ResolvedLink } from '../helpers/pageLinks.ts'
+import { PENDING_RENDER_HTML } from './import.ts'
+import { isBodylessEditor, isKnownEditor, normalizeBodylessContent } from './pages.ts'
+
+/** What a problem means for the page: `error` is broken for readers, `warning` is out of step. */
+export type ProblemSeverity = 'error' | 'warning'
+
+/**
+ * Every check the scan runs, in the order the admin screen lists them.
+ *
+ * The key is also the translation key of the check's label (`admin.utilities.pageProblems.checks.*`)
+ * and of the line a problem it finds is logged as (`admin.utilities.pageProblems.problems.*`), so a
+ * check added here needs both strings.
+ */
+export const PAGE_PROBLEM_CHECKS = [
+ { key: 'contentEmpty', group: 'content', severity: 'error' },
+ { key: 'renderEmpty', group: 'content', severity: 'error' },
+ { key: 'renderPending', group: 'content', severity: 'warning' },
+ { key: 'contentInvalidJson', group: 'content', severity: 'error' },
+ { key: 'contentInvalid', group: 'content', severity: 'error' },
+ { key: 'editorUnknown', group: 'editor', severity: 'error' },
+ { key: 'editorDisabled', group: 'editor', severity: 'warning' },
+ { key: 'redirectTargetMissing', group: 'redirects', severity: 'error' },
+ { key: 'redirectSelf', group: 'redirects', severity: 'error' },
+ { key: 'redirectLoop', group: 'redirects', severity: 'error' },
+ { key: 'redirectChainTooLong', group: 'redirects', severity: 'error' },
+ { key: 'redirectChain', group: 'redirects', severity: 'warning' },
+ { key: 'treeEntryMissing', group: 'tree', severity: 'error' },
+ { key: 'treeEntryMismatch', group: 'tree', severity: 'error' },
+ { key: 'treeEntryOrphaned', group: 'tree', severity: 'error' },
+ { key: 'treeEntryStale', group: 'tree', severity: 'warning' },
+ { key: 'hashMismatch', group: 'address', severity: 'error' },
+ { key: 'aliasDuplicate', group: 'address', severity: 'error' },
+ { key: 'localeInactive', group: 'locales', severity: 'warning' },
+ { key: 'localeGroupOrphaned', group: 'locales', severity: 'warning' },
+ { key: 'scheduledWithoutDates', group: 'publishing', severity: 'warning' },
+ { key: 'publishDatesInverted', group: 'publishing', severity: 'warning' },
+ { key: 'searchIndexMissing', group: 'search', severity: 'warning' },
+ { key: 'navigationMenuMissing', group: 'navigation', severity: 'warning' }
+] as const satisfies readonly { key: string; group: string; severity: ProblemSeverity }[]
+
+export type PageProblemCheck = (typeof PAGE_PROBLEM_CHECKS)[number]['key']
+
+const SEVERITY_OF = new Map(
+ PAGE_PROBLEM_CHECKS.map((check) => [check.key, check.severity])
+)
+
+/** One thing found wrong with one page, or with a tree entry that has no page. */
+export interface PageProblem {
+ check: PageProblemCheck
+ severity: ProblemSeverity
+ /** Null for an orphaned tree entry, which is the one problem that has no page. */
+ pageId: string | null
+ siteId: string
+ locale: string
+ path: string
+ title: string
+ /** Where to open the page, or null where there is no page to open. */
+ url: string | null
+ /** Filled into the problem's line, by the names its string uses. */
+ params: Record
+}
+
+export interface PageProblemBatch {
+ problems: PageProblem[]
+ /** Rows looked at by this call, pages and tree entries alike. */
+ scanned: number
+ /** What to send back for the next batch, or null once there is nothing left. */
+ cursor: string | null
+}
+
+/**
+ * Pages read per call. Small enough that one call answers in well under a second on a large wiki,
+ * which is what keeps the progress bar moving and a Stop button meaningful.
+ */
+const PAGE_BATCH_SIZE = 250
+
+/** Tree entries per call in the second phase, which reads only ids and so can take more. */
+const TREE_BATCH_SIZE = 1000
+
+/**
+ * How many redirections a reader's browser follows in a row before calling it a loop — `MAX_HOPS`
+ * in `frontend/src/components/PageRedirect.vue`, which stops on the one after it. The two have to
+ * agree, or this reports as fine a chain that readers are stopped at.
+ */
+const MAX_REDIRECT_HOPS = 5
+
+/**
+ * The two editors behind the experimental flag (`BODYLESS_EDITORS` in `frontend/src/stores/site.js`).
+ * The server has no content type for them yet, so they are not "unknown", and nothing about what they
+ * store is settled enough to check.
+ */
+const EXPERIMENTAL_EDITORS = new Set(['channel', 'api'])
+
+const REDIRECT_EDITOR = 'redirect'
+
+/** The two navigation modes under which a page draws a menu of its own rather than an ancestor's. */
+const OVERRIDE_MODES = new Set(['override', 'overrideExact'])
+
+/**
+ * Where the scan is. `p:` is the pages phase and `t:` the orphaned tree entries after it, each with
+ * the last id the previous call read; an id alone is enough to carry on because both walk in id
+ * order.
+ */
+function parseCursor(cursor?: string): { phase: 'pages' | 'tree'; after: string | null } {
+ if (cursor?.startsWith('t:')) {
+ return { phase: 'tree', after: cursor.slice(2) || null }
+ }
+ if (cursor?.startsWith('p:')) {
+ return { phase: 'pages', after: cursor.slice(2) || null }
+ }
+ return { phase: 'pages', after: null }
+}
+
+/** A page a redirection lands on, with what is needed to follow it further. */
+interface RedirectHop {
+ id: string
+ siteId: string
+ locale: string
+ path: string
+ editor: string
+ content: string | null
+}
+
+/**
+ * Page problems model
+ *
+ * A read-only scan of every page on every site for data that is broken or out of step: the things
+ * nothing at save time can see, because they are the result of an import, a failed write halfway
+ * through a multi-statement change, or a setting changed after the page was written.
+ *
+ * **It never changes anything.** Each check reports; fixing is a separate decision per check, and some
+ * of them have more than one right answer.
+ *
+ * **Driven by the caller, a batch at a time**, the way the 2.x import is: the admin screen asks for
+ * the next batch until the cursor comes back null, so progress is the screen's own count and stopping
+ * is simply not asking again. There is no job and nothing to clean up after a scan abandoned halfway.
+ * Pages are walked in id order, so one created mid-scan is either reached or not — never read twice.
+ */
+class PageProblems {
+ /**
+ * How big a full scan is: `pages` to say so, and `total` rows for the progress bar — every page,
+ * then every page entry in the tree, since the second phase reads those too.
+ */
+ async size(): Promise<{ pages: number; total: number }> {
+ const [pages] = await WIKI.db.select({ n: sql`count(*)::int` }).from(pagesTable)
+ const [entries] = await WIKI.db
+ .select({ n: sql`count(*)::int` })
+ .from(treeTable)
+ .where(eq(treeTable.type, 'page'))
+ const pageCount = Number(pages?.n ?? 0)
+ return { pages: pageCount, total: pageCount + Number(entries?.n ?? 0) }
+ }
+
+ /** The next batch of the scan, starting after `cursor` — or from the beginning without one. */
+ async scan(cursor?: string): Promise {
+ const { phase, after } = parseCursor(cursor)
+ return phase === 'pages' ? this.#scanPages(after) : this.#scanTree(after)
+ }
+
+ /**
+ * Every check that is a question about a page, for one batch of pages.
+ *
+ * One query answers all of them but the redirections: the tree entry comes in by join, and the
+ * locale group, the alias and the menu are subqueries per row. The content and the render are
+ * never read whole — only whether they are empty — except a bodyless page's content, which is the
+ * small settings document the JSON checks parse.
+ */
+ async #scanPages(after: string | null): Promise {
+ const groupMember = alias(pagesTable, 'groupMember')
+ const aliasTwin = alias(pagesTable, 'aliasTwin')
+
+ const rows = await WIKI.db
+ .select({
+ id: pagesTable.id,
+ siteId: pagesTable.siteId,
+ locale: pagesTable.locale,
+ path: pagesTable.path,
+ hash: pagesTable.hash,
+ alias: pagesTable.alias,
+ title: pagesTable.title,
+ description: pagesTable.description,
+ editor: pagesTable.editor,
+ contentType: pagesTable.contentType,
+ publishState: pagesTable.publishState,
+ publishStartDate: pagesTable.publishStartDate,
+ publishEndDate: pagesTable.publishEndDate,
+ isBrowsable: pagesTable.isBrowsable,
+ isSearchable: pagesTable.isSearchableComputed,
+ tags: pagesTable.tags,
+ authorId: pagesTable.authorId,
+ creatorId: pagesTable.creatorId,
+ ownerId: pagesTable.ownerId,
+ localeGroupId: pagesTable.localeGroupId,
+ contentEmpty: sql`coalesce(btrim(${pagesTable.content}), '') = ''`,
+ renderEmpty: sql`coalesce(btrim(${pagesTable.render}), '') = ''`,
+ renderPending: sql`coalesce(${pagesTable.render} = ${PENDING_RENDER_HTML}, false)`,
+ searchTextMissing: sql`${pagesTable.searchContent} is null`,
+ searchVectorMissing: sql`${pagesTable.ts} is null`,
+ bodylessContent: sql<
+ string | null
+ >`case when ${pagesTable.editor} in ('redirect', 'blog') then ${pagesTable.content} end`,
+ treeId: treeTable.id,
+ treeType: treeTable.type,
+ treeSiteId: treeTable.siteId,
+ treeLocale: treeTable.locale,
+ treeFolderPath: treeTable.folderPath,
+ treeFileName: treeTable.fileName,
+ treeTitle: treeTable.title,
+ treeTags: treeTable.tags,
+ treeMeta: treeTable.meta,
+ navigationMode: treeTable.navigationMode,
+ navigationId: treeTable.navigationId,
+ navigationExists: sql`exists (${WIKI.db
+ .select({ one: sql`1` })
+ .from(navigationTable)
+ .where(eq(navigationTable.id, treeTable.navigationId))})`,
+ groupSize: sql`(${WIKI.db
+ .select({ n: sql`count(*)::int` })
+ .from(groupMember)
+ .where(
+ and(
+ eq(groupMember.siteId, pagesTable.siteId),
+ eq(groupMember.localeGroupId, pagesTable.localeGroupId)
+ )
+ )})`,
+ aliasTaken: sql`exists (${WIKI.db
+ .select({ one: sql`1` })
+ .from(aliasTwin)
+ .where(
+ and(
+ eq(aliasTwin.siteId, pagesTable.siteId),
+ eq(aliasTwin.alias, pagesTable.alias),
+ sql`${aliasTwin.id} <> ${pagesTable.id}`
+ )
+ )})`
+ })
+ .from(pagesTable)
+ .leftJoin(treeTable, eq(treeTable.id, pagesTable.id))
+ .where(after ? gt(pagesTable.id, after) : undefined)
+ .orderBy(asc(pagesTable.id))
+ .limit(PAGE_BATCH_SIZE)
+
+ const problems: PageProblem[] = []
+ for (const row of rows) {
+ const report = (check: PageProblemCheck, params: Record = {}) => {
+ problems.push({
+ check,
+ severity: SEVERITY_OF.get(check)!,
+ pageId: row.id,
+ siteId: row.siteId,
+ locale: row.locale,
+ path: row.path,
+ title: row.title,
+ url: this.#pageUrl(row.siteId, row.locale, row.path, row.editor),
+ params
+ })
+ }
+ const site = WIKI.sites[row.siteId]
+ const isExperimental = EXPERIMENTAL_EDITORS.has(row.editor)
+ const isKnown = isKnownEditor(row.editor)
+ const isBodyless = isBodylessEditor(row.editor)
+
+ // -> Content
+ if (row.contentEmpty) {
+ report('contentEmpty')
+ }
+ if (isKnown && !isBodyless && row.renderEmpty) {
+ report('renderEmpty', { editor: row.editor })
+ }
+ if (row.renderPending) {
+ report('renderPending')
+ }
+ if (isBodyless && !row.contentEmpty) {
+ this.#checkBodylessContent(row.editor, row.bodylessContent, report)
+ }
+
+ // -> Editor
+ if (!isKnown && !isExperimental) {
+ report('editorUnknown', { editor: row.editor })
+ } else if (isKnown && site?.config?.editors?.[row.editor]?.isActive === false) {
+ report('editorDisabled', { editor: row.editor })
+ }
+
+ // -> Redirections
+ if (row.editor === REDIRECT_EDITOR && !row.contentEmpty) {
+ await this.#checkRedirect(row, report)
+ }
+
+ // -> Tree
+ if (!row.treeId) {
+ report('treeEntryMissing')
+ } else {
+ const folder = row.treeFolderPath ? decodeTreePath(row.treeFolderPath) : ''
+ const treePath = folder ? `${folder}/${row.treeFileName}` : (row.treeFileName ?? '')
+ if (
+ row.treeType !== 'page' ||
+ row.treeSiteId !== row.siteId ||
+ row.treeLocale !== row.locale ||
+ treePath.toLowerCase() !== row.path.toLowerCase()
+ ) {
+ report('treeEntryMismatch', { treePath: `${row.treeLocale}/${treePath}` })
+ } else {
+ const stale = this.#staleTreeFields(row)
+ if (stale.length > 0) {
+ report('treeEntryStale', { fields: stale.join(', ') })
+ }
+ }
+
+ // -> Navigation, which lives on the tree entry
+ const menuMissing = row.navigationId
+ ? !row.navigationExists
+ : OVERRIDE_MODES.has(row.navigationMode ?? '')
+ if (menuMissing) {
+ report('navigationMenuMissing', { mode: row.navigationMode ?? 'inherit' })
+ }
+ }
+
+ // -> Address
+ if (row.hash !== generatePathHash(row.path)) {
+ report('hashMismatch')
+ }
+ if (row.alias && row.aliasTaken) {
+ report('aliasDuplicate', { alias: row.alias })
+ }
+
+ // -> Locales
+ const activeLocales: string[] = site?.config?.locales?.active ?? []
+ if (!activeLocales.includes(row.locale)) {
+ report('localeInactive', { locale: row.locale })
+ }
+ if (row.localeGroupId && Number(row.groupSize) < 2) {
+ report('localeGroupOrphaned')
+ }
+
+ // -> Publishing
+ if (row.publishState === 'scheduled' && !row.publishStartDate && !row.publishEndDate) {
+ report('scheduledWithoutDates')
+ }
+ if (
+ row.publishStartDate &&
+ row.publishEndDate &&
+ row.publishEndDate.getTime() <= row.publishStartDate.getTime()
+ ) {
+ report('publishDatesInverted')
+ }
+
+ // -> Search. A render with no extracted text is a body the index cannot see; a page with no
+ // vector at all is missing from results even by title
+ if (
+ row.isSearchable &&
+ (row.searchVectorMissing || (!isBodyless && !row.renderEmpty && row.searchTextMissing))
+ ) {
+ report('searchIndexMissing')
+ }
+ }
+
+ const last = rows.at(-1)
+ return {
+ problems,
+ scanned: rows.length,
+ // -> A short batch is the last one; the tree phase starts from its own beginning
+ cursor: rows.length < PAGE_BATCH_SIZE ? 't:' : `p:${last!.id}`
+ }
+ }
+
+ /**
+ * Tree entries of type page with no page behind them.
+ *
+ * The one check that cannot be asked of a page, since there is none, so it is a phase of its own
+ * after the pages. Such an entry is listed in the file browser and the sidebar and leads to a 404.
+ */
+ async #scanTree(after: string | null): Promise {
+ const rows = await WIKI.db
+ .select({
+ id: treeTable.id,
+ siteId: treeTable.siteId,
+ locale: treeTable.locale,
+ folderPath: treeTable.folderPath,
+ fileName: treeTable.fileName,
+ title: treeTable.title,
+ pageId: pagesTable.id
+ })
+ .from(treeTable)
+ .leftJoin(pagesTable, eq(pagesTable.id, treeTable.id))
+ .where(and(eq(treeTable.type, 'page'), after ? gt(treeTable.id, after) : undefined))
+ .orderBy(asc(treeTable.id))
+ .limit(TREE_BATCH_SIZE)
+
+ const problems: PageProblem[] = rows
+ .filter((row) => !row.pageId)
+ .map((row) => {
+ const folder = row.folderPath ? decodeTreePath(row.folderPath) : ''
+ return {
+ check: 'treeEntryOrphaned',
+ severity: SEVERITY_OF.get('treeEntryOrphaned')!,
+ pageId: null,
+ siteId: row.siteId,
+ locale: row.locale,
+ path: folder ? `${folder}/${row.fileName}` : row.fileName,
+ title: row.title,
+ url: null,
+ params: {}
+ }
+ })
+
+ const last = rows.at(-1)
+ return {
+ problems,
+ scanned: rows.length,
+ cursor: rows.length < TREE_BATCH_SIZE ? null : `t:${last!.id}`
+ }
+ }
+
+ /**
+ * A redirection's or a blog's settings document, read the way saving it would.
+ *
+ * Two checks, because they are two different breakages: a document that is not a JSON object at
+ * all (what the editor opens is blank, and a blog quietly falls back to every default), and one that
+ * parses but that the save path would refuse — a redirection with no target, a blog asking for
+ * no posts per page. The second is the save path's own normalizer, so the two cannot disagree about
+ * what is valid, and the reason given is the one a save would have been refused with.
+ */
+ #checkBodylessContent(
+ editor: string,
+ content: string | null,
+ report: (check: PageProblemCheck, params?: Record) => void
+ ): void {
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(content ?? '')
+ } catch {
+ parsed = undefined
+ }
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ report('contentInvalidJson', { editor })
+ return
+ }
+ try {
+ normalizeBodylessContent(editor, content ?? '')
+ } catch (err: any) {
+ report('contentInvalid', { reason: err.message })
+ }
+ }
+
+ /**
+ * Follow a redirection the way a reader's browser would, and report where that goes wrong.
+ *
+ * Resolved with `resolveLink`, which is what the link table uses and what puts the site's locale
+ * prefix and page extensions into play, then looked up page by page — a chain is followed until it
+ * reaches something that is not a redirection, comes back on itself, or runs past what a browser
+ * will follow. Only this page's own problem is reported: a chain that breaks further along is
+ * reported against the page where it breaks, when the scan gets to that one.
+ *
+ * A URL target, or a path to something that is not a page (`/_admin`, a file), is not followed.
+ */
+ async #checkRedirect(
+ page: {
+ id: string
+ siteId: string
+ locale: string
+ path: string
+ bodylessContent: string | null
+ },
+ report: (check: PageProblemCheck, params?: Record) => void
+ ): Promise {
+ const visited = new Set([page.id])
+ let current: { siteId: string; locale: string; path: string; content: string | null } = {
+ ...page,
+ content: page.bodylessContent
+ }
+ let redirections = 1
+ let firstTarget = ''
+
+ while (true) {
+ const target = this.#redirectTargetOf(current.content)
+ if (!target) {
+ return
+ }
+ firstTarget ||= target
+ const link = resolveLink(target, current)
+ if (!link || (link.kind !== 'page' && link.kind !== 'alias' && link.kind !== 'pageId')) {
+ return
+ }
+ const hop = await this.#findPage(link)
+ if (!hop) {
+ if (redirections === 1) {
+ report('redirectTargetMissing', { target })
+ }
+ return
+ }
+ if (hop.id === page.id) {
+ report(redirections === 1 ? 'redirectSelf' : 'redirectLoop', { target: firstTarget })
+ return
+ }
+ if (visited.has(hop.id)) {
+ // -> This page leads into a loop that it is not part of: the pages in the loop report
+ // themselves, and a reader arriving here goes round it just the same
+ report('redirectLoop', { target: firstTarget })
+ return
+ }
+ if (hop.editor !== REDIRECT_EDITOR) {
+ if (redirections > 1) {
+ report('redirectChain', { target: firstTarget, hops: redirections })
+ }
+ return
+ }
+ visited.add(hop.id)
+ redirections++
+ if (redirections > MAX_REDIRECT_HOPS) {
+ report('redirectChainTooLong', { target: firstTarget, max: MAX_REDIRECT_HOPS })
+ return
+ }
+ current = hop
+ }
+ }
+
+ /** A redirection's target when it points at a page of this wiki, or null. */
+ #redirectTargetOf(content: string | null): string | null {
+ try {
+ const parsed = JSON.parse(content ?? '')
+ if (parsed?.kind === 'url' || typeof parsed?.target !== 'string') {
+ return null
+ }
+ const target = parsed.target.trim()
+ return target.length > 0 ? target : null
+ } catch {
+ return null
+ }
+ }
+
+ /** The page a resolved link addresses, by the same three keys `pageLinks.outboundFor` joins on. */
+ async #findPage(link: ResolvedLink): Promise {
+ const where =
+ link.kind === 'page'
+ ? and(
+ eq(pagesTable.siteId, link.targetSiteId),
+ eq(pagesTable.locale, link.targetLocale ?? ''),
+ eq(pagesTable.path, link.targetPath ?? '')
+ )
+ : link.kind === 'alias'
+ ? and(
+ eq(pagesTable.siteId, link.targetSiteId),
+ eq(pagesTable.alias, link.targetRef ?? '')
+ )
+ : sql`${pagesTable.id}::text = ${link.targetRef ?? ''}`
+ const [row] = await WIKI.db
+ .select({
+ id: pagesTable.id,
+ siteId: pagesTable.siteId,
+ locale: pagesTable.locale,
+ path: pagesTable.path,
+ editor: pagesTable.editor,
+ content: sql<
+ string | null
+ >`case when ${pagesTable.editor} = 'redirect' then ${pagesTable.content} end`
+ })
+ .from(pagesTable)
+ .where(where)
+ .limit(1)
+ return row ?? null
+ }
+
+ /**
+ * Which of the copies a tree entry keeps of its page no longer match it.
+ *
+ * Compared through `pages.treeMeta`, the function that writes them, and through JSON, since the
+ * stored copy has been through a `jsonb` column and a date in it is the ISO string of the one on the
+ * page.
+ */
+ #staleTreeFields(row: any): string[] {
+ const stale: string[] = []
+ if (row.treeTitle !== row.title) {
+ stale.push('title')
+ }
+ if (JSON.stringify(row.treeTags ?? []) !== JSON.stringify(row.tags ?? [])) {
+ stale.push('tags')
+ }
+ const expected = JSON.parse(JSON.stringify(WIKI.models.pages.treeMeta(row)))
+ const stored = (row.treeMeta ?? {}) as Record
+ for (const [key, value] of Object.entries(expected)) {
+ if (JSON.stringify(stored[key] ?? null) !== JSON.stringify(value ?? null)) {
+ stale.push(key)
+ }
+ }
+ return stale
+ }
+
+ /**
+ * Where to open a page from the admin area, which may be on another site's hostname.
+ *
+ * A redirection carries `?redirect=no`, since the whole reason to open one from here is to look at
+ * it rather than be sent on.
+ */
+ #pageUrl(siteId: string, locale: string, path: string, editor: string): string {
+ const hostname = WIKI.sites[siteId]?.hostname
+ const origin = hostname && hostname !== '*' ? `//${hostname}` : ''
+ const query = editor === REDIRECT_EDITOR ? '?redirect=no' : ''
+ return `${origin}${WIKI.models.pages.urlFor(siteId, locale, path)}${query}`
+ }
+}
+
+export const pageProblems = new PageProblems()
diff --git a/backend/models/pageRatings.ts b/backend/models/pageRatings.ts
index 12a63fa39..0fefd7f61 100644
--- a/backend/models/pageRatings.ts
+++ b/backend/models/pageRatings.ts
@@ -1,4 +1,4 @@
-import { and, eq, sql } from 'drizzle-orm'
+import { and, eq, exists, inArray, ne, not, sql } from 'drizzle-orm'
import { pageRatings as ratingsTable, pages as pagesTable } from '../db/schema.ts'
/** The two scales a site can rate its pages on. `off` is the absence of one. */
@@ -138,6 +138,86 @@ class PageRatings {
)
}
+ /**
+ * Recount the totals cached on every page, on every site, from the ratings table.
+ *
+ * The cache is only rewritten when one of a page's ratings changes, so anything that alters the
+ * rows without going through `rate` / `unrate` leaves it behind until the page is next rated — a
+ * deleted account's cascade, an import, a hand edit of the table. This is the way to put it right
+ * without waiting for that.
+ *
+ * Every page row is locked first, for the same reason `#changeAndRecount` locks the one it
+ * rewrites: a rating committed between counting and writing would otherwise be left out of the
+ * total written. Locked in id order so that two rebuilds cannot deadlock on each other.
+ *
+ * @returns How many pages had a cache that did not match their ratings.
+ */
+ async rebuildAll(): Promise {
+ return WIKI.db.transaction(async (tx) => {
+ await tx.select({ id: pagesTable.id }).from(pagesTable).orderBy(pagesTable.id).for('update')
+
+ const countedKinds = inArray(ratingsTable.kind, [...RATING_MODES])
+ const perKind = tx
+ .select({
+ pageId: ratingsTable.pageId,
+ kind: ratingsTable.kind,
+ count: sql`count(*)::int`.as('count'),
+ sum: sql`coalesce(sum(${ratingsTable.value}), 0)::int`.as('sum'),
+ up: sql`(count(*) filter (where ${ratingsTable.value} > 0))::int`.as('up'),
+ down: sql`(count(*) filter (where ${ratingsTable.value} < 0))::int`.as('down')
+ })
+ .from(ratingsTable)
+ .where(countedKinds)
+ .groupBy(ratingsTable.pageId, ratingsTable.kind)
+ .as('perKind')
+ const perPage = tx
+ .select({
+ pageId: perKind.pageId,
+ cache:
+ sql`jsonb_object_agg(${perKind.kind}, jsonb_build_object('count', ${perKind.count}, 'sum', ${perKind.sum}, 'up', ${perKind.up}, 'down', ${perKind.down}))`.as(
+ 'cache'
+ )
+ })
+ .from(perKind)
+ .groupBy(perKind.pageId)
+ .as('perPage')
+
+ // -> Pages somebody has rated, where the cache says otherwise
+ const recounted = await tx
+ .update(pagesTable)
+ .set({ ratings: sql`${perPage.cache}` })
+ .from(perPage)
+ .where(
+ and(
+ eq(perPage.pageId, pagesTable.id),
+ sql`${pagesTable.ratings} is distinct from ${perPage.cache}`
+ )
+ )
+ .returning({ id: pagesTable.id })
+
+ // -> Pages nobody has rated, still carrying totals
+ const cleared = await tx
+ .update(pagesTable)
+ .set({ ratings: {} })
+ .where(
+ and(
+ ne(pagesTable.ratings, {}),
+ not(
+ exists(
+ tx
+ .select({ one: sql`1` })
+ .from(ratingsTable)
+ .where(and(eq(ratingsTable.pageId, pagesTable.id), countedKinds))
+ )
+ )
+ )
+ )
+ .returning({ id: pagesTable.id })
+
+ return recounted.length + cleared.length
+ })
+ }
+
/**
* Apply a change to a page's ratings and rewrite the totals cached on it, in one transaction.
*
diff --git a/backend/models/pages.ts b/backend/models/pages.ts
index 97a4f00f9..15c1965f3 100644
--- a/backend/models/pages.ts
+++ b/backend/models/pages.ts
@@ -157,11 +157,19 @@ export function isBodylessEditor(editor: string): boolean {
return BODYLESS_EDITORS.has(editor)
}
+/** Whether `editor` is one this wiki has an editor for, which is what knowing its content type is. */
+export function isKnownEditor(editor: string): boolean {
+ return Object.hasOwn(EDITOR_CONTENT_TYPES, editor)
+}
+
/**
* Put a bodyless editor's content into the one spelling its column holds, refusing what it cannot
* use. Returns the content unchanged for an editor that writes an actual body.
*/
-function normalizeBodylessContent(editor: string, content: string | undefined): string | undefined {
+export function normalizeBodylessContent(
+ editor: string,
+ content: string | undefined
+): string | undefined {
switch (editor) {
case REDIRECT_EDITOR:
return normalizeRedirectContent(content)
@@ -2005,17 +2013,18 @@ class Pages {
reason: patch.reasonForChange
})
- if (treeTitle !== null || patch.tags !== undefined) {
- await WIKI.db
- .update(treeTable)
- .set({
- ...(treeTitle !== null ? { title: treeTitle } : {}),
- ...(patch.tags !== undefined ? { tags: patch.tags } : {}),
- meta: this.treeMeta(updated),
- updatedAt: sql`now()`
- })
- .where(eq(treeTable.id, id))
- }
+ // -> Every save, not only one that renamed or retagged the page: `meta` copies the author, the
+ // description, the editor and the publishing fields too, and a folder listing that read a
+ // stale copy would show a page as a draft long after it went live
+ await WIKI.db
+ .update(treeTable)
+ .set({
+ ...(treeTitle !== null ? { title: treeTitle } : {}),
+ ...(patch.tags !== undefined ? { tags: patch.tags } : {}),
+ meta: this.treeMeta(updated),
+ updatedAt: sql`now()`
+ })
+ .where(eq(treeTable.id, id))
// -> The source is whatever this save set it to, else whatever it already was: a save that only
// changed the title still rewrites the copy, since the title is in its front matter
@@ -2270,6 +2279,9 @@ class Pages {
authorId: actor.id
})
+ // -> Out of its set of translations first, which dissolves the set if this leaves one page in
+ // it: a group of one is no group, and the survivor's locale picker would offer nothing
+ await this.detachFromLocaleGroup(siteId, id)
await WIKI.db.delete(pagesTable).where(eq(pagesTable.id, id))
await WIKI.models.tree.deleteEntry(id)
// -> A page that overrode the sidebar owns a menu keyed by its own id, which nothing could reach
@@ -2363,6 +2375,11 @@ class Pages {
)
).map((row) => [row.id, row.contentType])
)
+ // -> Out of their sets of translations first, for the same reason `deletePage` does it
+ await this.detachFromLocaleGroups(
+ siteId,
+ entries.map((entry) => entry.id)
+ )
await WIKI.db.delete(pagesTable).where(
inArray(
pagesTable.id,
@@ -2925,8 +2942,11 @@ class Pages {
/**
* What a page's tree entry carries about it, so a folder listing needs no join.
+ *
+ * Public so that the page problem scan can tell a stale copy from a current one by the same rule
+ * that writes it.
*/
- private treeMeta(page: any): Record {
+ treeMeta(page: any): Record {
return {
authorId: page.authorId,
contentType: page.contentType,
diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js
index 30e991f7b..c4f74f512 100644
--- a/frontend/src/assets/icons.generated.js
+++ b/frontend/src/assets/icons.generated.js
@@ -5,7 +5,7 @@
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
removing an icon; `check-icons.mjs` fails the build if this drifts.
- 281 icons.
+ 282 icons.
*/
export const BUNDLED_ICONS = {
"la:angle-down": {"body":"","width":32,"height":32},
@@ -140,6 +140,7 @@ export const BUNDLED_ICONS = {
"la:star-half-alt": {"body":"","width":32,"height":32},
"la:star-solid": {"body":"","width":32,"height":32},
"la:stop": {"body":"","width":32,"height":32},
+ "la:stop-circle": {"body":"","width":32,"height":32},
"la:stream": {"body":"","width":32,"height":32},
"la:sun": {"body":"","width":32,"height":32},
"la:sync-alt": {"body":"","width":32,"height":32},
diff --git a/frontend/src/components/CheckUpdateDialog.vue b/frontend/src/components/CheckUpdateDialog.vue
index 00acf3eef..1aad34c70 100644
--- a/frontend/src/components/CheckUpdateDialog.vue
+++ b/frontend/src/components/CheckUpdateDialog.vue
@@ -36,6 +36,15 @@
Release Date: {{ state.latestDate }}
+
diff --git a/frontend/src/components/ImportWikijs2Overlay.vue b/frontend/src/components/ImportWikijs2Overlay.vue
index 7c6810761..c441d36eb 100644
--- a/frontend/src/components/ImportWikijs2Overlay.vue
+++ b/frontend/src/components/ImportWikijs2Overlay.vue
@@ -238,19 +238,9 @@
{{ t('admin.utilities.wikijs2Import.progress') }}
-
-
- {{ t('admin.utilities.wikijs2Import.progressEmpty') }}
-
-
- {{ entry.ts }}
- {{ entry.message }}
-
-
+
@@ -269,9 +259,11 @@
@@ -656,73 +633,4 @@ body.body--dark .import-header::after {
body.body--dark .content-child {
--tree-line: rgb(255 255 255 / 0.2);
}
-
-/*
- The log is a terminal, so it looks like one: monospaced, dark, scrolling on its own and keeping a
- fixed height whether it holds two lines or two hundred -- a panel that grew with the import would
- move the form beside it on every message. `--font-mono` is the app's own, as `UtilCodeEditor` uses.
-
- Dark in both themes, rather than following the app. This is machine output scrolling past, and every
- other place anybody reads that -- a terminal, a CI log, the browser console -- is dark; a light one
- reads as a document. It also keeps the colours below meaning one thing: a warning and an error have
- to be legible against exactly one background instead of two, which is why they can be the brighter
- end of the ramp and stay readable.
-
- The palette is the app's own dark surfaces (`_theme.scss`), so the panel sits in an admin overlay
- rather than looking like a component from somewhere else.
-*/
-.import-log {
- height: calc(100vh - 260px);
- min-height: 300px;
- overflow-y: auto;
- border-radius: 4px;
- padding: 12px;
- background-color: $dark-6;
- border: 1px solid $dark-3;
- color: $grey-4;
- font-family: var(--font-mono, monospace);
- font-size: 12px;
- line-height: 1.6;
-
- /*
- A step lighter than the timestamps, because it is not secondary the way they are: it is the only
- thing on the panel before an import starts, so it has to read as an instruction rather than as
- something greyed out. `$grey-7` was 4.3:1 against this background and `$grey-6` is 7.4:1, while
- still sitting a step below the message colour so it does not pass for output.
- */
- &__empty {
- color: $grey-6;
- font-style: italic;
- }
-
- &__line {
- display: flex;
- gap: 8px;
- white-space: pre-wrap;
- word-break: break-word;
- }
-
- &__ts {
- flex: none;
- color: $grey-7;
- }
-
- /*
- The full Material ramp is in `tailwind.css` as custom properties; `_palette.scss` carries only the
- steps its own stylesheets happened to need, and the mid tones a log wants are not among them.
- These are the light end of each hue, which is what stays legible on the dark panel above.
- */
- &__line--success .import-log__msg {
- color: var(--color-green-4);
- font-weight: 600;
- }
-
- &__line--warn .import-log__msg {
- color: var(--color-orange-4);
- }
-
- &__line--error .import-log__msg {
- color: var(--color-red-4);
- }
-}
diff --git a/frontend/src/components/PageProblemsOverlay.vue b/frontend/src/components/PageProblemsOverlay.vue
new file mode 100644
index 000000000..f5f447227
--- /dev/null
+++ b/frontend/src/components/PageProblemsOverlay.vue
@@ -0,0 +1,413 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ isRunning
+ ? t('admin.utilities.pageProblems.stop')
+ : t('admin.utilities.pageProblems.start')
+ }}
+
+
+
+ {{ t('admin.utilities.pageProblems.checksTitle') }}
+
+
+
+
+ |
+ {{ t(`admin.utilities.pageProblems.groups.${group.key}`) }}
+ |
+
+
+ |
+ {{ t(`admin.utilities.pageProblems.checks.${check.key}`) }}
+ |
+
+
+
+ {{
+ state.phase === 'idle' ? '–' : '0'
+ }}
+ |
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.utilities.pageProblems.progress') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ProgressLog.vue b/frontend/src/components/ProgressLog.vue
new file mode 100644
index 000000000..88c8b71e3
--- /dev/null
+++ b/frontend/src/components/ProgressLog.vue
@@ -0,0 +1,163 @@
+
+
+
+ {{ emptyText }}
+
+
+
{{ entry.ts }}
+
+
{{ entry.location }}{{ entry.location }}{{ entry.location ? ' ' : '' }}{{ entry.message }}
+
+
+
+
+
+
+
diff --git a/frontend/src/layouts/AdminLayout.vue b/frontend/src/layouts/AdminLayout.vue
index 23ba5c918..553d6715e 100644
--- a/frontend/src/layouts/AdminLayout.vue
+++ b/frontend/src/layouts/AdminLayout.vue
@@ -510,6 +510,10 @@ const overlays = {
loader: () => import('../components/ImportWikijs2Overlay.vue'),
loadingComponent: LoadingGeneric
}),
+ PageProblemsOverlay: defineAsyncComponent({
+ loader: () => import('../components/PageProblemsOverlay.vue'),
+ loadingComponent: LoadingGeneric
+ }),
UserEditOverlay: defineAsyncComponent({
loader: () => import('../components/UserEditOverlay.vue'),
loadingComponent: LoadingGeneric
diff --git a/frontend/src/pages/AdminUtilities.vue b/frontend/src/pages/AdminUtilities.vue
index e8633ab91..97146f844 100644
--- a/frontend/src/pages/AdminUtilities.vue
+++ b/frontend/src/pages/AdminUtilities.vue
@@ -181,6 +181,22 @@
:label="t(`common.actions.proceed`)" />
+
+
+
+ {{ t(`admin.utilities.rebuildPageRatings`) }}
+ {{ t(`admin.utilities.rebuildPageRatingsHint`) }}
+
+
+
+
+
@@ -188,20 +204,13 @@
{{ t(`admin.utilities.scanPageProblemsHint`) }}
-
-
-
- {{ t(`common.comingSoon`) }}
-
+
@@ -473,6 +482,14 @@ function openWikijs2Import() {
adminStore.$patch({ overlay: 'ImportWikijs2Overlay' })
}
+/**
+ * The page problem scan gets a screen of its own for the same reason: it runs for a while and reports
+ * as it goes, which is a progress log rather than a confirmation and a result.
+ */
+function openPageProblems() {
+ adminStore.$patch({ overlay: 'PageProblemsOverlay' })
+}
+
/**
* Close every websocket the wiki holds — the editors of anyone collaborating on a page, and any open
* admin terminal. Confirmed first because it interrupts people who are working: their clients
@@ -953,6 +970,44 @@ function rebuildPageLinks() {
})
}
+/**
+ * Count every page's rating totals again from the ratings table, on every site.
+ *
+ * Done in the request, unlike the link rebuild above: it is one aggregate over the ratings, not a read
+ * of every page's content, so the answer can say how many pages were out of step. Confirmed because
+ * it locks every page while it runs, not because anything is lost.
+ */
+function rebuildPageRatings() {
+ confirm({
+ title: t('admin.utilities.rebuildPageRatings'),
+ message: t('admin.utilities.rebuildPageRatingsConfirm'),
+ caption: t('admin.utilities.rebuildPageRatingsConfirmWarn'),
+ cancel: true,
+ persistent: true,
+ okLabel: t('common.actions.proceed')
+ }).onOk(async () => {
+ loading.show()
+ try {
+ const resp = await API_CLIENT.post('system/page-ratings/rebuild').json()
+ if (!resp?.ok) {
+ throw new Error(resp?.message || 'An unexpected error occured.')
+ }
+ const count = resp.count ?? 0
+ notify({
+ type: 'positive',
+ message: t('admin.utilities.rebuildPageRatingsSuccess', count, { count })
+ })
+ } catch (err) {
+ notify({
+ type: 'negative',
+ message: t('admin.utilities.rebuildPageRatingsFailed'),
+ caption: apiErrorMessage(err)
+ })
+ }
+ loading.hide()
+ })
+}
+
/**
* Throw away everything the wiki has cached off the database — files, icons, and the site, group and
* locale state read on every request. Not confirmed: nothing is lost and nothing stops working, the