@tikoci/rosetta 0.5.2 → 0.6.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/README.md +3 -1
- package/package.json +1 -1
- package/src/browse.ts +90 -0
- package/src/db.ts +130 -7
- package/src/extract-commands.ts +55 -10
- package/src/extract-dude.ts +409 -0
- package/src/mcp-http.test.ts +7 -5
- package/src/mcp.ts +278 -1
- package/src/paths.ts +1 -1
- package/src/query.test.ts +124 -6
- package/src/query.ts +93 -2
- package/src/release.test.ts +20 -0
package/src/mcp.ts
CHANGED
|
@@ -163,7 +163,7 @@ if (_dbSchemaVersion !== SCHEMA_VERSION) {
|
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
// Now import db.ts (opens the DB) and query.ts
|
|
166
|
-
const { getDbStats, initDb } = await import("./db.ts");
|
|
166
|
+
const { db, getDbStats, initDb } = await import("./db.ts");
|
|
167
167
|
const {
|
|
168
168
|
browseCommands,
|
|
169
169
|
browseCommandsAtVersion,
|
|
@@ -182,6 +182,8 @@ const {
|
|
|
182
182
|
searchPages,
|
|
183
183
|
searchProperties,
|
|
184
184
|
searchVideos,
|
|
185
|
+
searchDude,
|
|
186
|
+
getDudePage,
|
|
185
187
|
} = await import("./query.ts");
|
|
186
188
|
|
|
187
189
|
initDb();
|
|
@@ -229,6 +231,200 @@ server.registerResource(
|
|
|
229
231
|
}),
|
|
230
232
|
);
|
|
231
233
|
|
|
234
|
+
server.registerResource(
|
|
235
|
+
"schema-sql",
|
|
236
|
+
"rosetta://schema.sql",
|
|
237
|
+
{
|
|
238
|
+
title: "Database Schema DDL",
|
|
239
|
+
description: "Full SQLite DDL (CREATE TABLE/VIRTUAL TABLE/TRIGGER/INDEX statements) for ros-help.db. Read this before constructing raw SQL queries.",
|
|
240
|
+
mimeType: "application/sql",
|
|
241
|
+
},
|
|
242
|
+
async () => {
|
|
243
|
+
const rows = db
|
|
244
|
+
.prepare(
|
|
245
|
+
"SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY type DESC, name ASC",
|
|
246
|
+
)
|
|
247
|
+
.all() as Array<{ sql: string }>;
|
|
248
|
+
const ddl = rows.map((r) => `${r.sql};`).join("\n\n");
|
|
249
|
+
return {
|
|
250
|
+
contents: [{
|
|
251
|
+
uri: "rosetta://schema.sql",
|
|
252
|
+
mimeType: "application/sql",
|
|
253
|
+
text: ddl,
|
|
254
|
+
}],
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
server.registerResource(
|
|
260
|
+
"schema-guide",
|
|
261
|
+
"rosetta://schema-guide.md",
|
|
262
|
+
{
|
|
263
|
+
title: "Schema Guide",
|
|
264
|
+
description: "How to query ros-help.db: table relationships, FTS5 tokenizer differences, BM25 weights, and example query patterns.",
|
|
265
|
+
mimeType: "text/markdown",
|
|
266
|
+
},
|
|
267
|
+
async () => ({
|
|
268
|
+
contents: [{
|
|
269
|
+
uri: "rosetta://schema-guide.md",
|
|
270
|
+
mimeType: "text/markdown",
|
|
271
|
+
text: `# ros-help.db Schema Guide
|
|
272
|
+
|
|
273
|
+
Read \`rosetta://schema.sql\` for full DDL. This guide explains relationships, FTS5 quirks, and good query patterns.
|
|
274
|
+
|
|
275
|
+
## Table Map
|
|
276
|
+
|
|
277
|
+
| Table | Rows (approx) | Description |
|
|
278
|
+
|-------|-------------|-------------|
|
|
279
|
+
| \`pages\` | 317 | One row per Confluence HTML page. Primary content store. |
|
|
280
|
+
| \`sections\` | 2,984 | h1–h3 chunks of pages with anchor IDs for deep-linking. |
|
|
281
|
+
| \`properties\` | 4,860 | CLI property rows extracted from confluenceTable elements. |
|
|
282
|
+
| \`callouts\` | 1,034 | Note/Warning/Info/Tip blocks from Confluence callout macros. |
|
|
283
|
+
| \`commands\` | ~40K | RouterOS command tree entries (dir/cmd/arg) from inspect.json. |
|
|
284
|
+
| \`command_versions\` | 1.67M | Junction: which command paths exist in which RouterOS versions. |
|
|
285
|
+
| \`ros_versions\` | 46 | Metadata per extracted RouterOS version (7.9–7.23beta2). |
|
|
286
|
+
| \`devices\` | 144 | MikroTik hardware specs from product matrix CSV. |
|
|
287
|
+
| \`device_test_results\` | 2,874 | Ethernet/IPSec benchmark rows from mikrotik.com product pages. |
|
|
288
|
+
| \`changelogs\` | varies | Parsed per-entry changelog lines from MikroTik download server. |
|
|
289
|
+
| \`videos\` | 518 | MikroTik YouTube video metadata. |
|
|
290
|
+
| \`video_segments\` | ~1,890 | Chapter-level transcript segments (one per chapter or one per video). |
|
|
291
|
+
|
|
292
|
+
## Foreign Keys
|
|
293
|
+
|
|
294
|
+
\`\`\`
|
|
295
|
+
pages ←── sections.page_id
|
|
296
|
+
pages ←── properties.page_id
|
|
297
|
+
pages ←── callouts.page_id
|
|
298
|
+
pages ←── commands.page_id (nullable — not all commands link to a page)
|
|
299
|
+
devices ←── device_test_results.device_id
|
|
300
|
+
ros_versions ←── command_versions.ros_version
|
|
301
|
+
videos ←── video_segments.video_id (INTEGER FK to videos.id, NOT videos.video_id TEXT)
|
|
302
|
+
\`\`\`
|
|
303
|
+
|
|
304
|
+
## FTS5 Virtual Tables
|
|
305
|
+
|
|
306
|
+
Each table has a companion \`*_fts\` virtual table kept in sync via INSERT/UPDATE/DELETE triggers.
|
|
307
|
+
|
|
308
|
+
| FTS table | Source | Tokenizer | Indexed columns |
|
|
309
|
+
|-----------|--------|-----------|----------------|
|
|
310
|
+
| \`pages_fts\` | \`pages\` | \`porter unicode61\` | title (3×), path (2×), text (1×), code (0.5×) |
|
|
311
|
+
| \`properties_fts\` | \`properties\` | \`porter unicode61\` | name, description |
|
|
312
|
+
| \`callouts_fts\` | \`callouts\` | \`porter unicode61\` | content |
|
|
313
|
+
| \`changelogs_fts\` | \`changelogs\` | \`porter unicode61\` | category, description |
|
|
314
|
+
| \`videos_fts\` | \`videos\` | \`porter unicode61\` | title, description |
|
|
315
|
+
| \`video_segments_fts\` | \`video_segments\` | \`porter unicode61\` | chapter_title, transcript |
|
|
316
|
+
| \`devices_fts\` | \`devices\` | **\`unicode61\` only** | product_name, product_code, architecture, cpu |
|
|
317
|
+
|
|
318
|
+
**Why devices use \`unicode61\` without porter:** Model numbers like "RB5009" and "hAP ax3" must not be stemmed. Porter would corrupt them.
|
|
319
|
+
|
|
320
|
+
## BM25 Column Weights (pages_fts)
|
|
321
|
+
|
|
322
|
+
The MCP tools use \`bm25(pages_fts, 3.0, 2.0, 1.0, 0.5)\` — title gets 3× weight, path 2×, body text 1×, code blocks 0.5×. In SQLite FTS5 BM25, **lower (more negative) scores rank better**.
|
|
323
|
+
|
|
324
|
+
\`\`\`sql
|
|
325
|
+
SELECT p.id, p.title, p.url,
|
|
326
|
+
bm25(pages_fts, 3.0, 2.0, 1.0, 0.5) AS rank
|
|
327
|
+
FROM pages_fts
|
|
328
|
+
JOIN pages p ON p.id = pages_fts.rowid
|
|
329
|
+
WHERE pages_fts MATCH 'firewall filter'
|
|
330
|
+
ORDER BY rank -- ascending = best match first
|
|
331
|
+
LIMIT 10;
|
|
332
|
+
\`\`\`
|
|
333
|
+
|
|
334
|
+
## FTS5 Query Syntax
|
|
335
|
+
|
|
336
|
+
\`\`\`sql
|
|
337
|
+
-- Phrase search (exact sequence)
|
|
338
|
+
WHERE pages_fts MATCH '"firewall filter"'
|
|
339
|
+
|
|
340
|
+
-- AND (default — all terms must appear)
|
|
341
|
+
WHERE pages_fts MATCH 'dhcp relay'
|
|
342
|
+
|
|
343
|
+
-- OR
|
|
344
|
+
WHERE pages_fts MATCH 'dhcp OR relay'
|
|
345
|
+
|
|
346
|
+
-- Column-scoped search
|
|
347
|
+
WHERE pages_fts MATCH 'title:firewall'
|
|
348
|
+
|
|
349
|
+
-- NEAR (terms within N tokens of each other)
|
|
350
|
+
WHERE pages_fts MATCH 'NEAR(firewall filter, 5)'
|
|
351
|
+
|
|
352
|
+
-- Prefix match
|
|
353
|
+
WHERE pages_fts MATCH 'route*'
|
|
354
|
+
\`\`\`
|
|
355
|
+
|
|
356
|
+
Porter stemming is automatic — "configuring" matches "configuration", "configured", "configure".
|
|
357
|
+
|
|
358
|
+
## Common Join Patterns
|
|
359
|
+
|
|
360
|
+
### Page + its properties
|
|
361
|
+
\`\`\`sql
|
|
362
|
+
SELECT p.title, pr.name, pr.type, pr.default_val, pr.description
|
|
363
|
+
FROM pages p
|
|
364
|
+
JOIN properties pr ON pr.page_id = p.id
|
|
365
|
+
WHERE p.id = 328220;
|
|
366
|
+
\`\`\`
|
|
367
|
+
|
|
368
|
+
### FTS search → full section content
|
|
369
|
+
\`\`\`sql
|
|
370
|
+
SELECT p.title, s.heading, s.text, s.anchor_id
|
|
371
|
+
FROM pages_fts
|
|
372
|
+
JOIN pages p ON p.id = pages_fts.rowid
|
|
373
|
+
JOIN sections s ON s.page_id = p.id
|
|
374
|
+
WHERE pages_fts MATCH 'mangle routing mark'
|
|
375
|
+
ORDER BY bm25(pages_fts, 3.0, 2.0, 1.0, 0.5)
|
|
376
|
+
LIMIT 5;
|
|
377
|
+
\`\`\`
|
|
378
|
+
|
|
379
|
+
### Command path → linked documentation page
|
|
380
|
+
\`\`\`sql
|
|
381
|
+
SELECT c.path, c.type, p.title, p.url
|
|
382
|
+
FROM commands c
|
|
383
|
+
LEFT JOIN pages p ON p.id = c.page_id
|
|
384
|
+
WHERE c.path = '/ip/firewall/filter';
|
|
385
|
+
\`\`\`
|
|
386
|
+
|
|
387
|
+
### Commands available in a specific RouterOS version
|
|
388
|
+
\`\`\`sql
|
|
389
|
+
SELECT c.path, c.type
|
|
390
|
+
FROM commands c
|
|
391
|
+
JOIN command_versions cv ON cv.command_path = c.path
|
|
392
|
+
WHERE cv.ros_version = '7.22'
|
|
393
|
+
AND c.path LIKE '/ip/firewall/%'
|
|
394
|
+
ORDER BY c.path;
|
|
395
|
+
\`\`\`
|
|
396
|
+
|
|
397
|
+
### Device hardware lookup + benchmarks
|
|
398
|
+
\`\`\`sql
|
|
399
|
+
SELECT d.product_name, d.ram_mb, d.cpu,
|
|
400
|
+
t.test_type, t.mode, t.packet_size, t.throughput_mbps
|
|
401
|
+
FROM devices d
|
|
402
|
+
JOIN device_test_results t ON t.device_id = d.id
|
|
403
|
+
WHERE d.product_name LIKE '%RB5009%'
|
|
404
|
+
ORDER BY t.test_type, t.packet_size;
|
|
405
|
+
\`\`\`
|
|
406
|
+
|
|
407
|
+
### Changelog entries for a version range, breaking changes only
|
|
408
|
+
\`\`\`sql
|
|
409
|
+
SELECT version, released, category, description
|
|
410
|
+
FROM changelogs
|
|
411
|
+
WHERE is_breaking = 1
|
|
412
|
+
AND version >= '7.20' AND version <= '7.22'
|
|
413
|
+
ORDER BY version, sort_order;
|
|
414
|
+
\`\`\`
|
|
415
|
+
|
|
416
|
+
## Gotchas
|
|
417
|
+
|
|
418
|
+
- **Version sorting:** \`ORDER BY version\` is lexicographic, not numeric. '7.9' > '7.10' lexicographically. Use the \`compareVersions()\` helper in query.ts or fetch all and sort in application code.
|
|
419
|
+
- **content= FTS tables:** Do not SELECT directly from \`*_fts\` tables — they are content tables and must be JOINed via rowid to the source table to get non-indexed columns.
|
|
420
|
+
- **video_segments.video_id** is an INTEGER FK to \`videos.id\`, not the TEXT \`videos.video_id\` (YouTube ID). Join on \`video_segments.video_id = videos.id\`.
|
|
421
|
+
- **NULL page_id in commands:** ~8% of command dirs have no linked page (\`page_id IS NULL\`). Use LEFT JOIN when joining commands to pages.
|
|
422
|
+
- **devices_fts LIKE fallback:** For model numbers ending in ™/® or containing superscripts, FTS may miss them. Use \`LIKE '%RB5009%'\` as a fallback on \`devices.product_name\`.
|
|
423
|
+
`,
|
|
424
|
+
}],
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
427
|
+
|
|
232
428
|
// ---- routeros_search ----
|
|
233
429
|
|
|
234
430
|
server.registerTool(
|
|
@@ -725,6 +921,87 @@ and explanations that complement the text documentation.
|
|
|
725
921
|
},
|
|
726
922
|
);
|
|
727
923
|
|
|
924
|
+
// ---- routeros_dude_search ----
|
|
925
|
+
|
|
926
|
+
server.registerTool(
|
|
927
|
+
"routeros_dude_search",
|
|
928
|
+
{
|
|
929
|
+
description: `Search archived "The Dude" network monitor documentation (from wiki.mikrotik.com via Wayback Machine).
|
|
930
|
+
|
|
931
|
+
The Dude GUI client is retired, but the Dude server/database remains in RouterOS under /dude.
|
|
932
|
+
These are archived wiki pages covering the Dude v6 GUI (primary) and legacy v3/v4 (reference).
|
|
933
|
+
Many pages include GUI screenshots — use routeros_dude_get_page to see image references.
|
|
934
|
+
|
|
935
|
+
Separate from routeros_search (which covers current RouterOS v7 docs only).
|
|
936
|
+
For current RouterOS /dude command-line interface, use routeros_command_tree with path "/dude".
|
|
937
|
+
|
|
938
|
+
→ routeros_dude_get_page: read full page text + screenshot list
|
|
939
|
+
→ routeros_command_tree: browse /dude commands in current RouterOS
|
|
940
|
+
→ routeros_search: search current RouterOS v7 documentation`,
|
|
941
|
+
inputSchema: {
|
|
942
|
+
query: z.string().describe("Search terms (e.g., 'probes SNMP', 'device discovery', 'notifications')"),
|
|
943
|
+
limit: z
|
|
944
|
+
.number()
|
|
945
|
+
.int()
|
|
946
|
+
.min(1)
|
|
947
|
+
.max(20)
|
|
948
|
+
.default(8)
|
|
949
|
+
.optional()
|
|
950
|
+
.describe("Max results (1–20, default 8)"),
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
async ({ query, limit }) => {
|
|
954
|
+
const results = searchDude(query, limit ?? 8);
|
|
955
|
+
if (results.length === 0) {
|
|
956
|
+
return {
|
|
957
|
+
content: [
|
|
958
|
+
{
|
|
959
|
+
type: "text",
|
|
960
|
+
text: `No Dude wiki results for: "${query}"\n\nTry:\n- Broader search terms (e.g., 'monitor' instead of 'monitoring')\n- routeros_search for current RouterOS documentation\n- routeros_command_tree with path "/dude" for current /dude commands`,
|
|
961
|
+
},
|
|
962
|
+
],
|
|
963
|
+
};
|
|
964
|
+
}
|
|
965
|
+
return {
|
|
966
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
967
|
+
};
|
|
968
|
+
},
|
|
969
|
+
);
|
|
970
|
+
|
|
971
|
+
// ---- routeros_dude_get_page ----
|
|
972
|
+
|
|
973
|
+
server.registerTool(
|
|
974
|
+
"routeros_dude_get_page",
|
|
975
|
+
{
|
|
976
|
+
description: `Get full content of an archived Dude wiki page by ID or title.
|
|
977
|
+
|
|
978
|
+
Returns the complete page text, code blocks, and a list of GUI screenshots with local file paths.
|
|
979
|
+
Screenshots are downloaded images from the archived wiki — use a file viewer for multimodal analysis.
|
|
980
|
+
|
|
981
|
+
→ routeros_dude_search: find pages by topic
|
|
982
|
+
→ routeros_command_tree: browse /dude commands in current RouterOS`,
|
|
983
|
+
inputSchema: {
|
|
984
|
+
id: z.union([z.number().int(), z.string()]).describe("Page ID (number) or title/slug (string)"),
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
async ({ id }) => {
|
|
988
|
+
const page = getDudePage(typeof id === "string" && /^\d+$/.test(id) ? Number.parseInt(id, 10) : id);
|
|
989
|
+
if (!page) {
|
|
990
|
+
return {
|
|
991
|
+
content: [
|
|
992
|
+
{
|
|
993
|
+
type: "text",
|
|
994
|
+
text: `No Dude page found for: "${id}"\n\nTry routeros_dude_search to find available pages.`,
|
|
995
|
+
},
|
|
996
|
+
],
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
return {
|
|
1000
|
+
content: [{ type: "text", text: JSON.stringify(page, null, 2) }],
|
|
1001
|
+
};
|
|
1002
|
+
},
|
|
1003
|
+
);
|
|
1004
|
+
|
|
728
1005
|
// ---- routeros_command_version_check ----
|
|
729
1006
|
|
|
730
1007
|
server.registerTool(
|
package/src/paths.ts
CHANGED
|
@@ -79,7 +79,7 @@ export function detectMode(srcDir: string): InvocationMode {
|
|
|
79
79
|
* Stamped into the DB via `PRAGMA user_version` by initDb() and checked at MCP
|
|
80
80
|
* startup to detect stale DBs for bunx users who auto-update the package.
|
|
81
81
|
*/
|
|
82
|
-
export const SCHEMA_VERSION =
|
|
82
|
+
export const SCHEMA_VERSION = 2;
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
85
|
* Resolve the version string.
|
package/src/query.test.ts
CHANGED
|
@@ -32,6 +32,8 @@ const {
|
|
|
32
32
|
getTestResultMeta,
|
|
33
33
|
normalizeDeviceQuery,
|
|
34
34
|
searchVideos,
|
|
35
|
+
searchDude,
|
|
36
|
+
getDudePage,
|
|
35
37
|
} = await import("./query.ts");
|
|
36
38
|
const { parseChangelog } = await import("./extract-changelogs.ts");
|
|
37
39
|
const { parseVtt, segmentTranscript } = await import("./extract-videos.ts");
|
|
@@ -75,12 +77,12 @@ beforeAll(() => {
|
|
|
75
77
|
(id, page_id, name, type, default_val, description, section, sort_order)
|
|
76
78
|
VALUES (2, 1, 'address-pool', 'string', '', 'Name of the address pool to use', NULL, 1)`);
|
|
77
79
|
|
|
78
|
-
db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
79
|
-
VALUES ('7.22', 'stable', 0, '2024-01-01T00:00:00Z')`);
|
|
80
|
-
db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
81
|
-
VALUES ('7.9', 'stable', 0, '2023-01-01T00:00:00Z')`);
|
|
82
|
-
db.run(`INSERT INTO ros_versions (version, channel, extra_packages, extracted_at)
|
|
83
|
-
VALUES ('7.10.2', 'stable', 0, '2023-06-01T00:00:00Z')`);
|
|
80
|
+
db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
|
|
81
|
+
VALUES ('7.22', 'x86', 'stable', 0, '2024-01-01T00:00:00Z')`);
|
|
82
|
+
db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
|
|
83
|
+
VALUES ('7.9', 'x86', 'stable', 0, '2023-01-01T00:00:00Z')`);
|
|
84
|
+
db.run(`INSERT INTO ros_versions (version, arch, channel, extra_packages, extracted_at)
|
|
85
|
+
VALUES ('7.10.2', 'x86', 'stable', 0, '2023-06-01T00:00:00Z')`);
|
|
84
86
|
|
|
85
87
|
db.run(`INSERT INTO commands
|
|
86
88
|
(id, path, name, type, parent_path, page_id, description, ros_version)
|
|
@@ -350,6 +352,41 @@ beforeAll(() => {
|
|
|
350
352
|
(id, video_id, chapter_title, start_s, end_s, transcript, sort_order)
|
|
351
353
|
VALUES
|
|
352
354
|
(3, 2, NULL, 0, NULL, 'BGP peering and route reflection allow scalable routing in large networks.', 0)`);
|
|
355
|
+
|
|
356
|
+
// Dude wiki page fixtures for searchDude tests
|
|
357
|
+
db.run(`INSERT INTO dude_pages
|
|
358
|
+
(id, slug, title, path, version, url, wayback_url, text, code, word_count)
|
|
359
|
+
VALUES
|
|
360
|
+
(1, 'Probes', 'Probes', 'The Dude > v6 > Probes', 'v6',
|
|
361
|
+
'https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes',
|
|
362
|
+
'https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes',
|
|
363
|
+
'Probes are used to monitor specific services on devices. SNMP probes query MIB values. TCP probes check port availability. The Dude supports custom probe definitions with thresholds and alerts.',
|
|
364
|
+
NULL, 30)`);
|
|
365
|
+
db.run(`INSERT INTO dude_pages
|
|
366
|
+
(id, slug, title, path, version, url, wayback_url, text, code, word_count)
|
|
367
|
+
VALUES
|
|
368
|
+
(2, 'Device_discovery', 'Device Discovery', 'The Dude > v6 > Device Discovery', 'v6',
|
|
369
|
+
'https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Device_discovery',
|
|
370
|
+
'https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Device_discovery',
|
|
371
|
+
'Device discovery scans networks using SNMP, TCP, and ICMP. The Dude can automatically discover routers, switches, and other network devices on specified subnets.',
|
|
372
|
+
'/dude discovery add address=10.0.0.0/24', 25)`);
|
|
373
|
+
db.run(`INSERT INTO dude_pages
|
|
374
|
+
(id, slug, title, path, version, url, wayback_url, text, code, word_count)
|
|
375
|
+
VALUES
|
|
376
|
+
(3, 'Notifications', 'Notifications', 'The Dude > v3 > Notifications', 'v3',
|
|
377
|
+
'https://wiki.mikrotik.com/wiki/Manual:The_Dude/Notifications',
|
|
378
|
+
'https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude/Notifications',
|
|
379
|
+
'The Dude can send email and SMS notifications when device status changes. Configure SMTP settings for email alerts.',
|
|
380
|
+
NULL, 20)`);
|
|
381
|
+
db.run(`INSERT INTO dude_images (page_id, filename, alt_text, caption, local_path, original_url, sort_order)
|
|
382
|
+
VALUES (1, 'Dude-probes-all.JPG', 'Probes list', 'All probes view', 'dude/images/Dude-probes-all.JPG',
|
|
383
|
+
'https://wiki.mikrotik.com/wiki/File:Dude-probes-all.JPG', 0)`);
|
|
384
|
+
db.run(`INSERT INTO dude_images (page_id, filename, alt_text, caption, local_path, original_url, sort_order)
|
|
385
|
+
VALUES (1, 'Dude-probe-settings.JPG', 'Probe settings', 'Probe configuration dialog', 'dude/images/Dude-probe-settings.JPG',
|
|
386
|
+
'https://wiki.mikrotik.com/wiki/File:Dude-probe-settings.JPG', 1)`);
|
|
387
|
+
db.run(`INSERT INTO dude_images (page_id, filename, alt_text, caption, local_path, original_url, sort_order)
|
|
388
|
+
VALUES (2, 'Dude-discovery.JPG', 'Discovery settings', NULL, 'dude/images/Dude-discovery.JPG',
|
|
389
|
+
'https://wiki.mikrotik.com/wiki/File:Dude-discovery.JPG', 0)`);
|
|
353
390
|
});
|
|
354
391
|
|
|
355
392
|
// ---------------------------------------------------------------------------
|
|
@@ -1502,6 +1539,13 @@ describe("schema", () => {
|
|
|
1502
1539
|
expect(triggers).toContain("video_segs_au");
|
|
1503
1540
|
});
|
|
1504
1541
|
|
|
1542
|
+
test("content-sync triggers exist for dude_pages", () => {
|
|
1543
|
+
const triggers = triggerNames();
|
|
1544
|
+
expect(triggers).toContain("dude_pages_ai");
|
|
1545
|
+
expect(triggers).toContain("dude_pages_ad");
|
|
1546
|
+
expect(triggers).toContain("dude_pages_au");
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1505
1549
|
test("PRAGMA user_version matches SCHEMA_VERSION", () => {
|
|
1506
1550
|
const result = checkSchemaVersion();
|
|
1507
1551
|
expect(result.ok).toBe(true);
|
|
@@ -1667,3 +1711,77 @@ describe("searchVideos", () => {
|
|
|
1667
1711
|
expect(results.length).toBeLessThanOrEqual(1);
|
|
1668
1712
|
});
|
|
1669
1713
|
});
|
|
1714
|
+
|
|
1715
|
+
// ---------------------------------------------------------------------------
|
|
1716
|
+
// searchDude: FTS against dude_pages_fts (integration, uses fixture DB)
|
|
1717
|
+
// ---------------------------------------------------------------------------
|
|
1718
|
+
|
|
1719
|
+
describe("searchDude", () => {
|
|
1720
|
+
test("finds pages matching query", () => {
|
|
1721
|
+
const results = searchDude("probes SNMP");
|
|
1722
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1723
|
+
expect(results[0].title).toBe("Probes");
|
|
1724
|
+
});
|
|
1725
|
+
|
|
1726
|
+
test("returns image_count for pages with images", () => {
|
|
1727
|
+
const results = searchDude("probes");
|
|
1728
|
+
expect(results[0].image_count).toBe(2);
|
|
1729
|
+
});
|
|
1730
|
+
|
|
1731
|
+
test("returns version field", () => {
|
|
1732
|
+
const results = searchDude("probes");
|
|
1733
|
+
expect(results[0].version).toBe("v6");
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
test("finds v3 pages", () => {
|
|
1737
|
+
const results = searchDude("notifications email");
|
|
1738
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1739
|
+
expect(results[0].version).toBe("v3");
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
test("AND→OR fallback finds results", () => {
|
|
1743
|
+
const results = searchDude("device discovery subnet ICMP");
|
|
1744
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
test("returns empty array for empty query", () => {
|
|
1748
|
+
expect(searchDude("")).toEqual([]);
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
test("respects limit parameter", () => {
|
|
1752
|
+
const results = searchDude("dude", 1);
|
|
1753
|
+
expect(results.length).toBeLessThanOrEqual(1);
|
|
1754
|
+
});
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
// ---------------------------------------------------------------------------
|
|
1758
|
+
// getDudePage: full page retrieval with images (integration, uses fixture DB)
|
|
1759
|
+
// ---------------------------------------------------------------------------
|
|
1760
|
+
|
|
1761
|
+
describe("getDudePage", () => {
|
|
1762
|
+
test("returns page by ID with images", () => {
|
|
1763
|
+
const page = getDudePage(1);
|
|
1764
|
+
expect(page).not.toBeNull();
|
|
1765
|
+
expect(page!.title).toBe("Probes");
|
|
1766
|
+
expect(page!.images.length).toBe(2);
|
|
1767
|
+
expect(page!.images[0].filename).toBe("Dude-probes-all.JPG");
|
|
1768
|
+
});
|
|
1769
|
+
|
|
1770
|
+
test("returns page by title", () => {
|
|
1771
|
+
const page = getDudePage("Probes");
|
|
1772
|
+
expect(page).not.toBeNull();
|
|
1773
|
+
expect(page!.id).toBe(1);
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
test("returns page by slug", () => {
|
|
1777
|
+
const page = getDudePage("Device_discovery");
|
|
1778
|
+
expect(page).not.toBeNull();
|
|
1779
|
+
expect(page!.title).toBe("Device Discovery");
|
|
1780
|
+
expect(page!.images.length).toBe(1);
|
|
1781
|
+
});
|
|
1782
|
+
|
|
1783
|
+
test("returns null for non-existent page", () => {
|
|
1784
|
+
expect(getDudePage(999)).toBeNull();
|
|
1785
|
+
expect(getDudePage("NonExistent")).toBeNull();
|
|
1786
|
+
});
|
|
1787
|
+
});
|
package/src/query.ts
CHANGED
|
@@ -615,7 +615,7 @@ export function diffCommandVersions(
|
|
|
615
615
|
pathPrefix?: string,
|
|
616
616
|
): CommandDiffResult {
|
|
617
617
|
const allVersionRows = db
|
|
618
|
-
.prepare("SELECT version FROM ros_versions")
|
|
618
|
+
.prepare("SELECT DISTINCT version FROM ros_versions")
|
|
619
619
|
.all() as Array<{ version: string }>;
|
|
620
620
|
const knownVersions = allVersionRows.map((r) => r.version).sort(compareVersions);
|
|
621
621
|
|
|
@@ -689,7 +689,7 @@ export function checkCommandVersions(
|
|
|
689
689
|
const versions = rows.map((r) => r.ros_version).sort(compareVersions);
|
|
690
690
|
|
|
691
691
|
const allVersionRows = db
|
|
692
|
-
.prepare("SELECT version FROM ros_versions")
|
|
692
|
+
.prepare("SELECT DISTINCT version FROM ros_versions")
|
|
693
693
|
.all() as Array<{ version: string }>;
|
|
694
694
|
const allVersions = allVersionRows.map((r) => r.version).sort(compareVersions);
|
|
695
695
|
const minTracked = allVersions[0] ?? null;
|
|
@@ -1490,6 +1490,97 @@ function runVideosFtsQuery(ftsQuery: string, limit: number): VideoSearchResult[]
|
|
|
1490
1490
|
|
|
1491
1491
|
// ── Current versions ──
|
|
1492
1492
|
|
|
1493
|
+
// ── Dude wiki search ──
|
|
1494
|
+
|
|
1495
|
+
export type DudeSearchResult = {
|
|
1496
|
+
id: number;
|
|
1497
|
+
title: string;
|
|
1498
|
+
path: string;
|
|
1499
|
+
version: string;
|
|
1500
|
+
url: string;
|
|
1501
|
+
word_count: number | null;
|
|
1502
|
+
image_count: number;
|
|
1503
|
+
excerpt: string;
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
export type DudeImageResult = {
|
|
1507
|
+
id: number;
|
|
1508
|
+
filename: string;
|
|
1509
|
+
alt_text: string | null;
|
|
1510
|
+
caption: string | null;
|
|
1511
|
+
local_path: string;
|
|
1512
|
+
original_url: string | null;
|
|
1513
|
+
wayback_url: string | null;
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
export type DudePageResult = {
|
|
1517
|
+
id: number;
|
|
1518
|
+
slug: string;
|
|
1519
|
+
title: string;
|
|
1520
|
+
path: string;
|
|
1521
|
+
version: string;
|
|
1522
|
+
url: string;
|
|
1523
|
+
wayback_url: string;
|
|
1524
|
+
text: string;
|
|
1525
|
+
code: string | null;
|
|
1526
|
+
last_edited: string | null;
|
|
1527
|
+
word_count: number | null;
|
|
1528
|
+
images: DudeImageResult[];
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1531
|
+
/** Search archived Dude wiki pages via FTS, with AND→OR fallback. */
|
|
1532
|
+
export function searchDude(query: string, limit = 8): DudeSearchResult[] {
|
|
1533
|
+
const terms = extractTerms(query);
|
|
1534
|
+
if (terms.length === 0) return [];
|
|
1535
|
+
|
|
1536
|
+
let ftsQuery = buildFtsQuery(terms, "AND");
|
|
1537
|
+
if (!ftsQuery) return [];
|
|
1538
|
+
let results = runDudeFtsQuery(ftsQuery, limit);
|
|
1539
|
+
|
|
1540
|
+
if (results.length === 0 && terms.length > 1) {
|
|
1541
|
+
ftsQuery = buildFtsQuery(terms, "OR");
|
|
1542
|
+
results = runDudeFtsQuery(ftsQuery, limit);
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
return results;
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
function runDudeFtsQuery(ftsQuery: string, limit: number): DudeSearchResult[] {
|
|
1549
|
+
if (!ftsQuery) return [];
|
|
1550
|
+
try {
|
|
1551
|
+
return db
|
|
1552
|
+
.prepare(
|
|
1553
|
+
`SELECT dp.id, dp.title, dp.path, dp.version, dp.url, dp.word_count,
|
|
1554
|
+
(SELECT COUNT(*) FROM dude_images di WHERE di.page_id = dp.id) as image_count,
|
|
1555
|
+
snippet(dude_pages_fts, 2, '**', '**', '...', 25) as excerpt
|
|
1556
|
+
FROM dude_pages_fts fts
|
|
1557
|
+
JOIN dude_pages dp ON dp.id = fts.rowid
|
|
1558
|
+
WHERE dude_pages_fts MATCH ?
|
|
1559
|
+
ORDER BY rank
|
|
1560
|
+
LIMIT ?`,
|
|
1561
|
+
)
|
|
1562
|
+
.all(ftsQuery, limit) as DudeSearchResult[];
|
|
1563
|
+
} catch {
|
|
1564
|
+
return [];
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/** Get a Dude wiki page by ID or title, with associated images. */
|
|
1569
|
+
export function getDudePage(idOrTitle: number | string): DudePageResult | null {
|
|
1570
|
+
const row =
|
|
1571
|
+
typeof idOrTitle === "number"
|
|
1572
|
+
? db.prepare("SELECT * FROM dude_pages WHERE id = ?").get(idOrTitle)
|
|
1573
|
+
: db.prepare("SELECT * FROM dude_pages WHERE title = ? COLLATE NOCASE OR slug = ? COLLATE NOCASE").get(idOrTitle, idOrTitle);
|
|
1574
|
+
if (!row) return null;
|
|
1575
|
+
const page = row as DudePageResult;
|
|
1576
|
+
page.images = db
|
|
1577
|
+
.prepare("SELECT id, filename, alt_text, caption, local_path, original_url, wayback_url FROM dude_images WHERE page_id = ? ORDER BY sort_order")
|
|
1578
|
+
.all(page.id) as DudeImageResult[];
|
|
1579
|
+
return page;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
// ── Current versions (live fetch) ──
|
|
1583
|
+
|
|
1493
1584
|
const VERSION_BASE_URL = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
|
|
1494
1585
|
|
|
1495
1586
|
/** Fetch current RouterOS versions from MikroTik's upgrade server. */
|
package/src/release.test.ts
CHANGED
|
@@ -202,6 +202,21 @@ describe("Makefile", () => {
|
|
|
202
202
|
expect(phonyBlock).toContain("extract-videos");
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
+
test("has extract-dude target", () => {
|
|
206
|
+
expect(makefile).toContain("extract-dude:");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("has extract-dude-from-cache target", () => {
|
|
210
|
+
expect(makefile).toContain("extract-dude-from-cache:");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("extract-dude is in PHONY", () => {
|
|
214
|
+
const phonyStart = makefile.indexOf(".PHONY:");
|
|
215
|
+
const phonyEnd = makefile.indexOf("\n\n", phonyStart);
|
|
216
|
+
const phonyBlock = makefile.slice(phonyStart, phonyEnd);
|
|
217
|
+
expect(phonyBlock).toContain("extract-dude");
|
|
218
|
+
});
|
|
219
|
+
|
|
205
220
|
test("release depends on preflight", () => {
|
|
206
221
|
expect(makefile).toMatch(/^release:.*preflight/m);
|
|
207
222
|
});
|
|
@@ -255,6 +270,11 @@ describe("release.yml", () => {
|
|
|
255
270
|
expect(src).toContain("link-commands.ts");
|
|
256
271
|
});
|
|
257
272
|
|
|
273
|
+
test("imports Dude wiki from cache", () => {
|
|
274
|
+
const src = readText(".github/workflows/release.yml");
|
|
275
|
+
expect(src).toContain("extract-dude-from-cache");
|
|
276
|
+
});
|
|
277
|
+
|
|
258
278
|
test("runs quality gate before release", () => {
|
|
259
279
|
const src = readText(".github/workflows/release.yml");
|
|
260
280
|
expect(src).toContain("bun run typecheck");
|