Commit the accumulated infrastructure work that was living only in the working tree: monitoring stack, emergency access/bot, gyro allocator, grimmory, adguard, backup audit and the OpenCode agent definitions. Also ignore Python bytecode, local archives and Nix/direnv artifacts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GTocXkGUUazHdKKd3r9k71
193 lines
6.8 KiB
JavaScript
193 lines
6.8 KiB
JavaScript
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, /<!-- grimmory:user:start -->/);
|
|
});
|
|
|
|
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("<!-- grimmory:user:end -->", "");
|
|
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",
|
|
}),
|
|
};
|
|
}
|