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.
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.
| Permission | What the user is told |
|---|---|
books.list | list every book in your library (metadata) |
books.get | read one book's metadata |
books.update | edit a book's metadata |
books.create | add new books to your library |
books.content | read the text content of your book files |
ui.show | open result windows |
ui.form | ask you questions through forms |
reader.extend | add its button inside the reader (required for the reader surface) |
reader.selection | read the text you select in the reader (required for the selection surface) |
reader.highlight | highlight text in the reader |
reader.on_page | be told when you turn a page |
reader.transform | rewrite the book's text as you read it |
reader.style | change the reader's theme, font and layout |
net.fetch | download 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:
context-menu: the right-click menu on books; you receive the selected book ids.tools: an Open button on your plugin's row in the Plugins panel.reader: a button inside the reader; you receive the open book's id. Needsreader.extend.selection: a reader button that runs on the text the user selected; it arrives asextra.selectioninon_action. Needsreader.selection.
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
| Call | Permission | Returns |
|---|---|---|
calibre.books.list() | books.list | array of book tables: id, title, author, series, tags, formats, lang, pubdate, pub, rating, comments |
calibre.books.get(id) | books.get | one book table, or nil |
calibre.books.update(id, patch) | books.update | edits any of title, author, series, tags, lang, pubdate, pub, comments, rating |
calibre.books.create(meta) | books.create | the new book's id (needs at least title) |
calibre.books.content(id) | books.content | the 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.show | a results window; rows are strings or {label=β¦, value=β¦} |
calibre.ui.form{title=β¦, fields=β¦, submit=β¦} | ui.form | opens 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 |
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
| Call | Permission | Does |
|---|---|---|
calibre.reader.highlight(color) | reader.highlight | wraps the user's current selection in a highlight of that colour; returns true when something was highlighted |
calibre.reader.selection() | reader.selection | the text currently selected in the open book |
calibre.reader.set_style{theme=β¦, font=β¦, size=β¦, leading=β¦, margin=β¦, justify=β¦, mode=β¦, turn=β¦} | reader.style | adjusts 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.