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-apiIt 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.tsand the helpers fromapi/index.ts. Switch to the package and delete the copies. -
The full reference is generated from the code: the API reference.
Steps#
-
Find the plugin.
getLibraryHubchecks 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;byandforgo 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@sincewhich version added it.The deprecated names
getBooks(which answers{ books }),onBooksReady,BooksApiand theBOOKS_*constants still work. Within 0.x the API only adds and deprecates; deprecated names go at 1.0. -
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) -
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 belowEvery 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
listBooksoraddToShelf, rejects a book Calibre doesn't hold withnot-calibre. -
Offer a conversion. A book that has only AZW3 or MOBI has no EPUB to read. With
convert: truethe user is asked; on yes, Calibre adds an EPUB. It resolvesnullif they decline.const epub = await books.bookFile(id, 'EPUB', { convert: true }); -
Handle failures. A failure rejects with an error whose
codesays why:server-off,book-missing,format-missingand 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
warningssays so. - Locations. An EPUB chapter's
locationis its file inside the book (OEBPS/c3.xhtml), orfile#anchorwhen 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 ischars a-b, offsets into the PDF's text that the reader turns into a page. Pass a location toopenBookas it is. For a passage inside a chapter, keep the chapter's location (EPUB) or add the passage's offset tostart(PDF), and pass its opening words asquote. - Offsets.
startandendcount code points (an emoji is one) into the book's whole text, andtextis 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 aspdfTextgives them, joined by blank lines. - Hash.
hashis 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.
pdfTextgives the same text, socharscount 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
formatsays which. Only one that can't be read (DRM, a damaged file) rejects withneeds-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 withdeclinedorconversion-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.
-
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 -
Make a shelf, add and remove books. Each write is one logged run;
runis 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 isunchangedon 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. createShelfwith a name whoseshelf_idexists already returns that shelf (created: false) and adds the books.
- A book already on the shelf is
-
Undo. Pass a
runback; fields go back unless the user changed them since (those are named inkept), 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 } -
Rename, delete and merge shelves (API 0.4). Each is a logged run
undoRunreverses. A rename keeps the shelf'sshelf_id, so nothing that names it breaks, and rejects withbusy, 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 } -
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
suggestShelvesasks Topic Map (see With Topic Map). Without Topic Map, or with one that doesn't rank shelves yet, it rejects withneeds-topic-map; when Topic Map hasn't indexed the book, withno-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))); -
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'] }, …] -
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 thatundoRuntakes 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 withno-hubwithout 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), orchars 1200-3400, code-point offsets into the textpdfTextgives, which say the PDF page. What a split adds after it (§2,w3) is ignored. Pass the passage's opening asquotewith 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.
As a link#
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"e=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:
| State | What the feature shows |
|---|---|
| Topic Map isn't installed or enabled | Install Topic Map to … where its row or button would be |
| Topic Map is there without the call, or its API is older | Update Topic Map to … |
| The call is there | The feature itself |
The calls each feature uses:
| Feature | Topic 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 words | nearest 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 shelf | groupHealth |
| Near-duplicate shelves, by what their books are about | overlaps (centroid pairs, merged with Library Hub's name and shared-book tests) |
| Suggest shelves | libraryCollection (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 book | openMap |
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));