@tikoci/rosetta 0.5.1 → 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/src/db.ts CHANGED
@@ -21,6 +21,9 @@
21
21
  * videos_fts — FTS5 over title, description
22
22
  * video_segments — transcript segments (one per chapter, or full video if no chapters)
23
23
  * video_segments_fts — FTS5 over chapter_title, transcript
24
+ * dude_pages — The Dude documentation pages (archived from wiki.mikrotik.com via Wayback Machine)
25
+ * dude_pages_fts — FTS5 over title, path, text, code
26
+ * dude_images — screenshot images from The Dude wiki pages
24
27
  */
25
28
 
26
29
  import sqlite from "bun:sqlite";
@@ -205,17 +208,83 @@ export function initDb() {
205
208
  db.run(`CREATE INDEX IF NOT EXISTS idx_commands_type ON commands(type);`);
206
209
 
207
210
  // -- Command version tracking --
211
+ //
212
+ // ros_versions is keyed on (version, arch) — restraml emits per-arch
213
+ // deep-inspect.<arch>.json (x86 vs arm64; arm64 carries ~1K extra nodes
214
+ // for wifi-qcom etc.). _meta fields capture provenance from the inspect
215
+ // file (generatedAt, crashPaths, completionStats). _attrs is a JSON
216
+ // catch-all for forward-compat: anything restraml later emits lands here
217
+ // first and gets promoted to a column once shape is stable.
218
+ //
219
+ // command_versions intentionally has no FK to ros_versions — the composite
220
+ // PK on the parent makes a single-column FK invalid, and command_versions
221
+ // is slated for replacement by schema_node_presence in the upcoming
222
+ // schema_nodes refactor (BACKLOG.md "Multi-arch schema import").
223
+
224
+ // Migration: legacy ros_versions had `version TEXT PRIMARY KEY` with no
225
+ // arch column. Detect via PRAGMA table_info and rebuild both tables in
226
+ // place — the FK on command_versions.ros_version requires the dance.
227
+ // Idempotent: only fires when arch column is missing.
228
+ {
229
+ const rvCols = db.prepare("PRAGMA table_info(ros_versions)").all() as Array<{ name: string }>;
230
+ if (rvCols.length > 0 && !rvCols.some((c) => c.name === "arch")) {
231
+ db.run("PRAGMA foreign_keys=OFF;");
232
+ db.run("BEGIN;");
233
+ try {
234
+ db.run(`CREATE TABLE ros_versions_new (
235
+ version TEXT NOT NULL,
236
+ arch TEXT NOT NULL DEFAULT 'x86',
237
+ channel TEXT,
238
+ extra_packages INTEGER NOT NULL DEFAULT 0,
239
+ extracted_at TEXT NOT NULL,
240
+ generated_at TEXT,
241
+ crash_paths_tested TEXT,
242
+ crash_paths_crashed TEXT,
243
+ completion_stats TEXT,
244
+ source_url TEXT,
245
+ _attrs TEXT,
246
+ PRIMARY KEY (version, arch)
247
+ );`);
248
+ db.run(`INSERT INTO ros_versions_new (version, arch, channel, extra_packages, extracted_at)
249
+ SELECT version, 'x86', channel, extra_packages, extracted_at FROM ros_versions;`);
250
+ db.run("DROP TABLE ros_versions;");
251
+ db.run("ALTER TABLE ros_versions_new RENAME TO ros_versions;");
252
+
253
+ db.run(`CREATE TABLE command_versions_new (
254
+ command_path TEXT NOT NULL,
255
+ ros_version TEXT NOT NULL,
256
+ PRIMARY KEY (command_path, ros_version)
257
+ );`);
258
+ db.run("INSERT INTO command_versions_new SELECT command_path, ros_version FROM command_versions;");
259
+ db.run("DROP TABLE command_versions;");
260
+ db.run("ALTER TABLE command_versions_new RENAME TO command_versions;");
261
+ db.run("COMMIT;");
262
+ } catch (e) {
263
+ db.run("ROLLBACK;");
264
+ throw e;
265
+ }
266
+ db.run("PRAGMA foreign_keys=ON;");
267
+ }
268
+ }
208
269
 
209
270
  db.run(`CREATE TABLE IF NOT EXISTS ros_versions (
210
- version TEXT PRIMARY KEY,
211
- channel TEXT,
212
- extra_packages INTEGER NOT NULL DEFAULT 0,
213
- extracted_at TEXT NOT NULL
271
+ version TEXT NOT NULL,
272
+ arch TEXT NOT NULL DEFAULT 'x86',
273
+ channel TEXT,
274
+ extra_packages INTEGER NOT NULL DEFAULT 0,
275
+ extracted_at TEXT NOT NULL,
276
+ generated_at TEXT,
277
+ crash_paths_tested TEXT,
278
+ crash_paths_crashed TEXT,
279
+ completion_stats TEXT,
280
+ source_url TEXT,
281
+ _attrs TEXT,
282
+ PRIMARY KEY (version, arch)
214
283
  );`);
215
284
 
216
285
  db.run(`CREATE TABLE IF NOT EXISTS command_versions (
217
286
  command_path TEXT NOT NULL,
218
- ros_version TEXT NOT NULL REFERENCES ros_versions(version),
287
+ ros_version TEXT NOT NULL,
219
288
  PRIMARY KEY (command_path, ros_version)
220
289
  );`);
221
290
 
@@ -426,6 +495,58 @@ export function initDb() {
426
495
  END;`);
427
496
 
428
497
  db.run(`CREATE INDEX IF NOT EXISTS idx_video_segs_video ON video_segments(video_id);`);
498
+
499
+ // -- The Dude documentation (archived from MikroTik wiki via Wayback Machine) --
500
+
501
+ db.run(`CREATE TABLE IF NOT EXISTS dude_pages (
502
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
503
+ slug TEXT NOT NULL UNIQUE,
504
+ title TEXT NOT NULL,
505
+ path TEXT NOT NULL,
506
+ version TEXT NOT NULL DEFAULT 'v6',
507
+ url TEXT NOT NULL,
508
+ wayback_url TEXT NOT NULL,
509
+ text TEXT NOT NULL,
510
+ code TEXT,
511
+ last_edited TEXT,
512
+ word_count INTEGER
513
+ );`);
514
+
515
+ db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS dude_pages_fts USING fts5(
516
+ title, path, text, code,
517
+ content=dude_pages,
518
+ content_rowid=id,
519
+ tokenize='porter unicode61'
520
+ );`);
521
+
522
+ db.run(`CREATE TRIGGER IF NOT EXISTS dude_pages_ai AFTER INSERT ON dude_pages BEGIN
523
+ INSERT INTO dude_pages_fts(rowid, title, path, text, code)
524
+ VALUES (new.id, new.title, new.path, new.text, new.code);
525
+ END;`);
526
+ db.run(`CREATE TRIGGER IF NOT EXISTS dude_pages_ad AFTER DELETE ON dude_pages BEGIN
527
+ INSERT INTO dude_pages_fts(dude_pages_fts, rowid, title, path, text, code)
528
+ VALUES('delete', old.id, old.title, old.path, old.text, old.code);
529
+ END;`);
530
+ db.run(`CREATE TRIGGER IF NOT EXISTS dude_pages_au AFTER UPDATE ON dude_pages BEGIN
531
+ INSERT INTO dude_pages_fts(dude_pages_fts, rowid, title, path, text, code)
532
+ VALUES('delete', old.id, old.title, old.path, old.text, old.code);
533
+ INSERT INTO dude_pages_fts(rowid, title, path, text, code)
534
+ VALUES (new.id, new.title, new.path, new.text, new.code);
535
+ END;`);
536
+
537
+ db.run(`CREATE TABLE IF NOT EXISTS dude_images (
538
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
539
+ page_id INTEGER NOT NULL REFERENCES dude_pages(id),
540
+ filename TEXT NOT NULL,
541
+ alt_text TEXT,
542
+ caption TEXT,
543
+ local_path TEXT NOT NULL,
544
+ original_url TEXT,
545
+ wayback_url TEXT,
546
+ sort_order INTEGER NOT NULL
547
+ );`);
548
+
549
+ db.run(`CREATE INDEX IF NOT EXISTS idx_dude_images_page ON dude_images(page_id);`);
429
550
  }
430
551
 
431
552
  /**
@@ -441,8 +562,24 @@ export function checkSchemaVersion(): { ok: boolean; actual: number; expected: n
441
562
  export function getDbStats() {
442
563
  const count = (sql: string) =>
443
564
  Number((db.prepare(sql).get() as { c: number }).c ?? 0);
565
+ const dbSizeBytes = (() => {
566
+ try {
567
+ return Bun.file(DB_PATH).size;
568
+ } catch {
569
+ return null;
570
+ }
571
+ })();
572
+ const schemaVersion = (() => {
573
+ try {
574
+ return (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version;
575
+ } catch {
576
+ return null;
577
+ }
578
+ })();
444
579
  return {
445
580
  db_path: DB_PATH,
581
+ db_size_bytes: dbSizeBytes,
582
+ schema_version: schemaVersion,
446
583
  pages: count("SELECT COUNT(*) AS c FROM pages"),
447
584
  sections: count("SELECT COUNT(*) AS c FROM sections"),
448
585
  properties: count("SELECT COUNT(*) AS c FROM properties"),
@@ -454,12 +591,14 @@ export function getDbStats() {
454
591
  devices_with_tests: count("SELECT COUNT(DISTINCT device_id) AS c FROM device_test_results"),
455
592
  changelogs: count("SELECT COUNT(*) AS c FROM changelogs"),
456
593
  changelog_versions: count("SELECT COUNT(DISTINCT version) AS c FROM changelogs"),
457
- ros_versions: count("SELECT COUNT(*) AS c FROM ros_versions"),
594
+ ros_versions: count("SELECT COUNT(DISTINCT version) AS c FROM ros_versions"),
458
595
  videos: count("SELECT COUNT(*) AS c FROM videos"),
459
596
  video_segments: count("SELECT COUNT(*) AS c FROM video_segments"),
597
+ dude_pages: count("SELECT COUNT(*) AS c FROM dude_pages"),
598
+ dude_images: count("SELECT COUNT(*) AS c FROM dude_images"),
460
599
  ...(() => {
461
600
  // Semantic version sort — SQL MIN/MAX is lexicographic ("7.10" < "7.9")
462
- const versions = (db.prepare("SELECT version FROM ros_versions").all() as Array<{ version: string }>).map((r) => r.version);
601
+ const versions = (db.prepare("SELECT DISTINCT version FROM ros_versions").all() as Array<{ version: string }>).map((r) => r.version);
463
602
  if (versions.length === 0) return { ros_version_min: null, ros_version_max: null };
464
603
  const norm = (v: string) => {
465
604
  const clean = v.replace(/beta\d*/, "").replace(/rc\d*/, "");
@@ -1,18 +1,24 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * extract-commands.ts — Load RouterOS command tree from inspect.json into SQLite.
3
+ * extract-commands.ts — Load RouterOS command tree from inspect.json (or
4
+ * deep-inspect.<arch>.json) into SQLite.
4
5
  *
5
6
  * Walks the nested JSON tree and flattens it into a `commands` table with:
6
7
  * path, name, type (dir|cmd|arg), parent_path, description
7
8
  *
8
- * Also populates command_versions for version tracking.
9
+ * Also populates command_versions for version tracking, and ros_versions
10
+ * with provenance (arch, _meta.generatedAt, crashPaths, completionStats)
11
+ * when the source file is a deep-inspect.json (i.e. has a `_meta` envelope).
9
12
  *
10
13
  * Usage:
11
- * bun run src/extract-commands.ts [inspect.json-path-or-url] [--version=7.22] [--channel=stable] [--extra]
12
- * bun run src/extract-commands.ts --accumulate [inspect.json-path] [--version=X]
14
+ * bun run src/extract-commands.ts [path-or-url] [--version=7.22] [--channel=stable] [--extra] [--arch=x86|arm64]
15
+ * bun run src/extract-commands.ts --accumulate [path] [--version=X]
13
16
  *
14
17
  * In default mode: replaces commands table and sets as primary version.
15
18
  * With --accumulate: only adds to command_versions, does not touch commands table.
19
+ *
20
+ * --arch defaults to x86 (back-compat for legacy single-arch inspect.json).
21
+ * deep-inspect.x86.json / deep-inspect.arm64.json filenames auto-derive arch.
16
22
  */
17
23
 
18
24
  import { db, initDb } from "./db.ts";
@@ -33,6 +39,13 @@ const flagArgs = Object.fromEntries(
33
39
  const DEFAULT_INSPECT_URL = `${RESTRAML_PAGES_URL}/7.22.1/extra/inspect.json`;
34
40
  const INSPECT_SOURCE = positional[0] || DEFAULT_INSPECT_URL;
35
41
 
42
+ function deriveArch(filepath: string, flag: string | undefined): "x86" | "arm64" {
43
+ if (flag === "x86" || flag === "arm64") return flag;
44
+ if (/deep-inspect\.arm64\b/.test(filepath)) return "arm64";
45
+ if (/deep-inspect\.x86\b/.test(filepath)) return "x86";
46
+ return "x86";
47
+ }
48
+
36
49
  // Derive version from path if not explicitly set
37
50
  function deriveVersion(filepath: string): string {
38
51
  const match = filepath.match(/\/(\d+\.\d+(?:\.\d+)?(?:beta\d+|rc\d+)?)\//);
@@ -48,11 +61,30 @@ function deriveChannel(version: string): string {
48
61
 
49
62
  const version = flagArgs.version || deriveVersion(INSPECT_SOURCE);
50
63
  const channel = flagArgs.channel || deriveChannel(version);
64
+ const arch = deriveArch(INSPECT_SOURCE, flagArgs.arch);
51
65
 
52
66
  console.log(`Loading inspect.json from ${INSPECT_SOURCE}...`);
53
- console.log(`Version: ${version}, Channel: ${channel}, Extra: ${extraPackages}, Accumulate: ${accumulate}`);
67
+ console.log(`Version: ${version}, Channel: ${channel}, Arch: ${arch}, Extra: ${extraPackages}, Accumulate: ${accumulate}`);
54
68
  const inspectData = await loadJson<Record<string, unknown>>(INSPECT_SOURCE);
55
69
 
70
+ // deep-inspect.json files carry a `_meta` envelope; vanilla inspect.json does not.
71
+ interface DeepInspectMeta {
72
+ version?: string;
73
+ generatedAt?: string;
74
+ crashPathsTested?: string[];
75
+ crashPathsCrashed?: string[];
76
+ completionStats?: Record<string, unknown>;
77
+ }
78
+ const meta = (inspectData._meta && typeof inspectData._meta === "object")
79
+ ? (inspectData._meta as DeepInspectMeta)
80
+ : null;
81
+ if (meta) {
82
+ console.log(` _meta.version=${meta.version} generatedAt=${meta.generatedAt}`);
83
+ if (meta.completionStats) {
84
+ console.log(` completionStats=${JSON.stringify(meta.completionStats)}`);
85
+ }
86
+ }
87
+
56
88
  interface CommandRow {
57
89
  path: string;
58
90
  name: string;
@@ -65,7 +97,7 @@ const rows: CommandRow[] = [];
65
97
 
66
98
  function walk(obj: Record<string, unknown>, parentPath: string) {
67
99
  for (const [key, value] of Object.entries(obj)) {
68
- if (key === "_type" || key === "desc") continue;
100
+ if (key === "_type" || key === "desc" || key === "_meta") continue;
69
101
  if (typeof value !== "object" || value === null) continue;
70
102
 
71
103
  const node = value as Record<string, unknown>;
@@ -104,11 +136,24 @@ console.log(` dirs: ${dirs}, cmds: ${cmds}, args: ${args}`);
104
136
  // Initialize DB and insert
105
137
  initDb();
106
138
 
107
- // Register this version
139
+ // Register this version (per-arch row). _meta fields are populated when the
140
+ // source is a deep-inspect.json; legacy inspect.json leaves them NULL.
108
141
  db.run(
109
- `INSERT OR REPLACE INTO ros_versions (version, channel, extra_packages, extracted_at)
110
- VALUES (?, ?, ?, datetime('now'))`,
111
- [version, channel, extraPackages ? 1 : 0],
142
+ `INSERT OR REPLACE INTO ros_versions
143
+ (version, arch, channel, extra_packages, extracted_at,
144
+ generated_at, crash_paths_tested, crash_paths_crashed, completion_stats, source_url)
145
+ VALUES (?, ?, ?, ?, datetime('now'), ?, ?, ?, ?, ?)`,
146
+ [
147
+ version,
148
+ arch,
149
+ channel,
150
+ extraPackages ? 1 : 0,
151
+ meta?.generatedAt ?? null,
152
+ meta?.crashPathsTested ? JSON.stringify(meta.crashPathsTested) : null,
153
+ meta?.crashPathsCrashed ? JSON.stringify(meta.crashPathsCrashed) : null,
154
+ meta?.completionStats ? JSON.stringify(meta.completionStats) : null,
155
+ INSPECT_SOURCE,
156
+ ],
112
157
  );
113
158
 
114
159
  if (!accumulate) {
@@ -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 (