diff --git a/packages/obsidian/src/library/book.ts b/packages/obsidian/src/library/book.ts new file mode 100644 index 0000000..77f4c9d --- /dev/null +++ b/packages/obsidian/src/library/book.ts @@ -0,0 +1,187 @@ +import type { LibraryNoteCtx, MediaTypeSpec, SpecDeps } from 'packages/obsidian/src/library/types'; +import { stripQuotes, extractMyNotes, extractCustomSections, type CustomSection } from 'packages/obsidian/src/watchlist/parse'; +import { yamlList, yamlScalar, quotedOrNull } from 'packages/obsidian/src/watchlist/yaml'; + +const OPENLIBRARY_BASE = 'https://openlibrary.org'; + +export interface BookRecord { + title: string; + readStatus: string; + rating: string; + ratingStars: string; + authors: string[]; + year: number | null; + pages: number | null; + genre: string[]; + olid: string; + isbn: string; + poster: string | null; + url: string; +} + +function deriveReadStatus(prev: Record): string { + const canonical = stripQuotes(prev['read_status']); + if (canonical) return canonical; + // skeleton conversion: legacy boolean `read` field + return stripQuotes(prev['read']) === 'true' ? 'Read' : 'Unread'; +} + +function deriveRating(prev: Record): { rating: string; ratingStars: string } { + const canonicalRating = stripQuotes(prev['rating']); + if (canonicalRating) return { rating: canonicalRating, ratingStars: stripQuotes(prev['rating_stars']) }; + // skeleton conversion: legacy numeric `personalRating` field -> N stars + const legacy = Number(stripQuotes(prev['personalRating'])); + if (Number.isFinite(legacy) && legacy > 0) { + return { rating: String(legacy), ratingStars: '⭐️'.repeat(legacy) }; + } + return { rating: '0', ratingStars: '' }; +} + +function deriveAuthors(doc: any, prev: Record): string[] { + const fresh: string[] = Array.isArray(doc.author_name) ? doc.author_name.filter(Boolean) : []; + if (fresh.length) return fresh; + // skeleton conversion: legacy singular `author` field, used only when Open Library gives none + const legacy = stripQuotes(prev['author']); + return legacy ? [legacy] : []; +} + +/** + * Pure mapper: Open Library `search.json` doc + prev frontmatter -> canonical BookRecord. + * User-managed fields (read_status, rating, rating_stars, isbn) are preserved from prev + * (with legacy-skeleton fallback for read_status/rating/authors); everything else is + * freshly derived from the doc on every call. + */ +export function buildBook(doc: any, prev: Record): BookRecord { + const title: string = doc.title ?? stripQuotes(prev['title']) ?? ''; + const { rating, ratingStars } = deriveRating(prev); + const readStatus = deriveReadStatus(prev); + const authors = deriveAuthors(doc, prev); + const genre: string[] = Array.isArray(doc.subject) ? doc.subject.filter(Boolean).slice(0, 8) : []; + const olid = String(doc.key ?? '').replace(/^\/works\//, '') || stripQuotes(prev['olid']); + + return { + title, + readStatus, + rating, + ratingStars, + authors, + year: typeof doc.first_publish_year === 'number' ? doc.first_publish_year : null, + pages: typeof doc.number_of_pages_median === 'number' ? doc.number_of_pages_median : null, + genre, + olid, + isbn: stripQuotes(prev['isbn']), + poster: doc.cover_i ? `https://covers.openlibrary.org/b/id/${doc.cover_i}-L.jpg` : null, + url: doc.key ? `${OPENLIBRARY_BASE}${doc.key}` : olid ? `${OPENLIBRARY_BASE}/works/${olid}` : '', + }; +} + +/** Carries the legacy goodreads search link forward as a `## Links` entry across every re-render. */ +function resolveGoodreadsLink(prev: Record, body: string): string { + const fromBody = /\[Goodreads\]\(([^)]+)\)/.exec(body ?? ''); + if (fromBody) return fromBody[1]; + // first-pass skeleton conversion: legacy plain `url` field IS the goodreads link, before + // it gets overwritten by the canonical Open Library url + const legacyUrl = stripQuotes(prev['url']); + if (legacyUrl && !legacyUrl.includes('openlibrary.org')) return legacyUrl; + return ''; +} + +export function renderBook(r: BookRecord, myNotes: string, customSections: CustomSection[] = [], goodreadsUrl = ''): string { + const fm = [ + '---', + 'type: book_item', + `title: ${yamlScalar(r.title)}`, + `read_status: ${r.readStatus}`, + `rating: ${r.rating}`, + `rating_stars: ${r.ratingStars}`, + `authors: ${yamlList(r.authors)}`, + `year: ${r.year ?? 'null'}`, + `pages: ${r.pages ?? 'null'}`, + `genre: ${yamlList(r.genre)}`, + `olid: ${r.olid}`, + `isbn: ${r.isbn}`, + `poster: ${quotedOrNull(r.poster)}`, + `url: ${quotedOrNull(r.url)}`, + 'tags: [books, book]', + '---', + ]; + + const b: string[] = ['', `# ${r.title}`, '']; + if (r.poster) b.push(`![poster|200](${r.poster})`, ''); + const meta = ['**Book**', ...[r.year !== null ? String(r.year) : '', r.pages !== null ? `${r.pages} p.` : ''].filter(x => x)]; + b.push(meta.join(' · '), ''); + b.push(`**Read Status:** ${r.readStatus}`, ''); + if (r.authors.length) b.push(`**Authors:** ${r.authors.join(', ')}`, ''); + const links: string[] = []; + if (r.url) links.push(`- [Open Library](${r.url})`); + if (goodreadsUrl) links.push(`- [Goodreads](${goodreadsUrl})`); + if (links.length) b.push('## Links', ...links, ''); + for (const s of customSections) b.push(`## ${s.heading}`, s.content, ''); + b.push('## My Notes', '', myNotes); + if (myNotes) b.push(''); + return fm.join('\n') + '\n' + b.join('\n'); +} + +async function fetchDocs(query: string, deps: SpecDeps): Promise { + const qs = new URLSearchParams({ q: query, limit: '10' }); + const res = await deps.http(`${OPENLIBRARY_BASE}/search.json?${qs.toString()}`, {}); + return res?.docs ?? []; +} + +export const bookSpec: MediaTypeSpec = { + typeName: 'book', + itemType: 'book_item', + folderSettingKey: 'libraryBookFolder', + enabledSettingKey: 'libraryBookEnabled', + throttleMs: 250, + + hasId(fm: Record): boolean { + return !!stripQuotes(fm['olid']); + }, + + isActive(fm: Record): boolean { + // static once enriched; no chapter/issue sources, no flips -- only a missing + // olid ever makes a book note active again (a full sync bypasses this check) + return !stripQuotes(fm['olid']); + }, + + async resolve(ctx: LibraryNoteCtx, deps: SpecDeps): Promise | null> { + const query = stripQuotes(ctx.frontmatter['title']) || ctx.filename.replace(/\.md$/, ''); + if (!query) return null; + try { + const docs = await fetchDocs(query, deps); + if (docs.length === 0) return null; + const q = query.toLowerCase(); + const exacts = docs.filter(d => String(d.title ?? '').toLowerCase() === q); + const pick = exacts.length === 1 ? exacts[0] : exacts.length === 0 && docs.length === 1 ? docs[0] : null; + if (!pick) return null; + const olid = String(pick.key ?? '').replace(/^\/works\//, ''); + return olid ? { olid } : null; + } catch (e) { + deps.log(`book resolve failed for "${query}": ${String(e)}`); + return null; + } + }, + + async sync(ctx: LibraryNoteCtx, deps: SpecDeps): Promise<{ content: string; flipped: boolean } | null> { + const fm = ctx.frontmatter; + const olid = stripQuotes(fm['olid']); + if (!olid) return null; // needs resolve() first + + const query = stripQuotes(fm['title']) || ctx.filename.replace(/\.md$/, ''); + let docs: any[]; + try { + docs = await fetchDocs(query, deps); + } catch (e) { + deps.log(`book open library fetch failed (olid ${olid}): ${String(e)}`); + return null; + } + const doc = docs.find(d => String(d.key ?? '') === `/works/${olid}`) ?? docs[0]; + if (!doc) return null; + + const record = buildBook(doc, fm); + const goodreadsUrl = resolveGoodreadsLink(fm, ctx.body); + const content = renderBook(record, extractMyNotes(ctx.body), extractCustomSections(ctx.body), goodreadsUrl); + return { content, flipped: false }; // static spec -- no automation, never flips + }, +}; diff --git a/tests/fixtures/canonical-book.md b/tests/fixtures/canonical-book.md new file mode 100644 index 0000000..f2b1f5f --- /dev/null +++ b/tests/fixtures/canonical-book.md @@ -0,0 +1,33 @@ +--- +type: book_item +title: 1984 +read_status: Read +rating: 5 +rating_stars: ⭐️⭐️⭐️⭐️⭐️ +authors: [George Orwell] +year: 1949 +pages: 328 +genre: [Dystopian fiction, Science fiction, Politics, Totalitarianism, Fiction, Classic literature, "Government, resistance to", Surveillance] +olid: OL1168083W +isbn: 9780451524935 +poster: "https://covers.openlibrary.org/b/id/12919016-L.jpg" +url: "https://openlibrary.org/works/OL1168083W" +tags: [books, book] +--- + +# 1984 + +![poster|200](https://covers.openlibrary.org/b/id/12919016-L.jpg) + +**Book** · 1949 · 328 p. + +**Read Status:** Read + +**Authors:** George Orwell + +## Links +- [Open Library](https://openlibrary.org/works/OL1168083W) +- [Goodreads](https://www.goodreads.com/search?q=1984) + +## My Notes + diff --git a/tests/fixtures/openlibrary-search.json b/tests/fixtures/openlibrary-search.json new file mode 100644 index 0000000..9a0ad5a --- /dev/null +++ b/tests/fixtures/openlibrary-search.json @@ -0,0 +1,30 @@ +{ + "numFound": 1, + "start": 0, + "docs": [ + { + "key": "/works/OL1168083W", + "title": "1984", + "author_name": ["George Orwell"], + "first_publish_year": 1949, + "number_of_pages_median": 328, + "subject": [ + "Dystopian fiction", + "Science fiction", + "Politics", + "Totalitarianism", + "Fiction", + "Classic literature", + "Government, resistance to", + "Surveillance", + "Censorship", + "Propaganda", + "Big Brother (Fictitious character)", + "Thought control", + "Newspeak (Fictitious language)", + "Ministry of Truth (Fictitious organization)" + ], + "cover_i": 12919016 + } + ] +} diff --git a/tests/library-book.test.ts b/tests/library-book.test.ts new file mode 100644 index 0000000..7c63cb3 --- /dev/null +++ b/tests/library-book.test.ts @@ -0,0 +1,321 @@ +import { describe, expect, test } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { buildBook, renderBook, bookSpec, type BookRecord } from 'packages/obsidian/src/library/book'; +import type { SpecDeps, LibraryNoteCtx } from 'packages/obsidian/src/library/types'; +import olFixture from 'tests/fixtures/openlibrary-search.json'; + +const OL_DOC = olFixture.docs[0]; + +const EMPTY_PREV: Record = {}; + +function makeDeps(overrides: Partial = {}): SpecDeps & { notifyCalls: string[]; logCalls: string[] } { + const notifyCalls: string[] = []; + const logCalls: string[] = []; + return { + http: async () => ({}), + httpText: async () => '', + getKey: () => '', + log: (msg: string) => { + logCalls.push(msg); + }, + notify: (msg: string) => { + notifyCalls.push(msg); + }, + notifyCalls, + logCalls, + ...overrides, + }; +} + +describe('buildBook field mapping', () => { + const r = buildBook(OL_DOC, EMPTY_PREV); + test('core fields', () => { + expect(r.title).toBe('1984'); + expect(r.olid).toBe('OL1168083W'); + expect(r.authors).toEqual(['George Orwell']); + expect(r.year).toBe(1949); + expect(r.pages).toBe(328); + expect(r.poster).toBe('https://covers.openlibrary.org/b/id/12919016-L.jpg'); + expect(r.url).toBe('https://openlibrary.org/works/OL1168083W'); + }); + test('genre capped at 8 (fixture has 14 subjects)', () => { + expect(r.genre.length).toBe(8); + expect(r.genre).toEqual([ + 'Dystopian fiction', + 'Science fiction', + 'Politics', + 'Totalitarianism', + 'Fiction', + 'Classic literature', + 'Government, resistance to', + 'Surveillance', + ]); + }); + test('no cover_i -> poster null', () => { + expect(buildBook({ ...OL_DOC, cover_i: undefined }, EMPTY_PREV).poster).toBeNull(); + }); + test('no first_publish_year/number_of_pages_median -> year/pages null', () => { + const r2 = buildBook({ ...OL_DOC, first_publish_year: undefined, number_of_pages_median: undefined }, EMPTY_PREV); + expect(r2.year).toBeNull(); + expect(r2.pages).toBeNull(); + }); +}); + +describe('buildBook user-field preservation', () => { + test('read_status defaults to Unread', () => { + expect(buildBook(OL_DOC, EMPTY_PREV).readStatus).toBe('Unread'); + }); + test('read_status carried from prev', () => { + expect(buildBook(OL_DOC, { read_status: 'Read' }).readStatus).toBe('Read'); + }); + test('rating/rating_stars carried from prev', () => { + const prev = { rating: '5', rating_stars: '⭐️⭐️⭐️⭐️⭐️' }; + const r = buildBook(OL_DOC, prev); + expect(r.rating).toBe('5'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️⭐️⭐️'); + }); + test('isbn preserved when prev had it', () => { + expect(buildBook(OL_DOC, { isbn: '9780451524935' }).isbn).toBe('9780451524935'); + }); + test('isbn empty when prev had none', () => { + expect(buildBook(OL_DOC, EMPTY_PREV).isbn).toBe(''); + }); + test('authors always fresh from doc.author_name, ignoring any prior authors', () => { + expect(buildBook(OL_DOC, { authors: '[Someone Else]' }).authors).toEqual(['George Orwell']); + }); +}); + +describe('buildBook skeleton conversion', () => { + test('read: true -> read_status Read', () => { + expect(buildBook(OL_DOC, { read: 'true', personalRating: '' }).readStatus).toBe('Read'); + }); + test('read: false -> read_status Unread', () => { + expect(buildBook(OL_DOC, { read: 'false', personalRating: '' }).readStatus).toBe('Unread'); + }); + test('personalRating 3 -> rating 3 + 3 stars', () => { + const r = buildBook(OL_DOC, { read: 'false', personalRating: '3' }); + expect(r.rating).toBe('3'); + expect(r.ratingStars).toBe('⭐️⭐️⭐️'); + }); + test('empty personalRating -> rating 0, no stars', () => { + const r = buildBook(OL_DOC, { read: 'false', personalRating: '' }); + expect(r.rating).toBe('0'); + expect(r.ratingStars).toBe(''); + }); + test('legacy `author` used only when doc has no author_name', () => { + const r = buildBook({ ...OL_DOC, author_name: undefined }, { author: 'George Orwell' }); + expect(r.authors).toEqual(['George Orwell']); + }); + test('no doc author_name and no legacy author -> empty authors', () => { + const r = buildBook({ ...OL_DOC, author_name: undefined }, EMPTY_PREV); + expect(r.authors).toEqual([]); + }); +}); + +describe('renderBook golden', () => { + const RECORD: BookRecord = { + title: '1984', + readStatus: 'Read', + rating: '5', + ratingStars: '⭐️⭐️⭐️⭐️⭐️', + authors: ['George Orwell'], + year: 1949, + pages: 328, + genre: ['Dystopian fiction', 'Science fiction', 'Politics', 'Totalitarianism', 'Fiction', 'Classic literature', 'Government, resistance to', 'Surveillance'], + olid: 'OL1168083W', + isbn: '9780451524935', + poster: 'https://covers.openlibrary.org/b/id/12919016-L.jpg', + url: 'https://openlibrary.org/works/OL1168083W', + }; + + test('matches canonical book fixture byte-for-byte', () => { + const expected = readFileSync(join(import.meta.dir, 'fixtures', 'canonical-book.md'), 'utf-8'); + expect(renderBook(RECORD, '', [], 'https://www.goodreads.com/search?q=1984')).toBe(expected); + }); + + test('custom section (Collection) placed after Links, before My Notes', () => { + const out = renderBook(RECORD, '', [{ heading: 'Collection', content: 'Part of [[Books]]' }], 'https://www.goodreads.com/search?q=1984'); + const linksIdx = out.indexOf('## Links'); + const collectionIdx = out.indexOf('## Collection'); + const myNotesIdx = out.indexOf('## My Notes'); + expect(collectionIdx).toBeGreaterThan(linksIdx); + expect(myNotesIdx).toBeGreaterThan(collectionIdx); + expect(out).toContain('## Collection\nPart of [[Books]]\n'); + }); + + test('My Notes content preserved', () => { + const out = renderBook(RECORD, 'reread every few years'); + expect(out).toContain('## My Notes\n\nreread every few years'); + }); + + test('no goodreads url -> Links has only Open Library entry', () => { + const out = renderBook(RECORD, ''); + expect(out).toContain('- [Open Library](https://openlibrary.org/works/OL1168083W)'); + expect(out).not.toContain('[Goodreads]'); + }); + + test('no poster -> poster line omitted', () => { + const r = { ...RECORD, poster: null }; + expect(renderBook(r, '')).not.toContain('![poster'); + }); + + test('no authors -> Authors line omitted', () => { + const r = { ...RECORD, authors: [] }; + expect(renderBook(r, '')).not.toContain('**Authors:**'); + }); +}); + +describe('bookSpec.hasId', () => { + test('olid set -> true', () => expect(bookSpec.hasId({ olid: 'OL1168083W' })).toBe(true)); + test('olid empty -> false', () => expect(bookSpec.hasId({ olid: '' })).toBe(false)); + test('olid missing -> false', () => expect(bookSpec.hasId({})).toBe(false)); +}); + +describe('bookSpec.isActive', () => { + test('olid empty -> active (needs first pass)', () => { + expect(bookSpec.isActive({})).toBe(true); + }); + test('olid set -> static, regardless of read_status', () => { + expect(bookSpec.isActive({ olid: 'OL1168083W', read_status: 'Reading' })).toBe(false); + }); + test('olid set + Read -> static', () => { + expect(bookSpec.isActive({ olid: 'OL1168083W', read_status: 'Read' })).toBe(false); + }); + test('olid set + Unread -> static', () => { + expect(bookSpec.isActive({ olid: 'OL1168083W', read_status: 'Unread' })).toBe(false); + }); +}); + +function ctxFor(fm: Record, body = '## My Notes\n\n'): LibraryNoteCtx { + return { frontmatter: fm, body, filename: '1984.md' }; +} + +describe('bookSpec.sync — open library enrich', () => { + test('no olid -> null (needs resolve first)', async () => { + const deps = makeDeps(); + const result = await bookSpec.sync(ctxFor({}), deps); + expect(result).toBeNull(); + }); + + test('open library fetch failure -> log, return null (no throw)', async () => { + const deps = makeDeps({ + http: async () => { + throw new Error('network down'); + }, + }); + const result = await bookSpec.sync(ctxFor({ olid: 'OL1168083W', title: '1984' }), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); + + test('doc not found for olid, no results at all -> null', async () => { + const deps = makeDeps({ http: async () => ({ docs: [] }) }); + const result = await bookSpec.sync(ctxFor({ olid: 'OL1168083W', title: '1984' }), deps); + expect(result).toBeNull(); + }); + + test('successful enrich -> canonical fm + flipped always false', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984', read_status: 'Read', rating: '5', rating_stars: '⭐️⭐️⭐️⭐️⭐️' }; + const result = await bookSpec.sync(ctxFor(fm), deps); + expect(result).not.toBeNull(); + expect(result!.flipped).toBe(false); + expect(result!.content).toContain('type: book_item'); + expect(result!.content).toContain('olid: OL1168083W'); + expect(result!.content).toContain('authors: [George Orwell]'); + expect(result!.content).toContain('year: 1949'); + expect(result!.content).toContain('pages: 328'); + }); + + test('isbn preserved through sync when prev had it', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984', isbn: '9780451524935' }; + const result = await bookSpec.sync(ctxFor(fm), deps); + expect(result!.content).toContain('isbn: 9780451524935'); + }); + + test('first-pass skeleton conversion: legacy author/read/personalRating/goodreads url -> canonical + Links entry, Collection section preserved', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { + olid: 'OL1168083W', + title: '1984', + author: 'George Orwell', + read: 'false', + personalRating: '', + url: 'https://www.goodreads.com/search?q=1984', + }; + const body = '## Collection\n\nPart of [[Books]]\n\n## My Notes\n\n'; + const result = await bookSpec.sync(ctxFor(fm, body), deps); + expect(result!.content).toContain('read_status: Unread'); + expect(result!.content).toContain('authors: [George Orwell]'); + expect(result!.content).toContain('- [Open Library](https://openlibrary.org/works/OL1168083W)'); + expect(result!.content).toContain('- [Goodreads](https://www.goodreads.com/search?q=1984)'); + expect(result!.content).toContain('## Collection\n\nPart of [[Books]]\n'); + }); + + test('goodreads link carried forward from already-converted body, ignoring canonical fm url', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984', url: 'https://openlibrary.org/works/OL1168083W' }; + const body = '## Links\n- [Open Library](https://openlibrary.org/works/OL1168083W)\n- [Goodreads](https://www.goodreads.com/search?q=1984)\n\n## My Notes\n\n'; + const result = await bookSpec.sync(ctxFor(fm, body), deps); + expect(result!.content).toContain('- [Goodreads](https://www.goodreads.com/search?q=1984)'); + }); + + test('no goodreads link anywhere -> Links has only Open Library entry', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984' }; + const result = await bookSpec.sync(ctxFor(fm), deps); + expect(result!.content).toContain('- [Open Library](https://openlibrary.org/works/OL1168083W)'); + expect(result!.content).not.toContain('[Goodreads]'); + }); + + test('My Notes content preserved through sync', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const fm = { olid: 'OL1168083W', title: '1984' }; + const result = await bookSpec.sync(ctxFor(fm, '## My Notes\n\nreread every few years'), deps); + expect(result!.content).toContain('## My Notes\n\nreread every few years'); + }); +}); + +describe('bookSpec.resolve', () => { + test('unique exact title match -> accepted', async () => { + const deps = makeDeps({ http: async () => olFixture }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toEqual({ olid: 'OL1168083W' }); + }); + test('no exact match, sole result -> accepted', async () => { + const deps = makeDeps({ + http: async () => ({ docs: [{ key: '/works/OL999W', title: 'Some Other Title' }] }), + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toEqual({ olid: 'OL999W' }); + }); + test('ambiguous (multiple results, no exact match) -> null', async () => { + const deps = makeDeps({ + http: async () => ({ + docs: [ + { key: '/works/OL1W', title: 'Foo' }, + { key: '/works/OL2W', title: 'Bar' }, + ], + }), + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toBeNull(); + }); + test('no results -> null', async () => { + const deps = makeDeps({ http: async () => ({ docs: [] }) }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toBeNull(); + }); + test('http throws -> log, return null (no throw)', async () => { + const deps = makeDeps({ + http: async () => { + throw new Error('down'); + }, + }); + const result = await bookSpec.resolve(ctxFor({ title: '1984' }, ''), deps); + expect(result).toBeNull(); + expect(deps.logCalls.length).toBeGreaterThan(0); + }); +});