feat(kobo): add KOReader catalog plugin integration

Add the KOReader catalog surfaces, plugin package metadata, and device progress handling needed for the plugin workflow. Keep progress status updates non-blocking so secondary status failures do not reject successful sync writes.
This commit is contained in:
neon
2026-06-27 14:28:36 +00:00
parent 842923044b
commit a72ca1155a
29 changed files with 2295 additions and 399 deletions
@@ -46,6 +46,14 @@ function makeResponse(data: unknown, options: { ok?: boolean; status?: number }
} as Response
}
function makeInvalidJsonResponse(): Response {
return {
ok: false,
status: 500,
json: async () => Promise.reject(new Error('not json')),
} as unknown as Response
}
describe('useKoreaderSync', () => {
beforeEach(() => {
vi.resetModules()
@@ -124,6 +132,20 @@ describe('useKoreaderSync', () => {
expect(apiMock).toHaveBeenCalledTimes(1)
})
it('createCredentials uses the fallback error when the response body is not JSON', async () => {
const payload: CreateKoreaderCredentialsPayload = {
username: 'new-user',
password: 'bad-secret',
}
apiMock.mockResolvedValueOnce(makeInvalidJsonResponse())
const { useKoreaderSync } = await import('../useKoreaderSync')
const { createCredentials } = useKoreaderSync()
await expect(createCredentials(payload)).rejects.toThrow('Failed to create credentials')
expect(apiMock).toHaveBeenCalledTimes(1)
})
it('updateCredentials makes PATCH with payload and refreshes status', async () => {
const payload: UpdateKoreaderCredentialsPayload = {
username: 'updated-user',
@@ -149,6 +171,32 @@ describe('useKoreaderSync', () => {
expect(credentials.value).toEqual(refreshedStatus.credentials)
})
it('updateCredentials throws error on non-ok response with message', async () => {
const payload: UpdateKoreaderCredentialsPayload = {
syncEnabled: false,
}
apiMock.mockResolvedValueOnce(makeResponse({ message: 'Username is already taken' }, { ok: false, status: 409 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { updateCredentials } = useKoreaderSync()
await expect(updateCredentials(payload)).rejects.toThrow('Username is already taken')
expect(apiMock).toHaveBeenCalledTimes(1)
})
it('updateCredentials uses the fallback error when the response body is not JSON', async () => {
const payload: UpdateKoreaderCredentialsPayload = {
syncEnabled: false,
}
apiMock.mockResolvedValueOnce(makeInvalidJsonResponse())
const { useKoreaderSync } = await import('../useKoreaderSync')
const { updateCredentials } = useKoreaderSync()
await expect(updateCredentials(payload)).rejects.toThrow('Failed to update credentials')
expect(apiMock).toHaveBeenCalledTimes(1)
})
it('deleteCredentials makes DELETE and clears refs', async () => {
apiMock.mockResolvedValueOnce(makeResponse({}))
@@ -164,6 +212,15 @@ describe('useKoreaderSync', () => {
expect(credentials.value).toBeNull()
})
it('deleteCredentials throws when the delete request fails', async () => {
apiMock.mockResolvedValueOnce(makeResponse({}, { ok: false, status: 500 }))
const { useKoreaderSync } = await import('../useKoreaderSync')
const { deleteCredentials } = useKoreaderSync()
await expect(deleteCredentials()).rejects.toThrow('Failed to delete credentials')
})
it('testConnection returns true on success response', async () => {
apiMock.mockResolvedValueOnce(makeResponse({ success: true }))
@@ -187,11 +244,11 @@ describe('useKoreaderSync', () => {
await expect(testConnection('reader-user', 'secret')).resolves.toBe(false)
})
it('getSyncUrl returns current origin plus koreader endpoint', async () => {
it('getSyncUrl returns current origin for plugin setup', async () => {
const { useKoreaderSync } = await import('../useKoreaderSync')
const { getSyncUrl } = useKoreaderSync()
expect(getSyncUrl()).toBe(`${window.location.origin}/api/v1/koreader`)
expect(getSyncUrl()).toBe(window.location.origin)
})
it('downloadPluginPackage requests the zip with the current origin and triggers a download', async () => {
@@ -231,4 +288,13 @@ describe('useKoreaderSync', () => {
await expect(downloadPluginPackage()).rejects.toThrow('Create KOReader sync credentials first')
})
it('downloadPluginPackage uses the fallback error when the response body is not JSON', async () => {
apiMock.mockResolvedValueOnce(makeInvalidJsonResponse())
const { useKoreaderSync } = await import('../useKoreaderSync')
const { downloadPluginPackage } = useKoreaderSync()
await expect(downloadPluginPackage()).rejects.toThrow('Failed to download the plugin')
})
})
@@ -64,7 +64,7 @@ export function useKoreaderSync() {
}
function getSyncUrl(): string {
return `${window.location.origin}/api/v1/koreader`
return window.location.origin
}
async function downloadPluginPackage(): Promise<void> {
@@ -360,7 +360,7 @@ async function handleDownloadPlugin() {
<div class="px-4 py-4 bg-card md:px-5">
<div class="mb-2 flex items-center gap-2">
<BookOpen :size="14" class="text-muted-foreground shrink-0" />
<p class="settings-label">Sync server URL</p>
<p class="settings-label">Plugin server URL</p>
</div>
<div class="flex flex-col gap-2 md:flex-row md:items-center">
<input :value="syncUrl" readonly class="input-field flex-1 min-w-0 font-mono text-xs md:text-sm" />
@@ -92,7 +92,7 @@ describe('KoreaderSettings', () => {
koreaderMock.createCredentials.mockResolvedValue(undefined)
koreaderMock.updateCredentials.mockResolvedValue(undefined)
koreaderMock.deleteCredentials.mockResolvedValue(undefined)
koreaderMock.getSyncUrl.mockReturnValue('https://bookorbit.example/api/v1/koreader')
koreaderMock.getSyncUrl.mockReturnValue('https://bookorbit.example')
koreaderMock.downloadPluginPackage.mockResolvedValue(undefined)
})
@@ -151,7 +151,7 @@ describe('KoreaderSettings', () => {
expect(wrapper.text()).toContain('14 books')
expect(wrapper.text()).toContain('1 device')
const syncUrlInput = wrapper.find('input[readonly]').element as HTMLInputElement
expect(syncUrlInput.value).toBe('https://bookorbit.example/api/v1/koreader')
expect(syncUrlInput.value).toBe('https://bookorbit.example')
expect(wrapper.text()).toContain('Kobo Libra 2')
expect(wrapper.text()).toContain('Project Hail Mary')
expect(wrapper.text()).toContain('Latest plugin: v0.5.0')
@@ -252,7 +252,7 @@ describe('KoreaderSettings', () => {
expect(koreaderMock.fetchSyncStatus).toHaveBeenCalledTimes(2)
expect(koreaderMock.updateCredentials).toHaveBeenCalledWith({ syncEnabled: false })
expect(vi.mocked(copyToClipboard)).toHaveBeenCalledWith('https://bookorbit.example/api/v1/koreader')
expect(vi.mocked(copyToClipboard)).toHaveBeenCalledWith('https://bookorbit.example')
expect(koreaderMock.downloadPluginPackage).toHaveBeenCalledTimes(1)
expect(wrapper.text()).toContain('Delete KOReader credentials?')
+15 -11
View File
@@ -2,7 +2,7 @@
Syncs your KOReader reading life into BookOrbit:
- Native BookOrbit catalog browsing from KOReader: "Continue reading", recently added books, libraries, collections, SmartScopes, authors, series, all books, "On device" and scoped or global search. Browse 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 a filter-within box. 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. The thumbnail cache is bounded and evicts the oldest covers.
- 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. 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.
@@ -29,13 +29,14 @@ Re-downloading later (for example after changing the sync password or server add
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 "Browse BookOrbit" to browse and download books from the same account.
- Use "Open dashboard" to open the BookOrbit dashboard, browse and download books from the same account.
## 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: tune "Periodically sync every # pages" (default 10, 0 disables mid-session pushes).
3. Optional: assign the "BookOrbit: sync this book" and "BookOrbit: sync all books" actions to gestures.
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.
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).
@@ -75,19 +76,22 @@ Years of statistics upload in batches of 500 events. Roughly 200k events means a
## 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.
- Catalog browser: "Browse BookOrbit" appears in the Tools menu after login. "Continue reading", recently added books, libraries, collections, SmartScopes, authors, series, all books and "On device" load through the BookOrbit JSON API, with page navigation, scoped/global search and sorting.
- Catalog search: on the root and browse screens the title-bar left button is a magnifier that opens global search; on a book page it becomes the actions menu (which still offers "Search in this scope"). Search is no longer a row in the root list.
- Catalog view and filters (actions menu, title-bar left button on a book page): toggle mosaic/list view, change the mosaic grid size, filter by read status (all/unread/reading/finished) and by format, sort, and reverse the sort direction (asc/desc). View mode, sort and grid size persist across sessions; the active sort, direction and filters show in the page subtitle.
- 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.
- 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. "Full description" opens the complete text, "All genres"/"All tags" list every value (the detail page itself only shows the first few with a "+N more" hint).
- 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: a "Filter..." row narrows the list as you type and "Next/Previous page" rows move through large libraries.
- 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, choose a file format, change folder/name if needed, confirm overwrite, watch the download progress, then accept "Open now". 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 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, and is hidden once every supported format is downloaded. The file chooser marks already-downloaded formats as "Open" instead of "Download". On-device state is refreshed when the detail opens.
- 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 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.
@@ -41,6 +41,7 @@ function BookOrbitApi.normalizeServerUrl(input)
local url = util.trim(input)
if url == "" then return nil end
url = url:gsub("/+$", "")
url = url:gsub("/api/v1/koreader$", "/api/v1")
if not url:match("/api/v1$") then
url = url .. "/api/v1"
end
@@ -252,6 +253,14 @@ function BookOrbitApi:catalogRoot()
return self:request("GET", "/koreader/plugin/catalog/root")
end
function BookOrbitApi:catalogDashboard()
return self:request("GET", "/koreader/plugin/catalog/dashboard")
end
function BookOrbitApi:catalogDiscover()
return self:request("GET", "/koreader/plugin/catalog/dashboard/discover")
end
function BookOrbitApi:catalogSection(section, params)
return self:request("GET", self:query("/koreader/plugin/catalog/sections/" .. util.urlEncode(section), params))
end
File diff suppressed because it is too large Load Diff
@@ -40,7 +40,7 @@ function CatalogDownload.install(Catalog)
if path then
self:openDownloadedFile(path)
else
self:showDownloadDialog(detail, files[1])
self:downloadDefaultFile(detail, files[1])
end
return
end
@@ -60,7 +60,7 @@ function CatalogDownload.install(Catalog)
if path then
self:openDownloadedFile(path)
else
self:showDownloadDialog(detail, file)
self:downloadDefaultFile(detail, file)
end
end,
},
@@ -84,6 +84,46 @@ function CatalogDownload.install(Catalog)
return (download_dir ~= "/" and download_dir or "") .. "/" .. filename
end
function Catalog:downloadDefaultFile(detail, file)
local filename = safeFilenameBase(detail)
local filetype = string.lower(file.format or "bin")
local local_path = self:getLocalDownloadPath(filename, filetype)
self:checkDownloadFile(local_path, detail, file)
end
function Catalog:showDownloadOptions(detail)
local files = self:supportedFiles(detail)
if #files == 0 then
UIManager:show(InfoMessage:new{ text = _("No KOReader-supported file found."), timeout = 3 })
return
end
if #files == 1 then
self:showDownloadDialog(detail, files[1])
return
end
local dialog
local buttons = {}
for _, file in ipairs(files) do
local label = self:fileLabel(file, false)
table.insert(buttons, {
{
text = T(_("Options - %1"), label),
callback = function()
UIManager:close(dialog)
self:showDownloadDialog(detail, file)
end,
},
})
end
dialog = ButtonDialog:new{
title = _("Download options"),
buttons = buttons,
}
UIManager:show(dialog)
end
function Catalog:showDownloadDialog(detail, file)
local filename = safeFilenameBase(detail)
local filetype = string.lower(file.format or "bin")
@@ -222,6 +262,10 @@ function CatalogDownload.install(Catalog)
local linked = self:linkDownloadedFile(local_path)
if linked then
self:refreshOnDevice()
if self.markStackDirty then self:markStackDirty() end
if self.detailMode and self:detailMode() then
self:refreshDetailView()
end
end
self:showDownloadedDialog(local_path, linked)
end
@@ -16,9 +16,14 @@ local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local HorizontalGroup = require("ui/widget/horizontalgroup")
local HorizontalSpan = require("ui/widget/horizontalspan")
local IconWidget = require("ui/widget/iconwidget")
local ImageWidget = require("ui/widget/imagewidget")
local InputContainer = require("ui/widget/container/inputcontainer")
local LeftContainer = require("ui/widget/container/leftcontainer")
local LineWidget = require("ui/widget/linewidget")
local OverlapGroup = require("ui/widget/overlapgroup")
local ProgressWidget = require("ui/widget/progresswidget")
local RightContainer = require("ui/widget/container/rightcontainer")
local Size = require("ui/size")
local TextBoxWidget = require("ui/widget/textboxwidget")
local VerticalGroup = require("ui/widget/verticalgroup")
@@ -31,7 +36,17 @@ local Screen = Device.screen
local shortText = CatalogUtil.shortText
local firstAuthor = CatalogUtil.firstAuthor
local PROGRESS_BAR_HEIGHT = Screen:scaleBySize(5)
local PROGRESS_BAR_HEIGHT = Screen:scaleBySize(3)
local SCALE_BY_SIZE = Screen:scaleBySize(1000000) * (1 / 1000000)
local READ_STATUS_BADGE_ICONS = {
want_to_read = "bookmark",
reading = "dogear.reading",
on_hold = "dogear.abandoned",
rereading = "cre.render.reload",
read = "check",
skimmed = "check",
abandoned = "dogear.abandoned",
}
local CatalogWidgets = {}
@@ -39,8 +54,20 @@ local function hasProgress(book)
return book and book.progressPercentage and book.progressPercentage > 0
end
-- A slim solid progress bar, e-ink friendly (no animation), used to visualize
-- reading progress on cards and rows.
local function readStatusBadgeIcon(book)
local status = book and book.readStatus
if not status or status == "" or status == "unread" then return nil end
return READ_STATUS_BADGE_ICONS[status] or "dogear.reading"
end
local function rowFontSize(nominal, max_size, row_h)
local size = math.floor(nominal * row_h * (1 / 64) / SCALE_BY_SIZE)
if max_size and size > max_size then return max_size end
return math.max(8, size)
end
-- A slim borderless progress bar, e-ink friendly (no animation): a light-gray
-- track with a solid black fill, used to visualize reading progress on cards.
function CatalogWidgets.buildProgressBar(percentage, width)
if not percentage or percentage <= 0 then return nil end
local bar = ProgressWidget:new{
@@ -49,8 +76,8 @@ function CatalogWidgets.buildProgressBar(percentage, width)
percentage = math.min(1, percentage / 100),
margin_h = 0,
margin_v = 0,
bordersize = Size.border.thin,
bgcolor = Blitbuffer.COLOR_WHITE,
bordersize = 0,
bgcolor = Blitbuffer.COLOR_LIGHT_GRAY,
fillcolor = Blitbuffer.COLOR_BLACK,
}
return bar
@@ -134,6 +161,19 @@ function CatalogWidgets.buildCoverWidget(book, width, height, path, state)
return CatalogWidgets.buildFakeCover(book, width, height, footer)
end
function CatalogWidgets.buildReadStatusBadge(book, max_width)
local icon = readStatusBadgeIcon(book)
if not icon then return nil end
local size = math.max(Screen:scaleBySize(12), math.min(max_width, Screen:scaleBySize(20)))
return IconWidget:new{
icon = icon,
rotation_angle = 270,
width = size,
height = size,
}
end
local MosaicItem = InputContainer:extend{
entry = nil,
dimen = nil,
@@ -222,45 +262,114 @@ function ListItem:init()
}
local book = self.entry.book
local pad = Size.padding.default
local inner_h = math.max(1, self.dimen.h - 2 * Size.border.thin)
local cover_h = math.max(Screen:scaleBySize(40), inner_h - 2 * Size.padding.small)
local pad = Size.padding.small
local gap = Size.span.horizontal_default
local separator_h = Size.line.medium
local inner_h = math.max(1, self.dimen.h - separator_h)
local content_h = math.max(1, inner_h - 2 * Size.padding.small)
local cover_h = math.max(Screen:scaleBySize(38), math.min(Screen:scaleBySize(74), content_h))
local cover_w = math.floor(cover_h * 0.68)
local text_w = math.max(1, self.dimen.w - cover_w - 3 * pad)
local bar_reserve = hasProgress(book) and (PROGRESS_BAR_HEIGHT + Size.span.vertical_default) or 0
local left_w = cover_w + 2 * pad
local side_meta_text = self.menu:listSideMetaText(book)
local show_side_meta = side_meta_text ~= "" and self.dimen.w >= Screen:scaleBySize(520)
local right_w = show_side_meta and math.min(Screen:scaleBySize(168), math.floor(self.dimen.w * 0.20)) or 0
local right_pad = show_side_meta and pad or 0
local main_x = left_w + gap
local main_w = math.max(1, self.dimen.w - main_x - right_w - right_pad - gap)
local text_h = math.max(1, content_h - bar_reserve)
local title_font = rowFontSize(18, 20, inner_h)
local subtitle_font = rowFontSize(14, 16, inner_h)
local side_font = rowFontSize(13, 15, inner_h)
local title_h = math.max(1, math.floor(text_h * 0.52))
local subtitle_h = math.max(1, text_h - title_h)
local path = self.menu:cachedThumbnailPath(book)
local state = self.menu:thumbnailState(book)
local text_col = VerticalGroup:new{ align = "left" }
table.insert(text_col, TextBoxWidget:new{
text = self.menu:listText(book),
width = text_w,
height = math.max(1, inner_h - 2 * Size.padding.small - bar_reserve),
text = BD.auto(shortText(book and book.title or _("Untitled"), 58)),
width = main_w,
height = title_h,
height_adjust = true,
alignment = "left",
face = Font:getFace("smallinfofont"),
bold = true,
face = Font:getFace("cfont", title_font),
height_overflow_show_ellipsis = true,
})
local bar = CatalogWidgets.buildProgressBar(book and book.progressPercentage, text_w)
if bar then
table.insert(text_col, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(text_col, bar)
local subtitle = self.menu:listSubtitleLine(book)
if subtitle then
table.insert(text_col, TextBoxWidget:new{
text = BD.auto(subtitle),
width = main_w,
height = subtitle_h,
height_adjust = true,
alignment = "left",
face = Font:getFace("cfont", subtitle_font),
height_overflow_show_ellipsis = true,
})
end
local row = HorizontalGroup:new{ align = "center" }
table.insert(row, HorizontalSpan:new{ width = pad })
table.insert(row, CatalogWidgets.buildCoverWidget(book, cover_w, cover_h, path, state))
table.insert(row, HorizontalSpan:new{ width = pad })
table.insert(row, text_col)
local body_col = VerticalGroup:new{ align = "left" }
table.insert(body_col, text_col)
local bar = CatalogWidgets.buildProgressBar(book and book.progressPercentage, main_w)
if bar then
table.insert(body_col, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(body_col, bar)
end
local row_dimen = Geom:new{ w = self.dimen.w, h = inner_h }
local row = OverlapGroup:new{
dimen = row_dimen:copy(),
LeftContainer:new{
dimen = row_dimen:copy(),
CenterContainer:new{
dimen = Geom:new{ w = left_w, h = inner_h },
CatalogWidgets.buildCoverWidget(book, cover_w, cover_h, path, state),
},
},
LeftContainer:new{
dimen = row_dimen:copy(),
HorizontalGroup:new{
HorizontalSpan:new{ width = main_x },
body_col,
},
},
}
if show_side_meta then
table.insert(row, RightContainer:new{
dimen = row_dimen:copy(),
HorizontalGroup:new{
TextBoxWidget:new{
text = side_meta_text,
width = right_w,
height = text_h,
height_adjust = true,
alignment = "right",
face = Font:getFace("cfont", side_font),
height_overflow_show_ellipsis = true,
},
HorizontalSpan:new{ width = right_pad },
},
})
end
local content = VerticalGroup:new{ align = "left" }
table.insert(content, row)
table.insert(content, LineWidget:new{
background = Blitbuffer.COLOR_LIGHT_GRAY,
dimen = Geom:new{ w = self.dimen.w, h = separator_h },
})
self[1] = FrameContainer:new{
width = self.dimen.w,
height = self.dimen.h,
margin = 0,
padding = 0,
bordersize = Size.border.thin,
bordersize = 0,
background = Blitbuffer.COLOR_WHITE,
row,
content,
}
end
@@ -274,7 +383,229 @@ function ListItem:onHoldSelect()
return true
end
-- Shared card chrome for dashboard tiles: thin rounded border on a white fill.
local CARD_BORDER = Size.border.thin
local CARD_RADIUS = Size.radius.window
local COVER_CARD_PAD = Size.padding.small
-- Typical book-cover aspect ratio (width / height); covers are sized to this so
-- the card hugs the cover with no horizontal whitespace.
local COVER_ASPECT = 0.66
local function cardFrame(width, height, padding, child)
return FrameContainer:new{
width = width,
height = height,
margin = 0,
padding = padding,
bordersize = CARD_BORDER,
radius = CARD_RADIUS,
background = Blitbuffer.COLOR_WHITE,
child,
}
end
-- The cover height a cover card derives from its total height (mirrors
-- DashboardCoverCard:init), reserving room for a progress bar when present.
local function coverCardCoverHeight(card_h, with_progress)
local inner_h = math.max(1, card_h - 2 * COVER_CARD_PAD - 2 * CARD_BORDER)
local bar_h = with_progress and (PROGRESS_BAR_HEIGHT + Size.span.vertical_default) or 0
return math.max(Screen:scaleBySize(60), inner_h - bar_h)
end
-- The width a cover card occupies for a given height, so the layout can lay out
-- a row of cards that tightly wrap their covers.
function CatalogWidgets.coverCardWidth(card_h, with_progress)
local cover_w = math.floor(coverCardCoverHeight(card_h, with_progress) * COVER_ASPECT)
return cover_w + 2 * COVER_CARD_PAD + 2 * CARD_BORDER
end
function CatalogWidgets.buildDashboardCoverWidget(book, width, height, path, state)
local cover = CatalogWidgets.buildCoverWidget(book, width, height, path, state)
local badge = CatalogWidgets.buildReadStatusBadge(book, math.floor(math.min(width, height) * 0.18))
if not badge then return cover end
badge.overlap_align = "right"
local dimen = Geom:new{ w = width, h = height }
return OverlapGroup:new{
dimen = dimen:copy(),
allow_mirroring = false,
cover,
badge,
}
end
-- A thin horizontal rule used to separate dashboard sections.
function CatalogWidgets.buildDivider(width)
return LineWidget:new{
background = Blitbuffer.COLOR_GRAY,
dimen = Geom:new{ w = width, h = Size.line.medium },
}
end
-- A bold section header (Continue reading / Browse).
function CatalogWidgets.buildSectionHeader(text, width, height)
return TextBoxWidget:new{
text = text,
width = width,
height = height,
alignment = "left",
bold = true,
face = Font:getFace("cfont", 18),
height_overflow_show_ellipsis = true,
}
end
-- A muted status line (Updated / offline cache / unavailable).
function CatalogWidgets.buildStatusLabel(text, width, height, alignment)
return TextBoxWidget:new{
text = text,
width = width,
height = height,
alignment = alignment or "left",
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
face = Font:getFace("xx_smallinfofont"),
height_overflow_show_ellipsis = true,
}
end
-- A cover-only book card: the cover fills the card, with an optional slim
-- progress bar pinned at the bottom. Used in the Continue reading / Discover rows.
local DashboardCoverCard = InputContainer:extend{
entry = nil,
dimen = nil,
menu = nil,
}
function DashboardCoverCard:init()
self.ges_events = {
TapSelect = { GestureRange:new{ ges = "tap", range = self.dimen } },
HoldSelect = { GestureRange:new{ ges = "hold", range = self.dimen } },
}
local book = self.entry.book
local pad = COVER_CARD_PAD
local inner_w = math.max(1, self.dimen.w - 2 * pad - 2 * CARD_BORDER)
local inner_h = math.max(1, self.dimen.h - 2 * pad - 2 * CARD_BORDER)
local bar_h = hasProgress(book) and (PROGRESS_BAR_HEIGHT + Size.span.vertical_default) or 0
local cover_h = math.max(Screen:scaleBySize(60), inner_h - bar_h)
local cover_w = math.min(inner_w, math.floor(cover_h * COVER_ASPECT))
local path = self.menu:cachedThumbnailPath(book)
local state = self.menu:thumbnailState(book)
local col = VerticalGroup:new{ align = "center" }
table.insert(col, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = cover_h },
CatalogWidgets.buildDashboardCoverWidget(book, cover_w, cover_h, path, state),
})
if bar_h > 0 then
table.insert(col, VerticalSpan:new{ width = Size.span.vertical_default })
table.insert(col, CatalogWidgets.buildProgressBar(book.progressPercentage, cover_w))
end
self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
col,
})
end
function DashboardCoverCard:onTapSelect()
self.menu:onMenuSelect(self.entry)
return true
end
function DashboardCoverCard:onHoldSelect()
self.menu:onMenuSelect(self.entry)
return true
end
-- A browse-grid tile: centred icon over a label, with an optional count.
local DashboardBrowseTile = InputContainer:extend{
entry = nil,
dimen = nil,
menu = nil,
}
function DashboardBrowseTile:init()
self.ges_events = {
TapSelect = { GestureRange:new{ ges = "tap", range = self.dimen } },
HoldSelect = { GestureRange:new{ ges = "hold", range = self.dimen } },
}
local entry = self.entry
local pad = Size.padding.small
local inner_w = math.max(1, self.dimen.w - 2 * pad - 2 * CARD_BORDER)
local inner_h = math.max(1, self.dimen.h - 2 * pad - 2 * CARD_BORDER)
local icon_size = math.min(Screen:scaleBySize(22), math.floor(inner_h * 0.45))
local label = entry.text or ""
if entry.mandatory then
label = label .. " (" .. tostring(entry.mandatory) .. ")"
end
local col = VerticalGroup:new{ align = "center" }
if entry.icon then
table.insert(col, IconWidget:new{ icon = entry.icon, width = icon_size, height = icon_size })
table.insert(col, VerticalSpan:new{ width = Size.span.vertical_default })
end
table.insert(col, TextBoxWidget:new{
text = BD.auto(shortText(label, 22)),
width = inner_w,
alignment = "center",
face = Font:getFace("x_smallinfofont"),
height_overflow_show_ellipsis = true,
})
self[1] = cardFrame(self.dimen.w, self.dimen.h, pad, CenterContainer:new{
dimen = Geom:new{ w = inner_w, h = inner_h },
col,
})
end
function DashboardBrowseTile:onTapSelect()
self.menu:onMenuSelect(self.entry)
return true
end
function DashboardBrowseTile:onHoldSelect()
self.menu:onMenuSelect(self.entry)
return true
end
-- A small tappable icon button, used for the Discover section's reroll control.
local DashboardIconButton = InputContainer:extend{
entry = nil,
dimen = nil,
menu = nil,
}
function DashboardIconButton:init()
self.ges_events = {
TapSelect = { GestureRange:new{ ges = "tap", range = self.dimen } },
HoldSelect = { GestureRange:new{ ges = "hold", range = self.dimen } },
}
local icon_size = math.max(1, math.min(self.dimen.w, self.dimen.h) - Size.padding.tiny)
self[1] = CenterContainer:new{
dimen = Geom:new{ w = self.dimen.w, h = self.dimen.h },
IconWidget:new{ icon = self.entry.icon, width = icon_size, height = icon_size },
}
end
function DashboardIconButton:onTapSelect()
self.menu:onMenuSelect(self.entry)
return true
end
function DashboardIconButton:onHoldSelect()
self.menu:onMenuSelect(self.entry)
return true
end
CatalogWidgets.MosaicItem = MosaicItem
CatalogWidgets.ListItem = ListItem
CatalogWidgets.DashboardCoverCard = DashboardCoverCard
CatalogWidgets.DashboardBrowseTile = DashboardBrowseTile
CatalogWidgets.DashboardIconButton = DashboardIconButton
return CatalogWidgets
@@ -6,6 +6,7 @@ the plugin zip from the BookOrbit server and atomically replaces the current
plugin directory. KOReader must be restarted for the new files to load.
]]
local Device = require("device")
local lfs = require("libs/libkoreader-lfs")
local BookOrbitUpdater = {}
@@ -63,21 +64,28 @@ function BookOrbitUpdater.apply(api, plugin_dir, progress_cb)
return nil, tostring(err or "download failed")
end
-- Extract into a staging directory so a partial unzip never touches the
-- live plugin directory.
local ret = os.execute("unzip -o " .. sq(tmp_zip) .. " -d " .. sq(staging))
os.remove(tmp_zip)
if ret ~= 0 then
os.execute("rm -rf " .. sq(staging))
return nil, "unzip failed with exit code " .. tostring(ret)
if not lfs.mkdir(staging) and lfs.attributes(staging, "mode") ~= "directory" then
os.remove(tmp_zip)
return nil, "could not create update staging directory"
end
-- The zip must contain exactly the plugin folder as its top-level entry.
local extracted = staging .. "/" .. plugin_name
if lfs.attributes(extracted, "mode") ~= "directory" then
-- Extract into a staging directory so a partial unpack never touches the
-- live plugin directory. Use KOReader's archive helper instead of the
-- platform unzip command: unzip warning exit codes vary by platform.
local ok_unpack, unpack_err = Device:unpackArchive(tmp_zip, staging, true)
os.remove(tmp_zip)
if not ok_unpack then
os.execute("rm -rf " .. sq(staging))
return nil, "update zip does not contain expected directory: " .. plugin_name
return nil, tostring(unpack_err or "archive extraction failed")
end
-- The zip must contain the plugin folder as its top-level entry. We strip
-- that root during extraction, so the staging directory should now look
-- like the plugin directory itself.
if lfs.attributes(staging .. "/main.lua", "mode") ~= "file" then
os.execute("rm -rf " .. sq(staging))
return nil, "update zip does not contain expected plugin files: " .. plugin_name
end
-- Atomic-ish swap (all paths share the same filesystem):
@@ -89,7 +97,7 @@ function BookOrbitUpdater.apply(api, plugin_dir, progress_cb)
os.execute("rm -rf " .. sq(staging))
return nil, "could not create plugin backup"
end
if not os.rename(extracted, dir) then
if not os.rename(staging, dir) then
-- Restore backup so the plugin remains usable.
os.rename(backup, dir)
os.execute("rm -rf " .. sq(staging))
+259 -129
View File
@@ -19,6 +19,7 @@ local Math = require("optmath")
local MultiInputDialog = require("ui/widget/multiinputdialog")
local NetworkMgr = require("ui/network/manager")
local Notification = require("ui/widget/notification")
local PluginShare = require("pluginshare")
local UIManager = require("ui/uimanager")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local logger = require("logger")
@@ -37,7 +38,7 @@ local BookOrbitMenuPin = require("bookorbit_menu_pin")
local BookOrbitSweep = require("bookorbit_sweep")
local BookOrbitUpdater = require("bookorbit_updater")
local PLUGIN_VERSION = "0.1.0"
local PLUGIN_VERSION = "1.0.0"
local SYNC_STRATEGY = {
PROMPT = 1,
@@ -75,6 +76,8 @@ BookOrbit.default_settings = {
catalog_grid_cols = 3,
catalog_grid_rows = 3,
catalog_recent_searches = {},
catalog_auto_open = "off",
catalog_dashboard_cache = nil,
update_check_last_at = 0,
}
@@ -85,6 +88,7 @@ function BookOrbit:init()
self.last_page_turn_timestamp = 0
self.page_update_counter = 0
self.periodic_push_scheduled = false
self.provision_applied = false
self.periodic_push_task = function()
self.periodic_push_scheduled = false
self.page_update_counter = 0
@@ -132,6 +136,9 @@ function BookOrbit:init()
if self.settings.update_check_last_at == nil then
self.settings.update_check_last_at = 0
end
if self.settings.catalog_auto_open == nil then
self.settings.catalog_auto_open = "off"
end
self:applyProvision()
@@ -143,6 +150,7 @@ function BookOrbit:init()
pcall(BookOrbitMenuPin.ensure)
self:onDispatcherRegisterActions()
self.ui.menu:registerToMainMenu(self)
self:onStart()
UIManager:scheduleIn(5, function()
self:maybeCheckForUpdate(false)
end)
@@ -181,6 +189,7 @@ function BookOrbit:applyProvision()
self.settings.username = provision.username
self.settings.userkey = provision.userkey
self.settings.provision_fingerprint = fingerprint
self.provision_applied = true
G_reader_settings:flush()
logger.info("BookOrbit: applied provision file for", provision.username)
UIManager:nextTick(function()
@@ -212,6 +221,28 @@ function BookOrbit:isLoggedIn()
return self.settings.server_url ~= nil and self.settings.username ~= nil and self.settings.userkey ~= nil
end
function BookOrbit:onStart()
if PluginShare.bookorbit_auto_open_done then return end
PluginShare.bookorbit_auto_open_done = true
local mode = self.settings.catalog_auto_open or "off"
if mode == "off" then return end
UIManager:scheduleIn(1.2, function()
self:maybeAutoOpenCatalog(mode)
end)
end
function BookOrbit:maybeAutoOpenCatalog(mode)
if mode ~= "filemanager" and mode ~= "always" then return end
if self.provision_applied then return end
if not self:isLoggedIn() then return end
if self.catalog_browser ~= nil then return end
if mode == "filemanager" and not (self.ui and self.ui.file_chooser ~= nil) then
return
end
self:browseCatalog(true)
end
local function showSyncError()
UIManager:show(InfoMessage:new{
text = _("Something went wrong when syncing to BookOrbit, please check your network connection and try again later."),
@@ -331,6 +362,34 @@ function BookOrbit:updateCheckMenuText()
return T(_("Installed plugin: v%1 (Login required)"), PLUGIN_VERSION)
end
function BookOrbit:catalogAutoOpenLabel()
local mode = self.settings.catalog_auto_open or "off"
if mode == "filemanager" then
return _("File manager only")
elseif mode == "always" then
return _("Every startup")
end
return _("Off")
end
function BookOrbit:catalogAutoOpenMenu()
local function item(text, value)
return {
text = text,
checked_func = function() return (self.settings.catalog_auto_open or "off") == value end,
callback = function()
self.settings.catalog_auto_open = value
G_reader_settings:flush()
end,
}
end
return {
item(_("Off"), "off"),
item(_("File manager only"), "filemanager"),
item(_("Every startup"), "always"),
}
end
function BookOrbit:addToMainMenu(menu_items)
menu_items.bookorbit = {
text = _("BookOrbit"),
@@ -339,108 +398,42 @@ function BookOrbit:addToMainMenu(menu_items)
sorting_hint = "tools",
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,
separator = true,
},
{
text = _("Browse library"),
text = _("Open dashboard"),
enabled_func = function()
return self:isLoggedIn()
end,
callback = function()
self:browseCatalog()
end,
separator = true,
},
{
text = _("Auto sync this 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)
text_func = function()
return self:updateCheckMenuText()
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.]]),
enabled_func = function()
return self:isLoggedIn()
and not BookOrbitSweep.isRunning()
and not BookOrbitBookSync.isRunning()
end,
keep_menu_open = true,
callback = function()
self.settings.annotation_sync = not self.settings.annotation_sync
self:checkForUpdate()
end,
},
{
text_func = function()
return T(_("Periodically sync every # pages (%1)"), self:getSyncPeriod())
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_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 = _("Sync behavior"),
sub_item_table = {
{
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
),
},
},
enabled = false,
separator = true,
},
{
text = _("Sync this book now"),
@@ -463,61 +456,198 @@ If set to 0, updating progress based on page turns will be disabled.]]),
separator = true,
},
{
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,
text = _("Auto sync this 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:checkForUpdate()
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,
separator = true,
},
{
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 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 = _("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,
},
},
},
},
}
end
function BookOrbit:browseCatalog()
function BookOrbit:dashboardMenuItems(catalog)
local menu_items = {}
self:addToMainMenu(menu_items)
local bookorbit_items = menu_items.bookorbit.sub_item_table or {}
local items = {
icon = "appbar.menu",
}
for index, item in ipairs(bookorbit_items) do
if index ~= 1 then
table.insert(items, item)
end
end
return items
end
function BookOrbit:showDashboardMenu(catalog)
local CenterContainer = require("ui/widget/container/centercontainer")
local TouchMenu = require("ui/widget/touchmenu")
if self.dashboard_menu_container then
local old_container = self.dashboard_menu_container
self.dashboard_menu_container = nil
UIManager:close(old_container)
end
local menu_container = CenterContainer:new{
covers_header = true,
ignore = "height",
dimen = Device.screen:getSize(),
}
local dashboard_menu = TouchMenu:new{
width = Device.screen:getWidth(),
last_index = 1,
tab_item_table = { self:dashboardMenuItems(catalog) },
show_parent = menu_container,
}
local closing = false
dashboard_menu.close_callback = function()
if closing then return true end
closing = true
if self.dashboard_menu_container == menu_container then
self.dashboard_menu_container = nil
end
UIManager:close(menu_container)
return true
end
menu_container[1] = dashboard_menu
self.dashboard_menu_container = menu_container
UIManager:show(menu_container)
end
function BookOrbit:openCatalogBrowser(prefer_cached_dashboard)
if self.catalog_browser ~= nil then return end
self.catalog_browser = BookOrbitCatalog:new{
title = _("BookOrbit"),
api = self:apiOpts(),
settings = self.settings,
prefer_cached_dashboard = prefer_cached_dashboard,
save_settings = function()
G_reader_settings:flush()
end,
show_dashboard_menu = function(catalog)
self:showDashboardMenu(catalog)
end,
_manager = self,
close_callback = function()
UIManager:close(self.catalog_browser)
self.catalog_browser = nil
end,
}
UIManager:show(self.catalog_browser)
end
function BookOrbit:browseCatalog(allow_offline)
if not self:isLoggedIn() then
promptLogin()
return
end
NetworkMgr:runWhenConnected(function()
self.catalog_browser = BookOrbitCatalog:new{
title = _("BookOrbit"),
api = self:apiOpts(),
settings = self.settings,
save_settings = function()
G_reader_settings:flush()
end,
_manager = self,
close_callback = function()
UIManager:close(self.catalog_browser)
self.catalog_browser = nil
end,
}
UIManager:show(self.catalog_browser)
end)
if allow_offline then
self:openCatalogBrowser(true)
else
NetworkMgr:runWhenConnected(function()
self:openCatalogBrowser(false)
end)
end
end
function BookOrbit:setServerAddress()
@@ -1006,7 +1136,7 @@ function BookOrbit:onBookOrbitToggleAutoSync(toggle, from_menu)
and Device:hasSeamlessWifiToggle()
and G_reader_settings:readSetting("wifi_enable_action") ~= "turn_on" then
UIManager:show(InfoMessage:new{
text = _("You will have to switch the 'Action when Wi-Fi is off' Network setting to 'turn on' to be able to enable this feature!"),
text = _("Auto sync needs KOReader to turn Wi-Fi on without asking. Open Network settings, set 'Action when Wi-Fi is off' to 'Turn on', then enable Auto sync again."),
})
return true
end
+16
View File
@@ -1,3 +1,5 @@
import type { HighlightOfTheDayWidgetData, ReadingGoalWidgetData, ReadingStreakWidgetData } from "./dashboard";
export interface KoreaderCredentials {
username: string;
syncEnabled: boolean;
@@ -195,6 +197,20 @@ export interface KoreaderCatalogSectionResponse {
query?: string | null;
}
export interface KoreaderCatalogDashboardResponse {
generatedAt: string;
sections: KoreaderCatalogEntry[];
continueReading: KoreaderCatalogBookListItem[];
discover: KoreaderCatalogBookListItem[];
readingGoal: ReadingGoalWidgetData;
readingStreak: ReadingStreakWidgetData;
highlightOfTheDay: HighlightOfTheDayWidgetData | null;
}
export interface KoreaderCatalogDiscoverResponse {
discover: KoreaderCatalogBookListItem[];
}
export interface KoreaderCatalogReadStatusResult {
readStatus: KoreaderCatalogSettableReadStatus;
}
+33 -8
View File
@@ -149,6 +149,7 @@ function makeService(overrides: { bookMetadataLockService?: unknown } = {}) {
const libraryService = {
verifyUserAccess: vi.fn().mockResolvedValue(undefined),
findAll: vi.fn().mockResolvedValue([]),
findOne: vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 }),
};
const queryBuilder = {
buildWhere: vi.fn(),
@@ -2040,7 +2041,7 @@ describe('BookService', () => {
bookRepo.findFileById.mockResolvedValue({ id: 7, bookId: 10, libraryId: 1, absolutePath: '/books/a.m4b', format: 'm4b' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue(null);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
await service.saveProgress(user.id, 7, { percentage: 25, positionSeconds: 900 } as never, user);
@@ -2048,21 +2049,42 @@ describe('BookService', () => {
});
it('passes null positionSeconds when not provided in DTO', async () => {
const { service, bookRepo, libraryService } = makeService();
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser();
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue(null);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 3, markAsFinishedPercentComplete: 97 });
await service.saveProgress(user.id, 8, { percentage: 50 } as never, user);
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 8, null, null, 50, null, null, null, null, null, null);
expect(libraryService.findOne).toHaveBeenCalledWith(2);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 3, 97);
});
it('does not fail progress save when auto status update fails', async () => {
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser();
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
bookRepo.upsertProgress.mockResolvedValue(undefined);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 3, markAsFinishedPercentComplete: 97 });
userBookStatusService.autoUpdate.mockRejectedValueOnce(new Error('status update failed'));
await expect(service.saveProgress(user.id, 8, { percentage: 50 } as never, user)).resolves.toBeUndefined();
expect(bookRepo.upsertProgress).toHaveBeenCalledWith(user.id, 8, null, null, 50, null, null, null, null, null, null);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 3, 97);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[book.progress_status_update] [fail] userId=1 bookId=11 libraryId=2'));
warnSpy.mockRestore();
});
it('mirrors EPUB percentage to Kobo state for users with Kobo sync permission', async () => {
const { service, bookRepo, libraryService } = makeService();
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser({ permissions: [Permission.KoboSync] });
bookRepo.findFileById.mockResolvedValue({ id: 8, bookId: 11, libraryId: 2, absolutePath: '/books/b.epub', format: 'epub' });
@@ -2070,7 +2092,7 @@ describe('BookService', () => {
bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(true);
bookRepo.syncKoboReadingStateFromProgress.mockResolvedValue(true);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 4, markAsFinishedPercentComplete: 90 });
await service.saveProgress(
user.id,
@@ -2087,6 +2109,7 @@ describe('BookService', () => {
);
expect(bookRepo.syncKoboReadingStateFromProgress).toHaveBeenCalledWith(user.id, 8, 50, 'OEBPS/ch1.xhtml', 'KoboSpan', 'kobo.25.1', 25);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 11, 50, 4, 90);
});
it('does not mirror EPUB percentage to Kobo state when two-way sync is disabled', async () => {
@@ -2097,7 +2120,7 @@ describe('BookService', () => {
bookRepo.upsertProgress.mockResolvedValue(undefined);
bookRepo.isKoboTwoWayProgressSyncEnabled.mockResolvedValue(false);
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 1, markAsFinishedPercentComplete: 99 });
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 4, markAsFinishedPercentComplete: 90 });
await service.saveProgress(user.id, 8, { percentage: 50 } as never, user);
@@ -2142,7 +2165,7 @@ describe('BookService', () => {
describe('saveAudioProgress', () => {
it('writes audio progress when current file belongs to the target book', async () => {
const { service, bookRepo, libraryService } = makeService();
const { service, bookRepo, libraryService, userBookStatusService } = makeService();
const user = makeUser({ id: 21 });
bookRepo.findLibraryIdByBookId.mockResolvedValue(1);
@@ -2154,7 +2177,7 @@ describe('BookService', () => {
libraryId: 1,
});
libraryService.verifyUserAccess.mockResolvedValue(undefined);
libraryService.findOne = vi.fn().mockResolvedValue(null);
libraryService.findOne = vi.fn().mockResolvedValue({ readingThreshold: 4, markAsFinishedPercentComplete: 90 });
await service.saveAudioProgress(
user.id,
@@ -2168,6 +2191,8 @@ describe('BookService', () => {
);
expect(bookRepo.upsertAudioProgress).toHaveBeenCalledWith(user.id, 10, 7, 120, 33);
expect(libraryService.findOne).toHaveBeenCalledWith(1);
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(user.id, 10, 33, 4, 90);
});
it('throws BadRequestException when current file belongs to a different book', async () => {
+20 -10
View File
@@ -215,6 +215,11 @@ type LibraryWriteSettingsLookupResult = {
type PostMetadataSaveMode = 'sync' | 'schedule';
export type ProgressStatusFileContext = {
bookId: number;
libraryId: number;
};
@Injectable()
export class BookService {
private readonly logger = new Logger(BookService.name);
@@ -1830,10 +1835,20 @@ export class BookService {
throw new BadRequestException(`currentFileId ${dto.currentFileId} does not belong to book ${bookId}`);
}
await this.bookRepo.upsertAudioProgress(userId, bookId, dto.currentFileId, dto.positionSeconds, dto.percentage);
this.libraryService
.findOne(libraryId)
.then((lib) => this.userBookStatusService.autoUpdate(userId, bookId, dto.percentage, lib.readingThreshold, lib.markAsFinishedPercentComplete))
.catch((err: Error) => this.logger.warn(`Auto status update failed for book ${bookId}: ${err.message}`));
await this.autoUpdateReadStatusForProgress(userId, { bookId, libraryId }, dto.percentage);
}
async autoUpdateReadStatusForProgress(userId: number, file: ProgressStatusFileContext, percentage: number): Promise<void> {
const startedAt = Date.now();
try {
const library = await this.libraryService.findOne(file.libraryId);
await this.userBookStatusService.autoUpdate(userId, file.bookId, percentage, library.readingThreshold, library.markAsFinishedPercentComplete);
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn(
`[book.progress_status_update] [fail] userId=${userId} bookId=${file.bookId} libraryId=${file.libraryId} durationMs=${Date.now() - startedAt} errorClass=${err.constructor.name} error="${sanitizeLogValue(err.message)}" - auto status update failed`,
);
}
}
/**
@@ -1872,12 +1887,7 @@ export class BookService {
dto.koboContentSourceProgressPercent ?? null,
);
}
this.libraryService
.findOne(file.libraryId)
.then((lib) =>
this.userBookStatusService.autoUpdate(userId, file.bookId, dto.percentage, lib.readingThreshold, lib.markAsFinishedPercentComplete),
)
.catch((err: Error) => this.logger.warn(`Auto status update failed for book ${file.bookId}: ${err.message}`));
await this.autoUpdateReadStatusForProgress(userId, file, dto.percentage);
}
async clearFileProgress(userId: number, fileId: number, user: RequestUser): Promise<void> {
@@ -13,5 +13,6 @@ import { DashboardWidgetService } from './dashboard-widget.service';
imports: [BookModule, LibraryModule, SmartScopeModule],
controllers: [DashboardController],
providers: [DashboardService, DashboardRepository, DashboardWidgetService, DashboardWidgetRepository],
exports: [DashboardWidgetService],
})
export class DashboardModule {}
@@ -1,3 +1,5 @@
import { Logger } from '@nestjs/common';
import { KoboReadingStateService } from './kobo-reading-state.service';
import { ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../../achievement/achievement-events.service';
@@ -286,6 +288,31 @@ describe('KoboReadingStateService', () => {
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
expect.objectContaining({ userId: 1, bookId: 5, progress: 42.5, source: 'kobo' }),
);
expect(achievementEvents.emit.mock.invocationCallOrder[0]!).toBeGreaterThan(userBookStatusService.autoUpdate.mock.invocationCallOrder[0]!);
});
it('does not fail the Kobo progress update when auto status update fails', async () => {
const db = makeDb();
const stateInsert = makeInsertChain();
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
db.insert.mockReturnValue(stateInsert);
db.query.books.findFirst.mockResolvedValue({ id: 5 });
db.query.koboReadingStates.findFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ entitlementId: '5', currentBookmark: { ProgressPercent: 42.5 } });
userBookStatusService.autoUpdate.mockRejectedValueOnce(new Error('status update failed'));
await expect(
makeService(db).upsertState(1, 5, { CurrentBookmark: { LastModified: '2026-01-01T00:00:00Z', ProgressPercent: 42.5 } }, 1, 99, false),
).resolves.toEqual(expect.objectContaining({ EntitlementId: 'entitlement-5' }));
expect(userBookStatusService.autoUpdate).toHaveBeenCalledWith(1, 5, 42.5, 1, 99);
expect(achievementEvents.emit).toHaveBeenCalledWith(
ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED,
expect.objectContaining({ userId: 1, bookId: 5, progress: 42.5, source: 'kobo' }),
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('[kobo.reading_state_status_update] [fail] userId=1 bookId=5'));
warnSpy.mockRestore();
});
it('does not replace newer internal CFI progress with stale Kobo percent', async () => {
@@ -1,4 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
@@ -10,6 +10,7 @@ import { KoboBookAccessService } from './kobo-book-access.service';
import { KoboBookIdentityService } from './kobo-book-identity.service';
import { KoboProgressBridgeService } from './kobo-progress-bridge.service';
import { KoboSettingsService } from './kobo-settings.service';
import { sanitizeLogValue } from '../../../common/utils/log-sanitize.utils';
type Db = NodePgDatabase<typeof schema>;
type JsonObj = Record<string, unknown>;
@@ -46,6 +47,8 @@ function maxIsoTimestamp(...values: (string | null | undefined)[]): string | nul
@Injectable()
export class KoboReadingStateService {
private readonly logger = new Logger(KoboReadingStateService.name);
constructor(
@Inject(DB) private readonly db: Db,
private readonly bookAccessService: KoboBookAccessService,
@@ -163,7 +166,7 @@ export class KoboReadingStateService {
);
await this.markSnapshotBookUnsynced(userId, bookId);
}
void this.userBookStatusService.autoUpdate(userId, bookId, percent, readingThreshold, finishedThreshold);
await this.autoUpdateReadStatus(userId, bookId, percent, readingThreshold, finishedThreshold);
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
userId,
bookId,
@@ -175,6 +178,24 @@ export class KoboReadingStateService {
return this.getRawState(userId, bookId);
}
private async autoUpdateReadStatus(
userId: number,
bookId: number,
percent: number,
readingThreshold: number,
finishedThreshold: number,
): Promise<void> {
const startedAt = Date.now();
try {
await this.userBookStatusService.autoUpdate(userId, bookId, percent, readingThreshold, finishedThreshold);
} catch (error: unknown) {
const err = error instanceof Error ? error : new Error(String(error));
this.logger.warn(
`[kobo.reading_state_status_update] [fail] userId=${userId} bookId=${bookId} durationMs=${Date.now() - startedAt} errorClass=${err.constructor.name} error="${sanitizeLogValue(err.message)}" - auto status update failed`,
);
}
}
async getRawState(userId: number, bookId: number): Promise<unknown> {
const book = await this.db.query.books.findFirst({
where: eq(schema.books.id, bookId),
@@ -7,6 +7,7 @@ import { KoreaderCatalogController } from './koreader-catalog.controller';
function makeController() {
const catalogService = {
getRoot: vi.fn().mockReturnValue({ sections: [] }),
getDashboard: vi.fn().mockResolvedValue({ generatedAt: '2026-06-26T00:00:00.000Z', sections: [], continueReading: [] }),
getSectionEntries: vi.fn().mockResolvedValue({ section: 'libraries', items: [] }),
getBooksPage: vi
.fn()
@@ -34,6 +35,7 @@ describe('KoreaderCatalogController', () => {
const sectionQuery = { page: 1 } as never;
expect(controller.root()).toEqual({ sections: [] });
await expect(controller.dashboard(user)).resolves.toEqual(expect.objectContaining({ generatedAt: '2026-06-26T00:00:00.000Z' }));
await expect(controller.sections(user, 'libraries', sectionQuery)).resolves.toEqual({ section: 'libraries', items: [] });
await expect(controller.books(user, query)).resolves.toEqual(expect.objectContaining({ total: 0 }));
await expect(controller.bookDetail(user, 10)).resolves.toEqual({ id: 10 });
@@ -42,6 +44,7 @@ describe('KoreaderCatalogController', () => {
await controller.thumbnail(user, 10, reply, '"etag"');
await controller.download(user, 100, reply);
expect(catalogService.getDashboard).toHaveBeenCalledWith(user);
expect(catalogService.getSectionEntries).toHaveBeenCalledWith(user, 'libraries', sectionQuery);
expect(catalogService.getBooksPage).toHaveBeenCalledWith(user, query);
expect(catalogService.getBookDetail).toHaveBeenCalledWith(user, 10);
@@ -25,6 +25,18 @@ export class KoreaderCatalogController {
return this.catalogService.getRoot();
}
@Get('dashboard')
@Header('Cache-Control', 'private, max-age=30')
dashboard(@CurrentUser() user: RequestUser) {
return this.catalogService.getDashboard(user);
}
@Get('dashboard/discover')
@Header('Cache-Control', 'no-store')
discover(@CurrentUser() user: RequestUser) {
return this.catalogService.getDiscover(user);
}
@Get('sections/:section')
@Header('Cache-Control', 'private, max-age=30')
sections(@CurrentUser() user: RequestUser, @Param('section') section: string, @Query() query: KoreaderCatalogSectionQueryDto) {
@@ -92,6 +92,7 @@ function makeService() {
getDistinctAuthorsPage: vi.fn().mockResolvedValue({ items: [{ name: 'Frank Herbert', bookCount: 2 }], hasNext: false }),
getDistinctSeriesPage: vi.fn().mockResolvedValue({ items: [{ name: 'Dune', bookCount: 6 }], hasNext: false }),
getBooksPage: vi.fn().mockResolvedValue({ entries: [], total: 0 }),
getRandomBooks: vi.fn().mockResolvedValue([]),
};
const bookService = {
getDetail: vi.fn().mockResolvedValue(makeDetail()),
@@ -130,16 +131,30 @@ function makeService() {
findByBookIds: vi.fn().mockResolvedValue(new Map([[10, { status: 'reading' }]])),
setManual: vi.fn().mockResolvedValue(undefined),
};
const dashboardWidgetService = {
getReadingGoal: vi.fn().mockResolvedValue({ goalBooks: 24, completedBooks: 6, year: 2026 }),
getReadingStreak: vi.fn().mockResolvedValue({ currentStreak: 4, longestStreak: 9, lastSevenDays: [true, false, true, true, true, false, true] }),
getHighlightOfTheDay: vi.fn().mockResolvedValue({
text: 'Fear is the mind-killer.',
note: null,
bookTitle: 'Dune',
bookId: 10,
hasCover: true,
chapterTitle: 'Chapter 1',
createdAt: '2026-03-01T00:00:00.000Z',
}),
};
const service = new KoreaderCatalogService(
opdsBookService as never,
bookService as never,
bookReadService as never,
userBookStatusService as never,
dashboardWidgetService as never,
{ appDataPath: '/data', bookDockPath: '/data/book-dock' },
);
return { service, opdsBookService, bookService, bookReadService, userBookStatusService };
return { service, opdsBookService, bookService, bookReadService, userBookStatusService, dashboardWidgetService };
}
describe('KoreaderCatalogService', () => {
@@ -176,6 +191,95 @@ describe('KoreaderCatalogService', () => {
);
});
it('builds a capped dashboard payload from catalog and widget data', async () => {
const { service, opdsBookService, dashboardWidgetService } = makeService();
const user = makeUser({ id: 7 });
opdsBookService.getBooksPage.mockResolvedValueOnce({
total: 1,
entries: [
{
id: 10,
title: 'Dune',
folderPath: '/books/dune',
addedAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-02-01T00:00:00.000Z'),
description: null,
seriesName: 'Dune',
seriesIndex: 1,
language: 'en',
publisher: 'Ace',
isbn13: null,
hasCover: true,
authors: ['Frank Herbert'],
files: [{ id: 100, format: 'epub' }],
},
],
});
opdsBookService.getRandomBooks.mockResolvedValueOnce([
{
id: 22,
title: 'Neuromancer',
folderPath: '/books/neuromancer',
addedAt: new Date('2026-01-05T00:00:00.000Z'),
updatedAt: new Date('2026-02-05T00:00:00.000Z'),
description: null,
seriesName: null,
seriesIndex: null,
language: 'en',
publisher: 'Ace',
isbn13: null,
hasCover: true,
authors: ['William Gibson'],
files: [{ id: 200, format: 'epub' }],
},
]);
const dashboard = await service.getDashboard(user);
expect(opdsBookService.getBooksPage).toHaveBeenCalledWith(7, 'recently_read', 1, 5, { readStatus: 'reading' }, false, user.contentFilters);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 8, false, user.contentFilters);
expect(dashboard.sections.map((section) => section.id)).toContain('all-books');
expect(dashboard.continueReading[0]).toEqual(expect.objectContaining({ id: 10, progressPercentage: 47.4, readStatus: 'reading' }));
expect(dashboard.discover[0]).toEqual(expect.objectContaining({ id: 22, title: 'Neuromancer' }));
expect(dashboard.readingGoal).toEqual({ goalBooks: 24, completedBooks: 6, year: 2026 });
expect(dashboard.readingStreak.currentStreak).toBe(4);
expect(dashboard.highlightOfTheDay?.bookId).toBe(10);
expect(dashboard.generatedAt).toEqual(expect.any(String));
expect(dashboardWidgetService.getReadingGoal).toHaveBeenCalledWith(user);
expect(dashboardWidgetService.getReadingStreak).toHaveBeenCalledWith(user);
expect(dashboardWidgetService.getHighlightOfTheDay).toHaveBeenCalledWith(user);
});
it('rerolls discover books via getDiscover', async () => {
const { service, opdsBookService } = makeService();
const user = makeUser({ id: 7 });
opdsBookService.getRandomBooks.mockResolvedValueOnce([
{
id: 33,
title: 'Frankenstein',
folderPath: '/books/frankenstein',
addedAt: new Date('2026-01-09T00:00:00.000Z'),
updatedAt: new Date('2026-02-09T00:00:00.000Z'),
description: null,
seriesName: null,
seriesIndex: null,
language: 'en',
publisher: 'Lackington',
isbn13: null,
hasCover: true,
authors: ['Mary Shelley'],
files: [{ id: 300, format: 'epub' }],
},
]);
const result = await service.getDiscover(user);
expect(opdsBookService.getRandomBooks).toHaveBeenCalledWith(7, 8, false, user.contentFilters);
expect(result.discover).toHaveLength(1);
expect(result.discover[0]).toEqual(expect.objectContaining({ id: 33, title: 'Frankenstein' }));
});
it('forwards read-status, format, and id filters to the books query', async () => {
const { service, opdsBookService } = makeService();
const user = makeUser({ id: 4 });
@@ -9,6 +9,8 @@ import type { FastifyReply } from 'fastify';
import type {
KoreaderCatalogBookDetail,
KoreaderCatalogBookListItem,
KoreaderCatalogDashboardResponse,
KoreaderCatalogDiscoverResponse,
KoreaderCatalogEntry,
KoreaderCatalogFile,
KoreaderCatalogPage,
@@ -30,6 +32,7 @@ import { storageConfig } from '../../config/config';
import { BookReadService } from '../book/book-read.service';
import { BookService } from '../book/book.service';
import type { BookDetailDto } from '../book/dto/book-detail.dto';
import { DashboardWidgetService } from '../dashboard/dashboard-widget.service';
import { fileMimeType } from '../opds/opds-xml.helpers';
import { OpdsBookEntry, OpdsBookService } from '../opds/opds-book.service';
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
@@ -43,6 +46,8 @@ type BatchProgressCandidate = BatchProgressRow & { percentage: number; updatedAt
const CATALOG_BASE = '/api/v1/koreader/plugin/catalog';
const AUTHOR_SERIES_PAGE_SIZE = 60;
const DASHBOARD_CONTINUE_READING_LIMIT = 5;
const DASHBOARD_DISCOVER_LIMIT = 8;
const NATURAL_SORT_ORDER: Record<KoreaderCatalogSort, KoreaderCatalogSortOrder> = {
title: 'asc',
@@ -111,6 +116,7 @@ export class KoreaderCatalogService {
private readonly bookService: BookService,
private readonly bookReadService: BookReadService,
private readonly userBookStatusService: UserBookStatusService,
private readonly dashboardWidgetService: DashboardWidgetService,
@Inject(storageConfig.KEY) private readonly storage: ConfigType<typeof storageConfig>,
) {}
@@ -118,6 +124,49 @@ export class KoreaderCatalogService {
return { sections: ROOT_SECTIONS.map((section) => ({ ...section })) };
}
async getDashboard(user: RequestUser): Promise<KoreaderCatalogDashboardResponse> {
const continueReadingQuery = Object.assign(new KoreaderCatalogBooksQueryDto(), {
page: 1,
size: DASHBOARD_CONTINUE_READING_LIMIT,
sort: 'recently_read' as const,
readStatus: 'reading' as const,
});
const [continueReading, discover, readingGoal, readingStreak, highlightOfTheDay] = await Promise.all([
this.getBooksPage(user, continueReadingQuery),
this.buildDiscover(user),
this.dashboardWidgetService.getReadingGoal(user),
this.dashboardWidgetService.getReadingStreak(user),
this.dashboardWidgetService.getHighlightOfTheDay(user),
]);
return {
generatedAt: new Date().toISOString(),
sections: ROOT_SECTIONS.map((section) => ({ ...section })),
continueReading: continueReading.items,
discover,
readingGoal,
readingStreak,
highlightOfTheDay,
};
}
async getDiscover(user: RequestUser): Promise<KoreaderCatalogDiscoverResponse> {
return { discover: await this.buildDiscover(user) };
}
private async buildDiscover(user: RequestUser): Promise<KoreaderCatalogBookListItem[]> {
const entries = await this.opdsBookService.getRandomBooks(user.id, DASHBOARD_DISCOVER_LIMIT, user.isSuperuser, user.contentFilters);
if (entries.length === 0) return [];
const bookIds = entries.map((entry) => entry.id);
const [progressMap, statusMap] = await Promise.all([
this.findBestProgressMap(user.id, bookIds),
this.userBookStatusService.findByBookIds(user.id, bookIds),
]);
return entries.map((entry) => this.mapBookListItem(entry, progressMap.get(entry.id) ?? null, statusMap.get(entry.id)?.status ?? null));
}
async getSectionEntries(user: RequestUser, section: string, query: { page?: number; q?: string } = {}): Promise<KoreaderCatalogSectionResponse> {
switch (section) {
case 'libraries':
@@ -9,22 +9,41 @@ async function readPluginFile(name: string): Promise<string> {
}
describe('KOReader plugin update source wiring', () => {
it('places update checking after full sync and keeps sync behavior unboxed', async () => {
it('keeps update status on the first BookOrbit menu page and groups advanced settings', async () => {
const main = await readPluginFile('main.lua');
const dashboardIndex = main.indexOf('text = _("Open dashboard")');
const updateRowIndex = main.indexOf('return self:updateCheckMenuText()');
const browseBlock = main.slice(main.indexOf('text = _("Browse library")'), main.indexOf('text = _("Auto sync this book")'));
const fullSyncBlock = main.slice(main.indexOf('text = _("Sync all books now")'), updateRowIndex);
const syncBehaviorBlock = main.slice(
main.indexOf('return T(_("Periodically sync every # pages (%1)")'),
main.indexOf('text = _("Sync this book now")'),
);
const lastSyncIndex = main.indexOf('return T(_("Last sync: %1")');
const syncThisIndex = main.indexOf('text = _("Sync this book now")');
const syncAllIndex = main.indexOf('text = _("Sync all books now")');
const autoSyncIndex = main.indexOf('text = _("Auto sync this book")');
const twoWayIndex = main.indexOf('text = _("Two-way highlight sync")');
const syncSettingsIndex = main.indexOf('text = _("Sync settings")');
const accountIndex = main.indexOf('text = _("Account & setup")');
const syncSettingsBlock = main.slice(syncSettingsIndex, accountIndex);
const autoSyncBlock = main.slice(autoSyncIndex, syncSettingsIndex);
expect(main).toContain('function BookOrbit:updateCheckMenuText()');
expect(browseBlock).toContain('separator = true');
expect(fullSyncBlock).toContain('separator = true');
expect(syncBehaviorBlock).not.toContain('separator = true');
expect(updateRowIndex).toBeGreaterThan(main.indexOf('text = _("Sync all books now")'));
expect(updateRowIndex).toBeLessThan(main.indexOf('return T(_("Last sync: %1")'));
expect(dashboardIndex).toBeGreaterThan(0);
expect(updateRowIndex).toBeGreaterThan(dashboardIndex);
expect(updateRowIndex).toBeLessThan(lastSyncIndex);
expect(lastSyncIndex).toBeLessThan(syncThisIndex);
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(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(
main
.slice(main.indexOf('return T(_("Periodically sync every # pages (%1)")'), main.indexOf('return T(_("Sync to a newer state (%1)")'))
.includes('separator = true'),
).toBe(false);
expect(main.indexOf('keep_menu_open = true,\n callback = function()\n self:checkForUpdate()')).toBeGreaterThan(
0,
);
@@ -34,6 +53,39 @@ describe('KOReader plugin update source wiring', () => {
expect(main).not.toContain('return T(_("Installed plugin: v%1"), PLUGIN_VERSION)');
});
it('keeps manual sync actions above advanced sync settings', async () => {
const main = await readPluginFile('main.lua');
const syncSettingsBlock = main.slice(
main.indexOf('return T(_("Periodically sync every # pages (%1)")'),
main.indexOf('text = _("Account & setup")'),
);
expect(main.indexOf('text = _("Sync this book now")')).toBeLessThan(main.indexOf('text = _("Sync settings")'));
expect(main.indexOf('text = _("Sync all books now")')).toBeLessThan(main.indexOf('text = _("Sync settings")'));
expect(syncSettingsBlock).not.toContain('text = _("Sync this book now")');
expect(syncSettingsBlock).not.toContain('text = _("Sync all books now")');
});
it('keeps the primary detail download as a default download and puts customization in options', async () => {
const catalog = await readPluginFile('bookorbit_catalog.lua');
const download = await readPluginFile('bookorbit_catalog_download.lua');
const detailActionsBlock = catalog.slice(
catalog.indexOf('function BookOrbitCatalog:showDetailActions()'),
catalog.indexOf('function BookOrbitCatalog:showSetStatusDialog'),
);
const detailHeaderBlock = catalog.slice(
catalog.indexOf('function BookOrbitCatalog:buildDetailHeader'),
catalog.indexOf('function BookOrbitCatalog:updateDetailItems'),
);
expect(detailActionsBlock).toContain('text = _("Download options")');
expect(detailActionsBlock).toContain('self:showDownloadOptions(detail)');
expect(detailHeaderBlock).toContain('if #supported_files == 1 then');
expect(detailHeaderBlock).toContain('self:downloadDefaultFile(detail, supported_files[1])');
expect(detailHeaderBlock).toContain('self:showFileChoices(detail)');
expect(download).toContain('function Catalog:downloadDefaultFile(detail, file)');
});
it('throttles automatic update prompts and does not interrupt the catalog browser', async () => {
const main = await readPluginFile('main.lua');
@@ -183,7 +183,7 @@ describe('KoreaderPluginService', () => {
expect(koreaderService.applyProgressForResolvedFile).toHaveBeenCalledWith(
7,
{ id: 10, bookId: 20 },
{ id: 10, bookId: 20, libraryId: 1 },
{
percentage: 0.5,
progress: '/body/DocFragment[3]/body',
@@ -201,7 +201,7 @@ describe('KoreaderPluginService', () => {
await service.bulkProgress(makeUser(), progressDto([{ hash: HASH_A, percentage: 0.2, timestamp: 1700000000 }]));
expect(koreaderService.applyProgressForResolvedFile).toHaveBeenCalledWith(7, { id: 10, bookId: 20 }, expect.any(Object), {
expect(koreaderService.applyProgressForResolvedFile).toHaveBeenCalledWith(7, { id: 10, bookId: 20, libraryId: 1 }, expect.any(Object), {
skipSharedProgress: true,
});
});
@@ -211,7 +211,7 @@ describe('KoreaderPluginService', () => {
await service.bulkProgress(makeUser(), progressDto([{ hash: HASH_A, percentage: 0.2 }]));
expect(koreaderService.applyProgressForResolvedFile).toHaveBeenCalledWith(7, { id: 10, bookId: 20 }, expect.any(Object), {
expect(koreaderService.applyProgressForResolvedFile).toHaveBeenCalledWith(7, { id: 10, bookId: 20, libraryId: 1 }, expect.any(Object), {
skipSharedProgress: false,
});
});
@@ -154,7 +154,7 @@ export class KoreaderPluginService {
const stale = await this.isStaleProgress(user.id, match.bookFileId, item.timestamp);
await this.koreaderService.applyProgressForResolvedFile(
user.id,
{ id: match.bookFileId, bookId: match.bookId },
{ id: match.bookFileId, bookId: match.bookId, libraryId: match.libraryId },
{
percentage: item.percentage,
progress: item.progress,
+12 -1
View File
@@ -4,6 +4,7 @@ import { CommonModule } from '../../common/common.module';
import { AchievementModule } from '../achievement/achievement.module';
import { AnnotationModule } from '../annotation/annotation.module';
import { BookModule } from '../book/book.module';
import { DashboardModule } from '../dashboard/dashboard.module';
import { OpdsModule } from '../opds/opds.module';
import { PositionConverterModule } from '../position-converter/position-converter.module';
import { UserModule } from '../user/user.module';
@@ -25,7 +26,17 @@ import { KoreaderService } from './koreader.service';
import { KoreaderStatsService } from './koreader-stats.service';
@Module({
imports: [CommonModule, UserModule, UserBookStatusModule, AchievementModule, AnnotationModule, BookModule, OpdsModule, PositionConverterModule],
imports: [
CommonModule,
UserModule,
UserBookStatusModule,
AchievementModule,
AnnotationModule,
BookModule,
DashboardModule,
OpdsModule,
PositionConverterModule,
],
controllers: [KoreaderController, KoreaderPluginController, KoreaderCatalogController],
providers: [
KoreaderService,
@@ -56,7 +56,7 @@ describe('KoreaderRepository', () => {
});
it('returns the book file when found by current hash', async () => {
const file = { id: 10, bookId: 20 };
const file = { id: 10, bookId: 20, libraryId: 1 };
db.select.mockReturnValue(makeQueryChain([file]));
const result = await repo.resolveBookFileByHash('abc123', null);
@@ -66,7 +66,7 @@ describe('KoreaderRepository', () => {
});
it('falls back to hash history when current hash lookup returns nothing', async () => {
const file = { id: 10, bookId: 20 };
const file = { id: 10, bookId: 20, libraryId: 1 };
db.select.mockReturnValueOnce(makeQueryChain([])).mockReturnValueOnce(makeQueryChain([file]));
const result = await repo.resolveBookFileByHash('oldhash', null);
@@ -76,6 +76,49 @@ describe('KoreaderRepository', () => {
});
});
describe('resolveBookFilesByHashes', () => {
it('returns an empty map when no hashes are provided', async () => {
const result = await repo.resolveBookFilesByHashes([], null);
expect(result.size).toBe(0);
expect(db.select).not.toHaveBeenCalled();
});
it('returns an empty map when the user has no accessible libraries', async () => {
const result = await repo.resolveBookFilesByHashes(['hash'], []);
expect(result.size).toBe(0);
expect(db.select).not.toHaveBeenCalled();
});
it('resolves direct hashes first and falls back to hash history for missing hashes', async () => {
db.select
.mockReturnValueOnce(
makeQueryChain([
{ hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31 },
{ hash: null, bookFileId: 12, bookId: 22, libraryId: 32 },
]),
)
.mockReturnValueOnce(makeQueryChain([{ hash: 'old', bookFileId: 13, bookId: 23, libraryId: 33 }]));
const result = await repo.resolveBookFilesByHashes(['current', 'old'], [31, 33]);
expect(result.get('current')).toEqual({ bookFileId: 11, bookId: 21, libraryId: 31 });
expect(result.get('old')).toEqual({ bookFileId: 13, bookId: 23, libraryId: 33 });
expect(result.size).toBe(2);
expect(db.select).toHaveBeenCalledTimes(2);
});
it('skips hash history lookup when all hashes resolve directly', async () => {
db.select.mockReturnValueOnce(makeQueryChain([{ hash: 'current', bookFileId: 11, bookId: 21, libraryId: 31 }]));
const result = await repo.resolveBookFilesByHashes(['current'], null);
expect(result.get('current')).toEqual({ bookFileId: 11, bookId: 21, libraryId: 31 });
expect(db.select).toHaveBeenCalledTimes(1);
});
});
describe('getAccessibleLibraryIds', () => {
it('returns null for superusers', async () => {
db.query.users.findFirst.mockResolvedValue({ isSuperuser: true });
@@ -104,6 +147,47 @@ describe('KoreaderRepository', () => {
});
});
describe('koreader user records', () => {
it('finds a koreader user by app user id', async () => {
const row = { userId: 42, username: 'reader' };
db.query.koreaderUsers.findFirst.mockResolvedValue(row);
await expect(repo.findKoreaderUser(42)).resolves.toBe(row);
expect(db.query.koreaderUsers.findFirst).toHaveBeenCalledTimes(1);
});
it('finds a koreader user by username', async () => {
const row = { userId: 42, username: 'reader' };
db.query.koreaderUsers.findFirst.mockResolvedValue(row);
await expect(repo.findKoreaderUserByUsername('reader')).resolves.toBe(row);
expect(db.query.koreaderUsers.findFirst).toHaveBeenCalledTimes(1);
});
it('creates a koreader user and returns the inserted row', async () => {
const data = { userId: 42, username: 'reader', passwordHash: 'hash', passwordMd5: 'md5' };
const returning = vi.fn().mockResolvedValue([data]);
const values = vi.fn().mockReturnValue({ returning });
db.insert.mockReturnValue({ values });
await expect(repo.createKoreaderUser(data)).resolves.toEqual(data);
expect(values).toHaveBeenCalledWith(data);
expect(returning).toHaveBeenCalledTimes(1);
});
it('updates a koreader user', async () => {
const where = vi.fn().mockResolvedValue(undefined);
const set = vi.fn().mockReturnValue({ where });
db.update.mockReturnValue({ set });
await repo.updateKoreaderUser(42, { syncEnabled: false });
expect(db.update).toHaveBeenCalledTimes(1);
expect(set).toHaveBeenCalledWith({ syncEnabled: false });
expect(where).toHaveBeenCalledTimes(1);
});
});
describe('deleteKoreaderUser', () => {
it('deletes the koreader user record for the given userId', async () => {
const deleteChain = { where: vi.fn().mockResolvedValue(undefined) };
@@ -116,6 +200,117 @@ describe('KoreaderRepository', () => {
});
});
describe('device progress records', () => {
it('upserts device progress as non-orphaned progress', async () => {
const onConflictDoUpdate = vi.fn().mockResolvedValue(undefined);
const values = vi.fn().mockReturnValue({ onConflictDoUpdate });
db.insert.mockReturnValue({ values });
await repo.upsertDeviceProgress({
bookFileId: 10,
userId: 42,
device: 'Kobo',
deviceId: 'device-1',
percentage: 57.5,
progress: '/body/1',
chapterIndex: 2,
syncTimestamp: 12345,
});
expect(values).toHaveBeenCalledWith(
expect.objectContaining({
bookFileId: 10,
userId: 42,
device: 'Kobo',
deviceId: 'device-1',
percentage: 57.5,
orphaned: false,
orphanedHash: null,
}),
);
expect(onConflictDoUpdate).toHaveBeenCalledWith(
expect.objectContaining({
target: expect.any(Array),
targetWhere: expect.any(Object),
set: expect.objectContaining({
percentage: 57.5,
progress: '/body/1',
chapterIndex: 2,
syncTimestamp: 12345,
updatedAt: expect.any(Date),
}),
}),
);
});
it('returns the latest device progress row or null', async () => {
const row = { id: 1, bookFileId: 10, userId: 42 };
db.select.mockReturnValueOnce(makeQueryChain([row])).mockReturnValueOnce(makeQueryChain([]));
await expect(repo.getLatestDeviceProgress(10, 42)).resolves.toBe(row);
await expect(repo.getLatestDeviceProgress(10, 42)).resolves.toBeNull();
});
it('returns all device progress rows ordered by update time', async () => {
const rows = [{ id: 1 }, { id: 2 }];
db.select.mockReturnValue(makeQueryChain(rows));
await expect(repo.getAllDeviceProgress(10, 42)).resolves.toBe(rows);
});
it('maps device list rows from raw SQL results', async () => {
const lastSync = new Date('2026-01-01T00:00:00.000Z');
db.execute.mockResolvedValue({
rows: [
{ device: 'Kobo', device_id: 'device-1', last_sync_at: lastSync, last_book_title: 'Book' },
{ device: 'Phone', device_id: 'device-2', last_sync_at: lastSync, last_book_title: null },
],
});
await expect(repo.getDevicesList(42)).resolves.toEqual([
{ device: 'Kobo', deviceId: 'device-1', lastSyncAt: lastSync, lastBookTitle: 'Book' },
{ device: 'Phone', deviceId: 'device-2', lastSyncAt: lastSync, lastBookTitle: null },
]);
});
it('counts total synced books and defaults missing results to zero', async () => {
db.select.mockReturnValueOnce(makeQueryChain([{ count: '3' }])).mockReturnValueOnce(makeQueryChain([]));
await expect(repo.getTotalSyncedBooks(42)).resolves.toBe(3);
await expect(repo.getTotalSyncedBooks(42)).resolves.toBe(0);
});
});
describe('reading progress records', () => {
it('returns reading progress or null', async () => {
const row = { id: 1, bookFileId: 10, userId: 42 };
db.select.mockReturnValueOnce(makeQueryChain([row])).mockReturnValueOnce(makeQueryChain([]));
await expect(repo.getReadingProgress(10, 42)).resolves.toBe(row);
await expect(repo.getReadingProgress(10, 42)).resolves.toBeNull();
});
it('combines device and web reading progress for the dashboard', async () => {
const deviceProgress = [{ id: 1 }];
const readingProgress = { id: 2 };
const getAllDeviceProgress = vi.spyOn(repo, 'getAllDeviceProgress').mockResolvedValue(deviceProgress as never);
const getReadingProgress = vi.spyOn(repo, 'getReadingProgress').mockResolvedValue(readingProgress as never);
await expect(repo.getBookProgressForDashboard(10, 42)).resolves.toEqual({ deviceProgress, readingProgress });
expect(getAllDeviceProgress).toHaveBeenCalledWith(10, 42);
expect(getReadingProgress).toHaveBeenCalledWith(10, 42);
});
});
describe('chapters', () => {
it('returns chapters ordered by chapter index', async () => {
const rows = [{ chapterIndex: 1 }, { chapterIndex: 2 }];
db.select.mockReturnValue(makeQueryChain(rows));
await expect(repo.getChapters(10)).resolves.toBe(rows);
});
});
describe('findBookFileIdByBookId', () => {
it('returns the book file id when found', async () => {
db.select.mockReturnValue(makeQueryChain([{ id: 5 }]));
@@ -6,6 +6,7 @@ import { DB } from '../../db';
import * as schema from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
type ResolvedBookFileByHash = { id: number; bookId: number; libraryId: number };
@Injectable()
export class KoreaderRepository {
@@ -36,13 +37,13 @@ export class KoreaderRepository {
await this.db.delete(schema.koreaderUsers).where(eq(schema.koreaderUsers.userId, userId));
}
async resolveBookFileByHash(hash: string, accessibleLibraryIds: number[] | null): Promise<{ id: number; bookId: number } | null> {
async resolveBookFileByHash(hash: string, accessibleLibraryIds: number[] | null): Promise<ResolvedBookFileByHash | null> {
if (accessibleLibraryIds !== null && accessibleLibraryIds.length === 0) return null;
const libraryFilter = accessibleLibraryIds ? inArray(schema.books.libraryId, accessibleLibraryIds) : undefined;
const [byFileHash] = await this.db
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId })
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId })
.from(schema.bookFiles)
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
.where(and(eq(schema.bookFiles.fileHash, hash), libraryFilter))
@@ -51,7 +52,7 @@ export class KoreaderRepository {
if (byFileHash) return byFileHash;
const [byFileHashHistory] = await this.db
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId })
.select({ id: schema.bookFiles.id, bookId: schema.bookFiles.bookId, libraryId: schema.books.libraryId })
.from(schema.bookFileHashHistory)
.innerJoin(schema.bookFiles, eq(schema.bookFiles.id, schema.bookFileHashHistory.bookFileId))
.innerJoin(schema.books, eq(schema.books.id, schema.bookFiles.bookId))
@@ -17,7 +17,6 @@ vi.mock('crypto', () => ({
}));
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../achievement/achievement-events.service';
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
import { KoreaderChapterExtractorService } from './koreader-chapter-extractor.service';
import { KoreaderChapterService } from './koreader-chapter.service';
import type { KoreaderPackageService } from './koreader-package.service';
@@ -73,9 +72,6 @@ describe('KoreaderService', () => {
let mockChapterExtractor: {
extractAndStoreChapters: ReturnType<typeof vi.fn>;
};
let mockUserBookStatusService: {
autoUpdate: ReturnType<typeof vi.fn>;
};
let mockAchievementEvents: {
emit: ReturnType<typeof vi.fn>;
};
@@ -88,6 +84,7 @@ describe('KoreaderService', () => {
};
let mockBookService: {
syncKoboReadingStateForExternalProgress: ReturnType<typeof vi.fn>;
autoUpdateReadStatusForProgress: ReturnType<typeof vi.fn>;
};
let mockPackageService: {
getVersionInfo: ReturnType<typeof vi.fn>;
@@ -140,10 +137,6 @@ describe('KoreaderService', () => {
extractAndStoreChapters: vi.fn(),
};
mockUserBookStatusService = {
autoUpdate: vi.fn(),
};
mockAchievementEvents = {
emit: vi.fn(),
};
@@ -154,6 +147,7 @@ describe('KoreaderService', () => {
mockBookService = {
syncKoboReadingStateForExternalProgress: vi.fn().mockResolvedValue(undefined),
autoUpdateReadStatusForProgress: vi.fn().mockResolvedValue(undefined),
};
mockPackageService = {
getVersionInfo: vi.fn().mockResolvedValue({ pluginVersion: 'unknown', serverVersion: '1.0.0' }),
@@ -178,7 +172,6 @@ describe('KoreaderService', () => {
mockRepo.getAccessibleLibraryIds.mockResolvedValue([1, 2]);
mockChapterService.parseChapterIndexFromProgress.mockReturnValue(null);
mockChapterExtractor.extractAndStoreChapters.mockResolvedValue([]);
mockUserBookStatusService.autoUpdate.mockResolvedValue(undefined);
vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
vi.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined);
@@ -189,7 +182,6 @@ describe('KoreaderService', () => {
mockPluginRepo as unknown as KoreaderPluginRepository,
mockChapterService as unknown as KoreaderChapterService,
mockChapterExtractor as unknown as KoreaderChapterExtractorService,
mockUserBookStatusService as unknown as UserBookStatusService,
mockAchievementEvents as unknown as AchievementEventsService,
mockPositionConverter as never,
mockBookService as never,
@@ -343,7 +335,7 @@ describe('KoreaderService', () => {
describe('saveProgress', () => {
it('resolves the book file, parses progress, extracts chapters, and updates synced progress', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 44, bookId: 55 });
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 44, bookId: 55, libraryId: 3 });
mockChapterService.parseChapterIndexFromProgress.mockReturnValue(6);
mockChapterExtractor.extractAndStoreChapters.mockRejectedValueOnce(new Error('extract failed'));
@@ -371,7 +363,7 @@ describe('KoreaderService', () => {
});
expect(mockRepo.upsertReadingProgress).toHaveBeenCalledWith(44, 12, 50, null, '/body/DocFragment[7]');
expect(mockBookService.syncKoboReadingStateForExternalProgress).toHaveBeenCalledWith(12, 44, 50);
expect(mockUserBookStatusService.autoUpdate).toHaveBeenCalledWith(12, 55, 50);
expect(mockBookService.autoUpdateReadStatusForProgress).toHaveBeenCalledWith(12, { id: 44, bookId: 55, libraryId: 3 }, 50);
expect(mockAchievementEvents.emit).toHaveBeenCalledWith(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
userId: 12,
bookId: 55,
@@ -380,7 +372,7 @@ describe('KoreaderService', () => {
source: 'koreader',
});
expect(mockAchievementEvents.emit.mock.invocationCallOrder[0]!).toBeGreaterThan(
mockUserBookStatusService.autoUpdate.mock.invocationCallOrder[0]!,
mockBookService.autoUpdateReadStatusForProgress.mock.invocationCallOrder[0]!,
);
expect(result).toEqual({
document: 'abcdef1234567890fedcba',
@@ -400,7 +392,7 @@ describe('KoreaderService', () => {
expect(mockRepo.upsertDeviceProgress).not.toHaveBeenCalled();
expect(mockRepo.upsertReadingProgress).not.toHaveBeenCalled();
expect(mockUserBookStatusService.autoUpdate).not.toHaveBeenCalled();
expect(mockBookService.autoUpdateReadStatusForProgress).not.toHaveBeenCalled();
expect(mockAchievementEvents.emit).not.toHaveBeenCalled();
});
@@ -419,7 +411,7 @@ describe('KoreaderService', () => {
});
it('uses the default device and generated device id when the payload leaves them empty', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 88, bookId: 99 });
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 88, bookId: 99, libraryId: 4 });
await service.saveProgress(12, {
document: 'default-device-document',
@@ -442,7 +434,7 @@ describe('KoreaderService', () => {
describe('getProgress', () => {
it('returns device progress when the device sync is latest', async () => {
const latestDeviceTime = new Date('2026-02-01T10:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20 });
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue({
percentage: 0.66,
progress: '/body/DocFragment[8]/body',
@@ -468,7 +460,7 @@ describe('KoreaderService', () => {
it('returns web reader progress with null XPointer when no CFI is stored', async () => {
const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20 });
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue({
percentage: 0.2,
progress: '/body/DocFragment[5]/body',
@@ -495,7 +487,7 @@ describe('KoreaderService', () => {
it('converts CFI to DocFragment XPointer using chapter service (no file I/O)', async () => {
const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20 });
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue({
percentage: 50,
@@ -518,7 +510,7 @@ describe('KoreaderService', () => {
it('returns exact web reader KOReader XPointer when it is stored', async () => {
const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20 });
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue({
percentage: 50,
@@ -540,7 +532,7 @@ describe('KoreaderService', () => {
it('returns null XPointer when chapter service cannot parse CFI spine index', async () => {
const readerTime = new Date('2026-02-01T11:00:00.000Z');
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20 });
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue({
percentage: 30,
@@ -554,7 +546,7 @@ describe('KoreaderService', () => {
});
it('returns null when neither device nor web reader progress exists', async () => {
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20 });
mockRepo.resolveBookFileByHash.mockResolvedValue({ id: 10, bookId: 20, libraryId: 1 });
mockRepo.getLatestDeviceProgress.mockResolvedValue(null);
mockRepo.getReadingProgress.mockResolvedValue(null);
@@ -11,7 +11,6 @@ import { KoreaderPackageService } from './koreader-package.service';
import { KoreaderPluginRepository } from './koreader-plugin.repository';
import { BookService } from '../book/book.service';
import { PositionConverterService } from '../position-converter/position-converter.service';
import { UserBookStatusService } from '../user-book-status/user-book-status.service';
import { AchievementEventsService, ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED } from '../achievement/achievement-events.service';
const BCRYPT_ROUNDS = 12;
@@ -28,7 +27,6 @@ export class KoreaderService {
private readonly pluginRepo: KoreaderPluginRepository,
private readonly chapterService: KoreaderChapterService,
private readonly chapterExtractor: KoreaderChapterExtractorService,
private readonly userBookStatusService: UserBookStatusService,
private readonly achievementEvents: AchievementEventsService,
private readonly positionConverter: PositionConverterService,
private readonly bookService: BookService,
@@ -144,7 +142,7 @@ export class KoreaderService {
async applyProgressForResolvedFile(
userId: number,
bookFile: { id: number; bookId: number },
bookFile: { id: number; bookId: number; libraryId: number },
data: { percentage: number; progress?: string; device: string; deviceId: string; timestamp?: number },
options?: { skipSharedProgress?: boolean },
) {
@@ -169,7 +167,7 @@ export class KoreaderService {
const cfi = data.progress ? await this.convertProgressToCfi(bookFile.id, data.progress) : null;
await this.repo.upsertReadingProgress(bookFile.id, userId, bookorbitPercentage, cfi, data.progress ?? null);
await this.bookService.syncKoboReadingStateForExternalProgress(userId, bookFile.id, bookorbitPercentage).catch(() => undefined);
await this.userBookStatusService.autoUpdate(userId, bookFile.bookId, bookorbitPercentage);
await this.bookService.autoUpdateReadStatusForProgress(userId, bookFile, bookorbitPercentage);
this.achievementEvents.emit(ACHIEVEMENT_EVENT_BOOK_PROGRESS_CHANGED, {
userId,
bookId: bookFile.bookId,