@tikoci/rosetta 0.6.5 → 0.6.6
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/package.json +1 -1
- package/src/browse.ts +5 -1
- package/src/extract-dude.test.ts +30 -0
- package/src/extract-dude.ts +70 -30
- package/src/mcp.ts +15 -2
- package/src/query.test.ts +11 -6
- package/src/query.ts +34 -5
package/package.json
CHANGED
package/src/browse.ts
CHANGED
|
@@ -265,6 +265,9 @@ function renderSearchResults(resp: SearchResponse): string {
|
|
|
265
265
|
const out: string[] = [];
|
|
266
266
|
const modeNote = resp.fallbackMode === "or" ? dim(" (OR fallback)") : "";
|
|
267
267
|
out.push(` ${bold(String(resp.results.length))} of ${resp.total} results for ${cyan(`"${resp.query}"`)}${modeNote}`);
|
|
268
|
+
if (resp.note) {
|
|
269
|
+
out.push(` ${yellow("Hint:")} ${resp.note}`);
|
|
270
|
+
}
|
|
268
271
|
out.push("");
|
|
269
272
|
|
|
270
273
|
const w = termWidth();
|
|
@@ -1092,7 +1095,8 @@ async function handleNumberSelect(idx: number): Promise<void> {
|
|
|
1092
1095
|
async function doSearch(query: string): Promise<void> {
|
|
1093
1096
|
const resp = searchPages(query);
|
|
1094
1097
|
if (resp.results.length === 0) {
|
|
1095
|
-
|
|
1098
|
+
const extra = resp.note ? ` ${yellow("Hint:")} ${resp.note}` : "";
|
|
1099
|
+
console.log(` ${dim("No results.")} Try: ${cyan("props")} ${query}, ${cyan("cal")} ${query}, ${cyan("vid")} ${query}${extra}`);
|
|
1096
1100
|
return;
|
|
1097
1101
|
}
|
|
1098
1102
|
await paged(renderSearchResults(resp));
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { parseDudePage } from "./extract-dude.ts";
|
|
5
|
+
|
|
6
|
+
const probesHtml = readFileSync(join(import.meta.dirname, "..", "dude", "pages", "Probes.html"), "utf-8");
|
|
7
|
+
|
|
8
|
+
describe("parseDudePage", () => {
|
|
9
|
+
test("uses the inner article body instead of wiki chrome", () => {
|
|
10
|
+
const parsed = parseDudePage(probesHtml, "https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes");
|
|
11
|
+
expect(parsed.text).not.toContain("Jump to navigation");
|
|
12
|
+
expect(parsed.text).not.toContain("Jump to search");
|
|
13
|
+
expect(parsed.text).not.toContain("Retrieved from");
|
|
14
|
+
expect(parsed.text).not.toContain("FILE ARCHIVED ON");
|
|
15
|
+
expect(parsed.text).not.toContain("Contents");
|
|
16
|
+
expect(parsed.text).toContain("The Probes pane shows the available methods");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("extracts useful inline and block code snippets", () => {
|
|
20
|
+
const parsed = parseDudePage(probesHtml, "https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes");
|
|
21
|
+
expect(parsed.code).toContain("Custom_Voltage_Function()");
|
|
22
|
+
expect(parsed.code).toContain('if((oid("1.3.6.1.4.1.14988.1.1.3.8.0")>19),"1", "0")');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("keeps content images but skips wiki UI icons", () => {
|
|
26
|
+
const parsed = parseDudePage(probesHtml, "https://web.archive.org/web/2024/https://wiki.mikrotik.com/wiki/Manual:The_Dude_v6/Probes");
|
|
27
|
+
expect(parsed.images.some((img) => img.filename === "Version.png")).toBeFalse();
|
|
28
|
+
expect(parsed.images.some((img) => img.filename === "Dude-probes-all.JPG")).toBeTrue();
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/extract-dude.ts
CHANGED
|
@@ -44,7 +44,7 @@ interface PageDef {
|
|
|
44
44
|
// ── Page list (from CDX API enumeration) ──
|
|
45
45
|
|
|
46
46
|
const V6_PAGES: PageDef[] = [
|
|
47
|
-
|
|
47
|
+
// Note: Manual:The_Dude and Manual:The_Dude_v6 are wiki stubs (no content)
|
|
48
48
|
{ wikiPath: "Manual:The_Dude_v6/Installation", slug: "Installation", path: "The Dude > v6 > Installation", version: "v6" },
|
|
49
49
|
{ wikiPath: "Manual:The_Dude_v6/First_use", slug: "First_use", path: "The Dude > v6 > First Use", version: "v6" },
|
|
50
50
|
{ wikiPath: "Manual:The_Dude_v6/Interface", slug: "Interface", path: "The Dude > v6 > Interface", version: "v6" },
|
|
@@ -90,8 +90,7 @@ const V3_PAGES: PageDef[] = [
|
|
|
90
90
|
{ wikiPath: "Manual:The_Dude/Interface", slug: "v3_Interface", path: "The Dude > v3/v4 > Interface", version: "v3" },
|
|
91
91
|
{ wikiPath: "Manual:The_Dude/Device_settings", slug: "v3_Device_settings", path: "The Dude > v3/v4 > Device Settings", version: "v3" },
|
|
92
92
|
{ wikiPath: "Manual:The_Dude/Device_discovery", slug: "v3_Device_discovery", path: "The Dude > v3/v4 > Device Discovery", version: "v3" },
|
|
93
|
-
|
|
94
|
-
{ wikiPath: "Manual:The_Dude/Device_list", slug: "v3_Device_list", path: "The Dude > v3/v4 > Device List", version: "v3" },
|
|
93
|
+
// Note: Manual:The_Dude/Device_map and Manual:The_Dude/Device_list are wiki stubs (no content)
|
|
95
94
|
{ wikiPath: "Manual:The_Dude/Networks", slug: "v3_Networks", path: "The Dude > v3/v4 > Networks", version: "v3" },
|
|
96
95
|
{ wikiPath: "Manual:The_Dude/Links", slug: "v3_Links", path: "The Dude > v3/v4 > Links", version: "v3" },
|
|
97
96
|
{ wikiPath: "Manual:The_Dude/Agents", slug: "v3_Agents", path: "The Dude > v3/v4 > Agents", version: "v3" },
|
|
@@ -161,7 +160,44 @@ interface ParsedPage {
|
|
|
161
160
|
images: Array<{ filename: string; altText: string | null; originalUrl: string; waybackImageUrl: string }>;
|
|
162
161
|
}
|
|
163
162
|
|
|
164
|
-
|
|
163
|
+
const DUDE_NOISE_SELECTORS = [
|
|
164
|
+
"#toc",
|
|
165
|
+
".toc",
|
|
166
|
+
".mw-editsection",
|
|
167
|
+
".navbox",
|
|
168
|
+
"#catlinks",
|
|
169
|
+
".printfooter",
|
|
170
|
+
"#wm-ipp-base",
|
|
171
|
+
"#wm-ipp",
|
|
172
|
+
"#donato",
|
|
173
|
+
".wb-autocomplete-suggestions",
|
|
174
|
+
"#mw-navigation",
|
|
175
|
+
"#footer",
|
|
176
|
+
".noprint",
|
|
177
|
+
"#jump-to-nav",
|
|
178
|
+
".mw-jump-link",
|
|
179
|
+
"#siteSub",
|
|
180
|
+
"#contentSub",
|
|
181
|
+
"#contentSub2",
|
|
182
|
+
"script",
|
|
183
|
+
"style",
|
|
184
|
+
"noscript",
|
|
185
|
+
] as const;
|
|
186
|
+
|
|
187
|
+
function removeNoise(root: ParentNode): void {
|
|
188
|
+
for (const sel of DUDE_NOISE_SELECTORS) {
|
|
189
|
+
for (const el of root.querySelectorAll(sel)) el.remove();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function getPrimaryContent(document: Document): Element | null {
|
|
194
|
+
return document.querySelector("#mw-content-text .mw-parser-output")
|
|
195
|
+
?? document.querySelector("#mw-content-text")
|
|
196
|
+
?? document.querySelector("#bodyContent .mw-parser-output")
|
|
197
|
+
?? document.querySelector("#bodyContent");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function parseDudePage(html: string, _waybackBaseUrl: string): ParsedPage {
|
|
165
201
|
const { document } = parseHTML(html);
|
|
166
202
|
|
|
167
203
|
// Title from first h1 or page heading
|
|
@@ -172,21 +208,17 @@ function parsePage(html: string, _waybackBaseUrl: string): ParsedPage {
|
|
|
172
208
|
.replace(/_/g, " ") ?? "Unknown";
|
|
173
209
|
|
|
174
210
|
// Main content area
|
|
175
|
-
const content = document
|
|
211
|
+
const content = getPrimaryContent(document);
|
|
176
212
|
if (!content) return { title, text: "", code: "", lastEdited: null, images: [] };
|
|
177
213
|
|
|
178
|
-
// Remove navigation, TOC, edit links, Wayback Machine toolbar
|
|
179
|
-
for (const sel of ["#toc", ".toc", ".mw-editsection", ".navbox", "#catlinks",
|
|
180
|
-
".printfooter", "#wm-ipp-base", "#wm-ipp", "#donato", ".wb-autocomplete-suggestions",
|
|
181
|
-
"#mw-navigation", "#footer", ".noprint"]) {
|
|
182
|
-
for (const el of content.querySelectorAll(sel)) el.remove();
|
|
183
|
-
}
|
|
184
|
-
|
|
185
214
|
// Extract code blocks
|
|
186
215
|
const codeBlocks: string[] = [];
|
|
187
|
-
|
|
216
|
+
const seenCodeBlocks = new Set<string>();
|
|
217
|
+
for (const pre of content.querySelectorAll("pre, code")) {
|
|
188
218
|
const text = pre.textContent?.trim();
|
|
189
|
-
if (text
|
|
219
|
+
if (!text || text.length < 6 || seenCodeBlocks.has(text)) continue;
|
|
220
|
+
seenCodeBlocks.add(text);
|
|
221
|
+
codeBlocks.push(text);
|
|
190
222
|
}
|
|
191
223
|
|
|
192
224
|
// Extract images
|
|
@@ -195,6 +227,8 @@ function parsePage(html: string, _waybackBaseUrl: string): ParsedPage {
|
|
|
195
227
|
for (const img of content.querySelectorAll("img")) {
|
|
196
228
|
const src = img.getAttribute("src") ?? "";
|
|
197
229
|
const alt = img.getAttribute("alt") ?? null;
|
|
230
|
+
const parentA = img.closest("a");
|
|
231
|
+
const href = parentA?.getAttribute("href") ?? "";
|
|
198
232
|
|
|
199
233
|
// Skip tiny icons, navigation icons, and Wayback Machine UI images
|
|
200
234
|
const width = Number.parseInt(img.getAttribute("width") ?? "0", 10);
|
|
@@ -204,21 +238,17 @@ function parsePage(html: string, _waybackBaseUrl: string): ParsedPage {
|
|
|
204
238
|
// Extract filename from MediaWiki image URLs
|
|
205
239
|
// Patterns: /images/X/XX/Filename.JPG or /wiki/File:Filename.JPG
|
|
206
240
|
let filename: string | null = null;
|
|
241
|
+
const fileMatch = href.match(/File:([^&"]+)/);
|
|
242
|
+
if (fileMatch) filename = decodeURIComponent(fileMatch[1]);
|
|
243
|
+
|
|
207
244
|
const srcMatch = src.match(/\/images\/[0-9a-f]\/[0-9a-f]{2}\/([^/?]+)/i)
|
|
208
245
|
?? src.match(/\/([^/]+\.(?:jpg|jpeg|png|gif|svg))(?:\?|$)/i);
|
|
209
246
|
if (srcMatch) {
|
|
210
|
-
filename
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// Also check parent <a> links for File: references
|
|
214
|
-
if (!filename) {
|
|
215
|
-
const parentA = img.closest("a");
|
|
216
|
-
const href = parentA?.getAttribute("href") ?? "";
|
|
217
|
-
const fileMatch = href.match(/File:([^&"]+)/);
|
|
218
|
-
if (fileMatch) filename = decodeURIComponent(fileMatch[1]);
|
|
247
|
+
filename ??= decodeURIComponent(srcMatch[1]);
|
|
219
248
|
}
|
|
220
249
|
|
|
221
250
|
if (!filename) continue;
|
|
251
|
+
filename = filename.replace(/^\d+px-/, "");
|
|
222
252
|
if (seenFilenames.has(filename)) continue;
|
|
223
253
|
// Skip common wiki icons
|
|
224
254
|
if (/^Icon-\w+\.png$/i.test(filename)) continue;
|
|
@@ -242,9 +272,17 @@ function parsePage(html: string, _waybackBaseUrl: string): ParsedPage {
|
|
|
242
272
|
}
|
|
243
273
|
|
|
244
274
|
// Extract body text
|
|
245
|
-
const
|
|
275
|
+
const textRoot = content.cloneNode(true) as Element;
|
|
276
|
+
removeNoise(textRoot);
|
|
277
|
+
for (const img of textRoot.querySelectorAll("img")) img.remove();
|
|
278
|
+
|
|
279
|
+
const textContent = textRoot.textContent ?? "";
|
|
246
280
|
// Clean up whitespace: collapse multiple blank lines, trim
|
|
247
|
-
const text = textContent
|
|
281
|
+
const text = textContent
|
|
282
|
+
.replace(/Retrieved from\s+"https?:\/\/[^\n"]+"/gi, "")
|
|
283
|
+
.replace(/FILE ARCHIVED ON[\s\S]*$/i, "")
|
|
284
|
+
.replace(/\n[ \t]*\n[ \t]*\n+/g, "\n\n")
|
|
285
|
+
.trim();
|
|
248
286
|
|
|
249
287
|
// Last edited date from footer
|
|
250
288
|
const lastEditedEl = document.querySelector("#footer-info-lastmod, .lastmod");
|
|
@@ -326,7 +364,7 @@ async function main() {
|
|
|
326
364
|
}
|
|
327
365
|
|
|
328
366
|
// Parse HTML
|
|
329
|
-
const parsed =
|
|
367
|
+
const parsed = parseDudePage(html, wbUrl);
|
|
330
368
|
if (!parsed.text && !parsed.code) {
|
|
331
369
|
console.log(` WARN: empty content for ${pageDef.slug}`);
|
|
332
370
|
}
|
|
@@ -403,7 +441,9 @@ async function main() {
|
|
|
403
441
|
console.log(`DB: ${stats.c} dude_pages, ${imgStats.c} dude_images`);
|
|
404
442
|
}
|
|
405
443
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
444
|
+
if (import.meta.main) {
|
|
445
|
+
main().catch((e) => {
|
|
446
|
+
console.error("Fatal:", e);
|
|
447
|
+
process.exit(1);
|
|
448
|
+
});
|
|
449
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -462,6 +462,7 @@ Workflow — what to do next:
|
|
|
462
462
|
|
|
463
463
|
Tips:
|
|
464
464
|
- Use specific technical terms: "DHCP relay agent" not "how to set up DHCP"
|
|
465
|
+
- For retired Dude GUI or wiki topics, prefer routeros_dude_search; routeros_search covers current RouterOS v7 docs
|
|
465
466
|
- Documentation: 317 pages from March 2026 Confluence export (~515K words)
|
|
466
467
|
- Docs reflect the then-current long-term release (~7.22), not version-pinned
|
|
467
468
|
- Command data: RouterOS 7.9–7.23beta2. No v6 data available.
|
|
@@ -990,14 +991,26 @@ server.registerTool(
|
|
|
990
991
|
Returns the complete page text, code blocks, and a list of GUI screenshots with local file paths.
|
|
991
992
|
Screenshots are downloaded images from the archived wiki — use a file viewer for multimodal analysis.
|
|
992
993
|
|
|
994
|
+
max_length defaults to 16000. If the page text+code exceeds it, content is truncated and a
|
|
995
|
+
truncated field shows the original lengths. Dude pages are generally small (< 12K chars)
|
|
996
|
+
so truncation is uncommon.
|
|
997
|
+
|
|
993
998
|
→ routeros_dude_search: find pages by topic
|
|
994
999
|
→ routeros_command_tree: browse /dude commands in current RouterOS`,
|
|
995
1000
|
inputSchema: {
|
|
996
1001
|
id: z.union([z.number().int(), z.string()]).describe("Page ID (number) or title/slug (string)"),
|
|
1002
|
+
max_length: z
|
|
1003
|
+
.number()
|
|
1004
|
+
.int()
|
|
1005
|
+
.min(1000)
|
|
1006
|
+
.max(200000)
|
|
1007
|
+
.optional()
|
|
1008
|
+
.default(16000)
|
|
1009
|
+
.describe("Max combined text+code characters to return (default: 16000)."),
|
|
997
1010
|
},
|
|
998
1011
|
},
|
|
999
|
-
async ({ id }) => {
|
|
1000
|
-
const page = getDudePage(typeof id === "string" && /^\d+$/.test(id) ? Number.parseInt(id, 10) : id);
|
|
1012
|
+
async ({ id, max_length }) => {
|
|
1013
|
+
const page = getDudePage(typeof id === "string" && /^\d+$/.test(id) ? Number.parseInt(id, 10) : id, max_length);
|
|
1001
1014
|
if (!page) {
|
|
1002
1015
|
return {
|
|
1003
1016
|
content: [
|
package/src/query.test.ts
CHANGED
|
@@ -1770,6 +1770,11 @@ describe("searchDude", () => {
|
|
|
1770
1770
|
const results = searchDude("dude", 1);
|
|
1771
1771
|
expect(results.length).toBeLessThanOrEqual(1);
|
|
1772
1772
|
});
|
|
1773
|
+
|
|
1774
|
+
test("searchPages adds a Dude-specific hint for explicit Dude queries", () => {
|
|
1775
|
+
const results = searchPages("the dude probes");
|
|
1776
|
+
expect(results.note).toContain("routeros_dude_search");
|
|
1777
|
+
});
|
|
1773
1778
|
});
|
|
1774
1779
|
|
|
1775
1780
|
// ---------------------------------------------------------------------------
|
|
@@ -1780,22 +1785,22 @@ describe("getDudePage", () => {
|
|
|
1780
1785
|
test("returns page by ID with images", () => {
|
|
1781
1786
|
const page = getDudePage(1);
|
|
1782
1787
|
expect(page).not.toBeNull();
|
|
1783
|
-
expect(page
|
|
1784
|
-
expect(page
|
|
1785
|
-
expect(page
|
|
1788
|
+
expect(page?.title).toBe("Probes");
|
|
1789
|
+
expect(page?.images.length).toBe(2);
|
|
1790
|
+
expect(page?.images[0]?.filename).toBe("Dude-probes-all.JPG");
|
|
1786
1791
|
});
|
|
1787
1792
|
|
|
1788
1793
|
test("returns page by title", () => {
|
|
1789
1794
|
const page = getDudePage("Probes");
|
|
1790
1795
|
expect(page).not.toBeNull();
|
|
1791
|
-
expect(page
|
|
1796
|
+
expect(page?.id).toBe(1);
|
|
1792
1797
|
});
|
|
1793
1798
|
|
|
1794
1799
|
test("returns page by slug", () => {
|
|
1795
1800
|
const page = getDudePage("Device_discovery");
|
|
1796
1801
|
expect(page).not.toBeNull();
|
|
1797
|
-
expect(page
|
|
1798
|
-
expect(page
|
|
1802
|
+
expect(page?.title).toBe("Device Discovery");
|
|
1803
|
+
expect(page?.images.length).toBe(1);
|
|
1799
1804
|
});
|
|
1800
1805
|
|
|
1801
1806
|
test("returns null for non-existent page", () => {
|
package/src/query.ts
CHANGED
|
@@ -24,6 +24,7 @@ export type SearchResponse = {
|
|
|
24
24
|
fallbackMode: "or" | null;
|
|
25
25
|
results: SearchResult[];
|
|
26
26
|
total: number;
|
|
27
|
+
note?: string;
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
type CsvScalar = string | number | null;
|
|
@@ -88,6 +89,14 @@ const COMPOUND_TERMS: [string, string][] = [
|
|
|
88
89
|
["address", "list"],
|
|
89
90
|
];
|
|
90
91
|
|
|
92
|
+
function getSpecialSearchHint(question: string): string | undefined {
|
|
93
|
+
const normalized = question.toLowerCase();
|
|
94
|
+
if (/(?:^|\b)(?:the\s+)?dude(?:\b|$)/.test(normalized)) {
|
|
95
|
+
return 'This looks like a Dude query. For retired Dude GUI or wiki topics, use routeros_dude_search. For current RouterOS CLI and server paths, use routeros_command_tree with path "/dude".';
|
|
96
|
+
}
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
91
100
|
function escapeCsv(value: CsvScalar): string {
|
|
92
101
|
if (value === null) return "";
|
|
93
102
|
const text = String(value);
|
|
@@ -141,8 +150,9 @@ export function buildFtsQuery(terms: string[], mode: "AND" | "OR"): string {
|
|
|
141
150
|
|
|
142
151
|
export function searchPages(question: string, limit = DEFAULT_LIMIT): SearchResponse {
|
|
143
152
|
const terms = extractTerms(question);
|
|
153
|
+
const note = getSpecialSearchHint(question);
|
|
144
154
|
if (terms.length === 0) {
|
|
145
|
-
return { query: question, ftsQuery: "", fallbackMode: null, results: [], total: 0 };
|
|
155
|
+
return { query: question, ftsQuery: "", fallbackMode: null, results: [], total: 0, ...(note ? { note } : {}) };
|
|
146
156
|
}
|
|
147
157
|
|
|
148
158
|
// Try AND first
|
|
@@ -160,7 +170,7 @@ export function searchPages(question: string, limit = DEFAULT_LIMIT): SearchResp
|
|
|
160
170
|
|
|
161
171
|
attachBestSections(results, terms);
|
|
162
172
|
|
|
163
|
-
return { query: question, ftsQuery, fallbackMode, results, total: results.length };
|
|
173
|
+
return { query: question, ftsQuery, fallbackMode, results, total: results.length, ...(note ? { note } : {}) };
|
|
164
174
|
}
|
|
165
175
|
|
|
166
176
|
function runFtsQuery(ftsQuery: string, limit: number): SearchResult[] {
|
|
@@ -1566,6 +1576,7 @@ export type DudePageResult = {
|
|
|
1566
1576
|
last_edited: string | null;
|
|
1567
1577
|
word_count: number | null;
|
|
1568
1578
|
images: DudeImageResult[];
|
|
1579
|
+
truncated?: { text_total: number; code_total: number };
|
|
1569
1580
|
};
|
|
1570
1581
|
|
|
1571
1582
|
/** Search archived Dude wiki pages via FTS, with AND→OR fallback. */
|
|
@@ -1596,7 +1607,9 @@ function runDudeFtsQuery(ftsQuery: string, limit: number): DudeSearchResult[] {
|
|
|
1596
1607
|
FROM dude_pages_fts fts
|
|
1597
1608
|
JOIN dude_pages dp ON dp.id = fts.rowid
|
|
1598
1609
|
WHERE dude_pages_fts MATCH ?
|
|
1599
|
-
ORDER BY
|
|
1610
|
+
ORDER BY bm25(dude_pages_fts, 3.0, 2.0, 1.0, 0.5),
|
|
1611
|
+
CASE dp.version WHEN 'v6' THEN 0 ELSE 1 END,
|
|
1612
|
+
dp.title
|
|
1600
1613
|
LIMIT ?`,
|
|
1601
1614
|
)
|
|
1602
1615
|
.all(ftsQuery, limit) as DudeSearchResult[];
|
|
@@ -1605,8 +1618,9 @@ function runDudeFtsQuery(ftsQuery: string, limit: number): DudeSearchResult[] {
|
|
|
1605
1618
|
}
|
|
1606
1619
|
}
|
|
1607
1620
|
|
|
1608
|
-
/** Get a Dude wiki page by ID or title, with associated images.
|
|
1609
|
-
|
|
1621
|
+
/** Get a Dude wiki page by ID or title, with associated images.
|
|
1622
|
+
* maxLength: if set, truncates text+code combined and adds a truncated field. */
|
|
1623
|
+
export function getDudePage(idOrTitle: number | string, maxLength?: number): DudePageResult | null {
|
|
1610
1624
|
const row =
|
|
1611
1625
|
typeof idOrTitle === "number"
|
|
1612
1626
|
? db.prepare("SELECT * FROM dude_pages WHERE id = ?").get(idOrTitle)
|
|
@@ -1616,6 +1630,21 @@ export function getDudePage(idOrTitle: number | string): DudePageResult | null {
|
|
|
1616
1630
|
page.images = db
|
|
1617
1631
|
.prepare("SELECT id, filename, alt_text, caption, local_path, original_url, wayback_url FROM dude_images WHERE page_id = ? ORDER BY sort_order")
|
|
1618
1632
|
.all(page.id) as DudeImageResult[];
|
|
1633
|
+
|
|
1634
|
+
if (maxLength) {
|
|
1635
|
+
const textLen = page.text.length;
|
|
1636
|
+
const codeLen = page.code?.length ?? 0;
|
|
1637
|
+
if (textLen + codeLen > maxLength) {
|
|
1638
|
+
const codeBudget = Math.min(codeLen, Math.floor(maxLength * 0.2));
|
|
1639
|
+
const textBudget = maxLength - codeBudget;
|
|
1640
|
+
page.truncated = { text_total: textLen, code_total: codeLen };
|
|
1641
|
+
page.text = textLen > textBudget ? `${page.text.slice(0, textBudget)}\n\n[... truncated — ${textLen} chars total, showing first ${textBudget}]` : page.text;
|
|
1642
|
+
if (page.code && codeLen > codeBudget) {
|
|
1643
|
+
page.code = `${page.code.slice(0, codeBudget)}\n# [... truncated — ${codeLen} chars total]`;
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1619
1648
|
return page;
|
|
1620
1649
|
}
|
|
1621
1650
|
|