feat: add rebuild page ratings + scan for page problems utilities

pull/8104/head
NGPixel 3 days ago
parent 7e6fcc09c3
commit 7ca0c302f7
No known key found for this signature in database

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

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

@ -122,6 +122,7 @@ export const AUDIT_ACTIONS = {
'updateSearchConfig',
'rebuildSearchIndex',
'rebuildPageLinks',
'rebuildPageRatings',
'installExtension',
'startImport',
'finishImport',

@ -280,7 +280,7 @@ const MD_IMAGE_WITH_ATTRS = /!\[[^\]]*\]\(\s*([^)\s]+)[^)]*\)\{([^}]*)\}/
const HTML_IMAGE = /<img\b[^>]*>/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 =
'<p class="is-pending-render">This page has been imported and is waiting to be rendered.</p>'
export interface ImportSessionSite {

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

@ -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<string, ProblemSeverity>(
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<string, string | number>
}
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<number>`count(*)::int` }).from(pagesTable)
const [entries] = await WIKI.db
.select({ n: sql<number>`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<PageProblemBatch> {
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<PageProblemBatch> {
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<boolean>`coalesce(btrim(${pagesTable.content}), '') = ''`,
renderEmpty: sql<boolean>`coalesce(btrim(${pagesTable.render}), '') = ''`,
renderPending: sql<boolean>`coalesce(${pagesTable.render} = ${PENDING_RENDER_HTML}, false)`,
searchTextMissing: sql<boolean>`${pagesTable.searchContent} is null`,
searchVectorMissing: sql<boolean>`${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<boolean>`exists (${WIKI.db
.select({ one: sql`1` })
.from(navigationTable)
.where(eq(navigationTable.id, treeTable.navigationId))})`,
groupSize: sql<number>`(${WIKI.db
.select({ n: sql`count(*)::int` })
.from(groupMember)
.where(
and(
eq(groupMember.siteId, pagesTable.siteId),
eq(groupMember.localeGroupId, pagesTable.localeGroupId)
)
)})`,
aliasTaken: sql<boolean>`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<string, string | number> = {}) => {
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<PageProblemBatch> {
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<string, string | number>) => 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<string, string | number>) => void
): Promise<void> {
const visited = new Set<string>([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<RedirectHop | null> {
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<string, unknown>
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()

@ -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<number> {
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<number>`count(*)::int`.as('count'),
sum: sql<number>`coalesce(sum(${ratingsTable.value}), 0)::int`.as('sum'),
up: sql<number>`(count(*) filter (where ${ratingsTable.value} > 0))::int`.as('up'),
down: sql<number>`(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<PageRatingsCache>`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.
*

@ -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<string, any> {
treeMeta(page: any): Record<string, any> {
return {
authorId: page.authorId,
contentType: page.contentType,

@ -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":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -140,6 +140,7 @@ export const BUNDLED_ICONS = {
"la:star-half-alt": {"body":"<path fill=\"currentColor\" d=\"m16 2.375l-.906 2.031l-3.25 7.313l-7.938.812l-2.25.25l1.688 1.5l5.906 5.344l-1.656 7.813l-.469 2.187h.031L9.094 28.5L16 24.531l6.906 3.969l1.969 1.125l-.469-2.188l-1.656-7.812l5.906-5.344l1.688-1.5l-2.25-.25l-7.938-.812l-3.25-7.313zm0 4.906l2.563 5.782l.25.5l.562.062l6.313.656l-4.72 4.25l-.437.375l.125.563l1.313 6.187L16.5 22.5l-.5-.281z\"/>","width":32,"height":32},
"la:star-solid": {"body":"<path fill=\"currentColor\" d=\"m30.336 12.547l-10.172-1.074L16 2.133l-4.164 9.34l-10.172 1.074l7.598 6.848L7.14 29.398L16 24.29l8.86 5.11l-2.122-10.004z\"/>","width":32,"height":32},
"la:stop": {"body":"<path fill=\"currentColor\" d=\"M6 6v20h20V6zm2 2h16v16H8z\"/>","width":32,"height":32},
"la:stop-circle": {"body":"<path fill=\"currentColor\" d=\"M16 3C8.85 3 3 8.85 3 16s5.85 13 13 13s13-5.85 13-13S23.15 3 16 3m0 2c6.05 0 11 4.95 11 11s-4.95 11-11 11S5 22.05 5 16S9.95 5 16 5m-5 6v10h10V11zm2 2h6v6h-6z\"/>","width":32,"height":32},
"la:stream": {"body":"<path fill=\"currentColor\" d=\"M5 5v6h19V5zm2 2h15v2H7zm2 6v6h19v-6zm2 2h15v2H11zm-6 6v6h19v-6zm2 2h15v2H7z\"/>","width":32,"height":32},
"la:sun": {"body":"<path fill=\"currentColor\" d=\"M15 3v5h2V3zM7.5 6.094L6.094 7.5l3.531 3.563l1.438-1.438zm17 0l-3.563 3.531l1.438 1.438L25.906 7.5zM16 9c-3.855 0-7 3.145-7 7s3.145 7 7 7s7-3.145 7-7s-3.145-7-7-7m0 2c2.773 0 5 2.227 5 5s-2.227 5-5 5s-5-2.227-5-5s2.227-5 5-5M3 15v2h5v-2zm21 0v2h5v-2zM9.625 20.938L6.094 24.5L7.5 25.906l3.563-3.531zm12.75 0l-1.438 1.437l3.563 3.531l1.406-1.406zM15 24v5h2v-5z\"/>","width":32,"height":32},
"la:sync-alt": {"body":"<path fill=\"currentColor\" d=\"M16 4c-5.113 0-9.383 3.16-11.125 7.625l1.844.75C8.176 8.641 11.71 6 16 6c3.242 0 6.133 1.59 7.938 4H20v2h7V5h-2v3.094A11.94 11.94 0 0 0 16 4m9.281 15.625C23.824 23.359 20.29 26 16 26c-3.277 0-6.156-1.613-7.969-4H12v-2H5v7h2v-3.094C9.188 26.386 12.395 28 16 28c5.113 0 9.383-3.16 11.125-7.625z\"/>","width":32,"height":32},

@ -36,6 +36,15 @@
<div class="text-body2">
Release Date: <strong>{{ state.latestDate }}</strong>
</div>
<div class="text-body2 mt-2">
<a
href="https://github.com/requarks/wiki/releases"
target="_blank"
rel="noopener noreferrer"
class="text-primary"
>{{ t(`admin.system.releaseNotes`) }}</a
>
</div>
</div>
</template>
</w-card-section>

@ -238,19 +238,9 @@
<w-card>
<w-card-header>{{ t('admin.utilities.wikijs2Import.progress') }}</w-card-header>
<div class="p-4 pt-0">
<div ref="logPanel" class="import-log">
<div v-if="state.log.length < 1" class="import-log__empty">
{{ t('admin.utilities.wikijs2Import.progressEmpty') }}
</div>
<div
v-for="(entry, idx) of state.log"
:key="`log-` + idx"
class="import-log__line"
:class="`import-log__line--` + entry.level">
<span class="import-log__ts">{{ entry.ts }}</span>
<span class="import-log__msg">{{ entry.message }}</span>
</div>
</div>
<progress-log
:entries="state.log"
:empty-text="t('admin.utilities.wikijs2Import.progressEmpty')" />
</div>
</w-card>
</div>
@ -269,9 +259,11 @@
</template>
<script setup>
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ProgressLog from '@/components/ProgressLog.vue'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
@ -300,7 +292,6 @@ const { t } = useI18n()
// DATA
const archiveIpt = ref(null)
const logPanel = ref(null)
const state = reactive({
/** Defaults to whichever site the admin area is already pointed at. */
@ -536,27 +527,13 @@ async function refreshSite() {
}
}
/**
* Add a line to the log, and keep the panel at the bottom.
*
* Pinned only when the reader is already there, so that scrolling back to read a warning is not
* undone by the next of the several thousand lines a large import writes.
*/
/** Add a line to the log. `ProgressLog` keeps the panel at the bottom as it grows. */
function addLog(level, message) {
const panel = logPanel.value
const wasAtBottom = panel ? panel.scrollHeight - panel.scrollTop - panel.clientHeight < 40 : true
state.log.push({
ts: Temporal.Now.plainTimeISO().toString({ smallestUnit: 'second' }),
level,
message
})
if (wasAtBottom) {
nextTick(() => {
if (logPanel.value) {
logPanel.value.scrollTop = logPanel.value.scrollHeight
}
})
}
}
</script>
@ -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);
}
}
</style>

@ -0,0 +1,413 @@
<template>
<w-layout view="hHh lpR fFf" container>
<!--
The bar and the progress strip under it are two rows of the ONE header element, as in
`ImportWikijs2Overlay` -- see the note there for why the strip cannot be a sibling.
-->
<w-header class="card-header scan-header">
<div class="flex items-center px-4 py-2">
<w-icon name="img:/_assets/icons/ultraviolet-rescan-document.svg" left size="md" />
<div>
<span>{{ t('admin.utilities.scanPageProblems') }}</span>
<div class="text-caption">{{ t('admin.utilities.pageProblems.subtitle') }}</div>
</div>
<w-space />
<w-btn
class="mr-2"
flat
rounded
color="white"
:aria-label="t(`common.actions.viewDocs`)"
icon="la:question-circle"
:href="siteStore.docsBase + `/admin/utilities`"
target="_blank"
type="a" />
<w-btn-group push>
<w-btn
push
color="white"
text-color="grey-7"
:label="t(`common.actions.close`)"
:aria-label="t(`common.actions.close`)"
icon="la:times"
@click="close" />
</w-btn-group>
</div>
<w-linear-progress
v-if="showProgress"
size="10px"
:striped="isRunning"
:color="state.phase === 'failed' ? 'negative' : 'positive'"
:value="state.progress"
:aria-label="t('admin.utilities.pageProblems.progress')" />
</w-header>
<w-page-container>
<w-page class="p-4">
<div class="grid grid-cols-12 gap-4 items-start">
<!-- ----------------------- -->
<!-- What is checked -->
<!-- ----------------------- -->
<div class="col-span-12 lg:col-span-5 flex flex-col gap-4">
<!--
Spelt out in the slot rather than through `icon`/`label`, for the reason given on the
import's own button: `WBtn`'s `loading` would hide the label, and the label is what
says which of the two this button currently does.
-->
<w-btn
unelevated
size="lg"
class="w-full"
:color="isRunning ? 'negative' : 'positive'"
text-color="white"
:disabled="state.checks.length < 1"
@click="toggleScan">
<w-icon :name="isRunning ? 'la:stop-circle' : 'la:play-circle'" class="shrink-0" />
<span>{{
isRunning
? t('admin.utilities.pageProblems.stop')
: t('admin.utilities.pageProblems.start')
}}</span>
</w-btn>
<w-card class="pb-2">
<w-card-header>{{ t('admin.utilities.pageProblems.checksTitle') }}</w-card-header>
<!--
A table rather than a list, because each row is read ACROSS: what is checked and
what the scan found. One row group per heading, which is what lets
the stripes restart under each one and gives the heading `scope="rowgroup"`.
-->
<table class="checks-table w-full border-collapse text-left">
<tbody v-for="group of checkGroups" :key="group.key">
<tr>
<th scope="rowgroup" colspan="2" class="checks-table__group">
{{ t(`admin.utilities.pageProblems.groups.${group.key}`) }}
</th>
</tr>
<tr v-for="check of group.checks" :key="check.key" class="checks-table__row">
<td class="px-4 py-1.5 text-sm">
{{ t(`admin.utilities.pageProblems.checks.${check.key}`) }}
</td>
<td class="w-20 px-4 py-1.5 text-right">
<w-badge
v-if="state.counts[check.key]"
rounded
:color="check.severity === 'error' ? 'negative' : 'orange-8'"
:label="state.counts[check.key]" />
<w-icon
v-else-if="state.phase === 'done'"
name="la:check"
size="16px"
class="text-positive"
:aria-label="t('admin.utilities.pageProblems.noProblems')" />
<span v-else class="text-sm text-grey-6">{{
state.phase === 'idle' ? '–' : '0'
}}</span>
</td>
</tr>
</tbody>
</table>
</w-card>
</div>
<!-- ----------------------- -->
<!-- Progress log -->
<!-- ----------------------- -->
<div class="col-span-12 lg:col-span-7">
<w-card>
<w-card-header>{{ t('admin.utilities.pageProblems.progress') }}</w-card-header>
<div class="p-4 pt-0">
<progress-log
:entries="state.log"
:empty-text="t('admin.utilities.pageProblems.progressEmpty')" />
</div>
</w-card>
</div>
</div>
</w-page>
</w-page-container>
</w-layout>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import ProgressLog from '@/components/ProgressLog.vue'
import { apiErrorMessage } from '@/helpers/apiError'
import { useAdminStore } from '@/stores/admin'
import { useSiteStore } from '@/stores/site'
/**
* Scan every page on every site for broken or out-of-step data, and say what was found.
*
* Driven from here a batch at a time, the way the 2.x import is: `GET /system/page-problems` answers
* one batch and a cursor, and the next batch is simply the next request. So the progress bar is this
* component's own count, and stopping is not asking again -- there is no job on the server to cancel
* and nothing left behind by a scan abandoned halfway. The server's `models/pageProblems.ts` is where
* the checks are, and what each one means.
*
* Read-only: every problem is a line in the log, linked to the page it is about.
*/
// STORES
const adminStore = useAdminStore()
const siteStore = useSiteStore()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
/** `{ key, group, severity }`, from the server, in the order it lists them. */
checks: [],
/** Problems found so far, per check key. */
counts: {},
/** `{ ts, level, message, location?, url? }` — see `ProgressLog`. */
log: [],
/** `idle`, `running`, then `done`, `stopped` or `failed`. */
phase: 'idle',
/** 0..1, rows read against the total the first batch reported. */
progress: 0
})
/**
* Asked for between batches. Not state: nothing draws it, and it has to be readable from inside the
* loop the moment it is set.
*/
let stopRequested = false
// COMPUTED
const isRunning = computed(() => state.phase === 'running')
/** As for the import: the strip is for watching a run, and for how far a failed one got. */
const showProgress = computed(() => state.phase === 'running' || state.phase === 'failed')
/** The checks under their headings, in the server's order for both. */
const checkGroups = computed(() => {
const groups = []
for (const check of state.checks) {
let group = groups.at(-1)
if (group?.key !== check.group) {
group = { key: check.group, checks: [] }
groups.push(group)
}
group.checks.push(check)
}
return groups
})
/** Which site a problem is on is only worth saying when there is more than one to choose from. */
const siteTitles = computed(() =>
adminStore.sites.length > 1
? Object.fromEntries(adminStore.sites.map((site) => [site.id, site.title]))
: null
)
// METHODS
function close() {
adminStore.$patch({ overlay: '' })
}
function addLog(level, message, extra = {}) {
state.log.push({
ts: Temporal.Now.plainTimeISO().toString({ smallestUnit: 'second' }),
level,
message,
...extra
})
}
async function loadChecks() {
try {
const resp = await API_CLIENT.get('system/page-problems/checks').json()
state.checks = resp?.checks ?? []
} catch (err) {
addLog('error', apiErrorMessage(err))
}
}
/** One problem as a line: where it is, linked when there is a page to open, then what is wrong. */
function logProblem(problem) {
const site = siteTitles.value?.[problem.siteId]
addLog(
problem.severity === 'error' ? 'error' : 'warn',
t(`admin.utilities.pageProblems.problems.${problem.check}`, problem.params ?? {}),
{
location: `${site ? `${site} · ` : ''}${problem.locale}/${problem.path}`,
url: problem.url
}
)
}
async function startScan() {
stopRequested = false
state.phase = 'running'
state.progress = 0
state.counts = {}
state.log = []
let cursor = null
let total = 0
let scanned = 0
let errors = 0
let warnings = 0
try {
do {
const resp = await API_CLIENT.get('system/page-problems', {
searchParams: cursor ? { cursor } : {}
}).json()
if (!resp?.ok) {
throw new Error(resp?.message || 'An unexpected error occured.')
}
if (!cursor) {
total = resp.total ?? 0
addLog('info', t('admin.utilities.pageProblems.logStarted', { pages: resp.pages ?? 0 }))
}
scanned += resp.scanned ?? 0
for (const problem of resp.problems ?? []) {
state.counts[problem.check] = (state.counts[problem.check] ?? 0) + 1
if (problem.severity === 'error') {
errors++
} else {
warnings++
}
logProblem(problem)
}
state.progress = total > 0 ? Math.min(scanned / total, 1) : 1
cursor = resp.cursor
} while (cursor && !stopRequested)
if (cursor) {
state.phase = 'stopped'
addLog('warn', t('admin.utilities.pageProblems.logStopped', { scanned, total }))
} else {
state.phase = 'done'
if (errors + warnings < 1) {
addLog('success', t('admin.utilities.pageProblems.logClean'))
} else {
addLog('success', t('admin.utilities.pageProblems.logFinished', { errors, warnings }))
}
}
} catch (err) {
addLog('error', apiErrorMessage(err))
state.phase = 'failed'
}
}
/** Takes effect when the batch in flight comes back; nothing on the server needs telling. */
function stopScan() {
stopRequested = true
}
function toggleScan() {
if (isRunning.value) {
stopScan()
} else {
startScan()
}
}
// MOUNTED
onMounted(loadChecks)
// -> Closing the overlay mid-scan ends it, rather than leaving a loop requesting batches for nobody
onBeforeUnmount(stopScan)
</script>
<style scoped lang="scss">
/*
The header's closing line, redrawn under the progress strip. The same treatment as
`.import-header` in `ImportWikijs2Overlay`, which says why: `.card-header`'s own border and shadow
read as one thick rule beneath a progress bar, and a 1px border lands on a fractional device row
under display scaling.
*/
.scan-header {
flex-direction: column;
align-items: stretch;
position: relative;
}
body.body--light .scan-header,
body.body--dark .scan-header {
border-bottom: 0;
box-shadow: none;
}
.scan-header::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 1px;
background-color: $dark-6;
transform: scaleY(calc(1 / var(--w-dpr, 1)));
transform-origin: bottom;
}
body.body--dark .scan-header::after {
background-color: #000;
}
/*
The checks table. Its rules are hairlines drawn by a pseudo-element on each cell, scaled to one
device pixel the way `WTable` draws its own -- a 1px border lands on a fractional device row under
display scaling -- and the stripes restart in every row group, the heading being its first row.
*/
.checks-table {
--checks-rule: rgb(0 0 0 / 0.12);
--checks-group: #{$dark-1};
--checks-stripe: rgb(0 0 0 / 0.02);
--checks-hover: rgb(0 0 0 / 0.05);
th,
td {
position: relative;
}
tbody tr > *::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background-color: var(--checks-rule);
transform: scaleY(calc(1 / var(--w-dpr, 1)));
transform-origin: top left;
pointer-events: none;
}
}
.checks-table__group {
padding: 6px 16px;
background-color: var(--checks-group);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #fff;
}
.checks-table__row:nth-child(odd) {
background-color: var(--checks-stripe);
}
.checks-table__row:hover {
background-color: var(--checks-hover);
}
body.body--dark .checks-table {
--checks-rule: rgb(255 255 255 / 0.15);
--checks-group: #{$dark-6};
--checks-stripe: rgb(255 255 255 / 0.025);
--checks-hover: rgb(255 255 255 / 0.07);
}
</style>

@ -0,0 +1,163 @@
<template>
<div ref="panel" class="progress-log" role="log">
<div v-if="entries.length < 1" class="progress-log__empty">
{{ emptyText }}
</div>
<div
v-for="(entry, idx) of entries"
:key="`log-` + idx"
class="progress-log__line"
:class="`progress-log__line--` + entry.level">
<span class="progress-log__ts">{{ entry.ts }}</span>
<!-- -> Written tight: the line is `pre-wrap`, so any whitespace here would be drawn -->
<span class="progress-log__msg"
><a
v-if="entry.location && entry.url"
class="progress-log__location"
:href="entry.url"
target="_blank"
rel="noopener"
>{{ entry.location }}</a
><span v-else-if="entry.location" class="progress-log__location">{{ entry.location }}</span
>{{ entry.location ? ' ' : '' }}{{ entry.message }}</span
>
</div>
</div>
</template>
<script setup>
import { nextTick, ref, watch } from 'vue'
/**
* The terminal-style log a long-running admin utility writes into as it goes — the 2.x import and the
* page problem scan.
*
* The owner holds the lines and pushes to them; this only draws them and keeps the panel at the
* bottom. Each entry is `{ ts, level, message }`, `level` one of `info`, `success`, `warn` or `error`,
* and optionally a `location` it is about, drawn ahead of the message and linked when it has a `url`.
*/
const props = defineProps({
entries: {
type: Array,
required: true
},
/** What the panel says before anything has been logged. */
emptyText: {
type: String,
default: ''
}
})
const panel = ref(null)
/*
Pinned to the bottom only when the reader is already there, so that scrolling back to read a warning
is not undone by the next of the several thousand lines a large run writes. Measured before the new
lines are drawn (`pre`), which is the only moment "was at the bottom" still means anything.
*/
watch(
() => props.entries.length,
() => {
const el = panel.value
const wasAtBottom = el ? el.scrollHeight - el.scrollTop - el.clientHeight < 40 : true
if (wasAtBottom) {
nextTick(() => {
if (panel.value) {
panel.value.scrollTop = panel.value.scrollHeight
}
})
}
},
{ flush: 'pre' }
)
</script>
<style scoped lang="scss">
/*
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 run 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.
*/
.progress-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 a run 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;
}
/*
Plain white rather than the line's own colour, so that what a line is ABOUT reads apart from what
it says about it; underlined only where it goes somewhere.
*/
&__location {
color: #fff;
font-weight: 600;
}
a.progress-log__location {
text-decoration: underline;
text-decoration-color: rgb(255 255 255 / 0.4);
text-underline-offset: 2px;
&:hover {
text-decoration-color: currentColor;
}
}
/*
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 .progress-log__msg {
color: var(--color-green-4);
font-weight: 600;
}
&__line--warn .progress-log__msg {
color: var(--color-orange-4);
}
&__line--error .progress-log__msg {
color: var(--color-red-4);
}
}
</style>

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

@ -181,6 +181,22 @@
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="star-half-empty" :hue-rotate="45" />
<w-item-section>
<w-item-label>{{ t(`admin.utilities.rebuildPageRatings`) }}</w-item-label>
<w-item-label caption>{{ t(`admin.utilities.rebuildPageRatingsHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
@click="rebuildPageRatings"
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
<w-item>
<blueprint-icon icon="rescan-document" :hue-rotate="45" />
<w-item-section>
@ -188,20 +204,13 @@
<w-item-label caption>{{ t(`admin.utilities.scanPageProblemsHint`) }}</w-item-label>
</w-item-section>
<w-item-section side>
<!--
The wrapper is what carries the tooltip: a disabled button is transparent to the
hit test, so a tooltip written inside it would never be shown. See WTooltip.
-->
<span data-tooltip-anchor class="inline-flex">
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
disabled
:label="t(`common.actions.proceed`)" />
<w-tooltip>{{ t(`common.comingSoon`) }}</w-tooltip>
</span>
<w-btn
class="acrylic-btn"
flat
icon="la:arrow-circle-right"
color="primary"
@click="openPageProblems"
:label="t(`common.actions.proceed`)" />
</w-item-section>
</w-item>
</w-list>
@ -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

Loading…
Cancel
Save