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>
242 lines
7.5 KiB
JavaScript
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,
|
|
}
|