Insurgently

Read books from your plugin

Find Library Hub from another plugin, read its book list, shelves, book files and book text, change shelves, and open a book at a place.

For people building on Library Hub. Coding agents get the same knowledge as Markdown in the Agent docs.

Other plugins read books through a small API, reached the way plugins reach Model Hub. Topic Map uses it for collections of books.

Before you start#

  • Install the types and helpers as a package (from API 0.9), with a tilde so you take patch fixes only:

    npm install --save-dev @insurgently/library-hub-api
    

    It has no dependencies and imports nothing at runtime; your bundler keeps the helpers you use. The package says which Library Hub release serves each version in its CHANGELOG.

  • Before 0.9 there was no package: plugins copied library-hub-api.d.ts and the helpers from api/index.ts. Switch to the package and delete the copies.

  • The full reference is generated from the code: the API reference.

Steps#

  1. Find the plugin. getLibraryHub checks that Library Hub is installed and new enough. If not, it gives a plain message to show the user.

    import { getLibraryHub, hasMethod } from '@insurgently/library-hub-api';
    
    const found = getLibraryHub(this.app, '0.8.0', { by: 'My Plugin', for: 'for book collections' });
    if (!found.libraryHub) return new Notice(found.message);
    const books = found.libraryHub;
    

    by and for go into the message: "My Plugin needs Library Hub for book collections." Gate on the lowest version you need, and check a newer method before calling it: if (hasMethod(books, 'bookText')) …. Each method's comment says @since which version added it.

    The deprecated names getBooks (which answers { books }), onBooksReady, BooksApi and the BOOKS_* constants still work. Within 0.x the API only adds and deprecates; deprecated names go at 1.0.

  2. Read a shelf and its books.

    const ids = await books.shelfMembers('field-guides');   // a shelf's Calibre ids
    const list = await books.listBooks(ids);                // id, title, authors, formats, notePath, context
    const all = await books.listBooks('all');               // every book in Calibre (API 0.9)
    
  3. Read a book's file or text.

    const epub = await books.bookFile(list[0].id, 'EPUB');  // bytes, through the Content Server
    const text = await books.pdfText(list[0].id);           // a PDF's text, laid out
    const book = await books.bookText(list[0].id);          // the text as chapters (API 0.8): see below
    

    Every call that names a book takes any book reference (API 0.8): a Calibre id, a note, a file, an ISBN and so on (see Name the book by what you know). A call that only works on Calibre books, such as listBooks or addToShelf, rejects a book Calibre doesn't hold with not-calibre.

  4. Offer a conversion. A book that has only AZW3 or MOBI has no EPUB to read. With convert: true the user is asked; on yes, Calibre adds an EPUB. It resolves null if they decline.

    const epub = await books.bookFile(id, 'EPUB', { convert: true });
    
  5. Handle failures. A failure rejects with an error whose code says why: server-off, book-missing, format-missing and others.

Read a book's text (API 0.8)#

bookText reads a book's file and gives its text as chapters, each with a place the reader can open. Use it instead of parsing EPUBs and PDFs yourself: the chapters are the ones Library Hub's chapter notes count, and every location lands in its reader.

const t = await books.bookText('isbn:9780306406157');
// t.format: 'EPUB' or 'PDF'; t.hash; t.length; t.warnings; t.outline (false: a PDF cut into even parts, API 0.9); t.book
for (const c of t.chapters) {
	// c.n, c.title, c.location, c.start, c.end, c.text
}
await books.openBook(t.book.notePath!, { location: t.chapters[2].location });
  • Chapters. An EPUB's, AZW3's or MOBI's come from its table of contents (an EPUB's nav or NCX), every entry, with front and back matter (cover, contents, notes, index…) left out and never merged, however many. A file several entries point into is split at their anchors, and text no entry claims is a chapter of its own when it's more than a heading. The book is read with foliate-js, the same parser Library Hub's reader shows it with. A PDF's come from its outline (its bookmarks), front and back matter left out; a PDF without one is cut into 12 even parts, and warnings says so.
  • Locations. An EPUB chapter's location is its file inside the book (OEBPS/c3.xhtml), or file#anchor when it starts inside one. An AZW3 or MOBI chapter's is its contents entry as the reader names it (kindle:pos:fid:0001:off:0000000000, filepos:0000001234), or a section's start as a CFI (epubcfi(/6/4!/4)). A PDF chapter's is chars a-b, offsets into the PDF's text that the reader turns into a page. Pass a location to openBook as it is. For a passage inside a chapter, keep the chapter's location (EPUB) or add the passage's offset to start (PDF), and pass its opening words as quote.
  • Offsets. start and end count code points (an emoji is one) into the book's whole text, and text is exactly that slice. The whole text is an EPUB's files (an AZW3's or MOBI's sections) in reading order, matter included, a page's paragraphs separated by a blank line and its line breaks by a line break, or a PDF's pages as pdfText gives them, joined by blank lines.
  • Hash. hash is a SHA-256 of the whole text. It changes when the text does, not when the file's markup or Calibre's metadata does, so keep your passages while it matches. (Every EPUB's hash changed once in API 0.9, when its text began to come from foliate-js.)
  • PDF text. A PDF is laid out from where the text sits on the page: a line on its own line, a wider gap a blank line, a word broken at a line's end joined. pdfText gives the same text, so chars count the same in both.
  • AZW3 and MOBI. A book with only an AZW3 or MOBI is read as it is, with or without Calibre's Content Server, and format says which. Only one that can't be read (DRM, a damaged file) rejects with needs-conversion, the message naming the cause. Ask again with { convert: true } and the user is asked to let Calibre make an EPUB. If they decline, or it fails, the book's PDF is read instead when it has one (with a warning); otherwise it rejects with declined or conversion-failed.
  • Books outside Calibre. A book file in the vault is read as it is, and so is a file another provider serves for a book's note.

bookChapters gives the same without each chapter's text. It still reads the whole book, so call one or the other, not both.

Check what's reachable (API 0.8)#

status() says whether Calibre's Content Server and the other providers answer, and what the library holds. It never rejects, so use it for a health panel instead of probing with a book id.

const s = await books.status();
// s.calibre: { state: 'server' | 'file' | 'off', files, text }
// s.providers: [{ id, name, state: 'ok' | 'bad', files, text }]
// s.library: { notes, calibreBooks, formats: { EPUB: 120, PDF: 30, … }, shelves }

calibre.files is whether book files (and so bookText of a Calibre book) can be read now. It asks Calibre once and each provider's checks, giving each a few seconds.

Change shelves (API 0.3)#

Shelves are notes with a shelf_id; a book's membership is plain frontmatter on its note: the shelf ids in shelves, minus those in shelves_exclude (since API 0.8; before, collections, collections_exclude and collection_id). The API edits the same fields the plugin's own commands and Shelf view edit, so a vault without the plugin still shows its shelves.

  1. List shelves and read one.

    const shelves = await books.listShelves();            // id, name, path, group?, members
    const ids = await books.shelfMembers('field-guides');  // Calibre ids, ascending
    const q = await books.shelfBaseQuery('field-guides');  // { filter, base }: the shelf as a Bases filter, and a whole .base file
    
  2. Make a shelf, add and remove books. Each write is one logged run; run is null when nothing changed.

    const made = await books.createShelf({ name: 'Field Guides', description: 'Birds, trees and stones.', books: [12, 40] });
    // made.created, made.shelf, made.changed, made.unchanged, made.noNote, made.run
    const added = await books.addToShelf('field-guides', [77]);      // pins the shelf in each book's note
    const gone = await books.removeFromShelf('field-guides', [12]);  // takes it out of `shelves` and excludes it
    
    • A book already on the shelf is unchanged, and a book not on it is unchanged on a remove: asking twice is safe.
    • A book with no note yet is listed in noNote: membership lives in the note, so sync notes first.
    • createShelf with a name whose shelf_id exists already returns that shelf (created: false) and adds the books.
  3. Undo. Pass a run back; fields go back unless the user changed them since (those are named in kept), and files the run made go to the trash. A run already undone restores nothing.

    const report = await books.undoRun(made.run!);   // { restored, trashed, kept }
    
  4. Rename, delete and merge shelves (API 0.4). Each is a logged run undoRun reverses. A rename keeps the shelf's shelf_id, so nothing that names it breaks, and rejects with busy, changing nothing, while Obsidian is still updating links in the vault; a delete trashes the note and takes its id out of every book note; a merge pins every book on the other shelves to the first, then deletes them.

    await books.renameShelf('field-guides', 'Field guides and manuals');
    const merged = await books.mergeShelves(['field-guides', 'manuals']);   // { shelf, merged, changed, noNote, run }
    const gone = await books.deleteShelf('old-shelf');                      // { deleted, unpinned, run }
    
  5. Ask where a book belongs. That is found by what the book is about, which needs book vectors, and Library Hub keeps none: since API 0.8 suggestShelves asks Topic Map (see With Topic Map). Without Topic Map, or with one that doesn't rank shelves yet, it rejects with needs-topic-map; when Topic Map hasn't indexed the book, with no-vectors. Check for the codes and carry on without it.

    const suits = await books.suggestShelves(77).catch((e) => (e.code === 'needs-topic-map' ? [] : Promise.reject(e)));
    
  6. Ask which shelves books are on (API 0.7). For each Calibre id, in the order asked, its note and the ids of the shelves it is on. A book without a note is on none.

    const on = await books.shelvesOf([77, 78]);   // [{ id: 77, notePath, shelves: ['river-books'] }, …]
    
  7. Make a shelf from a description (API 0.7). Ask for the estimate first: how many books a model would read, in how many calls through library-hub.shelf-keep, and about what it costs. Then make it: the books it keeps are pinned to a new shelf in one run that undoRun takes back. The books read are those that share the description's words, and any naming a term (by meaning before API 0.8). Both reject with no-hub without Model Hub.

    const spec = { name: 'Colorado River peoples', description: 'Books about the Quechan, Mojave and Cocopah.', terms: ['Quechan', 'Mojave'] };
    const est = await books.estimateShelfFromDescription(spec);   // { candidates, calls, tokens, usd, text }
    const made = await books.shelfFromDescription(spec);          // CreatedShelf with { considered, costUsd }
    

Summaries (API 0.7)#

Library Hub writes summaries and chapter notes from the book's own text, so another plugin or an agent asks it rather than writing its own. estimateSummary reads the book and gives the size and cost; summarizeBook starts the same background job the plugin's commands run (it shows in the Library panel) and answers at once; jobStatus follows it.

const est = await books.estimateSummary(77, 'chapters');        // { chapters, calls, tokens, usd, exists, text }
const { job, run } = await books.summarizeBook(77, 'chapters'); // 'summary' (one page) is the default
const now = await books.jobStatus(job);                         // { status, label, done, total, spentUsd, error? }
await books.undoRun(run);                                       // stops the job if it is still going, then undoes it

findBooks('salt roads') finds a book's Calibre id by words from its title or author, and pdfText(id, { from, to }) reads a long PDF a part at a time.

The operations as tools#

LIBRARY_HUB_TOOLS from @insurgently/library-hub-api/tools (API 0.8; before, from the package's root, where it is now deprecated) lists each operation as a tool a model can call: a name (library-hub.create_shelf), a description written for the model, a JSON-schema input, whether it reads or writes, the LibraryHubApi method it maps to with the order of its arguments, and for some writes the method that estimates their cost.

Since API 0.7 Library Hub registers them with Model Hub's tool registry when both are loaded, and takes them away when it unloads. They then run from the Hub's panel, from other plugins through callTool, and from an outside agent through the Hub's MCP server, under the user's tool settings: reads run freely, writes ask first, and an estimate is what the approval dialog shows. A write's run is its undo handle, so the Hub's Undo puts it back. Library Hub runs no server of its own.

The tool that needs book vectors, suggest_shelves, isn't offered by Library Hub alone, so an agent isn't handed a tool that can only fail. shelf_from_description and its estimate work by words, and are always offered.

Open a book in the reader (API 0.5, 0.7)#

openBook opens a book in Library Hub's reader, in a new tab, at a place you name. The book needs an EPUB or PDF, and a note unless it is a file in the vault.

const r = await books.openBook('isbn:9780306406157', { chapter: 3 });
await books.openBook(77, { quote: 'The river bends past the salt works', href: 'chapter3.xhtml' });
// r.placed: 'highlight', 'quote', 'href', 'chapter', 'page', or 'start' when nothing matched

Name the book by what you know (API 0.7)#

The first argument is a book reference: whatever your plugin has.

  • A Calibre id, as a number or digits: 77, '77'.
  • A book note, by path, name or link: 'Books/Library/Salt Roads — Reed.md', 'Salt Roads — Reed', '[[Salt Roads — Reed]]'.
  • A book file's path. A file in the vault opens as it is, with or without a note (an absolute path inside the vault works too). A path inside a Calibre library opens that Calibre book: its folder, Salt Roads (42), names the id.
  • An ISBN (10 or 13 digits, hyphens allowed), a Calibre uuid, or an Audiobookshelf or OPDS id. Book notes are looked in first; for an ISBN or a uuid, Calibre's own list next.

When a string could be read two ways, a prefix says which: calibre:, note:, file:, isbn:, uuid:, audiobookshelf:, opds:. The object form names one: { isbn: '978…' }. Ten or thirteen digits that check out as an ISBN are read as one; any other digits are a Calibre id.

Nothing matches? It rejects with book-missing, and the message lists each thing it tried. findBook(ref) resolves a reference without opening anything: { notePath, calibreId, file, via }.

Name the place#

  • highlight is a highlight's id (h_…) from the book's highlights note. The reader checks the highlight's text first (see Highlights), so it never lands on the wrong words.
  • quote is a short passage, a sentence or so, copied from the book. The reader finds it and highlights it for this visit (nothing is saved). Spaces, line breaks, case and quote marks don't have to match. If the whole passage isn't found, its first words are tried.
  • location is a passage's location exactly as a corpus records it, such as Topic Map's: a contents entry (text/c3.xhtml#s2), or chars 1200-3400, code-point offsets into the text pdfText gives, which say the PDF page. What a split adds after it (§2, w3) is ignored. Pass the passage's opening as quote with it to land on the passage itself.
  • chapter is a chapter's title (case and spacing don't matter), or its number, counted as Library Hub's chapter notes count them: front and back matter left out. For a PDF, the numbers are the chapter notes' parts.
  • href is a contents entry, a section file with an optional #anchor, as the book's table of contents or spine has it.
  • page is a page number, for PDFs.

The parts are tried in that order, and the first that lands wins. A quote is looked for first in the section or page the other parts point at. When none lands, the book opens where the reader would have opened it anyway, and the user is told. A book with no EPUB or PDF rejects with format-missing.

The same works from a link, in a note, a skill's output or anything outside Obsidian. book takes any reference; every value is URL-encoded, and all but book are optional:

obsidian://library-hub-open?book=isbn%3A9780306406157&chapter=3
obsidian://library-hub-open?book=Books%2FLibrary%2FSalt%20Roads%20%E2%80%94%20Reed.md&quote=The%20river%20bends&location=chars%201200-3400

The parameters are book, highlight, quote, location, chapter, href and page. Links written before 0.7 (obsidian://library-hub-read?book=77&…, and a highlight's ?note=…&hl=…) keep working. A link with something wrong in it, such as a page that isn't a number, shows a notice saying what.

As a tool#

library-hub.open_book in LIBRARY_HUB_TOOLS takes { book, at } with the same values, so a model can open a book at the passage it is talking about.

With Topic Map (API 0.8)#

Topic Map keeps the book vectors; Library Hub asks it and keeps the shelves, the review and the writes. Its adapter lives in src/topic-map/: the types are vendored from Topic Map's api/ by npm run sync-topic-map (into src/topic-map/topic-map-api.d.ts; don't edit it by hand), and link.ts finds Topic Map by its plugin id, checks its API version (0.3, the first that serves the group calls) and detects each group call on its own, the way hasMethod does. Each feature is then in one of three states, and says which:

StateWhat the feature shows
Topic Map isn't installed or enabledInstall Topic Map to … where its row or button would be
Topic Map is there without the call, or its API is olderUpdate Topic Map to …
The call is thereThe feature itself

The calls each feature uses:

FeatureTopic Map call
Similar books (by words for a book Topic Map hasn't indexed)similar
The shelves that suit a book (Book tab, suggestShelves, library-hub.suggest_shelves)rankGroups with { shelf: shelf_id } groups
A shelf from a description, by meaning as well as wordsnearest with { text }
Shelf health: strays and splits (named by Topic Map when it can) as proposals for Review, its spread said against chance; Check this shelf, Split this shelfgroupHealth
Near-duplicate shelves, by what their books are aboutoverlaps (centroid pairs, merged with Library Hub's name and shared-book tests)
Suggest shelveslibraryCollection (read only), then findGroups
New books offered the shelves they suit (on topic-map:index-changed with items, and in Bring in new books)place
Open the map for a shelf or a bookopenMap

When Topic Map refuses with not-indexed, Library Hub offers Index the library in Topic Map once, priced first (estimateLibraryIndex), and starts it only when you say so (startLibraryIndex, with a spend limit). Don't ask again is remembered; the command still works. The library-hub.suggest_shelves tool is offered to Model Hub only while rankGroups is there, and offered again whenever Topic Map loads (topic-map:ready) or goes (topic-map:unload). New books are offered shelves only when topic-map:index-changed names the library collection (libraryCollection). A shelf with books Topic Map hasn't indexed yet says so ("3 of its books aren't indexed in Topic Map yet, so they didn't count"), and one with none indexed is listed last, unscored, or left unchecked. The index offer also says whether Topic Map keeps the index current afterwards (its Keep the library index up to date setting, off by default, under a daily limit). New books are offered shelves only when Library Hub announced them (its books-changed added) and Topic Map has since embedded them: indexing the whole library offers nothing. The e2e suite runs a real Topic Map build beside Library Hub and Model Hub (test/e2e/topic-map, from npm run sync-topic-map -- [repo] [ref], which clones and builds Topic Map in a temporary folder; test/e2e/specs/topic-map-live.e2e.ts), and a fake API for the states the real one can't easily reach (test/e2e/plugins/topic-map-fake). Scores are always said in words first, with the numbers after: "close to this shelf's books, inside the spread of its own books (score 0.82, bar 0.74)".

Hear about new books#

Library Hub tells other plugins when books arrive, change or go: after a notes sync or resync, when you bring in new books, and when a book note is deleted, renamed or moved. Changes close together come as one, so a sync of two hundred books is one call (API 0.7).

const stop = libraryHub.onBooksChanged((c) => {
	// c.added, c.changed, c.removed: { notePath, id, title, authors, previousPath? }
	// c.source: 'sync', 'intake' or 'vault'
	reindex(c.added);
});
this.register(stop);

The same news comes as the workspace event library-hub:books-changed (LIBRARY_HUB_BOOKS_CHANGED_EVENT), for a plugin that listens before Library Hub has loaded. A book's id is its Calibre id, or null for a book only another provider holds.

A book note you make by hand, in Obsidian or with another tool, isn't announced as added. Only a sync, a resync and Bring in new books announce new books. Deleting, renaming or moving any book note is announced, as source: 'vault'. To see a hand-made note, list the books again (listBooks) when you need them, or watch the vault yourself.

If the plugin is reloaded#

Library Hub fires library-hub:ready with the API each time it loads. onLibraryHubReady runs your callback now and on every reload, through your plugin's registerEvent, so it stops when your plugin unloads.

onLibraryHubReady(this.app, this, '0.8.0', (api) => (this.libraryHub = api));
Package
@insurgently/library-hub-api
Plugin id
library-hub
Needs
Obsidian 1.13.1, desktop
License
MIT