@tikoci/rosetta 0.4.2 → 0.4.4
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 +11 -3
- package/package.json +1 -1
- package/src/browse.ts +1234 -0
- package/src/db.ts +82 -0
- package/src/extract-devices.ts +14 -1
- package/src/extract-videos.test.ts +356 -0
- package/src/extract-videos.ts +734 -0
- package/src/mcp-http.test.ts +5 -5
- package/src/mcp.ts +61 -0
- package/src/query.test.ts +187 -1
- package/src/query.ts +51 -0
- package/src/release.test.ts +12 -0
package/src/mcp-http.test.ts
CHANGED
|
@@ -315,7 +315,7 @@ describe("HTTP transport: session lifecycle", () => {
|
|
|
315
315
|
expect(serverInfo.name).toBe("rosetta");
|
|
316
316
|
});
|
|
317
317
|
|
|
318
|
-
test("tools/list returns all
|
|
318
|
+
test("tools/list returns all 14 tools after initialization", async () => {
|
|
319
319
|
const { sessionId } = await mcpInitialize(server.url);
|
|
320
320
|
|
|
321
321
|
// Send initialized notification first (required by protocol)
|
|
@@ -326,7 +326,7 @@ describe("HTTP transport: session lifecycle", () => {
|
|
|
326
326
|
|
|
327
327
|
const result = (messages[0] as Record<string, unknown>).result as Record<string, unknown>;
|
|
328
328
|
const tools = result.tools as Array<{ name: string }>;
|
|
329
|
-
expect(tools.length).toBe(
|
|
329
|
+
expect(tools.length).toBe(14);
|
|
330
330
|
|
|
331
331
|
const toolNames = tools.map((t) => t.name).sort();
|
|
332
332
|
expect(toolNames).toContain("routeros_search");
|
|
@@ -547,8 +547,8 @@ describe("HTTP transport: multi-session", () => {
|
|
|
547
547
|
const tools1 = ((msgs1[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
548
548
|
const tools2 = ((msgs2[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
549
549
|
|
|
550
|
-
expect(tools1.length).toBe(
|
|
551
|
-
expect(tools2.length).toBe(
|
|
550
|
+
expect(tools1.length).toBe(14);
|
|
551
|
+
expect(tools2.length).toBe(14);
|
|
552
552
|
});
|
|
553
553
|
|
|
554
554
|
test("deleting one session does not affect another", async () => {
|
|
@@ -570,7 +570,7 @@ describe("HTTP transport: multi-session", () => {
|
|
|
570
570
|
// Client2 still works
|
|
571
571
|
const msgs = await mcpRequest(server.url, client2.sessionId, "tools/list", 2);
|
|
572
572
|
const tools = ((msgs[0] as Record<string, unknown>).result as Record<string, unknown>).tools as unknown[];
|
|
573
|
-
expect(tools.length).toBe(
|
|
573
|
+
expect(tools.length).toBe(14);
|
|
574
574
|
|
|
575
575
|
// Client1 is gone
|
|
576
576
|
const resp = await fetch(server.url, {
|
package/src/mcp.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* and browse the command tree.
|
|
7
7
|
*
|
|
8
8
|
* CLI flags (for compiled binary or `bun run src/mcp.ts`):
|
|
9
|
+
* browse Interactive terminal browser (REPL)
|
|
9
10
|
* --setup [--force] Download database + print MCP client config
|
|
10
11
|
* --version Print version
|
|
11
12
|
* --help Print usage
|
|
@@ -55,6 +56,7 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
55
56
|
console.log("Usage:");
|
|
56
57
|
console.log(" rosetta Start MCP server (stdio transport)");
|
|
57
58
|
console.log(" rosetta --http Start with Streamable HTTP transport");
|
|
59
|
+
console.log(" rosetta browse Interactive terminal browser");
|
|
58
60
|
console.log(" rosetta --setup Download database + print MCP client config");
|
|
59
61
|
console.log(" rosetta --setup --force Re-download database");
|
|
60
62
|
console.log(" rosetta --version Print version");
|
|
@@ -80,6 +82,13 @@ if (args.includes("--help") || args.includes("-h")) {
|
|
|
80
82
|
// Wrap in async IIFE — bun build --compile does not support top-level await
|
|
81
83
|
(async () => {
|
|
82
84
|
|
|
85
|
+
if (args[0] === "browse") {
|
|
86
|
+
// Strip "browse" from argv so browse.ts only sees flags/queries
|
|
87
|
+
process.argv.splice(2, 1);
|
|
88
|
+
await import("./browse.ts");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
83
92
|
if (args.includes("--setup")) {
|
|
84
93
|
const { runSetup } = await import("./setup.ts");
|
|
85
94
|
await runSetup(args.includes("--force"));
|
|
@@ -172,6 +181,7 @@ const {
|
|
|
172
181
|
getTestResultMeta,
|
|
173
182
|
searchPages,
|
|
174
183
|
searchProperties,
|
|
184
|
+
searchVideos,
|
|
175
185
|
} = await import("./query.ts");
|
|
176
186
|
|
|
177
187
|
initDb();
|
|
@@ -240,6 +250,7 @@ Workflow — what to do next:
|
|
|
240
250
|
→ routeros_search_properties: find specific properties mentioned in results
|
|
241
251
|
→ routeros_search_callouts: find warnings/notes about topics in results
|
|
242
252
|
→ routeros_command_tree: browse the command hierarchy for a feature
|
|
253
|
+
→ routeros_search_videos: search MikroTik YouTube video transcripts for tutorials and demos
|
|
243
254
|
|
|
244
255
|
Tips:
|
|
245
256
|
- Use specific technical terms: "DHCP relay agent" not "how to set up DHCP"
|
|
@@ -664,6 +675,56 @@ Coverage depends on which versions were extracted — typically matches ros_vers
|
|
|
664
675
|
},
|
|
665
676
|
);
|
|
666
677
|
|
|
678
|
+
// ---- routeros_search_videos ----
|
|
679
|
+
|
|
680
|
+
server.registerTool(
|
|
681
|
+
"routeros_search_videos",
|
|
682
|
+
{
|
|
683
|
+
description: `Search MikroTik YouTube video transcripts for RouterOS topics.
|
|
684
|
+
|
|
685
|
+
Searches chapter-level transcript segments from official MikroTik YouTube videos.
|
|
686
|
+
Returns matching segments with video title, URL, chapter name, and an excerpt.
|
|
687
|
+
Auto-caption quality varies — short config snippets may not appear verbatim.
|
|
688
|
+
|
|
689
|
+
MUM conference talks are excluded (those are long, off-topic lectures).
|
|
690
|
+
Results include the video URL and start timestamp for direct chapter linking.
|
|
691
|
+
|
|
692
|
+
Useful for: finding tutorial walkthroughs, feature announcements, demo configs,
|
|
693
|
+
and explanations that complement the text documentation.
|
|
694
|
+
|
|
695
|
+
→ routeros_search: search official text documentation (more precise for property names)
|
|
696
|
+
→ routeros_get_page: read full documentation page for a topic
|
|
697
|
+
→ routeros_search_callouts: find Warnings/Notes embedded in documentation`,
|
|
698
|
+
inputSchema: {
|
|
699
|
+
query: z.string().describe("Topic to search for in video transcripts (e.g., 'VLAN trunking', 'BGP route reflection')"),
|
|
700
|
+
limit: z
|
|
701
|
+
.number()
|
|
702
|
+
.int()
|
|
703
|
+
.min(1)
|
|
704
|
+
.max(20)
|
|
705
|
+
.default(5)
|
|
706
|
+
.optional()
|
|
707
|
+
.describe("Max results (1–20, default 5)"),
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
async ({ query, limit }) => {
|
|
711
|
+
const results = searchVideos(query, limit ?? 5);
|
|
712
|
+
if (results.length === 0) {
|
|
713
|
+
return {
|
|
714
|
+
content: [
|
|
715
|
+
{
|
|
716
|
+
type: "text",
|
|
717
|
+
text: `No video transcript results for: "${query}"\n\nTry:\n- Broader or simpler search terms\n- routeros_search for official documentation\n- routeros_search_callouts for Notes and Warnings in docs`,
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
|
|
724
|
+
};
|
|
725
|
+
},
|
|
726
|
+
);
|
|
727
|
+
|
|
667
728
|
// ---- routeros_command_version_check ----
|
|
668
729
|
|
|
669
730
|
server.registerTool(
|
package/src/query.test.ts
CHANGED
|
@@ -31,8 +31,10 @@ const {
|
|
|
31
31
|
searchDeviceTests,
|
|
32
32
|
getTestResultMeta,
|
|
33
33
|
normalizeDeviceQuery,
|
|
34
|
+
searchVideos,
|
|
34
35
|
} = await import("./query.ts");
|
|
35
36
|
const { parseChangelog } = await import("./extract-changelogs.ts");
|
|
37
|
+
const { parseVtt, segmentTranscript } = await import("./extract-videos.ts");
|
|
36
38
|
|
|
37
39
|
// ---------------------------------------------------------------------------
|
|
38
40
|
// Fixtures: one "DHCP Server" page + one "Firewall Filter" page
|
|
@@ -320,6 +322,30 @@ beforeAll(() => {
|
|
|
320
322
|
VALUES ('7.22', '2026-Mar-09 10:38', 'bridge', 0, 'added local and static MAC synchronization for MLAG', 2)`);
|
|
321
323
|
db.run(`INSERT INTO changelogs (version, released, category, is_breaking, description, sort_order)
|
|
322
324
|
VALUES ('7.22.1', '2026-Apr-01 09:00', 'wifi', 0, 'fixed channel switching for MediaTek access points', 0)`);
|
|
325
|
+
|
|
326
|
+
// Video and transcript fixtures for searchVideos tests
|
|
327
|
+
db.run(`INSERT INTO videos
|
|
328
|
+
(id, video_id, title, description, channel, upload_date, duration_s, url, has_chapters)
|
|
329
|
+
VALUES
|
|
330
|
+
(1, 'abc123', 'RouterOS VLAN Tutorial', 'How to configure VLANs on MikroTik', 'MikroTik', '20240101', 600,
|
|
331
|
+
'https://www.youtube.com/watch?v=abc123', 1)`);
|
|
332
|
+
db.run(`INSERT INTO videos
|
|
333
|
+
(id, video_id, title, description, channel, upload_date, duration_s, url, has_chapters)
|
|
334
|
+
VALUES
|
|
335
|
+
(2, 'def456', 'BGP Routing with RouterOS', 'Advanced BGP configuration', 'MikroTik', '20240201', 900,
|
|
336
|
+
'https://www.youtube.com/watch?v=def456', 0)`);
|
|
337
|
+
db.run(`INSERT INTO video_segments
|
|
338
|
+
(id, video_id, chapter_title, start_s, end_s, transcript, sort_order)
|
|
339
|
+
VALUES
|
|
340
|
+
(1, 1, 'Introduction', 0, 120, 'Welcome to the VLAN tutorial. In this video we cover VLAN trunking on MikroTik bridge.', 0)`);
|
|
341
|
+
db.run(`INSERT INTO video_segments
|
|
342
|
+
(id, video_id, chapter_title, start_s, end_s, transcript, sort_order)
|
|
343
|
+
VALUES
|
|
344
|
+
(2, 1, 'Bridge VLAN configuration', 120, 600, 'To set up bridge VLAN filtering enable vlan-filtering on the bridge interface.', 1)`);
|
|
345
|
+
db.run(`INSERT INTO video_segments
|
|
346
|
+
(id, video_id, chapter_title, start_s, end_s, transcript, sort_order)
|
|
347
|
+
VALUES
|
|
348
|
+
(3, 2, NULL, 0, NULL, 'BGP peering and route reflection allow scalable routing in large networks.', 0)`);
|
|
323
349
|
});
|
|
324
350
|
|
|
325
351
|
// ---------------------------------------------------------------------------
|
|
@@ -1381,6 +1407,7 @@ describe("schema", () => {
|
|
|
1381
1407
|
"pages", "properties", "callouts", "sections",
|
|
1382
1408
|
"commands", "command_versions", "ros_versions",
|
|
1383
1409
|
"devices", "device_test_results", "changelogs", "schema_migrations",
|
|
1410
|
+
"videos", "video_segments",
|
|
1384
1411
|
];
|
|
1385
1412
|
for (const table of expected) {
|
|
1386
1413
|
expect(names).toContain(table);
|
|
@@ -1389,7 +1416,7 @@ describe("schema", () => {
|
|
|
1389
1416
|
|
|
1390
1417
|
test("all FTS5 virtual tables exist", () => {
|
|
1391
1418
|
const names = tableNames();
|
|
1392
|
-
const expected = ["pages_fts", "properties_fts", "callouts_fts", "devices_fts", "changelogs_fts"];
|
|
1419
|
+
const expected = ["pages_fts", "properties_fts", "callouts_fts", "devices_fts", "changelogs_fts", "videos_fts", "video_segments_fts"];
|
|
1393
1420
|
for (const fts of expected) {
|
|
1394
1421
|
expect(names).toContain(fts);
|
|
1395
1422
|
}
|
|
@@ -1430,6 +1457,20 @@ describe("schema", () => {
|
|
|
1430
1457
|
expect(triggers).toContain("changelogs_au");
|
|
1431
1458
|
});
|
|
1432
1459
|
|
|
1460
|
+
test("content-sync triggers exist for videos", () => {
|
|
1461
|
+
const triggers = triggerNames();
|
|
1462
|
+
expect(triggers).toContain("videos_ai");
|
|
1463
|
+
expect(triggers).toContain("videos_ad");
|
|
1464
|
+
expect(triggers).toContain("videos_au");
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
test("content-sync triggers exist for video_segments", () => {
|
|
1468
|
+
const triggers = triggerNames();
|
|
1469
|
+
expect(triggers).toContain("video_segs_ai");
|
|
1470
|
+
expect(triggers).toContain("video_segs_ad");
|
|
1471
|
+
expect(triggers).toContain("video_segs_au");
|
|
1472
|
+
});
|
|
1473
|
+
|
|
1433
1474
|
test("PRAGMA user_version matches SCHEMA_VERSION", () => {
|
|
1434
1475
|
const result = checkSchemaVersion();
|
|
1435
1476
|
expect(result.ok).toBe(true);
|
|
@@ -1450,3 +1491,148 @@ describe("getDbStats", () => {
|
|
|
1450
1491
|
expect(stats.ros_version_max).toBe("7.22");
|
|
1451
1492
|
});
|
|
1452
1493
|
});
|
|
1494
|
+
|
|
1495
|
+
// ---------------------------------------------------------------------------
|
|
1496
|
+
// parseVtt: WebVTT parsing (pure function, no DB)
|
|
1497
|
+
// ---------------------------------------------------------------------------
|
|
1498
|
+
|
|
1499
|
+
describe("parseVtt", () => {
|
|
1500
|
+
const SIMPLE_VTT = `WEBVTT
|
|
1501
|
+
Kind: captions
|
|
1502
|
+
Language: en
|
|
1503
|
+
|
|
1504
|
+
00:00:01.000 --> 00:00:04.000
|
|
1505
|
+
Hello and welcome to the VLAN tutorial.
|
|
1506
|
+
|
|
1507
|
+
00:00:04.000 --> 00:00:07.000
|
|
1508
|
+
Hello and welcome to the VLAN tutorial. Today we will cover trunking.
|
|
1509
|
+
|
|
1510
|
+
00:00:07.000 --> 00:00:10.000
|
|
1511
|
+
Today we will cover trunking. Let us begin.
|
|
1512
|
+
`;
|
|
1513
|
+
|
|
1514
|
+
test("parses cue start times correctly", () => {
|
|
1515
|
+
const cues = parseVtt(SIMPLE_VTT);
|
|
1516
|
+
expect(cues.length).toBeGreaterThan(0);
|
|
1517
|
+
expect(cues[0].start_s).toBe(1);
|
|
1518
|
+
});
|
|
1519
|
+
|
|
1520
|
+
test("deduplicates overlapping auto-caption cues", () => {
|
|
1521
|
+
// Second cue text is suffix of third — only unique segments should survive
|
|
1522
|
+
const cues = parseVtt(SIMPLE_VTT);
|
|
1523
|
+
// All cue texts should be unique (no exact duplicates)
|
|
1524
|
+
const texts = cues.map((c) => c.text);
|
|
1525
|
+
const unique = new Set(texts);
|
|
1526
|
+
expect(unique.size).toBe(texts.length);
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
test("returns empty array for empty input", () => {
|
|
1530
|
+
expect(parseVtt("")).toEqual([]);
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
test("returns empty array for header-only VTT", () => {
|
|
1534
|
+
expect(parseVtt("WEBVTT\nKind: captions\n")).toEqual([]);
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
test("strips HTML tags from cue text", () => {
|
|
1538
|
+
const vtt = `WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n<b>Bold text</b> and <i>italic</i>.\n`;
|
|
1539
|
+
const cues = parseVtt(vtt);
|
|
1540
|
+
expect(cues[0].text).not.toContain("<b>");
|
|
1541
|
+
expect(cues[0].text).toContain("Bold text");
|
|
1542
|
+
});
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
// ---------------------------------------------------------------------------
|
|
1546
|
+
// segmentTranscript: chapter grouping (pure function, no DB)
|
|
1547
|
+
// ---------------------------------------------------------------------------
|
|
1548
|
+
|
|
1549
|
+
describe("segmentTranscript", () => {
|
|
1550
|
+
const cues = [
|
|
1551
|
+
{ start_s: 0, text: "Introduction begins now." },
|
|
1552
|
+
{ start_s: 10, text: "This covers the basics." },
|
|
1553
|
+
{ start_s: 60, text: "Chapter two starts here." },
|
|
1554
|
+
{ start_s: 90, text: "Configuration details follow." },
|
|
1555
|
+
];
|
|
1556
|
+
|
|
1557
|
+
test("single segment when no chapters provided", () => {
|
|
1558
|
+
const segments = segmentTranscript(cues);
|
|
1559
|
+
expect(segments).toHaveLength(1);
|
|
1560
|
+
expect(segments[0].chapter_title).toBeNull();
|
|
1561
|
+
expect(segments[0].start_s).toBe(0);
|
|
1562
|
+
expect(segments[0].end_s).toBeNull();
|
|
1563
|
+
expect(segments[0].transcript).toContain("Introduction begins now.");
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
test("splits by chapter boundaries", () => {
|
|
1567
|
+
const chapters = [
|
|
1568
|
+
{ title: "Introduction", start_time: 0, end_time: 60 },
|
|
1569
|
+
{ title: "Configuration", start_time: 60, end_time: 120 },
|
|
1570
|
+
];
|
|
1571
|
+
const segments = segmentTranscript(cues, chapters);
|
|
1572
|
+
expect(segments).toHaveLength(2);
|
|
1573
|
+
expect(segments[0].chapter_title).toBe("Introduction");
|
|
1574
|
+
expect(segments[1].chapter_title).toBe("Configuration");
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
test("cues are assigned to correct chapters", () => {
|
|
1578
|
+
const chapters = [
|
|
1579
|
+
{ title: "Introduction", start_time: 0, end_time: 60 },
|
|
1580
|
+
{ title: "Configuration", start_time: 60, end_time: 120 },
|
|
1581
|
+
];
|
|
1582
|
+
const segments = segmentTranscript(cues, chapters);
|
|
1583
|
+
expect(segments[0].transcript).toContain("Introduction begins now.");
|
|
1584
|
+
expect(segments[0].transcript).not.toContain("Chapter two");
|
|
1585
|
+
expect(segments[1].transcript).toContain("Chapter two starts here.");
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
test("chapter start_s and end_s are set correctly", () => {
|
|
1589
|
+
const chapters = [
|
|
1590
|
+
{ title: "Intro", start_time: 0, end_time: 60 },
|
|
1591
|
+
{ title: "Body", start_time: 60, end_time: 300 },
|
|
1592
|
+
];
|
|
1593
|
+
const segments = segmentTranscript(cues, chapters);
|
|
1594
|
+
expect(segments[0].start_s).toBe(0);
|
|
1595
|
+
expect(segments[0].end_s).toBe(60); // next chapter start
|
|
1596
|
+
expect(segments[1].start_s).toBe(60);
|
|
1597
|
+
expect(segments[1].end_s).toBe(300);
|
|
1598
|
+
});
|
|
1599
|
+
});
|
|
1600
|
+
|
|
1601
|
+
// ---------------------------------------------------------------------------
|
|
1602
|
+
// searchVideos: FTS against video_segments_fts (integration, uses fixture DB)
|
|
1603
|
+
// ---------------------------------------------------------------------------
|
|
1604
|
+
|
|
1605
|
+
describe("searchVideos", () => {
|
|
1606
|
+
test("finds segments matching query", () => {
|
|
1607
|
+
const results = searchVideos("VLAN trunking bridge");
|
|
1608
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1609
|
+
expect(results[0].title).toBe("RouterOS VLAN Tutorial");
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
test("returns video_id and url", () => {
|
|
1613
|
+
const results = searchVideos("VLAN");
|
|
1614
|
+
expect(results[0].video_id).toBe("abc123");
|
|
1615
|
+
expect(results[0].url).toContain("youtube.com");
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
test("returns chapter_title when available", () => {
|
|
1619
|
+
const results = searchVideos("vlan filtering bridge");
|
|
1620
|
+
const chapterResult = results.find((r) => r.chapter_title !== null);
|
|
1621
|
+
expect(chapterResult).toBeDefined();
|
|
1622
|
+
});
|
|
1623
|
+
|
|
1624
|
+
test("returns null chapter_title for no-chapter video", () => {
|
|
1625
|
+
const results = searchVideos("BGP peering route reflection");
|
|
1626
|
+
expect(results.length).toBeGreaterThan(0);
|
|
1627
|
+
expect(results[0].chapter_title).toBeNull();
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
test("returns empty array for empty query", () => {
|
|
1631
|
+
expect(searchVideos("")).toEqual([]);
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
test("respects limit parameter", () => {
|
|
1635
|
+
const results = searchVideos("RouterOS", 1);
|
|
1636
|
+
expect(results.length).toBeLessThanOrEqual(1);
|
|
1637
|
+
});
|
|
1638
|
+
});
|
package/src/query.ts
CHANGED
|
@@ -1429,6 +1429,57 @@ function runChangelogFtsQuery(
|
|
|
1429
1429
|
}
|
|
1430
1430
|
}
|
|
1431
1431
|
|
|
1432
|
+
// ── Video/transcript search ──
|
|
1433
|
+
|
|
1434
|
+
export type VideoSearchResult = {
|
|
1435
|
+
video_id: string;
|
|
1436
|
+
title: string;
|
|
1437
|
+
url: string;
|
|
1438
|
+
upload_date: string | null;
|
|
1439
|
+
chapter_title: string | null;
|
|
1440
|
+
start_s: number;
|
|
1441
|
+
excerpt: string;
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1444
|
+
/** Search YouTube video transcripts via FTS, joining segment → video metadata. */
|
|
1445
|
+
export function searchVideos(query: string, limit = 5): VideoSearchResult[] {
|
|
1446
|
+
const terms = extractTerms(query);
|
|
1447
|
+
if (terms.length === 0) return [];
|
|
1448
|
+
|
|
1449
|
+
let ftsQuery = buildFtsQuery(terms, "AND");
|
|
1450
|
+
if (!ftsQuery) return [];
|
|
1451
|
+
let results = runVideosFtsQuery(ftsQuery, limit);
|
|
1452
|
+
|
|
1453
|
+
// Fallback to OR if AND returns nothing and we have multiple terms
|
|
1454
|
+
if (results.length === 0 && terms.length > 1) {
|
|
1455
|
+
ftsQuery = buildFtsQuery(terms, "OR");
|
|
1456
|
+
results = runVideosFtsQuery(ftsQuery, limit);
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
return results;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function runVideosFtsQuery(ftsQuery: string, limit: number): VideoSearchResult[] {
|
|
1463
|
+
if (!ftsQuery) return [];
|
|
1464
|
+
try {
|
|
1465
|
+
return db
|
|
1466
|
+
.prepare(
|
|
1467
|
+
`SELECT v.video_id, v.title, v.url, v.upload_date,
|
|
1468
|
+
vs.chapter_title, vs.start_s,
|
|
1469
|
+
snippet(video_segments_fts, 1, '**', '**', '...', 25) as excerpt
|
|
1470
|
+
FROM video_segments_fts fts
|
|
1471
|
+
JOIN video_segments vs ON vs.id = fts.rowid
|
|
1472
|
+
JOIN videos v ON v.id = vs.video_id
|
|
1473
|
+
WHERE video_segments_fts MATCH ?
|
|
1474
|
+
ORDER BY rank
|
|
1475
|
+
LIMIT ?`,
|
|
1476
|
+
)
|
|
1477
|
+
.all(ftsQuery, limit) as VideoSearchResult[];
|
|
1478
|
+
} catch {
|
|
1479
|
+
return [];
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1432
1483
|
// ── Current versions ──
|
|
1433
1484
|
|
|
1434
1485
|
const VERSION_BASE_URL = "https://upgrade.mikrotik.com/routeros/NEWESTa7";
|
package/src/release.test.ts
CHANGED
|
@@ -190,6 +190,18 @@ describe("Makefile", () => {
|
|
|
190
190
|
expect(makefile).toContain("release:");
|
|
191
191
|
});
|
|
192
192
|
|
|
193
|
+
test("has extract-videos target", () => {
|
|
194
|
+
expect(makefile).toContain("extract-videos:");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("extract-videos is in PHONY", () => {
|
|
198
|
+
// PHONY uses line continuation; check block before first blank line after .PHONY
|
|
199
|
+
const phonyStart = makefile.indexOf(".PHONY:");
|
|
200
|
+
const phonyEnd = makefile.indexOf("\n\n", phonyStart);
|
|
201
|
+
const phonyBlock = makefile.slice(phonyStart, phonyEnd);
|
|
202
|
+
expect(phonyBlock).toContain("extract-videos");
|
|
203
|
+
});
|
|
204
|
+
|
|
193
205
|
test("release depends on preflight", () => {
|
|
194
206
|
expect(makefile).toMatch(/^release:.*preflight/m);
|
|
195
207
|
});
|