Calibre.online Β· Plugin developer guide

Write a plugin

A calibre.online plugin is one Lua file. It runs in a real Lua 5.4 virtual machine compiled to WebAssembly, inside a Web Worker, and it can only ever do what its manifest declares. Users see those permissions before installing, spelled out in plain words.

The sandbox, honestly. Three fences stand between your code and the reader's library: the Worker (no page, no DOM), the Lua VM (no os, io, require, load or debug β€” they are removed before your code runs), and the host (every calibre.* call is re-checked against your manifest on the app side, so asking for something you never declared returns an error, not data). A call that runs longer than 20 seconds gets your plugin stopped.

The manifest

The file must open with a --[[manifest … ]] block holding JSON:

--[[manifest
{
  "id": "my-plugin",              // lowercase-with-dashes, unique
  "name": "My plugin",
  "version": "1.0.0",
  "description": "One or two honest sentences.",
  "author": "you",                // optional
  "homepage": "https://…",        // optional, https only
  "icon": "πŸ“š",                   // optional: an emoji, or an https image URL
  "permissions": ["books.get", "ui.show"],
  "net_allow": ["api.example.com"],    // required with net:fetch: the ONLY hosts fetch may reach
  "actions": [
    { "id": "run", "title": "Do the thing", "surface": "context-menu" }
  ],
  "settings": [                   // optional: your settings panel
    { "key": "speed", "type": "number", "label": "Speed", "default": 10 }
  ]
}
]]

Permissions

Every API function is its own permission. The manifest declares exactly the calls the plugin may make, the user reads them in plain words before installing, and the host refuses anything undeclared at call time. ui.toast, ui.progress, settings.get and log are free: they can neither read nor change anything of the user's.

PermissionWhat the user is told
books.listlist every book in your library (metadata)
books.getread one book's metadata
books.updateedit a book's metadata
books.createadd new books to your library
books.contentread the text content of your book files
ui.showopen result windows
ui.formask you questions through forms
reader.extendadd its button inside the reader (required for the reader surface)
reader.selectionread the text you select in the reader (required for the selection surface)
reader.highlighthighlight text in the reader
reader.on_pagebe told when you turn a page
reader.transformrewrite the book's text as you read it
reader.stylechange the reader's theme, font and layout
net.fetchdownload from the web β€” requires net_allow, the only hosts fetch may reach (subdomains pass); any other host is refused at call time

Actions and surfaces

Each action appears somewhere in the app and calls your on_action function when used:

Settings

Declare fields and the app draws your settings panel with its own components, so it looks native everywhere. Types: text, number, toggle, select (give options). Read values with calibre.settings.get("key").

The entry point

function on_action(action_id, book_ids)
  -- action_id: which of your actions was used
  -- book_ids:  array of the books it applies to
end

The calibre.* API

CallPermissionReturns
calibre.books.list()books.listarray of book tables: id, title, author, series, tags, formats, lang, pubdate, pub, rating, comments
calibre.books.get(id)books.getone book table, or nil
calibre.books.update(id, patch)books.updateedits any of title, author, series, tags, lang, pubdate, pub, comments, rating
calibre.books.create(meta)books.createthe new book's id (needs at least title)
calibre.books.content(id)books.contentthe book's readable text (capped at 2 MB)
calibre.ui.toast(msg)β€”a small notification, prefixed with your plugin's name
calibre.ui.show{title=…, rows=…}ui.showa results window; rows are strings or {label=…, value=…}
calibre.ui.form{title=…, fields=…, submit=…}ui.formopens a form, waits, returns a table of values (nil if cancelled). Fields use the same shape as settings.
calibre.settings.get(key)β€”the user's value for one of your declared settings
calibre.net.fetch(url)net.fetch{status=…, body=…}; https GET only, hosts from your net_allow list only, 2 MB cap, 10 s timeout
calibre.log(msg)β€”writes to the browser console for debugging
Design system, enforced. Plugins never emit HTML or CSS. Windows and forms are described as data (ui.show, ui.form, manifest settings) and the app renders them with its own components. Your tool looks native in light and dark, on phone and desktop, without you doing anything, and nothing you output can inject script into the page.

Extending the reader

Beyond buttons, three hook functions and three calls let a plugin live inside the reading experience. Hooks are plain global functions: define them and, with the matching permission, the reader calls them.

-- called on every page turn (needs reader.on_page)
function on_page(book_id, chapter, page, pages)
  if page == pages then calibre.ui.toast("chapter finished!") end
end

-- rewrite the chapter's text as it is displayed (needs reader.transform).
-- texts is an array of the chapter's TEXT NODES; return the same array,
-- reworded however you like. Markup never crosses this boundary in
-- either direction: you cannot inject an element, attribute or script.
function on_transform(book_id, texts)
  for i, t in ipairs(texts) do
    texts[i] = string.gsub(t, "whale", "πŸ‹")
  end
  return texts
end

-- an action on the selection surface receives what the user selected
function on_action(action, book_ids, extra)
  if action == "define" then
    calibre.ui.show({ title = "You selected", rows = { extra.selection } })
  end
end
CallPermissionDoes
calibre.reader.highlight(color)reader.highlightwraps the user's current selection in a highlight of that colour; returns true when something was highlighted
calibre.reader.selection()reader.selectionthe text currently selected in the open book
calibre.reader.set_style{theme=…, font=…, size=…, leading=…, margin=…, justify=…, mode=…, turn=…}reader.styleadjusts the reader's own settings β€” themes light/sepia/dark/black, fonts book/serif/sans, the same knobs the settings panel offers, clamped to the same ranges. Nothing free-form: no CSS, no colours outside the themes.

A complete example

Reading time: counts the words of each selected book and turns them into hours at the reader's own pace. Copy it whole into Plugins β†’ Install… to try it.

--[[manifest
{
  "id": "reading-time",
  "name": "Reading time",
  "version": "1.0.0",
  "description": "Estimates how long each selected book takes to read, at your own pace.",
  "author": "calibre.online",
  "icon": "\u23f1\ufe0f",
  "permissions": ["books.get", "books.content", "ui.show"],
  "actions": [
    { "id": "estimate", "title": "Estimate reading time", "surface": "context-menu" },
    { "id": "estimate", "title": "Estimate reading time", "surface": "tools" }
  ],
  "settings": [
    { "key": "wpm", "type": "number", "label": "Your reading speed (words per minute)", "default": 220 }
  ]
}
]]

function on_action(action, book_ids)
  local wpm = calibre.settings.get("wpm") or 220
  if wpm < 60 then wpm = 220 end

  local rows = {}
  for i, id in ipairs(book_ids) do
    local book = calibre.books.get(id)
    if book then
      local text = calibre.books.content(id)
      local words = 0
      for _ in string.gmatch(text, "%S+") do words = words + 1 end
      local minutes = math.floor(words / wpm + 0.5)
      local label
      if minutes >= 60 then
        label = string.format("%dh%02d", math.floor(minutes / 60), minutes % 60)
      else
        label = minutes .. " min"
      end
      rows[#rows + 1] = { label = book.title, value = words .. " words, about " .. label }
    end
  end

  if #rows == 0 then
    calibre.ui.toast("select a book with a readable file first")
    return
  end
  calibre.ui.show({ title = "Reading time at " .. wpm .. " wpm", rows = rows })
end

Ship it

Host the .lua file anywhere (a GitHub gist works), then submit it to the store with a link. We review by hand and list it with your name, icon and homepage.