chapterhouse 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/korg.js +5 -5
- package/dist/api/korg.test.js +3 -3
- package/dist/api/route-coverage.test.js +225 -0
- package/dist/api/server.js +321 -7
- package/dist/api/server.test.js +310 -3
- package/dist/shared/api-schemas.js +618 -0
- package/package.json +1 -1
- package/web/dist/assets/{index-BbX9RKf3.js → index-tBfBbEk5.js} +156 -93
- package/web/dist/assets/index-tBfBbEk5.js.map +1 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BbX9RKf3.js.map +0 -1
package/dist/api/server.js
CHANGED
|
@@ -16,8 +16,9 @@ import { createAuthMiddleware, getBootstrapAuthResponse } from "./auth.js";
|
|
|
16
16
|
import { assertAgentEditAccess } from "./agent-edit-access.js";
|
|
17
17
|
import { createConcurrentConnectionLimiter, createFixedWindowRateLimiter } from "./rate-limit.js";
|
|
18
18
|
import { createTeamRouter } from "./team.js";
|
|
19
|
-
import { writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, } from "../wiki/fs.js";
|
|
19
|
+
import { readPage, writePage, deletePage, pageExists, listPages, ensureWikiStructure, assertPagePath, getWikiDir, } from "../wiki/fs.js";
|
|
20
20
|
import { parseWikiFrontmatter } from "../wiki/frontmatter.js";
|
|
21
|
+
import { normalizeWikiPath } from "../wiki/path-utils.js";
|
|
21
22
|
import { loadRegistry, saveRegistry } from "../wiki/project-registry.js";
|
|
22
23
|
import { getProjectRulesPath, listTopLevelSoftRules, loadProjectRules, loadProjectRuleSummary, renderInitialProjectRulesPage, saveProjectRulesHardFields, saveProjectRulesSoftRules, } from "../wiki/project-rules.js";
|
|
23
24
|
import { readWikiPage, teamWikiSync } from "../wiki/team-sync.js";
|
|
@@ -29,7 +30,7 @@ import { getCurrentRunId, getDb, getSessionMessages, getTaskEvents } from "../st
|
|
|
29
30
|
import { getTaskLogEvents, subscribeTaskLog } from "../copilot/task-event-log.js";
|
|
30
31
|
import { subscribeSession, getSessionEventsFromDb, getSessionMaxSeqFromDb, oldestSessionSeq, } from "../copilot/turn-event-log.js";
|
|
31
32
|
import { getStatus, onStatusChange } from "../status.js";
|
|
32
|
-
import { formatSseData
|
|
33
|
+
import { formatSseData } from "./sse.js";
|
|
33
34
|
import { assertAuthenticationConfigured, createHealthPayload, createPublicConfigPayload, getDisplayHost, resolveApiToken, shouldServeSpaPath, } from "./server-runtime.js";
|
|
34
35
|
import { BadRequestError, ForbiddenError, InternalServerError, NotFoundError, apiNotFoundHandler, asBadRequest, createApiErrorHandler, parseRequest, } from "./errors.js";
|
|
35
36
|
import { childLogger } from "../util/logger.js";
|
|
@@ -40,6 +41,7 @@ import { recordObservation } from "../memory/observations.js";
|
|
|
40
41
|
import { recordDecision } from "../memory/decisions.js";
|
|
41
42
|
import { upsertEntity } from "../memory/entities.js";
|
|
42
43
|
import { getInboxItem, listPendingInboxItems, resolveInboxItem } from "../memory/inbox.js";
|
|
44
|
+
import { ingestSource } from "../wiki/ingest.js";
|
|
43
45
|
import { listKorgResearchSessions, routeKorgMessage } from "./korg.js";
|
|
44
46
|
const log = childLogger("server");
|
|
45
47
|
const modeContext = new ModeContext(config);
|
|
@@ -146,7 +148,7 @@ const prMergeHookSchema = z.object({
|
|
|
146
148
|
});
|
|
147
149
|
const korgRequestSchema = z.object({
|
|
148
150
|
message: requiredString("Missing 'message' in request body"),
|
|
149
|
-
|
|
151
|
+
sessionKey: z.string().trim().min(1).optional(),
|
|
150
152
|
}).strict();
|
|
151
153
|
const projectHardRulesSchema = z.object({
|
|
152
154
|
hardRules: z.object({
|
|
@@ -192,6 +194,149 @@ function createProjectDetailPayload(slug, cwd) {
|
|
|
192
194
|
softRules: listTopLevelSoftRules(rules.soft),
|
|
193
195
|
};
|
|
194
196
|
}
|
|
197
|
+
function coerceWikiPageUpdated(path, updated) {
|
|
198
|
+
const normalized = updated?.trim();
|
|
199
|
+
if (normalized) {
|
|
200
|
+
return normalized;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
return statSync(join(getWikiDir(), path)).mtime.toISOString();
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return "unknown";
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function wikiPathToBrowserSlug(path) {
|
|
210
|
+
return path.replace(/^pages\//, "").replace(/\.md$/, "");
|
|
211
|
+
}
|
|
212
|
+
function normalizeOptionalQueryParam(value) {
|
|
213
|
+
if (typeof value !== "string") {
|
|
214
|
+
return undefined;
|
|
215
|
+
}
|
|
216
|
+
const trimmed = value.trim();
|
|
217
|
+
return trimmed ? trimmed : undefined;
|
|
218
|
+
}
|
|
219
|
+
function matchesWikiBrowserFilters(page, filters) {
|
|
220
|
+
if (filters.type && page.type !== filters.type) {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
if (!filters.q) {
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
const query = filters.q.toLowerCase();
|
|
227
|
+
return page.title.toLowerCase().includes(query) || page.summary.toLowerCase().includes(query);
|
|
228
|
+
}
|
|
229
|
+
function mapIndexEntryToBrowserPage(entry) {
|
|
230
|
+
return {
|
|
231
|
+
slug: wikiPathToBrowserSlug(entry.path),
|
|
232
|
+
title: entry.title,
|
|
233
|
+
summary: entry.summary,
|
|
234
|
+
type: entry.section,
|
|
235
|
+
last_updated: coerceWikiPageUpdated(entry.path, entry.updated),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
function listFallbackWikiBrowserPages(filters) {
|
|
239
|
+
const entries = parseIndex();
|
|
240
|
+
const indexed = new Set(entries.map((entry) => entry.path));
|
|
241
|
+
const indexedResults = entries.map(mapIndexEntryToBrowserPage);
|
|
242
|
+
const orphanResults = listPages()
|
|
243
|
+
.filter((path) => !indexed.has(path))
|
|
244
|
+
.map((path) => ({
|
|
245
|
+
slug: wikiPathToBrowserSlug(path),
|
|
246
|
+
title: path,
|
|
247
|
+
summary: "",
|
|
248
|
+
type: "Unindexed",
|
|
249
|
+
last_updated: coerceWikiPageUpdated(path, undefined),
|
|
250
|
+
}));
|
|
251
|
+
return [...indexedResults, ...orphanResults].filter((page) => matchesWikiBrowserFilters(page, filters));
|
|
252
|
+
}
|
|
253
|
+
function listDbWikiBrowserPages(filters) {
|
|
254
|
+
const db = getDb();
|
|
255
|
+
const clauses = [];
|
|
256
|
+
const params = [];
|
|
257
|
+
if (filters.type) {
|
|
258
|
+
clauses.push("entity_type = ?");
|
|
259
|
+
params.push(filters.type);
|
|
260
|
+
}
|
|
261
|
+
if (filters.q) {
|
|
262
|
+
clauses.push("(title LIKE ? COLLATE NOCASE OR COALESCE(summary, '') LIKE ? COLLATE NOCASE)");
|
|
263
|
+
params.push(`%${filters.q}%`, `%${filters.q}%`);
|
|
264
|
+
}
|
|
265
|
+
const rows = db.prepare(`
|
|
266
|
+
SELECT path, title, summary, entity_type, last_updated, pinned
|
|
267
|
+
FROM wiki_pages
|
|
268
|
+
${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
269
|
+
ORDER BY COALESCE(last_updated, '') DESC, title ASC
|
|
270
|
+
`).all(...params);
|
|
271
|
+
return rows.map((row) => ({
|
|
272
|
+
slug: wikiPathToBrowserSlug(row.path),
|
|
273
|
+
title: row.title,
|
|
274
|
+
summary: row.summary ?? "",
|
|
275
|
+
type: row.entity_type ?? "topics",
|
|
276
|
+
last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
|
|
277
|
+
...(row.pinned ? { pinned: true } : {}),
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
function parseWikiSourcePages(value) {
|
|
281
|
+
if (!value) {
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const parsed = JSON.parse(value);
|
|
286
|
+
return Array.isArray(parsed)
|
|
287
|
+
? parsed.filter((entry) => typeof entry === "string").map((entry) => normalizeWikiPath(entry))
|
|
288
|
+
: [];
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
return [];
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function listWikiSources(page) {
|
|
295
|
+
const rows = getDb().prepare(`
|
|
296
|
+
SELECT source_type, origin, raw_path, pages_updated, status, session_id, ingested_at
|
|
297
|
+
FROM wiki_sources
|
|
298
|
+
ORDER BY ingested_at DESC, id ASC
|
|
299
|
+
`).all();
|
|
300
|
+
return rows
|
|
301
|
+
.filter((row) => !page || parseWikiSourcePages(row.pages_updated).includes(page))
|
|
302
|
+
.map((row) => ({
|
|
303
|
+
source_url: row.origin,
|
|
304
|
+
path: row.raw_path ?? undefined,
|
|
305
|
+
kind: row.source_type,
|
|
306
|
+
status: row.status,
|
|
307
|
+
session_id: row.session_id,
|
|
308
|
+
ingested_at: row.ingested_at,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
function wikiPathToSlug(path) {
|
|
312
|
+
const segments = normalizeWikiPath(path).split("/").filter(Boolean);
|
|
313
|
+
const file = segments[segments.length - 1] ?? path;
|
|
314
|
+
const base = file.endsWith(".md") ? file.slice(0, -3) : file;
|
|
315
|
+
if (base === "index" && segments.length >= 2) {
|
|
316
|
+
return segments[segments.length - 2] ?? base;
|
|
317
|
+
}
|
|
318
|
+
return base;
|
|
319
|
+
}
|
|
320
|
+
function listWikiLinks(page) {
|
|
321
|
+
const rows = page
|
|
322
|
+
? getDb().prepare(`
|
|
323
|
+
SELECT from_page, to_page, link_type
|
|
324
|
+
FROM wiki_links
|
|
325
|
+
WHERE from_page = ? OR to_page = ?
|
|
326
|
+
ORDER BY from_page ASC, to_page ASC, link_type ASC
|
|
327
|
+
`).all(page, page)
|
|
328
|
+
: getDb().prepare(`
|
|
329
|
+
SELECT from_page, to_page, link_type
|
|
330
|
+
FROM wiki_links
|
|
331
|
+
ORDER BY from_page ASC, to_page ASC, link_type ASC
|
|
332
|
+
`).all();
|
|
333
|
+
return rows.map((row) => ({
|
|
334
|
+
source_slug: wikiPathToSlug(row.from_page),
|
|
335
|
+
target_slug: wikiPathToSlug(row.to_page),
|
|
336
|
+
link_type: row.link_type,
|
|
337
|
+
...(page ? { direction: row.from_page === page ? "outgoing" : "incoming" } : {}),
|
|
338
|
+
}));
|
|
339
|
+
}
|
|
195
340
|
// Load a configured API token when present; startup validation below enforces auth.
|
|
196
341
|
let apiToken = null;
|
|
197
342
|
try {
|
|
@@ -312,6 +457,13 @@ function readPathParam(req) {
|
|
|
312
457
|
}
|
|
313
458
|
return raw;
|
|
314
459
|
}
|
|
460
|
+
function readOptionalPageFilter(req) {
|
|
461
|
+
const raw = req.query.page;
|
|
462
|
+
if (typeof raw !== "string" || !raw.trim()) {
|
|
463
|
+
return undefined;
|
|
464
|
+
}
|
|
465
|
+
return normalizeWikiPath(raw.trim());
|
|
466
|
+
}
|
|
315
467
|
function assertValidPagePath(path) {
|
|
316
468
|
try {
|
|
317
469
|
assertPagePath(path);
|
|
@@ -758,11 +910,11 @@ app.get("/stream", (req, res) => {
|
|
|
758
910
|
}
|
|
759
911
|
sseClients.set(connectionId, res);
|
|
760
912
|
const unsubscribeStatus = onStatusChange((status, message) => {
|
|
761
|
-
res.write(
|
|
913
|
+
res.write(formatSseData({ type: "status", status, message }));
|
|
762
914
|
});
|
|
763
915
|
const currentStatus = getStatus();
|
|
764
916
|
if (currentStatus.status !== "idle") {
|
|
765
|
-
res.write(
|
|
917
|
+
res.write(formatSseData({ type: "status", ...currentStatus }));
|
|
766
918
|
}
|
|
767
919
|
const heartbeat = setInterval(() => {
|
|
768
920
|
res.write(`:ping\n\n`);
|
|
@@ -1448,7 +1600,7 @@ app.get("/api/wiki/pages", async (req, res) => {
|
|
|
1448
1600
|
summary: e.summary,
|
|
1449
1601
|
section: e.section,
|
|
1450
1602
|
tags: e.tags || [],
|
|
1451
|
-
updated: e.updated
|
|
1603
|
+
updated: coerceWikiPageUpdated(e.path, e.updated),
|
|
1452
1604
|
scope: getWikiPageScope(e.path),
|
|
1453
1605
|
}));
|
|
1454
1606
|
const orphanResults = listPages()
|
|
@@ -1459,12 +1611,60 @@ app.get("/api/wiki/pages", async (req, res) => {
|
|
|
1459
1611
|
summary: "",
|
|
1460
1612
|
section: "Unindexed",
|
|
1461
1613
|
tags: [],
|
|
1462
|
-
updated:
|
|
1614
|
+
updated: coerceWikiPageUpdated(p, undefined),
|
|
1463
1615
|
scope: getWikiPageScope(p),
|
|
1464
1616
|
}));
|
|
1465
1617
|
res.json([...indexedResults, ...orphanResults]);
|
|
1466
1618
|
});
|
|
1619
|
+
app.get("/api/wiki/browser-pages", async (req, res) => {
|
|
1620
|
+
ensureWikiStructure();
|
|
1621
|
+
const filters = {
|
|
1622
|
+
q: normalizeOptionalQueryParam(req.query.q),
|
|
1623
|
+
type: normalizeOptionalQueryParam(req.query.type),
|
|
1624
|
+
};
|
|
1625
|
+
const db = getDb();
|
|
1626
|
+
const { count } = db.prepare("SELECT COUNT(*) AS count FROM wiki_pages").get();
|
|
1627
|
+
const pages = count > 0
|
|
1628
|
+
? listDbWikiBrowserPages(filters)
|
|
1629
|
+
: listFallbackWikiBrowserPages(filters);
|
|
1630
|
+
res.json({ pages });
|
|
1631
|
+
});
|
|
1632
|
+
app.get("/api/wiki/sources", async (req, res) => {
|
|
1633
|
+
ensureWikiStructure();
|
|
1634
|
+
res.json({ sources: listWikiSources(readOptionalPageFilter(req)) });
|
|
1635
|
+
});
|
|
1636
|
+
app.get("/api/wiki/links", async (req, res) => {
|
|
1637
|
+
ensureWikiStructure();
|
|
1638
|
+
res.json({ links: listWikiLinks(readOptionalPageFilter(req)) });
|
|
1639
|
+
});
|
|
1467
1640
|
app.get("/api/wiki/page", async (req, res) => {
|
|
1641
|
+
const slugParam = normalizeOptionalQueryParam(req.query.slug);
|
|
1642
|
+
if (slugParam) {
|
|
1643
|
+
const path = `pages/${slugParam}.md`;
|
|
1644
|
+
assertValidPagePath(path);
|
|
1645
|
+
const db = getDb();
|
|
1646
|
+
const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
|
|
1647
|
+
const authorizationHeader = typeof req.headers.authorization === "string"
|
|
1648
|
+
? req.headers.authorization
|
|
1649
|
+
: undefined;
|
|
1650
|
+
const rawContent = await readWikiPage(path, { authorizationHeader });
|
|
1651
|
+
if (rawContent === undefined) {
|
|
1652
|
+
throw new NotFoundError("Page not found");
|
|
1653
|
+
}
|
|
1654
|
+
const { parsed: frontmatter } = parseWikiFrontmatter(rawContent);
|
|
1655
|
+
res.json({
|
|
1656
|
+
page: {
|
|
1657
|
+
slug: slugParam,
|
|
1658
|
+
title: row?.title ?? slugParam,
|
|
1659
|
+
type: row?.entity_type ?? "topics",
|
|
1660
|
+
last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
|
|
1661
|
+
compiled_truth: rawContent,
|
|
1662
|
+
pinned: Boolean(row?.pinned),
|
|
1663
|
+
frontmatter,
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1468
1668
|
const path = assertValidPagePath(readPathParam(req));
|
|
1469
1669
|
const authorizationHeader = typeof req.headers.authorization === "string"
|
|
1470
1670
|
? req.headers.authorization
|
|
@@ -1489,6 +1689,54 @@ app.put("/api/wiki/page", async (req, res) => {
|
|
|
1489
1689
|
});
|
|
1490
1690
|
res.json({ ok: true, created, path });
|
|
1491
1691
|
});
|
|
1692
|
+
const wikiUpdateSchema = z.object({
|
|
1693
|
+
slug: requiredString("Missing 'slug' in request body"),
|
|
1694
|
+
compiled_truth: z.string().optional(),
|
|
1695
|
+
frontmatter: z.record(z.string(), z.unknown()).optional(),
|
|
1696
|
+
});
|
|
1697
|
+
app.post("/api/wiki/update", authMiddleware, async (req, res) => {
|
|
1698
|
+
const { slug, compiled_truth, frontmatter: newFrontmatter } = parseRequest(wikiUpdateSchema, req.body);
|
|
1699
|
+
const path = assertValidPagePath(`pages/${slug}.md`);
|
|
1700
|
+
let finalContent = compiled_truth;
|
|
1701
|
+
if (newFrontmatter !== undefined) {
|
|
1702
|
+
const existing = readPage(path);
|
|
1703
|
+
const base = finalContent ?? existing ?? "";
|
|
1704
|
+
const { parsed: existingFrontmatter, body } = parseWikiFrontmatter(base);
|
|
1705
|
+
const merged = { ...existingFrontmatter, ...newFrontmatter };
|
|
1706
|
+
const fmLines = Object.entries(merged).map(([k, v]) => `${k}: ${JSON.stringify(v)}`).join("\n");
|
|
1707
|
+
finalContent = `---\n${fmLines}\n---\n${body}`;
|
|
1708
|
+
}
|
|
1709
|
+
if (finalContent !== undefined) {
|
|
1710
|
+
await withWikiWrite(() => writePage(path, finalContent));
|
|
1711
|
+
}
|
|
1712
|
+
const db = getDb();
|
|
1713
|
+
const row = db.prepare("SELECT title, entity_type, last_updated, pinned FROM wiki_pages WHERE path = ?").get(path);
|
|
1714
|
+
const content = readPage(path) ?? finalContent ?? "";
|
|
1715
|
+
const { parsed: frontmatter } = parseWikiFrontmatter(content);
|
|
1716
|
+
res.json({
|
|
1717
|
+
ok: true,
|
|
1718
|
+
page: {
|
|
1719
|
+
slug,
|
|
1720
|
+
title: row?.title ?? slug,
|
|
1721
|
+
type: row?.entity_type ?? "topics",
|
|
1722
|
+
last_updated: row?.last_updated ?? coerceWikiPageUpdated(path, undefined),
|
|
1723
|
+
compiled_truth: content,
|
|
1724
|
+
pinned: Boolean(row?.pinned),
|
|
1725
|
+
frontmatter,
|
|
1726
|
+
},
|
|
1727
|
+
});
|
|
1728
|
+
});
|
|
1729
|
+
const wikiPinSchema = z.object({
|
|
1730
|
+
slug: requiredString("Missing 'slug' in request body"),
|
|
1731
|
+
pinned: z.boolean({ error: "Missing 'pinned' in request body" }),
|
|
1732
|
+
});
|
|
1733
|
+
app.post("/api/wiki/page/pin", authMiddleware, async (req, res) => {
|
|
1734
|
+
const { slug, pinned } = parseRequest(wikiPinSchema, req.body);
|
|
1735
|
+
const path = assertValidPagePath(`pages/${slug}.md`);
|
|
1736
|
+
const db = getDb();
|
|
1737
|
+
db.prepare("UPDATE wiki_pages SET pinned = ? WHERE path = ?").run(pinned ? 1 : 0, path);
|
|
1738
|
+
res.json({ ok: true, pinned: Boolean(pinned) });
|
|
1739
|
+
});
|
|
1492
1740
|
app.delete("/api/wiki/page", async (req, res) => {
|
|
1493
1741
|
const path = assertValidPagePath(readPathParam(req));
|
|
1494
1742
|
const removed = await withWikiWrite(() => deletePage(path));
|
|
@@ -1502,6 +1750,72 @@ app.post("/api/wiki/korg", authMiddleware, async (req, res) => {
|
|
|
1502
1750
|
app.get("/api/wiki/korg/sessions", authMiddleware, (_req, res) => {
|
|
1503
1751
|
res.json({ sessions: listKorgResearchSessions(getDb()) });
|
|
1504
1752
|
});
|
|
1753
|
+
const wikiIngestSchema = z.object({
|
|
1754
|
+
source_url: z.string().optional(),
|
|
1755
|
+
path: z.string().optional(),
|
|
1756
|
+
topic: z.string().optional(),
|
|
1757
|
+
});
|
|
1758
|
+
app.post("/api/wiki/ingest", authMiddleware, async (req, res) => {
|
|
1759
|
+
const { source_url, path: ingestPath, topic } = parseRequest(wikiIngestSchema, req.body ?? {});
|
|
1760
|
+
const source = source_url ?? ingestPath;
|
|
1761
|
+
if (!source) {
|
|
1762
|
+
throw new BadRequestError("Missing 'source_url' or 'path' in request body");
|
|
1763
|
+
}
|
|
1764
|
+
const type = source_url ? "url" : "text";
|
|
1765
|
+
await withWikiWrite(() => ingestSource(source, type, topic));
|
|
1766
|
+
res.json({ ok: true });
|
|
1767
|
+
});
|
|
1768
|
+
const wikiSearchSchema = z.object({
|
|
1769
|
+
q: z.string().optional(),
|
|
1770
|
+
type: z.string().optional(),
|
|
1771
|
+
});
|
|
1772
|
+
app.get("/api/wiki/search", authMiddleware, async (req, res) => {
|
|
1773
|
+
ensureWikiStructure();
|
|
1774
|
+
const q = normalizeOptionalQueryParam(req.query.q);
|
|
1775
|
+
const type = normalizeOptionalQueryParam(req.query.type);
|
|
1776
|
+
const db = getDb();
|
|
1777
|
+
let rows;
|
|
1778
|
+
try {
|
|
1779
|
+
const clauses = ["wiki_pages_fts MATCH ?"];
|
|
1780
|
+
const params = [q ? `${q}*` : "*"];
|
|
1781
|
+
if (type) {
|
|
1782
|
+
clauses.push("entity_type = ?");
|
|
1783
|
+
params.push(type);
|
|
1784
|
+
}
|
|
1785
|
+
rows = db.prepare(`
|
|
1786
|
+
SELECT path, title, entity_type, last_updated
|
|
1787
|
+
FROM wiki_pages_fts
|
|
1788
|
+
WHERE ${clauses.join(" AND ")}
|
|
1789
|
+
ORDER BY rank
|
|
1790
|
+
LIMIT 50
|
|
1791
|
+
`).all(...params);
|
|
1792
|
+
}
|
|
1793
|
+
catch {
|
|
1794
|
+
const clauses = [];
|
|
1795
|
+
const params = [];
|
|
1796
|
+
if (q) {
|
|
1797
|
+
clauses.push("title LIKE ? COLLATE NOCASE");
|
|
1798
|
+
params.push(`%${q}%`);
|
|
1799
|
+
}
|
|
1800
|
+
if (type) {
|
|
1801
|
+
clauses.push("entity_type = ?");
|
|
1802
|
+
params.push(type);
|
|
1803
|
+
}
|
|
1804
|
+
rows = db.prepare(`
|
|
1805
|
+
SELECT path, title, entity_type, last_updated
|
|
1806
|
+
FROM wiki_pages
|
|
1807
|
+
${clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : ""}
|
|
1808
|
+
LIMIT 50
|
|
1809
|
+
`).all(...params);
|
|
1810
|
+
}
|
|
1811
|
+
const pages = rows.map((row) => ({
|
|
1812
|
+
slug: wikiPathToBrowserSlug(row.path),
|
|
1813
|
+
title: row.title,
|
|
1814
|
+
type: row.entity_type ?? "topics",
|
|
1815
|
+
last_updated: coerceWikiPageUpdated(row.path, row.last_updated ?? undefined),
|
|
1816
|
+
}));
|
|
1817
|
+
res.json({ pages });
|
|
1818
|
+
});
|
|
1505
1819
|
// ---------------------------------------------------------------------------
|
|
1506
1820
|
// Skills
|
|
1507
1821
|
// ---------------------------------------------------------------------------
|