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,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());
|
||||
Reference in New Issue
Block a user