@tikoci/rosetta 0.5.0 → 0.5.2
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/README.md +64 -147
- package/package.json +1 -1
- package/src/browse.ts +116 -40
- package/src/db.ts +16 -0
- package/src/extract-devices.ts +5 -1
- package/src/extract-html.test.ts +67 -0
- package/src/extract-html.ts +239 -112
- package/src/mcp.ts +8 -4
- package/src/query.test.ts +36 -5
- package/src/query.ts +32 -20
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Set BEFORE any import that transitively loads db.ts
|
|
2
|
+
process.env.DB_PATH = ":memory:";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "bun:test";
|
|
5
|
+
import { parseHTML } from "linkedom";
|
|
6
|
+
|
|
7
|
+
// Dynamic import so the DB_PATH assignment above wins over module caching
|
|
8
|
+
const { extractPlainText, sanitizeExtractedText } = await import("./extract-html.ts");
|
|
9
|
+
|
|
10
|
+
describe("sanitizeExtractedText", () => {
|
|
11
|
+
it("removes Confluence TOC CDATA style blocks", () => {
|
|
12
|
+
const input = `
|
|
13
|
+
Intro
|
|
14
|
+
/*<![CDATA[*/
|
|
15
|
+
div.rbtoc1774430868497 {padding: 0px;}
|
|
16
|
+
div.rbtoc1774430868497 ul {margin-left: 0px;}
|
|
17
|
+
div.rbtoc1774430868497 li {margin-left: 0px;padding-left: 0px;}
|
|
18
|
+
/*]]>*/
|
|
19
|
+
Basic Setup
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
const out = sanitizeExtractedText(input);
|
|
23
|
+
expect(out).toContain("Intro");
|
|
24
|
+
expect(out).toContain("Basic Setup");
|
|
25
|
+
expect(out).not.toContain("rbtoc1774430868497");
|
|
26
|
+
expect(out).not.toContain("/*<![CDATA[*/");
|
|
27
|
+
expect(out).not.toContain("/*]]>*/");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("removes bare rbtoc css lines without wrappers", () => {
|
|
31
|
+
const input = `
|
|
32
|
+
Top
|
|
33
|
+
|
|
34
|
+
div.rbtoc1234 {padding: 0px;}
|
|
35
|
+
div.rbtoc1234 ul {margin-left: 0px;}
|
|
36
|
+
|
|
37
|
+
after
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const out = sanitizeExtractedText(input);
|
|
41
|
+
expect(out).toContain("Top");
|
|
42
|
+
expect(out).toContain("after");
|
|
43
|
+
expect(out).not.toContain("div.rbtoc1234");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("keeps normal RouterOS content", () => {
|
|
47
|
+
const input = "/interface vrrp add interface=ether1 vrid=49 priority=254";
|
|
48
|
+
const out = sanitizeExtractedText(input);
|
|
49
|
+
expect(out).toBe(input);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("extractPlainText", () => {
|
|
54
|
+
it("keeps heading and paragraph separated", () => {
|
|
55
|
+
const { document } = parseHTML("<div><h1>Basic Setup</h1><p>This is the basic VRRP configuration example.</p></div>");
|
|
56
|
+
const out = extractPlainText(document.querySelector("div"));
|
|
57
|
+
expect(out).toContain("Basic Setup\nThis is the basic VRRP configuration example.");
|
|
58
|
+
expect(out).not.toContain("Basic SetupThis");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps subsection heading boundaries", () => {
|
|
62
|
+
const { document } = parseHTML("<div><h2>Overview</h2><p>The Point-to-Point Protocol (PPP) provides...</p></div>");
|
|
63
|
+
const out = extractPlainText(document.querySelector("div"));
|
|
64
|
+
expect(out).toContain("Overview\nThe Point-to-Point Protocol (PPP) provides...");
|
|
65
|
+
expect(out).not.toContain("OverviewThe");
|
|
66
|
+
});
|
|
67
|
+
});
|
package/src/extract-html.ts
CHANGED
|
@@ -64,6 +64,120 @@ interface SectionRow {
|
|
|
64
64
|
sort_order: number;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
const BLOCK_TAGS = new Set([
|
|
68
|
+
"address",
|
|
69
|
+
"article",
|
|
70
|
+
"aside",
|
|
71
|
+
"blockquote",
|
|
72
|
+
"dd",
|
|
73
|
+
"div",
|
|
74
|
+
"dl",
|
|
75
|
+
"dt",
|
|
76
|
+
"fieldset",
|
|
77
|
+
"figcaption",
|
|
78
|
+
"figure",
|
|
79
|
+
"footer",
|
|
80
|
+
"form",
|
|
81
|
+
"h1",
|
|
82
|
+
"h2",
|
|
83
|
+
"h3",
|
|
84
|
+
"h4",
|
|
85
|
+
"h5",
|
|
86
|
+
"h6",
|
|
87
|
+
"header",
|
|
88
|
+
"hr",
|
|
89
|
+
"li",
|
|
90
|
+
"main",
|
|
91
|
+
"nav",
|
|
92
|
+
"ol",
|
|
93
|
+
"p",
|
|
94
|
+
"pre",
|
|
95
|
+
"section",
|
|
96
|
+
"table",
|
|
97
|
+
"tbody",
|
|
98
|
+
"td",
|
|
99
|
+
"th",
|
|
100
|
+
"thead",
|
|
101
|
+
"tr",
|
|
102
|
+
"ul",
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Remove Confluence TOC style leakage that may survive DOM cleanup when HTML is malformed.
|
|
107
|
+
* Keeps cleanup narrow to rbtoc CSS so normal content is not affected.
|
|
108
|
+
*/
|
|
109
|
+
export function sanitizeExtractedText(input: string): string {
|
|
110
|
+
if (!input) return "";
|
|
111
|
+
|
|
112
|
+
return input
|
|
113
|
+
// Full CDATA-wrapped TOC style block.
|
|
114
|
+
.replace(/\/\*<!\[CDATA\[\*\/[\s\S]*?\/\*\]\]>\*\//g, "")
|
|
115
|
+
// Bare TOC CSS lines that can leak without wrappers.
|
|
116
|
+
.replace(/^\s*div\.rbtoc\d+[^\n]*(?:\n\s*div\.rbtoc\d+[^\n]*)*/gm, "")
|
|
117
|
+
// Any orphaned CDATA markers.
|
|
118
|
+
.replace(/\/\*<!\[CDATA\[\*\//g, "")
|
|
119
|
+
.replace(/\/\*\]\]>\*\//g, "")
|
|
120
|
+
// Prevent giant gaps in rendered output.
|
|
121
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
122
|
+
.trim();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Convert a DOM subtree to readable plain text while preserving block boundaries.
|
|
127
|
+
* This avoids merged tokens like "OverviewThe" caused by raw textContent collapse.
|
|
128
|
+
*/
|
|
129
|
+
export function extractPlainText(root: Element | null): string {
|
|
130
|
+
if (!root) return "";
|
|
131
|
+
|
|
132
|
+
let out = "";
|
|
133
|
+
|
|
134
|
+
const addBreak = () => {
|
|
135
|
+
if (!out) return;
|
|
136
|
+
if (!out.endsWith("\n")) out += "\n";
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const addText = (text: string) => {
|
|
140
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
141
|
+
if (!normalized) return;
|
|
142
|
+
if (out && !out.endsWith("\n") && !out.endsWith(" ")) out += " ";
|
|
143
|
+
out += normalized;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const walk = (node: Node) => {
|
|
147
|
+
if (node.nodeType === 3) {
|
|
148
|
+
addText(node.textContent || "");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (node.nodeType !== 1) return;
|
|
153
|
+
|
|
154
|
+
const el = node as Element;
|
|
155
|
+
const tag = el.tagName.toLowerCase();
|
|
156
|
+
|
|
157
|
+
if (tag === "style" || tag === "script" || tag === "noscript") return;
|
|
158
|
+
if (tag === "br") {
|
|
159
|
+
addBreak();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const isBlock = BLOCK_TAGS.has(tag);
|
|
164
|
+
if (isBlock) addBreak();
|
|
165
|
+
|
|
166
|
+
for (const child of el.childNodes) {
|
|
167
|
+
walk(child as unknown as Node);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (isBlock) addBreak();
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
walk(root as unknown as Node);
|
|
174
|
+
|
|
175
|
+
return out
|
|
176
|
+
.replace(/[ \t]*\n[ \t]*/g, "\n")
|
|
177
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
178
|
+
.trim();
|
|
179
|
+
}
|
|
180
|
+
|
|
67
181
|
/**
|
|
68
182
|
* Split main content into sections by h1–h3 headings with id attributes.
|
|
69
183
|
* Uses innerHTML + regex to locate heading boundaries, then parses each
|
|
@@ -105,7 +219,7 @@ function extractSections(mainContent: Element, pageId: number): SectionRow[] {
|
|
|
105
219
|
codeChunks.push(ce.textContent?.trim() || "");
|
|
106
220
|
}
|
|
107
221
|
|
|
108
|
-
const text = root
|
|
222
|
+
const text = sanitizeExtractedText(extractPlainText(root));
|
|
109
223
|
const code = codeChunks.join("\n\n");
|
|
110
224
|
|
|
111
225
|
return {
|
|
@@ -179,8 +293,15 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
|
|
|
179
293
|
const codeLang = codeLangs.size > 0 ? [...codeLangs].join(",") : null;
|
|
180
294
|
const codeLines = code.split("\n").filter((l) => l.trim()).length;
|
|
181
295
|
|
|
296
|
+
// Remove <style> elements before extracting text — they contain Confluence
|
|
297
|
+
// table-of-contents CSS (CDATA blocks like div.rbtoc... {padding:0}) that
|
|
298
|
+
// otherwise pollute the plain-text index and appear in page views.
|
|
299
|
+
for (const styleEl of mainContent?.querySelectorAll("style") ?? []) {
|
|
300
|
+
styleEl.remove();
|
|
301
|
+
}
|
|
302
|
+
|
|
182
303
|
// Plain text from main content (includes code block text too, which is fine for FTS)
|
|
183
|
-
const text = mainContent
|
|
304
|
+
const text = sanitizeExtractedText(extractPlainText(mainContent));
|
|
184
305
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
185
306
|
|
|
186
307
|
// Callouts: extract note/warning/info blocks
|
|
@@ -231,149 +352,155 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
|
|
|
231
352
|
|
|
232
353
|
// ---- Main ----
|
|
233
354
|
|
|
234
|
-
|
|
235
|
-
|
|
355
|
+
function main() {
|
|
356
|
+
console.log("Initializing database...");
|
|
357
|
+
initDb();
|
|
236
358
|
|
|
237
359
|
// Drop existing data for clean re-extraction (respect FK order)
|
|
238
|
-
db.run("DELETE FROM sections;");
|
|
239
|
-
db.run("DELETE FROM callouts;");
|
|
240
|
-
db.run("INSERT INTO callouts_fts(callouts_fts) VALUES('rebuild');");
|
|
241
|
-
db.run("DELETE FROM properties;");
|
|
242
|
-
db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
|
|
243
|
-
db.run("PRAGMA foreign_keys = OFF;");
|
|
244
|
-
db.run("DELETE FROM pages;");
|
|
245
|
-
db.run("PRAGMA foreign_keys = ON;");
|
|
246
|
-
db.run("INSERT INTO pages_fts(pages_fts) VALUES('rebuild');");
|
|
247
|
-
|
|
248
|
-
const htmlFiles = readdirSync(HTML_DIR)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
console.log(`Extracting ${htmlFiles.length} HTML files from ${HTML_DIR}`);
|
|
360
|
+
db.run("DELETE FROM sections;");
|
|
361
|
+
db.run("DELETE FROM callouts;");
|
|
362
|
+
db.run("INSERT INTO callouts_fts(callouts_fts) VALUES('rebuild');");
|
|
363
|
+
db.run("DELETE FROM properties;");
|
|
364
|
+
db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
|
|
365
|
+
db.run("PRAGMA foreign_keys = OFF;");
|
|
366
|
+
db.run("DELETE FROM pages;");
|
|
367
|
+
db.run("PRAGMA foreign_keys = ON;");
|
|
368
|
+
db.run("INSERT INTO pages_fts(pages_fts) VALUES('rebuild');");
|
|
369
|
+
|
|
370
|
+
const htmlFiles = readdirSync(HTML_DIR)
|
|
371
|
+
.filter((f) => f.endsWith(".html") && f !== "index.html")
|
|
372
|
+
.sort();
|
|
373
|
+
|
|
374
|
+
console.log(`Extracting ${htmlFiles.length} HTML files from ${HTML_DIR}`);
|
|
253
375
|
|
|
254
376
|
// Two-pass insert: first without parent_id (avoids FK ordering issues),
|
|
255
377
|
// then update parent relationships.
|
|
256
|
-
const insertPage = db.prepare(`
|
|
378
|
+
const insertPage = db.prepare(`
|
|
257
379
|
INSERT OR REPLACE INTO pages
|
|
258
380
|
(id, slug, title, path, depth, parent_id, url, text, code, code_lang,
|
|
259
381
|
author, last_updated, word_count, code_lines, html_file)
|
|
260
382
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
261
383
|
`);
|
|
262
|
-
const updateParent = db.prepare("UPDATE pages SET parent_id = ? WHERE id = ?");
|
|
384
|
+
const updateParent = db.prepare("UPDATE pages SET parent_id = ? WHERE id = ?");
|
|
263
385
|
|
|
264
|
-
let extracted = 0;
|
|
265
|
-
let skipped = 0;
|
|
266
|
-
let totalWords = 0;
|
|
267
|
-
let totalCodeLines = 0;
|
|
268
|
-
let totalCallouts = 0;
|
|
386
|
+
let extracted = 0;
|
|
387
|
+
let skipped = 0;
|
|
388
|
+
let totalWords = 0;
|
|
389
|
+
let totalCodeLines = 0;
|
|
390
|
+
let totalCallouts = 0;
|
|
269
391
|
|
|
270
|
-
const allPages: (PageRow & { callouts: CalloutRow[]; sections: SectionRow[] })[] = [];
|
|
392
|
+
const allPages: (PageRow & { callouts: CalloutRow[]; sections: SectionRow[] })[] = [];
|
|
271
393
|
|
|
272
394
|
// Pass 1: extract and insert all pages (parent_id = NULL)
|
|
273
|
-
const insertAll = db.transaction(() => {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
395
|
+
const insertAll = db.transaction(() => {
|
|
396
|
+
for (const file of htmlFiles) {
|
|
397
|
+
const html = readFileSync(resolve(HTML_DIR, file), "utf-8");
|
|
398
|
+
const page = extractPage(file, html);
|
|
399
|
+
if (!page) {
|
|
400
|
+
skipped++;
|
|
401
|
+
console.warn(` skipped: ${file}`);
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
insertPage.run(
|
|
405
|
+
page.id,
|
|
406
|
+
page.slug,
|
|
407
|
+
page.title,
|
|
408
|
+
page.path,
|
|
409
|
+
page.depth,
|
|
410
|
+
page.url,
|
|
411
|
+
page.text,
|
|
412
|
+
page.code,
|
|
413
|
+
page.code_lang,
|
|
414
|
+
page.author,
|
|
415
|
+
page.last_updated,
|
|
416
|
+
page.word_count,
|
|
417
|
+
page.code_lines,
|
|
418
|
+
page.html_file,
|
|
419
|
+
);
|
|
420
|
+
allPages.push(page);
|
|
421
|
+
extracted++;
|
|
422
|
+
totalWords += page.word_count;
|
|
423
|
+
totalCodeLines += page.code_lines;
|
|
281
424
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
page.slug,
|
|
285
|
-
page.title,
|
|
286
|
-
page.path,
|
|
287
|
-
page.depth,
|
|
288
|
-
page.url,
|
|
289
|
-
page.text,
|
|
290
|
-
page.code,
|
|
291
|
-
page.code_lang,
|
|
292
|
-
page.author,
|
|
293
|
-
page.last_updated,
|
|
294
|
-
page.word_count,
|
|
295
|
-
page.code_lines,
|
|
296
|
-
page.html_file,
|
|
297
|
-
);
|
|
298
|
-
allPages.push(page);
|
|
299
|
-
extracted++;
|
|
300
|
-
totalWords += page.word_count;
|
|
301
|
-
totalCodeLines += page.code_lines;
|
|
302
|
-
}
|
|
303
|
-
});
|
|
304
|
-
insertAll();
|
|
425
|
+
});
|
|
426
|
+
insertAll();
|
|
305
427
|
|
|
306
428
|
// Pass 2: set parent_id where the parent actually exists in the DB
|
|
307
|
-
const pageIds = new Set(allPages.map((p) => p.id));
|
|
308
|
-
const setParents = db.transaction(() => {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
429
|
+
const pageIds = new Set(allPages.map((p) => p.id));
|
|
430
|
+
const setParents = db.transaction(() => {
|
|
431
|
+
for (const page of allPages) {
|
|
432
|
+
if (page.parent_id && pageIds.has(page.parent_id)) {
|
|
433
|
+
updateParent.run(page.parent_id, page.id);
|
|
434
|
+
}
|
|
312
435
|
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
setParents();
|
|
436
|
+
});
|
|
437
|
+
setParents();
|
|
316
438
|
|
|
317
439
|
// Pass 3: insert callouts
|
|
318
|
-
const insertCallout = db.prepare(`
|
|
440
|
+
const insertCallout = db.prepare(`
|
|
319
441
|
INSERT INTO callouts (page_id, type, content, sort_order)
|
|
320
442
|
VALUES (?, ?, ?, ?)
|
|
321
443
|
`);
|
|
322
|
-
const insertCallouts = db.transaction(() => {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
444
|
+
const insertCallouts = db.transaction(() => {
|
|
445
|
+
for (const page of allPages) {
|
|
446
|
+
for (const c of page.callouts) {
|
|
447
|
+
insertCallout.run(c.page_id, c.type, c.content, c.sort_order);
|
|
448
|
+
totalCallouts++;
|
|
449
|
+
}
|
|
327
450
|
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
insertCallouts();
|
|
451
|
+
});
|
|
452
|
+
insertCallouts();
|
|
331
453
|
|
|
332
454
|
// Pass 4: insert sections
|
|
333
|
-
let totalSections = 0;
|
|
334
|
-
let pagesWithSections = 0;
|
|
335
|
-
const insertSection = db.prepare(`
|
|
455
|
+
let totalSections = 0;
|
|
456
|
+
let pagesWithSections = 0;
|
|
457
|
+
const insertSection = db.prepare(`
|
|
336
458
|
INSERT INTO sections (page_id, heading, level, anchor_id, text, code, word_count, sort_order)
|
|
337
459
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
338
460
|
`);
|
|
339
|
-
const insertSections = db.transaction(() => {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
461
|
+
const insertSections = db.transaction(() => {
|
|
462
|
+
for (const page of allPages) {
|
|
463
|
+
if (page.sections.length > 0) {
|
|
464
|
+
pagesWithSections++;
|
|
465
|
+
for (const s of page.sections) {
|
|
466
|
+
insertSection.run(s.page_id, s.heading, s.level, s.anchor_id, s.text, s.code, s.word_count, s.sort_order);
|
|
467
|
+
totalSections++;
|
|
468
|
+
}
|
|
346
469
|
}
|
|
347
470
|
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
insertSections();
|
|
471
|
+
});
|
|
472
|
+
insertSections();
|
|
351
473
|
|
|
352
|
-
const ftsCount = (db.prepare("SELECT COUNT(*) as c FROM pages_fts").get() as { c: number }).c;
|
|
474
|
+
const ftsCount = (db.prepare("SELECT COUNT(*) as c FROM pages_fts").get() as { c: number }).c;
|
|
353
475
|
|
|
354
|
-
console.log(`\nExtraction complete:`);
|
|
355
|
-
console.log(` Pages extracted: ${extracted}`);
|
|
356
|
-
console.log(` Pages skipped: ${skipped}`);
|
|
357
|
-
console.log(` Total words: ${totalWords.toLocaleString()}`);
|
|
358
|
-
console.log(` Total code lines: ${totalCodeLines.toLocaleString()}`);
|
|
359
|
-
console.log(` Total callouts: ${totalCallouts}`);
|
|
360
|
-
console.log(` Total sections: ${totalSections} (across ${pagesWithSections} pages)`);
|
|
361
|
-
console.log(` FTS index rows: ${ftsCount}`);
|
|
476
|
+
console.log(`\nExtraction complete:`);
|
|
477
|
+
console.log(` Pages extracted: ${extracted}`);
|
|
478
|
+
console.log(` Pages skipped: ${skipped}`);
|
|
479
|
+
console.log(` Total words: ${totalWords.toLocaleString()}`);
|
|
480
|
+
console.log(` Total code lines: ${totalCodeLines.toLocaleString()}`);
|
|
481
|
+
console.log(` Total callouts: ${totalCallouts}`);
|
|
482
|
+
console.log(` Total sections: ${totalSections} (across ${pagesWithSections} pages)`);
|
|
483
|
+
console.log(` FTS index rows: ${ftsCount}`);
|
|
362
484
|
|
|
363
485
|
// Quick search test
|
|
364
|
-
const testResults = db
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
console.log(`\nTest search for "firewall filter":`);
|
|
376
|
-
for (const r of testResults as Array<{ id: number; title: string; path: string; excerpt: string }>) {
|
|
377
|
-
|
|
378
|
-
|
|
486
|
+
const testResults = db
|
|
487
|
+
.prepare(
|
|
488
|
+
`SELECT s.id, s.title, s.path,
|
|
489
|
+
snippet(pages_fts, 2, '>>>', '<<<', '...', 20) as excerpt
|
|
490
|
+
FROM pages_fts fts
|
|
491
|
+
JOIN pages s ON s.id = fts.rowid
|
|
492
|
+
WHERE pages_fts MATCH 'firewall filter'
|
|
493
|
+
ORDER BY rank LIMIT 5`,
|
|
494
|
+
)
|
|
495
|
+
.all();
|
|
496
|
+
|
|
497
|
+
console.log(`\nTest search for "firewall filter":`);
|
|
498
|
+
for (const r of testResults as Array<{ id: number; title: string; path: string; excerpt: string }>) {
|
|
499
|
+
console.log(` [${r.id}] ${r.path}`);
|
|
500
|
+
console.log(` ${r.excerpt}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (import.meta.main) {
|
|
505
|
+
main();
|
|
379
506
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -978,6 +978,10 @@ Workflow:
|
|
|
978
978
|
→ routeros_device_lookup: get full specs (CPU, RAM, pricing) + block diagram for a specific device
|
|
979
979
|
→ routeros_search: find documentation about features relevant to the test type`,
|
|
980
980
|
inputSchema: {
|
|
981
|
+
device: z
|
|
982
|
+
.string()
|
|
983
|
+
.optional()
|
|
984
|
+
.describe("Filter by device product name (substring match, e.g., 'RB5009', 'hAP', 'CCR2216')"),
|
|
981
985
|
test_type: z
|
|
982
986
|
.string()
|
|
983
987
|
.optional()
|
|
@@ -1010,8 +1014,8 @@ Workflow:
|
|
|
1010
1014
|
.describe("Max results (default 50, max 200)"),
|
|
1011
1015
|
},
|
|
1012
1016
|
},
|
|
1013
|
-
async ({ test_type, mode, configuration, packet_size, sort_by, limit }) => {
|
|
1014
|
-
const hasFilters = test_type || mode || configuration || packet_size;
|
|
1017
|
+
async ({ device, test_type, mode, configuration, packet_size, sort_by, limit }) => {
|
|
1018
|
+
const hasFilters = device || test_type || mode || configuration || packet_size;
|
|
1015
1019
|
|
|
1016
1020
|
if (!hasFilters) {
|
|
1017
1021
|
// Discovery mode: return available filter values
|
|
@@ -1029,7 +1033,7 @@ Workflow:
|
|
|
1029
1033
|
}
|
|
1030
1034
|
|
|
1031
1035
|
const result = searchDeviceTests(
|
|
1032
|
-
{ test_type, mode, configuration, packet_size, sort_by },
|
|
1036
|
+
{ device, test_type, mode, configuration, packet_size, sort_by },
|
|
1033
1037
|
limit,
|
|
1034
1038
|
);
|
|
1035
1039
|
|
|
@@ -1065,7 +1069,7 @@ server.registerTool(
|
|
|
1065
1069
|
{
|
|
1066
1070
|
description: `Fetch current RouterOS version numbers from MikroTik's upgrade server.
|
|
1067
1071
|
|
|
1068
|
-
Returns the latest version for each release channel
|
|
1072
|
+
Returns the latest version for each release channel (stable, long-term, testing, development) plus the current WinBox 4 version.
|
|
1069
1073
|
Useful for determining if a user's version is current, outdated, or unpatched.
|
|
1070
1074
|
|
|
1071
1075
|
Key context for version reasoning:
|
package/src/query.test.ts
CHANGED
|
@@ -267,6 +267,10 @@ beforeAll(() => {
|
|
|
267
267
|
db.run(`INSERT INTO device_test_results
|
|
268
268
|
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
269
269
|
VALUES (1, 'ipsec', 'Single tunnel', 'AES-128-CBC + SHA1', 1400, 120.9, 1354.1)`);
|
|
270
|
+
// RB5009UG+S+IN = id 9 (9th device inserted)
|
|
271
|
+
db.run(`INSERT INTO device_test_results
|
|
272
|
+
(device_id, test_type, mode, configuration, packet_size, throughput_kpps, throughput_mbps)
|
|
273
|
+
VALUES (9, 'ethernet', 'Routing', 'none (fast path)', 1518, 1613.0, 19577.3)`);
|
|
270
274
|
|
|
271
275
|
// Page 3: a "large" page with sections for TOC testing
|
|
272
276
|
// Text is ~200 chars to keep fixture small, but we'll use max_length=50 to trigger truncation
|
|
@@ -1154,14 +1158,14 @@ describe("searchDevices", () => {
|
|
|
1154
1158
|
describe("searchDeviceTests", () => {
|
|
1155
1159
|
test("returns all test results with no filters", () => {
|
|
1156
1160
|
const res = searchDeviceTests({});
|
|
1157
|
-
expect(res.results.length).toBe(
|
|
1158
|
-
expect(res.total).toBe(
|
|
1161
|
+
expect(res.results.length).toBe(4);
|
|
1162
|
+
expect(res.total).toBe(4);
|
|
1159
1163
|
});
|
|
1160
1164
|
|
|
1161
1165
|
test("filters by test_type", () => {
|
|
1162
1166
|
const res = searchDeviceTests({ test_type: "ethernet" });
|
|
1163
1167
|
expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
|
|
1164
|
-
expect(res.results.length).toBe(
|
|
1168
|
+
expect(res.results.length).toBe(3);
|
|
1165
1169
|
});
|
|
1166
1170
|
|
|
1167
1171
|
test("filters by test_type and mode", () => {
|
|
@@ -1181,6 +1185,32 @@ describe("searchDeviceTests", () => {
|
|
|
1181
1185
|
expect(res.results.every((r) => r.packet_size === 512)).toBe(true);
|
|
1182
1186
|
});
|
|
1183
1187
|
|
|
1188
|
+
test("filters by device name (substring match)", () => {
|
|
1189
|
+
const res = searchDeviceTests({ device: "hAP" });
|
|
1190
|
+
expect(res.results).toHaveLength(3);
|
|
1191
|
+
expect(res.results.every((r) => r.product_name.includes("hAP"))).toBe(true);
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test("device filter combined with test_type", () => {
|
|
1195
|
+
const res = searchDeviceTests({ device: "hAP", test_type: "ethernet" });
|
|
1196
|
+
expect(res.results).toHaveLength(2);
|
|
1197
|
+
expect(res.results.every((r) => r.test_type === "ethernet")).toBe(true);
|
|
1198
|
+
expect(res.results.every((r) => r.product_name.includes("hAP"))).toBe(true);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test("device filter with no matches returns empty", () => {
|
|
1202
|
+
const res = searchDeviceTests({ device: "Audience" });
|
|
1203
|
+
expect(res.results).toHaveLength(0);
|
|
1204
|
+
expect(res.total).toBe(0);
|
|
1205
|
+
});
|
|
1206
|
+
|
|
1207
|
+
test("device filter for RB5009 returns its test results", () => {
|
|
1208
|
+
const res = searchDeviceTests({ device: "RB5009" });
|
|
1209
|
+
expect(res.results).toHaveLength(1);
|
|
1210
|
+
expect(res.results[0].product_name).toBe("RB5009UG+S+IN");
|
|
1211
|
+
expect(res.results[0].packet_size).toBe(1518);
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1184
1214
|
test("sorts by mbps descending by default", () => {
|
|
1185
1215
|
const res = searchDeviceTests({ test_type: "ethernet" });
|
|
1186
1216
|
if (res.results.length >= 2) {
|
|
@@ -1210,7 +1240,7 @@ describe("searchDeviceTests", () => {
|
|
|
1210
1240
|
test("respects limit", () => {
|
|
1211
1241
|
const res = searchDeviceTests({}, 1);
|
|
1212
1242
|
expect(res.results).toHaveLength(1);
|
|
1213
|
-
expect(res.total).toBe(
|
|
1243
|
+
expect(res.total).toBe(4);
|
|
1214
1244
|
});
|
|
1215
1245
|
});
|
|
1216
1246
|
|
|
@@ -1220,9 +1250,10 @@ describe("dataset CSV exports", () => {
|
|
|
1220
1250
|
const lines = csv.trim().split("\n");
|
|
1221
1251
|
|
|
1222
1252
|
expect(lines[0]).toBe("product_name,product_code,architecture,cpu,cpu_cores,cpu_frequency,test_type,mode,configuration,packet_size,throughput_kpps,throughput_mbps,product_url");
|
|
1223
|
-
expect(lines).toHaveLength(
|
|
1253
|
+
expect(lines).toHaveLength(5);
|
|
1224
1254
|
expect(csv).toContain("hAP ax3");
|
|
1225
1255
|
expect(csv).toContain("IPQ-6010");
|
|
1256
|
+
expect(csv).toContain("RB5009UG+S+IN");
|
|
1226
1257
|
expect(csv).toContain("https://mikrotik.com/product/hap_ax3");
|
|
1227
1258
|
});
|
|
1228
1259
|
|