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 };