@zosmaai/pi-llm-wiki 0.2.1 → 0.3.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/.github/codeql/codeql-config.yml +12 -0
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/codeql.yml +1 -1
- package/.github/workflows/release.yml +8 -0
- package/README.md +24 -0
- package/docs/configuration.md +6 -0
- package/extensions/llm-wiki/lib/source-packet.ts +93 -4
- package/package.json +1 -1
- package/test/llm-wiki.test.ts +208 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
name: "CodeQL Config"
|
|
2
|
+
|
|
3
|
+
queries:
|
|
4
|
+
- uses: security-and-quality
|
|
5
|
+
|
|
6
|
+
# Suppress false positives for temporary file creation.
|
|
7
|
+
# This is a single-user CLI tool — TOCTOU race conditions on temp directories
|
|
8
|
+
# are not a valid threat model. The alerts only fire because tests pass
|
|
9
|
+
# os.tmpdir() paths into production capture functions.
|
|
10
|
+
query-filters:
|
|
11
|
+
- exclude:
|
|
12
|
+
id: js/insecure-temporary-file
|
package/.github/workflows/ci.yml
CHANGED
|
@@ -43,6 +43,14 @@ jobs:
|
|
|
43
43
|
node -e "const p=require('./package.json'); p.version='${{ steps.version.outputs.VERSION }}'; require('fs').writeFileSync('./package.json', JSON.stringify(p, null, 2)+'\n')"
|
|
44
44
|
npx biome check --fix package.json 2>&1
|
|
45
45
|
|
|
46
|
+
- name: Commit version bump back to main
|
|
47
|
+
run: |
|
|
48
|
+
git config user.name "github-actions[bot]"
|
|
49
|
+
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
50
|
+
git add package.json
|
|
51
|
+
git commit -m "chore(release): v${{ steps.version.outputs.VERSION }}"
|
|
52
|
+
git push origin HEAD:main
|
|
53
|
+
|
|
46
54
|
- name: Generate Release Notes
|
|
47
55
|
id: notes
|
|
48
56
|
run: |
|
package/README.md
CHANGED
|
@@ -42,6 +42,14 @@ Drop sources into `raw/`, then:
|
|
|
42
42
|
| `wiki_log_event` | Record custom event |
|
|
43
43
|
| `wiki_watch` | Schedule auto-updates |
|
|
44
44
|
|
|
45
|
+
## Features
|
|
46
|
+
|
|
47
|
+
- **Configurable PDF extraction** — MarkItDown timeout adjustable via `WIKI_MARKITDOWN_TIMEOUT_MS` env var
|
|
48
|
+
- **Smart content detection** — PDF bytes sniffed even from non-`.pdf` URLs, never written as markdown
|
|
49
|
+
- **Original artifacts preserved** — URL captures save the fetched payload under `original/source.*`
|
|
50
|
+
- **Clickable source links** — Captured URLs render as clickable Markdown links in source pages
|
|
51
|
+
- **Reliable prompt forwarding** — Slash commands properly forward user arguments to the model
|
|
52
|
+
|
|
45
53
|
## Architecture
|
|
46
54
|
|
|
47
55
|
Four layers with clear ownership:
|
|
@@ -67,6 +75,22 @@ Read [docs/architecture.md](docs/architecture.md) for details.
|
|
|
67
75
|
|
|
68
76
|
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
69
77
|
|
|
78
|
+
## Star History
|
|
79
|
+
|
|
80
|
+
[](https://star-history.com/#zosmaai/pi-llm-wiki&Date)
|
|
81
|
+
|
|
82
|
+
## Contributors
|
|
83
|
+
|
|
84
|
+
<a href="https://github.com/zosmaai/pi-llm-wiki/graphs/contributors">
|
|
85
|
+
<img src="https://contrib.rocks/image?repo=zosmaai/pi-llm-wiki" alt="Contributors" />
|
|
86
|
+
</a>
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
<div align="center">
|
|
91
|
+
<sub>Built with ❤️ by <a href="https://github.com/zosmaai">zosmaai</a></sub>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
70
94
|
## License
|
|
71
95
|
|
|
72
96
|
MIT
|
package/docs/configuration.md
CHANGED
|
@@ -23,6 +23,12 @@ Wiki configuration lives in `.wiki/config.json`.
|
|
|
23
23
|
| `auto_fix_lint` | false | Auto-fix lint issues |
|
|
24
24
|
| `batch_ingest_size` | 3 | Sources processed per ingest batch |
|
|
25
25
|
|
|
26
|
+
## Environment Variables
|
|
27
|
+
|
|
28
|
+
| Variable | Default | Description |
|
|
29
|
+
| ----------------------------- | ------- | ----------------------------------------------- |
|
|
30
|
+
| `WIKI_MARKITDOWN_TIMEOUT_MS` | 180000 | Timeout (ms) for MarkItDown PDF/text extraction |
|
|
31
|
+
|
|
26
32
|
## Page Frontmatter
|
|
27
33
|
|
|
28
34
|
```yaml
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { extname, join } from "node:path";
|
|
3
3
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
4
4
|
import { appendEvent } from "./metadata.js";
|
|
5
5
|
import { type VaultPaths, exec, fmtDate, nextSourceId, readText, writeJson } from "./utils.js";
|
|
@@ -52,6 +52,39 @@ function pdfExtractionFailureMessage(source: string): string {
|
|
|
52
52
|
return `_PDF content could not be converted to markdown from ${source}. Try increasing WIKI_MARKITDOWN_TIMEOUT_MS._\n`;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const URL_ORIGINAL_EXTENSIONS = new Set([".html", ".htm", ".md", ".pdf", ".txt", ".xml", ".json"]);
|
|
56
|
+
|
|
57
|
+
function originalFileNameForUrl(url: string): string {
|
|
58
|
+
try {
|
|
59
|
+
const parsed = new URL(url);
|
|
60
|
+
const ext = extname(parsed.pathname).toLowerCase();
|
|
61
|
+
if (URL_ORIGINAL_EXTENSIONS.has(ext)) return `source${ext}`;
|
|
62
|
+
} catch {
|
|
63
|
+
const path = url.split(/[?#]/, 1)[0] ?? "";
|
|
64
|
+
const ext = extname(path).toLowerCase();
|
|
65
|
+
if (URL_ORIGINAL_EXTENSIONS.has(ext)) return `source${ext}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return "source.html";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function preserveUrlOriginal(
|
|
72
|
+
pi: ExtensionAPI,
|
|
73
|
+
packetPath: string,
|
|
74
|
+
url: string,
|
|
75
|
+
signal?: AbortSignal,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
const originalPath = join(packetPath, "original", originalFileNameForUrl(url));
|
|
78
|
+
try {
|
|
79
|
+
await exec(pi, "curl", ["-sL", "--max-time", "30", "-o", originalPath, url], {
|
|
80
|
+
signal,
|
|
81
|
+
timeout: 35_000,
|
|
82
|
+
});
|
|
83
|
+
} catch {
|
|
84
|
+
// Preserve best-effort extraction behavior even when the original artifact cannot be saved.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
55
88
|
/** Capture a URL into a source packet. */
|
|
56
89
|
export async function captureUrl(
|
|
57
90
|
pi: ExtensionAPI,
|
|
@@ -65,6 +98,8 @@ export async function captureUrl(
|
|
|
65
98
|
mkdirSync(join(packetPath, "original"), { recursive: true });
|
|
66
99
|
mkdirSync(join(packetPath, "attachments"), { recursive: true });
|
|
67
100
|
|
|
101
|
+
await preserveUrlOriginal(pi, packetPath, url, signal);
|
|
102
|
+
|
|
68
103
|
// Try to fetch and extract content
|
|
69
104
|
let extracted = "";
|
|
70
105
|
let title = url;
|
|
@@ -171,12 +206,20 @@ export async function captureFile(
|
|
|
171
206
|
mkdirSync(join(packetPath, "original"), { recursive: true });
|
|
172
207
|
mkdirSync(join(packetPath, "attachments"), { recursive: true });
|
|
173
208
|
|
|
174
|
-
const
|
|
209
|
+
const lowerPath = filePath.toLowerCase();
|
|
210
|
+
const isPdf = lowerPath.endsWith(".pdf");
|
|
211
|
+
const isXml = lowerPath.endsWith(".xml");
|
|
175
212
|
const content = isPdf ? "" : readText(filePath);
|
|
176
213
|
const fileName = filePath.split("/").pop() || "unknown";
|
|
177
214
|
|
|
178
|
-
// Try MarkItDown for PDFs.
|
|
179
215
|
let extracted = content;
|
|
216
|
+
|
|
217
|
+
// Convert XML to markdown
|
|
218
|
+
if (isXml && content) {
|
|
219
|
+
extracted = xmlToMarkdown(content);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Try MarkItDown for PDFs.
|
|
180
223
|
if (isPdf) {
|
|
181
224
|
const markitdown = await exec(
|
|
182
225
|
pi,
|
|
@@ -274,7 +317,7 @@ export function captureText(paths: VaultPaths, text: string, title?: string): Ca
|
|
|
274
317
|
function buildSourcePageSkeleton(manifest: Record<string, unknown>, extracted: string): string {
|
|
275
318
|
const id = String(manifest.id);
|
|
276
319
|
const title = String(manifest.title || id);
|
|
277
|
-
const url = manifest.url ? `\n> _Original: ${manifest.url}_` : "";
|
|
320
|
+
const url = manifest.url ? `\n> _Original: [${manifest.url}](${manifest.url})_` : "";
|
|
278
321
|
const format = String(manifest.format || "unknown");
|
|
279
322
|
const captured = String(manifest.captured || fmtDate());
|
|
280
323
|
|
|
@@ -328,12 +371,58 @@ status: skeleton
|
|
|
328
371
|
`;
|
|
329
372
|
}
|
|
330
373
|
|
|
374
|
+
/** Basic XML to markdown conversion: strip tags while preserving text structure. */
|
|
375
|
+
function xmlToMarkdown(xml: string): string {
|
|
376
|
+
// Extract title from first <title> or root element
|
|
377
|
+
let title = "";
|
|
378
|
+
const titleMatch = xml.match(/<title[^>]*>([^<]*)<\/title>/i);
|
|
379
|
+
if (titleMatch) title = titleMatch[1].trim();
|
|
380
|
+
|
|
381
|
+
// Strip XML declaration and doctype
|
|
382
|
+
let text = xml.replace(/<\?xml[^>]*\?>\s*/gi, "");
|
|
383
|
+
text = text.replace(/<!DOCTYPE[^>]*>\s*/gi, "");
|
|
384
|
+
|
|
385
|
+
// Replace block-level tags with newlines
|
|
386
|
+
text = text.replace(/<\/(p|div|section|article|li|h\d|tr|blockquote|pre)>/gi, "\n");
|
|
387
|
+
text = text.replace(/<br\s*\/?>/gi, "\n");
|
|
388
|
+
|
|
389
|
+
// Strip remaining tags — match < followed by tag name characters to >
|
|
390
|
+
// Using a loop to handle malformed/broken tags that lack a closing >
|
|
391
|
+
let prev = "";
|
|
392
|
+
while (prev !== text) {
|
|
393
|
+
prev = text;
|
|
394
|
+
text = text.replace(/<[a-zA-Z\/!?][^>]*>/g, "");
|
|
395
|
+
}
|
|
396
|
+
// Remove any stray < that didn't form a complete tag
|
|
397
|
+
text = text.replace(/</g, "");
|
|
398
|
+
|
|
399
|
+
// Decode XML entities in a single pass to avoid double-unescaping
|
|
400
|
+
text = text.replace(/&(?:amp|lt|gt|quot|#\d+);/gi, (entity) => {
|
|
401
|
+
const map: Record<string, string> = { "&": "&", "<": "<", ">": ">", """: '"' };
|
|
402
|
+
const lower = entity.toLowerCase();
|
|
403
|
+
if (map[lower]) return map[lower];
|
|
404
|
+
if (lower.startsWith("&#")) return String.fromCodePoint(Number.parseInt(entity.slice(2, -1)));
|
|
405
|
+
return entity;
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// Clean up excessive blank lines
|
|
409
|
+
text = text.replace(/\n{3,}/g, "\n\n").trim();
|
|
410
|
+
|
|
411
|
+
if (!text) return xml; // fallback: return raw if stripping produced nothing
|
|
412
|
+
|
|
413
|
+
const lines = [];
|
|
414
|
+
if (title) lines.push(`# ${title}\n`);
|
|
415
|
+
lines.push(text);
|
|
416
|
+
return lines.join("\n\n");
|
|
417
|
+
}
|
|
418
|
+
|
|
331
419
|
function guessFormat(filePath: string): string {
|
|
332
420
|
const lower = filePath.toLowerCase();
|
|
333
421
|
if (lower.endsWith(".pdf")) return "pdf";
|
|
334
422
|
if (lower.endsWith(".md")) return "markdown";
|
|
335
423
|
if (lower.endsWith(".txt")) return "text";
|
|
336
424
|
if (lower.endsWith(".html") || lower.endsWith(".htm")) return "html";
|
|
425
|
+
if (lower.endsWith(".xml")) return "xml";
|
|
337
426
|
if (lower.endsWith(".docx")) return "docx";
|
|
338
427
|
return "file";
|
|
339
428
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zosmaai/pi-llm-wiki",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "LLM Wiki for Pi — self-maintaining knowledge base following Karpathy's pattern. Obsidian-friendly, auto-updating, personal & company wiki.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/test/llm-wiki.test.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
6
|
+
import { captureFile, captureText, captureUrl } from "../extensions/llm-wiki/lib/source-packet.js";
|
|
7
|
+
import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
|
|
6
8
|
|
|
7
9
|
// ─── Helpers ────────────────────────────────────────────
|
|
8
10
|
|
|
@@ -232,6 +234,212 @@ describe("skill frontmatter validation", () => {
|
|
|
232
234
|
|
|
233
235
|
// ─── Wiki Directory Structure Tests ─────────────────────
|
|
234
236
|
|
|
237
|
+
describe("source packet capture", () => {
|
|
238
|
+
const html = "<html><head><title>Example Page</title></head><body>Hello</body></html>";
|
|
239
|
+
const pdfBytes = "%PDF-1.7\n1 0 obj\n<</Type/Catalog>>\nendobj\n";
|
|
240
|
+
|
|
241
|
+
beforeEach(() => {
|
|
242
|
+
tempDir = join(tmpdir(), `pi-llm-wiki-capture-${Date.now()}`);
|
|
243
|
+
mkdirSync(tempDir, { recursive: true });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
afterEach(() => {
|
|
247
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
/** Build a mock pi.exec that optionally handles curl -o file writes. */
|
|
251
|
+
function mockPi(stdout?: string, writeOriginal = true) {
|
|
252
|
+
return {
|
|
253
|
+
exec: async (command: string, args: string[]) => {
|
|
254
|
+
if (command === "sh") return { stdout: "no\n", stderr: "", code: 0 };
|
|
255
|
+
if (command === "curl" && args.includes("-o")) {
|
|
256
|
+
if (writeOriginal) {
|
|
257
|
+
const outputPath = args[args.indexOf("-o") + 1];
|
|
258
|
+
writeFileSync(outputPath, stdout ?? html, "utf-8");
|
|
259
|
+
}
|
|
260
|
+
return { stdout: "", stderr: "", code: 0 };
|
|
261
|
+
}
|
|
262
|
+
if (command === "curl") return { stdout: stdout ?? html, stderr: "", code: 0 };
|
|
263
|
+
throw new Error(`Unexpected command: ${command}`);
|
|
264
|
+
},
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function makePaths() {
|
|
269
|
+
const p = getVaultPaths(join(tempDir, `wiki-${Math.random().toString(36).slice(2)}`));
|
|
270
|
+
ensureVaultStructure(p);
|
|
271
|
+
return p;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
it("should preserve the original artifact for URL captures and render clickable links", async () => {
|
|
275
|
+
const paths = makePaths();
|
|
276
|
+
const pi = mockPi();
|
|
277
|
+
|
|
278
|
+
const url = "https://example.com/article";
|
|
279
|
+
const result = await captureUrl(pi as never, paths, url);
|
|
280
|
+
|
|
281
|
+
// Original artifact preserved
|
|
282
|
+
expect(existsSync(join(result.packetPath, "original", "source.html"))).toBe(true);
|
|
283
|
+
expect(readFile(join(result.packetPath, "original", "source.html"))).toBe(html);
|
|
284
|
+
expect(readFile(join(result.packetPath, "extracted.md"))).toBe(html);
|
|
285
|
+
|
|
286
|
+
// Clickable URL in source page
|
|
287
|
+
const sourcePage = readFile(result.sourcePagePath);
|
|
288
|
+
expect(sourcePage).toContain(`> _Original: [${url}](${url})_`);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("should write PDF extraction failure message for .pdf URLs when MarkItDown is unavailable", async () => {
|
|
292
|
+
const paths = makePaths();
|
|
293
|
+
const pi = mockPi();
|
|
294
|
+
|
|
295
|
+
const url = "https://example.com/report.pdf";
|
|
296
|
+
const result = await captureUrl(pi as never, paths, url);
|
|
297
|
+
|
|
298
|
+
// Should NOT have written raw original for .pdf (no uvx)
|
|
299
|
+
expect(existsSync(join(result.packetPath, "original", "source.pdf"))).toBe(true);
|
|
300
|
+
|
|
301
|
+
// extracted.md should contain a failure message, not raw bytes
|
|
302
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
303
|
+
expect(extracted).not.toContain("%PDF-");
|
|
304
|
+
expect(extracted).toContain("PDF content could not be converted");
|
|
305
|
+
expect(extracted).toContain(url);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("should sniff %PDF- bytes from non-.pdf URLs and write a failure message instead", async () => {
|
|
309
|
+
const paths = makePaths();
|
|
310
|
+
// curl returns PDF bytes from a non-.pdf URL
|
|
311
|
+
const pi = mockPi(pdfBytes);
|
|
312
|
+
|
|
313
|
+
const url = "https://example.com/download?format=pdf";
|
|
314
|
+
const result = await captureUrl(pi as never, paths, url);
|
|
315
|
+
|
|
316
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
317
|
+
expect(extracted).not.toContain("%PDF-");
|
|
318
|
+
expect(extracted).toContain("PDF content could not be converted");
|
|
319
|
+
expect(extracted).toContain(url);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("should name original artifacts based on URL extension", async () => {
|
|
323
|
+
const cases: Array<{ url: string; expected: string }> = [
|
|
324
|
+
{ url: "https://example.com/article.html", expected: "source.html" },
|
|
325
|
+
{ url: "https://example.com/doc.pdf", expected: "source.pdf" },
|
|
326
|
+
{ url: "https://example.com/notes.md", expected: "source.md" },
|
|
327
|
+
{ url: "https://example.com/data.xml", expected: "source.xml" },
|
|
328
|
+
{ url: "https://example.com/readme.txt", expected: "source.txt" },
|
|
329
|
+
{ url: "https://example.com/page", expected: "source.html" },
|
|
330
|
+
{ url: "https://example.com/page?format=pdf", expected: "source.html" },
|
|
331
|
+
];
|
|
332
|
+
|
|
333
|
+
for (const { url, expected } of cases) {
|
|
334
|
+
const paths = makePaths();
|
|
335
|
+
const pi = mockPi();
|
|
336
|
+
const result = await captureUrl(pi as never, paths, url);
|
|
337
|
+
const originalFile = join(result.packetPath, "original", expected);
|
|
338
|
+
expect(existsSync(originalFile)).toBe(true);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("should render source page without an Original: line for text captures", async () => {
|
|
343
|
+
const paths = makePaths();
|
|
344
|
+
const result = captureText(paths, "Some text content", "My Note");
|
|
345
|
+
|
|
346
|
+
const sourcePage = readFile(result.sourcePagePath);
|
|
347
|
+
expect(sourcePage).toContain("# My Note");
|
|
348
|
+
expect(sourcePage).not.toContain("Original:");
|
|
349
|
+
expect(sourcePage).toContain("_Auto-preview: Some text content_");
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("should truncate auto-preview to 500 characters", async () => {
|
|
353
|
+
const paths = makePaths();
|
|
354
|
+
const longText = "A".repeat(1000);
|
|
355
|
+
const result = captureText(paths, longText, "Long Note");
|
|
356
|
+
|
|
357
|
+
const sourcePage = readFile(result.sourcePagePath);
|
|
358
|
+
// Preview should be 500 chars + "..."
|
|
359
|
+
expect(sourcePage).toContain(`_Auto-preview: ${"A".repeat(500)}..._`);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("should handle local PDF file capture failure message when MarkItDown is unavailable", async () => {
|
|
363
|
+
const paths = makePaths();
|
|
364
|
+
// Create a temporary PDF file
|
|
365
|
+
const pdfPath = join(tempDir, "test.pdf");
|
|
366
|
+
writeFileSync(pdfPath, pdfBytes, "utf-8");
|
|
367
|
+
|
|
368
|
+
const pi = mockPi();
|
|
369
|
+
const result = await captureFile(pi as never, paths, pdfPath);
|
|
370
|
+
|
|
371
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
372
|
+
expect(extracted).not.toContain("%PDF-");
|
|
373
|
+
expect(extracted).toContain("PDF content could not be converted");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("should copy local non-PDF file content to extracted.md", async () => {
|
|
377
|
+
const paths = makePaths();
|
|
378
|
+
const mdPath = join(tempDir, "notes.md");
|
|
379
|
+
writeFileSync(mdPath, "# My Notes\n\nHello world.", "utf-8");
|
|
380
|
+
|
|
381
|
+
const pi = mockPi();
|
|
382
|
+
const result = await captureFile(pi as never, paths, mdPath);
|
|
383
|
+
|
|
384
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
385
|
+
expect(extracted).toContain("# My Notes");
|
|
386
|
+
expect(extracted).toContain("Hello world.");
|
|
387
|
+
|
|
388
|
+
// Original file should be preserved
|
|
389
|
+
expect(existsSync(join(result.packetPath, "original", "notes.md"))).toBe(true);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("should convert XML files to readable markdown in extracted.md", async () => {
|
|
393
|
+
const paths = makePaths();
|
|
394
|
+
const xmlContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
395
|
+
<document>
|
|
396
|
+
<title>Project Report</title>
|
|
397
|
+
<section>
|
|
398
|
+
<heading>Findings</heading>
|
|
399
|
+
<p>The analysis revealed several key insights.</p>
|
|
400
|
+
<list>
|
|
401
|
+
<item>First finding</item>
|
|
402
|
+
<item>Second finding</item>
|
|
403
|
+
</list>
|
|
404
|
+
</section>
|
|
405
|
+
</document>`;
|
|
406
|
+
const xmlPath = join(tempDir, "report.xml");
|
|
407
|
+
writeFileSync(xmlPath, xmlContent, "utf-8");
|
|
408
|
+
|
|
409
|
+
const pi = mockPi();
|
|
410
|
+
const result = await captureFile(pi as never, paths, xmlPath);
|
|
411
|
+
|
|
412
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
413
|
+
// Should have extracted title
|
|
414
|
+
expect(extracted).toContain("Project Report");
|
|
415
|
+
// Should have extracted text content
|
|
416
|
+
expect(extracted).toContain("The analysis revealed several key insights.");
|
|
417
|
+
expect(extracted).toContain("First finding");
|
|
418
|
+
expect(extracted).toContain("Second finding");
|
|
419
|
+
// Should NOT contain raw XML tags
|
|
420
|
+
expect(extracted).not.toContain("<?xml");
|
|
421
|
+
expect(extracted).not.toContain("<document>");
|
|
422
|
+
expect(extracted).not.toContain("</document>");
|
|
423
|
+
|
|
424
|
+
// Original file should be preserved
|
|
425
|
+
expect(existsSync(join(result.packetPath, "original", "report.xml"))).toBe(true);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("should fall back to raw XML content when tag stripping produces nothing", async () => {
|
|
429
|
+
const paths = makePaths();
|
|
430
|
+
const xmlContent = `<?xml version="1.0"?><data><![CDATA[Hello]]></data>`;
|
|
431
|
+
const xmlPath = join(tempDir, "minimal.xml");
|
|
432
|
+
writeFileSync(xmlPath, xmlContent, "utf-8");
|
|
433
|
+
|
|
434
|
+
const pi = mockPi();
|
|
435
|
+
const result = await captureFile(pi as never, paths, xmlPath);
|
|
436
|
+
|
|
437
|
+
const extracted = readFile(join(result.packetPath, "extracted.md"));
|
|
438
|
+
// Should have the text content at minimum
|
|
439
|
+
expect(extracted).toContain("Hello");
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
235
443
|
describe("wiki directory structure", () => {
|
|
236
444
|
let wikiDir: string;
|
|
237
445
|
|