feat(koreader): rebuild the plugin interface and fix four-way annotation sync

Squash of feat/koreader-widget-board. KOReader plugin 2.0.0 gets the widget-board dashboard, redesigned
book details, menus, lists and dialogs, Decor page and reading companions. Highlights, notes and bookmarks
now stay in step across web, Kobo, KOReader and the phones (defects D-00 to D-20, migration 0121), and
reading progress and narration hand off cleanly between devices.
This commit is contained in:
neon
2026-10-19 21:04:23 +00:00
parent 7202a59afc
commit 80244ea4e2
349 changed files with 66312 additions and 8386 deletions
@@ -1,3 +1,7 @@
-- Proves KOReader's side of BookOrbit: a trimmed Tools entry, the menu bar
-- tab that acts as a button (and its opt-out), the icon path the tab bar can
-- resolve, and where each of the menu sheet's actions goes.
package.loaded["gettext"] = function(text)
return text
end
@@ -11,20 +15,23 @@ package.loaded["ffi/util"] = {
end,
}
local ticks = {}
package.loaded["device"] = { model = "test-device" }
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
package.loaded["ui/widget/inputdialog"] = {}
package.loaded["ui/widget/multiinputdialog"] = {}
package.loaded["ui/network/manager"] = {}
package.loaded["ui/widget/notification"] = {}
package.loaded["ui/uimanager"] = {}
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
package.loaded["util"] = {
trim = function(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end,
package.loaded["ui/network/manager"] = {
runWhenConnected = function(_, callback) callback() end,
}
package.loaded["ui/widget/notification"] = {}
package.loaded["ui/uimanager"] = {
nextTick = function(_, callback) table.insert(ticks, callback) end,
tickAfterNext = function(_, callback) table.insert(ticks, callback) end,
show = function() end,
}
package.loaded["ffi/sha2"] = { md5 = function(value) return value end }
package.loaded["libs/libkoreader-lfs"] = { currentdir = function() return "/opt/koreader" end }
package.loaded["bookorbit_api"] = {}
package.loaded["bookorbit_highlight_diagnostics"] = {}
package.loaded["bookorbit_sweep"] = {
syncStatus = function()
return { lastSweepAt = 0, matched = 0, unmatched = 0 }
@@ -46,194 +53,151 @@ local function itemText(item)
if item.text_func then return item:text_func() end
end
local function findItem(items, text)
for _, item in ipairs(items) do
if itemText(item) == text then return item end
local function texts(items)
local out = {}
for _, item in ipairs(items) do table.insert(out, itemText(item)) end
return table.concat(out, "|")
end
local function newPlugin(opts)
opts = opts or {}
local calls = {}
local plugin = {
settings = { menu_bar_icon = opts.menu_bar_icon },
path = opts.path or "plugins/bookorbit.koplugin",
ui = opts.reader and { document = { file = "/books/Book.epub" }, menu = {} } or { menu = {} },
isLoggedIn = function() return opts.logged_out ~= true end,
calls = calls,
}
MainMenu.install(plugin)
for _, name in ipairs({ "browseCatalog", "openSignIn", "openBookSheet", "openSettings", "openSyncStatus", "startSweep", "applyUpdate", "customizeHome" }) do
plugin[name] = function(_, ...) table.insert(calls, { name, ... }) end
end
end
local function hasItem(items, text)
return findItem(items, text) ~= nil
end
local function assertItemText(items, index, text, label)
assertEqual(itemText(items[index]), text, label)
end
local function assertSeparator(items, index, expected, label)
assertEqual(items[index].separator == true, expected, label)
return plugin, calls
end
local function menuItems(plugin)
local menu_items = {}
plugin:addToMainMenu(menu_items)
return menu_items.bookorbit.sub_item_table
return menu_items
end
local function newPlugin(logged_in)
local plugin = {
SYNC_STRATEGY = { PROMPT = 1, SILENT = 2, DISABLE = 3 },
settings = {
auto_sync = true,
annotation_sync = true,
skip_sync_when_offline = false,
sync_forward = 1,
sync_backward = 1,
},
isLoggedIn = function()
return logged_in
end,
updateCheckMenuText = function()
return "Installed plugin: current"
end,
getSyncPeriod = function()
return 10
end,
}
MainMenu.install(plugin)
return plugin
local function runTicks()
while #ticks > 0 do table.remove(ticks, 1)() end
end
local plugin = newPlugin(false)
-- The file browser's Tools entry: open BookOrbit, or its settings.
local plugin, calls = newPlugin()
local items = menuItems(plugin)
assertEqual(#items, 1, "logged-out menu only has setup group")
assertEqual(hasItem(items, "Account & setup"), true, "logged-out menu keeps account setup")
assertEqual(hasItem(items, "Open dashboard"), false, "logged-out menu hides dashboard")
assertEqual(hasItem(items, "Current book"), false, "logged-out menu hides current book")
assertEqual(hasItem(items, "Sync current book now"), false, "logged-out menu hides current book sync")
assertEqual(hasItem(items, "Sync all books now"), false, "logged-out menu hides sync all")
assertEqual(hasItem(items, "Sync settings"), false, "logged-out menu hides sync settings")
assertEqual(hasItem(items, "Settings"), false, "logged-out menu hides settings")
assertEqual(texts(items.bookorbit.sub_item_table), "Open BookOrbit|Settings", "the Tools entry is two rows in the file browser")
assertEqual(items.bookorbit.sorting_hint, "tools", "the entry still falls back to the Tools menu")
local account = findItem(items, "Account & setup")
assertEqual(hasItem(account.sub_item_table, "Login"), true, "logged-out setup has login")
local dashboard_items = plugin:dashboardMenuItems()
assertEqual(hasItem(dashboard_items, "Account & setup"), true, "dashboard setup menu keeps account setup when logged out")
-- In a book it adds This book.
local reader = newPlugin({ reader = true })
assertEqual(texts(menuItems(reader).bookorbit.sub_item_table), "Open BookOrbit|This book|Settings", "a book adds This book")
plugin = newPlugin(true)
items = menuItems(plugin)
assertEqual(#items, 4, "file manager menu has dashboard, global sync, settings and setup rows")
assertItemText(items, 1, "Open dashboard", "file manager order starts with dashboard")
assertItemText(items, 2, "Sync all books now", "file manager order keeps global sync second")
assertItemText(items, 3, "Settings", "file manager order keeps settings third")
assertItemText(items, 4, "Account & setup", "file manager order ends with setup")
assertSeparator(items, 1, true, "file manager dashboard ends dashboard group")
assertSeparator(items, 2, true, "file manager sync all ends global sync group")
assertSeparator(items, 3, true, "file manager settings ends settings group")
assertSeparator(items, 4, false, "file manager setup has no trailing separator")
assertEqual(hasItem(items, "Open dashboard"), true, "logged-in menu shows dashboard")
assertEqual(hasItem(items, "Open dashboard on startup (Off)"), false, "logged-in top level hides dashboard startup")
assertEqual(hasItem(items, "Installed plugin: current"), false, "logged-in top level hides update row")
assertEqual(hasItem(items, "Current book"), false, "file manager menu hides current book")
assertEqual(hasItem(items, "Sync current book now"), false, "file manager menu hides current book sync")
assertEqual(hasItem(items, "Auto sync current book"), false, "file manager menu hides current book auto sync")
assertEqual(hasItem(items, "Sync all books now"), true, "logged-in menu shows sync all")
assertEqual(hasItem(items, "Two-way highlights & bookmarks"), false, "logged-in top level hides the two-way toggle")
assertEqual(hasItem(items, "Skip auto-sync when offline"), false, "logged-in top level hides offline skip toggle")
assertEqual(hasItem(items, "Sync settings"), false, "logged-in menu replaces sync settings with settings")
assertEqual(hasItem(items, "Settings"), true, "logged-in menu shows settings")
assertEqual(hasItem(items, "Account & setup"), true, "logged-in menu keeps account setup")
local file_manager_settings = findItem(items, "Settings")
assertEqual(hasItem(file_manager_settings.sub_item_table, "Dashboard"), true, "file manager settings has dashboard group")
assertEqual(hasItem(file_manager_settings.sub_item_table, "Sync"), true, "file manager settings has sync group")
assertEqual(hasItem(file_manager_settings.sub_item_table, "Plugin"), true, "file manager settings has plugin group")
local file_manager_dashboard_settings = findItem(file_manager_settings.sub_item_table, "Dashboard")
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Open dashboard on startup (Off)"), true, "file manager dashboard settings has startup option")
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Section 1 (Stats)"), true, "file manager dashboard settings has slot 1")
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Section 2 (Continue reading)"), true, "file manager dashboard settings has slot 2")
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Section 3 (Discover)"), true, "file manager dashboard settings has slot 3")
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Section 4 (Browse)"), true, "file manager dashboard settings has slot 4")
assertEqual(hasItem(file_manager_dashboard_settings.sub_item_table, "Reset to Default"), true, "dashboard settings can restore the four defaults")
local file_manager_sync_settings = findItem(file_manager_settings.sub_item_table, "Sync")
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Auto sync current book"), false, "file manager sync settings hides reader auto sync")
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Two-way highlights & bookmarks"), true, "file manager sync settings has the two-way toggle")
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Skip auto-sync when offline"), true, "file manager sync settings has offline skip toggle")
assertEqual(hasItem(file_manager_sync_settings.sub_item_table, "Open dashboard on startup (Off)"), false, "file manager sync settings excludes dashboard startup")
local file_manager_plugin_settings = findItem(file_manager_settings.sub_item_table, "Plugin")
assertEqual(hasItem(file_manager_plugin_settings.sub_item_table, "Installed plugin: current"), true, "file manager plugin settings has update row")
-- The menu bar tab is a button: never remembered, never a list.
local tab = items.bookorbit_tab
assertEqual(tab ~= nil, true, "the menu bar tab is offered by default")
assertEqual(tab.remember, false, "the tab is never remembered, so opening the menu never fires it")
assertEqual(#tab, 0, "the tab holds no rows")
assertEqual(type(tab.callback), "function", "the tab acts on a tap")
dashboard_items = plugin:dashboardMenuItems()
assertEqual(hasItem(dashboard_items, "Open dashboard"), false, "dashboard mirror skips open dashboard")
assertItemText(dashboard_items, 1, "Installed plugin: current", "dashboard mirror promotes update row first")
assertItemText(dashboard_items, 2, "Sync all books now", "dashboard mirror keeps sync all second")
assertItemText(dashboard_items, 3, "Settings", "dashboard mirror keeps settings third")
assertItemText(dashboard_items, 4, "Account & setup", "dashboard mirror keeps setup fourth")
assertSeparator(dashboard_items, 1, true, "dashboard mirror update row ends update group")
assertSeparator(dashboard_items, 2, true, "dashboard mirror sync all ends global sync group")
assertSeparator(dashboard_items, 3, true, "dashboard mirror settings ends settings group")
assertSeparator(dashboard_items, 4, false, "dashboard mirror setup has no trailing separator")
assertEqual(hasItem(dashboard_items, "Sync all books now"), true, "dashboard mirror keeps sync all")
assertEqual(hasItem(dashboard_items, "Installed plugin: current"), true, "dashboard mirror top level shows update row")
assertEqual(hasItem(dashboard_items, "Account & setup"), true, "dashboard mirror keeps account setup")
local dashboard_settings = findItem(dashboard_items, "Settings")
local dashboard_dashboard_settings = findItem(dashboard_settings.sub_item_table, "Dashboard")
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Open dashboard on startup (Off)"), true, "dashboard settings has startup option")
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Section 1 (Stats)"), true, "dashboard settings has slot 1")
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Section 2 (Continue reading)"), true, "dashboard settings has slot 2")
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Section 3 (Discover)"), true, "dashboard settings has slot 3")
assertEqual(hasItem(dashboard_dashboard_settings.sub_item_table, "Section 4 (Browse)"), true, "dashboard settings has slot 4")
assertEqual(hasItem(dashboard_items, "Section 1 (Stats)"), false, "slot pickers are not promoted to the dashboard menu top level")
local dashboard_sync_settings = findItem(dashboard_settings.sub_item_table, "Sync")
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Two-way highlights & bookmarks"), true, "dashboard sync settings has the two-way toggle")
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Skip auto-sync when offline"), true, "dashboard sync settings has offline skip toggle")
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Open dashboard on startup (Off)"), false, "dashboard sync settings excludes dashboard startup")
assertEqual(hasItem(dashboard_settings.sub_item_table, "Plugin"), false, "dashboard settings omits duplicate plugin group")
-- Its icon is the shipped SVG, reached from KOReader's icon folders by a
-- climb to the root, with the extension the tab bar adds stripped.
assertEqual(tab.icon:sub(1, 9), "../../../", "the icon name climbs out of the icon folders")
assertEqual(tab.icon:find("opt/koreader/plugins/bookorbit.koplugin/assets/bookorbit.tab", 1, true) ~= nil, true, "a relative plugin path is made absolute")
assertEqual(tab.icon:sub(-4) ~= ".svg", true, "the extension is left to the tab bar")
local user_plugin = newPlugin({ path = "/mnt/onboard/.adds/koreader/plugins/bookorbit.koplugin" })
assertEqual(user_plugin:menuBarIconName():find("../mnt/onboard/.adds/koreader/plugins/bookorbit.koplugin/assets/bookorbit.tab", 1, true) ~= nil, true,
"an absolute plugin path is used as it is")
plugin.ui = { document = {} }
items = menuItems(plugin)
assertEqual(#items, 6, "reader menu has dashboard, current-book, auto-sync, global sync, settings and setup rows")
assertItemText(items, 1, "Open dashboard", "reader order starts with dashboard")
assertItemText(items, 2, "Sync current book now", "reader order keeps current book sync second")
assertItemText(items, 3, "Auto sync current book", "reader order keeps auto sync third")
assertItemText(items, 4, "Sync all books now", "reader order keeps sync all fourth")
assertItemText(items, 5, "Settings", "reader order keeps settings fifth")
assertItemText(items, 6, "Account & setup", "reader order ends with setup")
assertSeparator(items, 1, true, "reader dashboard ends dashboard group")
assertSeparator(items, 2, false, "reader current book sync stays in sync action group")
assertSeparator(items, 3, true, "reader auto sync ends current-book group")
assertSeparator(items, 4, true, "reader sync all ends sync action group")
assertSeparator(items, 5, true, "reader settings ends settings group")
assertSeparator(items, 6, false, "reader setup has no trailing separator")
assertEqual(hasItem(items, "Current book"), false, "reader menu flattens current book actions")
assertEqual(hasItem(items, "Sync current book now"), true, "reader menu has manual book sync")
assertEqual(hasItem(items, "Auto sync current book"), true, "reader top level has auto sync")
assertEqual(hasItem(items, "Push progress"), false, "reader menu hides progress-only push")
assertEqual(hasItem(items, "Pull progress"), false, "reader menu hides progress-only pull")
assertEqual(hasItem(items, "Retry highlight sync"), false, "reader menu hides highlight retry")
assertEqual(hasItem(items, "Retry open-book match"), false, "reader menu hides match retry")
-- Turned off, the tab is not registered at all (an unordered item would show
-- up as a NEW: row in the first menu).
local hidden = newPlugin({ menu_bar_icon = false })
assertEqual(menuItems(hidden).bookorbit_tab, nil, "no tab when the icon is turned off")
local settings = findItem(items, "Settings")
assertEqual(hasItem(settings.sub_item_table, "Dashboard"), true, "reader settings has dashboard group")
assertEqual(hasItem(settings.sub_item_table, "Sync"), true, "reader settings has sync group")
assertEqual(hasItem(settings.sub_item_table, "Plugin"), true, "reader settings has plugin group")
local reader_dashboard_settings = findItem(settings.sub_item_table, "Dashboard")
assertEqual(hasItem(reader_dashboard_settings.sub_item_table, "Open dashboard on startup (Off)"), true, "reader dashboard settings has startup option")
local sync_settings = findItem(settings.sub_item_table, "Sync")
assertEqual(hasItem(sync_settings.sub_item_table, "Auto sync current book"), false, "reader sync settings excludes top-level auto sync")
assertEqual(hasItem(sync_settings.sub_item_table, "Two-way highlights & bookmarks"), true, "sync settings has the two-way toggle")
assertEqual(hasItem(sync_settings.sub_item_table, "Skip auto-sync when offline"), true, "sync settings has offline toggle")
assertEqual(hasItem(sync_settings.sub_item_table, "Open dashboard on startup (Off)"), false, "sync settings excludes dashboard startup")
assertEqual(hasItem(items, "Two-way highlights & bookmarks"), false, "top level hides the two-way toggle")
assertEqual(hasItem(items, "Skip auto-sync when offline"), false, "top level hides offline toggle")
assertEqual(findItem(sync_settings.sub_item_table, "Skip auto-sync when offline").enabled_func(), true, "offline toggle is enabled when auto sync is on")
local reader_plugin_settings = findItem(settings.sub_item_table, "Plugin")
assertEqual(hasItem(reader_plugin_settings.sub_item_table, "Installed plugin: current"), true, "reader plugin settings has update row")
-- Tapping the tab: BookOrbit in the file browser, This book in a book, and
-- sign-in when signed out, each after the menu's own close.
plugin.ui.menu.onCloseFileManagerMenu = function() table.insert(calls, { "close_fm_menu" }) end
tab.callback()
assertEqual(calls[1][1], "close_fm_menu", "the KOReader menu closes first")
assertEqual(#calls, 1, "nothing opens before the close has painted")
runTicks()
assertEqual(calls[2][1], "browseCatalog", "the file browser's tab opens BookOrbit")
local reader_dashboard_items = plugin:dashboardMenuItems()
assertEqual(hasItem(reader_dashboard_items, "Sync current book now"), false, "dashboard mirror hides current book sync while reader is open")
assertEqual(hasItem(reader_dashboard_items, "Auto sync current book"), false, "dashboard mirror hides auto sync while reader is open")
assertItemText(reader_dashboard_items, 1, "Installed plugin: current", "reader dashboard mirror promotes update row first")
assertItemText(reader_dashboard_items, 2, "Sync all books now", "reader dashboard mirror keeps sync all second")
assertItemText(reader_dashboard_items, 3, "Settings", "reader dashboard mirror keeps settings third")
assertItemText(reader_dashboard_items, 4, "Account & setup", "reader dashboard mirror keeps setup fourth")
local book, book_calls = newPlugin({ reader = true })
book.ui.menu.onCloseReaderMenu = function() table.insert(book_calls, { "close_reader_menu" }) end
menuItems(book).bookorbit_tab.callback()
runTicks()
assertEqual(book_calls[1][1], "close_reader_menu", "the reader menu closes first")
assertEqual(book_calls[2][1], "openBookSheet", "in a book the tab opens This book")
plugin.settings.annotation_sync = false
plugin.settings.auto_sync = false
items = menuItems(plugin)
settings = findItem(items, "Settings")
sync_settings = findItem(settings.sub_item_table, "Sync")
assertEqual(findItem(sync_settings.sub_item_table, "Skip auto-sync when offline").enabled_func(), false, "offline toggle is disabled when auto sync is off")
assertEqual(hasItem(items, "Retry highlight sync"), false, "two-way-off reader menu hides highlight retry")
assertEqual(hasItem(items, "Retry open-book match"), false, "two-way-off reader menu hides match retry")
local out, out_calls = newPlugin({ logged_out = true })
menuItems(out).bookorbit_tab.callback()
runTicks()
assertEqual(out_calls[1][1], "openSignIn", "signed out, the tab opens sign-in")
-- Open BookOrbit from Tools signs in first when needed.
out_calls[1] = nil
out:openBookOrbit()
assertEqual(out_calls[#out_calls][1], "openSignIn", "Open BookOrbit asks for a sign-in when signed out")
-- The sheet's actions close it, then act.
local sheet_plugin, sheet_calls = newPlugin()
local events = {}
local catalog = {
goDashboard = function() table.insert(events, "home") end,
onCloseAllMenus = function() table.insert(events, "exit") end,
onSearchButtonTap = function() table.insert(events, "search") end,
boardOpenLink = function(_, link) table.insert(events, "link:" .. link) end,
showBookActions = function() table.insert(events, "list-options") end,
}
local closes, refreshes = 0, 0
local page = {
close = function() closes = closes + 1 end,
refresh = function() refreshes = refreshes + 1 end,
sheet_data = {},
}
package.loaded["bookorbit_menu_sheet"] = { data = function() return { reloaded = true } end }
local function run(action)
sheet_plugin:onMenuSheetAction(page, catalog, action)
runTicks()
end
run({ kind = "home" })
run({ kind = "exit" })
run({ kind = "search" })
run({ kind = "link", link = "authors" })
run({ kind = "list-options" })
assertEqual(table.concat(events, ","), "home,exit,search,link:authors,list-options", "catalog actions reach the catalog")
assertEqual(closes, 5, "every action closes the sheet first")
-- Sync now keeps the sheet open: its tile shows the run, then the outcome.
run({ kind = "sync" })
assertEqual(closes, 5, "Sync now leaves the sheet open")
assertEqual(sheet_calls[1][1], "startSweep", "Sync now starts a library sync")
local sweep = sheet_calls[1][2]
assertEqual(sweep.quiet, true, "the sync reports to the sheet, not a dialog")
assertEqual(page.sheet_data.sync.running, true, "the tile says the sync is running")
sweep.on_progress("Matching books")
assertEqual(page.sheet_data.sync.text, "Matching books", "the short progress line is shown")
sweep.on_done(nil)
assertEqual(page.sheet_data.reloaded, true, "the sheet's numbers are reloaded when the sync ends")
assertEqual(page.sheet_data.sync.done, true, "the tile says the sync finished")
assertEqual(refreshes, 3, "the sheet redraws at start, on progress and at the end")
plugin.errorLabel = function() return "fallback" end
sheet_plugin.errorLabel = plugin.errorLabel
sweep.on_done("not_started")
assertEqual(page.sheet_data.sync.err, "Another sync is running", "a sync that did not start says why")
table.remove(sheet_calls, 1)
run({ kind = "settings" })
run({ kind = "status" })
run({ kind = "about" })
run({ kind = "customize" })
local names = {}
for _, call in ipairs(sheet_calls) do table.insert(names, call[1]) end
assertEqual(table.concat(names, ","), "openSettings,openSyncStatus,openSettings,customizeHome", "plugin actions reach the plugin")
assertEqual(sheet_calls[3][3], "about", "Help & about opens settings on About")
print("bookorbit_menu_context_test.lua: ok")