From 7bb992f58d696b813813a3504d3b81b5e39af1fe Mon Sep 17 00:00:00 2001 From: whitlocktech Date: Sat, 27 Jun 2026 15:49:05 -0500 Subject: [PATCH] Wiki Phase 4: full-text search + revision history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- WIKI_UPGRADE.md | 7 +- client/package-lock.json | 10 ++ client/package.json | 1 + client/src/api/client.js | 5 + client/src/routes/admin/views/WikiAdmin.jsx | 14 +- client/src/routes/admin/views/WikiEditor.jsx | 19 +++ client/src/routes/admin/views/WikiHistory.jsx | 139 ++++++++++++++++++ client/src/routes/wiki/Wiki.jsx | 58 ++++++-- client/src/styles/theme.css | 80 ++++++++++ server/db/schema.sql | 16 ++ server/src/model/wiki/wiki.db.js | 38 +++++ server/src/model/wiki/wiki.model.js | 60 ++++++++ .../src/router/v1/admin/admin.controller.js | 46 ++++++ server/src/router/v1/admin/admin.routes.js | 9 ++ .../src/router/v1/public/public.controller.js | 4 + 15 files changed, 492 insertions(+), 14 deletions(-) create mode 100644 client/src/routes/admin/views/WikiHistory.jsx diff --git a/WIKI_UPGRADE.md b/WIKI_UPGRADE.md index e66e0a4..91d55f1 100644 --- a/WIKI_UPGRADE.md +++ b/WIKI_UPGRADE.md @@ -321,11 +321,16 @@ phase if preferred). Do not merge a phase that hasn't been verified. - Implementation note: links are plain anchors to `/wiki/` (the WYSIWYG fits this better than `[[ ]]` syntax); the sanitizer also allows `data-wiki-slug`. -### Phase 4 — Discovery & trust +### Phase 4 — Discovery & trust ✅ - FULLTEXT search (public search box + admin filter); revision history list / diff / restore. - **Exit check**: search returns expected pages; edit a page twice, diff the revisions, restore an older one, confirm a new revision is recorded. +- **Verified** (2026-06-27): `?q=` natural-language search matches on both body + (`recipes`→crafting) and title (`monsters`); the public search box and admin + filter both work. A page edited twice produced 3 revisions; the History modal + shows a word-level diff (added vs removed) of an old revision against current; + restoring reverted the page and appended a "Restored from revision #N" entry. ### Verification (every phase) Use the preview workflow, not manual hand-off: start the dev server, exercise the diff --git a/client/package-lock.json b/client/package-lock.json index e72ee3d..7c50d39 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "@tiptap/extension-link": "^2.27.2", "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", + "diff": "^5.2.2", "dompurify": "^3.4.11", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -1794,6 +1795,15 @@ } } }, + "node_modules/diff": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", + "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dompurify": { "version": "3.4.11", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", diff --git a/client/package.json b/client/package.json index 4338950..4b1948f 100644 --- a/client/package.json +++ b/client/package.json @@ -13,6 +13,7 @@ "@tiptap/extension-link": "^2.27.2", "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", + "diff": "^5.2.2", "dompurify": "^3.4.11", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/client/src/api/client.js b/client/src/api/client.js index 45c5666..474e78c 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -53,6 +53,7 @@ export const api = { const qs = new URLSearchParams() if (opts.category) qs.set('category', opts.category) if (opts.tag) qs.set('tag', opts.tag) + if (opts.q) qs.set('q', opts.q) const s = qs.toString() return req(`/public/wiki${s ? `?${s}` : ''}`) }, @@ -90,6 +91,10 @@ export const api = { publishWiki: (slug, published) => req(`/admin/wiki/${slug}/publish`, { method: 'PATCH', body: { published } }), deleteWiki: (slug) => req(`/admin/wiki/${slug}`, { method: 'DELETE' }), + listWikiRevisions: (slug) => req(`/admin/wiki/${slug}/revisions`), + getWikiRevision: (slug, id) => req(`/admin/wiki/${slug}/revisions/${id}`), + restoreWikiRevision: (slug, id) => + req(`/admin/wiki/${slug}/revisions/${id}/restore`, { method: 'POST' }), listWikiTags: () => req('/admin/wiki/tags'), listWikiCategories: () => req('/admin/wiki/categories'), createWikiCategory: (data) => req('/admin/wiki/categories', { method: 'POST', body: data }), diff --git a/client/src/routes/admin/views/WikiAdmin.jsx b/client/src/routes/admin/views/WikiAdmin.jsx index 30b2e08..48fcc1c 100644 --- a/client/src/routes/admin/views/WikiAdmin.jsx +++ b/client/src/routes/admin/views/WikiAdmin.jsx @@ -9,7 +9,11 @@ import WikiCategories from './WikiCategories.jsx' export default function WikiAdmin() { const [tick, setTick] = useState(0) const reload = useCallback(() => setTick((t) => t + 1), []) - const { loading, error, data } = useAsync(() => api.admin.listWiki(), [tick]) + const [q, setQ] = useState('') + const { loading, error, data } = useAsync( + () => api.admin.listWiki(q.trim() ? `?q=${encodeURIComponent(q.trim())}` : ''), + [tick, q], + ) const [editing, setEditing] = useState(null) // null | 'new' | slug const [managingCats, setManagingCats] = useState(false) const pages = data || [] @@ -21,6 +25,14 @@ export default function WikiAdmin() { {pages.length} page{pages.length === 1 ? '' : 's'} · edit content and structure

+ setQ(e.target.value)} + className="input" + placeholder="Search pages…" + style={{ width: 200 }} + /> diff --git a/client/src/routes/admin/views/WikiEditor.jsx b/client/src/routes/admin/views/WikiEditor.jsx index 541c3d3..d2bd4d3 100644 --- a/client/src/routes/admin/views/WikiEditor.jsx +++ b/client/src/routes/admin/views/WikiEditor.jsx @@ -1,5 +1,6 @@ import { lazy, Suspense, useEffect, useState } from 'react' import Modal from '../../../components/Modal.jsx' +import WikiHistory from './WikiHistory.jsx' import { api } from '../../../api/client.js' // Admin-only and heavy (TipTap) — load as its own chunk so the public bundle @@ -22,6 +23,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) { const [loading, setLoading] = useState(isEdit) const [busy, setBusy] = useState(false) const [error, setError] = useState('') + const [showHistory, setShowHistory] = useState(false) // Categories (dropdown) + pages (internal-link picker), for both new and edit. useEffect(() => { @@ -110,6 +112,7 @@ export default function WikiEditor({ slug, onClose, onSaved }) { } return ( + <> )} + {isEdit && ( + + )} @@ -208,6 +216,17 @@ export default function WikiEditor({ slug, onClose, onSaved }) {
)} + {showHistory && ( + setShowHistory(false)} + onRestored={() => { + setShowHistory(false) + onSaved() + }} + /> + )} + ) } diff --git a/client/src/routes/admin/views/WikiHistory.jsx b/client/src/routes/admin/views/WikiHistory.jsx new file mode 100644 index 0000000..f9bec45 --- /dev/null +++ b/client/src/routes/admin/views/WikiHistory.jsx @@ -0,0 +1,139 @@ +import { useEffect, useMemo, useState } from 'react' +import { diffWords } from 'diff' +import Modal from '../../../components/Modal.jsx' +import { dateTime } from '../../../lib/format.js' +import { api } from '../../../api/client.js' + +// Plain-text view of a body, for a readable word-level diff. +function toText(html) { + if (!html) return '' + if (typeof window === 'undefined' || !window.DOMParser) return html + const doc = new DOMParser().parseFromString(html, 'text/html') + return doc.body.textContent || '' +} + +export default function WikiHistory({ slug, onClose, onRestored }) { + const [revisions, setRevisions] = useState([]) + const [current, setCurrent] = useState(null) + const [selected, setSelected] = useState(null) // full revision snapshot + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + let active = true + Promise.all([api.admin.listWikiRevisions(slug), api.admin.getWiki(slug)]) + .then(([revs, page]) => { + if (!active) return + setRevisions(revs) + setCurrent(page) + }) + .catch(() => active && setError('Could not load history.')) + .finally(() => active && setLoading(false)) + return () => { + active = false + } + }, [slug]) + + async function selectRevision(id) { + setError('') + try { + const rev = await api.admin.getWikiRevision(slug, id) + setSelected(rev) + } catch (err) { + setError(err.message || 'Could not load revision.') + } + } + + async function restore() { + if (!selected) return + if (!confirm(`Restore the page to revision #${selected.id}? This creates a new revision.`)) return + setBusy(true) + try { + await api.admin.restoreWikiRevision(slug, selected.id) + onRestored() + } catch (err) { + setError(err.message || 'Could not restore.') + setBusy(false) + } + } + + // Diff the selected revision (old) against the current saved page (new). + const parts = useMemo( + () => (selected ? diffWords(toText(selected.body), toText(current?.body || '')) : []), + [selected, current], + ) + + return ( + + + + + } + > + {loading ? ( + + ) : error ? ( +

{error}

+ ) : ( +
+
    + {revisions.map((r, i) => ( +
  • + +
  • + ))} + {revisions.length === 0 &&
  • No revisions yet.
  • } +
+ +
+ {!selected ? ( +

+ Select a revision to see what changed between it and the current page. +

+ ) : ( + <> +

+ Diff: revision #{selected.id} → current +

+
+ {parts.length === 0 || (parts.length === 1 && !parts[0].added && !parts[0].removed) ? ( + No textual differences. + ) : ( + parts.map((p, i) => ( + + {p.value} + + )) + )} +
+ + )} +
+
+ )} +
+ ) +} diff --git a/client/src/routes/wiki/Wiki.jsx b/client/src/routes/wiki/Wiki.jsx index c43d51f..685c545 100644 --- a/client/src/routes/wiki/Wiki.jsx +++ b/client/src/routes/wiki/Wiki.jsx @@ -1,3 +1,4 @@ +import { useState } from 'react' import { Link, useSearchParams } from 'react-router-dom' import PublicLayout from '../../components/PublicLayout.jsx' import PageHeader from '../../components/PageHeader.jsx' @@ -5,6 +6,30 @@ import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx' import { useAsync } from '../../lib/useAsync.js' import { api } from '../../api/client.js' +function SearchBox({ initial, onSubmit }) { + const [term, setTerm] = useState(initial || '') + return ( +
{ + e.preventDefault() + onSubmit(term.trim()) + }} + style={{ display: 'flex', gap: 8, maxWidth: 460, margin: '0 auto 8px' }} + > + setTerm(e.target.value)} + className="input" + placeholder="Search the wiki…" + /> + +
+ ) +} + // Group published pages under their category, preserving category sort order and // collecting anything uncategorized into a trailing section. function groupByCategory(categories, pages) { @@ -36,16 +61,19 @@ function PageCard({ page }) { } export default function Wiki() { - const [searchParams] = useSearchParams() + const [searchParams, setSearchParams] = useSearchParams() const activeCategory = searchParams.get('category') const activeTag = searchParams.get('tag') - // Tag view fetches a tag-filtered page list; otherwise all pages (grouped here). + const activeQ = searchParams.get('q') + // Search / tag views fetch a filtered page list; otherwise all pages (grouped here). + const pageOpts = activeQ ? { q: activeQ } : activeTag ? { tag: activeTag } : {} const { loading, error, data } = useAsync( () => - Promise.all([api.wikiCategories(), api.wiki(activeTag ? { tag: activeTag } : {})]).then( - ([categories, pages]) => ({ categories, pages }), - ), - [activeTag], + Promise.all([api.wikiCategories(), api.wiki(pageOpts)]).then(([categories, pages]) => ({ + categories, + pages, + })), + [activeTag, activeQ], ) const allSections = data ? groupByCategory(data.categories, data.pages) : [] @@ -53,7 +81,10 @@ export default function Wiki() { ? allSections.filter((s) => s.slug === activeCategory) : allSections const hasPages = data && data.pages.length > 0 - const filtered = Boolean(activeCategory || activeTag) + const flat = Boolean(activeTag || activeQ) // flat-list views + const filtered = Boolean(activeCategory || activeTag || activeQ) + + const runSearch = (term) => setSearchParams(term ? { q: term } : {}) return ( @@ -64,6 +95,8 @@ export default function Wiki() { title="Mysticmoon Wiki" lead="A calm starting point for shard guides, the world and its lore, gameplay systems, and community rules." /> + + {loading && } {error && } {!loading && !error && !hasPages && !filtered && No wiki pages yet.} @@ -74,13 +107,14 @@ export default function Wiki() { ← All sections {activeTag && · Tagged #{activeTag}} + {activeQ && · Results for “{activeQ}”}

)} - {/* Tag view: a flat list of matching pages (tags cross categories). */} - {!loading && !error && activeTag && + {/* Flat list: search results or a tag filter (both cross categories). */} + {!loading && !error && flat && (data.pages.length === 0 ? ( - No pages with this tag. + {activeQ ? 'No pages match that search.' : 'No pages with this tag.'} ) : (
{data.pages.map((p) => ( @@ -90,10 +124,10 @@ export default function Wiki() { ))} {/* Category / full view: grouped sections. */} - {!loading && !error && !activeTag && hasPages && activeCategory && sections.length === 0 && ( + {!loading && !error && !flat && hasPages && activeCategory && sections.length === 0 && ( No pages in this section yet. )} - {!activeTag && + {!flat && sections.map((section) => (

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 (?, ?, ?)', [ @@ -264,6 +298,7 @@ module.exports = { listAllSummaries, findBySlug, findPublishedBySlug, + searchSummaries, insert, updateBySlug, deleteBySlug, @@ -283,6 +318,9 @@ module.exports = { insertLink, getBacklinks, getExistingSlugs, + insertRevision, + listRevisions, + findRevision, seedDefault, seedDefaultCategory, assignCategoryBySlug, diff --git a/server/src/model/wiki/wiki.model.js b/server/src/model/wiki/wiki.model.js index fe56a17..cf408b8 100644 --- a/server/src/model/wiki/wiki.model.js +++ b/server/src/model/wiki/wiki.model.js @@ -34,6 +34,19 @@ async function rebuildLinks(pageId, html) { } } +// 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) @@ -43,6 +56,10 @@ 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) @@ -77,6 +94,7 @@ async function create({ slug, title, body, excerpt, categoryId, published, updat 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) } @@ -102,6 +120,8 @@ async function update(slug, input) { 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) } @@ -120,6 +140,42 @@ async function remove(slug) { 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() @@ -164,12 +220,16 @@ async function removeCategory(id) { module.exports = { listPublished, listAll, + search, getBySlug, getPublishedBySlug, create, update, setPublished, remove, + listRevisions, + getRevision, + restoreRevision, listTags, getTagBySlug, listCategories, diff --git a/server/src/router/v1/admin/admin.controller.js b/server/src/router/v1/admin/admin.controller.js index 1dcbc7c..bba3c83 100644 --- a/server/src/router/v1/admin/admin.controller.js +++ b/server/src/router/v1/admin/admin.controller.js @@ -172,6 +172,9 @@ async function uploadFile(req, res) { // ── Wiki pages ───────────────────────────────────────────────────────── async function listWiki(req, res) { try { + const q = (req.query.q || '').trim() + if (q) return res.json(await wiki.search(q, { publishedOnly: false })) + const filters = {} if (req.query.category) { const category = await wiki.getCategoryBySlug(req.query.category) @@ -246,6 +249,7 @@ async function updateWiki(req, res) { if ('body' in req.body) input.body = req.body.body || null if ('excerpt' in req.body) input.excerpt = req.body.excerpt || null if ('published' in req.body) input.published = Boolean(req.body.published) + if ('change_note' in req.body) input.changeNote = req.body.change_note if ('tags' in req.body) input.tags = Array.isArray(req.body.tags) ? req.body.tags : [] if ('category_id' in req.body) { const cat = await resolveCategoryId(req.body) @@ -288,6 +292,45 @@ async function deleteWiki(req, res) { } } +// ── Wiki revisions ───────────────────────────────────────────────────── +async function listWikiRevisions(req, res) { + try { + const revisions = await wiki.listRevisions(req.params.slug) + if (revisions == null) return res.status(404).json({ message: 'Not found' }) + return res.json(revisions) + } catch (err) { + log.error('listWikiRevisions', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +async function getWikiRevision(req, res) { + try { + const rev = await wiki.getRevision(req.params.slug, Number(req.params.id)) + if (!rev) return res.status(404).json({ message: 'Not found' }) + return res.json(rev) + } catch (err) { + log.error('getWikiRevision', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +async function restoreWikiRevision(req, res) { + try { + const page = await wiki.restoreRevision(req.params.slug, Number(req.params.id), req.user.id) + if (!page) return res.status(404).json({ message: 'Not found' }) + await activity.log({ + req, + action: 'wiki.revision.restore', + detail: { slug: req.params.slug, revision: Number(req.params.id) }, + }) + return res.json(page) + } catch (err) { + log.error('restoreWikiRevision', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + // ── Wiki tags ────────────────────────────────────────────────────────── async function listWikiTags(req, res) { try { @@ -488,6 +531,9 @@ module.exports = { updateWiki, publishWiki, deleteWiki, + listWikiRevisions, + getWikiRevision, + restoreWikiRevision, listWikiTags, listWikiCategories, createWikiCategory, diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index 734ca2a..bf31ec9 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -114,6 +114,7 @@ adminRouter.put( body('category_id').optional({ values: 'null' }).isInt(), body('published').optional().isBoolean(), body('tags').optional().isArray(), + body('change_note').optional({ values: 'falsy' }).isString().isLength({ max: 280 }), validate, ctrl.updateWiki, ) @@ -123,6 +124,14 @@ adminRouter.patch( validate, ctrl.publishWiki, ) +adminRouter.get('/wiki/:slug/revisions', ctrl.listWikiRevisions) +adminRouter.get('/wiki/:slug/revisions/:id', param('id').isInt(), validate, ctrl.getWikiRevision) +adminRouter.post( + '/wiki/:slug/revisions/:id/restore', + param('id').isInt(), + validate, + ctrl.restoreWikiRevision, +) adminRouter.delete('/wiki/:slug', ctrl.deleteWiki) // ── Settings ────────────────────────────────────────────────────────── diff --git a/server/src/router/v1/public/public.controller.js b/server/src/router/v1/public/public.controller.js index a3c8ef3..2559171 100644 --- a/server/src/router/v1/public/public.controller.js +++ b/server/src/router/v1/public/public.controller.js @@ -68,6 +68,10 @@ async function getWikiTags(req, res) { async function getWikiList(req, res) { try { + // Full-text search takes precedence over category/tag filters. + const q = (req.query.q || '').trim() + if (q) return res.json(await wiki.search(q, { publishedOnly: true })) + const filters = {} if (req.query.category) { const category = await wiki.getCategoryBySlug(req.query.category)