@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/LICENSE +21 -0
- package/README.md +333 -0
- package/bin/rosetta.js +34 -0
- package/matrix/2026-03-25/matrix.csv +145 -0
- package/matrix/CLAUDE.md +7 -0
- package/matrix/get-mikrotik-products-csv.sh +20 -0
- package/package.json +34 -0
- package/src/assess-html.ts +267 -0
- package/src/db.ts +360 -0
- package/src/extract-all-versions.ts +147 -0
- package/src/extract-changelogs.ts +266 -0
- package/src/extract-commands.ts +175 -0
- package/src/extract-devices.ts +194 -0
- package/src/extract-html.ts +379 -0
- package/src/extract-properties.ts +234 -0
- package/src/link-commands.ts +208 -0
- package/src/mcp.ts +725 -0
- package/src/query.test.ts +994 -0
- package/src/query.ts +990 -0
- package/src/release.test.ts +280 -0
- package/src/restraml.ts +65 -0
- package/src/search.ts +49 -0
- package/src/setup.ts +224 -0
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
|
+
}
|