@tikoci/rosetta 0.5.1 → 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/package.json +1 -1
- package/src/browse.ts +54 -189
- 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 +232 -112
- package/src/mcp.ts +2 -14
- package/src/query.ts +22 -18
package/package.json
CHANGED
package/src/browse.ts
CHANGED
|
@@ -281,7 +281,7 @@ function renderSearchResults(resp: SearchResponse): string {
|
|
|
281
281
|
|
|
282
282
|
// Navigation hints
|
|
283
283
|
const hints = [
|
|
284
|
-
`${cyan("
|
|
284
|
+
`${cyan("[N]")} view page`,
|
|
285
285
|
`${cyan("[s <query>]")} search`,
|
|
286
286
|
`${cyan("[p <query>]")} properties`,
|
|
287
287
|
`${cyan("[cmd <path>]")} commands`,
|
|
@@ -374,7 +374,7 @@ function renderPage(page: NonNullable<ReturnType<typeof getPage>>): string {
|
|
|
374
374
|
// Navigation hints
|
|
375
375
|
out.push("");
|
|
376
376
|
const hints: string[] = [];
|
|
377
|
-
if (page.sections && page.sections.length > 0) hints.push(`${cyan("
|
|
377
|
+
if (page.sections && page.sections.length > 0) hints.push(`${cyan("[N]")} section`);
|
|
378
378
|
hints.push(`${cyan("[p]")} properties`);
|
|
379
379
|
hints.push(`${cyan("[cmd]")} command tree`);
|
|
380
380
|
hints.push(`${cyan("[cal]")} callouts`);
|
|
@@ -480,7 +480,7 @@ function renderDeviceResults(results: DeviceResult[], mode: string, total: numbe
|
|
|
480
480
|
out.push("");
|
|
481
481
|
}
|
|
482
482
|
|
|
483
|
-
out.push(` ${cyan("
|
|
483
|
+
out.push(` ${cyan("[N]")} view device ${cyan("[tests]")} benchmarks ${cyan("[b]")} back`);
|
|
484
484
|
return out.join("\n");
|
|
485
485
|
}
|
|
486
486
|
|
|
@@ -529,24 +529,17 @@ function renderDeviceCard(d: DeviceResult): string {
|
|
|
529
529
|
out.push(` ${dim("Block diagram:")} ${cyan(link(d.block_diagram_url, "view"))}`);
|
|
530
530
|
}
|
|
531
531
|
|
|
532
|
-
// Test results (attached for exact matches)
|
|
532
|
+
// Test results (attached for exact matches)
|
|
533
533
|
if (d.test_results && d.test_results.length > 0) {
|
|
534
|
-
const pkt512 = d.test_results.filter((t) => t.packet_size === 512);
|
|
535
|
-
const displayTests = pkt512.length > 0 ? pkt512 : d.test_results.slice(0, 20);
|
|
536
|
-
const pktNote = pkt512.length > 0 ? " (512B baseline)" : "";
|
|
537
534
|
out.push("");
|
|
538
|
-
out.push(` ${bold("Benchmarks:")} ${dim(`(${
|
|
539
|
-
for (const t of
|
|
535
|
+
out.push(` ${bold("Benchmarks:")} ${dim(`(${d.test_results.length} tests)`)}`);
|
|
536
|
+
for (const t of d.test_results.slice(0, 12)) {
|
|
540
537
|
const mbps = t.throughput_mbps ? `${fmt(t.throughput_mbps)} Mbps` : "";
|
|
541
538
|
const kpps = t.throughput_kpps ? `${fmt(t.throughput_kpps)} Kpps` : "";
|
|
542
539
|
out.push(` ${dim(pad(t.test_type, 9))} ${pad(t.mode, 16)} ${dim(pad(t.configuration, 28))} ${pad(`${t.packet_size}B`, 6)} ${bold(mbps)} ${dim(kpps)}`);
|
|
543
540
|
}
|
|
544
|
-
if (d.test_results.length >
|
|
545
|
-
|
|
546
|
-
d.test_results.filter((t) => t.packet_size !== 512).map((t) => t.packet_size),
|
|
547
|
-
)].sort((a, b) => a - b);
|
|
548
|
-
const sizesStr = otherSizes.map((s) => `${s}B`).join(", ");
|
|
549
|
-
out.push(` ${dim(`Also at ${sizesStr} — use`)} ${cyan("tests")} ${dim("<type> <size> for other packet sizes")}`);
|
|
541
|
+
if (d.test_results.length > 12) {
|
|
542
|
+
out.push(` ${dim(`... and ${d.test_results.length - 12} more (use`)} ${cyan("tests")} ${dim("for full listing)")}`);
|
|
550
543
|
}
|
|
551
544
|
}
|
|
552
545
|
|
|
@@ -637,7 +630,7 @@ function renderChangelogs(results: ChangelogResult[]): string {
|
|
|
637
630
|
}
|
|
638
631
|
|
|
639
632
|
out.push("");
|
|
640
|
-
out.push(` ${cyan("[cl
|
|
633
|
+
out.push(` ${cyan("[cl breaking]")} breaking only ${cyan("[cl <ver>]")} specific version ${cyan("[b]")} back`);
|
|
641
634
|
return out.join("\n");
|
|
642
635
|
}
|
|
643
636
|
|
|
@@ -728,6 +721,13 @@ function renderStats(): string {
|
|
|
728
721
|
const out: string[] = [];
|
|
729
722
|
out.push(` ${bold("Database Statistics")}`);
|
|
730
723
|
out.push(` ${dim("Path:")} ${stats.db_path}`);
|
|
724
|
+
if (stats.db_size_bytes != null) {
|
|
725
|
+
const mb = stats.db_size_bytes / (1024 * 1024);
|
|
726
|
+
out.push(` ${dim("Size:")} ${mb.toFixed(1)} MB`);
|
|
727
|
+
}
|
|
728
|
+
if (stats.schema_version != null) {
|
|
729
|
+
out.push(` ${dim("Schema:")} v${stats.schema_version}`);
|
|
730
|
+
}
|
|
731
731
|
out.push(` ${dim("Export:")} ${stats.doc_export}`);
|
|
732
732
|
out.push("");
|
|
733
733
|
|
|
@@ -771,7 +771,7 @@ function renderHelp(): string {
|
|
|
771
771
|
cmd("props <query>", "sp", "Search properties by FTS", "routeros_search_properties");
|
|
772
772
|
cmd("cmd [path]", "tree", "Browse command tree", "routeros_command_tree");
|
|
773
773
|
cmd("device <query>", "dev", "Look up device specs", "routeros_device_lookup");
|
|
774
|
-
cmd("tests [device]
|
|
774
|
+
cmd("tests [device] [type]", "", "Cross-device benchmarks", "routeros_search_tests");
|
|
775
775
|
cmd("callouts [query]", "cal", "Search callouts (type filter: cal warning)", "routeros_search_callouts");
|
|
776
776
|
cmd("changelog [query]", "cl", "Search changelogs (cl 7.22, cl breaking)", "routeros_search_changelogs");
|
|
777
777
|
cmd("videos <query>", "vid", "Search video transcripts", "routeros_search_videos");
|
|
@@ -791,69 +791,8 @@ function renderHelp(): string {
|
|
|
791
791
|
return out.join("\n");
|
|
792
792
|
}
|
|
793
793
|
|
|
794
|
-
/** Fetch the full transcript text for a specific video segment. */
|
|
795
|
-
function getVideoSegmentTranscript(youtubeVideoId: string, startS: number): string | null {
|
|
796
|
-
try {
|
|
797
|
-
const row = db
|
|
798
|
-
.prepare(
|
|
799
|
-
`SELECT vs.transcript FROM video_segments vs
|
|
800
|
-
JOIN videos v ON v.id = vs.video_id
|
|
801
|
-
WHERE v.video_id = ? AND vs.start_s = ? LIMIT 1`,
|
|
802
|
-
)
|
|
803
|
-
.get(youtubeVideoId, startS) as { transcript: string } | null;
|
|
804
|
-
return row?.transcript ?? null;
|
|
805
|
-
} catch {
|
|
806
|
-
return null;
|
|
807
|
-
}
|
|
808
|
-
}
|
|
809
|
-
|
|
810
794
|
// ── Command dispatcher ──
|
|
811
795
|
|
|
812
|
-
/** Re-render the current context after going back — uses cached results where possible. */
|
|
813
|
-
async function renderCurrentCtx(): Promise<void> {
|
|
814
|
-
switch (ctx.type) {
|
|
815
|
-
case "home":
|
|
816
|
-
console.log(renderWelcome());
|
|
817
|
-
return;
|
|
818
|
-
case "search":
|
|
819
|
-
await paged(renderSearchResults(ctx.response));
|
|
820
|
-
return;
|
|
821
|
-
case "commands": {
|
|
822
|
-
const children = browseCommands(ctx.path);
|
|
823
|
-
if (children.length > 0) await paged(renderCommandTree(ctx.path, children));
|
|
824
|
-
else console.log(dim(` No children at "${ctx.path}".`));
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
case "devices":
|
|
828
|
-
await paged(renderDeviceResults(ctx.results, "cached", ctx.results.length));
|
|
829
|
-
return;
|
|
830
|
-
case "device":
|
|
831
|
-
await paged(renderDeviceCard(ctx.device));
|
|
832
|
-
return;
|
|
833
|
-
case "callouts":
|
|
834
|
-
if (ctx.results.length > 0) await paged(renderCallouts(ctx.results));
|
|
835
|
-
return;
|
|
836
|
-
case "changelogs":
|
|
837
|
-
if (ctx.results.length > 0) await paged(renderChangelogs(ctx.results));
|
|
838
|
-
return;
|
|
839
|
-
case "videos":
|
|
840
|
-
if (ctx.results.length > 0) await paged(renderVideos(ctx.results));
|
|
841
|
-
return;
|
|
842
|
-
case "page": {
|
|
843
|
-
const page = getPage(ctx.pageId);
|
|
844
|
-
if (page) await paged(renderPage(page));
|
|
845
|
-
return;
|
|
846
|
-
}
|
|
847
|
-
case "sections": {
|
|
848
|
-
const page = getPage(ctx.pageId);
|
|
849
|
-
if (page) await paged(renderPage(page));
|
|
850
|
-
return;
|
|
851
|
-
}
|
|
852
|
-
default:
|
|
853
|
-
console.log(dim(` In ${ctx.type} context. Type a command or help.`));
|
|
854
|
-
}
|
|
855
|
-
}
|
|
856
|
-
|
|
857
796
|
async function dispatch(input: string): Promise<void> {
|
|
858
797
|
const trimmed = input.trim();
|
|
859
798
|
if (!trimmed) return;
|
|
@@ -888,8 +827,7 @@ async function dispatch(input: string): Promise<void> {
|
|
|
888
827
|
if (!popCtx()) {
|
|
889
828
|
console.log(dim(" Already at top."));
|
|
890
829
|
} else {
|
|
891
|
-
console.log(dim(` ← ${ctx.type}`));
|
|
892
|
-
await renderCurrentCtx();
|
|
830
|
+
console.log(dim(` ← back to ${ctx.type}`));
|
|
893
831
|
}
|
|
894
832
|
return;
|
|
895
833
|
|
|
@@ -904,20 +842,7 @@ async function dispatch(input: string): Promise<void> {
|
|
|
904
842
|
return;
|
|
905
843
|
|
|
906
844
|
case "page": {
|
|
907
|
-
if (!rest) {
|
|
908
|
-
// From commands context: navigate to the linked page
|
|
909
|
-
if (ctx.type === "commands") {
|
|
910
|
-
const row = db.prepare("SELECT page_id FROM commands WHERE path = ? LIMIT 1").get(ctx.path) as { page_id: number | null } | null;
|
|
911
|
-
if (row?.page_id) {
|
|
912
|
-
await doPage(String(row.page_id));
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
console.log(dim(` No linked page for ${ctx.path}.`));
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
console.log(dim(" Usage: page <id|title>"));
|
|
919
|
-
return;
|
|
920
|
-
}
|
|
845
|
+
if (!rest) { console.log(dim(" Usage: page <id|title>")); return; }
|
|
921
846
|
await doPage(rest);
|
|
922
847
|
return;
|
|
923
848
|
}
|
|
@@ -925,12 +850,13 @@ async function dispatch(input: string): Promise<void> {
|
|
|
925
850
|
case "p":
|
|
926
851
|
case "prop": {
|
|
927
852
|
if (!rest) {
|
|
928
|
-
// Context-scoped: show properties for current page
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
853
|
+
// Context-scoped: show properties for current page
|
|
854
|
+
if (ctx.type === "page") {
|
|
855
|
+
const page = getPage(ctx.pageId, 0); // just get metadata
|
|
856
|
+
if (page) {
|
|
857
|
+
await doPropsForPage(ctx.pageId, ctx.title);
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
934
860
|
}
|
|
935
861
|
console.log(dim(" Usage: prop <name> — or navigate to a page first"));
|
|
936
862
|
return;
|
|
@@ -948,15 +874,8 @@ async function dispatch(input: string): Promise<void> {
|
|
|
948
874
|
|
|
949
875
|
case "cmd":
|
|
950
876
|
case "tree": {
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
// Re-display current commands context
|
|
954
|
-
cmdPath = ctx.path;
|
|
955
|
-
} else if (cmdPath && !cmdPath.startsWith("/") && ctx.type === "commands") {
|
|
956
|
-
// Relative path: resolve against current commands path
|
|
957
|
-
cmdPath = `${ctx.path}/${cmdPath}`;
|
|
958
|
-
}
|
|
959
|
-
await doCommandTree(cmdPath);
|
|
877
|
+
const path = rest || (ctx.type === "commands" ? ctx.path : "");
|
|
878
|
+
await doCommandTree(path);
|
|
960
879
|
return;
|
|
961
880
|
}
|
|
962
881
|
|
|
@@ -974,24 +893,13 @@ async function dispatch(input: string): Promise<void> {
|
|
|
974
893
|
|
|
975
894
|
case "cal":
|
|
976
895
|
case "callouts": {
|
|
977
|
-
if (!rest) {
|
|
978
|
-
//
|
|
979
|
-
const
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
`SELECT c.type, c.content, p.title as page_title, p.url as page_url,
|
|
985
|
-
c.page_id, c.content as excerpt
|
|
986
|
-
FROM callouts c JOIN pages p ON p.id = c.page_id
|
|
987
|
-
WHERE c.page_id = ? ORDER BY c.sort_order`,
|
|
988
|
-
).all(pageId) as CalloutResult[];
|
|
989
|
-
if (pageCallouts.length > 0) {
|
|
990
|
-
await paged(renderCallouts(pageCallouts));
|
|
991
|
-
pushCtx({ type: "callouts", query: "", results: pageCallouts });
|
|
992
|
-
} else {
|
|
993
|
-
console.log(dim(" No callouts for this page."));
|
|
994
|
-
}
|
|
896
|
+
if (!rest && ctx.type === "page") {
|
|
897
|
+
// Show callouts for current page
|
|
898
|
+
const results = searchCallouts("", undefined, 50);
|
|
899
|
+
const pageCallouts = results.filter((c) => c.page_id === (ctx as { pageId: number }).pageId);
|
|
900
|
+
if (pageCallouts.length > 0) {
|
|
901
|
+
await paged(renderCallouts(pageCallouts));
|
|
902
|
+
pushCtx({ type: "callouts", query: "", results: pageCallouts });
|
|
995
903
|
return;
|
|
996
904
|
}
|
|
997
905
|
}
|
|
@@ -1006,7 +914,6 @@ async function dispatch(input: string): Promise<void> {
|
|
|
1006
914
|
}
|
|
1007
915
|
|
|
1008
916
|
case "vid":
|
|
1009
|
-
case "video":
|
|
1010
917
|
case "videos": {
|
|
1011
918
|
if (!rest) { console.log(dim(" Usage: videos <query>")); return; }
|
|
1012
919
|
await doSearchVideos(rest);
|
|
@@ -1077,18 +984,9 @@ async function handleNumberSelect(idx: number): Promise<void> {
|
|
|
1077
984
|
if (ctx.type === "videos" && ctx.results[idx]) {
|
|
1078
985
|
const v = ctx.results[idx];
|
|
1079
986
|
const timeUrl = v.start_s > 0 ? `${v.url}&t=${v.start_s}` : v.url;
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
out.push(` ${cyan(link(timeUrl))}`);
|
|
1084
|
-
// Fetch and show full transcript for this segment
|
|
1085
|
-
const transcript = getVideoSegmentTranscript(v.video_id, v.start_s);
|
|
1086
|
-
if (transcript) {
|
|
1087
|
-
out.push("");
|
|
1088
|
-
out.push(` ${dim("── transcript ──")}`);
|
|
1089
|
-
out.push(transcript.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
1090
|
-
}
|
|
1091
|
-
await paged(out.join("\n"));
|
|
987
|
+
console.log(`\n ${bold(v.title)}`);
|
|
988
|
+
if (v.chapter_title) console.log(` ${magenta(`§ ${v.chapter_title}`)} ${dim(`@ ${formatTime(v.start_s)}`)}`);
|
|
989
|
+
console.log(` ${cyan(link(timeUrl))}\n`);
|
|
1092
990
|
return;
|
|
1093
991
|
}
|
|
1094
992
|
if (ctx.type === "properties" && ctx.results[idx]) {
|
|
@@ -1215,64 +1113,33 @@ async function doDeviceLookup(query: string): Promise<void> {
|
|
|
1215
1113
|
}
|
|
1216
1114
|
|
|
1217
1115
|
async function doTests(argsStr: string): Promise<void> {
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
// Parse args if provided
|
|
1222
|
-
if (argsStr) {
|
|
1223
|
-
const knownTypes = ["ethernet", "ipsec"];
|
|
1224
|
-
let offset = 0;
|
|
1225
|
-
if (parts[0] && !knownTypes.includes(parts[0].toLowerCase())) {
|
|
1226
|
-
filters.device = parts[0];
|
|
1227
|
-
offset = 1;
|
|
1228
|
-
}
|
|
1229
|
-
if (parts[offset]) filters.test_type = parts[offset];
|
|
1230
|
-
// packet_size is always numeric; mode can be multi-word — identify by type
|
|
1231
|
-
const remaining = parts.slice(offset + 1);
|
|
1232
|
-
const pktIdx = remaining.findIndex((p) => /^\d+$/.test(p));
|
|
1233
|
-
if (pktIdx !== -1) {
|
|
1234
|
-
filters.packet_size = Number.parseInt(remaining[pktIdx], 10);
|
|
1235
|
-
const modeParts = [...remaining.slice(0, pktIdx), ...remaining.slice(pktIdx + 1)];
|
|
1236
|
-
if (modeParts.length > 0) filters.mode = modeParts.join(" ");
|
|
1237
|
-
} else if (remaining.length > 0) {
|
|
1238
|
-
filters.mode = remaining.join(" ");
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
// Auto-inject device name from context when in a device view and none specified
|
|
1243
|
-
if (!filters.device && ctx.type === "device") {
|
|
1244
|
-
filters.device = ctx.device.product_name;
|
|
1245
|
-
}
|
|
1246
|
-
|
|
1247
|
-
// Show help if no filters could be determined
|
|
1248
|
-
if (!argsStr && !filters.device) {
|
|
1116
|
+
if (!argsStr) {
|
|
1117
|
+
// Show available filter values
|
|
1249
1118
|
const meta = getTestResultMeta();
|
|
1250
1119
|
console.log(` ${bold("Test filters:")}`);
|
|
1251
1120
|
console.log(` ${dim("Types:")} ${meta.test_types.join(", ")}`);
|
|
1252
1121
|
console.log(` ${dim("Modes:")} ${meta.modes.join(", ")}`);
|
|
1253
1122
|
console.log(` ${dim("Packet sizes:")} ${meta.packet_sizes.join(", ")}`);
|
|
1254
1123
|
console.log("");
|
|
1255
|
-
console.log(` ${dim("Usage: tests [device] <type> [
|
|
1124
|
+
console.log(` ${dim("Usage: tests [device] <type> [mode] [packet_size]")}`);
|
|
1256
1125
|
console.log(` ${dim("Example: tests ethernet Routing 1518")}`);
|
|
1257
1126
|
console.log(` ${dim("Example: tests rb5009 ethernet 1518")}`);
|
|
1258
1127
|
return;
|
|
1259
1128
|
}
|
|
1260
1129
|
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
const validSizes = [64, 512, 1400, 1518];
|
|
1264
|
-
const size = Number(filters.packet_size);
|
|
1265
|
-
const rounded = validSizes.reduce((prev, curr) =>
|
|
1266
|
-
Math.abs(curr - size) < Math.abs(prev - size) ? curr : prev,
|
|
1267
|
-
);
|
|
1268
|
-
if (rounded !== size) {
|
|
1269
|
-
console.log(dim(` Rounding ${size}B → ${rounded}B (nearest test packet size)`));
|
|
1270
|
-
filters.packet_size = rounded;
|
|
1271
|
-
}
|
|
1272
|
-
}
|
|
1130
|
+
const parts = argsStr.split(/\s+/);
|
|
1131
|
+
const filters: Record<string, string | number> = {};
|
|
1273
1132
|
|
|
1274
|
-
//
|
|
1275
|
-
|
|
1133
|
+
// Known test types — if parts[0] is not a known type, treat it as a device filter
|
|
1134
|
+
const knownTypes = ["ethernet", "ipsec"];
|
|
1135
|
+
let offset = 0;
|
|
1136
|
+
if (parts[0] && !knownTypes.includes(parts[0].toLowerCase())) {
|
|
1137
|
+
filters.device = parts[0];
|
|
1138
|
+
offset = 1;
|
|
1139
|
+
}
|
|
1140
|
+
if (parts[offset]) filters.test_type = parts[offset];
|
|
1141
|
+
if (parts[offset + 1]) filters.mode = parts[offset + 1];
|
|
1142
|
+
if (parts[offset + 2] && /^\d+$/.test(parts[offset + 2])) filters.packet_size = Number.parseInt(parts[offset + 2], 10);
|
|
1276
1143
|
|
|
1277
1144
|
const result = searchDeviceTests(filters);
|
|
1278
1145
|
if (result.results.length === 0) {
|
|
@@ -1379,6 +1246,7 @@ async function doCurrentVersions(): Promise<void> {
|
|
|
1379
1246
|
const ch = pad(channel, 14);
|
|
1380
1247
|
out.push(` ${dim(ch)} ${bold(String(v))}`);
|
|
1381
1248
|
}
|
|
1249
|
+
out.push(` ${dim(pad("winbox 4", 14))} ${bold(String(result.winbox ?? dim("unavailable")))}`);
|
|
1382
1250
|
console.log(out.join("\n"));
|
|
1383
1251
|
}
|
|
1384
1252
|
|
|
@@ -1429,9 +1297,6 @@ async function main() {
|
|
|
1429
1297
|
} catch (err) {
|
|
1430
1298
|
console.error(red(` Error: ${err instanceof Error ? err.message : String(err)}`));
|
|
1431
1299
|
}
|
|
1432
|
-
// Clear any keys that leaked from the pager into readline's internal line buffer
|
|
1433
|
-
// (the key pressed to exit paging — q/SPACE/ENTER — can appear in the next prompt)
|
|
1434
|
-
rl.write(null as unknown as string, { ctrl: true, name: "u" });
|
|
1435
1300
|
rl.setPrompt(buildPrompt());
|
|
1436
1301
|
rl.prompt();
|
|
1437
1302
|
});
|
package/src/db.ts
CHANGED
|
@@ -441,8 +441,24 @@ export function checkSchemaVersion(): { ok: boolean; actual: number; expected: n
|
|
|
441
441
|
export function getDbStats() {
|
|
442
442
|
const count = (sql: string) =>
|
|
443
443
|
Number((db.prepare(sql).get() as { c: number }).c ?? 0);
|
|
444
|
+
const dbSizeBytes = (() => {
|
|
445
|
+
try {
|
|
446
|
+
return Bun.file(DB_PATH).size;
|
|
447
|
+
} catch {
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
})();
|
|
451
|
+
const schemaVersion = (() => {
|
|
452
|
+
try {
|
|
453
|
+
return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
|
|
454
|
+
} catch {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
})();
|
|
444
458
|
return {
|
|
445
459
|
db_path: DB_PATH,
|
|
460
|
+
db_size_bytes: dbSizeBytes,
|
|
461
|
+
schema_version: schemaVersion,
|
|
446
462
|
pages: count("SELECT COUNT(*) AS c FROM pages"),
|
|
447
463
|
sections: count("SELECT COUNT(*) AS c FROM sections"),
|
|
448
464
|
properties: count("SELECT COUNT(*) AS c FROM properties"),
|
package/src/extract-devices.ts
CHANGED
|
@@ -120,7 +120,11 @@ if (lines.length < 2) {
|
|
|
120
120
|
// Skip header row
|
|
121
121
|
const dataLines = lines.slice(1);
|
|
122
122
|
|
|
123
|
-
// Idempotent: clear existing data (FTS triggers handle cleanup)
|
|
123
|
+
// Idempotent: clear existing data (FTS triggers handle cleanup).
|
|
124
|
+
// device_test_results FKs into devices — wipe it first to avoid a FOREIGN KEY
|
|
125
|
+
// constraint failure on re-run over a populated DB. extract-test-results runs
|
|
126
|
+
// later in the pipeline and repopulates it.
|
|
127
|
+
db.run("DELETE FROM device_test_results");
|
|
124
128
|
db.run("DELETE FROM devices");
|
|
125
129
|
|
|
126
130
|
const insert = db.prepare(`INSERT INTO devices (
|
|
@@ -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 {
|
|
@@ -187,7 +301,7 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
|
|
|
187
301
|
}
|
|
188
302
|
|
|
189
303
|
// Plain text from main content (includes code block text too, which is fine for FTS)
|
|
190
|
-
const text = mainContent
|
|
304
|
+
const text = sanitizeExtractedText(extractPlainText(mainContent));
|
|
191
305
|
const wordCount = text.split(/\s+/).filter(Boolean).length;
|
|
192
306
|
|
|
193
307
|
// Callouts: extract note/warning/info blocks
|
|
@@ -238,149 +352,155 @@ function extractPage(file: string, html: string): (PageRow & { callouts: Callout
|
|
|
238
352
|
|
|
239
353
|
// ---- Main ----
|
|
240
354
|
|
|
241
|
-
|
|
242
|
-
|
|
355
|
+
function main() {
|
|
356
|
+
console.log("Initializing database...");
|
|
357
|
+
initDb();
|
|
243
358
|
|
|
244
359
|
// Drop existing data for clean re-extraction (respect FK order)
|
|
245
|
-
db.run("DELETE FROM sections;");
|
|
246
|
-
db.run("DELETE FROM callouts;");
|
|
247
|
-
db.run("INSERT INTO callouts_fts(callouts_fts) VALUES('rebuild');");
|
|
248
|
-
db.run("DELETE FROM properties;");
|
|
249
|
-
db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
|
|
250
|
-
db.run("PRAGMA foreign_keys = OFF;");
|
|
251
|
-
db.run("DELETE FROM pages;");
|
|
252
|
-
db.run("PRAGMA foreign_keys = ON;");
|
|
253
|
-
db.run("INSERT INTO pages_fts(pages_fts) VALUES('rebuild');");
|
|
254
|
-
|
|
255
|
-
const htmlFiles = readdirSync(HTML_DIR)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
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}`);
|
|
260
375
|
|
|
261
376
|
// Two-pass insert: first without parent_id (avoids FK ordering issues),
|
|
262
377
|
// then update parent relationships.
|
|
263
|
-
const insertPage = db.prepare(`
|
|
378
|
+
const insertPage = db.prepare(`
|
|
264
379
|
INSERT OR REPLACE INTO pages
|
|
265
380
|
(id, slug, title, path, depth, parent_id, url, text, code, code_lang,
|
|
266
381
|
author, last_updated, word_count, code_lines, html_file)
|
|
267
382
|
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
268
383
|
`);
|
|
269
|
-
const updateParent = db.prepare("UPDATE pages SET parent_id = ? WHERE id = ?");
|
|
384
|
+
const updateParent = db.prepare("UPDATE pages SET parent_id = ? WHERE id = ?");
|
|
270
385
|
|
|
271
|
-
let extracted = 0;
|
|
272
|
-
let skipped = 0;
|
|
273
|
-
let totalWords = 0;
|
|
274
|
-
let totalCodeLines = 0;
|
|
275
|
-
let totalCallouts = 0;
|
|
386
|
+
let extracted = 0;
|
|
387
|
+
let skipped = 0;
|
|
388
|
+
let totalWords = 0;
|
|
389
|
+
let totalCodeLines = 0;
|
|
390
|
+
let totalCallouts = 0;
|
|
276
391
|
|
|
277
|
-
const allPages: (PageRow & { callouts: CalloutRow[]; sections: SectionRow[] })[] = [];
|
|
392
|
+
const allPages: (PageRow & { callouts: CalloutRow[]; sections: SectionRow[] })[] = [];
|
|
278
393
|
|
|
279
394
|
// Pass 1: extract and insert all pages (parent_id = NULL)
|
|
280
|
-
const insertAll = db.transaction(() => {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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;
|
|
288
424
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
page.slug,
|
|
292
|
-
page.title,
|
|
293
|
-
page.path,
|
|
294
|
-
page.depth,
|
|
295
|
-
page.url,
|
|
296
|
-
page.text,
|
|
297
|
-
page.code,
|
|
298
|
-
page.code_lang,
|
|
299
|
-
page.author,
|
|
300
|
-
page.last_updated,
|
|
301
|
-
page.word_count,
|
|
302
|
-
page.code_lines,
|
|
303
|
-
page.html_file,
|
|
304
|
-
);
|
|
305
|
-
allPages.push(page);
|
|
306
|
-
extracted++;
|
|
307
|
-
totalWords += page.word_count;
|
|
308
|
-
totalCodeLines += page.code_lines;
|
|
309
|
-
}
|
|
310
|
-
});
|
|
311
|
-
insertAll();
|
|
425
|
+
});
|
|
426
|
+
insertAll();
|
|
312
427
|
|
|
313
428
|
// Pass 2: set parent_id where the parent actually exists in the DB
|
|
314
|
-
const pageIds = new Set(allPages.map((p) => p.id));
|
|
315
|
-
const setParents = db.transaction(() => {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
}
|
|
319
435
|
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
setParents();
|
|
436
|
+
});
|
|
437
|
+
setParents();
|
|
323
438
|
|
|
324
439
|
// Pass 3: insert callouts
|
|
325
|
-
const insertCallout = db.prepare(`
|
|
440
|
+
const insertCallout = db.prepare(`
|
|
326
441
|
INSERT INTO callouts (page_id, type, content, sort_order)
|
|
327
442
|
VALUES (?, ?, ?, ?)
|
|
328
443
|
`);
|
|
329
|
-
const insertCallouts = db.transaction(() => {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
+
}
|
|
334
450
|
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
insertCallouts();
|
|
451
|
+
});
|
|
452
|
+
insertCallouts();
|
|
338
453
|
|
|
339
454
|
// Pass 4: insert sections
|
|
340
|
-
let totalSections = 0;
|
|
341
|
-
let pagesWithSections = 0;
|
|
342
|
-
const insertSection = db.prepare(`
|
|
455
|
+
let totalSections = 0;
|
|
456
|
+
let pagesWithSections = 0;
|
|
457
|
+
const insertSection = db.prepare(`
|
|
343
458
|
INSERT INTO sections (page_id, heading, level, anchor_id, text, code, word_count, sort_order)
|
|
344
459
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
345
460
|
`);
|
|
346
|
-
const insertSections = db.transaction(() => {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
+
}
|
|
353
469
|
}
|
|
354
470
|
}
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
insertSections();
|
|
471
|
+
});
|
|
472
|
+
insertSections();
|
|
358
473
|
|
|
359
|
-
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;
|
|
360
475
|
|
|
361
|
-
console.log(`\nExtraction complete:`);
|
|
362
|
-
console.log(` Pages extracted: ${extracted}`);
|
|
363
|
-
console.log(` Pages skipped: ${skipped}`);
|
|
364
|
-
console.log(` Total words: ${totalWords.toLocaleString()}`);
|
|
365
|
-
console.log(` Total code lines: ${totalCodeLines.toLocaleString()}`);
|
|
366
|
-
console.log(` Total callouts: ${totalCallouts}`);
|
|
367
|
-
console.log(` Total sections: ${totalSections} (across ${pagesWithSections} pages)`);
|
|
368
|
-
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}`);
|
|
369
484
|
|
|
370
485
|
// Quick search test
|
|
371
|
-
const testResults = db
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
console.log(`\nTest search for "firewall filter":`);
|
|
383
|
-
for (const r of testResults as Array<{ id: number; title: string; path: string; excerpt: string }>) {
|
|
384
|
-
|
|
385
|
-
|
|
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();
|
|
386
506
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -1032,17 +1032,8 @@ Workflow:
|
|
|
1032
1032
|
};
|
|
1033
1033
|
}
|
|
1034
1034
|
|
|
1035
|
-
// Round packet_size to the nearest valid test size (64, 512, 1400, 1518)
|
|
1036
|
-
let effectivePacketSize = packet_size;
|
|
1037
|
-
if (packet_size !== undefined) {
|
|
1038
|
-
const validSizes = [64, 512, 1400, 1518];
|
|
1039
|
-
effectivePacketSize = validSizes.reduce((prev, curr) =>
|
|
1040
|
-
Math.abs(curr - packet_size) < Math.abs(prev - packet_size) ? curr : prev,
|
|
1041
|
-
);
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
1035
|
const result = searchDeviceTests(
|
|
1045
|
-
{ device, test_type, mode, configuration, packet_size
|
|
1036
|
+
{ device, test_type, mode, configuration, packet_size, sort_by },
|
|
1046
1037
|
limit,
|
|
1047
1038
|
);
|
|
1048
1039
|
|
|
@@ -1065,9 +1056,6 @@ Workflow:
|
|
|
1065
1056
|
text: JSON.stringify({
|
|
1066
1057
|
...result,
|
|
1067
1058
|
has_more: result.total > result.results.length,
|
|
1068
|
-
...(effectivePacketSize !== packet_size && packet_size !== undefined
|
|
1069
|
-
? { packet_size_rounded: `${packet_size} → ${effectivePacketSize} (nearest valid test size)` }
|
|
1070
|
-
: {}),
|
|
1071
1059
|
}, null, 2),
|
|
1072
1060
|
}],
|
|
1073
1061
|
};
|
|
@@ -1081,7 +1069,7 @@ server.registerTool(
|
|
|
1081
1069
|
{
|
|
1082
1070
|
description: `Fetch current RouterOS version numbers from MikroTik's upgrade server.
|
|
1083
1071
|
|
|
1084
|
-
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.
|
|
1085
1073
|
Useful for determining if a user's version is current, outdated, or unpatched.
|
|
1086
1074
|
|
|
1087
1075
|
Key context for version reasoning:
|
package/src/query.ts
CHANGED
|
@@ -1493,27 +1493,31 @@ function runVideosFtsQuery(ftsQuery: string, limit: number): VideoSearchResult[]
|
|
|
1493
1493
|
const VERSION_BASE_URL = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
|
|
1494
1494
|
|
|
1495
1495
|
/** Fetch current RouterOS versions from MikroTik's upgrade server. */
|
|
1496
|
+
const WINBOX_URL = "https://upgrade.mikrotik.com/routeros/winbox/LATEST.4";
|
|
1497
|
+
|
|
1496
1498
|
export async function fetchCurrentVersions(): Promise<{
|
|
1497
1499
|
channels: Record<string, string | null>;
|
|
1500
|
+
winbox: string | null;
|
|
1498
1501
|
fetched_at: string;
|
|
1499
1502
|
}> {
|
|
1500
1503
|
const channels: Record<string, string | null> = {};
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
channels[channel] =
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1504
|
+
const fetchOne = async (url: string): Promise<string | null> => {
|
|
1505
|
+
try {
|
|
1506
|
+
const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
1507
|
+
if (!resp.ok) return null;
|
|
1508
|
+
const text = await resp.text();
|
|
1509
|
+
return text.trim().split(/\s+/)[0] || null;
|
|
1510
|
+
} catch {
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
};
|
|
1514
|
+
const [, winbox] = await Promise.all([
|
|
1515
|
+
Promise.all(
|
|
1516
|
+
VERSION_CHANNELS.map(async (channel) => {
|
|
1517
|
+
channels[channel] = await fetchOne(`${VERSION_BASE_URL}.${channel}`);
|
|
1518
|
+
}),
|
|
1519
|
+
),
|
|
1520
|
+
fetchOne(WINBOX_URL),
|
|
1521
|
+
]);
|
|
1522
|
+
return { channels, winbox, fetched_at: new Date().toISOString() };
|
|
1519
1523
|
}
|