Files
website/server/src/model/wiki/wiki.model.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

242 lines
7.5 KiB
JavaScript

const wikiDb = require('./wiki.db')
const { cleanBody } = require('../../utils/sanitizeHtml')
const { extractTargets } = require('./wiki.links')
function slugifyTag(label) {
return String(label)
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '')
}
// Upsert each label into wiki_tags and set the page's tag set exactly.
async function syncTags(pageId, tags) {
const ids = []
const seen = new Set()
for (const raw of tags) {
const label = String(raw).trim()
if (!label) continue
const slug = slugifyTag(label)
if (!slug || seen.has(slug)) continue
seen.add(slug)
ids.push(await wikiDb.upsertTag(slug, label))
}
await wikiDb.setPageTags(pageId, ids)
await wikiDb.deleteOrphanTags()
}
// Rebuild the page's outgoing internal-link rows from its (sanitized) body.
async function rebuildLinks(pageId, html) {
await wikiDb.clearLinks(pageId)
for (const target of extractTargets(html)) {
await wikiDb.insertLink(pageId, target)
}
}
// Snapshot the current content of a page into the revision history.
async function writeRevision(page, editorId, changeNote = null) {
await wikiDb.insertRevision({
pageId: page.id,
title: page.title,
body: page.body,
excerpt: page.excerpt,
categoryId: page.category_id,
editorId,
changeNote,
})
}
// ── Pages ──────────────────────────────────────────────────────────────
async function listPublished(filters = {}) {
return wikiDb.listPublishedSummaries(filters)
}
async function listAll(filters = {}) {
return wikiDb.listAllSummaries(filters)
}
async function search(q, opts = {}) {
return wikiDb.searchSummaries(q, opts)
}
// Admin detail: page + its tags.
async function getBySlug(slug) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
page.tags = await wikiDb.getTagsForPage(page.id)
return page
}
// Public detail: page + tags + backlinks + missing (red) link targets.
async function getPublishedBySlug(slug) {
const page = await wikiDb.findPublishedBySlug(slug)
if (!page) return null
page.tags = await wikiDb.getTagsForPage(page.id)
page.backlinks = await wikiDb.getBacklinks(slug, { publishedOnly: true })
const targets = extractTargets(page.body)
const existing = await wikiDb.getExistingSlugs(targets)
page.missing_links = targets.filter((t) => !existing.has(t))
return page
}
async function create({ slug, title, body, excerpt, categoryId, published, updatedBy, tags }) {
const clean = cleanBody(body)
await wikiDb.insert({
slug,
title,
body: clean,
excerpt: excerpt || null,
categoryId: categoryId ?? null,
published: published !== false,
updatedBy,
})
const page = await wikiDb.findBySlug(slug)
if (Array.isArray(tags)) await syncTags(page.id, tags)
await rebuildLinks(page.id, clean)
await writeRevision(page, updatedBy, 'Created')
return getBySlug(slug)
}
// Partial update — only keys present in `input` are written.
async function update(slug, input) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { updated_by: input.updatedBy ?? null }
let cleanForLinks = null
if ('title' in input) fields.title = input.title
if ('body' in input) {
fields.body = cleanBody(input.body)
cleanForLinks = fields.body
}
if ('excerpt' in input) fields.excerpt = input.excerpt || null
if ('categoryId' in input) fields.category_id = input.categoryId ?? null
if ('published' in input) {
fields.published = input.published ? 1 : 0
if (input.published && !current.published_at) fields.published_at = new Date()
}
await wikiDb.updateBySlug(slug, fields)
if (Array.isArray(input.tags)) await syncTags(current.id, input.tags)
if (cleanForLinks != null) await rebuildLinks(current.id, cleanForLinks)
const page = await wikiDb.findBySlug(slug)
await writeRevision(page, input.updatedBy ?? null, input.changeNote || null)
return getBySlug(slug)
}
async function setPublished(slug, published) {
const current = await wikiDb.findBySlug(slug)
if (!current) return null
const fields = { published: published ? 1 : 0 }
if (published && !current.published_at) fields.published_at = new Date()
await wikiDb.updateBySlug(slug, fields)
return getBySlug(slug)
}
async function remove(slug) {
const res = await wikiDb.deleteBySlug(slug)
await wikiDb.deleteOrphanTags() // page_tags cascade on delete; drop now-empty tags
return res
}
// ── Revisions ──────────────────────────────────────────────────────────
async function listRevisions(slug) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
return wikiDb.listRevisions(page.id)
}
async function getRevision(slug, revId) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
const rev = await wikiDb.findRevision(revId)
if (!rev || rev.page_id !== page.id) return null
return rev
}
// Restore an old revision: overwrite the page with the snapshot, rebuild links,
// then record a new revision (history stays append-only).
async function restoreRevision(slug, revId, editorId) {
const page = await wikiDb.findBySlug(slug)
if (!page) return null
const rev = await wikiDb.findRevision(revId)
if (!rev || rev.page_id !== page.id) return null
await wikiDb.updateBySlug(slug, {
title: rev.title,
body: rev.body,
excerpt: rev.excerpt,
category_id: rev.category_id,
updated_by: editorId,
})
await rebuildLinks(page.id, rev.body)
const restored = await wikiDb.findBySlug(slug)
await writeRevision(restored, editorId, `Restored from revision #${revId}`)
return getBySlug(slug)
}
// ── Tags ───────────────────────────────────────────────────────────────
async function listTags() {
return wikiDb.listTags()
}
async function getTagBySlug(slug) {
return wikiDb.findTagBySlug(slug)
}
// ── Categories ─────────────────────────────────────────────────────────
async function listCategories() {
return wikiDb.listCategories()
}
async function getCategoryBySlug(slug) {
return wikiDb.findCategoryBySlug(slug)
}
async function getCategoryById(id) {
return wikiDb.findCategoryById(id)
}
async function createCategory({ slug, title, description, sortOrder }) {
const id = await wikiDb.insertCategory({ slug, title, description, sortOrder })
return wikiDb.findCategoryById(id)
}
async function updateCategory(id, input) {
const fields = {}
if ('title' in input) fields.title = input.title
if ('slug' in input) fields.slug = input.slug
if ('description' in input) fields.description = input.description || null
if ('sortOrder' in input) fields.sort_order = input.sortOrder
await wikiDb.updateCategory(id, fields)
return wikiDb.findCategoryById(id)
}
async function removeCategory(id) {
return wikiDb.deleteCategory(id)
}
module.exports = {
listPublished,
listAll,
search,
getBySlug,
getPublishedBySlug,
create,
update,
setPublished,
remove,
listRevisions,
getRevision,
restoreRevision,
listTags,
getTagBySlug,
listCategories,
getCategoryBySlug,
getCategoryById,
createCategory,
updateCategory,
removeCategory,
}