feat(reader): serialize and coordinate sync jobs in KOReader plugin

Introduce a sync coordinator to serialize and prioritize KOReader sync jobs (such as progress sync, highlight sync, library sweep, and update check). Coalesce pending jobs by family to prevent race conditions and redundant network requests. Use a dedicated scheduler for open annotations to handle highlight sync and matching asynchronously on book open. Add a diagnostics menu in the settings to track detailed sync status and provide clear error messages for debugging.
This commit is contained in:
neon
2026-07-08 17:55:40 +00:00
parent 512f16e288
commit 36bfc6e83d
26 changed files with 3140 additions and 473 deletions
+45 -7
View File
@@ -33,6 +33,7 @@ import { useThemeStore } from '@/stores/theme'
import { useAppInfo } from '@/features/settings/composables/useAppInfo' import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { buildSidebarVersionUi } from '@/components/sidebar/versionUi' import { buildSidebarVersionUi } from '@/components/sidebar/versionUi'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew' import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
function useSidebarSection(key: string) { function useSidebarSection(key: string) {
const isOpen = ref(localStorage.getItem(`bookorbit:sidebar:${key}`) !== 'false') const isOpen = ref(localStorage.getItem(`bookorbit:sidebar:${key}`) !== 'false')
@@ -55,6 +56,7 @@ const { handleLibraryCreated } = useLibraryCreationRedirect()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo() const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew() const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
const supportUrl = 'https://ko-fi.com/neonbookorbit'
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full')) const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
const versionUi = computed(() => { const versionUi = computed(() => {
@@ -438,14 +440,50 @@ onUnmounted(() => stopLibraryUploadListener())
</SidebarGroup> </SidebarGroup>
</SidebarContent> </SidebarContent>
<SidebarFooter v-if="versionUi.currentLabel" class="border-t border-sidebar-border/60"> <SidebarFooter class="border-t border-sidebar-border/60">
<div class="hidden justify-center group-data-[collapsible=icon]:flex">
<Tooltip>
<TooltipTrigger as-child>
<a
:href="supportUrl"
target="_blank"
rel="noopener noreferrer"
aria-label="Support BookOrbit on Ko-fi"
class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-sidebar-border/60 text-primary transition-colors hover:bg-primary/8 hover:text-primary/85 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring"
>
<Icons.Heart class="h-4 w-4 fill-primary/20" aria-hidden="true" />
</a>
</TooltipTrigger>
<TooltipContent side="right">Support BookOrbit</TooltipContent>
</Tooltip>
</div>
<div class="px-2 py-2 group-data-[collapsible=icon]:hidden"> <div class="px-2 py-2 group-data-[collapsible=icon]:hidden">
<div class="flex flex-wrap items-center justify-center gap-2 text-center text-[12px] font-medium leading-none text-sidebar-foreground/80"> <div
<RouterLink to="/whats-new" class="inline-flex items-center gap-1.5 transition-colors hover:text-sidebar-foreground"> class="flex min-w-0 flex-wrap items-center justify-center gap-x-2 gap-y-1 text-center text-[12px] font-medium leading-none text-sidebar-foreground/80"
{{ versionUi.currentLabel }} >
<span v-if="hasUnseenWhatsNew" class="h-1.5 w-1.5 rounded-full bg-primary" aria-label="New release notes available" /> <RouterLink
v-if="versionUi.currentLabel"
to="/whats-new"
class="inline-flex min-w-0 max-w-full items-center gap-1.5 transition-colors hover:text-sidebar-foreground"
>
<span class="truncate">{{ versionUi.currentLabel }}</span>
<span v-if="hasUnseenWhatsNew" class="h-1.5 w-1.5 flex-none rounded-full bg-primary" aria-label="New release notes available" />
</RouterLink> </RouterLink>
<span v-if="versionUi.currentLabel" class="text-sidebar-foreground/45">•</span>
<a
:href="supportUrl"
target="_blank"
rel="noopener noreferrer"
aria-label="Support BookOrbit on Ko-fi"
class="inline-flex shrink-0 items-center gap-1.5 text-sidebar-foreground/85 transition-colors hover:text-primary"
>
<Icons.Heart class="h-3.5 w-3.5 fill-primary/20 text-primary" aria-hidden="true" />
<span>Support</span>
</a>
<span v-if="versionUi.showLatest" class="text-sidebar-foreground/45">•</span> <span v-if="versionUi.showLatest" class="text-sidebar-foreground/45">•</span>
<a <a
@@ -453,9 +491,9 @@ onUnmounted(() => stopLibraryUploadListener())
:href="versionUi.latestHref" :href="versionUi.latestHref"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="font-semibold text-primary transition-colors hover:text-primary/85" class="inline-flex min-w-0 max-w-full font-semibold text-primary transition-colors hover:text-primary/85"
> >
Latest {{ versionUi.latestLabel }} <span class="truncate">Latest {{ versionUi.latestLabel }}</span>
</a> </a>
</div> </div>
</div> </div>
+43 -103
View File
@@ -2,128 +2,68 @@
Syncs your KOReader reading life into BookOrbit: Syncs your KOReader reading life into BookOrbit:
- Native BookOrbit catalog browsing from KOReader: a reading-first dashboard with compact Continue reading rows, goal/streak/highlight summaries, "On device", libraries, collections, SmartScopes, authors, series, all books and scoped or global search. The dashboard can auto-open on startup and falls back to its last cached snapshot when offline. Browse books as a cover mosaic or a detailed list, with read-status, reading-progress bars and on-device indicators, read-status and format filters, a configurable grid, persisted view/sort/grid, recent-search history, loading feedback, download progress and inline retry on failures. Navigation runs off the UI thread (cancellable, no freeze) and the next page's covers are prefetched. The detail page shows genres, tags, page count and an expandable full description, and can set read status and star rating directly (synced back like any other status/rating change). Series open as an ordered read-through view with a "read N/total" header. Authors and series lists are paginated with footer arrows and filtered from the catalog action menu. Downloads are linked to BookOrbit sync after the file is saved, and books already on the device offer a "Read" action that opens the local file. Bulk downloads can run from the current page, all books matching a list, or selected books across pages. The thumbnail cache is bounded and evicts the oldest covers. - **Catalog browsing**: a reading-first dashboard on the device with libraries, collections, SmartScopes, authors, series and search. Mosaic or list view, read-status/format filters, downloads and bulk downloads, "On device" indicators, and a "Read" action for books already on the device.
- Live reading progress of the open book (pull on open with conflict prompt, optional periodic push every N page turns, push on close and suspend), compatible with the kosync flow BookOrbit already speaks. - **Progress sync**: pulls progress on book open (with a conflict prompt), pushes periodically and on close/suspend.
- Reading statistics: the raw per-page time events from KOReader's statistics database. BookOrbit turns them into reading sessions and daily stats, so they show up in the existing dashboards. - **Reading statistics**: uploads KOReader's per-page time events; BookOrbit turns them into reading sessions and daily stats.
- Highlights and notes, two ways: device highlights appear in the BookOrbit web reader as native highlights, and highlights, note edits and deletions made in BookOrbit come back to the device. - **Two-way highlights**: device highlights appear as native highlights on the web reader; highlights, edits and deletions made on the web come back to the device.
- Book status (reading / complete / abandoned) and star ratings, with newest-change-wins conflict handling. - **Status & ratings**: reading/complete/abandoned status and star ratings sync both ways, newest-change-wins.
The open book is captured from live memory when it is closed, on suspend, and via "Sync this book now", so highlights and status made mid-session sync without reopening the book. Manual book sync checks BookOrbit for newer progress before uploading local progress, so a second device does not overwrite a first device's newer location. The full-library sweep ("Sync all books now") is manual-only. Only books BookOrbit already knows about are synced, matched by the same partial-MD5 file hash KOReader uses internally. Unmatched books on the device are re-checked automatically as your library changes.
Only books that BookOrbit already knows are synced. Matching uses the same partial-MD5 file hash KOReader uses internally, which BookOrbit's scanner computes for every file. Books on the device that are not in any BookOrbit library are skipped and re-checked automatically when your library changes.
## Install (preconfigured, recommended) ## Install (preconfigured, recommended)
1. In BookOrbit web settings, open Settings, KOReader and create sync credentials (username + password). The KOReader sync permission must be enabled for your account. 1. In BookOrbit web settings: Settings > KOReader, create sync credentials (KOReader sync permission required on your account).
2. Click "Download preconfigured plugin". The zip is generated by your server with the server address and your sync credentials already embedded (`bookorbit_provision.lua` inside the plugin folder). 2. Click "Download preconfigured plugin". The zip embeds your server address and credentials.
3. Unzip and copy the `bookorbit.koplugin/` folder to the `koreader/plugins/` directory on your device. 3. Unzip and copy `bookorbit.koplugin/` into `koreader/plugins/` on your device.
4. Restart KOReader. The plugin configures itself on startup (a confirmation message appears) and removes the embedded credentials file. 4. Restart KOReader. The plugin configures itself on startup and removes the embedded credentials file.
Re-downloading later (for example after changing the sync password or server address) and reinstalling applies the new configuration, even over an existing login. Reinstalling an old zip after a logout does not log back in: every download carries a generation stamp and the plugin only applies a configuration it has not applied before. Re-downloading later (e.g. after changing your password or server address) and reinstalling applies the new config, even over an existing login. Each download carries a generation stamp, so replaying an old zip after logging out won't log you back in.
## Install (manual) ## Install (manual)
1. Copy the `bookorbit.koplugin/` folder from the repository to the `koreader/plugins/` directory on your device. 1. Copy `bookorbit.koplugin/` from the repository into `koreader/plugins/` on your device.
2. Restart KOReader. 2. Restart KOReader.
3. In BookOrbit web settings, open Settings, KOReader and create sync credentials (username + password). The KOReader sync permission must be enabled for your account. 3. In BookOrbit web settings: Settings > KOReader, create sync credentials.
4. On the device, open the main menu, Tools tab: "BookOrbit sync" sits directly below "Calibre" on the first page. 4. On the device: Tools > BookOrbit (sits below Calibre) - set the server address, log in, and use "Open dashboard" to browse/download books.
- Set the BookOrbit server address (e.g. `https://bookorbit.example.com`; the `/api/v1` suffix is added automatically).
- Login with the credentials from step 3.
- Use "Open dashboard" to open the BookOrbit dashboard, browse and download books from the same account.
## Setup ## Setup
1. In the "BookOrbit sync" menu, enable "Auto sync this book" if you want the open book synced automatically. Leave the stock "Progress sync" plugin unconfigured to avoid double syncing. 1. Tools > BookOrbit > "Auto sync current book" to sync the open book automatically. Leave the stock "Progress sync" plugin unconfigured to avoid double syncing.
2. Optional: in "Sync settings", tune "Periodically sync every # pages" (default 10, 0 disables mid-session pushes). 2. Optional: Settings > Sync > "Periodically sync every # pages" (default 10, 0 disables mid-session pushes).
3. Optional: in "Sync settings", set "Open dashboard on startup" to "File manager only" or "Every startup" if you want the BookOrbit dashboard to open automatically after KOReader starts. 3. Optional: Settings > Dashboard > "Open dashboard on startup".
4. Optional: assign the "BookOrbit: sync this book" and "BookOrbit: sync all books" actions to gestures. 4. Optional: assign "BookOrbit: sync this book" / "sync all books" to gestures.
Menu placement note: the plugin generates `settings/reader_menu_order.lua` and `settings/filemanager_menu_order.lua` to pin its entry below Calibre, and keeps them up to date across KOReader updates. Delete those files to reset the menu order. If you maintain your own menu order files, only the single `bookorbit_sync` entry is inserted (a one-time rewrite normalizes formatting and strips comments). The plugin manages `settings/reader_menu_order.lua` and `settings/filemanager_menu_order.lua` to keep its menu entry pinned below Calibre across updates; delete those files to reset your menu order.
## Two-way highlight sync From the dashboard menu, the plugin update check is top-level; from the Tools menu, it lives under Settings > Plugin.
Plugin 0.4 exchanges highlights instead of only uploading them (server 0.4+; against an older server the plugin falls back to upload-only automatically):
- Device to web: highlights upload as before. BookOrbit converts their crengine positions (xpointers) to reader positions (CFIs), verifying every conversion against the highlighted text, so they render as native highlights in the web reader where you can edit notes, recolor and delete them.
- Web to device: on book open (with "Auto sync this book" on), after "Sync this book now", and during the full sweep for closed books, the plugin applies web-created highlights, edits and deletions. For the open book they are verified against the rendering engine first; a position that does not match its text is re-anchored by searching the text, and the corrected position is reported back to the server. For closed books they are merged into the sidecar the same way KOHighlights does, and KOReader re-validates them on next open.
- Deletions go both ways: deleting in the web moves nothing until the device confirms; deleting on the device moves the highlight to the BookOrbit trash, where it can be restored (a restore pushes it back to devices).
- Identity is the highlight's creation datetime plus position, so extending a highlight on the device is recognized as a move, never as a delete-and-recreate.
- Styles map across: highlight/underline/strikethrough map 1:1, BookOrbit's squiggly renders as underline on the device (and stays squiggly on the web), KOReader's invert renders inverted on the web. Colors map between KOReader's named palette and hex.
- "Two-way highlight sync" in the plugin settings turns the apply direction off; uploads continue either way.
## How syncing works ## How syncing works
- With "Auto sync this book" on: progress is pulled when a book opens (with conflict prompts), pushed every N page turns (10 seconds after you stop turning pages, 25 second debounce, silently skipped when offline), and the whole book (progress, highlights, status, rating, reading time) is snapshotted from live memory and uploaded when the book is closed and on suspend. - With auto sync on: progress pulls on open (with conflict prompts), pushes every N page turns (debounced, skipped offline), and the whole book state (progress, highlights, status, rating, reading time) uploads from live memory on close and suspend.
- "Sync this book now" (menu item or gesture) first checks BookOrbit for newer progress, applies or prompts according to your progress-sync settings, then does the same per-book snapshot on demand, including a forced statistics flush. If another device has newer progress and you decline it, highlights, status, rating and reading time still sync, but local progress is not pushed over the newer server value. If the book is not in any BookOrbit library it tells you so. - "Sync current book now": checks BookOrbit for newer progress first, then does the same snapshot on demand.
- "Sync all books now" runs the full-library sweep: match new books, upload reading time for every matched book, then highlights, statuses and bulk progress. It never runs automatically. If a book is open when it starts, that book's sidecar and statistics are flushed first, so the sweep sees current data. - "Sync all books now" (manual only): a full-library sweep - matches new books, then uploads reading time, highlights, statuses and progress for everything.
- Everything is incremental: page-stat events are uploaded past a per-book watermark that only advances after the server acknowledges them, sidecars are only parsed when their modification time changed, and unmatched books are only re-checked when BookOrbit's library actually changed. An interrupted sync resumes where it left off; resends are server-side no-ops. - Everything is incremental and resumable: watermarks, modification-time checks and change detection mean an interrupted sync just resumes, and resends are server-side no-ops.
- Local plugin state lives in `settings/bookorbit_sync_state.lua` next to your other KOReader settings. Deleting it is safe: the next sync re-uploads everything and the server deduplicates. - Plugin state lives in `settings/bookorbit_sync_state.lua`. Deleting it is safe: the next sync re-uploads everything and the server deduplicates.
## First sync on a device with lots of history A first sync on a device with years of history uploads in batches of 500 events (roughly 400 requests per 200k events, a few minutes of background work), and is safe to interrupt.
Years of statistics upload in batches of 500 events. Roughly 200k events means about 400 requests, a few minutes of background work. The UI stays usable; progress is kept on every acknowledged batch, so you can suspend or lose wifi mid-way without losing anything. ## Two-way highlight sync
Requires server 0.4+; against an older server the plugin falls back to upload-only automatically.
- Device to web: highlight positions (crengine xpointers) convert to reader CFIs, verified against the highlighted text, so they render as native, editable highlights on the web.
- Web to device: applied on book open (with auto sync on), on manual sync, and during the full sweep for closed books, with positions re-verified/re-anchored against the text.
- Deletions go both ways through a trash/restore flow, so nothing is lost outright.
- Identity is the highlight's creation datetime plus position, so extending a highlight on the device is recognized as a move, not a delete-and-recreate.
- Styles map across formats (e.g. squiggly <-> underline, invert, named colors <-> hex).
- Toggle via Settings > Sync > "Two-way highlight sync" (uploads keep working either way).
## Limitations ## Limitations
- Web-side highlight changes reach a closed book's sidecar only through the manual sweep (and only when that book's sidecar changed since the last sweep) or on the next open of the book. - Web highlight changes reach a closed book only via a manual sweep or its next open.
- Web-created PDF highlights are not supported; device PDF highlights sync up and are listed (and jump to the page) in BookOrbit, but are not drawn over the PDF. - Web-created PDF highlights aren't supported (device PDF highlights sync up but aren't drawn over the PDF).
- Books with reading statistics but no sidecar path in the reading history sync reading time only (no highlights/status/progress); the full sweep covers them. - Books with reading stats but no sidecar path sync reading time only, until the full sweep covers them.
- Position-only bookmarks (no highlighted text) are not synced. - Position-only bookmarks (no highlighted text) don't sync.
- Device clocks far in the past can delay edit detection for highlights that were last modified from the web (the device edit must carry a newer timestamp than the applied one). - A device clock far in the past can delay edit detection for web-modified highlights.
- The full-library sweep is manual-only by design; books you never open again only sync when you press "Sync all books now". - The full sweep is manual-only; books you never reopen only sync when you run it.
- A cloned device with a different KOReader `device_id` re-uploads its history under the new device identity, which double-counts reading time. Clones that keep the same `device_id` deduplicate naturally. - A cloned device with a different KOReader `device_id` double-counts reading time; clones keeping the same `device_id` deduplicate naturally.
## Hand-test checklist
- Menu placement: "BookOrbit sync" appears on the Tools first page directly below Calibre, in both the file manager and the reader; `settings/reader_menu_order.lua` and `settings/filemanager_menu_order.lua` exist with the bookorbit marker line; a restart does not rewrite them.
- Menu layout: the first BookOrbit menu page starts with Open dashboard, Installed plugin/update, Last sync, manual sync actions, Auto sync, Two-way highlight sync, Sync settings and Account & setup.
- Catalog browser: "Open dashboard" appears in the Tools menu after login and opens the dashboard root. The dashboard shows compact Continue reading rows, reading goal/streak/highlight summaries, "On device", libraries, collections, SmartScopes, authors, series and all books. Tapping dashboard books/highlights opens the book detail; tapping shortcuts opens the matching existing catalog screen.
- Catalog dashboard cache: open the dashboard successfully, turn wifi off, then open it again through startup auto-open; the last successful dashboard appears with an offline-cache label. Against an older BookOrbit server without the dashboard endpoint, the plugin falls back to the old catalog root and reports that the server needs an update.
- Startup dashboard: with "Open dashboard on startup" set to Off, restart does not open it. With File manager only, restarting into the file manager opens the dashboard once. With Every startup, restarting into the file manager or reader opens the dashboard once. A fresh preconfigured install still shows the provision confirmation without auto-opening over it.
- Catalog search: the title-bar left button opens the catalog action menu everywhere. Dashboard and section pages offer "Search BookOrbit"; book lists offer "Search in this list". Search is no longer a row in the root list. The title bar has no close button; use "Close BookOrbit" from the menu.
- Catalog view and filters (actions menu): toggle mosaic/list view, change the mosaic grid size, filter by read status (all/unread/reading/finished) and by format, sort, and change order (asc/desc). View mode, sort and grid size persist across sessions; the active sort, direction and filters show in the page subtitle.
- Catalog responsiveness: tapping any section, book page or detail shows a dismissable loading message and does not freeze the reader (network runs in a subprocess); tapping the loading message cancels the request. The next page's covers are prefetched a second after a page settles, so paging forward shows covers immediately.
- Catalog detail edits (detail page actions menu): "Set read status" and "Set rating" write straight to BookOrbit and update the page in place; against an older server without these endpoints the action reports it is unsupported instead of erroring. "Description" opens the complete text, and "Genres"/"Tags" list every value (the detail page itself only shows the first few with a "+N more" hint).
- Catalog series view: opening a series shows its books in series order with a "Read N/total" header.
- Catalog authors/series lists: "Filter..." in the catalog action menu narrows the list as you type, and footer arrows move through large libraries.
- Catalog manual refresh: the actions menu "Refresh" re-fetches the current scope and retries any covers that failed.
- Catalog indicators: each card/row shows reading progress, read status and an "On device" badge when a matching file already exists locally. "On device" lists those matched books via a single `?ids=` lookup.
- Catalog search history: the search dialog offers a "Recent" button to re-run one of the last searches (stored locally).
- Catalog feedback: section/page/detail fetches show a loading message; failures show an inline "Retry" dialog; downloads show throttled byte/percent progress.
- Catalog download: open a book detail, tap "Download" for the default folder/name, confirm overwrite if needed, watch the download progress, then accept "Open now". Use "Download options" in the detail action menu to change folder/name. The downloaded file is matched by partial MD5 and linked in `bookorbit_sync_state.lua`; the "On device" badge refreshes after a successful link.
- Catalog long-press actions: hold a dashboard cover, mosaic card or list row. The detail-backed action sheet offers details, read/open when on device, download, download options, read status, rating, description, genres and tags. On normal book lists it also offers Select.
- Catalog selected download: in a normal book list, hold a book and choose "Select", or choose "Select books" from the action menu. Select books across multiple pages, verify the top-right selected badges, selected count and disabled download button while nothing is selected. With books selected, the title-bar search icon becomes a download button and opens the same confirmation as "Download selected". The refresh icon clears selected books; when nothing is selected, it exits selection mode. Returning to the dashboard exits selection mode and restores search/refresh. "Select page", "Clear" and "Exit selection" work from the selection action menu.
- Catalog list bulk download: from a filtered list or series, run "Download this page" and "Download all in this list". The confirmation shows the target folder, format preset, existing-file policy and total count. Broad "All Books" downloads require the same confirmation.
- Catalog bulk settings: in the catalog action menu, change "Bulk download settings" between EPUB/PDF/Comics format presets and Skip/Overwrite existing-file policies. Verify the next queue uses the selected policy.
- Catalog bulk failure handling: simulate a network/server failure or device write failure during a queue. The queue continues where possible, deletes failed partial downloads through the download client, shows failed titles in the completion summary, and "Retry failed" retries only the failed books.
- Catalog bulk cancellation: start a multi-book queue, tap "Cancel after current file", and verify the active transfer finishes or fails before the queue stops with a stopped summary.
- Catalog read vs download: when a book is already on the device, the detail page shows a primary "Read" button that opens the local file. "Download" is shown next to it only when there are formats not yet on the device, starts with the default folder/name, and is hidden once every supported format is downloaded. The detail action menu offers "Download options" for folder/name changes. The file chooser marks already-downloaded formats as "Open" instead of "Download". On-device state is refreshed when the detail opens and after a successful download.
- Custom menu order: remove the marker line and shuffle the tools list; on restart only the `bookorbit_sync` id is re-inserted, the rest is preserved. A syntactically broken file is left untouched (warning in the log).
- Login: wrong password shows a login error; server URL with and without trailing `/` and with `/api/v1` already appended all work.
- Preconfigured install: download the zip from web settings, install, restart; the "set up for ... as ..." message appears once, the server URL and login are filled in, `bookorbit_provision.lua` is gone from the plugin folder, and a second restart shows no message.
- Provision after logout: logout in the plugin menu, reinstall the SAME zip, restart; still logged out. Download a FRESH zip, reinstall, restart; logged in again.
- Provision over existing login: with a working login, install a fresh zip with different credentials and restart; the new credentials win and the message appears.
- Open a book that another device read further: pull prompt appears (with "Prompt" strategy); accepting jumps to the newer location.
- Periodic push: with "Auto sync this book" on and the period set to 3, turn 3 pages and stop; about 10 seconds later one progress request hits the server. Turning 3 more pages within 25 seconds gets debounced. Toggling auto sync off cancels a pending push.
- Mid-session highlight, then "Sync this book now": the highlight appears in BookOrbit immediately, no reopen needed.
- Close a book after highlighting and changing status/rating: reading time, highlights, status, rating and progress all land in BookOrbit; reopening and closing again uploads nothing new.
- "Sync this book now" on a book that is not in BookOrbit: shows "not in your BookOrbit library"; closing such a book stays silent.
- Manual "Sync all books now": summary message shows matched books / events / highlights; settings page in BookOrbit shows the sweep. Running it with a freshly annotated book still open includes that book's data.
- Re-running the manual sync immediately: all uploads are no-ops, counts stay stable.
- Add a previously unmatched device book to BookOrbit, rescan the library, run a manual sync: its history backfills.
- Suspend with a book open: the snapshot uploads before wifi drops (where the platform allows); suspend mid-sweep, resume, sync again: no duplicates, sweep completes.
- Airplane mode: manual syncs report the server as unreachable; automatic ones stay silent.
- Gestures: "BookOrbit: sync this book" is assignable from the file manager gesture settings and works inside the reader.
- With the statistics plugin actively recording (book open), a sweep still reads the stats database without errors.
Two-way highlight checks:
- Web highlight on a device-matched book, then open the book on the device: the highlight appears (notification shows the count), with the right color/style and note.
- Edit the note and color of that highlight in the web reader, reopen the book (or "Sync this book now"): the device copy updates.
- Delete a highlight in the web reader; on next device sync it disappears from the book and the bookmark list.
- Delete a highlight on the device; after the book closes (or a sweep), it appears under Annotations, Trash in the web. Restoring it brings it back to the device on the next sync.
- Extend an existing highlight on the device: the web copy moves, no duplicate and nothing lands in trash.
- Create a squiggly highlight on the web: it arrives as underline on the device, and stays squiggly in the web after the device re-syncs it.
- Create an invert highlight on the device: it renders inverted in the web reader.
- Kill KOReader mid-sync and sync again: no duplicates, no lost highlights (acks make re-delivery idempotent).
- Closed-book sweep with pending web highlights: the sidecar gains them with `annotations_externally_modified` set; opening the book shows them sorted and valid.
@@ -76,14 +76,58 @@ local function normalizeText(text)
return util.trim(text:gsub("%s+", " ")) return util.trim(text:gsub("%s+", " "))
end end
local function findByDatetime(annotations, datetime) local ackApplied
for index, a in ipairs(annotations or {}) do local applyEditFields
if a.datetime == datetime then
return index, a local function normalizePos(pos)
if type(pos) ~= "string" then return "" end
return util.trim(pos)
end
local function sameRangeAndText(annotation, entry)
local text = normalizeText(entry.text)
if text == "" or normalizePos(entry.pos0) == "" then return false end
if normalizeText(annotation.text) ~= text then return false end
if normalizePos(annotation.pos0) ~= normalizePos(entry.pos0) then return false end
local entry_pos1 = normalizePos(entry.pos1)
if entry_pos1 ~= "" and normalizePos(annotation.pos1) ~= entry_pos1 then return false end
return true
end
local function findByIdentity(annotations, entry)
local found_index, found_annotation, found_count = nil, nil, 0
if type(entry.datetime) == "string" and entry.datetime ~= "" then
for index, annotation in ipairs(annotations or {}) do
if annotation.datetime == entry.datetime then
found_index, found_annotation = index, annotation
found_count = found_count + 1
end
end
if found_count == 1 then return found_index, found_annotation end
end
for index, annotation in ipairs(annotations or {}) do
if sameRangeAndText(annotation, entry) then
return index, annotation
end end
end end
end end
local function applyIdentityFields(annotation, entry)
if type(entry.datetime) == "string" and entry.datetime ~= "" then
annotation.datetime = entry.datetime
end
applyEditFields(annotation, entry)
end
local function ackExisting(entry, annotation, verified)
return ackApplied(entry, {
verified = verified == true,
pos0 = annotation.pos0 or entry.pos0,
pos1 = annotation.pos1 or entry.pos1,
pageno = type(annotation.pageno) == "number" and annotation.pageno or entry.pageno,
})
end
-- crengine verification: both endpoints must resolve and, when the server -- crengine verification: both endpoints must resolve and, when the server
-- knows the highlighted text, the extracted range must match it. -- knows the highlighted text, the extracted range must match it.
local function verifyRange(document, pos0, pos1, text) local function verifyRange(document, pos0, pos1, text)
@@ -133,7 +177,7 @@ local function repairRange(document, entry)
return nil return nil
end end
local function ackApplied(entry, opts) ackApplied = function(entry, opts)
return { return {
serverId = entry.serverId, serverId = entry.serverId,
version = entry.version, version = entry.version,
@@ -147,7 +191,7 @@ local function ackApplied(entry, opts)
} }
end end
local function applyEditFields(annotation, entry) applyEditFields = function(annotation, entry)
annotation.drawer = entry.drawer or annotation.drawer annotation.drawer = entry.drawer or annotation.drawer
annotation.color = entry.color or annotation.color annotation.color = entry.color or annotation.color
if entry.text and entry.text ~= "" then annotation.text = entry.text end if entry.text and entry.text ~= "" then annotation.text = entry.text end
@@ -163,12 +207,15 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
local document = ui.document local document = ui.document
local annotations = ui.annotation.annotations local annotations = ui.annotation.annotations
local touched = 0 local touched = 0
local deleted_touched = 0
for _, entry in ipairs(to_apply.add or {}) do for _, entry in ipairs(to_apply.add or {}) do
local existing_index = findByDatetime(annotations, entry.datetime) local existing_index, existing_annotation = findByIdentity(annotations, entry)
if existing_index then if existing_index then
-- Idempotent retry of an earlier apply that lost its ack. applyIdentityFields(existing_annotation, entry)
table.insert(applied, ackApplied(entry, { verified = true, pos0 = entry.pos0, pos1 = entry.pos1 })) ui:handleEvent(Event:new("AnnotationsModified", { existing_annotation, index_modified = existing_index }))
touched = touched + 1
table.insert(applied, ackExisting(entry, existing_annotation, true))
elseif entry.posFormat ~= "xpointer" or not ui.rolling then elseif entry.posFormat ~= "xpointer" or not ui.rolling then
-- PDF adds from the web are out of scope; never expected here. -- PDF adds from the web are out of scope; never expected here.
table.insert(applied, ackApplied(entry, { failed = true })) table.insert(applied, ackApplied(entry, { failed = true }))
@@ -200,8 +247,15 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
pos0 = pos0, pos0 = pos0,
pos1 = pos1, pos1 = pos1,
} }
ui.annotation:addItem(item) local index = ui.annotation:addItem(item)
ui:handleEvent(Event:new("AnnotationsModified", { item, nb_highlights_added = 1 })) if ui.view and ui.view.footer and ui.view.footer.maybeUpdateFooter then
pcall(ui.view.footer.maybeUpdateFooter, ui.view.footer)
end
ui:handleEvent(Event:new("AnnotationsModified", {
item,
nb_highlights_added = 1,
index_modified = index,
}))
touched = touched + 1 touched = touched + 1
table.insert(applied, ackApplied(entry, { table.insert(applied, ackApplied(entry, {
verified = true, verified = true,
@@ -215,7 +269,7 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
end end
for _, entry in ipairs(to_apply.edit or {}) do for _, entry in ipairs(to_apply.edit or {}) do
local index, annotation = findByDatetime(annotations, entry.datetime) local index, annotation = findByIdentity(annotations, entry)
if not index then if not index then
logger.dbg("BookOrbit: annotation edit target missing:", entry.serverId) logger.dbg("BookOrbit: annotation edit target missing:", entry.serverId)
table.insert(applied, ackApplied(entry, { failed = true })) table.insert(applied, ackApplied(entry, { failed = true }))
@@ -228,13 +282,14 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
end end
for _, entry in ipairs(to_apply.delete or {}) do for _, entry in ipairs(to_apply.delete or {}) do
local index, annotation = findByDatetime(annotations, entry.datetime) local index, annotation = findByIdentity(annotations, entry)
if index then if index then
if ui.paging then if ui.paging then
pcall(ui.highlight.writePdfAnnotation, ui.highlight, "delete", annotation) pcall(ui.highlight.writePdfAnnotation, ui.highlight, "delete", annotation)
end end
ui.bookmark:removeItemByIndex(index) ui.bookmark:removeItemByIndex(index)
touched = touched + 1 touched = touched + 1
deleted_touched = deleted_touched + 1
end end
-- Missing entries were already deleted locally; ack either way. -- Missing entries were already deleted locally; ack either way.
table.insert(deleted, { serverId = entry.serverId, status = "applied" }) table.insert(deleted, { serverId = entry.serverId, status = "applied" })
@@ -243,7 +298,7 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
if touched > 0 then if touched > 0 then
UIManager:setDirty("all", "ui") UIManager:setDirty("all", "ui")
end end
return applied, deleted, touched return applied, deleted, touched, deleted_touched
end end
-- Merges server changes into the sidecar of a CLOSED book and raises -- Merges server changes into the sidecar of a CLOSED book and raises
@@ -253,13 +308,17 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
local doc_settings = DocSettings:open(file) local doc_settings = DocSettings:open(file)
local annotations = doc_settings:readSetting("annotations") or {} local annotations = doc_settings:readSetting("annotations") or {}
local touched = 0 local touched = 0
local deleted_touched = 0
for _, entry in ipairs(to_apply.add or {}) do for _, entry in ipairs(to_apply.add or {}) do
if entry.posFormat ~= "xpointer" then if entry.posFormat ~= "xpointer" then
table.insert(applied, ackApplied(entry, { failed = true })) table.insert(applied, ackApplied(entry, { failed = true }))
else else
local existing = findByDatetime(annotations, entry.datetime) local existing_index, existing = findByIdentity(annotations, entry)
if not existing then if existing_index then
applyIdentityFields(existing, entry)
touched = touched + 1
else
table.insert(annotations, { table.insert(annotations, {
datetime = entry.datetime, datetime = entry.datetime,
datetime_updated = entry.datetimeUpdated, datetime_updated = entry.datetimeUpdated,
@@ -276,12 +335,16 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
touched = touched + 1 touched = touched + 1
end end
-- Unverified until the book is opened; the server keeps it pending. -- Unverified until the book is opened; the server keeps it pending.
table.insert(applied, ackApplied(entry, { pos0 = entry.pos0, pos1 = entry.pos1, pageno = entry.pageno })) table.insert(applied, ackExisting(entry, existing or {
pos0 = entry.pos0,
pos1 = entry.pos1,
pageno = entry.pageno,
}, false))
end end
end end
for _, entry in ipairs(to_apply.edit or {}) do for _, entry in ipairs(to_apply.edit or {}) do
local index, annotation = findByDatetime(annotations, entry.datetime) local index, annotation = findByIdentity(annotations, entry)
if not index then if not index then
table.insert(applied, ackApplied(entry, { failed = true })) table.insert(applied, ackApplied(entry, { failed = true }))
else else
@@ -292,10 +355,11 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
end end
for _, entry in ipairs(to_apply.delete or {}) do for _, entry in ipairs(to_apply.delete or {}) do
local index = findByDatetime(annotations, entry.datetime) local index = findByIdentity(annotations, entry)
if index then if index then
table.remove(annotations, index) table.remove(annotations, index)
touched = touched + 1 touched = touched + 1
deleted_touched = deleted_touched + 1
end end
table.insert(deleted, { serverId = entry.serverId, status = "applied" }) table.insert(deleted, { serverId = entry.serverId, status = "applied" })
end end
@@ -305,7 +369,7 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
doc_settings:makeTrue("annotations_externally_modified") doc_settings:makeTrue("annotations_externally_modified")
doc_settings:flush() doc_settings:flush()
end end
return applied, deleted, touched return applied, deleted, touched, deleted_touched
end end
local function hasPending(to_apply) local function hasPending(to_apply)
@@ -396,16 +460,18 @@ function BookOrbitAnnotations.exchangeBook(opts)
local rounds = 0 local rounds = 0
while response and hasPending(response.toApply) and rounds < MAX_PULL_ROUNDS do while response and hasPending(response.toApply) and rounds < MAX_PULL_ROUNDS do
rounds = rounds + 1 rounds = rounds + 1
local applied, deleted, touched local applied, deleted, touched, deleted_touched
if opts.apply_mode == "live" and opts.ui and opts.ui.document then if opts.apply_mode == "live" and opts.ui and opts.ui.document then
applied, deleted, touched = BookOrbitAnnotations.applyLive(opts.ui, response.toApply) applied, deleted, touched, deleted_touched = BookOrbitAnnotations.applyLive(opts.ui, response.toApply)
elseif opts.apply_mode == "sidecar" and opts.file then elseif opts.apply_mode == "sidecar" and opts.file then
applied, deleted, touched = BookOrbitAnnotations.applySidecar(opts.file, response.toApply) applied, deleted, touched, deleted_touched = BookOrbitAnnotations.applySidecar(opts.file, response.toApply)
else else
break break
end end
result.applied = result.applied + touched deleted_touched = deleted_touched or 0
result.applied = result.applied + math.max(0, touched - deleted_touched)
result.deleted = result.deleted + deleted_touched
for _, entry in ipairs(applied) do for _, entry in ipairs(applied) do
if entry.status == "failed" then result.failed = result.failed + 1 end if entry.status == "failed" then result.failed = result.failed + 1 end
end end
@@ -31,8 +31,22 @@ local function scrubNulls(value)
return value return value
end end
local function decodeResponse(parts)
local raw = table.concat(parts or {})
if raw == "" then
return {}
end
local ok, decoded, decode_err = pcall(rapidjson.decode, raw)
if not ok or decoded == nil then
logger.dbg("BookOrbit: invalid JSON response:", ok and decode_err or decoded)
return nil, "invalid_json"
end
return scrubNulls(decoded) or {}
end
local BookOrbitApi = {} local BookOrbitApi = {}
BookOrbitApi.__index = BookOrbitApi BookOrbitApi.__index = BookOrbitApi
BookOrbitApi.decodeResponse = decodeResponse
-- Normalizes a user-entered server address to the API base, e.g. -- Normalizes a user-entered server address to the API base, e.g.
-- "https://books.example.com/" -> "https://books.example.com/api/v1". -- "https://books.example.com/" -> "https://books.example.com/api/v1".
@@ -100,15 +114,16 @@ function BookOrbitApi:request(method, path, body)
return nil, tostring(status or code or "network_error") return nil, tostring(status or code or "network_error")
end end
local decoded = nil local decoded, decode_err = decodeResponse(sink)
if sink[1] then
decoded = scrubNulls(rapidjson.decode(table.concat(sink)))
end
if code < 200 or code >= 300 then if code < 200 or code >= 300 then
return nil, code, decoded return nil, code, decoded
end end
if decode_err then
return nil, decode_err
end
return decoded or {} return decoded or {}
end end
@@ -19,6 +19,7 @@ local _ = require("gettext")
local BookOrbitAnnotations = require("bookorbit_annotations") local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitApi = require("bookorbit_api") local BookOrbitApi = require("bookorbit_api")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitSidecar = require("bookorbit_sidecar") local BookOrbitSidecar = require("bookorbit_sidecar")
local BookOrbitState = require("bookorbit_state") local BookOrbitState = require("bookorbit_state")
local BookOrbitStatsReader = require("bookorbit_stats_reader") local BookOrbitStatsReader = require("bookorbit_stats_reader")
@@ -133,6 +134,28 @@ local function finish(ctx, err)
ctx.state:flush() ctx.state:flush()
if ctx.plugin and ctx.plugin.recordSyncError then
if err == "auth" or err == "network" then
ctx.plugin:recordSyncError("book_sync", err)
elseif not err and ctx.had_errors then
ctx.plugin:recordSyncError("book_sync", "partial_failure")
elseif not err and ctx.plugin.recordSyncSuccess then
ctx.plugin:recordSyncSuccess(
"book_sync",
T(_("%1 reading events, %2 highlights uploaded"),
ctx.counts.page_stats, ctx.counts.annotations))
end
end
if ctx.plugin and ctx.plugin.recordHighlightSync and ctx.highlight_summary
and BookOrbitHighlightSummary.hasCounts(ctx.highlight_summary) then
local highlight_err = ctx.highlight_unsupported and "unsupported_server" or nil
if ctx.had_errors and not highlight_err then
highlight_err = "partial_failure"
end
ctx.plugin:recordHighlightSync(ctx.highlight_summary, highlight_err)
end
if err == "auth" then if err == "auth" then
if ctx.interactive then if ctx.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: login failed. Check your credentials."), timeout = 4 }) UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: login failed. Check your credentials."), timeout = 4 })
@@ -151,8 +174,19 @@ local function finish(ctx, err)
logger.dbg("BookOrbit: book sync skipped, book unmatched:", ctx.snap.digest) logger.dbg("BookOrbit: book sync skipped, book unmatched:", ctx.snap.digest)
else else
if ctx.interactive then if ctx.interactive then
local text = T(_("BookOrbit: book synced. %1 reading events, %2 highlights up, %3 applied."), local text = T(_("BookOrbit: book synced. %1 reading events."), ctx.counts.page_stats)
ctx.counts.page_stats, ctx.counts.annotations, ctx.counts.ann_applied or 0) if ctx.highlight_summary and BookOrbitHighlightSummary.hasCounts(ctx.highlight_summary) then
text = text .. "\n" .. BookOrbitHighlightSummary.message(ctx.highlight_summary)
else
text = text .. "\n" .. T(_("Highlights synced: %1 uploaded, %2 applied, %3 deleted."),
ctx.counts.annotations, ctx.counts.ann_applied or 0, ctx.counts.ann_deleted or 0)
end
if ctx.highlight_unsupported then
text = text .. "\n" .. _("BookOrbit server needs an update for two-way highlights.")
end
if ctx.highlight_disabled_reported then
text = text .. "\n" .. _("Two-way highlight sync is disabled.")
end
if ctx.skip_progress then if ctx.skip_progress then
text = text .. "\n" .. _("Progress was not changed.") text = text .. "\n" .. _("Progress was not changed.")
end end
@@ -162,8 +196,9 @@ local function finish(ctx, err)
UIManager:show(InfoMessage:new{ text = text, timeout = 4 }) UIManager:show(InfoMessage:new{ text = text, timeout = 4 })
end end
logger.info(string.format( logger.info(string.format(
"BookOrbit: book sync done reason=%s pageStats=%d annotations=%d applied=%d errors=%s", "BookOrbit: book sync done reason=%s pageStats=%d annotations=%d applied=%d deleted=%d errors=%s",
ctx.reason, ctx.counts.page_stats, ctx.counts.annotations, ctx.counts.ann_applied or 0, tostring(ctx.had_errors))) ctx.reason, ctx.counts.page_stats, ctx.counts.annotations,
ctx.counts.ann_applied or 0, ctx.counts.ann_deleted or 0, tostring(ctx.had_errors)))
end end
if ctx.on_finish then if ctx.on_finish then
@@ -280,6 +315,9 @@ stepAnnotations = function(ctx)
elseif ctx.reason == "manual" and ctx.plugin and ctx.plugin.ui and ctx.plugin.ui.document then elseif ctx.reason == "manual" and ctx.plugin and ctx.plugin.ui and ctx.plugin.ui.document then
apply_mode = "live" apply_mode = "live"
end end
elseif ctx.interactive then
ctx.highlight_disabled_reported = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
end end
local result, err = BookOrbitAnnotations.exchangeBook({ local result, err = BookOrbitAnnotations.exchangeBook({
@@ -300,15 +338,22 @@ stepAnnotations = function(ctx)
end end
if err == "unmatched" then return finish(ctx, "unmatched") end if err == "unmatched" then return finish(ctx, "unmatched") end
if err == "unsupported_server" then if err == "unsupported_server" then
ctx.highlight_unsupported = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
return step(ctx, stepAnnotationsLegacy) return step(ctx, stepAnnotationsLegacy)
end end
ctx.had_errors = true ctx.had_errors = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
return step(ctx, stepState) return step(ctx, stepState)
end end
if result.had_errors then ctx.had_errors = true end if result.had_errors then ctx.had_errors = true end
ctx.counts.annotations = ctx.counts.annotations + result.uploaded ctx.counts.annotations = ctx.counts.annotations + result.uploaded
ctx.counts.ann_applied = (ctx.counts.ann_applied or 0) + result.applied ctx.counts.ann_applied = (ctx.counts.ann_applied or 0) + result.applied
ctx.counts.ann_deleted = (ctx.counts.ann_deleted or 0) + result.deleted
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, result, {
closed_book = apply_mode == "sidecar",
})
if not result.had_errors then if not result.had_errors then
book.annCount = ctx.snap.ann_count book.annCount = ctx.snap.ann_count
end end
@@ -351,6 +396,7 @@ stepAnnotationsLegacy = function(ctx)
ctx.had_errors = true ctx.had_errors = true
ctx.ann_failed = true ctx.ann_failed = true
ctx.ann_delta = {} ctx.ann_delta = {}
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
if isTransportError(err) then return finish(ctx, "network") end if isTransportError(err) then return finish(ctx, "network") end
logger.dbg("BookOrbit: book sync annotations upload failed:", err) logger.dbg("BookOrbit: book sync annotations upload failed:", err)
return step(ctx, stepState) return step(ctx, stepState)
@@ -361,6 +407,7 @@ stepAnnotationsLegacy = function(ctx)
end end
ctx.counts.annotations = ctx.counts.annotations + #chunk ctx.counts.annotations = ctx.counts.annotations + #chunk
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { uploaded = #chunk })
return step(ctx, stepAnnotationsLegacy) return step(ctx, stepAnnotationsLegacy)
end end
@@ -451,18 +498,18 @@ function BookOrbitBookSync.run(opts)
if opts.interactive then if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 }) UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
end end
return return false
end end
local snap = opts.snap local snap = opts.snap
if not snap then return end if not snap then return false end
local client = BookOrbitApi.new(opts.api) local client = BookOrbitApi.new(opts.api)
if not client:isConfigured() then if not client:isConfigured() then
if opts.interactive then if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("Please configure the BookOrbit server and login first."), timeout = 3 }) UIManager:show(InfoMessage:new{ text = _("Please configure the BookOrbit server and login first."), timeout = 3 })
end end
return return false
end end
BookOrbitBookSync.running = true BookOrbitBookSync.running = true
@@ -477,7 +524,11 @@ function BookOrbitBookSync.run(opts)
annotation_sync = opts.annotation_sync ~= false, annotation_sync = opts.annotation_sync ~= false,
plugin = opts.plugin, plugin = opts.plugin,
on_finish = opts.on_finish, on_finish = opts.on_finish,
counts = { page_stats = 0, annotations = 0, ann_applied = 0 }, counts = { page_stats = 0, annotations = 0, ann_applied = 0, ann_deleted = 0 },
highlight_summary = BookOrbitHighlightSummary.normalize{
event = "book_sync",
reason = opts.reason or "manual",
},
had_errors = false, had_errors = false,
skip_progress = opts.skip_progress == true, skip_progress = opts.skip_progress == true,
} }
@@ -495,6 +546,7 @@ function BookOrbitBookSync.run(opts)
else else
step(ctx, stepMatch) step(ctx, stepMatch)
end end
return true
end end
return BookOrbitBookSync return BookOrbitBookSync
@@ -0,0 +1,67 @@
local T = require("ffi/util").template
local _ = require("gettext")
local HighlightDiagnostics = {}
local function count(value)
value = tonumber(value)
if not value or value < 0 then return 0 end
return value
end
local function hasOpenBook(ctx)
return ctx and ctx.has_open_book == true
end
function HighlightDiagnostics.lastSyncText(summary, format_time)
if type(summary) ~= "table" then return _("never") end
local text = T(_("%1 uploaded, %2 applied, %3 deleted, %4 failed, %5 skipped"),
count(summary.uploaded), count(summary.applied), count(summary.deleted),
count(summary.failed), count(summary.skipped))
local at = tonumber(summary.at)
if at and at > 0 then
local when = format_time and format_time(at) or os.date("%H:%M", at)
text = T(_("%1 at %2"), text, when)
end
return text
end
function HighlightDiagnostics.retryText(status)
if type(status) == "table" and status.pending then
return _("pending")
end
return _("none")
end
function HighlightDiagnostics.openHighlightsText(ctx)
ctx = ctx or {}
if not ctx.annotation_sync then
return _("two-way sync off")
end
if not hasOpenBook(ctx) then
return _("no reader book")
end
local pending = ctx.scheduler_status
local matched = ctx.matched == true
if type(pending) == "table" then
if matched then
return _("matched, sync scheduled")
end
if pending.phase == "matching" then
return _("unmatched, retrying match")
end
return _("unmatched, sync scheduled")
end
if matched then
return _("matched, idle")
end
local last = ctx.last_highlight_sync
if type(last) == "table" and count(last.skipped) > 0 then
return _("unmatched, skipped")
end
return _("unmatched, idle")
end
return HighlightDiagnostics
@@ -0,0 +1,106 @@
--[[--
Highlight sync summary helpers for BookOrbit.
Keeps the persisted diagnostic shape, count aggregation and concise user
messages consistent across open-book exchange, per-book sync and sweep.
]]
local T = require("ffi/util").template
local _ = require("gettext")
local HighlightSummary = {}
local COUNT_FIELDS = {
"uploaded",
"applied",
"deleted",
"failed",
"skipped",
"touched_books",
}
local ACTIONABLE_ERRORS = {
auth = true,
network = true,
unsupported_server = true,
partial_failure = true,
}
local function numberValue(value)
value = tonumber(value)
if not value or value < 0 then return 0 end
return value
end
function HighlightSummary.normalize(summary)
summary = summary or {}
local normalized = {
event = summary.event,
reason = summary.reason,
at = summary.at or os.time(),
message = summary.message,
}
for _, field in ipairs(COUNT_FIELDS) do
normalized[field] = numberValue(summary[field])
end
return normalized
end
function HighlightSummary.add(summary, result, opts)
summary = HighlightSummary.normalize(summary)
result = result or {}
opts = opts or {}
for _, field in ipairs(COUNT_FIELDS) do
summary[field] = summary[field] + numberValue(result[field])
end
if opts.skipped then
summary.skipped = summary.skipped + numberValue(opts.skipped)
end
if opts.closed_book and numberValue(result.applied) + numberValue(result.deleted) > 0 then
summary.touched_books = summary.touched_books + 1
end
if result.had_errors and numberValue(result.failed) == 0 then
summary.failed = summary.failed + 1
end
return summary
end
function HighlightSummary.hasCounts(summary)
summary = HighlightSummary.normalize(summary)
for _, field in ipairs(COUNT_FIELDS) do
if summary[field] > 0 then return true end
end
return false
end
function HighlightSummary.hasRemoteChanges(summary)
summary = HighlightSummary.normalize(summary)
return summary.applied + summary.deleted > 0
end
function HighlightSummary.actionableError(summary, err)
if ACTIONABLE_ERRORS[err] then return err end
summary = HighlightSummary.normalize(summary)
if summary.failed > 0 then return "partial_failure" end
end
function HighlightSummary.message(summary)
summary = HighlightSummary.normalize(summary)
local text = T(_("Highlights synced: %1 uploaded, %2 applied, %3 deleted."),
summary.uploaded, summary.applied, summary.deleted)
if summary.failed > 0 or summary.skipped > 0 then
text = text .. "\n" .. T(_("Failed: %1. Skipped: %2."), summary.failed, summary.skipped)
end
if summary.touched_books > 0 then
text = text .. "\n" .. T(_("%1 closed book(s) updated."), summary.touched_books)
end
return text
end
function HighlightSummary.diagnosticsText(summary)
if type(summary) ~= "table" then return _("none") end
return HighlightSummary.message(summary)
end
return HighlightSummary
@@ -1,9 +1,9 @@
--[[-- --[[--
Main menu mixin for the BookOrbit plugin. Main menu mixin for the BookOrbit plugin.
Builds the BookOrbit entry in KOReader's Tools menu (and the dashboard Builds the BookOrbit entry in KOReader's Tools menu and the dashboard menu that
hamburger menu that mirrors it), plus the account dialogs: server address, mirrors it, plus the account dialogs: server address, login/logout. Installed
login/logout. Installed onto the plugin controller as regular methods. onto the plugin controller as regular methods.
]] ]]
local Device = require("device") local Device = require("device")
@@ -19,7 +19,7 @@ local T = require("ffi/util").template
local _ = require("gettext") local _ = require("gettext")
local BookOrbitApi = require("bookorbit_api") local BookOrbitApi = require("bookorbit_api")
local BookOrbitBookSync = require("bookorbit_book_sync") local BookOrbitHighlightDiagnostics = require("bookorbit_highlight_diagnostics")
local BookOrbitSweep = require("bookorbit_sweep") local BookOrbitSweep = require("bookorbit_sweep")
-- Assigned from the plugin class on install so menu labels can name the -- Assigned from the plugin class on install so menu labels can name the
@@ -81,186 +81,407 @@ function MainMenu:catalogAutoOpenMenu()
} }
end end
local function formatBool(value)
return value and _("On") or _("Off")
end
local function formatTime(ts)
ts = tonumber(ts)
if not ts or ts <= 0 then return _("never") end
return os.date("%Y-%m-%d %H:%M", ts)
end
local function formatShortTime(ts)
ts = tonumber(ts)
if not ts or ts <= 0 then return _("never") end
return os.date("%H:%M", ts)
end
local function deviceIdPrefix(device_id)
if type(device_id) ~= "string" or device_id == "" then return _("unknown") end
return device_id:sub(1, 8)
end
local function versionText(version)
if type(version) ~= "string" or version == "" then return _("unknown") end
return "v" .. version:gsub("^v", "")
end
local function recordText(entry)
if type(entry) ~= "table" then return _("none") end
local when = formatTime(entry.at)
local message = entry.message or entry.event or _("unknown")
return T(_("%1 at %2"), tostring(message), when)
end
local function highlightRecordText(entry)
return BookOrbitHighlightDiagnostics.lastSyncText(entry, formatShortTime)
end
local function jobText(job)
if type(job) ~= "table" then return _("none") end
return tostring(job.label or job.family or _("unknown"))
end
local function jobStartedText(job)
if type(job) ~= "table" then return _("none") end
return formatTime(job.started_at)
end
local function safeDocumentDigest(plugin, has_open_book)
if not has_open_book or not plugin.getDocumentDigest then return nil end
local ok, digest = pcall(plugin.getDocumentDigest, plugin)
if ok then return digest end
end
local function hasReaderBook(plugin)
return plugin.ui and plugin.ui.document ~= nil
end
local function hasCredentials(settings)
return settings.server_url ~= nil and settings.username ~= nil and settings.userkey ~= nil
end
function MainMenu:diagnosticsRows()
local status = BookOrbitSweep.syncStatus()
local sync_status = self.getSyncCoordinatorStatus and self:getSyncCoordinatorStatus() or { pending_count = 0 }
local has_open_book = hasReaderBook(self)
local digest = safeDocumentDigest(self, has_open_book)
local scheduler = self.open_annotation_scheduler and self.open_annotation_scheduler:status() or nil
local logged_in = self:isLoggedIn()
local open_highlight_text = BookOrbitHighlightDiagnostics.openHighlightsText{
annotation_sync = self.settings.annotation_sync,
has_open_book = has_open_book,
matched = self.isOpenBookMatched and self:isOpenBookMatched(digest) or false,
scheduler_status = scheduler,
last_highlight_sync = self.settings.last_highlight_sync,
}
local rows = {
{ _("Server"), self.settings.server_url or _("not set") },
{ _("Username"), self.settings.username or _("not set") },
{ _("Login"), self:isLoggedIn() and _("configured") or _("not configured") },
{ _("Device"), Device.model or _("unknown") },
{ _("Device ID"), deviceIdPrefix(self.device_id) },
{ _("Plugin"), versionText(self.PLUGIN_VERSION) },
{ _("Latest plugin"), versionText(self.settings.update_latest_version) },
}
if not logged_in then
if self.settings.last_error then
table.insert(rows, "----------------------------")
table.insert(rows, { _("Last error"), recordText(self.settings.last_error) })
end
table.insert(rows, "----------------------------")
if hasCredentials(self.settings) then
table.insert(rows, {
_("Test connection"),
_("Run"),
callback = function()
self:testConnection()
end,
})
end
return rows
end
table.insert(rows, "----------------------------")
table.insert(rows, { _("Auto sync"), formatBool(self.settings.auto_sync) })
table.insert(rows, { _("Two-way highlights"), formatBool(self.settings.annotation_sync) })
table.insert(rows, { _("Open highlights"), open_highlight_text })
table.insert(rows, { _("Highlight apply retry"), BookOrbitHighlightDiagnostics.retryText(self.open_highlight_retry_status) })
table.insert(rows, { _("Skip offline auto-sync"), formatBool(self.settings.skip_sync_when_offline) })
table.insert(rows, { _("Current sync"), jobText(sync_status.current) })
table.insert(rows, { _("Current sync started"), jobStartedText(sync_status.current) })
table.insert(rows, { _("Pending syncs"), tostring(sync_status.pending_count or 0) })
table.insert(rows, { _("Next sync"), jobText(sync_status.next) })
table.insert(rows, { _("Last sweep"), formatTime(status.lastSweepAt) })
table.insert(rows, { _("Matched local books"), tostring(status.matched or 0) })
table.insert(rows, { _("Unmatched local books"), tostring(status.unmatched or 0) })
table.insert(rows, "----------------------------")
table.insert(rows, { _("Last sync"), recordText(self.settings.last_sync) })
table.insert(rows, { _("Last highlight sync"), highlightRecordText(self.settings.last_highlight_sync) })
table.insert(rows, { _("Last error"), recordText(self.settings.last_error) })
table.insert(rows, "----------------------------")
if has_open_book then
if self.settings.annotation_sync then
table.insert(rows, {
_("Retry highlight sync"),
_("Run"),
callback = function()
self:retryOpenHighlightSync()
end,
})
end
table.insert(rows, {
_("Retry open-book match"),
_("Run"),
callback = function()
self:retryOpenBookMatch()
end,
})
end
table.insert(rows, {
_("Test connection"),
_("Run"),
callback = function()
self:testConnection()
end,
})
table.insert(rows, {
_("Check for update"),
_("Run"),
callback = function()
self:checkForUpdate()
end,
})
return rows
end
function MainMenu:showDiagnostics()
local KeyValuePage = require("ui/widget/keyvaluepage")
UIManager:show(KeyValuePage:new{
title = _("BookOrbit diagnostics"),
kv_pairs = self:diagnosticsRows(),
value_overflow_align = "right",
})
end
function MainMenu:testConnection()
if not self.settings.server_url then
UIManager:show(InfoMessage:new{ text = _("Set the BookOrbit server address first."), timeout = 3 })
return
end
if not self:isLoggedIn() then
self:promptLogin()
return
end
NetworkMgr:runWhenConnected(function()
local checking = InfoMessage:new{ text = _("Testing BookOrbit connection...") }
UIManager:show(checking)
local body, err = self:newClient():auth()
UIManager:close(checking)
if body then
self:recordSyncSuccess("connection_test", _("Connection test succeeded"))
UIManager:show(InfoMessage:new{ text = _("BookOrbit connection works."), timeout = 3 })
else
self:recordSyncError("connection_test", err)
UIManager:show(InfoMessage:new{
text = T(_("BookOrbit connection failed: %1"), self:errorLabel(err)),
timeout = 4,
})
end
end)
end
function MainMenu:dashboardSettingsMenu()
return {
{
text_func = function()
return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())
end,
sub_item_table = self:catalogAutoOpenMenu(),
},
}
end
function MainMenu:syncSettingsMenu(has_open_book)
local items = {}
table.insert(items, {
text = _("Skip auto-sync when offline"),
checked_func = function() return self.settings.skip_sync_when_offline end,
enabled_func = function() return self.settings.auto_sync end,
help_text = _([[When enabled, automatic sync on book open is skipped if the device is not already online. Prevents the e-reader from stalling while trying to connect to Wi-Fi.]]),
callback = function()
self.settings.skip_sync_when_offline = not self.settings.skip_sync_when_offline
end,
})
table.insert(items, {
text_func = function()
return T(_("Periodically sync every # pages (%1)"), self:getSyncPeriod())
end,
enabled_func = function() return self.settings.auto_sync end,
keep_menu_open = true,
separator = true,
callback = function(touchmenu_instance)
local SpinWidget = require("ui/widget/spinwidget")
local spin = SpinWidget:new{
text = _([[This value determines how many page turns it takes to push book progress.
If set to 0, updating progress based on page turns will be disabled.]]),
value = self.settings.pages_before_update or 0,
value_min = 0,
value_max = 999,
value_step = 1,
value_hold_step = 10,
ok_text = _("Set"),
title_text = _("Number of pages before update"),
default_value = 10,
callback = function(widget)
self:setPagesBeforeUpdate(widget.value)
if touchmenu_instance then touchmenu_instance:updateItems() end
end,
}
UIManager:show(spin)
end,
})
table.insert(items, {
text = _("Two-way highlight sync"),
checked_func = function() return self.settings.annotation_sync end,
help_text = _([[Also applies highlights, notes and deletions made in BookOrbit to this device: on book open, after the manual book sync, and during the full sweep for closed books. Turning this off keeps uploads only.]]),
callback = function()
self.settings.annotation_sync = not self.settings.annotation_sync
end,
})
table.insert(items, {
text_func = function()
return T(_("Sync to a newer state (%1)"), getNameStrategy(self.settings.sync_forward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_forward end,
function(value) self.settings.sync_forward = value end
),
})
table.insert(items, {
text_func = function()
return T(_("Sync to an older state (%1)"), getNameStrategy(self.settings.sync_backward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_backward end,
function(value) self.settings.sync_backward = value end
),
})
return items
end
function MainMenu:pluginSettingsMenu()
return {
{
id = "plugin_update",
text_func = function()
return self:updateCheckMenuText()
end,
keep_menu_open = true,
callback = function()
self:checkForUpdate()
end,
},
}
end
function MainMenu:settingsMenu(has_open_book, opts)
opts = opts or {}
local items = {
{
text = _("Dashboard"),
sub_item_table = self:dashboardSettingsMenu(),
},
{
text = _("Sync"),
sub_item_table = self:syncSettingsMenu(has_open_book),
},
}
if opts.include_plugin ~= false then
table.insert(items, {
text = _("Plugin"),
sub_item_table = self:pluginSettingsMenu(),
})
end
return items
end
function MainMenu:addToMainMenu(menu_items) function MainMenu:addToMainMenu(menu_items)
menu_items.bookorbit = { local logged_in = self:isLoggedIn()
text = _("BookOrbit"), local has_open_book = hasReaderBook(self)
-- Fallback placement only: BookOrbitMenuPin normally pins this entry local items = {}
-- right below calibre on the first page of the Tools menu.
sorting_hint = "tools", if logged_in then
sub_item_table = { table.insert(items, {
{ id = "open_dashboard",
text = _("Open dashboard"), text = _("Open dashboard"),
enabled_func = function() callback = function()
return self:isLoggedIn() self:browseCatalog()
end, end,
callback = function() separator = true,
self:browseCatalog() })
end, if has_open_book then
}, table.insert(items, {
{ id = "sync_current_book",
text_func = function() text = _("Sync current book now"),
return self:updateCheckMenuText()
end,
enabled_func = function()
return self:isLoggedIn()
and not BookOrbitSweep.isRunning()
and not BookOrbitBookSync.isRunning()
end,
keep_menu_open = true,
callback = function()
self:checkForUpdate()
end,
},
{
text_func = function()
local status = BookOrbitSweep.syncStatus()
local when = (status.lastSweepAt == 0) and _("never")
or os.date("%Y-%m-%d %H:%M", status.lastSweepAt)
if status.unmatched > 0 then
return T(_("Last sync: %1 (%2 linked, %3 unmatched)"), when, status.matched, status.unmatched)
elseif status.matched > 0 then
return T(_("Last sync: %1 (%2 linked)"), when, status.matched)
end
return T(_("Last sync: %1"), when)
end,
enabled = false,
separator = true,
},
{
text = _("Sync this book now"),
enabled_func = function()
return self:isLoggedIn() and self.ui.document ~= nil
and not BookOrbitSweep.isRunning() and not BookOrbitBookSync.isRunning()
end,
callback = function() callback = function()
self:onBookOrbitSyncBook() self:onBookOrbitSyncBook()
end, end,
}, })
{ table.insert(items, {
text = _("Sync all books now"), id = "auto_sync_current_book",
enabled_func = function() text = _("Auto sync current book"),
return self:isLoggedIn() and not BookOrbitSweep.isRunning() and not BookOrbitBookSync.isRunning()
end,
callback = function()
self:startSweep()
end,
separator = true,
},
{
text = _("Auto sync this book"),
checked_func = function() return self.settings.auto_sync end, checked_func = function() return self.settings.auto_sync end,
help_text = _([[Pulls progress when a book is opened; pushes progress, highlights, status, rating and reading time when it is closed and on suspend.]]), help_text = _([[Pulls progress when a book is opened; pushes progress, highlights, status, rating and reading time when it is closed and on suspend.]]),
callback = function() callback = function()
self:onBookOrbitToggleAutoSync(nil, true) self:onBookOrbitToggleAutoSync(nil, true)
end, end,
},
{
text = _("Two-way highlight sync"),
checked_func = function() return self.settings.annotation_sync end,
help_text = _([[Also applies highlights, notes and deletions made in BookOrbit to this device: on book open, after the manual book sync, and during the full sweep for closed books. Turning this off keeps uploads only.]]),
callback = function()
self.settings.annotation_sync = not self.settings.annotation_sync
end,
},
{
text = _("Skip auto-sync when offline"),
checked_func = function() return self.settings.skip_sync_when_offline end,
enabled_func = function() return self.settings.auto_sync end,
help_text = _([[When enabled, automatic sync on book open is skipped if the device is not already online. Prevents the e-reader from stalling while trying to connect to Wi-Fi.]]),
callback = function()
self.settings.skip_sync_when_offline = not self.settings.skip_sync_when_offline
end,
separator = true, separator = true,
})
end
table.insert(items, {
text = _("Sync all books now"),
callback = function()
self:startSweep()
end,
separator = true,
})
table.insert(items, {
id = "settings",
text = _("Settings"),
separator = true,
sub_item_table = self:settingsMenu(has_open_book),
})
end
table.insert(items, {
text = _("Account & setup"),
sub_item_table = {
{
text = _("BookOrbit server address"),
keep_menu_open = true,
callback = function()
self:setServerAddress()
end,
}, },
{ {
text = _("Sync settings"), text_func = function()
sub_item_table = { return self.settings.userkey and _("Logout") or _("Login")
{ end,
text_func = function() enabled_func = function()
return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel()) return self.settings.server_url ~= nil
end, end,
sub_item_table = self:catalogAutoOpenMenu(), keep_menu_open = true,
}, callback_func = function()
{ if self.settings.userkey then
text_func = function() return function(menu)
return T(_("Periodically sync every # pages (%1)"), self:getSyncPeriod()) self:logout(menu)
end, end
enabled_func = function() return self.settings.auto_sync end, else
keep_menu_open = true, return function(menu)
callback = function(touchmenu_instance) self:login(menu)
local SpinWidget = require("ui/widget/spinwidget") end
local items = SpinWidget:new{ end
text = _([[This value determines how many page turns it takes to push book progress. end,
If set to 0, updating progress based on page turns will be disabled.]]),
value = self.settings.pages_before_update or 0,
value_min = 0,
value_max = 999,
value_step = 1,
value_hold_step = 10,
ok_text = _("Set"),
title_text = _("Number of pages before update"),
default_value = 10,
callback = function(spin)
self:setPagesBeforeUpdate(spin.value)
if touchmenu_instance then touchmenu_instance:updateItems() end
end,
}
UIManager:show(items)
end,
},
{
text_func = function()
return T(_("Sync to a newer state (%1)"), getNameStrategy(self.settings.sync_forward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_forward end,
function(value) self.settings.sync_forward = value end
),
},
{
text_func = function()
return T(_("Sync to an older state (%1)"), getNameStrategy(self.settings.sync_backward))
end,
sub_item_table = self:strategyMenu(
function() return self.settings.sync_backward end,
function(value) self.settings.sync_backward = value end
),
},
},
}, },
{ {
text = _("Account & setup"), text = _("Diagnostics"),
sub_item_table = { callback = function()
{ self:showDiagnostics()
text = _("BookOrbit server address"), end,
keep_menu_open = true,
callback = function()
self:setServerAddress()
end,
},
{
text_func = function()
return self.settings.userkey and _("Logout") or _("Login")
end,
enabled_func = function()
return self.settings.server_url ~= nil
end,
keep_menu_open = true,
callback_func = function()
if self.settings.userkey then
return function(menu)
self:logout(menu)
end
else
return function(menu)
self:login(menu)
end
end
end,
},
},
}, },
}, },
})
menu_items.bookorbit = {
text = _("BookOrbit"),
-- Fallback placement only: BookOrbitMenuPin normally pins this entry
-- right below calibre on the first page of the Tools menu.
sorting_hint = "tools",
sub_item_table = items,
} }
end end
@@ -271,9 +492,23 @@ function MainMenu:dashboardMenuItems(catalog)
local items = { local items = {
icon = "appbar.menu", icon = "appbar.menu",
} }
for index, item in ipairs(bookorbit_items) do if self:isLoggedIn() then
if index ~= 1 then local plugin_update = self:pluginSettingsMenu()[1]
table.insert(items, item) plugin_update.separator = true
table.insert(items, plugin_update)
end
for _index, item in ipairs(bookorbit_items) do
if item.id ~= "open_dashboard" and item.id ~= "sync_current_book" and item.id ~= "auto_sync_current_book" then
if item.id == "settings" then
table.insert(items, {
id = "settings",
text = _("Settings"),
separator = item.separator,
sub_item_table = self:settingsMenu(false, { include_plugin = false }),
})
else
table.insert(items, item)
end
end end
end end
if catalog then if catalog then
@@ -427,7 +662,11 @@ function MainMenu:doLogin(username, password, menu)
if menu then menu:updateItems() end if menu then menu:updateItems() end
UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") }) UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") })
UIManager:scheduleIn(1, function() UIManager:scheduleIn(1, function()
self:maybeCheckForUpdate(false) if self.requestUpdateCheck then
self:requestUpdateCheck(false, "login")
else
self:maybeCheckForUpdate(false)
end
end) end)
elseif err == 401 or err == 403 then elseif err == 401 or err == 403 then
UIManager:show(InfoMessage:new{ UIManager:show(InfoMessage:new{
@@ -0,0 +1,95 @@
local OpenAnnotationScheduler = {}
OpenAnnotationScheduler.__index = OpenAnnotationScheduler
function OpenAnnotationScheduler.new(opts)
opts = opts or {}
return setmetatable({
delay = opts.delay or 2,
schedule_in = opts.schedule_in,
token = 0,
pending = nil,
}, OpenAnnotationScheduler)
end
function OpenAnnotationScheduler:clear(token)
if self.pending and self.pending.token == token then
self.pending = nil
end
end
function OpenAnnotationScheduler:schedule(plugin, digest, reason, opts)
if not digest or digest == "" then return false end
opts = opts or {}
self.token = self.token + 1
local token = self.token
self.pending = {
token = token,
digest = digest,
reason = reason or "annotation_open",
match_retried = opts.match_retried == true,
phase = "scheduled",
}
self.schedule_in(self.delay, function()
self:run(plugin, token)
end)
return true
end
function OpenAnnotationScheduler:recordUnmatched(plugin, reason)
if plugin.recordOpenAnnotationUnmatched then
plugin:recordOpenAnnotationUnmatched(reason)
end
end
function OpenAnnotationScheduler:bookStillOpen(plugin, digest)
return not plugin.getDocumentDigest or plugin:getDocumentDigest() == digest
end
function OpenAnnotationScheduler:run(plugin, token)
local pending = self.pending
if not pending or pending.token ~= token then return end
local digest = pending.digest
local reason = pending.reason
if not self:bookStillOpen(plugin, digest) then
self:clear(token)
return
end
if plugin.shouldSkipAutoSyncOffline and plugin:shouldSkipAutoSyncOffline(reason) then
self:clear(token)
return
end
if plugin.isOpenBookMatched and plugin:isOpenBookMatched(digest) then
self:clear(token)
plugin:requestAnnotationExchange(reason)
return
end
if pending.match_retried or not plugin.matchOpenBookForAutoSync then
self:clear(token)
self:recordUnmatched(plugin, reason)
return
end
pending.phase = "matching"
plugin:matchOpenBookForAutoSync(function(matched)
local current = self.pending
if not current or current.token ~= token then return end
if not self:bookStillOpen(plugin, digest) then
self:clear(token)
return
end
self:clear(token)
if matched then
self:schedule(plugin, digest, reason, { match_retried = true })
else
self:recordUnmatched(plugin, reason)
end
end)
end
function OpenAnnotationScheduler:status()
return self.pending
end
return OpenAnnotationScheduler
@@ -53,14 +53,18 @@ end
-- Always the binary partial MD5: BookOrbit matches on the scanner-computed -- Always the binary partial MD5: BookOrbit matches on the scanner-computed
-- partial MD5 of the file, so the filename checksum method does not exist here. -- partial MD5 of the file, so the filename checksum method does not exist here.
function ProgressSync:getDocumentDigest() function ProgressSync:getDocumentDigest()
local digest = self.ui.doc_settings:readSetting("partial_md5_checksum") if not self.ui or not self.ui.document then return nil end
local doc_settings = self.ui.doc_settings
local digest = doc_settings and doc_settings:readSetting("partial_md5_checksum") or nil
if digest then return digest end if digest then return digest end
local file = self.ui.document.file local file = self.ui.document.file
if not file then return nil end if not file then return nil end
local ok, computed = pcall(util.partialMD5, file) local ok, computed = pcall(util.partialMD5, file)
if not ok or not computed then return nil end if not ok or not computed then return nil end
self.ui.doc_settings:saveSetting("partial_md5_checksum", computed) if doc_settings then
doc_settings:saveSetting("partial_md5_checksum", computed)
end
return computed return computed
end end
@@ -99,12 +103,12 @@ end
function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done) function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
if not self.ui or not self.ui.document then if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("Open a book to sync it."), timeout = 2 }) UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
return return false
end end
if self:getDocumentDigest() ~= digest then if self:getDocumentDigest() ~= digest then
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 }) UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
return return false
end end
local client = self:newClient() local client = self:newClient()
@@ -115,17 +119,17 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
logger.dbg("BookOrbit: progress check before book sync failed:", err) logger.dbg("BookOrbit: progress check before book sync failed:", err)
UIManager:show(InfoMessage:new{ text = _("Could not check latest progress. Syncing book data without changing progress."), timeout = 4 }) UIManager:show(InfoMessage:new{ text = _("Could not check latest progress. Syncing book data without changing progress."), timeout = 4 })
on_done(true) on_done(true)
return return true
end end
if not body.percentage then if not body.percentage then
on_done(false) on_done(false)
return return true
end end
if body.device == Device.model and body.device_id == self.device_id then if body.device == Device.model and body.device_id == self.device_id then
on_done(false) on_done(false)
return return true
end end
body.percentage = Math.roundPercent(body.percentage) body.percentage = Math.roundPercent(body.percentage)
@@ -134,7 +138,7 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
if local_percentage == body.percentage or body.progress == local_progress then if local_percentage == body.percentage or body.progress == local_progress then
on_done(false) on_done(false)
return return true
end end
local remote_newer = self:remoteProgressIsNewer(body, local_percentage) local remote_newer = self:remoteProgressIsNewer(body, local_percentage)
@@ -148,12 +152,13 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
else else
on_done(false) on_done(false)
end end
return return true
end end
local strategy = remote_newer and self.settings.sync_forward or self.settings.sync_backward local strategy = remote_newer and self.settings.sync_forward or self.settings.sync_backward
if strategy == SYNC_STRATEGY.SILENT then if strategy == SYNC_STRATEGY.SILENT then
self:applyRemoteProgress(body, on_done) self:applyRemoteProgress(body, on_done)
return true
elseif strategy == SYNC_STRATEGY.PROMPT then elseif strategy == SYNC_STRATEGY.PROMPT then
local template = remote_newer and _("Sync to latest location %1% from device '%2' before uploading this book?") local template = remote_newer and _("Sync to latest location %1% from device '%2' before uploading this book?")
or _("Sync to previous location %1% from device '%2' before uploading this book?") or _("Sync to previous location %1% from device '%2' before uploading this book?")
@@ -166,14 +171,17 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
on_done(remote_newer) on_done(remote_newer)
end, end,
}) })
return true
elseif remote_newer then elseif remote_newer then
UIManager:show(InfoMessage:new{ UIManager:show(InfoMessage:new{
text = _("BookOrbit has newer progress. Syncing book data without changing progress."), text = _("BookOrbit has newer progress. Syncing book data without changing progress."),
timeout = 4, timeout = 4,
}) })
on_done(true) on_done(true)
return true
else else
on_done(false) on_done(false)
return true
end end
end end
@@ -182,7 +190,12 @@ function ProgressSync:updateProgress(ensure_networking, interactive, on_suspend)
if interactive then self:promptLogin() end if interactive then self:promptLogin() end
return return
end end
if not self.ui or not self.ui.document then return end if not self.ui or not self.ui.document then
if interactive then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
end
return
end
local now = UIManager:getElapsedTimeSinceBoot() local now = UIManager:getElapsedTimeSinceBoot()
if not interactive and now - self.push_timestamp <= API_CALL_DEBOUNCE_DELAY then if not interactive and now - self.push_timestamp <= API_CALL_DEBOUNCE_DELAY then
@@ -201,15 +214,37 @@ function ProgressSync:updateProgress(ensure_networking, interactive, on_suspend)
local body, err = client:updateProgress(digest, self:getLastPercent(), self:getLastProgress(), os.time()) local body, err = client:updateProgress(digest, self:getLastPercent(), self:getLastProgress(), os.time())
if interactive then if interactive then
if body then if body then
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_push", _("Progress pushed"))
end
UIManager:show(InfoMessage:new{ text = _("Progress has been pushed to BookOrbit."), timeout = 3 }) UIManager:show(InfoMessage:new{ text = _("Progress has been pushed to BookOrbit."), timeout = 3 })
if not on_suspend then self:maybeCheckForUpdate(false) end if not on_suspend then
if self.requestUpdateCheck then
self:requestUpdateCheck(false, "progress_push")
else
self:maybeCheckForUpdate(false)
end
end
else else
if self.recordSyncError then
self:recordSyncError("progress_push", err)
end
showSyncError() showSyncError()
end end
elseif not body then elseif not body then
if self.recordSyncError then
self:recordSyncError("progress_push", err)
end
logger.dbg("BookOrbit: push failed:", err) logger.dbg("BookOrbit: push failed:", err)
elseif not on_suspend then elseif not on_suspend then
self:maybeCheckForUpdate(false) if self.recordSyncSuccess then
self:recordSyncSuccess("progress_push", _("Progress pushed"))
end
if self.requestUpdateCheck then
self:requestUpdateCheck(false, "progress_push")
else
self:maybeCheckForUpdate(false)
end
end end
if on_suspend and Device:hasWifiManager() then if on_suspend and Device:hasWifiManager() then
@@ -224,7 +259,12 @@ function ProgressSync:getProgress(ensure_networking, interactive)
if interactive then self:promptLogin() end if interactive then self:promptLogin() end
return return
end end
if not self.ui or not self.ui.document then return end if not self.ui or not self.ui.document then
if interactive then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
end
return
end
local now = UIManager:getElapsedTimeSinceBoot() local now = UIManager:getElapsedTimeSinceBoot()
if not interactive and now - self.pull_timestamp <= API_CALL_DEBOUNCE_DELAY then if not interactive and now - self.pull_timestamp <= API_CALL_DEBOUNCE_DELAY then
@@ -245,10 +285,17 @@ function ProgressSync:getProgress(ensure_networking, interactive)
if not body then if not body then
if interactive then showSyncError() end if interactive then showSyncError() end
if self.recordSyncError then
self:recordSyncError("progress_pull", err)
end
logger.dbg("BookOrbit: pull failed:", err) logger.dbg("BookOrbit: pull failed:", err)
return return
end end
self:maybeCheckForUpdate(false) if self.requestUpdateCheck then
self:requestUpdateCheck(false, "progress_pull")
else
self:maybeCheckForUpdate(false)
end
if not body.percentage then if not body.percentage then
if interactive then if interactive then
@@ -277,6 +324,9 @@ function ProgressSync:getProgress(ensure_networking, interactive)
if interactive then if interactive then
self:syncToProgress(body.progress) self:syncToProgress(body.progress)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 }) UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 })
return return
end end
@@ -291,6 +341,9 @@ function ProgressSync:getProgress(ensure_networking, interactive)
local strategy = self_older and self.settings.sync_forward or self.settings.sync_backward local strategy = self_older and self.settings.sync_forward or self.settings.sync_backward
if strategy == SYNC_STRATEGY.SILENT then if strategy == SYNC_STRATEGY.SILENT then
self:syncToProgress(body.progress) self:syncToProgress(body.progress)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 }) UIManager:show(InfoMessage:new{ text = _("Progress has been synchronized."), timeout = 3 })
elseif strategy == SYNC_STRATEGY.PROMPT then elseif strategy == SYNC_STRATEGY.PROMPT then
local template = self_older and _("Sync to latest location %1% from device '%2'?") local template = self_older and _("Sync to latest location %1% from device '%2'?")
@@ -299,6 +352,9 @@ function ProgressSync:getProgress(ensure_networking, interactive)
text = T(template, Math.round(body.percentage * 100), body.device), text = T(template, Math.round(body.percentage * 100), body.device),
ok_callback = function() ok_callback = function()
self:syncToProgress(body.progress) self:syncToProgress(body.progress)
if self.recordSyncSuccess then
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
end
end, end,
}) })
end end
@@ -23,6 +23,7 @@ local _ = require("gettext")
local BookOrbitAnnotations = require("bookorbit_annotations") local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitApi = require("bookorbit_api") local BookOrbitApi = require("bookorbit_api")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitSidecar = require("bookorbit_sidecar") local BookOrbitSidecar = require("bookorbit_sidecar")
local BookOrbitState = require("bookorbit_state") local BookOrbitState = require("bookorbit_state")
local BookOrbitStatsReader = require("bookorbit_stats_reader") local BookOrbitStatsReader = require("bookorbit_stats_reader")
@@ -97,8 +98,27 @@ local function finish(ctx, err)
ctx.progress_msg = nil ctx.progress_msg = nil
end end
ctx.state:flush() ctx.state:flush()
if ctx.on_finish then
pcall(ctx.on_finish, err) if ctx.plugin and ctx.plugin.recordSyncError then
if err == "auth" or err == "network" then
ctx.plugin:recordSyncError("sweep", err)
elseif not err and ctx.had_errors then
ctx.plugin:recordSyncError("sweep", "partial_failure")
elseif not err and ctx.plugin.recordSyncSuccess then
ctx.plugin:recordSyncSuccess(
"sweep",
T(_("%1 books, %2 reading events, %3 highlights"),
ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations))
end
end
if ctx.plugin and ctx.plugin.recordHighlightSync and ctx.highlight_summary
and BookOrbitHighlightSummary.hasCounts(ctx.highlight_summary) then
local highlight_err = ctx.highlight_unsupported and "unsupported_server" or nil
if ctx.had_errors and not highlight_err then
highlight_err = "partial_failure"
end
ctx.plugin:recordHighlightSync(ctx.highlight_summary, highlight_err)
end end
if err == "auth" then if err == "auth" then
@@ -107,6 +127,9 @@ local function finish(ctx, err)
else else
Notification:notify(_("BookOrbit sync: login failed")) Notification:notify(_("BookOrbit sync: login failed"))
end end
if ctx.on_finish then
pcall(ctx.on_finish, err)
end
return return
end end
@@ -115,20 +138,37 @@ local function finish(ctx, err)
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: server not reachable."), timeout = 4 }) UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: server not reachable."), timeout = 4 })
end end
logger.dbg("BookOrbit: sweep aborted, server not reachable") logger.dbg("BookOrbit: sweep aborted, server not reachable")
if ctx.on_finish then
pcall(ctx.on_finish, err)
end
return return
end end
if ctx.interactive then if ctx.interactive then
local text = T(_("BookOrbit sync done: %1 books matched, %2 reading events, %3 highlights."), local text = T(_("BookOrbit sync done: %1 books matched, %2 reading events, %3 highlights."),
ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations) ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations)
if ctx.highlight_summary and BookOrbitHighlightSummary.hasRemoteChanges(ctx.highlight_summary) then
text = text .. "\n" .. T(_("Highlights updated: %1 applied, %2 deleted, %3 closed book(s)."),
ctx.highlight_summary.applied, ctx.highlight_summary.deleted, ctx.highlight_summary.touched_books)
end
if ctx.highlight_unsupported then
text = text .. "\n" .. _("BookOrbit server needs an update for two-way highlights.")
end
if ctx.highlight_disabled_reported then
text = text .. "\n" .. _("Two-way highlight sync is disabled.")
end
if ctx.had_errors then if ctx.had_errors then
text = text .. "\n" .. _("Some books failed and will retry on the next sync.") text = text .. "\n" .. _("Some books failed and will retry on the next sync.")
end end
UIManager:show(InfoMessage:new{ text = text, timeout = 5 }) UIManager:show(InfoMessage:new{ text = text, timeout = 5 })
end end
logger.info(string.format( logger.info(string.format(
"BookOrbit: sweep done matched=%d pageStats=%d annotations=%d errors=%s", "BookOrbit: sweep done matched=%d pageStats=%d annotations=%d applied=%d deleted=%d errors=%s",
ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations, tostring(ctx.had_errors))) ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations,
ctx.counts.ann_applied or 0, ctx.counts.ann_deleted or 0, tostring(ctx.had_errors)))
if ctx.on_finish then
pcall(ctx.on_finish, err)
end
end end
local function step(ctx, fn) local function step(ctx, fn)
@@ -479,6 +519,9 @@ local function stepAnnotationsNext(ctx)
local apply_mode = "skip" local apply_mode = "skip"
if ctx.annotation_sync and entry.file ~= currentlyOpenFile() then if ctx.annotation_sync and entry.file ~= currentlyOpenFile() then
apply_mode = "sidecar" apply_mode = "sidecar"
elseif not ctx.annotation_sync and ctx.interactive and not ctx.highlight_disabled_reported then
ctx.highlight_disabled_reported = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
end end
local result, err = BookOrbitAnnotations.exchangeBook({ local result, err = BookOrbitAnnotations.exchangeBook({
@@ -496,11 +539,14 @@ local function stepAnnotationsNext(ctx)
-- Re-queue everything, including this entry, for the legacy path. -- Re-queue everything, including this entry, for the legacy path.
table.insert(ctx.ann_queue, 1, entry) table.insert(ctx.ann_queue, 1, entry)
ctx.use_legacy_annotations = true ctx.use_legacy_annotations = true
ctx.highlight_unsupported = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
buildLegacyAnnotationChunks(ctx) buildLegacyAnnotationChunks(ctx)
return step(ctx, ctx.steps.annotationsLegacyNext) return step(ctx, ctx.steps.annotationsLegacyNext)
end end
ctx.had_errors = true ctx.had_errors = true
if pending then pending.failed = true end if pending then pending.failed = true end
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
logger.dbg("BookOrbit: annotation exchange failed for", entry.md5, err) logger.dbg("BookOrbit: annotation exchange failed for", entry.md5, err)
return step(ctx, ctx.steps.annotationsNext) return step(ctx, ctx.steps.annotationsNext)
end end
@@ -513,6 +559,10 @@ local function stepAnnotationsNext(ctx)
end end
ctx.counts.annotations = ctx.counts.annotations + result.uploaded ctx.counts.annotations = ctx.counts.annotations + result.uploaded
ctx.counts.ann_applied = (ctx.counts.ann_applied or 0) + result.applied ctx.counts.ann_applied = (ctx.counts.ann_applied or 0) + result.applied
ctx.counts.ann_deleted = (ctx.counts.ann_deleted or 0) + result.deleted
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, result, {
closed_book = apply_mode == "sidecar",
})
return step(ctx, ctx.steps.annotationsNext) return step(ctx, ctx.steps.annotationsNext)
end end
@@ -565,6 +615,7 @@ local function stepAnnotationsLegacyNext(ctx)
if isAuthError(err) then return finish(ctx, "auth") end if isAuthError(err) then return finish(ctx, "auth") end
logger.dbg("BookOrbit: annotations upload failed:", err) logger.dbg("BookOrbit: annotations upload failed:", err)
ctx.had_errors = true ctx.had_errors = true
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
for _, book in ipairs(books) do for _, book in ipairs(books) do
local pending = ctx.pending_books[book.hash] local pending = ctx.pending_books[book.hash]
if pending then pending.failed = true end if pending then pending.failed = true end
@@ -586,6 +637,9 @@ local function stepAnnotationsLegacyNext(ctx)
elseif pending then elseif pending then
pending.ann_chunks_left = pending.ann_chunks_left - 1 pending.ann_chunks_left = pending.ann_chunks_left - 1
ctx.counts.annotations = ctx.counts.annotations + #book.annotations ctx.counts.annotations = ctx.counts.annotations + #book.annotations
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, {
uploaded = #book.annotations,
})
end end
end end
@@ -747,7 +801,7 @@ function BookOrbitSweep.run(opts)
if opts.interactive then if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 }) UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
end end
return return false
end end
local BookOrbitBookSync = require("bookorbit_book_sync") local BookOrbitBookSync = require("bookorbit_book_sync")
@@ -755,7 +809,7 @@ function BookOrbitSweep.run(opts)
if opts.interactive then if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit is syncing the current book, try again shortly."), timeout = 2 }) UIManager:show(InfoMessage:new{ text = _("BookOrbit is syncing the current book, try again shortly."), timeout = 2 })
end end
return return false
end end
local client = BookOrbitApi.new(opts.api) local client = BookOrbitApi.new(opts.api)
@@ -763,7 +817,7 @@ function BookOrbitSweep.run(opts)
if opts.interactive then if opts.interactive then
UIManager:show(InfoMessage:new{ text = _("Please configure the BookOrbit server and login first."), timeout = 3 }) UIManager:show(InfoMessage:new{ text = _("Please configure the BookOrbit server and login first."), timeout = 3 })
end end
return return false
end end
BookOrbitSweep.running = true BookOrbitSweep.running = true
@@ -781,9 +835,14 @@ function BookOrbitSweep.run(opts)
state = state, state = state,
interactive = opts.interactive or false, interactive = opts.interactive or false,
annotation_sync = opts.annotation_sync ~= false, annotation_sync = opts.annotation_sync ~= false,
plugin = opts.plugin,
full_recheck = opts.interactive or state.global.needsFullRecheck or false, full_recheck = opts.interactive or state.global.needsFullRecheck or false,
on_finish = opts.on_finish, on_finish = opts.on_finish,
counts = { books_matched = 0, page_stats = 0, annotations = 0, ann_applied = 0 }, counts = { books_matched = 0, page_stats = 0, annotations = 0, ann_applied = 0, ann_deleted = 0 },
highlight_summary = BookOrbitHighlightSummary.normalize{
event = "sweep",
reason = opts.interactive and "manual" or "auto",
},
had_errors = false, had_errors = false,
} }
ctx.steps = { ctx.steps = {
@@ -803,6 +862,7 @@ function BookOrbitSweep.run(opts)
logger.info("BookOrbit: sweep started, interactive:", ctx.interactive) logger.info("BookOrbit: sweep started, interactive:", ctx.interactive)
step(ctx, ctx.steps.enumerate) step(ctx, ctx.steps.enumerate)
return true
end end
return BookOrbitSweep return BookOrbitSweep
@@ -0,0 +1,146 @@
--[[--
Small sync job coordinator for the BookOrbit plugin.
It serializes sync work, coalesces pending jobs by family and lets callers
export a compact status snapshot for diagnostics. The coordinator deliberately
does not know about KOReader widgets or BookOrbit APIs; jobs provide the work.
]]
local SyncCoordinator = {}
SyncCoordinator.__index = SyncCoordinator
SyncCoordinator.PRIORITY = {
auto = 100,
manual = 200,
lifecycle = 300,
}
function SyncCoordinator.new(opts)
opts = opts or {}
return setmetatable({
now = opts.now or os.time,
current = nil,
pending = {},
pending_count = 0,
next_seq = 0,
}, SyncCoordinator)
end
local function copyJobStatus(job, now)
if not job then return nil end
local started_at = job.started_at
local status = {
family = job.family,
label = job.label or job.family,
source = job.source,
interactive = job.interactive == true,
priority = job.priority,
created_at = job.created_at,
started_at = started_at,
}
if started_at then
status.age = math.max(0, now - started_at)
end
return status
end
function SyncCoordinator:isBusy()
return self.current ~= nil
end
function SyncCoordinator:queuedCount()
return self.pending_count
end
function SyncCoordinator:coalesce(job)
local existing = self.pending[job.family]
if existing and existing.priority > job.priority then
return "kept", existing
end
if not existing then
self.pending_count = self.pending_count + 1
end
self.pending[job.family] = job
return "queued", job
end
function SyncCoordinator:nextPending()
local best_family, best_job
for family, job in pairs(self.pending) do
if not best_job
or job.priority > best_job.priority
or (job.priority == best_job.priority and job.seq < best_job.seq) then
best_family = family
best_job = job
end
end
return best_family, best_job
end
function SyncCoordinator:startNext()
local family, job = self:nextPending()
if not job then return end
self.pending[family] = nil
self.pending_count = self.pending_count - 1
self:start(job)
end
function SyncCoordinator:start(job)
self.current = job
job.started_at = self.now()
local finished = false
local function done()
if finished then return end
finished = true
if self.current == job then
self.current = nil
self:startNext()
end
end
local ok, started = pcall(job.run, done, job)
if not ok then
if job.on_error then
pcall(job.on_error, started)
end
done()
return
end
if job.async and started == false then
done()
elseif not job.async then
done()
end
end
function SyncCoordinator:submit(job)
assert(type(job) == "table", "sync job must be a table")
assert(type(job.family) == "string" and job.family ~= "", "sync job family required")
assert(type(job.run) == "function", "sync job run function required")
self.next_seq = self.next_seq + 1
job.seq = self.next_seq
job.created_at = self.now()
job.priority = job.priority or SyncCoordinator.PRIORITY.auto
job.label = job.label or job.family
if self.current then
return self:coalesce(job)
end
self:start(job)
return "started", job
end
function SyncCoordinator:status()
local now = self.now()
local _, next_job = self:nextPending()
return {
current = copyJobStatus(self.current, now),
pending_count = self.pending_count,
next = copyJobStatus(next_job, now),
}
end
return SyncCoordinator
@@ -145,6 +145,10 @@ function UpdateCheck:updateCheckMenuText()
end end
function UpdateCheck:checkForUpdate() function UpdateCheck:checkForUpdate()
if self.requestUpdateCheck then
self:requestUpdateCheck(true, "manual")
return
end
if not self:isLoggedIn() then if not self:isLoggedIn() then
self:promptLogin() self:promptLogin()
return return
@@ -168,6 +172,9 @@ function UpdateCheck:maybeCheckForUpdate(interactive)
self._checking_update = false self._checking_update = false
if not body then if not body then
if self.recordSyncError then
self:recordSyncError("update_check", err)
end
if interactive then if interactive then
UIManager:show(InfoMessage:new{ UIManager:show(InfoMessage:new{
text = T(_("Could not check for update: %1"), tostring(err or "network error")), text = T(_("Could not check for update: %1"), tostring(err or "network error")),
@@ -190,6 +197,9 @@ function UpdateCheck:doCheckForUpdate()
UIManager:close(checking) UIManager:close(checking)
if not body then if not body then
if self.recordSyncError then
self:recordSyncError("update_check", err)
end
UIManager:show(InfoMessage:new{ UIManager:show(InfoMessage:new{
text = T(_("Could not check for update: %1"), tostring(err or "network error")), text = T(_("Could not check for update: %1"), tostring(err or "network error")),
timeout = 4, timeout = 4,
@@ -207,6 +217,9 @@ function UpdateCheck:handleUpdateVersionResponse(body, interactive, prompt_allow
if type(plugin_latest) ~= "string" or plugin_latest == "unknown" then if type(plugin_latest) ~= "string" or plugin_latest == "unknown" then
G_reader_settings:flush() G_reader_settings:flush()
if self.recordSyncError then
self:recordSyncError("update_check", "unsupported_server", _("latest plugin version unavailable"))
end
if interactive then if interactive then
UIManager:show(InfoMessage:new{ UIManager:show(InfoMessage:new{
text = _("Could not determine the latest plugin version from the server."), text = _("Could not determine the latest plugin version from the server."),
+577 -97
View File
@@ -31,7 +31,10 @@ local BookOrbitAnnotations = require("bookorbit_annotations")
local BookOrbitApi = require("bookorbit_api") local BookOrbitApi = require("bookorbit_api")
local BookOrbitBookSync = require("bookorbit_book_sync") local BookOrbitBookSync = require("bookorbit_book_sync")
local BookOrbitCatalog = require("bookorbit_catalog") local BookOrbitCatalog = require("bookorbit_catalog")
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
local BookOrbitOpenAnnotationScheduler = require("bookorbit_open_annotation_scheduler")
local BookOrbitState = require("bookorbit_state") local BookOrbitState = require("bookorbit_state")
local BookOrbitSyncCoordinator = require("bookorbit_sync_coordinator")
local BookOrbitMainMenu = require("bookorbit_main_menu") local BookOrbitMainMenu = require("bookorbit_main_menu")
local BookOrbitMenuPin = require("bookorbit_menu_pin") local BookOrbitMenuPin = require("bookorbit_menu_pin")
local BookOrbitProgressSync = require("bookorbit_progress_sync") local BookOrbitProgressSync = require("bookorbit_progress_sync")
@@ -46,6 +49,20 @@ local SYNC_STRATEGY = {
DISABLE = 3, DISABLE = 3,
} }
local LAST_ERROR_LABELS = {
auth = _("login failed"),
network = _("server not reachable"),
invalid_json = _("invalid server response"),
unsupported_server = _("server update required"),
body_too_large = _("request too large"),
partial_failure = _("partial sync failure"),
}
local SYNC_JOB_PRIORITY = BookOrbitSyncCoordinator.PRIORITY
local OPEN_ANNOTATION_SYNC_DELAY = 2
local OPEN_HIGHLIGHT_RETRY_DELAY = 8
local OPEN_HIGHLIGHT_MAX_RETRIES = 2
local BookOrbit = WidgetContainer:extend{ local BookOrbit = WidgetContainer:extend{
name = "bookorbit", name = "bookorbit",
title = _("Login to BookOrbit"), title = _("Login to BookOrbit"),
@@ -104,7 +121,7 @@ function BookOrbit:init()
-- Push only, no pull, no network nagging: relies on the connection -- Push only, no pull, no network nagging: relies on the connection
-- being already up, like the stock kosync periodic push. -- being already up, like the stock kosync periodic push.
if self.settings.auto_sync and (self.settings.pages_before_update or 0) > 0 then if self.settings.auto_sync and (self.settings.pages_before_update or 0) > 0 then
self:updateProgress(false, false) self:requestProgressPush(false, false, "periodic")
end end
end end
@@ -161,7 +178,7 @@ function BookOrbit:init()
self.ui.menu:registerToMainMenu(self) self.ui.menu:registerToMainMenu(self)
self:onStart() self:onStart()
UIManager:scheduleIn(5, function() UIManager:scheduleIn(5, function()
self:maybeCheckForUpdate(false) self:requestUpdateCheck(false, "startup")
end) end)
end end
@@ -230,6 +247,419 @@ function BookOrbit:isLoggedIn()
return self.settings.server_url ~= nil and self.settings.username ~= nil and self.settings.userkey ~= nil return self.settings.server_url ~= nil and self.settings.username ~= nil and self.settings.userkey ~= nil
end end
function BookOrbit:errorLabel(err, fallback)
if LAST_ERROR_LABELS[err] then return LAST_ERROR_LABELS[err] end
if err == 401 or err == 403 then return LAST_ERROR_LABELS.auth end
if type(err) == "number" then
return T(_("server error %1"), err)
end
return fallback or tostring(err or _("unknown error"))
end
function BookOrbit:recordSyncSuccess(event, message)
self.settings.last_sync = {
event = event,
at = os.time(),
message = message,
}
self.settings.last_error = nil
G_reader_settings:flush()
end
function BookOrbit:recordSyncError(event, err, message)
self.settings.last_error = {
event = event,
at = os.time(),
code = err,
message = message or self:errorLabel(err),
}
G_reader_settings:flush()
end
function BookOrbit:recordHighlightSync(summary, err)
summary = BookOrbitHighlightSummary.normalize(summary)
summary.message = summary.message or BookOrbitHighlightSummary.message(summary)
self.settings.last_highlight_sync = summary
local code = BookOrbitHighlightSummary.actionableError(summary, err)
if code then
local message
if code == "unsupported_server" then
message = _("BookOrbit server needs an update for two-way highlights.")
end
self:recordSyncError("highlight_sync", code, message)
else
G_reader_settings:flush()
end
end
function BookOrbit:shouldSkipAutoSyncOffline(event)
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then
logger.dbg("BookOrbit: automatic sync skipped while offline:", event)
return true
end
return false
end
function BookOrbit:getSyncCoordinator()
if not self.sync_coordinator then
self.sync_coordinator = BookOrbitSyncCoordinator.new()
end
return self.sync_coordinator
end
function BookOrbit:getSyncCoordinatorStatus()
if not self.sync_coordinator then
return { pending_count = 0 }
end
return self.sync_coordinator:status()
end
function BookOrbit:getOpenAnnotationScheduler()
if not self.open_annotation_scheduler then
self.open_annotation_scheduler = BookOrbitOpenAnnotationScheduler.new{
delay = OPEN_ANNOTATION_SYNC_DELAY,
schedule_in = function(delay, callback)
UIManager:scheduleIn(delay, callback)
end,
}
end
return self.open_annotation_scheduler
end
function BookOrbit:isOpenBookMatched(digest)
if not digest then return false end
local state = BookOrbitState.open()
return state:getBook(digest) ~= nil
end
function BookOrbit:recordOpenAnnotationUnmatched(reason)
self:recordHighlightSync({
event = "open_book",
reason = reason or "annotation_open",
skipped = 1,
}, "unmatched")
end
function BookOrbit:scheduleOpenAnnotationSync(digest, reason, opts)
return self:getOpenAnnotationScheduler():schedule(self, digest, reason or "annotation_open", opts)
end
function BookOrbit:retryOpenHighlightSync()
if not self:isLoggedIn() then
self:promptLogin()
return
end
if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
return
end
self.open_highlight_retry_status = nil
self:requestAnnotationExchange("diagnostics_retry")
UIManager:show(InfoMessage:new{ text = _("BookOrbit highlight sync queued."), timeout = 2 })
end
function BookOrbit:retryOpenBookMatch()
if not self:isLoggedIn() then
self:promptLogin()
return
end
if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
return
end
local digest = self:getDocumentDigest()
if not digest then
UIManager:show(InfoMessage:new{ text = _("Could not identify the open book."), timeout = 2 })
return
end
local state = BookOrbitState.open()
state.unmatched[digest] = nil
state:flush()
self:requestOpenBookMatch("diagnostics", {
schedule_annotation_sync = self.settings.annotation_sync,
annotation_digest = digest,
})
UIManager:show(InfoMessage:new{ text = _("BookOrbit match retry queued."), timeout = 2 })
end
function BookOrbit:submitSyncJob(job)
local result = self:getSyncCoordinator():submit(job)
if (result == "queued" or result == "kept") and job.interactive then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync queued."), timeout = 2 })
end
return result
end
function BookOrbit:requestUpdateCheck(interactive, source)
if interactive and not self:isLoggedIn() then
self:promptLogin()
return
end
local submit = function()
self:submitSyncJob{
family = "update_check",
label = _("Update check"),
source = source or (interactive and "manual" or "auto"),
priority = interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto,
interactive = interactive == true,
run = function()
if interactive then
self:doCheckForUpdate()
else
self:maybeCheckForUpdate(false)
end
end,
}
end
if interactive then
NetworkMgr:runWhenConnected(submit)
else
submit()
end
end
function BookOrbit:requestProgressPush(ensure_networking, interactive, source, priority)
self:submitSyncJob{
family = "progress_push",
label = _("Progress push"),
source = source or (interactive and "manual" or "auto"),
priority = priority or (interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto),
interactive = interactive == true,
run = function()
self:updateProgress(ensure_networking, interactive)
end,
}
end
function BookOrbit:requestProgressPull(ensure_networking, interactive, source, priority)
self:submitSyncJob{
family = "progress_pull",
label = _("Progress pull"),
source = source or (interactive and "manual" or "auto"),
priority = priority or (interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto),
interactive = interactive == true,
run = function()
self:getProgress(ensure_networking, interactive)
end,
}
end
function BookOrbit:requestAnnotationExchange(source)
self:submitSyncJob{
family = "annotation_exchange",
label = _("Highlight sync"),
source = source or "auto",
priority = SYNC_JOB_PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
local execute = function()
self:exchangeAnnotationsForOpenBook(source or "auto")
done()
end
if NetworkMgr:willRerunWhenConnected(execute) then
return
end
execute()
end,
}
end
function BookOrbit:scheduleOpenHighlightRetry(reason, retry_count)
retry_count = retry_count or 0
if retry_count >= OPEN_HIGHLIGHT_MAX_RETRIES then
self.open_highlight_retry_status = nil
return
end
self.open_highlight_retry_status = {
pending = true,
reason = reason or "annotation_retry",
retry_count = retry_count + 1,
max_retries = OPEN_HIGHLIGHT_MAX_RETRIES,
}
UIManager:scheduleIn(OPEN_HIGHLIGHT_RETRY_DELAY, function()
self.open_highlight_retry_status = nil
if self:shouldSkipAutoSyncOffline("annotation_retry") then return end
self:submitSyncJob{
family = "annotation_exchange",
label = _("Highlight sync"),
source = reason or "annotation_retry",
priority = SYNC_JOB_PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
local execute = function()
self:exchangeAnnotationsForOpenBook(reason or "annotation_retry", retry_count + 1)
done()
end
if NetworkMgr:willRerunWhenConnected(execute) then
return
end
execute()
end,
}
end)
end
function BookOrbit:requestOpenBookMatch(source, opts)
opts = opts or {}
self:submitSyncJob{
family = "match_open_book",
label = _("Open book match"),
source = source or "auto",
priority = SYNC_JOB_PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
self:matchOpenBookForAutoSync(function(matched)
done()
if not matched then return end
if opts.schedule_annotation_sync and self.settings.annotation_sync then
self:scheduleOpenAnnotationSync(opts.annotation_digest or self:getDocumentDigest(), "annotation_open")
end
self:requestProgressPull(true, false, "reader_ready")
end)
end,
}
end
function BookOrbit:requestSweep(interactive, source)
local submit = function()
self:submitSyncJob{
family = "sweep",
label = _("Library sync"),
source = source or (interactive and "manual" or "auto"),
priority = interactive and SYNC_JOB_PRIORITY.manual or SYNC_JOB_PRIORITY.auto,
interactive = interactive == true,
async = true,
run = function(done)
local started = BookOrbitSweep.run{
api = self:apiOpts(),
interactive = interactive == true,
annotation_sync = self.settings.annotation_sync,
plugin = self,
on_finish = function(err)
if not err then
self:requestUpdateCheck(false, "sweep_done")
end
done()
end,
}
if started == false then
done()
end
end,
}
end
if NetworkMgr:willRerunWhenOnline(submit) then
return
end
submit()
end
function BookOrbit:requestManualBookSync(snap)
if not snap then return end
local api_opts = self:apiOpts()
self:submitSyncJob{
family = "book_snapshot",
label = _("Book sync"),
source = "manual",
priority = SYNC_JOB_PRIORITY.manual,
interactive = true,
async = true,
run = function(done)
local function run_book_sync(skip_progress)
local latest_snap = BookOrbitBookSync.capture(self)
if not latest_snap then
UIManager:show(InfoMessage:new{ text = _("Could not read this book's data."), timeout = 2 })
done()
return
end
if latest_snap.digest ~= snap.digest then
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
done()
return
end
local started = BookOrbitBookSync.run{
api = api_opts,
snap = latest_snap,
reason = "manual",
interactive = true,
plugin = self,
annotation_sync = self.settings.annotation_sync,
skip_progress = skip_progress,
on_finish = done,
}
if started == false then
done()
end
end
local run = function()
local will_finish = self:reconcileProgressBeforeBookSync(snap.digest, run_book_sync)
if will_finish == false then
done()
end
end
if NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
end,
}
end
function BookOrbit:requestBookSnapshotSync(opts)
opts = opts or {}
if not opts.snap then return end
local api_opts = self:apiOpts()
self:submitSyncJob{
family = "book_snapshot",
label = opts.label or _("Book sync"),
source = opts.reason or "auto",
priority = opts.priority or SYNC_JOB_PRIORITY.auto,
interactive = opts.interactive == true,
async = true,
run = function(done)
local function run()
local started = BookOrbitBookSync.run{
api = api_opts,
snap = opts.snap,
reason = opts.reason,
interactive = opts.interactive == true,
synchronous = opts.synchronous == true,
plugin = self,
on_finish = function()
if opts.on_finish then
pcall(opts.on_finish)
end
done()
end,
annotation_sync = self.settings.annotation_sync,
}
if started == false then
done()
end
end
if opts.go_online then
NetworkMgr:goOnlineToRun(run)
return
end
if opts.ensure_networking and NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
end,
}
end
function BookOrbit:onStart() function BookOrbit:onStart()
if PluginShare.bookorbit_auto_open_done then return end if PluginShare.bookorbit_auto_open_done then return end
PluginShare.bookorbit_auto_open_done = true PluginShare.bookorbit_auto_open_done = true
@@ -279,28 +709,92 @@ end
function BookOrbit:onReaderReady() function BookOrbit:onReaderReady()
if self.settings.auto_sync then if self.settings.auto_sync then
UIManager:nextTick(function() UIManager:nextTick(function()
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then if self:shouldSkipAutoSyncOffline("reader_ready") then return end
return local digest = self:getDocumentDigest()
end if self.settings.annotation_sync then
self:getProgress(true, false) if self:isOpenBookMatched(digest) then
end) self:scheduleOpenAnnotationSync(digest, "annotation_open")
if self.settings.annotation_sync then
UIManager:scheduleIn(2, function()
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then
return
end end
self:exchangeAnnotationsForOpenBook() end
end) self:requestOpenBookMatch("reader_ready", {
end schedule_annotation_sync = self.settings.annotation_sync,
annotation_digest = digest,
})
end)
end end
self:registerEvents() self:registerEvents()
self.last_page = self.ui:getCurrentPage() self.last_page = self.ui:getCurrentPage()
end end
local function titleFromFile(file)
if not file or file == "" then return nil end
local name = file:gsub(".*/", "")
if name == "" then return nil end
local title = name:gsub("%.[^%.]+$", "")
return title ~= "" and title or name
end
function BookOrbit:matchOpenBookForAutoSync(on_done)
if not self:isLoggedIn() or not self.ui or not self.ui.document then
if on_done then on_done(false) end
return
end
local digest = self:getDocumentDigest()
if not digest then
if on_done then on_done(false) end
return
end
local state = BookOrbitState.open()
if state:getBook(digest) then
if on_done then on_done(true) end
return
end
local run = function()
if not self.ui or not self.ui.document then
if on_done then on_done(false) end
return
end
local body, err = self:newClient():matchCheck({ digest }, {
[digest] = {
title = titleFromFile(self.ui.document.file),
source = "current_file",
},
})
if not body then
self:recordSyncError("match_open_book", err)
if on_done then on_done(false) end
return
end
local matched = false
for _, match in ipairs(body.matches or {}) do
if match.hash == digest then
state:setMatched(match.hash, match.bookFileId, match.bookId, self.ui.document.file)
matched = true
break
end
end
state:flush()
if on_done then on_done(matched) end
end
if self:shouldSkipAutoSyncOffline("match_open_book") then
if on_done then on_done(false) end
return
end
if NetworkMgr:willRerunWhenConnected(run) then
return
end
run()
end
-- Two-way annotation pull/push for the open book. Runs once per book open; -- Two-way annotation pull/push for the open book. Runs once per book open;
-- safe to call again manually, guarded against concurrent syncs. -- safe to call again manually, guarded against concurrent syncs.
function BookOrbit:exchangeAnnotationsForOpenBook() function BookOrbit:exchangeAnnotationsForOpenBook(reason, retry_count)
if self.annotation_exchange_running then return end if self.annotation_exchange_running then return end
if not self:isLoggedIn() or not self.ui or not self.ui.document then return end if not self:isLoggedIn() or not self.ui or not self.ui.document then return end
if BookOrbitBookSync.isRunning() or BookOrbitSweep.isRunning() then return end if BookOrbitBookSync.isRunning() or BookOrbitSweep.isRunning() then return end
@@ -310,6 +804,11 @@ function BookOrbit:exchangeAnnotationsForOpenBook()
local state = BookOrbitState.open() local state = BookOrbitState.open()
if not state:getBook(digest) then if not state:getBook(digest) then
-- Unknown or unmatched book: the close-path snapshot sync matches it. -- Unknown or unmatched book: the close-path snapshot sync matches it.
self:recordHighlightSync({
event = "open_book",
reason = reason or "auto",
skipped = 1,
}, "unmatched")
return return
end end
@@ -325,13 +824,36 @@ function BookOrbit:exchangeAnnotationsForOpenBook()
if not ok then if not ok then
logger.err("BookOrbit: annotation exchange error:", result) logger.err("BookOrbit: annotation exchange error:", result)
self:recordHighlightSync({
event = "open_book",
reason = reason or "auto",
failed = 1,
}, "partial_failure")
elseif result then elseif result then
local touched = (result.applied or 0) + (result.deleted or 0) local summary = BookOrbitHighlightSummary.add({
if touched > 0 then event = "open_book",
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"), touched)) reason = reason or "auto",
}, result)
self:recordHighlightSync(summary)
if BookOrbitHighlightSummary.hasRemoteChanges(summary) then
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"),
summary.applied + summary.deleted))
end
if reason == "annotation_open" and (summary.failed or 0) > 0 then
self:scheduleOpenHighlightRetry(reason, retry_count or 0)
elseif (summary.failed or 0) == 0 then
self.open_highlight_retry_status = nil
end
elseif err then
self:recordHighlightSync({
event = "open_book",
reason = reason or "auto",
skipped = err == "unmatched" and 1 or 0,
failed = (err == "network" or err == "unsupported_server") and 1 or 0,
}, err)
if err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
logger.dbg("BookOrbit: annotation exchange skipped:", err)
end end
elseif err and err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
logger.dbg("BookOrbit: annotation exchange skipped:", err)
end end
end end
@@ -382,23 +904,7 @@ function BookOrbit:startSweep()
promptLogin() promptLogin()
return return
end end
if BookOrbitBookSync.isRunning() then self:requestSweep(true, "manual")
UIManager:show(InfoMessage:new{ text = _("BookOrbit is syncing the current book, try again shortly."), timeout = 2 })
return
end
local sweep_opts = {
api = self:apiOpts(),
interactive = true,
annotation_sync = self.settings.annotation_sync,
on_finish = function(err)
if not err then self:maybeCheckForUpdate(false) end
end,
}
if NetworkMgr:willRerunWhenOnline(function() BookOrbitSweep.run(sweep_opts) end) then
return
end
BookOrbitSweep.run(sweep_opts)
end end
function BookOrbit:onBookOrbitSyncBook() function BookOrbit:onBookOrbitSyncBook()
@@ -407,11 +913,7 @@ function BookOrbit:onBookOrbitSyncBook()
return return
end end
if not self.ui or not self.ui.document then if not self.ui or not self.ui.document then
UIManager:show(InfoMessage:new{ text = _("Open a book to sync it."), timeout = 2 }) UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
return
end
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
return return
end end
@@ -421,27 +923,7 @@ function BookOrbit:onBookOrbitSyncBook()
return return
end end
local api_opts = self:apiOpts() self:requestManualBookSync(snap)
local run_book_sync = function(skip_progress)
local latest_snap = BookOrbitBookSync.capture(self)
if not latest_snap then
UIManager:show(InfoMessage:new{ text = _("Could not read this book's data."), timeout = 2 })
return
end
if latest_snap.digest ~= snap.digest then
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
return
end
BookOrbitBookSync.run{ api = api_opts, snap = latest_snap, reason = "manual", interactive = true, plugin = self,
annotation_sync = self.settings.annotation_sync, skip_progress = skip_progress }
end
local run = function()
self:reconcileProgressBeforeBookSync(snap.digest, run_book_sync)
end
if NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
end end
-- Events -- Events
@@ -453,10 +935,7 @@ function BookOrbit:_onCloseDocument()
UIManager:unschedule(self.periodic_push_task) UIManager:unschedule(self.periodic_push_task)
self.periodic_push_scheduled = false self.periodic_push_scheduled = false
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then if self:shouldSkipAutoSyncOffline("close") then return end
logger.dbg("BookOrbit: close sync skipped, another sync is running")
return
end
-- Snapshot now: reader objects die after this handler returns. ReaderUI -- Snapshot now: reader objects die after this handler returns. ReaderUI
-- already flushed the sidecar and statistics before broadcasting -- already flushed the sidecar and statistics before broadcasting
@@ -464,11 +943,14 @@ function BookOrbit:_onCloseDocument()
local snap = BookOrbitBookSync.capture(self) local snap = BookOrbitBookSync.capture(self)
if not snap then return end if not snap then return end
local api_opts = self:apiOpts() self:requestBookSnapshotSync{
NetworkMgr:goOnlineToRun(function() snap = snap,
BookOrbitBookSync.run{ api = api_opts, snap = snap, reason = "close", interactive = false, reason = "close",
annotation_sync = self.settings.annotation_sync } label = _("Close sync"),
end) priority = SYNC_JOB_PRIORITY.lifecycle,
interactive = false,
go_online = true,
}
end end
function BookOrbit:_onPageUpdate(page) function BookOrbit:_onPageUpdate(page)
@@ -491,8 +973,9 @@ function BookOrbit:_onResume()
if Device:hasWifiRestore() and NetworkMgr.wifi_was_on and G_reader_settings:isTrue("auto_restore_wifi") then if Device:hasWifiRestore() and NetworkMgr.wifi_was_on and G_reader_settings:isTrue("auto_restore_wifi") then
return return
end end
if self:shouldSkipAutoSyncOffline("resume") then return end
UIManager:scheduleIn(1, function() UIManager:scheduleIn(1, function()
self:getProgress(true, false) self:requestProgressPull(true, false, "resume")
end) end)
end end
@@ -502,51 +985,48 @@ function BookOrbit:_onSuspend()
self.periodic_push_scheduled = false self.periodic_push_scheduled = false
if not self:isLoggedIn() then return end if not self:isLoggedIn() then return end
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then return end if self:shouldSkipAutoSyncOffline("suspend") then return end
local snap = BookOrbitBookSync.capture(self) local snap = BookOrbitBookSync.capture(self)
if not snap then return end if not snap then return end
local on_finish local on_finish
if Device:hasWifiManager() then if Device:hasWifiManager() and not self:getSyncCoordinator():isBusy() then
on_finish = function() NetworkMgr:disableWifi() end on_finish = function() NetworkMgr:disableWifi() end
end end
local api_opts = self:apiOpts() self:requestBookSnapshotSync{
-- Synchronous: the device is about to sleep, so the uploads must not be snap = snap,
-- parked on the scheduler (they would run after resume and then kill reason = "suspend",
-- wifi mid-read via on_finish). label = _("Suspend sync"),
local run = function() priority = SYNC_JOB_PRIORITY.lifecycle,
BookOrbitBookSync.run{ interactive = false,
api = api_opts, snap = snap, reason = "suspend", synchronous = true,
interactive = false, synchronous = true, plugin = self, on_finish = on_finish, ensure_networking = true,
annotation_sync = self.settings.annotation_sync, on_finish = on_finish,
} }
end
if NetworkMgr:willRerunWhenOnline(run) then
return
end
run()
end end
function BookOrbit:_onNetworkConnected() function BookOrbit:_onNetworkConnected()
logger.dbg("BookOrbit: onNetworkConnected") logger.dbg("BookOrbit: onNetworkConnected")
UIManager:scheduleIn(0.5, function() UIManager:scheduleIn(0.5, function()
self:getProgress(false, false) if self:shouldSkipAutoSyncOffline("network_connected") then return end
self:maybeCheckForUpdate(false) self:requestProgressPull(false, false, "network_connected")
self:requestUpdateCheck(false, "network_connected")
end) end)
end end
function BookOrbit:_onNetworkDisconnecting() function BookOrbit:_onNetworkDisconnecting()
logger.dbg("BookOrbit: onNetworkDisconnecting") logger.dbg("BookOrbit: onNetworkDisconnecting")
self:updateProgress(false, false) if self:shouldSkipAutoSyncOffline("network_disconnecting") then return end
self:requestProgressPush(false, false, "network_disconnecting", SYNC_JOB_PRIORITY.lifecycle)
end end
function BookOrbit:onBookOrbitPushProgress() function BookOrbit:onBookOrbitPushProgress()
self:updateProgress(true, true) self:requestProgressPush(true, true, "manual")
end end
function BookOrbit:onBookOrbitPullProgress() function BookOrbit:onBookOrbitPullProgress()
self:getProgress(true, true) self:requestProgressPull(true, true, "manual")
end end
function BookOrbit:onBookOrbitSyncNow() function BookOrbit:onBookOrbitSyncNow()
@@ -573,13 +1053,13 @@ function BookOrbit:onBookOrbitToggleAutoSync(toggle, from_menu)
self:registerEvents() self:registerEvents()
if self.settings.auto_sync then if self.settings.auto_sync then
self:getProgress(true, true) self:requestProgressPull(true, true, "auto_enabled")
else else
UIManager:unschedule(self.periodic_push_task) UIManager:unschedule(self.periodic_push_task)
self.periodic_push_scheduled = false self.periodic_push_scheduled = false
self.page_update_counter = 0 self.page_update_counter = 0
if from_menu then if from_menu then
self:updateProgress(true, true) self:requestProgressPush(true, true, "auto_disabled")
end end
end end
@@ -0,0 +1,174 @@
local sidecar_annotations = {}
local saved_annotations
local marked_external = false
local flushed = false
package.loaded["docsettings"] = {
open = function()
return {
readSetting = function(_, key)
if key == "annotations" then return sidecar_annotations end
end,
saveSetting = function(_, key, value)
if key == "annotations" then saved_annotations = value end
end,
makeTrue = function(_, key)
if key == "annotations_externally_modified" then marked_external = true end
end,
flush = function()
flushed = true
end,
}
end,
}
package.loaded["ui/event"] = {
new = function(_, name, payload)
return { name = name, payload = payload }
end,
}
local dirty_scope
package.loaded["ui/uimanager"] = {
setDirty = function(_, scope)
dirty_scope = scope
end,
}
package.loaded["logger"] = {
dbg = function() end,
}
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["bookorbit_sidecar"] = {
normalizeAnnotations = function()
return {}, ""
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BookOrbitAnnotations = require("bookorbit_annotations")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local handled_event
local ui = {
rolling = true,
document = {},
annotation = {
annotations = {
{
datetime = "2026-07-08 09:10:11",
datetime_updated = "2026-07-08 09:10:11",
drawer = "lighten",
color = "yellow",
text = "same highlight",
note = "old note",
page = "/body/p[1]",
pos0 = "/body/p[1]",
pos1 = "/body/p[1].14",
pageno = 4,
},
},
addItem = function()
error("redelivery must not insert a duplicate live annotation")
end,
},
handleEvent = function(_, event)
handled_event = event
end,
}
local applied, deleted, touched = BookOrbitAnnotations.applyLive(ui, {
add = {
{
serverId = 10,
version = 5,
datetime = "2026-07-08 09:10:11",
datetimeUpdated = "2026-07-08 09:12:00",
drawer = "lighten",
color = "blue",
text = "same highlight",
note = "new note",
posFormat = "xpointer",
pos0 = "/body/p[1]",
pos1 = "/body/p[1].14",
},
},
})
assertEqual(#ui.annotation.annotations, 1, "live redelivery keeps one annotation")
assertEqual(ui.annotation.annotations[1].color, "blue", "live redelivery updates color")
assertEqual(ui.annotation.annotations[1].note, "new note", "live redelivery updates note")
assertEqual(applied[1].status, "applied", "live redelivery is acked")
assertEqual(applied[1].verified, true, "live redelivery is verified")
assertEqual(applied[1].pageno, 4, "live redelivery acks existing page")
assertEqual(#deleted, 0, "live redelivery has no deletes")
assertEqual(touched, 1, "live redelivery touches existing item")
assertEqual(handled_event.name, "AnnotationsModified", "live redelivery sends modified event")
assertEqual(handled_event.payload.index_modified, 1, "live redelivery sends modified index")
assertEqual(dirty_scope, "all", "live redelivery dirties UI")
sidecar_annotations = {
{
text = "fallback highlight",
note = "old sidecar note",
color = "yellow",
page = "/body/p[2]",
pos0 = "/body/p[2]",
pos1 = "/body/p[2].18",
pageno = 9,
},
}
saved_annotations = nil
marked_external = false
flushed = false
applied, deleted, touched = BookOrbitAnnotations.applySidecar("/tmp/book.epub", {
add = {
{
serverId = 11,
version = 2,
datetime = "2026-07-08 10:00:00",
datetimeUpdated = "2026-07-08 10:01:00",
drawer = "lighten",
color = "green",
text = "fallback highlight",
note = "new sidecar note",
posFormat = "xpointer",
pos0 = "/body/p[2]",
pos1 = "/body/p[2].18",
pageno = 9,
},
},
})
assertEqual(#sidecar_annotations, 1, "sidecar fallback keeps one annotation")
assertEqual(sidecar_annotations[1].datetime, "2026-07-08 10:00:00", "sidecar fallback stabilizes datetime")
assertEqual(sidecar_annotations[1].color, "green", "sidecar fallback updates color")
assertEqual(sidecar_annotations[1].note, "new sidecar note", "sidecar fallback updates note")
assertEqual(applied[1].status, "applied", "sidecar redelivery is acked")
assertEqual(applied[1].verified, false, "sidecar redelivery stays unverified")
assertEqual(#deleted, 0, "sidecar redelivery has no deletes")
assertEqual(touched, 1, "sidecar redelivery touches existing item")
assertEqual(saved_annotations, sidecar_annotations, "sidecar redelivery saves annotations")
assertEqual(marked_external, true, "sidecar redelivery marks external modification")
assertEqual(flushed, true, "sidecar redelivery flushes settings")
print("bookorbit_annotations_idempotency_test.lua: ok")
@@ -0,0 +1,123 @@
package.loaded["docsettings"] = {
open = function()
error("DocSettings should not be used by live apply")
end,
}
package.loaded["ui/event"] = {
new = function(_, name, payload)
return { name = name, payload = payload }
end,
}
local dirty_scope
local dirty_mode
package.loaded["ui/uimanager"] = {
setDirty = function(_, scope, mode)
dirty_scope = scope
dirty_mode = mode
end,
}
package.loaded["logger"] = {
dbg = function() end,
}
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["bookorbit_sidecar"] = {
normalizeAnnotations = function()
return {}, ""
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BookOrbitAnnotations = require("bookorbit_annotations")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local handled_event
local footer_updates = 0
local ui = {
rolling = true,
document = {
isXPointerInDocument = function()
return true
end,
getTextFromXPointers = function()
return "fresh web highlight"
end,
getPageFromXPointer = function()
return 12
end,
},
annotation = {
annotations = {},
addItem = function(self, item)
item.pageno = 12
table.insert(self.annotations, item)
return #self.annotations
end,
},
view = {
footer = {
maybeUpdateFooter = function()
footer_updates = footer_updates + 1
end,
},
},
handleEvent = function(_, event)
handled_event = event
end,
}
local applied, deleted, touched, deleted_touched = BookOrbitAnnotations.applyLive(ui, {
add = {
{
serverId = 42,
version = 3,
datetime = "2026-07-08 09:10:11",
datetimeUpdated = "2026-07-08 09:12:00",
drawer = "lighten",
color = "yellow",
text = "fresh web highlight",
note = "from web reader",
chapter = "Chapter 1",
posFormat = "xpointer",
pos0 = "/body/DocFragment[1]/p[1]/text().0",
pos1 = "/body/DocFragment[1]/p[1]/text().19",
},
},
})
assertEqual(#ui.annotation.annotations, 1, "remote highlight is inserted")
assertEqual(applied[1].status, "applied", "remote highlight is acked as applied")
assertEqual(applied[1].verified, true, "remote highlight is verified")
assertEqual(applied[1].pageno, 12, "ack includes inserted pageno")
assertEqual(#deleted, 0, "no deletes are emitted")
assertEqual(touched, 1, "one annotation is touched")
assertEqual(deleted_touched, 0, "no deleted touch is counted")
assertEqual(footer_updates, 1, "footer is refreshed")
assertEqual(dirty_scope, "all", "UI is dirtied")
assertEqual(dirty_mode, "ui", "UI dirty mode is set")
assertEqual(handled_event.name, "AnnotationsModified", "annotations modified event is sent")
assertEqual(handled_event.payload[1], ui.annotation.annotations[1], "event points at inserted item")
assertEqual(handled_event.payload.nb_highlights_added, 1, "event increments highlight count")
assertEqual(handled_event.payload.index_modified, 1, "event carries inserted index")
print("bookorbit_annotations_live_apply_test.lua: ok")
+138
View File
@@ -0,0 +1,138 @@
local mock_http_body = "{}"
local mock_http_code = 200
package.loaded["logger"] = {
dbg = function() end,
}
package.loaded["util"] = {
trim = function(value)
return tostring(value or ""):match("^%s*(.-)%s*$")
end,
urlEncode = function(value)
return tostring(value)
end,
removeFile = function() end,
}
package.loaded["socketutil"] = {
LARGE_BLOCK_TIMEOUT = 1,
LARGE_TOTAL_TIMEOUT = 1,
FILE_BLOCK_TIMEOUT = 1,
FILE_TOTAL_TIMEOUT = 1,
set_timeout = function() end,
reset_timeout = function() end,
}
package.loaded["socket"] = {
skip = function(n, ...)
local values = { ... }
local shifted = {}
for index = n + 1, #values do
table.insert(shifted, values[index])
end
return unpack(shifted)
end,
}
package.loaded["ltn12"] = {
sink = {
table = function(target)
return function(chunk)
if chunk then table.insert(target, chunk) end
return 1
end
end,
file = function(file)
return function(chunk)
if chunk then file:write(chunk) else file:close() end
return 1
end
end,
},
source = {
string = function(value)
local pending = value
return function()
local chunk = pending
pending = nil
return chunk
end
end,
},
}
package.loaded["socket.http"] = {
request = function(request)
if mock_http_body then
request.sink(mock_http_body)
end
return 1, mock_http_code, {}, "HTTP " .. tostring(mock_http_code)
end,
}
local rapidjson_null = {}
package.loaded["rapidjson"] = {
null = rapidjson_null,
encode = function()
return "{}"
end,
decode = function(raw)
if raw == "{}" then return {} end
if raw == "{\"ok\":true}" then return { ok = true } end
if raw == "{\"value\":null}" then return { value = rapidjson_null } end
if raw == "null" then return rapidjson_null end
return nil, "parse error"
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local BookOrbitApi = require("bookorbit_api")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local decoded, err = BookOrbitApi.decodeResponse({ "" })
assertEqual(type(decoded), "table", "empty body decodes to table")
assertEqual(err, nil, "empty body has no error")
decoded, err = BookOrbitApi.decodeResponse({ "{\"value\":null}" })
assertEqual(decoded.value, nil, "JSON null is scrubbed")
assertEqual(err, nil, "valid JSON has no error")
decoded, err = BookOrbitApi.decodeResponse({ "not-json" })
assertEqual(decoded, nil, "invalid JSON has no decoded body")
assertEqual(err, "invalid_json", "invalid JSON error code")
local client = BookOrbitApi.new{
server_url = "https://bookorbit.example.com/api/v1",
username = "reader",
userkey = "secret",
}
mock_http_body = "{\"ok\":true}"
mock_http_code = 200
local body
body, err = client:auth()
assertEqual(body.ok, true, "request returns decoded body")
assertEqual(err, nil, "valid request has no error")
mock_http_body = "not-json"
mock_http_code = 200
body, err = client:auth()
assertEqual(body, nil, "invalid success body has no decoded body")
assertEqual(err, "invalid_json", "invalid success body returns invalid_json")
mock_http_body = "not-json"
mock_http_code = 503
local errbody
body, err, errbody = client:auth()
assertEqual(body, nil, "HTTP error has no decoded body")
assertEqual(err, 503, "HTTP error preserves status code")
assertEqual(errbody, nil, "invalid HTTP error body is ignored")
print("bookorbit_api_test.lua: ok")
@@ -0,0 +1,111 @@
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
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["bookorbit_api"] = {}
package.loaded["bookorbit_sweep"] = {
syncStatus = function()
return { lastSweepAt = 0, matched = 0, unmatched = 0 }
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local MainMenu = require("bookorbit_main_menu")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function hasRow(rows, key)
for _, row in ipairs(rows) do
if type(row) == "table" and row[1] == key then return true end
end
return false
end
local function newPlugin(logged_in)
local plugin = {
SYNC_STRATEGY = { PROMPT = 1, SILENT = 2, DISABLE = 3 },
settings = {
auto_sync = true,
annotation_sync = true,
},
PLUGIN_VERSION = "test",
device_id = "123456789",
isLoggedIn = function()
return logged_in
end,
getSyncCoordinatorStatus = function()
return { pending_count = 0 }
end,
}
MainMenu.install(plugin)
return plugin
end
local plugin = newPlugin(false)
local rows = plugin:diagnosticsRows()
assertEqual(hasRow(rows, "Server"), true, "logged-out diagnostics shows setup rows")
assertEqual(hasRow(rows, "Open highlights"), false, "logged-out diagnostics hides sync state")
assertEqual(hasRow(rows, "Last highlight sync"), false, "logged-out diagnostics hides highlight summary")
assertEqual(hasRow(rows, "Retry highlight sync"), false, "logged-out diagnostics hides highlight retry")
assertEqual(hasRow(rows, "Retry open-book match"), false, "logged-out diagnostics hides match retry")
assertEqual(hasRow(rows, "Test connection"), false, "logged-out diagnostics hides connection action without credentials")
plugin.settings.server_url = "https://bookorbit.example.com"
plugin.settings.username = "reader"
plugin.settings.userkey = "secret"
rows = plugin:diagnosticsRows()
assertEqual(hasRow(rows, "Test connection"), true, "logged-out diagnostics shows connection action with credentials")
plugin = newPlugin(true)
rows = plugin:diagnosticsRows()
assertEqual(hasRow(rows, "Retry highlight sync"), false, "file manager diagnostics hides highlight retry")
assertEqual(hasRow(rows, "Retry open-book match"), false, "file manager diagnostics hides match retry")
assertEqual(hasRow(rows, "Test connection"), true, "file manager diagnostics keeps connection action")
assertEqual(hasRow(rows, "Open highlights"), true, "logged-in diagnostics shows sync state")
assertEqual(hasRow(rows, "Last highlight sync"), true, "logged-in diagnostics shows highlight summary")
plugin.ui = { document = {} }
plugin.getDocumentDigest = function()
return "digest"
end
plugin.isOpenBookMatched = function()
return true
end
rows = plugin:diagnosticsRows()
assertEqual(hasRow(rows, "Retry highlight sync"), true, "reader diagnostics shows highlight retry")
assertEqual(hasRow(rows, "Retry open-book match"), true, "reader diagnostics shows match retry")
plugin.settings.annotation_sync = false
rows = plugin:diagnosticsRows()
assertEqual(hasRow(rows, "Retry highlight sync"), false, "two-way-off diagnostics hides highlight retry")
assertEqual(hasRow(rows, "Retry open-book match"), true, "two-way-off diagnostics keeps match retry")
print("bookorbit_diagnostics_rows_test.lua: ok")
@@ -0,0 +1,62 @@
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local Diagnostics = require("bookorbit_highlight_diagnostics")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
assertEqual(Diagnostics.openHighlightsText{ annotation_sync = false, has_open_book = true }, "two-way sync off", "disabled state")
assertEqual(Diagnostics.openHighlightsText{ annotation_sync = true }, "no reader book", "no open book state")
assertEqual(Diagnostics.openHighlightsText{ annotation_sync = true, has_open_book = true, matched = true }, "matched, idle", "matched idle state")
assertEqual(Diagnostics.openHighlightsText{
annotation_sync = true,
has_open_book = true,
matched = true,
scheduler_status = { phase = "scheduled" },
}, "matched, sync scheduled", "matched scheduled state")
assertEqual(Diagnostics.openHighlightsText{
annotation_sync = true,
has_open_book = true,
matched = false,
scheduler_status = { phase = "matching" },
}, "unmatched, retrying match", "match retry state")
assertEqual(Diagnostics.openHighlightsText{
annotation_sync = true,
has_open_book = true,
matched = false,
last_highlight_sync = { skipped = 1 },
}, "unmatched, skipped", "unmatched skipped state")
assertEqual(Diagnostics.retryText(), "none", "empty retry state")
assertEqual(Diagnostics.retryText{ pending = true }, "pending", "pending retry state")
local text = Diagnostics.lastSyncText({
uploaded = 1,
applied = 2,
deleted = 3,
failed = 4,
skipped = 5,
at = 123,
}, function()
return "14:31"
end)
assertEqual(text, "1 uploaded, 2 applied, 3 deleted, 4 failed, 5 skipped at 14:31", "last sync summary")
assertEqual(Diagnostics.lastSyncText(nil), "never", "missing summary")
print("bookorbit_highlight_diagnostics_test.lua: ok")
@@ -0,0 +1,55 @@
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local HighlightSummary = require("bookorbit_highlight_summary")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local summary = HighlightSummary.normalize{
event = "book_sync",
reason = "manual",
uploaded = "2",
applied = 1,
deleted = nil,
}
assertEqual(summary.uploaded, 2, "uploaded is normalized")
assertEqual(summary.deleted, 0, "missing deleted count becomes zero")
summary = HighlightSummary.add(summary, { uploaded = 3, applied = 2, deleted = 1 }, { closed_book = true })
assertEqual(summary.uploaded, 5, "uploaded is aggregated")
assertEqual(summary.applied, 3, "applied is aggregated")
assertEqual(summary.deleted, 1, "deleted is aggregated")
assertEqual(summary.touched_books, 1, "closed book touch is counted")
summary = HighlightSummary.add(summary, { had_errors = true }, { skipped = 2 })
assertEqual(summary.failed, 1, "had_errors increments failed when no failed count exists")
assertEqual(summary.skipped, 2, "skipped is aggregated")
assertEqual(HighlightSummary.hasCounts(summary), true, "summary has counts")
assertEqual(HighlightSummary.hasRemoteChanges(summary), true, "summary has remote changes")
assertEqual(HighlightSummary.actionableError(summary), "partial_failure", "failed count is actionable")
assertEqual(HighlightSummary.actionableError({}, "unsupported_server"), "unsupported_server", "unsupported server is actionable")
assertEqual(HighlightSummary.actionableError({ skipped = 1 }, "unmatched"), nil, "unmatched skip is not actionable")
local message = HighlightSummary.message(summary)
assertEqual(message:match("Highlights synced: 5 uploaded, 3 applied, 1 deleted%.") ~= nil, true, "message has concise counts")
assertEqual(message:match("Failed: 1%. Skipped: 2%.") ~= nil, true, "message has failure counts")
assertEqual(message:match("1 closed book%(s%) updated%.") ~= nil, true, "message has closed book count")
print("bookorbit_highlight_summary_test.lua: ok")
@@ -0,0 +1,229 @@
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
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["bookorbit_api"] = {}
package.loaded["bookorbit_sweep"] = {
syncStatus = function()
return { lastSweepAt = 0, matched = 0, unmatched = 0 }
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local MainMenu = require("bookorbit_main_menu")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function itemText(item)
if item.text then return item.text end
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
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)
end
local function menuItems(plugin)
local menu_items = {}
plugin:addToMainMenu(menu_items)
return menu_items.bookorbit.sub_item_table
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
end
local plugin = newPlugin(false)
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")
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")
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 highlight sync"), false, "logged-in top level hides highlight 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")
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 highlight sync"), true, "file manager sync settings has highlight 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")
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")
local dashboard_sync_settings = findItem(dashboard_settings.sub_item_table, "Sync")
assertEqual(hasItem(dashboard_sync_settings.sub_item_table, "Two-way highlight sync"), true, "dashboard sync settings has highlight 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")
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")
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 highlight sync"), true, "sync settings has highlight 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 highlight sync"), false, "top level hides highlight 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")
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")
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")
print("bookorbit_menu_context_test.lua: ok")
@@ -0,0 +1,113 @@
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local OpenAnnotationScheduler = require("bookorbit_open_annotation_scheduler")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function newHarness()
local timers = {}
local scheduler = OpenAnnotationScheduler.new{
delay = 2,
schedule_in = function(delay, callback)
table.insert(timers, { delay = delay, callback = callback })
end,
}
local harness = {
timers = timers,
scheduler = scheduler,
}
function harness:runNext()
local timer = table.remove(self.timers, 1)
if timer then
timer.callback()
end
end
return harness
end
local function newPlugin(opts)
opts = opts or {}
local plugin = {
digest = opts.digest or "digest-a",
matched = opts.matched == true,
match_results = opts.match_results or {},
exchanges = {},
unmatched = 0,
match_calls = 0,
}
function plugin:getDocumentDigest()
return self.digest
end
function plugin:shouldSkipAutoSyncOffline()
return false
end
function plugin:isOpenBookMatched()
return self.matched
end
function plugin:requestAnnotationExchange(reason)
table.insert(self.exchanges, reason)
end
function plugin:recordOpenAnnotationUnmatched(reason)
self.unmatched = self.unmatched + 1
self.unmatched_reason = reason
end
function plugin:matchOpenBookForAutoSync(callback)
self.match_calls = self.match_calls + 1
local matched = table.remove(self.match_results, 1) == true
self.matched = matched
callback(matched)
end
return plugin
end
local harness = newHarness()
local plugin = newPlugin{ matched = true }
harness.scheduler:schedule(plugin, "digest-a", "annotation_open")
harness.scheduler:schedule(plugin, "digest-a", "annotation_open")
assertEqual(#harness.timers, 2, "replacement keeps old timer callback queued")
assertEqual(harness.timers[1].delay, 2, "open annotation delay is used")
harness:runNext()
assertEqual(#plugin.exchanges, 0, "replaced timer does not run exchange")
harness:runNext()
assertEqual(#plugin.exchanges, 1, "latest timer runs one exchange")
assertEqual(plugin.exchanges[1], "annotation_open", "exchange uses open annotation reason")
harness = newHarness()
plugin = newPlugin{ matched = false, match_results = { true } }
harness.scheduler:schedule(plugin, "digest-a", "annotation_open")
harness:runNext()
assertEqual(plugin.match_calls, 1, "unmatched fire retries match once")
assertEqual(#plugin.exchanges, 0, "match success reschedules instead of exchanging immediately")
assertEqual(#harness.timers, 1, "match success schedules another settled sync")
harness:runNext()
assertEqual(#plugin.exchanges, 1, "newly matched book exchanges after second delay")
harness = newHarness()
plugin = newPlugin{ matched = false, match_results = { false } }
harness.scheduler:schedule(plugin, "digest-a", "annotation_open")
harness:runNext()
assertEqual(plugin.match_calls, 1, "unmatched book gets one match retry")
assertEqual(plugin.unmatched, 1, "failed retry records unmatched")
assertEqual(plugin.unmatched_reason, "annotation_open", "unmatched keeps reason")
assertEqual(#plugin.exchanges, 0, "failed retry does not exchange")
harness = newHarness()
plugin = newPlugin{ matched = true }
harness.scheduler:schedule(plugin, "digest-a", "annotation_open")
plugin.digest = "digest-b"
harness:runNext()
assertEqual(#plugin.exchanges, 0, "stale timer does not sync a different open book")
assertEqual(plugin.unmatched, 0, "stale timer does not record unmatched")
print("bookorbit_open_annotation_scheduler_test.lua: ok")
@@ -0,0 +1,90 @@
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
package.loaded["ui/uimanager"] = {
scheduleIn = function(_, _, callback)
callback()
end,
}
package.loaded["ui/network/manager"] = {
willRerunWhenConnected = function(_, callback)
callback()
return true
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local SyncCoordinator = require("bookorbit_sync_coordinator")
local OPEN_HIGHLIGHT_MAX_RETRIES = 2
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local plugin = {
runs = 0,
sync_coordinator = SyncCoordinator.new(),
}
function plugin:shouldSkipAutoSyncOffline()
return false
end
function plugin:submitSyncJob(job)
return self.sync_coordinator:submit(job)
end
function plugin:exchangeAnnotationsForOpenBook(_, retry_count)
self.runs = self.runs + 1
if retry_count < OPEN_HIGHLIGHT_MAX_RETRIES then
self:scheduleOpenHighlightRetry("annotation_open", retry_count)
end
end
function plugin:scheduleOpenHighlightRetry(reason, retry_count)
retry_count = retry_count or 0
if retry_count >= OPEN_HIGHLIGHT_MAX_RETRIES then return end
local UIManager = require("ui/uimanager")
local NetworkMgr = require("ui/network/manager")
UIManager:scheduleIn(8, function()
if self:shouldSkipAutoSyncOffline("annotation_retry") then return end
self:submitSyncJob{
family = "annotation_exchange",
label = "Highlight sync",
source = reason or "annotation_retry",
priority = SyncCoordinator.PRIORITY.auto,
interactive = false,
async = true,
run = function(done)
local execute = function()
self:exchangeAnnotationsForOpenBook(reason or "annotation_retry", retry_count + 1)
done()
end
if NetworkMgr:willRerunWhenConnected(execute) then
return
end
execute()
end,
}
end)
end
plugin:scheduleOpenHighlightRetry("annotation_open", 0)
assertEqual(plugin.runs, 2, "retry stops at max retry count")
print("bookorbit_open_highlight_retry_test.lua: ok")
@@ -0,0 +1,95 @@
package.loaded["gettext"] = function(text)
return text
end
package.loaded["ffi/util"] = {
template = function(text, ...)
local values = { ... }
return (text:gsub("%%(%d+)", function(index)
return tostring(values[tonumber(index)])
end))
end,
}
package.loaded["ui/widget/confirmbox"] = {}
package.loaded["device"] = {}
package.loaded["ui/event"] = { new = function() return {} end }
package.loaded["ui/widget/infomessage"] = { new = function(_, opts) return opts end }
package.loaded["optmath"] = { roundPercent = function(value) return value end }
package.loaded["ui/network/manager"] = {}
local shown_texts = {}
package.loaded["ui/uimanager"] = {
show = function(_, message)
table.insert(shown_texts, message.text)
end,
scheduleIn = function() end,
unschedule = function() end,
getElapsedTimeSinceBoot = function() return 0 end,
}
package.loaded["logger"] = { dbg = function() end }
package.loaded["ui/time"] = { s = function(value) return value end }
local partial_calls = 0
package.loaded["util"] = {
partialMD5 = function(file)
partial_calls = partial_calls + 1
return "computed:" .. tostring(file)
end,
}
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local ProgressSync = require("bookorbit_progress_sync")
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local plugin = {}
ProgressSync.install(plugin)
plugin.ui = {
document = {
file = "/tmp/book.epub",
info = { has_pages = true },
},
}
assertEqual(plugin:getDocumentDigest(), "computed:/tmp/book.epub", "digest computes without doc settings")
assertEqual(partial_calls, 1, "partial md5 called once")
local saved_digest
plugin.ui.doc_settings = {
readSetting = function()
return nil
end,
saveSetting = function(_, _, value)
saved_digest = value
end,
}
assertEqual(plugin:getDocumentDigest(), "computed:/tmp/book.epub", "digest computes with empty doc settings")
assertEqual(saved_digest, "computed:/tmp/book.epub", "computed digest is cached when possible")
plugin.ui.doc_settings = {
readSetting = function()
return "cached"
end,
}
assertEqual(plugin:getDocumentDigest(), "cached", "cached digest is returned")
plugin.ui = nil
assertEqual(plugin:getDocumentDigest(), nil, "missing UI returns nil")
plugin.isLoggedIn = function()
return true
end
assertEqual(plugin:reconcileProgressBeforeBookSync("digest", function() end), false, "manual book sync rejects missing UI")
plugin:updateProgress(false, true)
plugin:getProgress(false, true)
assertEqual(shown_texts[1], "No reader book is open.", "manual book sync explains missing reader book")
assertEqual(shown_texts[2], "No reader book is open.", "interactive push explains missing reader book")
assertEqual(shown_texts[3], "No reader book is open.", "interactive pull explains missing reader book")
print("bookorbit_progress_digest_test.lua: ok")
@@ -0,0 +1,85 @@
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;" .. package.path
local SyncCoordinator = require("bookorbit_sync_coordinator")
local now = 1000
local function assertEqual(actual, expected, label)
if actual ~= expected then
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
end
end
local function newCoordinator()
return SyncCoordinator.new{
now = function()
return now
end,
}
end
local function asyncJob(label, priority, calls, done_ref)
return {
family = label,
label = label,
priority = priority,
async = true,
run = function(done)
table.insert(calls, label)
done_ref[label] = done
end,
}
end
local coordinator = newCoordinator()
local calls = {}
local done_ref = {}
coordinator:submit(asyncJob("current", SyncCoordinator.PRIORITY.auto, calls, done_ref))
assertEqual(calls[1], "current", "first job starts immediately")
assertEqual(coordinator:status().current.label, "current", "current status is exported")
coordinator:submit(asyncJob("progress_pull", SyncCoordinator.PRIORITY.auto, calls, done_ref))
coordinator:submit(asyncJob("progress_pull", SyncCoordinator.PRIORITY.auto, calls, done_ref))
assertEqual(coordinator:status().pending_count, 1, "same family coalesces to one pending job")
done_ref.current()
assertEqual(calls[2], "progress_pull", "coalesced job starts after current")
done_ref.progress_pull()
assertEqual(coordinator:status().pending_count, 0, "queue drains after coalesced job")
coordinator = newCoordinator()
calls = {}
done_ref = {}
coordinator:submit(asyncJob("current", SyncCoordinator.PRIORITY.auto, calls, done_ref))
coordinator:submit(asyncJob("annotation_exchange", SyncCoordinator.PRIORITY.auto, calls, done_ref))
coordinator:submit(asyncJob("progress_push", SyncCoordinator.PRIORITY.auto, calls, done_ref))
coordinator:submit(asyncJob("progress_push", SyncCoordinator.PRIORITY.manual, calls, done_ref))
assertEqual(coordinator:status().pending_count, 2, "manual replacement keeps one job per family")
assertEqual(coordinator:status().next.label, "progress_push", "manual job has next priority")
done_ref.current()
assertEqual(calls[2], "progress_push", "manual job runs before auto pending job")
done_ref.progress_push()
assertEqual(calls[3], "annotation_exchange", "remaining auto job runs after manual job")
coordinator = newCoordinator()
calls = {}
done_ref = {}
coordinator:submit(asyncJob("current", SyncCoordinator.PRIORITY.auto, calls, done_ref))
coordinator:submit(asyncJob("sweep", SyncCoordinator.PRIORITY.manual, calls, done_ref))
coordinator:submit(asyncJob("book_snapshot", SyncCoordinator.PRIORITY.lifecycle, calls, done_ref))
assertEqual(coordinator:status().next.label, "book_snapshot", "lifecycle job outranks manual pending job")
done_ref.current()
assertEqual(calls[2], "book_snapshot", "lifecycle job runs first")
coordinator = newCoordinator()
calls = {}
done_ref = {}
coordinator:submit(asyncJob("current", SyncCoordinator.PRIORITY.auto, calls, done_ref))
now = 1015
coordinator:submit(asyncJob("sweep", SyncCoordinator.PRIORITY.manual, calls, done_ref))
local status = coordinator:status()
assertEqual(status.current.label, "current", "status reports current label")
assertEqual(status.current.age, 15, "status reports current age")
assertEqual(status.pending_count, 1, "status reports pending count")
assertEqual(status.next.label, "sweep", "status reports next pending job")
print("bookorbit_sync_coordinator_test.lua: ok")
@@ -9,45 +9,63 @@ async function readPluginFile(name: string): Promise<string> {
} }
describe('KOReader plugin update source wiring', () => { describe('KOReader plugin update source wiring', () => {
it('keeps update status on the first BookOrbit menu page and groups advanced settings', async () => { it('keeps top-level actions separate from nested settings', async () => {
const menu = await readPluginFile('bookorbit_main_menu.lua'); const menu = await readPluginFile('bookorbit_main_menu.lua');
const updater = await readPluginFile('bookorbit_updater.lua'); const updater = await readPluginFile('bookorbit_updater.lua');
const dashboardIndex = menu.indexOf('text = _("Open dashboard")'); const dashboardIndex = menu.indexOf('id = "open_dashboard"');
const updateRowIndex = menu.indexOf('return self:updateCheckMenuText()'); const updateRowIndex = menu.indexOf('self:updateCheckMenuText()');
const lastSyncIndex = menu.indexOf('return T(_("Last sync: %1")'); const syncThisIndex = menu.indexOf('text = _("Sync current book now")');
const syncThisIndex = menu.indexOf('text = _("Sync this book now")'); const autoSyncIndex = menu.indexOf('text = _("Auto sync current book")');
const syncAllIndex = menu.indexOf('text = _("Sync all books now")'); const syncAllIndex = menu.indexOf('text = _("Sync all books now")');
const autoSyncIndex = menu.indexOf('text = _("Auto sync this book")'); const topLevelSettingsIndex = menu.indexOf('text = _("Settings")', dashboardIndex);
const twoWayIndex = menu.indexOf('text = _("Two-way highlight sync")'); const settingsMenuIndex = menu.indexOf('function MainMenu:settingsMenu');
const syncSettingsIndex = menu.indexOf('text = _("Sync settings")'); const dashboardSettingsIndex = menu.indexOf('text = _("Dashboard")', settingsMenuIndex);
const syncSettingsIndex = menu.indexOf('text = _("Sync")', settingsMenuIndex);
const pluginSettingsIndex = menu.indexOf('text = _("Plugin")', settingsMenuIndex);
const accountIndex = menu.indexOf('text = _("Account & setup")'); const accountIndex = menu.indexOf('text = _("Account & setup")');
const syncSettingsBlock = menu.slice(syncSettingsIndex, accountIndex); const topLevelActionBlock = menu.slice(dashboardIndex, topLevelSettingsIndex);
const autoSyncBlock = menu.slice(autoSyncIndex, syncSettingsIndex); const dashboardMenuBlock = menu.slice(menu.indexOf('function MainMenu:dashboardMenuItems'), menu.indexOf('function MainMenu:showDashboardMenu'));
const dashboardSettingsBlock = menu.slice(
menu.indexOf('function MainMenu:dashboardSettingsMenu'),
menu.indexOf('function MainMenu:syncSettingsMenu'),
);
const syncSettingsBlock = menu.slice(menu.indexOf('function MainMenu:syncSettingsMenu'), menu.indexOf('function MainMenu:pluginSettingsMenu'));
const pluginSettingsBlock = menu.slice(menu.indexOf('function MainMenu:pluginSettingsMenu'), settingsMenuIndex);
expect(updater).toContain('function UpdateCheck:updateCheckMenuText()'); expect(updater).toContain('function UpdateCheck:updateCheckMenuText()');
expect(dashboardIndex).toBeGreaterThan(0); expect(dashboardIndex).toBeGreaterThan(0);
expect(updateRowIndex).toBeGreaterThan(dashboardIndex); expect(dashboardIndex).toBeLessThan(syncThisIndex);
expect(updateRowIndex).toBeLessThan(lastSyncIndex); expect(syncThisIndex).toBeLessThan(autoSyncIndex);
expect(lastSyncIndex).toBeLessThan(syncThisIndex); expect(autoSyncIndex).toBeLessThan(syncAllIndex);
expect(syncThisIndex).toBeLessThan(syncAllIndex); expect(syncThisIndex).toBeLessThan(syncAllIndex);
expect(syncAllIndex).toBeLessThan(autoSyncIndex); expect(syncAllIndex).toBeLessThan(topLevelSettingsIndex);
expect(autoSyncIndex).toBeLessThan(twoWayIndex); expect(topLevelSettingsIndex).toBeLessThan(accountIndex);
expect(twoWayIndex).toBeLessThan(syncSettingsIndex); expect(dashboardSettingsIndex).toBeGreaterThan(settingsMenuIndex);
expect(syncSettingsIndex).toBeLessThan(accountIndex); expect(dashboardSettingsIndex).toBeLessThan(syncSettingsIndex);
expect(autoSyncBlock).toContain('separator = true'); expect(syncSettingsIndex).toBeLessThan(pluginSettingsIndex);
expect(syncSettingsBlock).toContain('return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())'); expect(topLevelActionBlock).not.toContain('self:updateCheckMenuText()');
expect(topLevelActionBlock).toContain('id = "sync_current_book"');
expect(topLevelActionBlock).toContain('id = "auto_sync_current_book"');
expect(topLevelActionBlock).toContain('text = _("Auto sync current book")');
expect(topLevelActionBlock).toContain('self:onBookOrbitToggleAutoSync(nil, true)');
expect(dashboardSettingsBlock).toContain('return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())');
expect(syncSettingsBlock).not.toContain('text = _("Auto sync current book")');
expect(syncSettingsBlock).toContain('text = _("Two-way highlight sync")');
expect(syncSettingsBlock).toContain('text = _("Skip auto-sync when offline")');
expect(syncSettingsBlock).not.toContain('return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())');
expect(syncSettingsBlock).toContain('return T(_("Periodically sync every # pages (%1)")'); expect(syncSettingsBlock).toContain('return T(_("Periodically sync every # pages (%1)")');
expect(syncSettingsBlock).toContain('return T(_("Sync to a newer state (%1)"), getNameStrategy(self.settings.sync_forward))'); expect(syncSettingsBlock).toContain('return T(_("Sync to a newer state (%1)"), getNameStrategy(self.settings.sync_forward))');
expect(syncSettingsBlock).toContain('return T(_("Sync to an older state (%1)"), getNameStrategy(self.settings.sync_backward))'); expect(syncSettingsBlock).toContain('return T(_("Sync to an older state (%1)"), getNameStrategy(self.settings.sync_backward))');
expect( expect(updateRowIndex).toBeGreaterThan(menu.indexOf('function MainMenu:pluginSettingsMenu'));
menu expect(pluginSettingsBlock).toContain('self:updateCheckMenuText()');
.slice(menu.indexOf('return T(_("Periodically sync every # pages (%1)")'), menu.indexOf('return T(_("Sync to a newer state (%1)")')) expect(pluginSettingsBlock).toContain('self:checkForUpdate()');
.includes('separator = true'), expect(dashboardMenuBlock).toContain('local plugin_update = self:pluginSettingsMenu()[1]');
).toBe(false); expect(dashboardMenuBlock).toContain('plugin_update.separator = true');
expect(dashboardMenuBlock).toContain('item.id ~= "sync_current_book"');
expect(dashboardMenuBlock).toContain('item.id ~= "auto_sync_current_book"');
expect(dashboardMenuBlock).toContain('include_plugin = false');
expect(menu.indexOf('keep_menu_open = true,\n callback = function()\n self:checkForUpdate()')).toBeGreaterThan( expect(menu.indexOf('self:checkForUpdate()')).toBeGreaterThan(0);
0,
);
expect(updater).toContain('return T(_("Plugin update available: v%1 -> v%2"), PLUGIN_VERSION, self.settings.update_latest_version)'); expect(updater).toContain('return T(_("Plugin update available: v%1 -> v%2"), PLUGIN_VERSION, self.settings.update_latest_version)');
expect(updater).toContain('return T(_("Installed plugin: v%1 (Check for update)"), PLUGIN_VERSION)'); expect(updater).toContain('return T(_("Installed plugin: v%1 (Check for update)"), PLUGIN_VERSION)');
expect(updater).toContain('return T(_("Installed plugin: v%1 (Login required)"), PLUGIN_VERSION)'); expect(updater).toContain('return T(_("Installed plugin: v%1 (Login required)"), PLUGIN_VERSION)');
@@ -56,14 +74,15 @@ describe('KOReader plugin update source wiring', () => {
it('keeps manual sync actions above advanced sync settings', async () => { it('keeps manual sync actions above advanced sync settings', async () => {
const menu = await readPluginFile('bookorbit_main_menu.lua'); const menu = await readPluginFile('bookorbit_main_menu.lua');
const syncSettingsBlock = menu.slice( const dashboardIndex = menu.indexOf('id = "open_dashboard"');
menu.indexOf('return T(_("Periodically sync every # pages (%1)")'), const settingsIndex = menu.indexOf('text = _("Settings")', dashboardIndex);
menu.indexOf('text = _("Account & setup")'), const syncSettingsBlock = menu.slice(menu.indexOf('function MainMenu:syncSettingsMenu'), menu.indexOf('function MainMenu:pluginSettingsMenu'));
);
expect(menu.indexOf('text = _("Sync this book now")')).toBeLessThan(menu.indexOf('text = _("Sync settings")')); expect(menu.indexOf('text = _("Sync current book now")')).toBeLessThan(settingsIndex);
expect(menu.indexOf('text = _("Sync all books now")')).toBeLessThan(menu.indexOf('text = _("Sync settings")')); expect(menu.indexOf('text = _("Auto sync current book")')).toBeLessThan(settingsIndex);
expect(syncSettingsBlock).not.toContain('text = _("Sync this book now")'); expect(menu.indexOf('text = _("Sync all books now")')).toBeLessThan(settingsIndex);
expect(syncSettingsBlock).not.toContain('text = _("Sync current book now")');
expect(syncSettingsBlock).not.toContain('text = _("Auto sync current book")');
expect(syncSettingsBlock).not.toContain('text = _("Sync all books now")'); expect(syncSettingsBlock).not.toContain('text = _("Sync all books now")');
}); });
@@ -167,7 +186,7 @@ describe('KOReader plugin update source wiring', () => {
const main = await readPluginFile('main.lua'); const main = await readPluginFile('main.lua');
const sweep = await readPluginFile('bookorbit_sweep.lua'); const sweep = await readPluginFile('bookorbit_sweep.lua');
expect(main).toContain('if not err then self:maybeCheckForUpdate(false) end'); expect(main).toContain('self:requestUpdateCheck(false, "sweep_done")');
expect(sweep).toContain('on_finish = opts.on_finish'); expect(sweep).toContain('on_finish = opts.on_finish');
expect(sweep).toContain('pcall(ctx.on_finish, err)'); expect(sweep).toContain('pcall(ctx.on_finish, err)');
}); });