@tikoci/rosetta 0.2.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/src/query.ts ADDED
@@ -0,0 +1,990 @@
1
+ /**
2
+ * query.ts — Natural-language → FTS5 query planner for RouterOS documentation.
3
+ *
4
+ * NL → FTS5 query planner for docs. SQL-as-RAG pattern:
5
+ * no author/date/engagement signals — just text search with BM25 ranking.
6
+ */
7
+
8
+ import { db } from "./db.ts";
9
+
10
+ export type SearchResult = {
11
+ id: number;
12
+ title: string;
13
+ path: string;
14
+ url: string;
15
+ word_count: number;
16
+ code_lines: number;
17
+ excerpt: string;
18
+ };
19
+
20
+ export type SearchResponse = {
21
+ query: string;
22
+ ftsQuery: string;
23
+ fallbackMode: "or" | null;
24
+ results: SearchResult[];
25
+ total: number;
26
+ };
27
+
28
+ const DEFAULT_LIMIT = 8;
29
+ const MAX_TERMS = 8;
30
+ const MIN_TERM_LENGTH = 2;
31
+
32
+ const STOP_WORDS = new Set([
33
+ "a", "about", "an", "and", "are", "by", "can", "command", "commands",
34
+ "configure", "do", "does", "documentation", "docs", "find", "for", "from",
35
+ "how", "i", "in", "into", "is", "it", "me", "mikrotik", "most", "my",
36
+ "of", "on", "or", "page", "pages", "routeros", "router", "show", "tell",
37
+ "that", "the", "their", "them", "these", "this", "those",
38
+ "what", "when", "where", "which", "why", "with", "without",
39
+ ]);
40
+
41
+ const COMPOUND_TERMS: [string, string][] = [
42
+ ["firewall", "filter"],
43
+ ["firewall", "mangle"],
44
+ ["firewall", "nat"],
45
+ ["firewall", "raw"],
46
+ ["ip", "address"],
47
+ ["ip", "route"],
48
+ ["ip", "pool"],
49
+ ["ip", "firewall"],
50
+ ["ip", "dns"],
51
+ ["ip", "dhcp"],
52
+ ["bridge", "port"],
53
+ ["bridge", "vlan"],
54
+ ["bridge", "filter"],
55
+ ["bridge", "host"],
56
+ ["system", "scheduler"],
57
+ ["system", "script"],
58
+ ["system", "package"],
59
+ ["system", "clock"],
60
+ ["system", "identity"],
61
+ ["system", "resource"],
62
+ ["interface", "bridge"],
63
+ ["interface", "vlan"],
64
+ ["interface", "wireless"],
65
+ ["interface", "ethernet"],
66
+ ["interface", "list"],
67
+ ["routing", "filter"],
68
+ ["routing", "table"],
69
+ ["routing", "ospf"],
70
+ ["routing", "bgp"],
71
+ ["container", "envs"],
72
+ ["container", "mounts"],
73
+ ["certificate", "import"],
74
+ ["caps", "man"],
75
+ ["wifi", "channel"],
76
+ ["wifi", "security"],
77
+ ["wifi", "configuration"],
78
+ ["dhcp", "server"],
79
+ ["dhcp", "client"],
80
+ ["dhcp", "relay"],
81
+ ["switch", "chip"],
82
+ ["switch", "rule"],
83
+ ["queue", "simple"],
84
+ ["queue", "tree"],
85
+ ["address", "list"],
86
+ ];
87
+
88
+ export function extractTerms(question: string): string[] {
89
+ return question
90
+ .toLowerCase()
91
+ .replace(/[^\w\s-]/g, " ")
92
+ .split(/\s+/)
93
+ .filter((t) => t.length >= MIN_TERM_LENGTH && !STOP_WORDS.has(t))
94
+ .slice(0, MAX_TERMS);
95
+ }
96
+
97
+ export function buildFtsQuery(terms: string[], mode: "AND" | "OR"): string {
98
+ if (terms.length === 0) return "";
99
+
100
+ // Check for compound terms and convert to NEAR expressions
101
+ const used = new Set<number>();
102
+ const parts: string[] = [];
103
+
104
+ for (const [a, b] of COMPOUND_TERMS) {
105
+ const idxA = terms.indexOf(a);
106
+ const idxB = terms.indexOf(b);
107
+ if (idxA >= 0 && idxB >= 0 && !used.has(idxA) && !used.has(idxB)) {
108
+ parts.push(`NEAR("${a}" "${b}", 5)`);
109
+ used.add(idxA);
110
+ used.add(idxB);
111
+ }
112
+ }
113
+
114
+ // Add remaining terms
115
+ for (let i = 0; i < terms.length; i++) {
116
+ if (!used.has(i)) {
117
+ parts.push(`"${terms[i]}"`);
118
+ }
119
+ }
120
+
121
+ return parts.join(mode === "AND" ? " AND " : " OR ");
122
+ }
123
+
124
+ export function searchPages(question: string, limit = DEFAULT_LIMIT): SearchResponse {
125
+ const terms = extractTerms(question);
126
+ if (terms.length === 0) {
127
+ return { query: question, ftsQuery: "", fallbackMode: null, results: [], total: 0 };
128
+ }
129
+
130
+ // Try AND first
131
+ let ftsQuery = buildFtsQuery(terms, "AND");
132
+ let fallbackMode: "or" | null = null;
133
+
134
+ let results = runFtsQuery(ftsQuery, limit);
135
+
136
+ // Fallback to OR if AND returns nothing and we have multiple terms
137
+ if (results.length === 0 && terms.length > 1) {
138
+ ftsQuery = buildFtsQuery(terms, "OR");
139
+ results = runFtsQuery(ftsQuery, limit);
140
+ fallbackMode = "or";
141
+ }
142
+
143
+ return { query: question, ftsQuery, fallbackMode, results, total: results.length };
144
+ }
145
+
146
+ function runFtsQuery(ftsQuery: string, limit: number): SearchResult[] {
147
+ if (!ftsQuery) return [];
148
+ try {
149
+ return db
150
+ .prepare(
151
+ `SELECT s.id, s.title, s.path, s.url, s.word_count, s.code_lines,
152
+ snippet(pages_fts, 2, '**', '**', '...', 30) as excerpt
153
+ FROM pages_fts fts
154
+ JOIN pages s ON s.id = fts.rowid
155
+ WHERE pages_fts MATCH ?
156
+ ORDER BY bm25(pages_fts, 3.0, 2.0, 1.0, 0.5)
157
+ LIMIT ?`,
158
+ )
159
+ .all(ftsQuery, limit) as SearchResult[];
160
+ } catch {
161
+ return [];
162
+ }
163
+ }
164
+
165
+ /** Section TOC entry returned when a large page would be truncated. */
166
+ export type SectionTocEntry = {
167
+ heading: string;
168
+ level: number;
169
+ anchor_id: string;
170
+ char_count: number;
171
+ url: string;
172
+ };
173
+
174
+ /** Get full page content by ID or title. Optional max_length truncates text+code.
175
+ * If `section` is provided, returns only that section's content.
176
+ * If content would be truncated and the page has sections, returns a TOC instead. */
177
+ export function getPage(idOrTitle: string | number, maxLength?: number, section?: string): {
178
+ id: number;
179
+ title: string;
180
+ path: string;
181
+ url: string;
182
+ text: string;
183
+ code: string;
184
+ word_count: number;
185
+ code_lines: number;
186
+ callouts: Array<{ type: string; content: string }>;
187
+ truncated?: { text_total: number; code_total: number };
188
+ sections?: SectionTocEntry[];
189
+ section?: { heading: string; level: number; anchor_id: string };
190
+ note?: string;
191
+ } | null {
192
+ const row =
193
+ typeof idOrTitle === "number" || /^\d+$/.test(String(idOrTitle))
194
+ ? db.prepare("SELECT id, title, path, url, text, code, word_count, code_lines FROM pages WHERE id = ?").get(Number(idOrTitle))
195
+ : db.prepare("SELECT id, title, path, url, text, code, word_count, code_lines FROM pages WHERE title = ? COLLATE NOCASE").get(idOrTitle);
196
+ if (!row) return null;
197
+ const page = row as { id: number; title: string; path: string; url: string; text: string; code: string; word_count: number; code_lines: number };
198
+ const callouts = db
199
+ .prepare("SELECT type, content FROM callouts WHERE page_id = ? ORDER BY sort_order")
200
+ .all(page.id) as Array<{ type: string; content: string }>;
201
+
202
+ // Section-specific retrieval: return section content including descendants
203
+ if (section) {
204
+ const sec = db
205
+ .prepare(
206
+ `SELECT heading, level, anchor_id, text, code, word_count, sort_order
207
+ FROM sections WHERE page_id = ? AND (anchor_id = ? OR heading = ? COLLATE NOCASE)
208
+ ORDER BY sort_order LIMIT 1`,
209
+ )
210
+ .get(page.id, section, section) as { heading: string; level: number; anchor_id: string; text: string; code: string; word_count: number; sort_order: number } | null;
211
+
212
+ if (sec) {
213
+ // Include descendant sections (children under this heading)
214
+ const nextSibling = db
215
+ .prepare(
216
+ `SELECT min(sort_order) as next_order FROM sections
217
+ WHERE page_id = ? AND sort_order > ? AND level <= ?`,
218
+ )
219
+ .get(page.id, sec.sort_order, sec.level) as { next_order: number | null };
220
+ const upperBound = nextSibling?.next_order ?? 999999;
221
+
222
+ const descendants = db
223
+ .prepare(
224
+ `SELECT heading, level, text, code, word_count
225
+ FROM sections WHERE page_id = ? AND sort_order > ? AND level > ? AND sort_order < ?
226
+ ORDER BY sort_order`,
227
+ )
228
+ .all(page.id, sec.sort_order, sec.level, upperBound) as Array<{ heading: string; level: number; text: string; code: string; word_count: number }>;
229
+
230
+ let fullText = sec.text;
231
+ let fullCode = sec.code;
232
+ let totalWords = sec.word_count;
233
+ for (const child of descendants) {
234
+ const prefix = "#".repeat(Math.min(child.level + 1, 4));
235
+ fullText += `\n\n${prefix} ${child.heading}\n${child.text}`;
236
+ if (child.code) fullCode += `\n${child.code}`;
237
+ totalWords += child.word_count;
238
+ }
239
+
240
+ return {
241
+ id: page.id,
242
+ title: page.title,
243
+ path: page.path,
244
+ url: `${page.url}#${sec.anchor_id}`,
245
+ text: fullText,
246
+ code: fullCode,
247
+ word_count: totalWords,
248
+ code_lines: fullCode.split("\n").filter((l) => l.trim()).length,
249
+ callouts,
250
+ section: { heading: sec.heading, level: sec.level, anchor_id: sec.anchor_id },
251
+ };
252
+ }
253
+
254
+ // Section not found — return TOC if sections exist
255
+ const toc = getPageToc(page.id, page.url);
256
+ if (toc.length > 0) {
257
+ return {
258
+ id: page.id, title: page.title, path: page.path, url: page.url,
259
+ text: "", code: "",
260
+ word_count: page.word_count, code_lines: page.code_lines,
261
+ callouts, sections: toc,
262
+ note: `Section "${section}" not found. ${toc.length} sections available — use a heading or anchor_id from the list.`,
263
+ };
264
+ }
265
+ // No sections — return full page with note
266
+ return {
267
+ id: page.id, title: page.title, path: page.path, url: page.url,
268
+ text: page.text, code: page.code,
269
+ word_count: page.word_count, code_lines: page.code_lines,
270
+ callouts,
271
+ note: `Section "${section}" not found (this page has no sections). Returning full page.`,
272
+ };
273
+ }
274
+
275
+ // Truncation with TOC fallback: if page would be truncated and has sections,
276
+ // return a table of contents instead of a truncated blob
277
+ let truncated: { text_total: number; code_total: number } | undefined;
278
+ let { text, code } = page;
279
+ if (maxLength && (text.length + code.length) > maxLength) {
280
+ const toc = getPageToc(page.id, page.url);
281
+ if (toc.length > 0) {
282
+ const totalChars = text.length + code.length;
283
+ return {
284
+ id: page.id, title: page.title, path: page.path, url: page.url,
285
+ text: "", code: "",
286
+ word_count: page.word_count, code_lines: page.code_lines,
287
+ callouts, sections: toc,
288
+ truncated: { text_total: text.length, code_total: code.length },
289
+ note: `Page content (${totalChars} chars) exceeds max_length (${maxLength}). Showing table of contents with ${toc.length} sections. Re-call with section parameter to retrieve specific sections.`,
290
+ };
291
+ }
292
+
293
+ // No sections — fall back to truncation
294
+ const textTotal = text.length;
295
+ const codeTotal = code.length;
296
+ const codeBudget = Math.min(code.length, Math.floor(maxLength * 0.2));
297
+ const textBudget = maxLength - codeBudget;
298
+ text = `${text.slice(0, textBudget)}\n\n[... truncated — ${textTotal} chars total, showing first ${textBudget}]`;
299
+ code = codeTotal > codeBudget ? `${code.slice(0, codeBudget)}\n# [... truncated — ${codeTotal} chars total]` : code;
300
+ truncated = { text_total: textTotal, code_total: codeTotal };
301
+ }
302
+
303
+ return { id: page.id, title: page.title, path: page.path, url: page.url, text, code, word_count: page.word_count, code_lines: page.code_lines, callouts, ...(truncated ? { truncated } : {}) };
304
+ }
305
+
306
+ /** Build section TOC for a page. */
307
+ function getPageToc(pageId: number, pageUrl: string): SectionTocEntry[] {
308
+ const rows = db
309
+ .prepare(
310
+ `SELECT heading, level, anchor_id, length(text) + length(code) as char_count
311
+ FROM sections WHERE page_id = ? ORDER BY sort_order`,
312
+ )
313
+ .all(pageId) as Array<{ heading: string; level: number; anchor_id: string; char_count: number }>;
314
+ return rows.map((r) => ({
315
+ heading: r.heading,
316
+ level: r.level,
317
+ anchor_id: r.anchor_id,
318
+ char_count: r.char_count,
319
+ url: `${pageUrl}#${r.anchor_id}`,
320
+ }));
321
+ }
322
+
323
+ /** Lookup property by name, optionally filtered by command path. */
324
+ export function lookupProperty(
325
+ name: string,
326
+ commandPath?: string,
327
+ ): Array<{
328
+ name: string;
329
+ type: string | null;
330
+ default_val: string | null;
331
+ description: string;
332
+ section: string | null;
333
+ page_title: string;
334
+ page_url: string;
335
+ page_id: number;
336
+ }> {
337
+ if (commandPath) {
338
+ // Find the page linked to this command path, then search properties there
339
+ const linked = db
340
+ .prepare(
341
+ `SELECT DISTINCT c.page_id FROM commands c
342
+ WHERE c.path = ? AND c.page_id IS NOT NULL`,
343
+ )
344
+ .get(commandPath) as { page_id: number } | null;
345
+
346
+ if (linked) {
347
+ return db
348
+ .prepare(
349
+ `SELECT p.name, p.type, p.default_val, p.description, p.section,
350
+ pg.title as page_title, pg.url as page_url, pg.id as page_id
351
+ FROM properties p
352
+ JOIN pages pg ON pg.id = p.page_id
353
+ WHERE p.page_id = ? AND p.name = ? COLLATE NOCASE
354
+ ORDER BY p.sort_order`,
355
+ )
356
+ .all(linked.page_id, name) as typeof lookupProperty extends (...a: unknown[]) => infer R ? R : never;
357
+ }
358
+ }
359
+
360
+ // Fallback: search by property name across all pages
361
+ return db
362
+ .prepare(
363
+ `SELECT p.name, p.type, p.default_val, p.description, p.section,
364
+ pg.title as page_title, pg.url as page_url, pg.id as page_id
365
+ FROM properties p
366
+ JOIN pages pg ON pg.id = p.page_id
367
+ WHERE p.name = ? COLLATE NOCASE
368
+ ORDER BY pg.title, p.sort_order`,
369
+ )
370
+ .all(name) as typeof lookupProperty extends (...a: unknown[]) => infer R ? R : never;
371
+ }
372
+
373
+ /** Browse the command tree at a given path. */
374
+ export function browseCommands(
375
+ cmdPath: string,
376
+ ): Array<{
377
+ path: string;
378
+ name: string;
379
+ type: string;
380
+ description: string | null;
381
+ page_title: string | null;
382
+ page_url: string | null;
383
+ }> {
384
+ return db
385
+ .prepare(
386
+ `SELECT c.path, c.name, c.type, c.description,
387
+ p.title as page_title, p.url as page_url
388
+ FROM commands c
389
+ LEFT JOIN pages p ON c.page_id = p.id
390
+ WHERE c.parent_path = ?
391
+ ORDER BY c.type DESC, c.name`,
392
+ )
393
+ .all(cmdPath) as typeof browseCommands extends (...a: unknown[]) => infer R ? R : never;
394
+ }
395
+
396
+ /** Search properties by FTS query. */
397
+ export function searchProperties(
398
+ query: string,
399
+ limit = 10,
400
+ ): Array<{
401
+ name: string;
402
+ type: string | null;
403
+ default_val: string | null;
404
+ description: string;
405
+ section: string | null;
406
+ page_title: string;
407
+ page_url: string;
408
+ excerpt: string;
409
+ }> {
410
+ const terms = extractTerms(query);
411
+ if (terms.length === 0) return [];
412
+
413
+ let ftsQuery = buildFtsQuery(terms, "AND");
414
+ if (!ftsQuery) return [];
415
+ let results = runPropertiesFtsQuery(ftsQuery, limit);
416
+
417
+ // Fallback to OR if AND returns nothing and we have multiple terms
418
+ if (results.length === 0 && terms.length > 1) {
419
+ ftsQuery = buildFtsQuery(terms, "OR");
420
+ results = runPropertiesFtsQuery(ftsQuery, limit);
421
+ }
422
+ return results;
423
+ }
424
+
425
+ function runPropertiesFtsQuery(
426
+ ftsQuery: string,
427
+ limit: number,
428
+ ): Array<{
429
+ name: string;
430
+ type: string | null;
431
+ default_val: string | null;
432
+ description: string;
433
+ section: string | null;
434
+ page_title: string;
435
+ page_url: string;
436
+ excerpt: string;
437
+ }> {
438
+ if (!ftsQuery) return [];
439
+ try {
440
+ return db
441
+ .prepare(
442
+ `SELECT p.name, p.type, p.default_val, p.description, p.section,
443
+ pg.title as page_title, pg.url as page_url,
444
+ snippet(properties_fts, 1, '**', '**', '...', 20) as excerpt
445
+ FROM properties_fts fts
446
+ JOIN properties p ON p.id = fts.rowid
447
+ JOIN pages pg ON pg.id = p.page_id
448
+ WHERE properties_fts MATCH ?
449
+ ORDER BY rank LIMIT ?`,
450
+ )
451
+ .all(ftsQuery, limit) as Array<{
452
+ name: string;
453
+ type: string | null;
454
+ default_val: string | null;
455
+ description: string;
456
+ section: string | null;
457
+ page_title: string;
458
+ page_url: string;
459
+ excerpt: string;
460
+ }>;
461
+ } catch {
462
+ return [];
463
+ }
464
+ }
465
+
466
+ type CalloutResult = {
467
+ type: string;
468
+ content: string;
469
+ page_title: string;
470
+ page_url: string;
471
+ page_id: number;
472
+ excerpt: string;
473
+ };
474
+
475
+ /** Search callout content via FTS, optionally filtered by type. */
476
+ export function searchCallouts(
477
+ query: string,
478
+ type?: string,
479
+ limit = 10,
480
+ ): CalloutResult[] {
481
+ const terms = extractTerms(query);
482
+
483
+ // Type-only browse: no search terms but type filter provided
484
+ if (terms.length === 0 && type) {
485
+ return db
486
+ .prepare(
487
+ `SELECT c.type, c.content, pg.title as page_title, pg.url as page_url,
488
+ pg.id as page_id, substr(c.content, 1, 200) as excerpt
489
+ FROM callouts c
490
+ JOIN pages pg ON pg.id = c.page_id
491
+ WHERE c.type = ?
492
+ ORDER BY c.page_id, c.sort_order LIMIT ?`,
493
+ )
494
+ .all(type, limit) as CalloutResult[];
495
+ }
496
+
497
+ if (terms.length === 0) return [];
498
+
499
+ let ftsQuery = buildFtsQuery(terms, "AND");
500
+ if (!ftsQuery) return [];
501
+ let results = runCalloutsFtsQuery(ftsQuery, type, limit);
502
+
503
+ // Fallback to OR if AND returns nothing and we have multiple terms
504
+ if (results.length === 0 && terms.length > 1) {
505
+ ftsQuery = buildFtsQuery(terms, "OR");
506
+ results = runCalloutsFtsQuery(ftsQuery, type, limit);
507
+ }
508
+
509
+ return results;
510
+ }
511
+
512
+ function runCalloutsFtsQuery(
513
+ ftsQuery: string,
514
+ type: string | undefined,
515
+ limit: number,
516
+ ): CalloutResult[] {
517
+ if (!ftsQuery) return [];
518
+ try {
519
+ const sql = type
520
+ ? `SELECT c.type, c.content, pg.title as page_title, pg.url as page_url, pg.id as page_id,
521
+ snippet(callouts_fts, 0, '**', '**', '...', 25) as excerpt
522
+ FROM callouts_fts fts
523
+ JOIN callouts c ON c.id = fts.rowid
524
+ JOIN pages pg ON pg.id = c.page_id
525
+ WHERE callouts_fts MATCH ? AND c.type = ?
526
+ ORDER BY rank LIMIT ?`
527
+ : `SELECT c.type, c.content, pg.title as page_title, pg.url as page_url, pg.id as page_id,
528
+ snippet(callouts_fts, 0, '**', '**', '...', 25) as excerpt
529
+ FROM callouts_fts fts
530
+ JOIN callouts c ON c.id = fts.rowid
531
+ JOIN pages pg ON pg.id = c.page_id
532
+ WHERE callouts_fts MATCH ?
533
+ ORDER BY rank LIMIT ?`;
534
+ return type
535
+ ? (db.prepare(sql).all(ftsQuery, type, limit) as CalloutResult[])
536
+ : (db.prepare(sql).all(ftsQuery, limit) as CalloutResult[]);
537
+ } catch {
538
+ return [];
539
+ }
540
+ }
541
+
542
+ /** Check which RouterOS versions include a given command path. */
543
+ export function checkCommandVersions(
544
+ commandPath: string,
545
+ ): {
546
+ command_path: string;
547
+ versions: string[];
548
+ first_seen: string | null;
549
+ last_seen: string | null;
550
+ note: string | null;
551
+ } {
552
+ const rows = db
553
+ .prepare(
554
+ `SELECT ros_version FROM command_versions
555
+ WHERE command_path = ?`,
556
+ )
557
+ .all(commandPath) as Array<{ ros_version: string }>;
558
+ const versions = rows.map((r) => r.ros_version).sort(compareVersions);
559
+
560
+ const allVersionRows = db
561
+ .prepare("SELECT version FROM ros_versions")
562
+ .all() as Array<{ version: string }>;
563
+ const allVersions = allVersionRows.map((r) => r.version).sort(compareVersions);
564
+ const minTracked = allVersions[0] ?? null;
565
+
566
+ const firstSeen = versions[0] ?? null;
567
+ const lastSeen = versions[versions.length - 1] ?? null;
568
+
569
+ // If first_seen equals our earliest tracked version, the command may predate our data
570
+ let note: string | null = null;
571
+ if (firstSeen && minTracked && firstSeen === minTracked) {
572
+ note = `Command exists in our earliest tracked version (${minTracked}). It likely existed in earlier versions too, but we have no data before ${minTracked}.`;
573
+ } else if (versions.length === 0) {
574
+ note = `No version data found. Our command tree covers ${minTracked ?? "7.9"}–7.23beta2. The command may exist outside this range, or the path may be wrong.`;
575
+ }
576
+
577
+ return {
578
+ command_path: commandPath,
579
+ versions,
580
+ first_seen: firstSeen,
581
+ last_seen: lastSeen,
582
+ note,
583
+ };
584
+ }
585
+
586
+ /** Compare RouterOS version strings numerically (e.g., "7.9" < "7.10.2" < "7.22"). */
587
+ export function compareVersions(a: string, b: string): number {
588
+ const normalize = (v: string) => {
589
+ const beta = v.includes("beta");
590
+ const rc = v.includes("rc");
591
+ const clean = v.replace(/beta\d*/, "").replace(/rc\d*/, "");
592
+ const parts = clean.split(".").map(Number);
593
+ // beta < rc < release for the same numeric version
594
+ const suffix = beta ? 0 : rc ? 1 : 2;
595
+ return { parts, suffix };
596
+ };
597
+ const na = normalize(a);
598
+ const nb = normalize(b);
599
+ for (let i = 0; i < Math.max(na.parts.length, nb.parts.length); i++) {
600
+ const pa = na.parts[i] ?? 0;
601
+ const pb = nb.parts[i] ?? 0;
602
+ if (pa !== pb) return pa - pb;
603
+ }
604
+ return na.suffix - nb.suffix;
605
+ }
606
+
607
+ /** Browse commands filtered by version (uses command_versions table). */
608
+ export function browseCommandsAtVersion(
609
+ cmdPath: string,
610
+ version: string,
611
+ ): Array<{
612
+ path: string;
613
+ name: string;
614
+ type: string;
615
+ description: string | null;
616
+ page_title: string | null;
617
+ page_url: string | null;
618
+ }> {
619
+ return db
620
+ .prepare(
621
+ `SELECT c.path, c.name, c.type, c.description,
622
+ p.title as page_title, p.url as page_url
623
+ FROM commands c
624
+ LEFT JOIN pages p ON c.page_id = p.id
625
+ JOIN command_versions cv ON cv.command_path = c.path
626
+ WHERE c.parent_path = ? AND cv.ros_version = ?
627
+ ORDER BY c.type DESC, c.name`,
628
+ )
629
+ .all(cmdPath, version) as Array<{ path: string; name: string; type: string; description: string | null; page_title: string | null; page_url: string | null }>;
630
+ }
631
+
632
+ // ── Device lookup and search ──
633
+
634
+ export type DeviceResult = {
635
+ id: number;
636
+ product_name: string;
637
+ product_code: string | null;
638
+ architecture: string | null;
639
+ cpu: string | null;
640
+ cpu_cores: number | null;
641
+ cpu_frequency: string | null;
642
+ license_level: number | null;
643
+ operating_system: string | null;
644
+ ram: string | null;
645
+ ram_mb: number | null;
646
+ storage: string | null;
647
+ storage_mb: number | null;
648
+ dimensions: string | null;
649
+ poe_in: string | null;
650
+ poe_out: string | null;
651
+ max_power_w: number | null;
652
+ wireless_24_chains: number | null;
653
+ wireless_5_chains: number | null;
654
+ eth_fast: number | null;
655
+ eth_gigabit: number | null;
656
+ eth_2500: number | null;
657
+ sfp_ports: number | null;
658
+ sfp_plus_ports: number | null;
659
+ eth_multigig: number | null;
660
+ usb_ports: number | null;
661
+ sim_slots: number | null;
662
+ msrp_usd: number | null;
663
+ };
664
+
665
+ export type DeviceFilters = {
666
+ architecture?: string;
667
+ min_ram_mb?: number;
668
+ min_storage_mb?: number;
669
+ license_level?: number;
670
+ has_poe?: boolean;
671
+ has_wireless?: boolean;
672
+ has_lte?: boolean;
673
+ };
674
+
675
+ const DEVICE_SELECT = `SELECT id, product_name, product_code, architecture, cpu,
676
+ cpu_cores, cpu_frequency, license_level, operating_system,
677
+ ram, ram_mb, storage, storage_mb, dimensions, poe_in, poe_out,
678
+ max_power_w, wireless_24_chains, wireless_5_chains,
679
+ eth_fast, eth_gigabit, eth_2500, sfp_ports, sfp_plus_ports,
680
+ eth_multigig, usb_ports, sim_slots, msrp_usd
681
+ FROM devices`;
682
+
683
+ /** Build FTS5 query for devices — appends prefix '*' to every term.
684
+ * Model numbers like "RB1100" need prefix matching to find "RB1100AHx4".
685
+ * No compound term handling (not relevant for device names). */
686
+ function buildDeviceFtsQuery(terms: string[], mode: "AND" | "OR"): string {
687
+ if (terms.length === 0) return "";
688
+ const parts = terms.map((t) => `"${t}"*`);
689
+ return parts.join(mode === "AND" ? " AND " : " OR ");
690
+ }
691
+
692
+ /** Look up a device by exact name or product code, then fall back to LIKE/FTS + filters. */
693
+ export function searchDevices(
694
+ query: string,
695
+ filters: DeviceFilters = {},
696
+ limit = 10,
697
+ ): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number } {
698
+ // 1. Try exact match on product_name or product_code
699
+ if (query) {
700
+ const exact = db
701
+ .prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE`)
702
+ .all(query, query) as DeviceResult[];
703
+ if (exact.length > 0) {
704
+ return { results: exact, mode: "exact", total: exact.length };
705
+ }
706
+ }
707
+
708
+ // 2. LIKE-based prefix/substring match on product_name and product_code.
709
+ // For 144 rows this is instant and catches model number substrings
710
+ // that FTS5 token matching misses (e.g. "RB1100" → "RB1100AHx4").
711
+ if (query) {
712
+ const likeTerms = query
713
+ .trim()
714
+ .split(/\s+/)
715
+ .filter((t) => t.length >= 2)
716
+ .map((t) => `%${t}%`);
717
+ if (likeTerms.length > 0) {
718
+ const likeConditions = likeTerms.map(
719
+ () => "(d.product_name LIKE ? COLLATE NOCASE OR d.product_code LIKE ? COLLATE NOCASE)",
720
+ );
721
+ const likeParams = likeTerms.flatMap((t) => [t, t]);
722
+ const likeSql = `${DEVICE_SELECT} d WHERE ${likeConditions.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
723
+ const likeResults = db.prepare(likeSql).all(...likeParams, limit) as DeviceResult[];
724
+ if (likeResults.length > 0) {
725
+ return { results: likeResults, mode: "like", total: likeResults.length };
726
+ }
727
+ }
728
+ }
729
+
730
+ // 3. FTS + structured filters
731
+ const whereClauses: string[] = [];
732
+ const params: (string | number)[] = [];
733
+
734
+ if (filters.architecture) {
735
+ whereClauses.push("d.architecture = ?");
736
+ params.push(filters.architecture);
737
+ }
738
+ if (filters.min_ram_mb) {
739
+ whereClauses.push("d.ram_mb >= ?");
740
+ params.push(filters.min_ram_mb);
741
+ }
742
+ if (filters.min_storage_mb) {
743
+ whereClauses.push("d.storage_mb >= ?");
744
+ params.push(filters.min_storage_mb);
745
+ }
746
+ if (filters.license_level) {
747
+ whereClauses.push("d.license_level = ?");
748
+ params.push(filters.license_level);
749
+ }
750
+ if (filters.has_poe) {
751
+ whereClauses.push("(d.poe_in IS NOT NULL OR d.poe_out IS NOT NULL)");
752
+ }
753
+ if (filters.has_wireless) {
754
+ whereClauses.push("(d.wireless_24_chains IS NOT NULL OR d.wireless_5_chains IS NOT NULL)");
755
+ }
756
+ if (filters.has_lte) {
757
+ whereClauses.push("d.sim_slots > 0");
758
+ }
759
+
760
+ const terms = query ? extractTerms(query) : [];
761
+
762
+ if (terms.length > 0) {
763
+ // FTS with filters — use prefix matching for device model numbers
764
+ const ftsQuery = buildDeviceFtsQuery(terms, "AND");
765
+ if (ftsQuery) {
766
+ const filterWhere = whereClauses.length > 0 ? ` AND ${whereClauses.join(" AND ")}` : "";
767
+ const sql = `SELECT d.id, d.product_name, d.product_code, d.architecture, d.cpu,
768
+ d.cpu_cores, d.cpu_frequency, d.license_level, d.operating_system,
769
+ d.ram, d.ram_mb, d.storage, d.storage_mb, d.dimensions, d.poe_in, d.poe_out,
770
+ d.max_power_w, d.wireless_24_chains, d.wireless_5_chains,
771
+ d.eth_fast, d.eth_gigabit, d.eth_2500, d.sfp_ports, d.sfp_plus_ports,
772
+ d.eth_multigig, d.usb_ports, d.sim_slots, d.msrp_usd
773
+ FROM devices_fts fts
774
+ JOIN devices d ON d.id = fts.rowid
775
+ WHERE devices_fts MATCH ?${filterWhere}
776
+ ORDER BY rank LIMIT ?`;
777
+ try {
778
+ const results = db.prepare(sql).all(ftsQuery, ...params, limit) as DeviceResult[];
779
+ if (results.length > 0) {
780
+ return { results, mode: "fts", total: results.length };
781
+ }
782
+ } catch { /* fall through to OR */ }
783
+
784
+ // Fallback to OR
785
+ if (terms.length > 1) {
786
+ const orQuery = buildDeviceFtsQuery(terms, "OR");
787
+ try {
788
+ const results = db.prepare(sql).all(orQuery, ...params, limit) as DeviceResult[];
789
+ if (results.length > 0) {
790
+ return { results, mode: "fts+or", total: results.length };
791
+ }
792
+ } catch { /* fall through */ }
793
+ }
794
+ }
795
+ }
796
+
797
+ // 4. Filter-only (no FTS query)
798
+ if (whereClauses.length > 0) {
799
+ const sql = `${DEVICE_SELECT} d WHERE ${whereClauses.join(" AND ")} ORDER BY d.product_name LIMIT ?`;
800
+ const results = db.prepare(sql).all(...params, limit) as DeviceResult[];
801
+ return { results, mode: "filter", total: results.length };
802
+ }
803
+
804
+ return { results: [], mode: "fts", total: 0 };
805
+ }
806
+
807
+ const VERSION_CHANNELS = ["stable", "long-term", "testing", "development"] as const;
808
+
809
+ // ── Changelog search ──
810
+
811
+ export type ChangelogResult = {
812
+ version: string;
813
+ released: string | null;
814
+ category: string;
815
+ is_breaking: number;
816
+ description: string;
817
+ excerpt: string;
818
+ };
819
+
820
+ /** Get all versions that have changelog data, sorted numerically. */
821
+ function getChangelogVersions(): string[] {
822
+ const rows = db
823
+ .prepare("SELECT DISTINCT version FROM changelogs")
824
+ .all() as Array<{ version: string }>;
825
+ return rows.map((r) => r.version).sort(compareVersions);
826
+ }
827
+
828
+ /** Filter versions to those within [fromVersion, toVersion] range (inclusive). */
829
+ function filterVersionRange(
830
+ versions: string[],
831
+ fromVersion?: string,
832
+ toVersion?: string,
833
+ ): string[] {
834
+ return versions.filter((v) => {
835
+ if (fromVersion && compareVersions(v, fromVersion) < 0) return false;
836
+ if (toVersion && compareVersions(v, toVersion) > 0) return false;
837
+ return true;
838
+ });
839
+ }
840
+
841
+ /** Search changelogs with FTS, version range, category, and breaking-only filters. */
842
+ export function searchChangelogs(
843
+ query: string,
844
+ options: {
845
+ version?: string;
846
+ fromVersion?: string;
847
+ toVersion?: string;
848
+ category?: string;
849
+ breakingOnly?: boolean;
850
+ limit?: number;
851
+ } = {},
852
+ ): ChangelogResult[] {
853
+ const limit = options.limit ?? 20;
854
+ const terms = extractTerms(query);
855
+
856
+ // Build version filter
857
+ let versionList: string[] | null = null;
858
+ if (options.version) {
859
+ versionList = [options.version];
860
+ } else if (options.fromVersion || options.toVersion) {
861
+ const all = getChangelogVersions();
862
+ versionList = filterVersionRange(all, options.fromVersion, options.toVersion);
863
+ if (versionList.length === 0) return [];
864
+ }
865
+
866
+ // No FTS query — browse by filters only
867
+ if (terms.length === 0) {
868
+ return browseChangelogs(versionList, options.category, options.breakingOnly, limit);
869
+ }
870
+
871
+ // FTS search with AND, then fallback to OR
872
+ let ftsQuery = buildFtsQuery(terms, "AND");
873
+ if (!ftsQuery) return [];
874
+ let results = runChangelogFtsQuery(ftsQuery, versionList, options.category, options.breakingOnly, limit);
875
+
876
+ if (results.length === 0 && terms.length > 1) {
877
+ ftsQuery = buildFtsQuery(terms, "OR");
878
+ results = runChangelogFtsQuery(ftsQuery, versionList, options.category, options.breakingOnly, limit);
879
+ }
880
+
881
+ return results;
882
+ }
883
+
884
+ /** Browse changelogs without FTS — just filters. */
885
+ function browseChangelogs(
886
+ versionList: string[] | null,
887
+ category: string | undefined,
888
+ breakingOnly: boolean | undefined,
889
+ limit: number,
890
+ ): ChangelogResult[] {
891
+ const where: string[] = [];
892
+ const params: (string | number)[] = [];
893
+
894
+ if (versionList) {
895
+ where.push(`c.version IN (${versionList.map(() => "?").join(",")})`);
896
+ params.push(...versionList);
897
+ }
898
+ if (category) {
899
+ where.push("c.category = ?");
900
+ params.push(category);
901
+ }
902
+ if (breakingOnly) {
903
+ where.push("c.is_breaking = 1");
904
+ }
905
+
906
+ if (where.length === 0) {
907
+ // No filters at all — return recent entries
908
+ where.push("1=1");
909
+ }
910
+
911
+ const sql = `SELECT c.version, c.released, c.category, c.is_breaking,
912
+ c.description, substr(c.description, 1, 200) as excerpt
913
+ FROM changelogs c
914
+ WHERE ${where.join(" AND ")}
915
+ ORDER BY c.sort_order
916
+ LIMIT ?`;
917
+
918
+ const rows = db.prepare(sql).all(...params, limit) as ChangelogResult[];
919
+ // Sort by version numerically (SQL sorts lexicographically: 7.9 > 7.22)
920
+ return rows.sort((a, b) => compareVersions(b.version, a.version) || a.description.localeCompare(b.description));
921
+ }
922
+
923
+ function runChangelogFtsQuery(
924
+ ftsQuery: string,
925
+ versionList: string[] | null,
926
+ category: string | undefined,
927
+ breakingOnly: boolean | undefined,
928
+ limit: number,
929
+ ): ChangelogResult[] {
930
+ if (!ftsQuery) return [];
931
+ try {
932
+ const where: string[] = ["changelogs_fts MATCH ?"];
933
+ const params: (string | number)[] = [ftsQuery];
934
+
935
+ if (versionList) {
936
+ where.push(`c.version IN (${versionList.map(() => "?").join(",")})`);
937
+ params.push(...versionList);
938
+ }
939
+ if (category) {
940
+ where.push("c.category = ?");
941
+ params.push(category);
942
+ }
943
+ if (breakingOnly) {
944
+ where.push("c.is_breaking = 1");
945
+ }
946
+
947
+ const sql = `SELECT c.version, c.released, c.category, c.is_breaking,
948
+ c.description,
949
+ snippet(changelogs_fts, 1, '**', '**', '...', 25) as excerpt
950
+ FROM changelogs_fts fts
951
+ JOIN changelogs c ON c.id = fts.rowid
952
+ WHERE ${where.join(" AND ")}
953
+ ORDER BY rank
954
+ LIMIT ?`;
955
+
956
+ return db.prepare(sql).all(...params, limit) as ChangelogResult[];
957
+ } catch {
958
+ return [];
959
+ }
960
+ }
961
+
962
+ // ── Current versions ──
963
+
964
+ const VERSION_BASE_URL = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
965
+
966
+ /** Fetch current RouterOS versions from MikroTik's upgrade server. */
967
+ export async function fetchCurrentVersions(): Promise<{
968
+ channels: Record<string, string | null>;
969
+ fetched_at: string;
970
+ }> {
971
+ const channels: Record<string, string | null> = {};
972
+ await Promise.all(
973
+ VERSION_CHANNELS.map(async (channel) => {
974
+ try {
975
+ const resp = await fetch(`${VERSION_BASE_URL}.${channel}`, {
976
+ signal: AbortSignal.timeout(10_000),
977
+ });
978
+ if (resp.ok) {
979
+ const text = await resp.text();
980
+ channels[channel] = text.trim().split(/\s+/)[0] || null;
981
+ } else {
982
+ channels[channel] = null;
983
+ }
984
+ } catch {
985
+ channels[channel] = null;
986
+ }
987
+ }),
988
+ );
989
+ return { channels, fetched_at: new Date().toISOString() };
990
+ }