Clears the 124 CODE_SMELL findings from the SonarQube scan (server, client, and bot). All changes are behaviour-preserving refactors — no route, protocol, schema, or config changes — verified against the full server (381) and client (43) test suites plus a clean client build. By rule: - S3776 (20, cognitive complexity): extract helpers/handlers so each function drops under the threshold — shard model upsert builders, page/wiki update, block validation, notification stream mapping (dispatch table), SSO mobile login, shard ingest deps, uo-link socket backfill/connect, the bot slash- command dispatchers + discord manager, and the Shard/UserDetail/HeroEditor/ CharacterStats React components. - S4624 (34, nested template literals): pull inner templates into locals / a withQs() helper; rewrite shardEvents.describe() as a formatter table. - S3358 (35, nested ternaries): lift to if/else vars, lookup maps, small components, or guarded JSX expressions. - S6479 (12, array-index React keys): key by stable content instead of index (two in-editor lists left as-is; index matches their by-index edit model). - S6353 (6): [0-9]/[^0-9] -> \d/\D. S125 (5): reword state-shape comments that parsed as code. S3800/S3782 (botScore): JSDoc-type PATH_WEIGHTS tuples. - S6481 (2): memoize Auth/Site context values (and SiteContext brand). - S4144: dedupe HeroEditor upload handler into useImageUpload(). - S1126 (2), S6035, S5869 (redundant A-Z under /i), S5843 (town-name regex -> prefix list): assorted one-liners. Co-Authored-By: Claude <noreply@anthropic.com>
249 lines
7.8 KiB
JavaScript
249 lines
7.8 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)
|
|
}
|
|
|
|
// Map the partial `input` to DB columns (only keys present are written), and
|
|
// report the cleaned body for link rebuilding when the body changed. Split out of
|
|
// update() so that stays a flat sequence of write steps.
|
|
function mapUpdateFields(input, current) {
|
|
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()
|
|
}
|
|
return { fields, cleanForLinks }
|
|
}
|
|
|
|
// 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, cleanForLinks } = mapUpdateFields(input, current)
|
|
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,
|
|
}
|