Files
website/server/src/model/wiki/wiki.db.js
whitlocktech 7bb992f58d Wiki Phase 4: full-text search + revision history
Final phase of the wiki upgrade (see WIKI_UPGRADE.md).

Schema (additive): wiki_revisions table (per-save content snapshots).
The FULLTEXT index on wiki_pages(title, body) shipped in Phase 1.

Search:
- MATCH ... AGAINST natural-language search over title + body, ordered by
  relevance
- public: GET /public/wiki?q= (published only); admin: GET /admin/wiki?q=
  (all statuses)
- public wiki index gains a search box; admin list gains a search field

Revision history:
- every create/update snapshots the page into wiki_revisions
- admin endpoints: list revisions, get one, and restore (restore overwrites
  the page, rebuilds links, and appends a new revision — history stays
  append-only); logged as wiki.revision.restore
- editor gains a History modal: revision list + word-level diff (jsdiff) of a
  chosen revision against the current page, with one-click restore

Verified end-to-end: search matches body and title; two edits produce three
revisions; diff renders added/removed words; restore reverts and records a new
revision. No console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 15:49:05 -05:00

328 lines
11 KiB
JavaScript

const { query } = require('../../utils/db')
// Full page row + joined category fields.
const PAGE_COLS =
'p.id, p.slug, p.title, p.body, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_by, p.created_at, p.updated_at, p.published_at, ' +
'c.slug AS category_slug, c.title AS category_title'
// List rows omit the body (lighter payload for indexes/tables).
const SUMMARY_COLS =
'p.id, p.slug, p.title, p.excerpt, p.category_id, p.published, p.sort_order, ' +
'p.updated_at, p.published_at, c.slug AS category_slug, c.title AS category_title'
const FROM = 'FROM wiki_pages p LEFT JOIN wiki_categories c ON c.id = p.category_id'
const ORDER = 'ORDER BY p.sort_order ASC, p.title ASC'
// Shared summary query builder with optional category / tag / status filters.
function buildSummaryQuery({ categoryId = null, tagId = null, published = null }) {
const joins = []
const where = []
const params = []
if (tagId != null) {
joins.push('JOIN wiki_page_tags pt ON pt.page_id = p.id AND pt.tag_id = ?')
params.push(tagId)
}
if (categoryId != null) {
where.push('p.category_id = ?')
params.push(categoryId)
}
if (published != null) {
where.push('p.published = ?')
params.push(published ? 1 : 0)
}
const clause = where.length ? `WHERE ${where.join(' AND ')}` : ''
return {
sql: `SELECT ${SUMMARY_COLS} ${FROM} ${joins.join(' ')} ${clause} ${ORDER}`,
params,
}
}
// Published summaries (public). Optional category / tag filters.
async function listPublishedSummaries({ categoryId = null, tagId = null } = {}) {
const { sql, params } = buildSummaryQuery({ categoryId, tagId, published: true })
return query(sql, params)
}
// All summaries (admin), with optional category / tag / status filters.
async function listAllSummaries({ categoryId = null, tagId = null, published = null } = {}) {
const { sql, params } = buildSummaryQuery({ categoryId, tagId, published })
return query(sql, params)
}
async function findBySlug(slug) {
const rows = await query(`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
async function findPublishedBySlug(slug) {
const rows = await query(
`SELECT ${PAGE_COLS} ${FROM} WHERE p.slug = ? AND p.published = 1 LIMIT 1`,
[slug],
)
return rows[0] || null
}
// Full-text search over title + body, ordered by relevance.
async function searchSummaries(q, { publishedOnly = true } = {}) {
const pub = publishedOnly ? 'AND p.published = 1' : ''
return query(
`SELECT ${SUMMARY_COLS} ${FROM}
WHERE MATCH(p.title, p.body) AGAINST (? IN NATURAL LANGUAGE MODE) ${pub}
ORDER BY MATCH(p.title, p.body) AGAINST (?) DESC, p.title ASC`,
[q, q],
)
}
// ── Page writes ────────────────────────────────────────────────────────
async function insert({
slug,
title,
body = null,
excerpt = null,
categoryId = null,
published = true,
sortOrder = 0,
updatedBy = null,
}) {
const res = await query(
'INSERT INTO wiki_pages (slug, title, body, excerpt, category_id, published, sort_order, published_at, updated_by) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
slug,
title,
body,
excerpt,
categoryId,
published ? 1 : 0,
sortOrder,
published ? new Date() : null,
updatedBy,
],
)
return res.insertId
}
// Dynamic update — only the provided columns are written.
async function updateBySlug(slug, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(slug)
await query(`UPDATE wiki_pages SET ${cols.join(', ')} WHERE slug = ?`, params)
}
async function deleteBySlug(slug) {
return query('DELETE FROM wiki_pages WHERE slug = ?', [slug])
}
// ── Categories ─────────────────────────────────────────────────────────
const CAT_COLS = 'id, slug, title, description, sort_order, created_at, updated_at'
// Categories with page counts (total + published) for index/admin views.
async function listCategories() {
return query(
`SELECT c.id, c.slug, c.title, c.description, c.sort_order, c.created_at, c.updated_at,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id) AS page_count,
(SELECT COUNT(*) FROM wiki_pages p WHERE p.category_id = c.id AND p.published = 1) AS published_count
FROM wiki_categories c
ORDER BY c.sort_order ASC, c.title ASC`,
)
}
async function findCategoryBySlug(slug) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
async function findCategoryById(id) {
const rows = await query(`SELECT ${CAT_COLS} FROM wiki_categories WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function insertCategory({ slug, title, description = null, sortOrder = 0 }) {
const res = await query(
'INSERT INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description, sortOrder],
)
return res.insertId
}
async function updateCategory(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE wiki_categories SET ${cols.join(', ')} WHERE id = ?`, params)
}
// Detach pages first (works even on upgraded DBs that lack the FK), then delete.
async function deleteCategory(id) {
await query('UPDATE wiki_pages SET category_id = NULL WHERE category_id = ?', [id])
return query('DELETE FROM wiki_categories WHERE id = ?', [id])
}
// ── Tags ───────────────────────────────────────────────────────────────
async function listTags() {
return query(
`SELECT t.id, t.slug, t.label,
(SELECT COUNT(*) FROM wiki_page_tags pt
JOIN wiki_pages p ON p.id = pt.page_id
WHERE pt.tag_id = t.id AND p.published = 1) AS published_count
FROM wiki_tags t ORDER BY t.label ASC`,
)
}
async function findTagBySlug(slug) {
const rows = await query('SELECT id, slug, label FROM wiki_tags WHERE slug = ? LIMIT 1', [slug])
return rows[0] || null
}
async function getTagsForPage(pageId) {
return query(
'SELECT t.slug, t.label FROM wiki_tags t ' +
'JOIN wiki_page_tags pt ON pt.tag_id = t.id WHERE pt.page_id = ? ORDER BY t.label ASC',
[pageId],
)
}
async function upsertTag(slug, label) {
await query('INSERT INTO wiki_tags (slug, label) VALUES (?, ?) ON DUPLICATE KEY UPDATE label = VALUES(label)', [
slug,
label,
])
const rows = await query('SELECT id FROM wiki_tags WHERE slug = ? LIMIT 1', [slug])
return rows[0].id
}
async function setPageTags(pageId, tagIds) {
await query('DELETE FROM wiki_page_tags WHERE page_id = ?', [pageId])
for (const tagId of tagIds) {
await query('INSERT IGNORE INTO wiki_page_tags (page_id, tag_id) VALUES (?, ?)', [pageId, tagId])
}
}
// Drop tags no longer attached to any page (keeps the tag list tidy).
async function deleteOrphanTags() {
return query('DELETE FROM wiki_tags WHERE id NOT IN (SELECT tag_id FROM wiki_page_tags)')
}
// ── Internal links / backlinks ─────────────────────────────────────────
async function clearLinks(pageId) {
return query('DELETE FROM wiki_links WHERE source_page_id = ?', [pageId])
}
async function insertLink(pageId, targetSlug) {
return query('INSERT INTO wiki_links (source_page_id, target_slug) VALUES (?, ?)', [pageId, targetSlug])
}
// Pages that link TO targetSlug (excludes the page linking to itself).
async function getBacklinks(targetSlug, { publishedOnly = true } = {}) {
const pub = publishedOnly ? 'AND p.published = 1' : ''
return query(
`SELECT DISTINCT p.slug, p.title FROM wiki_links l
JOIN wiki_pages p ON p.id = l.source_page_id
WHERE l.target_slug = ? AND p.slug <> ? ${pub}
ORDER BY p.title ASC`,
[targetSlug, targetSlug],
)
}
// Of the given slugs, which actually exist (for red-link detection).
async function getExistingSlugs(slugs) {
if (!slugs || slugs.length === 0) return new Set()
const placeholders = slugs.map(() => '?').join(',')
const rows = await query(`SELECT slug FROM wiki_pages WHERE slug IN (${placeholders})`, slugs)
return new Set(rows.map((r) => r.slug))
}
// ── Revisions ──────────────────────────────────────────────────────────
async function insertRevision({ pageId, title, body, excerpt, categoryId, editorId, changeNote }) {
return query(
'INSERT INTO wiki_revisions (page_id, title, body, excerpt, category_id, editor_id, change_note) ' +
'VALUES (?, ?, ?, ?, ?, ?, ?)',
[pageId, title, body || null, excerpt || null, categoryId ?? null, editorId ?? null, changeNote || null],
)
}
async function listRevisions(pageId) {
return query(
`SELECT r.id, r.change_note, r.created_at, r.editor_id, u.username AS editor
FROM wiki_revisions r LEFT JOIN users u ON u.id = r.editor_id
WHERE r.page_id = ? ORDER BY r.id DESC`,
[pageId],
)
}
async function findRevision(id) {
const rows = await query('SELECT * FROM wiki_revisions WHERE id = ? LIMIT 1', [id])
return rows[0] || null
}
// ── Seeding (idempotent) ───────────────────────────────────────────────
async function seedDefault(slug, title, body) {
await query('INSERT IGNORE INTO wiki_pages (slug, title, body) VALUES (?, ?, ?)', [
slug,
title,
body || null,
])
}
async function seedDefaultCategory(slug, title, description, sortOrder = 0) {
await query(
'INSERT IGNORE INTO wiki_categories (slug, title, description, sort_order) VALUES (?, ?, ?, ?)',
[slug, title, description || null, sortOrder],
)
}
// Assign a seeded page to a category by slug, only if not already categorized —
// migrates pre-upgrade pages without clobbering manual changes.
async function assignCategoryBySlug(pageSlug, categorySlug) {
await query(
'UPDATE wiki_pages SET category_id = (SELECT id FROM wiki_categories WHERE slug = ?) ' +
'WHERE slug = ? AND category_id IS NULL',
[categorySlug, pageSlug],
)
}
module.exports = {
listPublishedSummaries,
listAllSummaries,
findBySlug,
findPublishedBySlug,
searchSummaries,
insert,
updateBySlug,
deleteBySlug,
listCategories,
findCategoryBySlug,
findCategoryById,
insertCategory,
updateCategory,
deleteCategory,
listTags,
findTagBySlug,
getTagsForPage,
upsertTag,
setPageTags,
deleteOrphanTags,
clearLinks,
insertLink,
getBacklinks,
getExistingSlugs,
insertRevision,
listRevisions,
findRevision,
seedDefault,
seedDefaultCategory,
assignCategoryBySlug,
}