Capture current Ansible control plane state
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
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
# Grimmory OpenCode MCP
|
||||
|
||||
Read-only Grimmory `v3.2.4` API integration with manual synchronization to
|
||||
individual Obsidian book notes.
|
||||
|
||||
## Security
|
||||
|
||||
- Grimmory mutations are not implemented.
|
||||
- Authentication uses the current user's local login because annotations and
|
||||
reading progress are user-scoped.
|
||||
- The password is read from a mode `0600` file and is never passed in argv.
|
||||
- Access and refresh tokens remain in process memory only.
|
||||
|
||||
## Configure
|
||||
|
||||
```bash
|
||||
cd tools/grimmory-mcp
|
||||
npm install
|
||||
npm run configure
|
||||
```
|
||||
|
||||
The default password file is:
|
||||
|
||||
```text
|
||||
~/.config/opencode/secrets/grimmory_password
|
||||
```
|
||||
|
||||
## Obsidian behavior
|
||||
|
||||
- Notes: `90 Library/Books/<title> — grimmory-<id>.md`
|
||||
- Covers: `99 System/Export/Grimmory/Covers/<id>.<ext>`
|
||||
- Existing notes are matched by `grimmory_id`, so title changes rename instead
|
||||
of duplicate the note.
|
||||
- Text between `grimmory:user:start` and `grimmory:user:end` is preserved.
|
||||
- Generated metadata, progress, annotations and sessions are replaced from
|
||||
Grimmory on every sync.
|
||||
- `99 System/INDEX.md` is rebuilt after a successful manual sync.
|
||||
|
||||
## OpenCode
|
||||
|
||||
Restart OpenCode after changing its MCP configuration. Run `/grimmory-sync` to
|
||||
synchronize the complete accessible library manually.
|
||||
Generated
+1182
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "grimmory-opencode-mcp",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Read-only Grimmory MCP server with manual Obsidian synchronization",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"configure": "node src/configure.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.30.0",
|
||||
"zod": "3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { constants } from "node:fs";
|
||||
import { open } from "node:fs/promises";
|
||||
|
||||
function unwrap(payload) {
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
!Array.isArray(payload) &&
|
||||
Object.hasOwn(payload, "data")
|
||||
) {
|
||||
return payload.data;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export class GrimmoryClient {
|
||||
constructor({ baseUrl, username, passwordFile, fetchImpl = fetch }) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
this.username = username;
|
||||
this.passwordFile = passwordFile;
|
||||
this.fetch = fetchImpl;
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.loginPromise = null;
|
||||
this.refreshPromise = null;
|
||||
}
|
||||
|
||||
async credentials() {
|
||||
let password;
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(
|
||||
this.passwordFile,
|
||||
constants.O_RDONLY | constants.O_NOFOLLOW,
|
||||
);
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile()) {
|
||||
throw new Error("Grimmory password path must be a regular file");
|
||||
}
|
||||
if (process.getuid && info.uid !== process.getuid()) {
|
||||
throw new Error("Grimmory password file must be owned by the current user");
|
||||
}
|
||||
if ((info.mode & 0o077) !== 0) {
|
||||
throw new Error("Grimmory password file must not be accessible by group or others");
|
||||
}
|
||||
password = await handle.readFile("utf8");
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") {
|
||||
throw new Error(
|
||||
`Grimmory password file is missing: ${this.passwordFile}. Run npm run configure in the MCP directory.`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
await handle?.close();
|
||||
}
|
||||
if (!password) throw new Error("Grimmory password file is empty");
|
||||
return { username: this.username, password };
|
||||
}
|
||||
|
||||
async login(attempt = 0) {
|
||||
const response = await this.fetch(`${this.baseUrl}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(await this.credentials()),
|
||||
});
|
||||
let payload;
|
||||
try {
|
||||
payload = await this.parseResponse(response);
|
||||
} catch (error) {
|
||||
// v3.2.4 can generate the same refresh JWT for concurrent logins in one second.
|
||||
if (error.status === 400 && attempt === 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1100));
|
||||
return this.login(1);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const tokens = unwrap(payload);
|
||||
if (!tokens?.accessToken || !tokens?.refreshToken) {
|
||||
throw new Error("Grimmory login response does not contain tokens");
|
||||
}
|
||||
this.accessToken = tokens.accessToken;
|
||||
this.refreshToken = tokens.refreshToken;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
if (!this.refreshToken) return this.login();
|
||||
if (!this.refreshPromise) {
|
||||
this.refreshPromise = (async () => {
|
||||
const response = await this.fetch(`${this.baseUrl}/api/v1/auth/refresh`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken: this.refreshToken }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
return this.login();
|
||||
}
|
||||
const tokens = unwrap(await this.parseResponse(response));
|
||||
this.accessToken = tokens.accessToken;
|
||||
this.refreshToken = tokens.refreshToken;
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return this.refreshPromise;
|
||||
}
|
||||
|
||||
async ensureLogin() {
|
||||
if (this.accessToken) return;
|
||||
if (!this.loginPromise) {
|
||||
this.loginPromise = this.login().finally(() => {
|
||||
this.loginPromise = null;
|
||||
});
|
||||
}
|
||||
await this.loginPromise;
|
||||
}
|
||||
|
||||
async parseResponse(response) {
|
||||
if (response.status === 204) return null;
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = text;
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload?.message || payload?.error || text || response.statusText;
|
||||
throw Object.assign(
|
||||
new Error(`Grimmory API ${response.status}: ${message}`),
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async request(path, { optional = false, raw = false, retry = true } = {}) {
|
||||
await this.ensureLogin();
|
||||
const response = await this.fetch(`${this.baseUrl}${path}`, {
|
||||
headers: { authorization: `Bearer ${this.accessToken}` },
|
||||
});
|
||||
if (response.status === 401 && retry) {
|
||||
await this.refresh();
|
||||
return this.request(path, { optional, raw, retry: false });
|
||||
}
|
||||
if (optional && [204, 404].includes(response.status)) return null;
|
||||
if (raw) {
|
||||
if (!response.ok) await this.parseResponse(response);
|
||||
return response;
|
||||
}
|
||||
return unwrap(await this.parseResponse(response));
|
||||
}
|
||||
|
||||
currentUser() {
|
||||
return this.request("/api/v1/users/me");
|
||||
}
|
||||
|
||||
async libraries() {
|
||||
try {
|
||||
return await this.request("/api/v1/app/libraries");
|
||||
} catch (error) {
|
||||
if (!error.message.startsWith("Grimmory API 500:")) throw error;
|
||||
const user = await this.currentUser();
|
||||
if (user.assignedLibraries?.length) return user.assignedLibraries;
|
||||
const books = await this.allBooks();
|
||||
const byLibrary = new Map();
|
||||
for (const book of books) {
|
||||
const entry = byLibrary.get(book.libraryId) || {
|
||||
id: book.libraryId,
|
||||
name: null,
|
||||
bookCount: 0,
|
||||
};
|
||||
entry.bookCount += 1;
|
||||
byLibrary.set(book.libraryId, entry);
|
||||
}
|
||||
for (const entry of byLibrary.values()) {
|
||||
const sample = books.find((book) => book.libraryId === entry.id);
|
||||
if (sample) {
|
||||
const detail = await this.request(
|
||||
`/api/v1/books/${sample.id}?withDescription=false`,
|
||||
);
|
||||
entry.name = detail.libraryName || `Library ${entry.id}`;
|
||||
}
|
||||
}
|
||||
return [...byLibrary.values()];
|
||||
}
|
||||
}
|
||||
|
||||
shelves() {
|
||||
return this.request("/api/v1/app/shelves");
|
||||
}
|
||||
|
||||
authors(params = {}) {
|
||||
return this.request(`/api/v1/app/authors?${query(params)}`);
|
||||
}
|
||||
|
||||
filterOptions(params = {}) {
|
||||
return this.request(`/api/v1/app/filter-options?${query(params)}`);
|
||||
}
|
||||
|
||||
books(params = {}) {
|
||||
return this.request(`/api/v1/app/books?${query(params)}`);
|
||||
}
|
||||
|
||||
searchBooks(search, params = {}) {
|
||||
return this.request(
|
||||
`/api/v1/app/books/search?${query({ q: search, ...params })}`,
|
||||
);
|
||||
}
|
||||
|
||||
async allBooks() {
|
||||
const books = [];
|
||||
const seenPages = new Set();
|
||||
for (let page = 0; page < 10_000; page += 1) {
|
||||
const result = await this.books({ page, size: 50, sort: "title", dir: "asc" });
|
||||
if (!Array.isArray(result?.content)) {
|
||||
throw new Error("Grimmory books response has no content array");
|
||||
}
|
||||
const signature = result.content.map((book) => book.id).join(",");
|
||||
if (seenPages.has(signature) && signature) {
|
||||
throw new Error("Grimmory books pagination repeated a page");
|
||||
}
|
||||
seenPages.add(signature);
|
||||
books.push(...result.content);
|
||||
if (!hasNextPage(result, page, 50)) return books;
|
||||
}
|
||||
throw new Error("Grimmory books pagination exceeded the safety limit");
|
||||
}
|
||||
|
||||
async springPage(path) {
|
||||
const entries = [];
|
||||
const seenPages = new Set();
|
||||
for (let page = 0; page < 10_000; page += 1) {
|
||||
const separator = path.includes("?") ? "&" : "?";
|
||||
const result = await this.request(`${path}${separator}page=${page}&size=100`);
|
||||
if (!Array.isArray(result?.content)) {
|
||||
throw new Error("Grimmory session response has no content array");
|
||||
}
|
||||
const signature = JSON.stringify(result.content.map((entry) => entry.id));
|
||||
if (seenPages.has(signature) && result.content.length) {
|
||||
throw new Error("Grimmory session pagination repeated a page");
|
||||
}
|
||||
seenPages.add(signature);
|
||||
entries.push(...result.content);
|
||||
if (!hasNextPage(result, page, 100)) return entries;
|
||||
}
|
||||
throw new Error("Grimmory session pagination exceeded the safety limit");
|
||||
}
|
||||
|
||||
async bookBundle(bookId) {
|
||||
const id = Number(bookId);
|
||||
const [book, progress, notebook, annotations, bookmarks, notes, cfiNotes, reviews, sessions] =
|
||||
await Promise.all([
|
||||
this.request(`/api/v1/books/${id}?withDescription=true`),
|
||||
this.request(`/api/v1/app/books/${id}/progress`, { optional: true }),
|
||||
this.request(`/api/v1/notebook/export?bookId=${id}`, { optional: true }),
|
||||
this.request(`/api/v1/annotations/book/${id}`, { optional: true }),
|
||||
this.request(`/api/v1/bookmarks/book/${id}`, { optional: true }),
|
||||
this.request(`/api/v1/book-notes/book/${id}`, { optional: true }),
|
||||
this.request(`/api/v2/book-notes/book/${id}`, { optional: true }),
|
||||
this.request(`/api/v1/reviews/book/${id}`, { optional: true }),
|
||||
this.springPage(`/api/v1/reading-sessions/book/${id}`),
|
||||
]);
|
||||
return {
|
||||
book,
|
||||
progress,
|
||||
notebook: notebook || [],
|
||||
annotations: annotations || [],
|
||||
bookmarks: bookmarks || [],
|
||||
notes: notes || [],
|
||||
cfiNotes: cfiNotes || [],
|
||||
reviews: reviews || [],
|
||||
sessions,
|
||||
};
|
||||
}
|
||||
|
||||
cover(bookId) {
|
||||
return this.request(`/api/v1/media/book/${Number(bookId)}/cover`, {
|
||||
optional: true,
|
||||
raw: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function query(values) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
for (const item of Array.isArray(value) ? value : [value]) {
|
||||
params.append(key, String(item));
|
||||
}
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function hasNextPage(result, currentPage, pageSize) {
|
||||
if (typeof result.hasNext === "boolean") return result.hasNext;
|
||||
if (typeof result.last === "boolean") return !result.last;
|
||||
const number = result.page?.number ?? result.number ?? currentPage;
|
||||
const totalPages = result.page?.totalPages ?? result.totalPages;
|
||||
if (totalPages !== undefined) return number + 1 < totalPages;
|
||||
if (result.content.length < pageSize) return false;
|
||||
throw new Error("Grimmory books response has no pagination metadata");
|
||||
}
|
||||
|
||||
export { unwrap };
|
||||
@@ -0,0 +1,42 @@
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const destination =
|
||||
process.env.GRIMMORY_PASSWORD_FILE ||
|
||||
path.join(os.homedir(), ".config/opencode/secrets/grimmory_password");
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
console.error("Run this command in an interactive terminal.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.stdout.write("Grimmory password: ");
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.setEncoding("utf8");
|
||||
|
||||
let password = "";
|
||||
process.stdin.on("data", async (character) => {
|
||||
if (character === "\u0003") process.exit(130);
|
||||
if (character === "\r" || character === "\n") {
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.pause();
|
||||
process.stdout.write("\n");
|
||||
if (!password) {
|
||||
console.error("Password cannot be empty.");
|
||||
process.exit(1);
|
||||
}
|
||||
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
||||
await writeFile(destination, password, { mode: 0o600 });
|
||||
await chmod(destination, 0o600);
|
||||
console.log(`Password saved to ${destination}`);
|
||||
return;
|
||||
}
|
||||
if (character === "\u007f") {
|
||||
password = password.slice(0, -1);
|
||||
return;
|
||||
}
|
||||
password += character;
|
||||
});
|
||||
@@ -0,0 +1,371 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const USER_START = "<!-- grimmory:user:start -->";
|
||||
const USER_END = "<!-- grimmory:user:end -->";
|
||||
|
||||
export class ObsidianSync {
|
||||
constructor({ vaultPath, booksDir, coversDir, indexScript }) {
|
||||
this.vaultPath = path.resolve(vaultPath);
|
||||
this.booksPath = safeJoin(this.vaultPath, booksDir);
|
||||
this.coversDir = coversDir;
|
||||
this.coversPath = safeJoin(this.vaultPath, coversDir);
|
||||
this.indexScript = safeJoin(this.vaultPath, indexScript);
|
||||
}
|
||||
|
||||
async ensureDirectories() {
|
||||
await mkdir(this.booksPath, { recursive: true });
|
||||
await mkdir(this.coversPath, { recursive: true });
|
||||
const realVault = await realpath(this.vaultPath);
|
||||
assertWithin(realVault, await realpath(this.booksPath));
|
||||
assertWithin(realVault, await realpath(this.coversPath));
|
||||
}
|
||||
|
||||
async findExistingNote(bookId) {
|
||||
let files = [];
|
||||
try {
|
||||
files = await readdir(this.booksPath);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
const matches = [];
|
||||
for (const file of files.filter((name) => name.endsWith(".md"))) {
|
||||
const filePath = path.join(this.booksPath, file);
|
||||
if (frontmatterId(await readFile(filePath, "utf8")) === Number(bookId)) {
|
||||
matches.push(filePath);
|
||||
}
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`Duplicate Obsidian notes for grimmory_id ${bookId}`);
|
||||
}
|
||||
return matches[0] || null;
|
||||
}
|
||||
|
||||
async saveCover(client, bookId) {
|
||||
await this.ensureDirectories();
|
||||
const response = await client.cover(bookId);
|
||||
if (!response) return null;
|
||||
const declaredSize = Number(response.headers.get("content-length"));
|
||||
if (declaredSize > 10 * 1024 * 1024) throw new Error("Grimmory cover exceeds 10 MB");
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (bytes.length > 10 * 1024 * 1024) throw new Error("Grimmory cover exceeds 10 MB");
|
||||
const extension = coverExtension(bytes);
|
||||
if (!extension) throw new Error("Grimmory cover response is not a supported image");
|
||||
const relative = path.join(this.coversDir, `${bookId}.${extension}`);
|
||||
const destination = safeJoin(this.vaultPath, relative);
|
||||
await atomicWrite(destination, bytes);
|
||||
return relative.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
async syncBook(bundle, coverPath = null) {
|
||||
await this.ensureDirectories();
|
||||
const title = bookTitle(bundle.book);
|
||||
const desiredPath = path.join(
|
||||
this.booksPath,
|
||||
`${sanitizeFilename(title)} — grimmory-${bundle.book.id}.md`,
|
||||
);
|
||||
const existingPath = await this.findExistingNote(bundle.book.id);
|
||||
let userSection = defaultUserSection();
|
||||
if (existingPath) {
|
||||
const existingContent = await readFile(existingPath, "utf8");
|
||||
if (!hasValidUserSection(existingContent)) {
|
||||
throw new Error(`Existing Grimmory note has invalid user markers: ${existingPath}`);
|
||||
}
|
||||
userSection = extractUserSection(existingContent);
|
||||
}
|
||||
if (desiredPath !== existingPath) {
|
||||
try {
|
||||
await lstat(desiredPath);
|
||||
throw new Error(`Refusing to overwrite unrelated Obsidian note: ${desiredPath}`);
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") throw error;
|
||||
}
|
||||
}
|
||||
await atomicWrite(desiredPath, renderBookNote(bundle, coverPath, userSection));
|
||||
if (existingPath && existingPath !== desiredPath) await rm(existingPath);
|
||||
return path.relative(this.vaultPath, desiredPath);
|
||||
}
|
||||
|
||||
async rebuildIndex() {
|
||||
const realVault = await realpath(this.vaultPath);
|
||||
const realScript = await realpath(this.indexScript);
|
||||
assertWithin(realVault, realScript);
|
||||
if (!(await stat(realScript)).isFile()) throw new Error("Vault index script is not a regular file");
|
||||
const { stdout } = await execFileAsync("python3", [realScript, "rebuild"], {
|
||||
cwd: this.vaultPath,
|
||||
timeout: 120_000,
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
}
|
||||
|
||||
export function renderBookNote(bundle, coverPath = null, userSection = defaultUserSection()) {
|
||||
const { book, progress = {}, notebook = [], annotations = [], bookmarks = [], notes = [], cfiNotes = [], reviews = [], sessions = [] } = bundle;
|
||||
const metadata = book.metadata || {};
|
||||
const title = bookTitle(book);
|
||||
const authors = metadata.authors || book.authors || [];
|
||||
const status = studyStatus(book.readStatus || progress.readStatus);
|
||||
const percent = progressPercent(progress.readProgress ?? book.readProgress);
|
||||
const updated = new Date().toISOString().slice(0, 10);
|
||||
const created = dateOnly(book.addedOn) || updated;
|
||||
const cover = coverPath ? `[[${coverPath}]]` : "";
|
||||
const frontmatter = [
|
||||
"---",
|
||||
`title: ${yamlString(title)}`,
|
||||
"type: study",
|
||||
"tags: [study, book, grimmory]",
|
||||
`created: ${created}`,
|
||||
`updated: ${updated}`,
|
||||
`aliases: ${yamlArray([title])}`,
|
||||
"kind: book",
|
||||
`status: ${status}`,
|
||||
"area:",
|
||||
"priority: medium",
|
||||
`author: ${yamlString(authors.join(", "))}`,
|
||||
`url: ${yamlString(`https://books.ada-dev.ru/book/${book.id}`)}`,
|
||||
`cover: ${yamlString(cover)}`,
|
||||
`pages: ${metadata.pageCount ?? ""}`,
|
||||
`started: ${firstSessionDate(sessions)}`,
|
||||
`finished: ${dateOnly(book.dateFinished)}`,
|
||||
"related: []",
|
||||
`grimmory_id: ${book.id}`,
|
||||
`grimmory_library_id: ${book.libraryId}`,
|
||||
`grimmory_read_status: ${yamlString(book.readStatus || progress.readStatus || "UNSET")}`,
|
||||
`grimmory_progress: ${percent ?? ""}`,
|
||||
`grimmory_rating: ${book.personalRating ?? ""}`,
|
||||
`grimmory_synced_at: ${yamlString(new Date().toISOString())}`,
|
||||
"---",
|
||||
].join("\n");
|
||||
|
||||
const info = [
|
||||
`# ${title}`,
|
||||
"",
|
||||
"<!-- grimmory:generated:start -->",
|
||||
"## Информация",
|
||||
`- **Авторы:** ${authors.join(", ") || "—"}`,
|
||||
`- **Серия:** ${metadata.seriesName || metadata.series || "—"}${metadata.seriesNumber ? ` #${metadata.seriesNumber}` : ""}`,
|
||||
`- **Издатель:** ${metadata.publisher || "—"}`,
|
||||
`- **Дата публикации:** ${metadata.publishedDate || "—"}`,
|
||||
`- **ISBN:** ${[metadata.isbn13, metadata.isbn10].filter(Boolean).join(", ") || "—"}`,
|
||||
`- **Язык:** ${metadata.language || "—"}`,
|
||||
`- **Страниц:** ${metadata.pageCount || "—"}`,
|
||||
`- **Категории:** ${joinList(metadata.categories)}`,
|
||||
`- **Теги:** ${joinList(metadata.tags)}`,
|
||||
`- **Настроения:** ${joinList(metadata.moods)}`,
|
||||
`- **Полки:** ${joinList((book.shelves || []).map((shelf) => shelf.name))}`,
|
||||
`- **Статус:** ${book.readStatus || progress.readStatus || "UNSET"}`,
|
||||
`- **Прогресс:** ${percent === null ? "—" : `${percent}%`}`,
|
||||
`- **Личная оценка:** ${book.personalRating || "—"}`,
|
||||
"",
|
||||
...(coverPath ? [`![[${coverPath}]]`, ""] : []),
|
||||
"## Описание",
|
||||
metadata.description || book.description || "Описание отсутствует.",
|
||||
"",
|
||||
renderProgress(progress),
|
||||
renderNotebook(notebook, annotations, bookmarks, notes, cfiNotes),
|
||||
renderSessions(sessions),
|
||||
renderReviews(reviews),
|
||||
"<!-- grimmory:generated:end -->",
|
||||
"",
|
||||
userSection.trim(),
|
||||
"",
|
||||
];
|
||||
return `${frontmatter}\n\n${info.join("\n")}`;
|
||||
}
|
||||
|
||||
function renderProgress(progress) {
|
||||
const entries = [
|
||||
["Общий", progress.readProgress],
|
||||
["EPUB", progress.epubProgress?.percentage],
|
||||
["PDF", progress.pdfProgress?.percentage],
|
||||
["CBX", progress.cbxProgress?.percentage],
|
||||
["Аудиокнига", progress.audiobookProgress?.percentage],
|
||||
["KOReader", progress.koreaderProgress?.percentage],
|
||||
].filter(([, value]) => value !== undefined && value !== null);
|
||||
const lines = ["## Прогресс чтения"];
|
||||
if (!entries.length) lines.push("Прогресс отсутствует.");
|
||||
for (const [label, value] of entries) {
|
||||
lines.push(`- **${label}:** ${progressPercent(value)}%`);
|
||||
}
|
||||
if (progress.koreaderProgress?.device) {
|
||||
lines.push(`- **Устройство KOReader:** ${progress.koreaderProgress.device}`);
|
||||
}
|
||||
if (progress.lastReadTime) lines.push(`- **Последнее чтение:** ${progress.lastReadTime}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderNotebook(notebook, annotations, bookmarks, notes, cfiNotes) {
|
||||
const lines = ["## Аннотации и заметки"];
|
||||
const entries = notebook.length
|
||||
? notebook.map((entry) => ({
|
||||
type: entry.type,
|
||||
chapter: entry.chapterTitle,
|
||||
text: entry.text,
|
||||
note: entry.note,
|
||||
createdAt: entry.createdAt,
|
||||
}))
|
||||
: [
|
||||
...annotations.map((entry) => ({ type: "annotation", chapter: entry.chapterTitle, text: entry.text, note: entry.note, createdAt: entry.createdAt })),
|
||||
...bookmarks.map((entry) => ({ type: "bookmark", text: entry.title, note: entry.notes, createdAt: entry.createdAt })),
|
||||
...notes.map((entry) => ({ type: "note", text: entry.title, note: entry.content, createdAt: entry.createdAt })),
|
||||
...cfiNotes.map((entry) => ({ type: "cfi-note", chapter: entry.chapterTitle, text: entry.selectedText, note: entry.noteContent, createdAt: entry.createdAt })),
|
||||
];
|
||||
if (!entries.length) lines.push("Аннотации отсутствуют.");
|
||||
for (const entry of entries) {
|
||||
lines.push(`### ${entry.chapter || entry.type || "Запись"}`);
|
||||
if (entry.text) lines.push(`> ${String(entry.text).replace(/\n/g, "\n> ")}`);
|
||||
if (entry.note) lines.push("", entry.note);
|
||||
if (entry.createdAt) lines.push("", `_${entry.createdAt}_`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderSessions(sessions) {
|
||||
const lines = ["## Сессии чтения"];
|
||||
if (!sessions.length) lines.push("Сессии отсутствуют.");
|
||||
for (const session of sessions) {
|
||||
lines.push(
|
||||
`- ${session.startTime} — ${Math.round((session.durationSeconds || 0) / 60)} мин, ${progressPercent(session.startProgress)}% → ${progressPercent(session.endProgress)}%`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderReviews(reviews) {
|
||||
const lines = ["## Внешние отзывы"];
|
||||
if (!reviews.length) lines.push("Отзывы отсутствуют.");
|
||||
for (const review of reviews) {
|
||||
lines.push(`### ${review.title || review.reviewerName || review.metadataProvider || "Отзыв"}`);
|
||||
lines.push(`- **Источник:** ${review.metadataProvider || "—"}`);
|
||||
if (review.rating) lines.push(`- **Оценка:** ${review.rating}`);
|
||||
if (review.body) lines.push("", review.body);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function extractUserSection(content) {
|
||||
const start = content.indexOf(USER_START);
|
||||
const end = content.indexOf(USER_END);
|
||||
if (start === -1 || end === -1 || end < start) return defaultUserSection();
|
||||
return content.slice(start, end + USER_END.length);
|
||||
}
|
||||
|
||||
export function hasValidUserSection(content) {
|
||||
const start = content.indexOf(USER_START);
|
||||
const end = content.indexOf(USER_END);
|
||||
return start !== -1 && end !== -1 && end > start && content.indexOf(USER_START, start + 1) === -1 && content.indexOf(USER_END, end + 1) === -1;
|
||||
}
|
||||
|
||||
export function defaultUserSection() {
|
||||
return `${USER_START}\n## Заметки\n\n## Связанные заметки\n\n${USER_END}`;
|
||||
}
|
||||
|
||||
export function sanitizeFilename(value) {
|
||||
const result = String(value)
|
||||
.replace(/[\\/:*?"<>|]/g, "-")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/[. ]+$/g, "")
|
||||
.trim();
|
||||
return (result || "Без названия").slice(0, 160);
|
||||
}
|
||||
|
||||
export function progressPercent(value) {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return null;
|
||||
return Math.round((number <= 1 ? number * 100 : number) * 10) / 10;
|
||||
}
|
||||
|
||||
function studyStatus(status) {
|
||||
return {
|
||||
READING: "active",
|
||||
RE_READING: "active",
|
||||
PAUSED: "paused",
|
||||
READ: "done",
|
||||
WONT_READ: "dropped",
|
||||
ABANDONED: "dropped",
|
||||
}[status] || "planned";
|
||||
}
|
||||
|
||||
function bookTitle(book) {
|
||||
return book.metadata?.title || book.title || `Книга ${book.id}`;
|
||||
}
|
||||
|
||||
function yamlString(value) {
|
||||
return JSON.stringify(value ?? "");
|
||||
}
|
||||
|
||||
function yamlArray(values) {
|
||||
return JSON.stringify(values.filter(Boolean));
|
||||
}
|
||||
|
||||
function joinList(values) {
|
||||
return Array.isArray(values) && values.length ? values.join(", ") : "—";
|
||||
}
|
||||
|
||||
function dateOnly(value) {
|
||||
return value ? String(value).slice(0, 10) : "";
|
||||
}
|
||||
|
||||
function firstSessionDate(sessions) {
|
||||
const dates = sessions.map((session) => session.startTime).filter(Boolean).sort();
|
||||
return dateOnly(dates[0]);
|
||||
}
|
||||
|
||||
function coverExtension(bytes) {
|
||||
if (bytes[0] === 0xff && bytes[1] === 0xd8) return "jpg";
|
||||
if (bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png";
|
||||
if (bytes.subarray(0, 6).toString("ascii").startsWith("GIF8")) return "gif";
|
||||
if (bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP") return "webp";
|
||||
return null;
|
||||
}
|
||||
|
||||
function safeJoin(root, relative) {
|
||||
const result = path.resolve(root, relative);
|
||||
if (result !== root && !result.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error(`Path escapes Obsidian vault: ${relative}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assertWithin(root, candidate) {
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) {
|
||||
throw new Error(`Resolved path escapes Obsidian vault: ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
function frontmatterId(content) {
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
if (!match) return null;
|
||||
const markers = [...match[1].matchAll(/^grimmory_id:\s*(.*?)\s*$/gm)];
|
||||
if (!markers.length) return null;
|
||||
if (markers.length !== 1 || !/^\d+$/.test(markers[0][1])) {
|
||||
throw new Error("Ambiguous or malformed grimmory_id in Obsidian frontmatter");
|
||||
}
|
||||
return Number(markers[0][1]);
|
||||
}
|
||||
|
||||
async function atomicWrite(destination, content) {
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
const temporary = `${destination}.tmp-${randomUUID()}`;
|
||||
let handle;
|
||||
try {
|
||||
handle = await open(
|
||||
temporary,
|
||||
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW,
|
||||
0o600,
|
||||
);
|
||||
await handle.writeFile(content);
|
||||
await handle.close();
|
||||
handle = null;
|
||||
await rename(temporary, destination);
|
||||
} finally {
|
||||
await handle?.close();
|
||||
await rm(temporary, { force: true });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env node
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { z } from "zod";
|
||||
import { GrimmoryClient } from "./client.js";
|
||||
import { ObsidianSync } from "./obsidian.js";
|
||||
|
||||
const config = {
|
||||
baseUrl: process.env.GRIMMORY_BASE_URL || "https://books.ada-dev.ru",
|
||||
username: process.env.GRIMMORY_USERNAME || "ada",
|
||||
passwordFile:
|
||||
process.env.GRIMMORY_PASSWORD_FILE ||
|
||||
`${process.env.HOME}/.config/opencode/secrets/grimmory_password`,
|
||||
vaultPath:
|
||||
process.env.OBSIDIAN_VAULT ||
|
||||
`${process.env.HOME}/Documents/Vaults/SecondBrain`,
|
||||
booksDir: process.env.OBSIDIAN_BOOKS_DIR || "90 Library/Books",
|
||||
coversDir:
|
||||
process.env.OBSIDIAN_COVERS_DIR ||
|
||||
"99 System/Export/Grimmory/Covers",
|
||||
indexScript:
|
||||
process.env.OBSIDIAN_INDEX_SCRIPT ||
|
||||
"99 System/Tools/agent-tools/vault_index.py",
|
||||
};
|
||||
|
||||
if (!config.baseUrl.startsWith("https://")) {
|
||||
throw new Error("GRIMMORY_BASE_URL must use HTTPS");
|
||||
}
|
||||
|
||||
const client = new GrimmoryClient(config);
|
||||
const obsidian = new ObsidianSync(config);
|
||||
const server = new McpServer({ name: "grimmory", version: "0.1.0" });
|
||||
|
||||
const jsonResult = (data, isError = false) => ({
|
||||
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
|
||||
structuredContent: { result: data },
|
||||
isError,
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
"current_user",
|
||||
{ description: "Return the current Grimmory user, permissions and assigned libraries." },
|
||||
async () => jsonResult(await client.currentUser()),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_libraries",
|
||||
{ description: "List Grimmory libraries accessible to the current user." },
|
||||
async () => jsonResult(await client.libraries()),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_shelves",
|
||||
{ description: "List Grimmory shelves accessible to the current user." },
|
||||
async () => jsonResult(await client.shelves()),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_books",
|
||||
{
|
||||
description: "List a page of Grimmory books with metadata and reading state.",
|
||||
inputSchema: {
|
||||
page: z.number().int().min(0).default(0),
|
||||
size: z.number().int().min(1).max(50).default(20),
|
||||
libraryId: z.number().int().positive().optional(),
|
||||
status: z.array(z.string()).optional(),
|
||||
sort: z.string().default("title"),
|
||||
dir: z.enum(["asc", "desc"]).default("asc"),
|
||||
},
|
||||
},
|
||||
async (input) => jsonResult(await client.books(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"search_books",
|
||||
{
|
||||
description: "Search Grimmory books by title, author or metadata.",
|
||||
inputSchema: {
|
||||
query: z.string().min(1),
|
||||
page: z.number().int().min(0).default(0),
|
||||
size: z.number().int().min(1).max(50).default(20),
|
||||
},
|
||||
},
|
||||
async ({ query, ...params }) =>
|
||||
jsonResult(await client.searchBooks(query, params)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_book",
|
||||
{
|
||||
description:
|
||||
"Return a rich book bundle: metadata, progress, annotations, bookmarks, notes, reviews and reading sessions.",
|
||||
inputSchema: { bookId: z.number().int().positive() },
|
||||
},
|
||||
async ({ bookId }) => jsonResult(await client.bookBundle(bookId)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"list_authors",
|
||||
{
|
||||
description: "List a page of Grimmory authors.",
|
||||
inputSchema: {
|
||||
page: z.number().int().min(0).default(0),
|
||||
size: z.number().int().min(1).max(50).default(30),
|
||||
search: z.string().optional(),
|
||||
},
|
||||
},
|
||||
async (input) => jsonResult(await client.authors(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"get_filter_options",
|
||||
{
|
||||
description:
|
||||
"Return available authors, categories, tags, moods, statuses and other catalog filter values.",
|
||||
inputSchema: { libraryId: z.number().int().positive().optional() },
|
||||
},
|
||||
async (input) => jsonResult(await client.filterOptions(input)),
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"sync_book_to_obsidian",
|
||||
{
|
||||
description:
|
||||
"Fetch one Grimmory book and update its Obsidian note while preserving the user notes/links section.",
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
||||
inputSchema: {
|
||||
bookId: z.number().int().positive(),
|
||||
includeCover: z.boolean().default(true),
|
||||
rebuildIndex: z.boolean().default(true),
|
||||
},
|
||||
},
|
||||
async ({ bookId, includeCover, rebuildIndex }) => {
|
||||
const bundle = await client.bookBundle(bookId);
|
||||
const cover = includeCover ? await obsidian.saveCover(client, bookId) : null;
|
||||
const note = await obsidian.syncBook(bundle, cover);
|
||||
try {
|
||||
const index = rebuildIndex ? await obsidian.rebuildIndex() : null;
|
||||
return jsonResult({ status: "complete", bookId, note, cover, indexRebuilt: rebuildIndex, index });
|
||||
} catch (error) {
|
||||
return jsonResult({ status: "index_failed", bookId, note, cover, indexRebuilt: false, error: error.message }, true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"sync_all_to_obsidian",
|
||||
{
|
||||
description:
|
||||
"Manually synchronize every accessible Grimmory book to an individual Obsidian note, then rebuild the vault index.",
|
||||
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
|
||||
inputSchema: { includeCovers: z.boolean().default(true) },
|
||||
},
|
||||
async ({ includeCovers }) => {
|
||||
const books = await client.allBooks();
|
||||
const results = [];
|
||||
const failures = [];
|
||||
for (const book of books) {
|
||||
try {
|
||||
const bundle = await client.bookBundle(book.id);
|
||||
const cover = includeCovers
|
||||
? await obsidian.saveCover(client, book.id)
|
||||
: null;
|
||||
const note = await obsidian.syncBook(bundle, cover);
|
||||
results.push({ bookId: book.id, title: book.title, note, cover });
|
||||
} catch (error) {
|
||||
failures.push({ bookId: book.id, title: book.title, error: error.message });
|
||||
}
|
||||
}
|
||||
let index = null;
|
||||
let indexError = null;
|
||||
if (results.length || failures.length) {
|
||||
try {
|
||||
index = await obsidian.rebuildIndex();
|
||||
} catch (error) {
|
||||
indexError = error.message;
|
||||
}
|
||||
}
|
||||
const status = indexError ? "index_failed" : failures.length ? "partial" : "complete";
|
||||
return jsonResult({
|
||||
status,
|
||||
total: books.length,
|
||||
synchronized: results.length,
|
||||
failed: failures.length,
|
||||
results,
|
||||
failures,
|
||||
indexRebuilt: Boolean(index),
|
||||
index,
|
||||
indexError,
|
||||
}, status !== "complete");
|
||||
},
|
||||
);
|
||||
|
||||
await server.connect(new StdioServerTransport());
|
||||
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { GrimmoryClient, query, unwrap } from "../src/client.js";
|
||||
|
||||
test("unwrap handles Grimmory API response envelope", () => {
|
||||
assert.deepEqual(unwrap({ status: 200, data: { id: 7 } }), { id: 7 });
|
||||
assert.deepEqual(unwrap([1, 2]), [1, 2]);
|
||||
});
|
||||
|
||||
test("query repeats array values and skips empty values", () => {
|
||||
assert.equal(
|
||||
query({ page: 0, status: ["READING", "READ"], search: "" }),
|
||||
"page=0&status=READING&status=READ",
|
||||
);
|
||||
});
|
||||
|
||||
test("libraries falls back to the current user on the v3.2.4 endpoint bug", async () => {
|
||||
const responses = [
|
||||
new Response(JSON.stringify({ message: "broken" }), { status: 500 }),
|
||||
new Response(JSON.stringify({ data: { assignedLibraries: [{ id: 3 }] } })),
|
||||
];
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => responses.shift(),
|
||||
});
|
||||
client.accessToken = "test";
|
||||
assert.deepEqual(await client.libraries(), [{ id: 3 }]);
|
||||
});
|
||||
|
||||
test("springPage supports the nested Spring page metadata", async () => {
|
||||
let request = 0;
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => {
|
||||
const page = request++;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
content: page === 0 ? Array.from({ length: 100 }, (_, id) => id) : [100],
|
||||
page: { number: page, totalPages: 2 },
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
client.accessToken = "test";
|
||||
assert.equal((await client.springPage("/sessions")).length, 101);
|
||||
assert.equal(request, 2);
|
||||
});
|
||||
|
||||
test("allBooks follows nested pagination and rejects missing metadata", async () => {
|
||||
let request = 0;
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => {
|
||||
const page = request++;
|
||||
return new Response(JSON.stringify({ data: {
|
||||
content: page === 0 ? Array.from({ length: 50 }, (_, id) => ({ id })) : [{ id: 50 }],
|
||||
page: { number: page, totalPages: 2 },
|
||||
} }));
|
||||
},
|
||||
});
|
||||
client.accessToken = "test";
|
||||
assert.equal((await client.allBooks()).length, 51);
|
||||
assert.equal(request, 2);
|
||||
});
|
||||
|
||||
test("concurrent first requests share one login", async () => {
|
||||
let logins = 0;
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => new Response(JSON.stringify({ data: { ok: true } })),
|
||||
});
|
||||
client.login = async () => {
|
||||
logins += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
client.accessToken = "test";
|
||||
};
|
||||
await Promise.all([client.request("/one"), client.request("/two")]);
|
||||
assert.equal(logins, 1);
|
||||
});
|
||||
|
||||
test("springPage rejects missing pagination metadata on a full page", async () => {
|
||||
const client = new GrimmoryClient({
|
||||
baseUrl: "https://example.test",
|
||||
username: "ada",
|
||||
passwordFile: "/unused",
|
||||
fetchImpl: async () => new Response(JSON.stringify({ data: {
|
||||
content: Array.from({ length: 100 }, (_, id) => ({ id })),
|
||||
} })),
|
||||
});
|
||||
client.accessToken = "test";
|
||||
await assert.rejects(() => client.springPage("/sessions"), /no pagination metadata/);
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
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",
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
|
||||
test("MCP server exposes the read and manual sync tools", async () => {
|
||||
const transport = new StdioClientTransport({
|
||||
command: process.execPath,
|
||||
args: [path.join(projectRoot, "src/server.js")],
|
||||
env: { ...process.env },
|
||||
});
|
||||
const client = new Client({ name: "grimmory-test", version: "0.1.0" });
|
||||
try {
|
||||
await client.connect(transport);
|
||||
const { tools } = await client.listTools();
|
||||
const names = tools.map((tool) => tool.name);
|
||||
assert.ok(names.includes("get_book"));
|
||||
assert.ok(names.includes("sync_all_to_obsidian"));
|
||||
assert.equal(names.length, 10);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user