/** * Public API for the local reference library. * * The library is a per-project, gitignored directory of CSL-JSON * papers. It supports: * - add / get / remove / list (filesystem CRUD) * - search (BM25, in-memory, no ML model) * - sync (rebuild sql.js SQLite cache from filesystem) * * Usage: * * import { Library } from "./library/index.ts"; * const lib = new Library("/path/to/project"); * await lib.init(); * lib.add(cslItem); * const hits = lib.search("cachexia IL6", 10); * * The library is single-process per invocation (no concurrent * writers). sql.js is loaded on first initCache() call. */ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import type { CslItem } from "../csl/schema.ts"; import { libraryRoot as computeLibraryRoot, addPaper, removePaper, getPaper, listPapers, lookupByDoi, stats, syncCache, } from "./storage.ts"; import { BM25Index, type BM25Hit } from "./bm25.ts"; export class Library { private root: string; private index: BM25Index; private cachedPapers: CslItem[] = []; constructor(projectRoot: string) { this.root = computeLibraryRoot(projectRoot); this.index = new BM25Index(); } /** Path to the library root directory. */ get path(): string { return this.root; } /** * Initialise the library: create the directory if it doesn't exist, * load all papers into memory, build the BM25 index. * * Call once after construction. Subsequent calls are no-ops. */ async init(): Promise { if (!existsSync(this.root)) { mkdirSync(this.root, { recursive: true }); // README.md so the directory is self-describing. const readme = join(this.root, "README.md"); if (!existsSync(readme)) { writeFileSync( readme, [ "# Local paper library", "", "This directory is auto-generated by `paper-lab-library`. It contains", "the CSL-JSON metadata for every paper you've added to your local", "library. The directory is gitignored by default; remove it to reset", "the library.", "", "Each paper lives under `papers//` with:", "- `metadata.json` — CSL-JSON", "- `abstract.txt` — plain-text abstract", "", "Use `paper-lab-library` to manage entries, or `paper-lab-export`", "to dump the library as BibTeX/RIS.", ].join("\n"), "utf8", ); } } this.cachedPapers = listPapers(this.root); this.index.index(this.cachedPapers); } /** * Add or update a paper. Idempotent on id. Returns the directory * path of the new entry. * * After add(), the BM25 index is rebuilt incrementally via the * underlying index() method which fully replaces the in-memory * index. For libraries with thousands of papers this is wasteful; * for the typical case (<100 papers at first install) it's fine. */ add(csl: CslItem): string { const dir = addPaper(this.root, csl); // Update in-memory cache. We replace the paper (or add it). const existingIdx = this.cachedPapers.findIndex((p) => p.id === csl.id); if (existingIdx >= 0) { this.cachedPapers[existingIdx] = csl; } else { this.cachedPapers.push(csl); } this.index.index(this.cachedPapers); return dir; } /** * Remove a paper by id. Returns true if it existed. */ remove(id: string): boolean { const removed = removePaper(this.root, id); if (removed) { this.cachedPapers = this.cachedPapers.filter((p) => p.id !== id); this.index.index(this.cachedPapers); } return removed; } /** Get a paper by id (or null). */ get(id: string): CslItem | null { return getPaper(this.root, id); } /** Look up a paper by DOI. */ lookupByDoi(doi: string): CslItem | null { return lookupByDoi(this.root, doi); } /** List all papers. */ list(): CslItem[] { return listPapers(this.root); } /** * BM25 search. Returns top-N hits sorted by score descending. * Offline, no ML model, no network. */ search(query: string, topN = 10): BM25Hit[] { return this.index.search(query, topN); } /** * Stats: number of papers, total abstract bytes. */ stats(): { count: number; totalAbstractBytes: number } { return stats(this.root); } /** * Rebuild the sql.js SQLite cache from the filesystem. * * This is OPTIONAL — the BM25 search works without it. The cache * powers future SQL queries (FTS5, joins, etc.) and is kept in * sync with the filesystem via this call. * * Returns the number of entries indexed. */ async sync(): Promise { return syncCache(this.root); } /** Number of papers currently in memory. */ get size(): number { return this.cachedPapers.length; } }