@tikoci/rosetta 0.4.1 → 0.4.3

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/query.ts CHANGED
@@ -25,6 +25,8 @@ export type SearchResponse = {
25
25
  total: number;
26
26
  };
27
27
 
28
+ type CsvScalar = string | number | null;
29
+
28
30
  const DEFAULT_LIMIT = 8;
29
31
  const MAX_TERMS = 8;
30
32
  const MIN_TERM_LENGTH = 2;
@@ -85,6 +87,21 @@ const COMPOUND_TERMS: [string, string][] = [
85
87
  ["address", "list"],
86
88
  ];
87
89
 
90
+ function escapeCsv(value: CsvScalar): string {
91
+ if (value === null) return "";
92
+ const text = String(value);
93
+ return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
94
+ }
95
+
96
+ function rowsToCsv<T extends Record<string, CsvScalar>>(
97
+ rows: T[],
98
+ columns: Array<keyof T & string>,
99
+ ): string {
100
+ const header = columns.join(",");
101
+ const body = rows.map((row) => columns.map((column) => escapeCsv(row[column] ?? null)).join(",")).join("\n");
102
+ return body ? `${header}\n${body}\n` : `${header}\n`;
103
+ }
104
+
88
105
  export function extractTerms(question: string): string[] {
89
106
  return question
90
107
  .toLowerCase()
@@ -577,6 +594,79 @@ function runCalloutsFtsQuery(
577
594
  }
578
595
  }
579
596
 
597
+ /** Diff two RouterOS versions — which command paths were added/removed between them. */
598
+ export type CommandDiffResult = {
599
+ from_version: string;
600
+ to_version: string;
601
+ path_prefix: string | null;
602
+ added: string[];
603
+ removed: string[];
604
+ added_count: number;
605
+ removed_count: number;
606
+ note: string | null;
607
+ };
608
+
609
+ export function diffCommandVersions(
610
+ fromVersion: string,
611
+ toVersion: string,
612
+ pathPrefix?: string,
613
+ ): CommandDiffResult {
614
+ const allVersionRows = db
615
+ .prepare("SELECT version FROM ros_versions")
616
+ .all() as Array<{ version: string }>;
617
+ const knownVersions = allVersionRows.map((r) => r.version).sort(compareVersions);
618
+
619
+ const notes: string[] = [];
620
+ if (knownVersions.length > 0 && !knownVersions.includes(fromVersion)) {
621
+ notes.push(`Version ${fromVersion} is not in the tracked range (${knownVersions[0]}–${knownVersions[knownVersions.length - 1]}). Results may be incomplete.`);
622
+ }
623
+ if (knownVersions.length > 0 && !knownVersions.includes(toVersion)) {
624
+ notes.push(`Version ${toVersion} is not in the tracked range (${knownVersions[0]}–${knownVersions[knownVersions.length - 1]}). Results may be incomplete.`);
625
+ }
626
+
627
+ const prefix = pathPrefix || null; // treat empty string same as undefined
628
+ // Match the prefix itself OR any sub-path under it
629
+ const prefixFilter = prefix ? " AND (command_path = ? OR command_path LIKE ? || '/%')" : "";
630
+ const prefixParams = (v: string) => prefix ? [v, prefix, prefix] : [v];
631
+
632
+ type Row = { command_path: string };
633
+
634
+ const addedRows = db
635
+ .prepare(
636
+ `SELECT DISTINCT cv_to.command_path
637
+ FROM command_versions cv_to
638
+ WHERE cv_to.ros_version = ?${prefixFilter}
639
+ AND cv_to.command_path NOT IN (
640
+ SELECT command_path FROM command_versions WHERE ros_version = ?${prefixFilter}
641
+ )
642
+ ORDER BY cv_to.command_path`,
643
+ )
644
+ .all(...prefixParams(toVersion), ...prefixParams(fromVersion)) as Row[];
645
+
646
+ const removedRows = db
647
+ .prepare(
648
+ `SELECT DISTINCT cv_from.command_path
649
+ FROM command_versions cv_from
650
+ WHERE cv_from.ros_version = ?${prefixFilter}
651
+ AND cv_from.command_path NOT IN (
652
+ SELECT command_path FROM command_versions WHERE ros_version = ?${prefixFilter}
653
+ )
654
+ ORDER BY cv_from.command_path`,
655
+ )
656
+ .all(...prefixParams(fromVersion), ...prefixParams(toVersion)) as Row[];
657
+
658
+ return {
659
+ from_version: fromVersion,
660
+ to_version: toVersion,
661
+ path_prefix: prefix,
662
+ added: addedRows.map((r) => r.command_path),
663
+ removed: removedRows.map((r) => r.command_path),
664
+ added_count: addedRows.length,
665
+ removed_count: removedRows.length,
666
+ note: notes.length > 0 ? notes.join(" ") : null,
667
+ };
668
+ }
669
+
580
670
  /** Check which RouterOS versions include a given command path. */
581
671
  export function checkCommandVersions(
582
672
  commandPath: string,
@@ -678,6 +768,31 @@ export type DeviceTestResult = {
678
768
  throughput_mbps: number | null;
679
769
  };
680
770
 
771
+ /** Map Unicode superscript digits to ASCII equivalents for product name matching.
772
+ * MikroTik uses ² and ³ in product names (hAP ax³, hAP ac²), but users type ASCII. */
773
+ const SUPERSCRIPT_TO_ASCII: [string, string][] = [
774
+ ['\u00B9', '1'], // ¹
775
+ ['\u00B2', '2'], // ²
776
+ ['\u00B3', '3'], // ³
777
+ ];
778
+
779
+ /** SQL expression to normalize Unicode superscript digits in a column to ASCII.
780
+ * Wraps the column in nested REPLACE calls. */
781
+ const NORMALIZE_PRODUCT_NAME = (col: string) =>
782
+ SUPERSCRIPT_TO_ASCII.reduce(
783
+ (expr, [sup, ascii]) => `REPLACE(${expr}, '${sup}', '${ascii}')`,
784
+ col,
785
+ );
786
+
787
+ /** Normalize a device query: replace Unicode superscript digits with ASCII. */
788
+ export function normalizeDeviceQuery(query: string): string {
789
+ let result = query;
790
+ for (const [sup, ascii] of SUPERSCRIPT_TO_ASCII) {
791
+ result = result.replaceAll(sup, ascii);
792
+ }
793
+ return result;
794
+ }
795
+
681
796
  export type DeviceResult = {
682
797
  id: number;
683
798
  product_name: string;
@@ -759,34 +874,79 @@ function buildDeviceFtsQuery(terms: string[], mode: "AND" | "OR"): string {
759
874
  return parts.join(mode === "AND" ? " AND " : " OR ");
760
875
  }
761
876
 
877
+ /** Generate a disambiguation note when multiple devices matched a partial query.
878
+ * Helps the MCP client present meaningful choices to the user. */
879
+ function disambiguationNote(query: string, results: DeviceResult[]): string {
880
+ const names = results.map((r) => r.product_name);
881
+ // Find common prefix
882
+ const shortest = names.reduce((a, b) => (a.length < b.length ? a : b));
883
+ let prefix = "";
884
+ for (let i = 0; i < shortest.length; i++) {
885
+ if (names.every((n) => n[i]?.toLowerCase() === shortest[i]?.toLowerCase())) {
886
+ prefix += shortest[i];
887
+ } else break;
888
+ }
889
+ prefix = prefix.trim();
890
+ // Summarize key differences
891
+ const diffs: string[] = [];
892
+ const enclosures = new Set(names.map((n) => {
893
+ if (/\bOUT\b/i.test(n)) return "outdoor";
894
+ if (/\bIN\b/i.test(n)) return "indoor";
895
+ return null;
896
+ }).filter(Boolean));
897
+ if (enclosures.size > 1) diffs.push("enclosure (indoor/outdoor)");
898
+ const hasPoe = results.map((r) => !!(r.poe_in || r.poe_out));
899
+ if (hasPoe.includes(true) && hasPoe.includes(false)) diffs.push("PoE support");
900
+ const hasPoeOut = results.map((r) => !!r.poe_out);
901
+ if (!diffs.includes("PoE support") && hasPoeOut.includes(true) && hasPoeOut.includes(false)) diffs.push("PoE output");
902
+ const hasWireless = results.map((r) => !!(r.wireless_24_chains || r.wireless_5_chains));
903
+ if (hasWireless.includes(true) && hasWireless.includes(false)) diffs.push("wireless");
904
+ const lteCapable = results.map((r) => (r.sim_slots ?? 0) > 0);
905
+ if (lteCapable.includes(true) && lteCapable.includes(false)) diffs.push("LTE/cellular");
906
+ const family = prefix || query;
907
+ const diffStr = diffs.length > 0 ? ` Key differences: ${diffs.join(", ")}.` : "";
908
+ return `${results.length} devices match "${family}".${diffStr} Use the full product name for a specific device.`;
909
+ }
910
+
762
911
  /** Look up a device by exact name or product code, then fall back to LIKE/FTS + filters. */
763
912
  export function searchDevices(
764
913
  query: string,
765
914
  filters: DeviceFilters = {},
766
915
  limit = 10,
767
- ): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number; has_more: boolean } {
768
- // 1. Try exact match on product_name or product_code
769
- if (query) {
916
+ ): { results: DeviceResult[]; mode: "exact" | "fts" | "like" | "filter" | "fts+or"; total: number; has_more: boolean; note?: string } {
917
+ // Normalize Unicode superscripts ASCII digits for all matching stages.
918
+ // Users type "ax3" for "ax³", "ac2" for "ac²" — normalize once, use everywhere.
919
+ const q = normalizeDeviceQuery(query);
920
+ const normalizedName = NORMALIZE_PRODUCT_NAME('product_name');
921
+
922
+ // 1. Try exact match on product_name or product_code.
923
+ // Compares normalized query against both raw and superscript-normalized product_name.
924
+ if (q) {
770
925
  const exact = db
771
- .prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE`)
772
- .all(query, query) as DeviceResult[];
926
+ .prepare(`${DEVICE_SELECT} WHERE product_name = ? COLLATE NOCASE OR product_code = ? COLLATE NOCASE OR ${normalizedName} = ? COLLATE NOCASE`)
927
+ .all(q, q, q) as DeviceResult[];
773
928
  if (exact.length > 0) {
774
929
  return { results: attachTestResults(exact), mode: "exact", total: exact.length, has_more: false };
775
930
  }
776
931
  }
777
932
 
778
933
  // 2. LIKE-based prefix/substring match on product_name and product_code.
934
+ // Splits on whitespace, dashes, and underscores so that dash-separated
935
+ // queries like "rb5009-out" still find "RB5009UPr+S+OUT".
779
936
  // For 144 rows this is instant and catches model number substrings
780
937
  // that FTS5 token matching misses (e.g. "RB1100" → "RB1100AHx4").
781
- if (query) {
782
- const likeTerms = query
783
- .trim()
784
- .split(/\s+/)
785
- .filter((t) => t.length >= 2)
938
+ // Also normalizes product_name in SQL so "ax3" matches "ax³".
939
+ if (q) {
940
+ const rawTerms = q.trim().split(/[\s\-_]+/);
941
+ const longTerms = rawTerms.filter((t) => t.length >= 2);
942
+ // Preserve single-digit terms (version numbers like "3" in "hap ax 3")
943
+ // but only when accompanied by longer terms to avoid overly broad matches.
944
+ const digitTerms = rawTerms.filter((t) => t.length === 1 && /^\d$/.test(t));
945
+ const likeTerms = (longTerms.length > 0 ? [...longTerms, ...digitTerms] : longTerms)
786
946
  .map((t) => `%${t}%`);
787
947
  if (likeTerms.length > 0) {
788
948
  const likeConditions = likeTerms.map(
789
- () => "(d.product_name LIKE ? COLLATE NOCASE OR d.product_code LIKE ? COLLATE NOCASE)",
949
+ () => `(${normalizedName} LIKE ? COLLATE NOCASE OR d.product_code LIKE ? COLLATE NOCASE)`,
790
950
  );
791
951
  const likeParams = likeTerms.flatMap((t) => [t, t]);
792
952
  // Fetch limit+1 to detect truncation
@@ -799,7 +959,29 @@ export function searchDevices(
799
959
  if (trimmed.length <= 5) attachTestResults(trimmed);
800
960
  // Single match in any mode gets test results
801
961
  else if (trimmed.length === 1) attachTestResults(trimmed);
802
- return { results: trimmed, mode: "like", total: trimmed.length, has_more: hasMore };
962
+ const note = trimmed.length > 1 ? disambiguationNote(q, trimmed) : undefined;
963
+ return { results: trimmed, mode: "like", total: trimmed.length, has_more: hasMore, note };
964
+ }
965
+ }
966
+ }
967
+
968
+ // 2b. Slug-normalized LIKE: strip all separators from both query and product_url slug.
969
+ // Handles concatenated AKAs ("hapax3", "fiberboxplus", "wapaxlte7") and superscript
970
+ // queries ("hap ax3" → slug hap_ax3 → stripped hapax3). Anchors to /product/ prefix
971
+ // to avoid matching domain or path components.
972
+ if (q) {
973
+ const slugQuery = q.toLowerCase().replace(/[^a-z0-9]/g, "");
974
+ if (slugQuery.length >= 5) {
975
+ const slugPattern = `%/product/%${slugQuery}%`;
976
+ const slugSql = `${DEVICE_SELECT} d WHERE d.product_url IS NOT NULL AND REPLACE(LOWER(d.product_url), '_', '') LIKE ? ORDER BY d.product_name LIMIT ?`;
977
+ const slugResults = db.prepare(slugSql).all(slugPattern, limit + 1) as DeviceResult[];
978
+ if (slugResults.length > 0) {
979
+ const hasMore = slugResults.length > limit;
980
+ const trimmed = hasMore ? slugResults.slice(0, limit) : slugResults;
981
+ if (trimmed.length <= 5) attachTestResults(trimmed);
982
+ else if (trimmed.length === 1) attachTestResults(trimmed);
983
+ const note = trimmed.length > 1 ? disambiguationNote(q, trimmed) : undefined;
984
+ return { results: trimmed, mode: "like", total: trimmed.length, has_more: hasMore, note };
803
985
  }
804
986
  }
805
987
  }
@@ -834,7 +1016,7 @@ export function searchDevices(
834
1016
  whereClauses.push("d.sim_slots > 0");
835
1017
  }
836
1018
 
837
- const terms = query ? extractTerms(query) : [];
1019
+ const terms = q ? extractTerms(q) : [];
838
1020
 
839
1021
  if (terms.length > 0) {
840
1022
  // FTS with filters — use prefix matching for device model numbers
@@ -897,9 +1079,6 @@ export type DeviceTestRow = {
897
1079
  product_name: string;
898
1080
  product_code: string | null;
899
1081
  architecture: string;
900
- cpu: string | null;
901
- cpu_cores: number | null;
902
- cpu_frequency: string | null;
903
1082
  test_type: string;
904
1083
  mode: string;
905
1084
  configuration: string;
@@ -908,16 +1087,15 @@ export type DeviceTestRow = {
908
1087
  throughput_mbps: number | null;
909
1088
  };
910
1089
 
911
- export function searchDeviceTests(
912
- filters: {
913
- test_type?: string;
914
- mode?: string;
915
- configuration?: string;
916
- packet_size?: number;
917
- sort_by?: "mbps" | "kpps";
918
- },
919
- limit = 50,
920
- ): { results: DeviceTestRow[]; total: number } {
1090
+ type DeviceTestFilters = {
1091
+ test_type?: string;
1092
+ mode?: string;
1093
+ configuration?: string;
1094
+ packet_size?: number;
1095
+ sort_by?: "mbps" | "kpps";
1096
+ };
1097
+
1098
+ function buildTestWhereClause(filters: DeviceTestFilters): { whereClause: string; params: (string | number)[] } {
921
1099
  const where: string[] = [];
922
1100
  const params: (string | number)[] = [];
923
1101
 
@@ -938,7 +1116,17 @@ export function searchDeviceTests(
938
1116
  params.push(filters.packet_size);
939
1117
  }
940
1118
 
941
- const whereClause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
1119
+ return {
1120
+ whereClause: where.length > 0 ? `WHERE ${where.join(" AND ")}` : "",
1121
+ params,
1122
+ };
1123
+ }
1124
+
1125
+ export function searchDeviceTests(
1126
+ filters: DeviceTestFilters,
1127
+ limit = 50,
1128
+ ): { results: DeviceTestRow[]; total: number } {
1129
+ const { whereClause, params } = buildTestWhereClause(filters);
942
1130
  const orderCol = filters.sort_by === "kpps" ? "t.throughput_kpps" : "t.throughput_mbps";
943
1131
 
944
1132
  // Total count (before limit)
@@ -946,8 +1134,7 @@ export function searchDeviceTests(
946
1134
  JOIN devices d ON d.id = t.device_id ${whereClause}`;
947
1135
  const total = Number((db.prepare(totalSql).get(...params) as { c: number }).c);
948
1136
 
949
- const sql = `SELECT d.product_name, d.product_code, d.architecture, d.cpu,
950
- d.cpu_cores, d.cpu_frequency,
1137
+ const sql = `SELECT d.product_name, d.product_code, d.architecture,
951
1138
  t.test_type, t.mode, t.configuration, t.packet_size,
952
1139
  t.throughput_kpps, t.throughput_mbps
953
1140
  FROM device_test_results t
@@ -960,6 +1147,117 @@ export function searchDeviceTests(
960
1147
  return { results, total };
961
1148
  }
962
1149
 
1150
+ type DeviceTestCsvRow = {
1151
+ product_name: string;
1152
+ product_code: string | null;
1153
+ architecture: string | null;
1154
+ cpu: string | null;
1155
+ cpu_cores: number | null;
1156
+ cpu_frequency: string | null;
1157
+ test_type: string;
1158
+ mode: string;
1159
+ configuration: string;
1160
+ packet_size: number;
1161
+ throughput_kpps: number | null;
1162
+ throughput_mbps: number | null;
1163
+ product_url: string | null;
1164
+ };
1165
+
1166
+ export function exportDeviceTestsCsv(): string {
1167
+ const rows = db.prepare(`SELECT d.product_name, d.product_code, d.architecture,
1168
+ d.cpu, d.cpu_cores, d.cpu_frequency,
1169
+ t.test_type, t.mode, t.configuration, t.packet_size,
1170
+ t.throughput_kpps, t.throughput_mbps,
1171
+ d.product_url
1172
+ FROM device_test_results t
1173
+ JOIN devices d ON d.id = t.device_id
1174
+ ORDER BY d.product_name, t.test_type, t.mode, t.configuration, t.packet_size DESC`).all() as DeviceTestCsvRow[];
1175
+
1176
+ return rowsToCsv(rows, [
1177
+ "product_name",
1178
+ "product_code",
1179
+ "architecture",
1180
+ "cpu",
1181
+ "cpu_cores",
1182
+ "cpu_frequency",
1183
+ "test_type",
1184
+ "mode",
1185
+ "configuration",
1186
+ "packet_size",
1187
+ "throughput_kpps",
1188
+ "throughput_mbps",
1189
+ "product_url",
1190
+ ]);
1191
+ }
1192
+
1193
+ type DeviceCsvRow = {
1194
+ product_name: string;
1195
+ product_code: string | null;
1196
+ architecture: string | null;
1197
+ cpu: string | null;
1198
+ cpu_cores: number | null;
1199
+ cpu_frequency: string | null;
1200
+ license_level: number | null;
1201
+ operating_system: string | null;
1202
+ ram: string | null;
1203
+ ram_mb: number | null;
1204
+ storage: string | null;
1205
+ storage_mb: number | null;
1206
+ dimensions: string | null;
1207
+ poe_in: string | null;
1208
+ poe_out: string | null;
1209
+ max_power_w: number | null;
1210
+ wireless_24_chains: number | null;
1211
+ wireless_5_chains: number | null;
1212
+ eth_fast: number | null;
1213
+ eth_gigabit: number | null;
1214
+ eth_2500: number | null;
1215
+ sfp_ports: number | null;
1216
+ sfp_plus_ports: number | null;
1217
+ eth_multigig: number | null;
1218
+ usb_ports: number | null;
1219
+ sim_slots: number | null;
1220
+ msrp_usd: number | null;
1221
+ product_url: string | null;
1222
+ block_diagram_url: string | null;
1223
+ };
1224
+
1225
+ export function exportDevicesCsv(): string {
1226
+ const rows = db.prepare(`${DEVICE_SELECT} ORDER BY product_name`).all() as DeviceCsvRow[];
1227
+
1228
+ return rowsToCsv(rows, [
1229
+ "product_name",
1230
+ "product_code",
1231
+ "architecture",
1232
+ "cpu",
1233
+ "cpu_cores",
1234
+ "cpu_frequency",
1235
+ "license_level",
1236
+ "operating_system",
1237
+ "ram",
1238
+ "ram_mb",
1239
+ "storage",
1240
+ "storage_mb",
1241
+ "dimensions",
1242
+ "poe_in",
1243
+ "poe_out",
1244
+ "max_power_w",
1245
+ "wireless_24_chains",
1246
+ "wireless_5_chains",
1247
+ "eth_fast",
1248
+ "eth_gigabit",
1249
+ "eth_2500",
1250
+ "sfp_ports",
1251
+ "sfp_plus_ports",
1252
+ "eth_multigig",
1253
+ "usb_ports",
1254
+ "sim_slots",
1255
+ "msrp_usd",
1256
+ "product_url",
1257
+ "block_diagram_url",
1258
+ ]);
1259
+ }
1260
+
963
1261
  /** Get distinct values for test result fields (for discovery). */
964
1262
  export function getTestResultMeta(): {
965
1263
  test_types: string[];
package/src/setup.ts CHANGED
@@ -10,7 +10,7 @@
10
10
  import { execSync } from "node:child_process";
11
11
  import { existsSync, writeFileSync } from "node:fs";
12
12
  import { gunzipSync } from "bun";
13
- import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion } from "./paths.ts";
13
+ import { detectMode, resolveBaseDir, resolveDbPath, resolveVersion, SCHEMA_VERSION } from "./paths.ts";
14
14
 
15
15
  declare const REPO_URL: string;
16
16
 
@@ -84,8 +84,13 @@ export async function runSetup(force = false) {
84
84
  const db = new sqlite(dbPath, { readonly: true });
85
85
  const row = db.prepare("SELECT COUNT(*) AS c FROM pages").get() as { c: number };
86
86
  const cmdRow = db.prepare("SELECT COUNT(*) AS c FROM commands WHERE type='cmd'").get() as { c: number };
87
+ const versionRow = db.prepare("PRAGMA user_version").get() as { user_version: number };
87
88
  db.close();
88
- console.log(`✓ Database ready (${row.c} pages, ${cmdRow.c} commands)`);
89
+ if (versionRow.user_version !== SCHEMA_VERSION) {
90
+ console.warn(` Warning: DB schema version is ${versionRow.user_version}, expected ${SCHEMA_VERSION}.`);
91
+ console.warn(` The downloaded DB may be incompatible with this version of rosetta.`);
92
+ }
93
+ console.log(`✓ Database ready (${row.c} pages, ${cmdRow.c} commands, schema v${versionRow.user_version})`);
89
94
  } catch (e) {
90
95
  console.error(`✗ Database validation failed: ${e}`);
91
96
  const retryCmd = mode === "compiled" ? "rosetta" : mode === "package" ? "bunx @tikoci/rosetta" : "bun run src/setup.ts";