mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -33,6 +33,7 @@ import { useThemeStore } from '@/stores/theme'
|
||||
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
|
||||
import { buildSidebarVersionUi } from '@/components/sidebar/versionUi'
|
||||
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
|
||||
function useSidebarSection(key: string) {
|
||||
const isOpen = ref(localStorage.getItem(`bookorbit:sidebar:${key}`) !== 'false')
|
||||
@@ -55,6 +56,7 @@ const { handleLibraryCreated } = useLibraryCreationRedirect()
|
||||
const themeStore = useThemeStore()
|
||||
const { version, updateAvailable, latestVersion, loadAppInfo } = useAppInfo()
|
||||
const { hasUnseen: hasUnseenWhatsNew } = useWhatsNew()
|
||||
const supportUrl = 'https://ko-fi.com/neonbookorbit'
|
||||
|
||||
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full'))
|
||||
const versionUi = computed(() => {
|
||||
@@ -438,14 +440,50 @@ onUnmounted(() => stopLibraryUploadListener())
|
||||
</SidebarGroup>
|
||||
</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="flex flex-wrap items-center justify-center gap-2 text-center text-[12px] font-medium leading-none text-sidebar-foreground/80">
|
||||
<RouterLink to="/whats-new" class="inline-flex items-center gap-1.5 transition-colors hover:text-sidebar-foreground">
|
||||
{{ versionUi.currentLabel }}
|
||||
<span v-if="hasUnseenWhatsNew" class="h-1.5 w-1.5 rounded-full bg-primary" aria-label="New release notes available" />
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<a
|
||||
@@ -453,9 +491,9 @@ onUnmounted(() => stopLibraryUploadListener())
|
||||
:href="versionUi.latestHref"
|
||||
target="_blank"
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+43
-103
@@ -2,128 +2,68 @@
|
||||
|
||||
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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- Book status (reading / complete / abandoned) and star ratings, with newest-change-wins conflict handling.
|
||||
- **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.
|
||||
- **Progress sync**: pulls progress on book open (with a conflict prompt), pushes periodically and on close/suspend.
|
||||
- **Reading statistics**: uploads KOReader's per-page time events; BookOrbit turns them into reading sessions and daily stats.
|
||||
- **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.
|
||||
- **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 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.
|
||||
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.
|
||||
|
||||
## 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.
|
||||
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).
|
||||
3. Unzip and copy the `bookorbit.koplugin/` folder to the `koreader/plugins/` directory on your device.
|
||||
4. Restart KOReader. The plugin configures itself on startup (a confirmation message appears) and removes the embedded credentials file.
|
||||
1. In BookOrbit web settings: Settings > KOReader, create sync credentials (KOReader sync permission required on your account).
|
||||
2. Click "Download preconfigured plugin". The zip embeds your server address and credentials.
|
||||
3. Unzip and copy `bookorbit.koplugin/` into `koreader/plugins/` on your device.
|
||||
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)
|
||||
|
||||
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.
|
||||
3. In BookOrbit web settings, open Settings, KOReader and create sync credentials (username + password). The KOReader sync permission must be enabled for your account.
|
||||
4. On the device, open the main menu, Tools tab: "BookOrbit sync" sits directly below "Calibre" on the first page.
|
||||
- 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.
|
||||
3. In BookOrbit web settings: Settings > KOReader, create sync credentials.
|
||||
4. On the device: Tools > BookOrbit (sits below Calibre) - set the server address, log in, and use "Open dashboard" to browse/download books.
|
||||
|
||||
## 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.
|
||||
2. Optional: in "Sync settings", tune "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.
|
||||
4. Optional: assign the "BookOrbit: sync this book" and "BookOrbit: sync all books" actions to gestures.
|
||||
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: Settings > Sync > "Periodically sync every # pages" (default 10, 0 disables mid-session pushes).
|
||||
3. Optional: Settings > Dashboard > "Open dashboard on startup".
|
||||
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
|
||||
|
||||
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.
|
||||
From the dashboard menu, the plugin update check is top-level; from the Tools menu, it lives under Settings > Plugin.
|
||||
|
||||
## 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.
|
||||
- "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 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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 current book now": checks BookOrbit for newer progress first, then does the same snapshot on demand.
|
||||
- "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 and resumable: watermarks, modification-time checks and change detection mean an interrupted sync just resumes, and resends are server-side no-ops.
|
||||
- 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
|
||||
|
||||
- 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-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.
|
||||
- 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.
|
||||
- Position-only bookmarks (no highlighted text) are not synced.
|
||||
- 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).
|
||||
- The full-library sweep is manual-only by design; books you never open again only sync when you press "Sync all books now".
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- Web highlight changes reach a closed book only via a manual sweep or its next open.
|
||||
- Web-created PDF highlights aren't supported (device PDF highlights sync up but aren't drawn over the PDF).
|
||||
- Books with reading stats but no sidecar path sync reading time only, until the full sweep covers them.
|
||||
- Position-only bookmarks (no highlighted text) don't sync.
|
||||
- A device clock far in the past can delay edit detection for web-modified highlights.
|
||||
- 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` double-counts reading time; clones keeping the same `device_id` deduplicate naturally.
|
||||
|
||||
@@ -76,14 +76,58 @@ local function normalizeText(text)
|
||||
return util.trim(text:gsub("%s+", " "))
|
||||
end
|
||||
|
||||
local function findByDatetime(annotations, datetime)
|
||||
for index, a in ipairs(annotations or {}) do
|
||||
if a.datetime == datetime then
|
||||
return index, a
|
||||
local ackApplied
|
||||
local applyEditFields
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
-- knows the highlighted text, the extracted range must match it.
|
||||
local function verifyRange(document, pos0, pos1, text)
|
||||
@@ -133,7 +177,7 @@ local function repairRange(document, entry)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function ackApplied(entry, opts)
|
||||
ackApplied = function(entry, opts)
|
||||
return {
|
||||
serverId = entry.serverId,
|
||||
version = entry.version,
|
||||
@@ -147,7 +191,7 @@ local function ackApplied(entry, opts)
|
||||
}
|
||||
end
|
||||
|
||||
local function applyEditFields(annotation, entry)
|
||||
applyEditFields = function(annotation, entry)
|
||||
annotation.drawer = entry.drawer or annotation.drawer
|
||||
annotation.color = entry.color or annotation.color
|
||||
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 annotations = ui.annotation.annotations
|
||||
local touched = 0
|
||||
local deleted_touched = 0
|
||||
|
||||
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
|
||||
-- Idempotent retry of an earlier apply that lost its ack.
|
||||
table.insert(applied, ackApplied(entry, { verified = true, pos0 = entry.pos0, pos1 = entry.pos1 }))
|
||||
applyIdentityFields(existing_annotation, entry)
|
||||
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
|
||||
-- PDF adds from the web are out of scope; never expected here.
|
||||
table.insert(applied, ackApplied(entry, { failed = true }))
|
||||
@@ -200,8 +247,15 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
|
||||
pos0 = pos0,
|
||||
pos1 = pos1,
|
||||
}
|
||||
ui.annotation:addItem(item)
|
||||
ui:handleEvent(Event:new("AnnotationsModified", { item, nb_highlights_added = 1 }))
|
||||
local index = ui.annotation:addItem(item)
|
||||
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
|
||||
table.insert(applied, ackApplied(entry, {
|
||||
verified = true,
|
||||
@@ -215,7 +269,7 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
|
||||
end
|
||||
|
||||
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
|
||||
logger.dbg("BookOrbit: annotation edit target missing:", entry.serverId)
|
||||
table.insert(applied, ackApplied(entry, { failed = true }))
|
||||
@@ -228,13 +282,14 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
|
||||
end
|
||||
|
||||
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 ui.paging then
|
||||
pcall(ui.highlight.writePdfAnnotation, ui.highlight, "delete", annotation)
|
||||
end
|
||||
ui.bookmark:removeItemByIndex(index)
|
||||
touched = touched + 1
|
||||
deleted_touched = deleted_touched + 1
|
||||
end
|
||||
-- Missing entries were already deleted locally; ack either way.
|
||||
table.insert(deleted, { serverId = entry.serverId, status = "applied" })
|
||||
@@ -243,7 +298,7 @@ function BookOrbitAnnotations.applyLive(ui, to_apply)
|
||||
if touched > 0 then
|
||||
UIManager:setDirty("all", "ui")
|
||||
end
|
||||
return applied, deleted, touched
|
||||
return applied, deleted, touched, deleted_touched
|
||||
end
|
||||
|
||||
-- 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 annotations = doc_settings:readSetting("annotations") or {}
|
||||
local touched = 0
|
||||
local deleted_touched = 0
|
||||
|
||||
for _, entry in ipairs(to_apply.add or {}) do
|
||||
if entry.posFormat ~= "xpointer" then
|
||||
table.insert(applied, ackApplied(entry, { failed = true }))
|
||||
else
|
||||
local existing = findByDatetime(annotations, entry.datetime)
|
||||
if not existing then
|
||||
local existing_index, existing = findByIdentity(annotations, entry)
|
||||
if existing_index then
|
||||
applyIdentityFields(existing, entry)
|
||||
touched = touched + 1
|
||||
else
|
||||
table.insert(annotations, {
|
||||
datetime = entry.datetime,
|
||||
datetime_updated = entry.datetimeUpdated,
|
||||
@@ -276,12 +335,16 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
|
||||
touched = touched + 1
|
||||
end
|
||||
-- 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
|
||||
|
||||
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
|
||||
table.insert(applied, ackApplied(entry, { failed = true }))
|
||||
else
|
||||
@@ -292,10 +355,11 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
|
||||
end
|
||||
|
||||
for _, entry in ipairs(to_apply.delete or {}) do
|
||||
local index = findByDatetime(annotations, entry.datetime)
|
||||
local index = findByIdentity(annotations, entry)
|
||||
if index then
|
||||
table.remove(annotations, index)
|
||||
touched = touched + 1
|
||||
deleted_touched = deleted_touched + 1
|
||||
end
|
||||
table.insert(deleted, { serverId = entry.serverId, status = "applied" })
|
||||
end
|
||||
@@ -305,7 +369,7 @@ function BookOrbitAnnotations.applySidecar(file, to_apply)
|
||||
doc_settings:makeTrue("annotations_externally_modified")
|
||||
doc_settings:flush()
|
||||
end
|
||||
return applied, deleted, touched
|
||||
return applied, deleted, touched, deleted_touched
|
||||
end
|
||||
|
||||
local function hasPending(to_apply)
|
||||
@@ -396,16 +460,18 @@ function BookOrbitAnnotations.exchangeBook(opts)
|
||||
local rounds = 0
|
||||
while response and hasPending(response.toApply) and rounds < MAX_PULL_ROUNDS do
|
||||
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
|
||||
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
|
||||
applied, deleted, touched = BookOrbitAnnotations.applySidecar(opts.file, response.toApply)
|
||||
applied, deleted, touched, deleted_touched = BookOrbitAnnotations.applySidecar(opts.file, response.toApply)
|
||||
else
|
||||
break
|
||||
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
|
||||
if entry.status == "failed" then result.failed = result.failed + 1 end
|
||||
end
|
||||
|
||||
@@ -31,8 +31,22 @@ local function scrubNulls(value)
|
||||
return value
|
||||
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 = {}
|
||||
BookOrbitApi.__index = BookOrbitApi
|
||||
BookOrbitApi.decodeResponse = decodeResponse
|
||||
|
||||
-- Normalizes a user-entered server address to the API base, e.g.
|
||||
-- "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")
|
||||
end
|
||||
|
||||
local decoded = nil
|
||||
if sink[1] then
|
||||
decoded = scrubNulls(rapidjson.decode(table.concat(sink)))
|
||||
end
|
||||
local decoded, decode_err = decodeResponse(sink)
|
||||
|
||||
if code < 200 or code >= 300 then
|
||||
return nil, code, decoded
|
||||
end
|
||||
|
||||
if decode_err then
|
||||
return nil, decode_err
|
||||
end
|
||||
|
||||
return decoded or {}
|
||||
end
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ local _ = require("gettext")
|
||||
|
||||
local BookOrbitAnnotations = require("bookorbit_annotations")
|
||||
local BookOrbitApi = require("bookorbit_api")
|
||||
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
|
||||
local BookOrbitSidecar = require("bookorbit_sidecar")
|
||||
local BookOrbitState = require("bookorbit_state")
|
||||
local BookOrbitStatsReader = require("bookorbit_stats_reader")
|
||||
@@ -133,6 +134,28 @@ local function finish(ctx, err)
|
||||
|
||||
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 ctx.interactive then
|
||||
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)
|
||||
else
|
||||
if ctx.interactive then
|
||||
local text = T(_("BookOrbit: book synced. %1 reading events, %2 highlights up, %3 applied."),
|
||||
ctx.counts.page_stats, ctx.counts.annotations, ctx.counts.ann_applied or 0)
|
||||
local text = T(_("BookOrbit: book synced. %1 reading events."), ctx.counts.page_stats)
|
||||
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
|
||||
text = text .. "\n" .. _("Progress was not changed.")
|
||||
end
|
||||
@@ -162,8 +196,9 @@ local function finish(ctx, err)
|
||||
UIManager:show(InfoMessage:new{ text = text, timeout = 4 })
|
||||
end
|
||||
logger.info(string.format(
|
||||
"BookOrbit: book sync done reason=%s pageStats=%d annotations=%d applied=%d errors=%s",
|
||||
ctx.reason, ctx.counts.page_stats, ctx.counts.annotations, ctx.counts.ann_applied or 0, tostring(ctx.had_errors)))
|
||||
"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, ctx.counts.ann_deleted or 0, tostring(ctx.had_errors)))
|
||||
end
|
||||
|
||||
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
|
||||
apply_mode = "live"
|
||||
end
|
||||
elseif ctx.interactive then
|
||||
ctx.highlight_disabled_reported = true
|
||||
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
|
||||
end
|
||||
|
||||
local result, err = BookOrbitAnnotations.exchangeBook({
|
||||
@@ -300,15 +338,22 @@ stepAnnotations = function(ctx)
|
||||
end
|
||||
if err == "unmatched" then return finish(ctx, "unmatched") end
|
||||
if err == "unsupported_server" then
|
||||
ctx.highlight_unsupported = true
|
||||
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
|
||||
return step(ctx, stepAnnotationsLegacy)
|
||||
end
|
||||
ctx.had_errors = true
|
||||
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
|
||||
return step(ctx, stepState)
|
||||
end
|
||||
|
||||
if result.had_errors then ctx.had_errors = true end
|
||||
ctx.counts.annotations = ctx.counts.annotations + result.uploaded
|
||||
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
|
||||
book.annCount = ctx.snap.ann_count
|
||||
end
|
||||
@@ -351,6 +396,7 @@ stepAnnotationsLegacy = function(ctx)
|
||||
ctx.had_errors = true
|
||||
ctx.ann_failed = true
|
||||
ctx.ann_delta = {}
|
||||
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
|
||||
if isTransportError(err) then return finish(ctx, "network") end
|
||||
logger.dbg("BookOrbit: book sync annotations upload failed:", err)
|
||||
return step(ctx, stepState)
|
||||
@@ -361,6 +407,7 @@ stepAnnotationsLegacy = function(ctx)
|
||||
end
|
||||
|
||||
ctx.counts.annotations = ctx.counts.annotations + #chunk
|
||||
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { uploaded = #chunk })
|
||||
return step(ctx, stepAnnotationsLegacy)
|
||||
end
|
||||
|
||||
@@ -451,18 +498,18 @@ function BookOrbitBookSync.run(opts)
|
||||
if opts.interactive then
|
||||
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
|
||||
end
|
||||
return
|
||||
return false
|
||||
end
|
||||
|
||||
local snap = opts.snap
|
||||
if not snap then return end
|
||||
if not snap then return false end
|
||||
|
||||
local client = BookOrbitApi.new(opts.api)
|
||||
if not client:isConfigured() then
|
||||
if opts.interactive then
|
||||
UIManager:show(InfoMessage:new{ text = _("Please configure the BookOrbit server and login first."), timeout = 3 })
|
||||
end
|
||||
return
|
||||
return false
|
||||
end
|
||||
|
||||
BookOrbitBookSync.running = true
|
||||
@@ -477,7 +524,11 @@ function BookOrbitBookSync.run(opts)
|
||||
annotation_sync = opts.annotation_sync ~= false,
|
||||
plugin = opts.plugin,
|
||||
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,
|
||||
skip_progress = opts.skip_progress == true,
|
||||
}
|
||||
@@ -495,6 +546,7 @@ function BookOrbitBookSync.run(opts)
|
||||
else
|
||||
step(ctx, stepMatch)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
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.
|
||||
|
||||
Builds the BookOrbit entry in KOReader's Tools menu (and the dashboard
|
||||
hamburger menu that mirrors it), plus the account dialogs: server address,
|
||||
login/logout. Installed onto the plugin controller as regular methods.
|
||||
Builds the BookOrbit entry in KOReader's Tools menu and the dashboard menu that
|
||||
mirrors it, plus the account dialogs: server address, login/logout. Installed
|
||||
onto the plugin controller as regular methods.
|
||||
]]
|
||||
|
||||
local Device = require("device")
|
||||
@@ -19,7 +19,7 @@ local T = require("ffi/util").template
|
||||
local _ = require("gettext")
|
||||
|
||||
local BookOrbitApi = require("bookorbit_api")
|
||||
local BookOrbitBookSync = require("bookorbit_book_sync")
|
||||
local BookOrbitHighlightDiagnostics = require("bookorbit_highlight_diagnostics")
|
||||
local BookOrbitSweep = require("bookorbit_sweep")
|
||||
|
||||
-- Assigned from the plugin class on install so menu labels can name the
|
||||
@@ -81,186 +81,407 @@ function MainMenu:catalogAutoOpenMenu()
|
||||
}
|
||||
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)
|
||||
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 = {
|
||||
{
|
||||
text = _("Open dashboard"),
|
||||
enabled_func = function()
|
||||
return self:isLoggedIn()
|
||||
end,
|
||||
callback = function()
|
||||
self:browseCatalog()
|
||||
end,
|
||||
},
|
||||
{
|
||||
text_func = function()
|
||||
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,
|
||||
local logged_in = self:isLoggedIn()
|
||||
local has_open_book = hasReaderBook(self)
|
||||
local items = {}
|
||||
|
||||
if logged_in then
|
||||
table.insert(items, {
|
||||
id = "open_dashboard",
|
||||
text = _("Open dashboard"),
|
||||
callback = function()
|
||||
self:browseCatalog()
|
||||
end,
|
||||
separator = true,
|
||||
})
|
||||
if has_open_book then
|
||||
table.insert(items, {
|
||||
id = "sync_current_book",
|
||||
text = _("Sync current book now"),
|
||||
callback = function()
|
||||
self:onBookOrbitSyncBook()
|
||||
end,
|
||||
},
|
||||
{
|
||||
text = _("Sync all books now"),
|
||||
enabled_func = function()
|
||||
return self:isLoggedIn() and not BookOrbitSweep.isRunning() and not BookOrbitBookSync.isRunning()
|
||||
end,
|
||||
callback = function()
|
||||
self:startSweep()
|
||||
end,
|
||||
separator = true,
|
||||
},
|
||||
{
|
||||
text = _("Auto sync this book"),
|
||||
})
|
||||
table.insert(items, {
|
||||
id = "auto_sync_current_book",
|
||||
text = _("Auto sync current book"),
|
||||
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.]]),
|
||||
callback = function()
|
||||
self:onBookOrbitToggleAutoSync(nil, true)
|
||||
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,
|
||||
})
|
||||
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"),
|
||||
sub_item_table = {
|
||||
{
|
||||
text_func = function()
|
||||
return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())
|
||||
end,
|
||||
sub_item_table = self:catalogAutoOpenMenu(),
|
||||
},
|
||||
{
|
||||
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,
|
||||
callback = function(touchmenu_instance)
|
||||
local SpinWidget = require("ui/widget/spinwidget")
|
||||
local items = 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(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_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,
|
||||
},
|
||||
{
|
||||
text = _("Account & setup"),
|
||||
sub_item_table = {
|
||||
{
|
||||
text = _("BookOrbit server address"),
|
||||
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,
|
||||
},
|
||||
},
|
||||
text = _("Diagnostics"),
|
||||
callback = function()
|
||||
self:showDiagnostics()
|
||||
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
|
||||
|
||||
@@ -271,9 +492,23 @@ function MainMenu:dashboardMenuItems(catalog)
|
||||
local items = {
|
||||
icon = "appbar.menu",
|
||||
}
|
||||
for index, item in ipairs(bookorbit_items) do
|
||||
if index ~= 1 then
|
||||
table.insert(items, item)
|
||||
if self:isLoggedIn() then
|
||||
local plugin_update = self:pluginSettingsMenu()[1]
|
||||
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
|
||||
if catalog then
|
||||
@@ -427,7 +662,11 @@ function MainMenu:doLogin(username, password, menu)
|
||||
if menu then menu:updateItems() end
|
||||
UIManager:show(InfoMessage:new{ text = _("Logged in to BookOrbit.") })
|
||||
UIManager:scheduleIn(1, function()
|
||||
self:maybeCheckForUpdate(false)
|
||||
if self.requestUpdateCheck then
|
||||
self:requestUpdateCheck(false, "login")
|
||||
else
|
||||
self:maybeCheckForUpdate(false)
|
||||
end
|
||||
end)
|
||||
elseif err == 401 or err == 403 then
|
||||
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
|
||||
-- partial MD5 of the file, so the filename checksum method does not exist here.
|
||||
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
|
||||
|
||||
local file = self.ui.document.file
|
||||
if not file then return nil end
|
||||
local ok, computed = pcall(util.partialMD5, file)
|
||||
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
|
||||
end
|
||||
|
||||
@@ -99,12 +103,12 @@ end
|
||||
|
||||
function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
|
||||
if not self.ui or not self.ui.document then
|
||||
UIManager:show(InfoMessage:new{ text = _("Open a book to sync it."), timeout = 2 })
|
||||
return
|
||||
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
|
||||
return false
|
||||
end
|
||||
if self:getDocumentDigest() ~= digest then
|
||||
UIManager:show(InfoMessage:new{ text = _("The open book changed. Start the sync again."), timeout = 3 })
|
||||
return
|
||||
return false
|
||||
end
|
||||
|
||||
local client = self:newClient()
|
||||
@@ -115,17 +119,17 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
|
||||
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 })
|
||||
on_done(true)
|
||||
return
|
||||
return true
|
||||
end
|
||||
|
||||
if not body.percentage then
|
||||
on_done(false)
|
||||
return
|
||||
return true
|
||||
end
|
||||
|
||||
if body.device == Device.model and body.device_id == self.device_id then
|
||||
on_done(false)
|
||||
return
|
||||
return true
|
||||
end
|
||||
|
||||
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
|
||||
on_done(false)
|
||||
return
|
||||
return true
|
||||
end
|
||||
|
||||
local remote_newer = self:remoteProgressIsNewer(body, local_percentage)
|
||||
@@ -148,12 +152,13 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
|
||||
else
|
||||
on_done(false)
|
||||
end
|
||||
return
|
||||
return true
|
||||
end
|
||||
|
||||
local strategy = remote_newer and self.settings.sync_forward or self.settings.sync_backward
|
||||
if strategy == SYNC_STRATEGY.SILENT then
|
||||
self:applyRemoteProgress(body, on_done)
|
||||
return true
|
||||
elseif strategy == SYNC_STRATEGY.PROMPT then
|
||||
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?")
|
||||
@@ -166,14 +171,17 @@ function ProgressSync:reconcileProgressBeforeBookSync(digest, on_done)
|
||||
on_done(remote_newer)
|
||||
end,
|
||||
})
|
||||
return true
|
||||
elseif remote_newer then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("BookOrbit has newer progress. Syncing book data without changing progress."),
|
||||
timeout = 4,
|
||||
})
|
||||
on_done(true)
|
||||
return true
|
||||
else
|
||||
on_done(false)
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -182,7 +190,12 @@ function ProgressSync:updateProgress(ensure_networking, interactive, on_suspend)
|
||||
if interactive then self:promptLogin() end
|
||||
return
|
||||
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()
|
||||
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())
|
||||
if interactive 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 })
|
||||
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
|
||||
if self.recordSyncError then
|
||||
self:recordSyncError("progress_push", err)
|
||||
end
|
||||
showSyncError()
|
||||
end
|
||||
elseif not body then
|
||||
if self.recordSyncError then
|
||||
self:recordSyncError("progress_push", err)
|
||||
end
|
||||
logger.dbg("BookOrbit: push failed:", err)
|
||||
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
|
||||
|
||||
if on_suspend and Device:hasWifiManager() then
|
||||
@@ -224,7 +259,12 @@ function ProgressSync:getProgress(ensure_networking, interactive)
|
||||
if interactive then self:promptLogin() end
|
||||
return
|
||||
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()
|
||||
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 interactive then showSyncError() end
|
||||
if self.recordSyncError then
|
||||
self:recordSyncError("progress_pull", err)
|
||||
end
|
||||
logger.dbg("BookOrbit: pull failed:", err)
|
||||
return
|
||||
end
|
||||
self:maybeCheckForUpdate(false)
|
||||
if self.requestUpdateCheck then
|
||||
self:requestUpdateCheck(false, "progress_pull")
|
||||
else
|
||||
self:maybeCheckForUpdate(false)
|
||||
end
|
||||
|
||||
if not body.percentage then
|
||||
if interactive then
|
||||
@@ -277,6 +324,9 @@ function ProgressSync:getProgress(ensure_networking, interactive)
|
||||
|
||||
if interactive then
|
||||
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 })
|
||||
return
|
||||
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
|
||||
if strategy == SYNC_STRATEGY.SILENT then
|
||||
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 })
|
||||
elseif strategy == SYNC_STRATEGY.PROMPT then
|
||||
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),
|
||||
ok_callback = function()
|
||||
self:syncToProgress(body.progress)
|
||||
if self.recordSyncSuccess then
|
||||
self:recordSyncSuccess("progress_pull", _("Progress pulled"))
|
||||
end
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -23,6 +23,7 @@ local _ = require("gettext")
|
||||
|
||||
local BookOrbitAnnotations = require("bookorbit_annotations")
|
||||
local BookOrbitApi = require("bookorbit_api")
|
||||
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
|
||||
local BookOrbitSidecar = require("bookorbit_sidecar")
|
||||
local BookOrbitState = require("bookorbit_state")
|
||||
local BookOrbitStatsReader = require("bookorbit_stats_reader")
|
||||
@@ -97,8 +98,27 @@ local function finish(ctx, err)
|
||||
ctx.progress_msg = nil
|
||||
end
|
||||
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
|
||||
|
||||
if err == "auth" then
|
||||
@@ -107,6 +127,9 @@ local function finish(ctx, err)
|
||||
else
|
||||
Notification:notify(_("BookOrbit sync: login failed"))
|
||||
end
|
||||
if ctx.on_finish then
|
||||
pcall(ctx.on_finish, err)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
@@ -115,20 +138,37 @@ local function finish(ctx, err)
|
||||
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync: server not reachable."), timeout = 4 })
|
||||
end
|
||||
logger.dbg("BookOrbit: sweep aborted, server not reachable")
|
||||
if ctx.on_finish then
|
||||
pcall(ctx.on_finish, err)
|
||||
end
|
||||
return
|
||||
end
|
||||
|
||||
if ctx.interactive then
|
||||
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)
|
||||
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
|
||||
text = text .. "\n" .. _("Some books failed and will retry on the next sync.")
|
||||
end
|
||||
UIManager:show(InfoMessage:new{ text = text, timeout = 5 })
|
||||
end
|
||||
logger.info(string.format(
|
||||
"BookOrbit: sweep done matched=%d pageStats=%d annotations=%d errors=%s",
|
||||
ctx.counts.books_matched, ctx.counts.page_stats, ctx.counts.annotations, tostring(ctx.had_errors)))
|
||||
"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,
|
||||
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
|
||||
|
||||
local function step(ctx, fn)
|
||||
@@ -479,6 +519,9 @@ local function stepAnnotationsNext(ctx)
|
||||
local apply_mode = "skip"
|
||||
if ctx.annotation_sync and entry.file ~= currentlyOpenFile() then
|
||||
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
|
||||
|
||||
local result, err = BookOrbitAnnotations.exchangeBook({
|
||||
@@ -496,11 +539,14 @@ local function stepAnnotationsNext(ctx)
|
||||
-- Re-queue everything, including this entry, for the legacy path.
|
||||
table.insert(ctx.ann_queue, 1, entry)
|
||||
ctx.use_legacy_annotations = true
|
||||
ctx.highlight_unsupported = true
|
||||
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, nil, { skipped = 1 })
|
||||
buildLegacyAnnotationChunks(ctx)
|
||||
return step(ctx, ctx.steps.annotationsLegacyNext)
|
||||
end
|
||||
ctx.had_errors = true
|
||||
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)
|
||||
return step(ctx, ctx.steps.annotationsNext)
|
||||
end
|
||||
@@ -513,6 +559,10 @@ local function stepAnnotationsNext(ctx)
|
||||
end
|
||||
ctx.counts.annotations = ctx.counts.annotations + result.uploaded
|
||||
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)
|
||||
end
|
||||
|
||||
@@ -565,6 +615,7 @@ local function stepAnnotationsLegacyNext(ctx)
|
||||
if isAuthError(err) then return finish(ctx, "auth") end
|
||||
logger.dbg("BookOrbit: annotations upload failed:", err)
|
||||
ctx.had_errors = true
|
||||
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, { had_errors = true })
|
||||
for _, book in ipairs(books) do
|
||||
local pending = ctx.pending_books[book.hash]
|
||||
if pending then pending.failed = true end
|
||||
@@ -586,6 +637,9 @@ local function stepAnnotationsLegacyNext(ctx)
|
||||
elseif pending then
|
||||
pending.ann_chunks_left = pending.ann_chunks_left - 1
|
||||
ctx.counts.annotations = ctx.counts.annotations + #book.annotations
|
||||
ctx.highlight_summary = BookOrbitHighlightSummary.add(ctx.highlight_summary, {
|
||||
uploaded = #book.annotations,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -747,7 +801,7 @@ function BookOrbitSweep.run(opts)
|
||||
if opts.interactive then
|
||||
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
|
||||
end
|
||||
return
|
||||
return false
|
||||
end
|
||||
|
||||
local BookOrbitBookSync = require("bookorbit_book_sync")
|
||||
@@ -755,7 +809,7 @@ function BookOrbitSweep.run(opts)
|
||||
if opts.interactive then
|
||||
UIManager:show(InfoMessage:new{ text = _("BookOrbit is syncing the current book, try again shortly."), timeout = 2 })
|
||||
end
|
||||
return
|
||||
return false
|
||||
end
|
||||
|
||||
local client = BookOrbitApi.new(opts.api)
|
||||
@@ -763,7 +817,7 @@ function BookOrbitSweep.run(opts)
|
||||
if opts.interactive then
|
||||
UIManager:show(InfoMessage:new{ text = _("Please configure the BookOrbit server and login first."), timeout = 3 })
|
||||
end
|
||||
return
|
||||
return false
|
||||
end
|
||||
|
||||
BookOrbitSweep.running = true
|
||||
@@ -781,9 +835,14 @@ function BookOrbitSweep.run(opts)
|
||||
state = state,
|
||||
interactive = opts.interactive or false,
|
||||
annotation_sync = opts.annotation_sync ~= false,
|
||||
plugin = opts.plugin,
|
||||
full_recheck = opts.interactive or state.global.needsFullRecheck or false,
|
||||
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,
|
||||
}
|
||||
ctx.steps = {
|
||||
@@ -803,6 +862,7 @@ function BookOrbitSweep.run(opts)
|
||||
logger.info("BookOrbit: sweep started, interactive:", ctx.interactive)
|
||||
|
||||
step(ctx, ctx.steps.enumerate)
|
||||
return true
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
function UpdateCheck:checkForUpdate()
|
||||
if self.requestUpdateCheck then
|
||||
self:requestUpdateCheck(true, "manual")
|
||||
return
|
||||
end
|
||||
if not self:isLoggedIn() then
|
||||
self:promptLogin()
|
||||
return
|
||||
@@ -168,6 +172,9 @@ function UpdateCheck:maybeCheckForUpdate(interactive)
|
||||
self._checking_update = false
|
||||
|
||||
if not body then
|
||||
if self.recordSyncError then
|
||||
self:recordSyncError("update_check", err)
|
||||
end
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = T(_("Could not check for update: %1"), tostring(err or "network error")),
|
||||
@@ -190,6 +197,9 @@ function UpdateCheck:doCheckForUpdate()
|
||||
UIManager:close(checking)
|
||||
|
||||
if not body then
|
||||
if self.recordSyncError then
|
||||
self:recordSyncError("update_check", err)
|
||||
end
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = T(_("Could not check for update: %1"), tostring(err or "network error")),
|
||||
timeout = 4,
|
||||
@@ -207,6 +217,9 @@ function UpdateCheck:handleUpdateVersionResponse(body, interactive, prompt_allow
|
||||
|
||||
if type(plugin_latest) ~= "string" or plugin_latest == "unknown" then
|
||||
G_reader_settings:flush()
|
||||
if self.recordSyncError then
|
||||
self:recordSyncError("update_check", "unsupported_server", _("latest plugin version unavailable"))
|
||||
end
|
||||
if interactive then
|
||||
UIManager:show(InfoMessage:new{
|
||||
text = _("Could not determine the latest plugin version from the server."),
|
||||
|
||||
@@ -31,7 +31,10 @@ local BookOrbitAnnotations = require("bookorbit_annotations")
|
||||
local BookOrbitApi = require("bookorbit_api")
|
||||
local BookOrbitBookSync = require("bookorbit_book_sync")
|
||||
local BookOrbitCatalog = require("bookorbit_catalog")
|
||||
local BookOrbitHighlightSummary = require("bookorbit_highlight_summary")
|
||||
local BookOrbitOpenAnnotationScheduler = require("bookorbit_open_annotation_scheduler")
|
||||
local BookOrbitState = require("bookorbit_state")
|
||||
local BookOrbitSyncCoordinator = require("bookorbit_sync_coordinator")
|
||||
local BookOrbitMainMenu = require("bookorbit_main_menu")
|
||||
local BookOrbitMenuPin = require("bookorbit_menu_pin")
|
||||
local BookOrbitProgressSync = require("bookorbit_progress_sync")
|
||||
@@ -46,6 +49,20 @@ local SYNC_STRATEGY = {
|
||||
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{
|
||||
name = "bookorbit",
|
||||
title = _("Login to BookOrbit"),
|
||||
@@ -104,7 +121,7 @@ function BookOrbit:init()
|
||||
-- Push only, no pull, no network nagging: relies on the connection
|
||||
-- being already up, like the stock kosync periodic push.
|
||||
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
|
||||
|
||||
@@ -161,7 +178,7 @@ function BookOrbit:init()
|
||||
self.ui.menu:registerToMainMenu(self)
|
||||
self:onStart()
|
||||
UIManager:scheduleIn(5, function()
|
||||
self:maybeCheckForUpdate(false)
|
||||
self:requestUpdateCheck(false, "startup")
|
||||
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
|
||||
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()
|
||||
if PluginShare.bookorbit_auto_open_done then return end
|
||||
PluginShare.bookorbit_auto_open_done = true
|
||||
@@ -279,28 +709,92 @@ end
|
||||
function BookOrbit:onReaderReady()
|
||||
if self.settings.auto_sync then
|
||||
UIManager:nextTick(function()
|
||||
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then
|
||||
return
|
||||
end
|
||||
self:getProgress(true, false)
|
||||
end)
|
||||
if self.settings.annotation_sync then
|
||||
UIManager:scheduleIn(2, function()
|
||||
if self.settings.skip_sync_when_offline and not NetworkMgr:isOnline() then
|
||||
return
|
||||
if self:shouldSkipAutoSyncOffline("reader_ready") then return end
|
||||
local digest = self:getDocumentDigest()
|
||||
if self.settings.annotation_sync then
|
||||
if self:isOpenBookMatched(digest) then
|
||||
self:scheduleOpenAnnotationSync(digest, "annotation_open")
|
||||
end
|
||||
self:exchangeAnnotationsForOpenBook()
|
||||
end)
|
||||
end
|
||||
end
|
||||
self:requestOpenBookMatch("reader_ready", {
|
||||
schedule_annotation_sync = self.settings.annotation_sync,
|
||||
annotation_digest = digest,
|
||||
})
|
||||
end)
|
||||
end
|
||||
self:registerEvents()
|
||||
|
||||
self.last_page = self.ui:getCurrentPage()
|
||||
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;
|
||||
-- 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 not self:isLoggedIn() or not self.ui or not self.ui.document then return end
|
||||
if BookOrbitBookSync.isRunning() or BookOrbitSweep.isRunning() then return end
|
||||
@@ -310,6 +804,11 @@ function BookOrbit:exchangeAnnotationsForOpenBook()
|
||||
local state = BookOrbitState.open()
|
||||
if not state:getBook(digest) then
|
||||
-- Unknown or unmatched book: the close-path snapshot sync matches it.
|
||||
self:recordHighlightSync({
|
||||
event = "open_book",
|
||||
reason = reason or "auto",
|
||||
skipped = 1,
|
||||
}, "unmatched")
|
||||
return
|
||||
end
|
||||
|
||||
@@ -325,13 +824,36 @@ function BookOrbit:exchangeAnnotationsForOpenBook()
|
||||
|
||||
if not ok then
|
||||
logger.err("BookOrbit: annotation exchange error:", result)
|
||||
self:recordHighlightSync({
|
||||
event = "open_book",
|
||||
reason = reason or "auto",
|
||||
failed = 1,
|
||||
}, "partial_failure")
|
||||
elseif result then
|
||||
local touched = (result.applied or 0) + (result.deleted or 0)
|
||||
if touched > 0 then
|
||||
Notification:notify(T(_("BookOrbit: %1 highlight(s) updated"), touched))
|
||||
local summary = BookOrbitHighlightSummary.add({
|
||||
event = "open_book",
|
||||
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
|
||||
elseif err and err ~= "unmatched" and err ~= "unsupported_server" and err ~= "network" then
|
||||
logger.dbg("BookOrbit: annotation exchange skipped:", err)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -382,23 +904,7 @@ function BookOrbit:startSweep()
|
||||
promptLogin()
|
||||
return
|
||||
end
|
||||
if BookOrbitBookSync.isRunning() then
|
||||
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)
|
||||
self:requestSweep(true, "manual")
|
||||
end
|
||||
|
||||
function BookOrbit:onBookOrbitSyncBook()
|
||||
@@ -407,11 +913,7 @@ function BookOrbit:onBookOrbitSyncBook()
|
||||
return
|
||||
end
|
||||
if not self.ui or not self.ui.document then
|
||||
UIManager:show(InfoMessage:new{ text = _("Open a book to sync it."), timeout = 2 })
|
||||
return
|
||||
end
|
||||
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then
|
||||
UIManager:show(InfoMessage:new{ text = _("BookOrbit sync is already running."), timeout = 2 })
|
||||
UIManager:show(InfoMessage:new{ text = _("No reader book is open."), timeout = 2 })
|
||||
return
|
||||
end
|
||||
|
||||
@@ -421,27 +923,7 @@ function BookOrbit:onBookOrbitSyncBook()
|
||||
return
|
||||
end
|
||||
|
||||
local api_opts = self:apiOpts()
|
||||
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()
|
||||
self:requestManualBookSync(snap)
|
||||
end
|
||||
|
||||
-- Events
|
||||
@@ -453,10 +935,7 @@ function BookOrbit:_onCloseDocument()
|
||||
UIManager:unschedule(self.periodic_push_task)
|
||||
self.periodic_push_scheduled = false
|
||||
|
||||
if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then
|
||||
logger.dbg("BookOrbit: close sync skipped, another sync is running")
|
||||
return
|
||||
end
|
||||
if self:shouldSkipAutoSyncOffline("close") then return end
|
||||
|
||||
-- Snapshot now: reader objects die after this handler returns. ReaderUI
|
||||
-- already flushed the sidecar and statistics before broadcasting
|
||||
@@ -464,11 +943,14 @@ function BookOrbit:_onCloseDocument()
|
||||
local snap = BookOrbitBookSync.capture(self)
|
||||
if not snap then return end
|
||||
|
||||
local api_opts = self:apiOpts()
|
||||
NetworkMgr:goOnlineToRun(function()
|
||||
BookOrbitBookSync.run{ api = api_opts, snap = snap, reason = "close", interactive = false,
|
||||
annotation_sync = self.settings.annotation_sync }
|
||||
end)
|
||||
self:requestBookSnapshotSync{
|
||||
snap = snap,
|
||||
reason = "close",
|
||||
label = _("Close sync"),
|
||||
priority = SYNC_JOB_PRIORITY.lifecycle,
|
||||
interactive = false,
|
||||
go_online = true,
|
||||
}
|
||||
end
|
||||
|
||||
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
|
||||
return
|
||||
end
|
||||
if self:shouldSkipAutoSyncOffline("resume") then return end
|
||||
UIManager:scheduleIn(1, function()
|
||||
self:getProgress(true, false)
|
||||
self:requestProgressPull(true, false, "resume")
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -502,51 +985,48 @@ function BookOrbit:_onSuspend()
|
||||
self.periodic_push_scheduled = false
|
||||
|
||||
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)
|
||||
if not snap then return end
|
||||
|
||||
local on_finish
|
||||
if Device:hasWifiManager() then
|
||||
if Device:hasWifiManager() and not self:getSyncCoordinator():isBusy() then
|
||||
on_finish = function() NetworkMgr:disableWifi() end
|
||||
end
|
||||
local api_opts = self:apiOpts()
|
||||
-- Synchronous: the device is about to sleep, so the uploads must not be
|
||||
-- parked on the scheduler (they would run after resume and then kill
|
||||
-- wifi mid-read via on_finish).
|
||||
local run = function()
|
||||
BookOrbitBookSync.run{
|
||||
api = api_opts, snap = snap, reason = "suspend",
|
||||
interactive = false, synchronous = true, plugin = self, on_finish = on_finish,
|
||||
annotation_sync = self.settings.annotation_sync,
|
||||
}
|
||||
end
|
||||
if NetworkMgr:willRerunWhenOnline(run) then
|
||||
return
|
||||
end
|
||||
run()
|
||||
self:requestBookSnapshotSync{
|
||||
snap = snap,
|
||||
reason = "suspend",
|
||||
label = _("Suspend sync"),
|
||||
priority = SYNC_JOB_PRIORITY.lifecycle,
|
||||
interactive = false,
|
||||
synchronous = true,
|
||||
ensure_networking = true,
|
||||
on_finish = on_finish,
|
||||
}
|
||||
end
|
||||
|
||||
function BookOrbit:_onNetworkConnected()
|
||||
logger.dbg("BookOrbit: onNetworkConnected")
|
||||
UIManager:scheduleIn(0.5, function()
|
||||
self:getProgress(false, false)
|
||||
self:maybeCheckForUpdate(false)
|
||||
if self:shouldSkipAutoSyncOffline("network_connected") then return end
|
||||
self:requestProgressPull(false, false, "network_connected")
|
||||
self:requestUpdateCheck(false, "network_connected")
|
||||
end)
|
||||
end
|
||||
|
||||
function 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
|
||||
|
||||
function BookOrbit:onBookOrbitPushProgress()
|
||||
self:updateProgress(true, true)
|
||||
self:requestProgressPush(true, true, "manual")
|
||||
end
|
||||
|
||||
function BookOrbit:onBookOrbitPullProgress()
|
||||
self:getProgress(true, true)
|
||||
self:requestProgressPull(true, true, "manual")
|
||||
end
|
||||
|
||||
function BookOrbit:onBookOrbitSyncNow()
|
||||
@@ -573,13 +1053,13 @@ function BookOrbit:onBookOrbitToggleAutoSync(toggle, from_menu)
|
||||
self:registerEvents()
|
||||
|
||||
if self.settings.auto_sync then
|
||||
self:getProgress(true, true)
|
||||
self:requestProgressPull(true, true, "auto_enabled")
|
||||
else
|
||||
UIManager:unschedule(self.periodic_push_task)
|
||||
self.periodic_push_scheduled = false
|
||||
self.page_update_counter = 0
|
||||
if from_menu then
|
||||
self:updateProgress(true, true)
|
||||
self:requestProgressPush(true, true, "auto_disabled")
|
||||
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")
|
||||
@@ -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', () => {
|
||||
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 updater = await readPluginFile('bookorbit_updater.lua');
|
||||
const dashboardIndex = menu.indexOf('text = _("Open dashboard")');
|
||||
const updateRowIndex = menu.indexOf('return self:updateCheckMenuText()');
|
||||
const lastSyncIndex = menu.indexOf('return T(_("Last sync: %1")');
|
||||
const syncThisIndex = menu.indexOf('text = _("Sync this book now")');
|
||||
const dashboardIndex = menu.indexOf('id = "open_dashboard"');
|
||||
const updateRowIndex = menu.indexOf('self:updateCheckMenuText()');
|
||||
const syncThisIndex = menu.indexOf('text = _("Sync current book now")');
|
||||
const autoSyncIndex = menu.indexOf('text = _("Auto sync current book")');
|
||||
const syncAllIndex = menu.indexOf('text = _("Sync all books now")');
|
||||
const autoSyncIndex = menu.indexOf('text = _("Auto sync this book")');
|
||||
const twoWayIndex = menu.indexOf('text = _("Two-way highlight sync")');
|
||||
const syncSettingsIndex = menu.indexOf('text = _("Sync settings")');
|
||||
const topLevelSettingsIndex = menu.indexOf('text = _("Settings")', dashboardIndex);
|
||||
const settingsMenuIndex = menu.indexOf('function MainMenu:settingsMenu');
|
||||
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 syncSettingsBlock = menu.slice(syncSettingsIndex, accountIndex);
|
||||
const autoSyncBlock = menu.slice(autoSyncIndex, syncSettingsIndex);
|
||||
const topLevelActionBlock = menu.slice(dashboardIndex, topLevelSettingsIndex);
|
||||
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(dashboardIndex).toBeGreaterThan(0);
|
||||
expect(updateRowIndex).toBeGreaterThan(dashboardIndex);
|
||||
expect(updateRowIndex).toBeLessThan(lastSyncIndex);
|
||||
expect(lastSyncIndex).toBeLessThan(syncThisIndex);
|
||||
expect(dashboardIndex).toBeLessThan(syncThisIndex);
|
||||
expect(syncThisIndex).toBeLessThan(autoSyncIndex);
|
||||
expect(autoSyncIndex).toBeLessThan(syncAllIndex);
|
||||
expect(syncThisIndex).toBeLessThan(syncAllIndex);
|
||||
expect(syncAllIndex).toBeLessThan(autoSyncIndex);
|
||||
expect(autoSyncIndex).toBeLessThan(twoWayIndex);
|
||||
expect(twoWayIndex).toBeLessThan(syncSettingsIndex);
|
||||
expect(syncSettingsIndex).toBeLessThan(accountIndex);
|
||||
expect(autoSyncBlock).toContain('separator = true');
|
||||
expect(syncSettingsBlock).toContain('return T(_("Open dashboard on startup (%1)"), self:catalogAutoOpenLabel())');
|
||||
expect(syncAllIndex).toBeLessThan(topLevelSettingsIndex);
|
||||
expect(topLevelSettingsIndex).toBeLessThan(accountIndex);
|
||||
expect(dashboardSettingsIndex).toBeGreaterThan(settingsMenuIndex);
|
||||
expect(dashboardSettingsIndex).toBeLessThan(syncSettingsIndex);
|
||||
expect(syncSettingsIndex).toBeLessThan(pluginSettingsIndex);
|
||||
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(_("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(
|
||||
menu
|
||||
.slice(menu.indexOf('return T(_("Periodically sync every # pages (%1)")'), menu.indexOf('return T(_("Sync to a newer state (%1)")'))
|
||||
.includes('separator = true'),
|
||||
).toBe(false);
|
||||
expect(updateRowIndex).toBeGreaterThan(menu.indexOf('function MainMenu:pluginSettingsMenu'));
|
||||
expect(pluginSettingsBlock).toContain('self:updateCheckMenuText()');
|
||||
expect(pluginSettingsBlock).toContain('self:checkForUpdate()');
|
||||
expect(dashboardMenuBlock).toContain('local plugin_update = self:pluginSettingsMenu()[1]');
|
||||
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(
|
||||
0,
|
||||
);
|
||||
expect(menu.indexOf('self:checkForUpdate()')).toBeGreaterThan(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(_("Installed plugin: v%1 (Check for update)"), 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 () => {
|
||||
const menu = await readPluginFile('bookorbit_main_menu.lua');
|
||||
const syncSettingsBlock = menu.slice(
|
||||
menu.indexOf('return T(_("Periodically sync every # pages (%1)")'),
|
||||
menu.indexOf('text = _("Account & setup")'),
|
||||
);
|
||||
const dashboardIndex = menu.indexOf('id = "open_dashboard"');
|
||||
const settingsIndex = menu.indexOf('text = _("Settings")', dashboardIndex);
|
||||
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 all books now")')).toBeLessThan(menu.indexOf('text = _("Sync settings")'));
|
||||
expect(syncSettingsBlock).not.toContain('text = _("Sync this book now")');
|
||||
expect(menu.indexOf('text = _("Sync current book now")')).toBeLessThan(settingsIndex);
|
||||
expect(menu.indexOf('text = _("Auto sync current book")')).toBeLessThan(settingsIndex);
|
||||
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")');
|
||||
});
|
||||
|
||||
@@ -167,7 +186,7 @@ describe('KOReader plugin update source wiring', () => {
|
||||
const main = await readPluginFile('main.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('pcall(ctx.on_finish, err)');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user