mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user