import assert from "node:assert/strict"; import { chmod, mkdir, mkdtemp, readFile, readdir, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import test from "node:test"; import { ObsidianSync, extractUserSection, progressPercent, renderBookNote, sanitizeFilename, } from "../src/obsidian.js"; const bundle = { book: { id: 42, libraryId: 1, title: "Test Book", readStatus: "READING", personalRating: 4, metadata: { title: "Test Book", authors: ["Ada Author"], categories: ["Science"], tags: ["AI"], moods: [], pageCount: 300, description: "Description", }, shelves: [{ name: "Reading" }], }, progress: { readProgress: 0.425, koreaderProgress: { percentage: 42.5 } }, notebook: [{ type: "annotation", text: "Quote", note: "Comment" }], annotations: [], bookmarks: [], notes: [], cfiNotes: [], reviews: [], sessions: [], }; test("render creates a study note with normalized progress", () => { const note = renderBookNote(bundle, "99 System/Export/Grimmory/Covers/42.jpg"); assert.match(note, /grimmory_id: 42/); assert.match(note, /status: active/); assert.match(note, /grimmory_progress: 42.5/); assert.match(note, /> Quote/); }); test("user section survives extraction", () => { const note = renderBookNote(bundle); const edited = note.replace("## Связанные заметки", "## Связанные заметки\n\n- [[AI]]"); assert.match(extractUserSection(edited), /\[\[AI\]\]/); }); test("filename and progress normalization are safe", () => { assert.equal(sanitizeFilename('A/B: C?'), "A-B- C-"); assert.equal(progressPercent(0.5), 50); assert.equal(progressPercent(75), 75); }); test("syncBook creates one note in the configured vault directory", async () => { const { vault, sync } = await tempSync(); const relative = await sync.syncBook(bundle); const files = await readdir(path.join(vault, "Books")); const content = await readFile(path.join(vault, relative), "utf8"); assert.equal(relative, "Books/Test Book — grimmory-42.md"); assert.deepEqual(files, ["Test Book — grimmory-42.md"]); assert.match(content, /grimmory_id: 42/); assert.match(content, //); }); test("syncBook preserves the user section and renames title changes without duplicates", async () => { const { vault, sync } = await tempSync(); const first = await sync.syncBook({ ...bundle, book: { ...bundle.book, title: "Old Title", metadata: { ...bundle.book.metadata, title: "Old Title" } }, }); const edited = (await readFile(path.join(vault, first), "utf8")).replace( "## Связанные заметки", "## Связанные заметки\n\n- [[User Link]]", ); await writeFile(path.join(vault, first), edited); const second = await sync.syncBook({ ...bundle, book: { ...bundle.book, title: "New Title", metadata: { ...bundle.book.metadata, title: "New Title" } }, }); const files = await readdir(path.join(vault, "Books")); const content = await readFile(path.join(vault, second), "utf8"); assert.equal(second, "Books/New Title — grimmory-42.md"); assert.deepEqual(files, ["New Title — grimmory-42.md"]); assert.match(content, /\[\[User Link\]\]/); }); test("saveCover writes only under the configured covers directory", async () => { const { vault, sync } = await tempSync(); const client = { cover: async () => new Response(Uint8Array.from([0xff, 0xd8, 0xff]), { headers: { "content-type": "application/json" }, }), }; const relative = await sync.saveCover(client, 42); const bytes = await readFile(path.join(vault, relative)); assert.equal(relative, "Covers/42.jpg"); assert.deepEqual([...bytes], [0xff, 0xd8, 0xff]); assert.throws( () => new ObsidianSync({ vaultPath: vault, booksDir: "Books", coversDir: "../Covers", indexScript: "index.py" }), /Path escapes Obsidian vault/, ); }); test("syncBook ignores body IDs and refuses duplicate frontmatter IDs", async () => { const { vault, sync } = await tempSync(); await mkdir(path.join(vault, "Books"), { recursive: true }); await writeFile(path.join(vault, "Books/unrelated.md"), "Body mentions\ngrimmory_id: 42\n"); await sync.syncBook(bundle); assert.equal((await readdir(path.join(vault, "Books"))).length, 2); await writeFile( path.join(vault, "Books/duplicate.md"), "---\ntitle: duplicate\ngrimmory_id: 42\n---\n", ); await assert.rejects(() => sync.syncBook(bundle), /Duplicate Obsidian notes/); }); test("syncBook refuses to overwrite a generated note with broken user markers", async () => { const { vault, sync } = await tempSync(); const relative = await sync.syncBook(bundle); const file = path.join(vault, relative); const broken = (await readFile(file, "utf8")).replace("", ""); await writeFile(file, broken); await assert.rejects(() => sync.syncBook(bundle), /invalid user markers/); assert.equal(await readFile(file, "utf8"), broken); }); test("syncBook rejects malformed or repeated frontmatter IDs", async () => { for (const marker of ["grimmory_id: 42 # comment", "grimmory_id: 42\ngrimmory_id: 42"]) { const { vault, sync } = await tempSync(); await mkdir(path.join(vault, "Books"), { recursive: true }); await writeFile(path.join(vault, "Books/bad.md"), `---\ntitle: Bad\n${marker}\n---\n`); await assert.rejects(() => sync.syncBook(bundle), /Ambiguous or malformed/); } }); test("saveCover rejects a covers-directory symlink outside the vault", async () => { const { vault, sync } = await tempSync(); const outside = await mkdtemp(path.join(os.tmpdir(), "grimmory-cover-outside-")); await symlink(outside, path.join(vault, "Covers")); let requested = false; await assert.rejects( () => sync.saveCover({ cover: async () => { requested = true; } }, 42), /Resolved path escapes Obsidian vault/, ); assert.equal(requested, false); }); test("rebuildIndex invokes the configured script inside the temporary vault", async () => { const { vault, sync } = await tempSync(); await writeFile( path.join(vault, "index.py"), [ "import os, pathlib, sys", "pathlib.Path('index-called.txt').write_text(sys.argv[1] + '\\n' + os.getcwd())", "print('rebuilt')", ].join("\n"), ); await chmod(path.join(vault, "index.py"), 0o700); assert.equal(await sync.rebuildIndex(), "rebuilt"); const marker = await readFile(path.join(vault, "index-called.txt"), "utf8"); assert.equal(marker, `rebuild\n${vault}`); }); async function tempSync() { const vault = await mkdtemp(path.join(os.tmpdir(), "grimmory-obsidian-test-")); return { vault, sync: new ObsidianSync({ vaultPath: vault, booksDir: "Books", coversDir: "Covers", indexScript: "index.py", }), }; }