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>
328 lines
11 KiB
JavaScript
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,
|
|
}
|