@tikoci/rosetta 0.2.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/LICENSE +21 -0
- package/README.md +333 -0
- package/bin/rosetta.js +34 -0
- package/matrix/2026-03-25/matrix.csv +145 -0
- package/matrix/CLAUDE.md +7 -0
- package/matrix/get-mikrotik-products-csv.sh +20 -0
- package/package.json +34 -0
- package/src/assess-html.ts +267 -0
- package/src/db.ts +360 -0
- package/src/extract-all-versions.ts +147 -0
- package/src/extract-changelogs.ts +266 -0
- package/src/extract-commands.ts +175 -0
- package/src/extract-devices.ts +194 -0
- package/src/extract-html.ts +379 -0
- package/src/extract-properties.ts +234 -0
- package/src/link-commands.ts +208 -0
- package/src/mcp.ts +725 -0
- package/src/query.test.ts +994 -0
- package/src/query.ts +990 -0
- package/src/release.test.ts +280 -0
- package/src/restraml.ts +65 -0
- package/src/search.ts +49 -0
- package/src/setup.ts +224 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* extract-properties.ts — Parse confluenceTable property tables from HTML.
|
|
5
|
+
*
|
|
6
|
+
* For each page in the DB, re-reads the HTML file and extracts property tables:
|
|
7
|
+
* - Tables with "Property" in the first header cell
|
|
8
|
+
* - Each row: name (from <strong>), type (from <em>), default, description
|
|
9
|
+
* - Section heading: nearest h1/h2/h3 above the table
|
|
10
|
+
*
|
|
11
|
+
* Usage: bun run src/extract-properties.ts [html-dir]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { parseHTML } from "linkedom";
|
|
17
|
+
import { db, initDb } from "./db.ts";
|
|
18
|
+
|
|
19
|
+
const HTML_DIR =
|
|
20
|
+
process.argv[2] || resolve(import.meta.dirname, "../box/latest/ROS");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse the first cell of a property row to extract name, type, and default value.
|
|
24
|
+
*
|
|
25
|
+
* Patterns observed:
|
|
26
|
+
* <strong>name</strong> (<em>type</em>; Default: <strong>value</strong>)
|
|
27
|
+
* <strong>name</strong> (<em>type</em>; Default: value)
|
|
28
|
+
* <strong>name</strong> (<em>type</em>)
|
|
29
|
+
* <strong>name</strong>
|
|
30
|
+
*/
|
|
31
|
+
function parsePropertyCell(td: Element): { name: string; type: string | null; defaultVal: string | null } | null {
|
|
32
|
+
// Get the property name from the first <strong> element
|
|
33
|
+
const strongEl = td.querySelector("strong");
|
|
34
|
+
if (!strongEl) return null;
|
|
35
|
+
|
|
36
|
+
const name = strongEl.textContent?.trim() || "";
|
|
37
|
+
if (!name || name.length > 80) return null;
|
|
38
|
+
|
|
39
|
+
// Get the full cell text for type and default parsing
|
|
40
|
+
const cellText = td.textContent?.trim() || "";
|
|
41
|
+
|
|
42
|
+
// Extract type from <em> tag
|
|
43
|
+
let type: string | null = null;
|
|
44
|
+
const emEl = td.querySelector("em");
|
|
45
|
+
if (emEl) {
|
|
46
|
+
type = emEl.textContent?.trim() || null;
|
|
47
|
+
// Clean up trailing punctuation
|
|
48
|
+
if (type) type = type.replace(/[);,\s]+$/, "").trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Extract default value — look for "Default:" pattern in cell text
|
|
52
|
+
let defaultVal: string | null = null;
|
|
53
|
+
const defaultMatch = cellText.match(/Default:\s*(.+?)(?:\)|$)/i);
|
|
54
|
+
if (defaultMatch) {
|
|
55
|
+
defaultVal = defaultMatch[1].trim();
|
|
56
|
+
// Remove trailing parenthesis or semicolons
|
|
57
|
+
defaultVal = defaultVal.replace(/[)\s]+$/, "").trim() || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { name, type, defaultVal };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Get plain text from an element, stripping HTML. */
|
|
64
|
+
function plainText(el: Element): string {
|
|
65
|
+
return (el.textContent || "").trim().replace(/\s+/g, " ");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface PropertyRow {
|
|
69
|
+
pageId: number;
|
|
70
|
+
name: string;
|
|
71
|
+
type: string | null;
|
|
72
|
+
defaultVal: string | null;
|
|
73
|
+
description: string;
|
|
74
|
+
section: string | null;
|
|
75
|
+
sortOrder: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractProperties(pageId: number, htmlFile: string): PropertyRow[] {
|
|
79
|
+
const html = readFileSync(resolve(HTML_DIR, htmlFile), "utf-8");
|
|
80
|
+
const { document } = parseHTML(html);
|
|
81
|
+
const mainContent = document.querySelector("#main-content");
|
|
82
|
+
if (!mainContent) return [];
|
|
83
|
+
|
|
84
|
+
const properties: PropertyRow[] = [];
|
|
85
|
+
let sortOrder = 0;
|
|
86
|
+
|
|
87
|
+
// Build a map of element → nearest preceding heading for section context.
|
|
88
|
+
// Walk all children of main-content and track the current heading.
|
|
89
|
+
let currentHeading: string | null = null;
|
|
90
|
+
const headingMap = new Map<Element, string | null>();
|
|
91
|
+
|
|
92
|
+
// We need to find tables and their preceding headings.
|
|
93
|
+
// Walk through all elements in document order.
|
|
94
|
+
const allElements = mainContent.querySelectorAll("*");
|
|
95
|
+
for (const el of allElements) {
|
|
96
|
+
const tag = el.tagName?.toLowerCase();
|
|
97
|
+
if (tag === "h1" || tag === "h2" || tag === "h3") {
|
|
98
|
+
currentHeading = el.textContent?.trim() || null;
|
|
99
|
+
}
|
|
100
|
+
if (tag === "table") {
|
|
101
|
+
headingMap.set(el, currentHeading);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Process each confluenceTable
|
|
106
|
+
const tables = mainContent.querySelectorAll("table.confluenceTable, table.wrapped");
|
|
107
|
+
for (const table of tables) {
|
|
108
|
+
// Check if this is a property table by looking at header cells
|
|
109
|
+
const headerCells = table.querySelectorAll("th.confluenceTh, thead th");
|
|
110
|
+
const hasPropertyHeader = Array.from(headerCells).some((th) => {
|
|
111
|
+
const text = th.textContent?.trim().toLowerCase() || "";
|
|
112
|
+
return text === "property" || text === "read-only property";
|
|
113
|
+
});
|
|
114
|
+
if (!hasPropertyHeader) continue;
|
|
115
|
+
|
|
116
|
+
const section = headingMap.get(table) || null;
|
|
117
|
+
|
|
118
|
+
// Process data rows (all <tr> in <tbody>, skip header row)
|
|
119
|
+
const rows = table.querySelectorAll("tbody tr");
|
|
120
|
+
let isFirstRow = true;
|
|
121
|
+
for (const row of rows) {
|
|
122
|
+
const cells = row.querySelectorAll("td.confluenceTd, td");
|
|
123
|
+
// Skip header rows (th cells, not td)
|
|
124
|
+
const thCells = row.querySelectorAll("th.confluenceTh, th");
|
|
125
|
+
if (thCells.length > 0 && cells.length === 0) {
|
|
126
|
+
isFirstRow = false;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
// Also skip if first row has header-like content
|
|
130
|
+
if (isFirstRow && thCells.length > 0) {
|
|
131
|
+
isFirstRow = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
isFirstRow = false;
|
|
135
|
+
|
|
136
|
+
if (cells.length < 2) continue;
|
|
137
|
+
|
|
138
|
+
const parsed = parsePropertyCell(cells[0]);
|
|
139
|
+
if (!parsed) continue;
|
|
140
|
+
|
|
141
|
+
const description = plainText(cells[1]);
|
|
142
|
+
if (!description && !parsed.type) continue;
|
|
143
|
+
|
|
144
|
+
properties.push({
|
|
145
|
+
pageId,
|
|
146
|
+
name: parsed.name,
|
|
147
|
+
type: parsed.type,
|
|
148
|
+
defaultVal: parsed.defaultVal,
|
|
149
|
+
description: description || "",
|
|
150
|
+
section,
|
|
151
|
+
sortOrder: sortOrder++,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return properties;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- Main ----
|
|
160
|
+
|
|
161
|
+
console.log("Initializing database...");
|
|
162
|
+
initDb();
|
|
163
|
+
|
|
164
|
+
// Clear existing properties for clean re-extraction
|
|
165
|
+
db.run("DELETE FROM properties;");
|
|
166
|
+
db.run("INSERT INTO properties_fts(properties_fts) VALUES('rebuild');");
|
|
167
|
+
|
|
168
|
+
// Get all pages that have HTML files
|
|
169
|
+
type PageRef = { id: number; html_file: string; title: string };
|
|
170
|
+
const pages = db.prepare("SELECT id, html_file, title FROM pages").all() as PageRef[];
|
|
171
|
+
|
|
172
|
+
const insert = db.prepare(`
|
|
173
|
+
INSERT OR IGNORE INTO properties
|
|
174
|
+
(page_id, name, type, default_val, description, section, sort_order)
|
|
175
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
176
|
+
`);
|
|
177
|
+
|
|
178
|
+
let totalProperties = 0;
|
|
179
|
+
let pagesWithProps = 0;
|
|
180
|
+
|
|
181
|
+
const insertAll = db.transaction(() => {
|
|
182
|
+
for (const page of pages) {
|
|
183
|
+
const props = extractProperties(page.id, page.html_file);
|
|
184
|
+
if (props.length > 0) {
|
|
185
|
+
pagesWithProps++;
|
|
186
|
+
for (const p of props) {
|
|
187
|
+
insert.run(p.pageId, p.name, p.type, p.defaultVal, p.description, p.section, p.sortOrder);
|
|
188
|
+
totalProperties++;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
insertAll();
|
|
194
|
+
|
|
195
|
+
const ftsCount = (db.prepare("SELECT COUNT(*) as c FROM properties_fts").get() as { c: number }).c;
|
|
196
|
+
|
|
197
|
+
console.log(`\nProperty extraction complete:`);
|
|
198
|
+
console.log(` Properties extracted: ${totalProperties}`);
|
|
199
|
+
console.log(` Pages with properties: ${pagesWithProps}`);
|
|
200
|
+
console.log(` FTS index rows: ${ftsCount}`);
|
|
201
|
+
|
|
202
|
+
// Sample output
|
|
203
|
+
console.log(`\nSample properties from DHCP page:`);
|
|
204
|
+
const dhcpProps = db
|
|
205
|
+
.prepare(
|
|
206
|
+
`SELECT name, type, default_val, section
|
|
207
|
+
FROM properties
|
|
208
|
+
WHERE page_id = (SELECT id FROM pages WHERE title = 'DHCP')
|
|
209
|
+
ORDER BY sort_order LIMIT 10`,
|
|
210
|
+
)
|
|
211
|
+
.all() as Array<{ name: string; type: string; default_val: string; section: string }>;
|
|
212
|
+
|
|
213
|
+
for (const p of dhcpProps) {
|
|
214
|
+
console.log(` ${p.name} (${p.type || "?"}) [default: ${p.default_val || "none"}] — ${p.section}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Test FTS search
|
|
218
|
+
console.log(`\nSearch for "gateway":`);
|
|
219
|
+
const results = db
|
|
220
|
+
.prepare(
|
|
221
|
+
`SELECT p.name, p.type, p.default_val, pg.title as page_title, p.section,
|
|
222
|
+
snippet(properties_fts, 1, '>>>', '<<<', '...', 20) as excerpt
|
|
223
|
+
FROM properties_fts fts
|
|
224
|
+
JOIN properties p ON p.id = fts.rowid
|
|
225
|
+
JOIN pages pg ON pg.id = p.page_id
|
|
226
|
+
WHERE properties_fts MATCH 'gateway'
|
|
227
|
+
ORDER BY rank LIMIT 5`,
|
|
228
|
+
)
|
|
229
|
+
.all() as Array<{ name: string; type: string; default_val: string; page_title: string; section: string; excerpt: string }>;
|
|
230
|
+
|
|
231
|
+
for (const r of results) {
|
|
232
|
+
console.log(` ${r.name} [${r.page_title} / ${r.section}]`);
|
|
233
|
+
console.log(` ${r.excerpt}`);
|
|
234
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* link-commands.ts — Link commands to documentation pages.
|
|
5
|
+
*
|
|
6
|
+
* Strategies:
|
|
7
|
+
* 1. Code block paths: extract RouterOS menu paths from code blocks in each page
|
|
8
|
+
* 2. Known mappings: hardcoded path prefixes to page slugs
|
|
9
|
+
*
|
|
10
|
+
* The linking is page_id on the `commands` table (nullable, many commands per page).
|
|
11
|
+
*
|
|
12
|
+
* Usage: bun run src/link-commands.ts
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { parseHTML } from "linkedom";
|
|
18
|
+
import { db, initDb } from "./db.ts";
|
|
19
|
+
|
|
20
|
+
const HTML_DIR =
|
|
21
|
+
process.argv[2] || resolve(import.meta.dirname, "../box/latest/ROS");
|
|
22
|
+
|
|
23
|
+
initDb();
|
|
24
|
+
|
|
25
|
+
// Reset all links
|
|
26
|
+
db.run("UPDATE commands SET page_id = NULL;");
|
|
27
|
+
|
|
28
|
+
type PageRef = { id: number; title: string; html_file: string; code: string; path: string };
|
|
29
|
+
const pages = db.prepare("SELECT id, title, html_file, code, path FROM pages").all() as PageRef[];
|
|
30
|
+
|
|
31
|
+
type DirCmd = { id: number; path: string };
|
|
32
|
+
const dirCommands = db
|
|
33
|
+
.prepare("SELECT id, path FROM commands WHERE type = 'dir'")
|
|
34
|
+
.all() as DirCmd[];
|
|
35
|
+
|
|
36
|
+
// Build lookup: command path -> command row id
|
|
37
|
+
const cmdPathToId = new Map<string, number>();
|
|
38
|
+
for (const c of dirCommands) {
|
|
39
|
+
cmdPathToId.set(c.path, c.id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Strategy 1: Extract menu paths from first code block and page text
|
|
43
|
+
// RouterOS paths in code: /ip/firewall/filter, /system/clock, etc.
|
|
44
|
+
// Also handle old syntax: /ip firewall filter -> /ip/firewall/filter
|
|
45
|
+
const menuPathRe = /\/[a-z][a-z0-9-]+(?:[/ ][a-z][a-z0-9-]+)+/g;
|
|
46
|
+
|
|
47
|
+
function normalizeMenuPath(p: string): string {
|
|
48
|
+
return p.replace(/ /g, "/").toLowerCase();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Set of known non-RouterOS paths to ignore
|
|
52
|
+
const ignorePaths = new Set([
|
|
53
|
+
"/bin/bash", "/bin/sh", "/dev/null", "/usr/bin", "/usr/local",
|
|
54
|
+
"/etc/config", "/tmp/backup", "/var/log", "/proc/sys",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
function isRouterOsPath(p: string): boolean {
|
|
58
|
+
if (ignorePaths.has(p)) return false;
|
|
59
|
+
// Must start with a known top-level RouterOS dir
|
|
60
|
+
const firstSegment = p.split("/")[1];
|
|
61
|
+
return cmdPathToId.has(`/${firstSegment}`) || [
|
|
62
|
+
"ip", "ipv6", "interface", "system", "routing", "tool", "queue",
|
|
63
|
+
"ppp", "mpls", "certificate", "user", "snmp", "radius", "log",
|
|
64
|
+
"file", "disk", "container", "iot", "caps-man",
|
|
65
|
+
].includes(firstSegment);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Map: page_id -> set of command paths found in that page
|
|
69
|
+
const pageToCommandPaths = new Map<number, Set<string>>();
|
|
70
|
+
|
|
71
|
+
for (const page of pages) {
|
|
72
|
+
const paths = new Set<string>();
|
|
73
|
+
|
|
74
|
+
// Extract from code blocks
|
|
75
|
+
const codeMatches = page.code.matchAll(menuPathRe);
|
|
76
|
+
for (const m of codeMatches) {
|
|
77
|
+
const normalized = normalizeMenuPath(m[0]);
|
|
78
|
+
if (isRouterOsPath(normalized)) {
|
|
79
|
+
paths.add(normalized);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Also look in the HTML for <strong>/path/syntax</strong> and code elements
|
|
84
|
+
try {
|
|
85
|
+
const html = readFileSync(resolve(HTML_DIR, page.html_file), "utf-8");
|
|
86
|
+
const { document } = parseHTML(html);
|
|
87
|
+
const mainContent = document.querySelector("#main-content");
|
|
88
|
+
if (mainContent) {
|
|
89
|
+
// Check <strong> tags containing paths (common pattern for "Sub-menu: /ip/firewall/filter")
|
|
90
|
+
for (const strong of mainContent.querySelectorAll("strong")) {
|
|
91
|
+
const text = strong.textContent?.trim() || "";
|
|
92
|
+
if (text.startsWith("/") && text.length > 3) {
|
|
93
|
+
const normalized = normalizeMenuPath(text);
|
|
94
|
+
if (isRouterOsPath(normalized)) {
|
|
95
|
+
paths.add(normalized);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Check code elements too
|
|
100
|
+
for (const codeEl of mainContent.querySelectorAll("code")) {
|
|
101
|
+
const text = codeEl.textContent?.trim() || "";
|
|
102
|
+
if (text.startsWith("/") && text.length > 3) {
|
|
103
|
+
const normalized = normalizeMenuPath(text);
|
|
104
|
+
if (isRouterOsPath(normalized)) {
|
|
105
|
+
paths.add(normalized);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch {
|
|
111
|
+
// Skip if file can't be read
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (paths.size > 0) {
|
|
115
|
+
pageToCommandPaths.set(page.id, paths);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Now link: for each page's paths, find the best matching dir command
|
|
120
|
+
// A page "owns" a dir if the dir path matches one found in the page.
|
|
121
|
+
// For each command dir, pick the most specific page (the one whose found path
|
|
122
|
+
// is the longest match for the command path).
|
|
123
|
+
|
|
124
|
+
const updateSingle = db.prepare("UPDATE commands SET page_id = ? WHERE path = ?");
|
|
125
|
+
|
|
126
|
+
// Build reverse map: command_path -> candidate page_ids
|
|
127
|
+
const cmdToCandidatePages = new Map<string, number[]>();
|
|
128
|
+
|
|
129
|
+
for (const [pageId, paths] of pageToCommandPaths) {
|
|
130
|
+
for (const p of paths) {
|
|
131
|
+
// Direct match
|
|
132
|
+
if (cmdPathToId.has(p)) {
|
|
133
|
+
const existing = cmdToCandidatePages.get(p) || [];
|
|
134
|
+
existing.push(pageId);
|
|
135
|
+
cmdToCandidatePages.set(p, existing);
|
|
136
|
+
}
|
|
137
|
+
// Also try parent paths (e.g., /ip/dhcp-client/add -> /ip/dhcp-client)
|
|
138
|
+
const segments = p.split("/").filter(Boolean);
|
|
139
|
+
for (let i = segments.length - 1; i >= 1; i--) {
|
|
140
|
+
const parent = `/${segments.slice(0, i).join("/")}`;
|
|
141
|
+
if (cmdPathToId.has(parent)) {
|
|
142
|
+
const existing = cmdToCandidatePages.get(parent) || [];
|
|
143
|
+
if (!existing.includes(pageId)) {
|
|
144
|
+
existing.push(pageId);
|
|
145
|
+
cmdToCandidatePages.set(parent, existing);
|
|
146
|
+
}
|
|
147
|
+
break; // Only link to the most specific parent
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// For each command dir, pick the page that seems most authoritative:
|
|
154
|
+
// - Prefer the page whose breadcrumb path is closest to the command path
|
|
155
|
+
// - If tied, prefer the page with more property tables
|
|
156
|
+
const linkDir = db.transaction(() => {
|
|
157
|
+
for (const [cmdPath, candidatePageIds] of cmdToCandidatePages) {
|
|
158
|
+
const pageId = candidatePageIds[0];
|
|
159
|
+
|
|
160
|
+
// Link the dir itself
|
|
161
|
+
if (cmdPathToId.has(cmdPath)) {
|
|
162
|
+
updateSingle.run(pageId, cmdPath);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Also link child commands
|
|
166
|
+
const children = db
|
|
167
|
+
.prepare("SELECT path FROM commands WHERE parent_path = ? AND page_id IS NULL")
|
|
168
|
+
.all(cmdPath) as Array<{ path: string }>;
|
|
169
|
+
for (const child of children) {
|
|
170
|
+
updateSingle.run(pageId, child.path);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
linkDir();
|
|
175
|
+
|
|
176
|
+
// Stats
|
|
177
|
+
const totalDirs = (db.prepare("SELECT COUNT(*) as c FROM commands WHERE type='dir'").get() as { c: number }).c;
|
|
178
|
+
const linkedDirs = (db.prepare("SELECT COUNT(*) as c FROM commands WHERE type='dir' AND page_id IS NOT NULL").get() as { c: number }).c;
|
|
179
|
+
const totalCmds = (db.prepare("SELECT COUNT(*) as c FROM commands").get() as { c: number }).c;
|
|
180
|
+
const linkedCmds = (db.prepare("SELECT COUNT(*) as c FROM commands WHERE page_id IS NOT NULL").get() as { c: number }).c;
|
|
181
|
+
|
|
182
|
+
console.log("Linking complete:");
|
|
183
|
+
console.log(` Total commands: ${totalCmds}`);
|
|
184
|
+
console.log(` Linked commands: ${linkedCmds} (${((linkedCmds / totalCmds) * 100).toFixed(1)}%)`);
|
|
185
|
+
console.log(` Linked dirs: ${linkedDirs}/${totalDirs} (${((linkedDirs / totalDirs) * 100).toFixed(1)}%)`);
|
|
186
|
+
|
|
187
|
+
// Sample linked commands
|
|
188
|
+
console.log("\nSample linked dirs:");
|
|
189
|
+
const samples = db
|
|
190
|
+
.prepare(
|
|
191
|
+
`SELECT c.path, p.title, p.url
|
|
192
|
+
FROM commands c JOIN pages p ON c.page_id = p.id
|
|
193
|
+
WHERE c.type = 'dir'
|
|
194
|
+
ORDER BY c.path LIMIT 20`,
|
|
195
|
+
)
|
|
196
|
+
.all() as Array<{ path: string; title: string; url: string }>;
|
|
197
|
+
for (const s of samples) {
|
|
198
|
+
console.log(` ${s.path} -> ${s.title}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Unlinked dirs
|
|
202
|
+
console.log("\nUnlinked dirs (sample):");
|
|
203
|
+
const unlinked = db
|
|
204
|
+
.prepare("SELECT path FROM commands WHERE type='dir' AND page_id IS NULL ORDER BY path LIMIT 20")
|
|
205
|
+
.all() as Array<{ path: string }>;
|
|
206
|
+
for (const u of unlinked) {
|
|
207
|
+
console.log(` ${u.path}`);
|
|
208
|
+
}
|