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, }