diff --git a/apps/server/src/books/books-repository.ts b/apps/server/src/books/books-repository.ts index 45fdfbec..afc7b4f6 100644 --- a/apps/server/src/books/books-repository.ts +++ b/apps/server/src/books/books-repository.ts @@ -1,5 +1,5 @@ import { BookGenre, BookWithData } from '@koinsight/common/types'; -import { Book } from '@koinsight/common/types/book'; +import { Book, BookStatus } from '@koinsight/common/types/book'; import { BookDevice } from '@koinsight/common/types/book-device'; import { Genre } from '@koinsight/common/types/genre'; import { sum } from 'ramda'; @@ -134,4 +134,8 @@ export class BooksRepository { static async setReferencePages(id: number, referencePages: number | null) { return db('book').where({ id }).update({ reference_pages: referencePages }); } + + static async setStatus(id: number, status: BookStatus) { + return db('book').where({ id }).update({ status }); + } } diff --git a/apps/server/src/books/books-router.ts b/apps/server/src/books/books-router.ts index 09fc1d8d..96b474e1 100644 --- a/apps/server/src/books/books-router.ts +++ b/apps/server/src/books/books-router.ts @@ -1,9 +1,12 @@ +import { BookStatus } from '@koinsight/common/types/book'; import { NextFunction, Request, Response, Router } from 'express'; import { BooksRepository } from './books-repository'; import { BooksService } from './books-service'; import { coversRouter } from './covers/covers-router'; import { getBookById } from './get-book-by-id-middleware'; +const VALID_STATUSES: BookStatus[] = ['complete', 'reading', 'abandoned', 'on_hold', null]; + const router = Router(); router.use('/:bookId/cover', coversRouter); @@ -102,4 +105,30 @@ router.put('/:bookId/reference_pages', getBookById, async (req: Request, res: Re } }); +/** + * Updates a book's status (complete, reading, abandoned, on_hold, or null) + */ +router.put('/:bookId/status', getBookById, async (req: Request, res: Response) => { + const book = req.book!; + const { status } = req.body; + + if (status === undefined) { + res.status(400).json({ error: 'Missing required fields' }); + return; + } + + if (!VALID_STATUSES.includes(status)) { + res.status(400).json({ error: 'Invalid status value' }); + return; + } + + try { + await BooksRepository.setStatus(book.id, status); + res.status(200).json({ message: 'Status updated' }); + } catch (error) { + console.error(error); + res.status(500).json({ error: 'Failed to update status' }); + } +}); + export { router as booksRouter }; diff --git a/apps/server/src/db/factories/book-factory.ts b/apps/server/src/db/factories/book-factory.ts index a47b11c2..0fd23e93 100644 --- a/apps/server/src/db/factories/book-factory.ts +++ b/apps/server/src/db/factories/book-factory.ts @@ -13,6 +13,7 @@ export function fakeBook(overrides: Partial = {}): FakeBook { series: faker.book.series(), language: faker.location.language().alpha2, soft_deleted: false, + status: null, ...overrides, }; diff --git a/apps/server/src/db/migrations/20251223100000_add_status_to_book.ts b/apps/server/src/db/migrations/20251223100000_add_status_to_book.ts new file mode 100644 index 00000000..17d622ed --- /dev/null +++ b/apps/server/src/db/migrations/20251223100000_add_status_to_book.ts @@ -0,0 +1,13 @@ +import type { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + return knex.schema.alterTable('book', (table) => { + table.string('status').defaultTo(null); + }); +} + +export async function down(knex: Knex): Promise { + return knex.schema.alterTable('book', (table) => { + table.dropColumn('status'); + }); +} diff --git a/apps/server/src/upload/upload-service.ts b/apps/server/src/upload/upload-service.ts index e799e92d..6006a1c8 100644 --- a/apps/server/src/upload/upload-service.ts +++ b/apps/server/src/upload/upload-service.ts @@ -65,10 +65,14 @@ export class UploadService { authors: book.authors, series: book.series, language: book.language, + status: book.status ?? null, })); await Promise.all( - newBooks.map(({ id, ...book }) => trx('book').insert(book).onConflict('md5').ignore()) + newBooks.map(({ id, ...book }) => { + const query = trx('book').insert(book).onConflict('md5'); + return book.status ? query.merge(['status']) : query.ignore(); + }) ); // Determine device ID: from stats, override, or fall back to unknown device diff --git a/apps/web/src/api/books.ts b/apps/web/src/api/books.ts index 7f792e90..bcfbc7d8 100644 --- a/apps/web/src/api/books.ts +++ b/apps/web/src/api/books.ts @@ -1,4 +1,4 @@ -import { Book, BookWithData } from '@koinsight/common/types'; +import { Book, BookStatus, BookWithData } from '@koinsight/common/types'; import useSWR from 'swr'; import { API_URL, fetchFromAPI } from './api'; @@ -37,3 +37,7 @@ export function uploadBookCover(bookId: Book['id'], formData: FormData) { headers: { Accept: 'multipart/form-data' }, }); } + +export async function updateBookStatus(id: Book['id'], status: BookStatus) { + return fetchFromAPI<{ message: string }>(`books/${id}/status`, 'PUT', { status }); +} diff --git a/apps/web/src/components/status-icons/index.ts b/apps/web/src/components/status-icons/index.ts new file mode 100644 index 00000000..ea848969 --- /dev/null +++ b/apps/web/src/components/status-icons/index.ts @@ -0,0 +1 @@ +export { AbandonedIcon, CompletedIcon, OnHoldIcon, ReadingIcon } from './status-icons'; diff --git a/apps/web/src/components/status-icons/status-icons.tsx b/apps/web/src/components/status-icons/status-icons.tsx new file mode 100644 index 00000000..e24bdb3e --- /dev/null +++ b/apps/web/src/components/status-icons/status-icons.tsx @@ -0,0 +1,84 @@ +import { Tooltip } from '@mantine/core'; +import { IconArchive, IconBook, IconCircleCheck, IconPlayerPause } from '@tabler/icons-react'; +import { JSX } from 'react'; + +type StatusIconProps = { + size?: number; + withTooltip?: boolean; +}; + +export function CompletedIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element { + const icon = ( + + ); + + if (withTooltip) { + return ( + + {icon} + + ); + } + return icon; +} + +export function ReadingIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element { + const icon = ( + + ); + + if (withTooltip) { + return ( + + {icon} + + ); + } + return icon; +} + +export function OnHoldIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element { + const icon = ( + + ); + + if (withTooltip) { + return ( + + {icon} + + ); + } + return icon; +} + +export function AbandonedIcon({ size = 16, withTooltip = true }: StatusIconProps): JSX.Element { + const icon = ( + + ); + + if (withTooltip) { + return ( + + {icon} + + ); + } + return icon; +} diff --git a/apps/web/src/pages/book-page/book-card.tsx b/apps/web/src/pages/book-page/book-card.tsx index 0947e33a..7ae2b9ad 100644 --- a/apps/web/src/pages/book-page/book-card.tsx +++ b/apps/web/src/pages/book-page/book-card.tsx @@ -1,4 +1,4 @@ -import { BookWithData } from '@koinsight/common/types'; +import { BookStatus, BookWithData } from '@koinsight/common/types'; import { ActionIcon, Box, @@ -6,6 +6,7 @@ import { Flex, Group, Image, + Menu, Modal, Tabs, Title, @@ -15,13 +16,22 @@ import { useDisclosure, useMediaQuery } from '@mantine/hooks'; import { IconBooks, IconCalendar, + IconChevronDown, IconHighlight, IconNote, IconPencil, IconUser, + IconX, } from '@tabler/icons-react'; import { JSX, useState } from 'react'; import { API_URL } from '../../api/api'; +import { updateBookStatus } from '../../api/books'; +import { + AbandonedIcon, + CompletedIcon, + OnHoldIcon, + ReadingIcon, +} from '../../components/status-icons'; import { formatRelativeDate } from '../../utils/dates'; import { BookPageCoverSelector } from './components/book-page-cover-selector'; @@ -33,6 +43,36 @@ type BookCardProps = { book: BookWithData; }; +const getStatusIcon = (status: BookStatus, size = 16) => { + switch (status) { + case 'complete': + return ; + case 'reading': + return ; + case 'on_hold': + return ; + case 'abandoned': + return ; + default: + return null; + } +}; + +const getStatusLabel = (status: BookStatus) => { + switch (status) { + case 'complete': + return 'Completed'; + case 'reading': + return 'Reading'; + case 'on_hold': + return 'On Hold'; + case 'abandoned': + return 'Abandoned'; + default: + return 'Set Status'; + } +}; + export function BookCard({ book }: BookCardProps): JSX.Element { const media = useMediaQuery(`(max-width: 62em)`); const [isCoverSelectorOpened, { open: openCoverSelector, close: closeCoverSelector }] = @@ -44,6 +84,11 @@ export function BookCard({ book }: BookCardProps): JSX.Element { closeCoverSelector(); }; + const handleStatusChange = async (status: BookStatus) => { + await updateBookStatus(book.id, status); + mutate(`books/${book.id}`); + }; + return ( {book.authors ?? 'N/A'} - {book.title} + + {book.title} + {book.status && getStatusIcon(book.status, 24)} + + + + + + + + Reading status + } + onClick={() => handleStatusChange('complete')} + color={book.status === 'complete' ? 'green' : undefined} + > + Completed + + } + onClick={() => handleStatusChange('reading')} + color={book.status === 'reading' ? 'blue' : undefined} + > + Reading + + } + onClick={() => handleStatusChange('on_hold')} + color={book.status === 'on_hold' ? 'yellow' : undefined} + > + On Hold + + } + onClick={() => handleStatusChange('abandoned')} + color={book.status === 'abandoned' ? 'red' : undefined} + > + Abandoned + + + } + onClick={() => handleStatusChange(null)} + > + Clear status + + + diff --git a/apps/web/src/pages/books-page/books-cards.tsx b/apps/web/src/pages/books-page/books-cards.tsx index 4e6e1ad6..8b3ef836 100644 --- a/apps/web/src/pages/books-page/books-cards.tsx +++ b/apps/web/src/pages/books-page/books-cards.tsx @@ -1,5 +1,5 @@ -import { BookWithData } from '@koinsight/common/types'; -import { Box, Group, Image, Progress, Text, Tooltip } from '@mantine/core'; +import { BookStatus, BookWithData } from '@koinsight/common/types'; +import { Box, Flex, Group, Image, Progress, Text, Tooltip } from '@mantine/core'; import { useMediaQuery } from '@mantine/hooks'; import { IconBooks, @@ -12,10 +12,31 @@ import C from 'clsx'; import { JSX } from 'react'; import { useNavigate } from 'react-router'; import { API_URL } from '../../api/api'; +import { + AbandonedIcon, + CompletedIcon, + OnHoldIcon, + ReadingIcon, +} from '../../components/status-icons'; import { getBookPath } from '../../routes'; import style from './books-cards.module.css'; +const StatusIcon = ({ status }: { status: BookStatus }) => { + switch (status) { + case 'complete': + return ; + case 'reading': + return ; + case 'on_hold': + return ; + case 'abandoned': + return ; + default: + return null; + } +}; + type BooksCardsProps = { books: BookWithData[]; }; @@ -58,9 +79,12 @@ export function BooksCards({ books }: BooksCardsProps): JSX.Element { color="koinsight" /> - - {book.title} - + + + {book.title} + + {book.status && } + diff --git a/apps/web/src/pages/books-page/books-table.tsx b/apps/web/src/pages/books-page/books-table.tsx index 5fce64b7..91418f9c 100644 --- a/apps/web/src/pages/books-page/books-table.tsx +++ b/apps/web/src/pages/books-page/books-table.tsx @@ -1,14 +1,35 @@ -import { BookWithData } from '@koinsight/common/types'; +import { BookStatus, BookWithData } from '@koinsight/common/types'; import { Anchor, Flex, Image, Progress, Stack, Table, Tooltip } from '@mantine/core'; import { useMediaQuery } from '@mantine/hooks'; import { IconEyeClosed, IconHighlight } from '@tabler/icons-react'; import { JSX } from 'react'; import { NavLink } from 'react-router'; import { API_URL } from '../../api/api'; +import { + AbandonedIcon, + CompletedIcon, + OnHoldIcon, + ReadingIcon, +} from '../../components/status-icons'; import { getBookPath } from '../../routes'; import { formatRelativeDate, getDuration, shortDuration } from '../../utils/dates'; import style from './books-table.module.css'; +const StatusIcon = ({ status }: { status: BookStatus }) => { + switch (status) { + case 'complete': + return ; + case 'reading': + return ; + case 'on_hold': + return ; + case 'abandoned': + return ; + default: + return null; + } +}; + type BooksTableProps = { books: BookWithData[]; }; @@ -56,9 +77,12 @@ export function BooksTable({ books }: BooksTableProps): JSX.Element { /> - - {book.title} - + + + {book.title} + + {book.status && } + {book.authors ?? 'Unknown author'} {book.series !== 'N/A' ? ` ยทย ${book.series}` : ''} diff --git a/package-lock.json b/package-lock.json index f1f50549..0ed57d38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ } }, "apps/server": { - "version": "v0.2.0", + "version": "v0.2.2", "license": "MIT", "dependencies": { "@koinsight/common": "*", @@ -171,6 +171,7 @@ "integrity": "sha512-rkoPH+RqWopVxDnCBE/ysIdfQ2A7j1eDmW8tCxxrR9nnFBa9jKf86VgsSAzxBd1x+ny0GC4JgiD3SNfRHv3pOg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "4.0.16", "fflate": "^0.8.2", @@ -262,6 +263,7 @@ "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", @@ -362,7 +364,7 @@ } }, "apps/web": { - "version": "v0.2.0", + "version": "v0.2.2", "license": "MIT", "dependencies": { "@koinsight/common": "*", @@ -417,6 +419,7 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.12.tgz", "integrity": "sha512-bDEoUl4SneltfI1GeEaBk6BVDbLuB/w15YwseAmUvc8ldAbNcsVhxKxY/BdhwqUo6O3L2vhdlb3WwxR1y8741g==", "license": "MIT", + "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -452,6 +455,7 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.12.tgz", "integrity": "sha512-lMMDzDewd3lUNtJCAHDj3g8On9X5aBl4q6EBwgOixKQSby9RG9ASEpK8oYHundHTm9tzo3MDeXWV/z32oSQWuw==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -504,6 +508,7 @@ "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", "integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==", "license": "MIT", + "peer": true, "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", @@ -550,6 +555,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1990,6 +1996,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -2370,6 +2377,7 @@ "integrity": "sha512-Fll2FZ1riMjNmlmJOdAyY5pUbkftXslB5DgEzlIuNaiWhXd00FhWxVC/r4yV/4wBb9JfImTu+jiSvXTkJ7F/gA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.20.0" } @@ -2404,6 +2412,7 @@ "integrity": "sha512-USU8ZI/xyKJwFTpjSVIrSeHBVAGagkHQKPNbxeWwql/vDmnTIBgx+TJnhFnj1NXgz8XfprU0egV2dROLGpsBEg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3105,6 +3114,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6642,6 +6652,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6971,6 +6982,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6983,6 +6995,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7068,6 +7081,7 @@ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", "license": "MIT", + "peer": true, "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" @@ -8344,6 +8358,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8631,6 +8646,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8858,6 +8874,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -8964,6 +8981,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9203,13 +9221,14 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "packages/common": { "name": "@koinsight/common", - "version": "v0.2.0" + "version": "v0.2.2" } } } diff --git a/packages/common/types/book.ts b/packages/common/types/book.ts index 690c8d67..068aaffd 100644 --- a/packages/common/types/book.ts +++ b/packages/common/types/book.ts @@ -1,3 +1,5 @@ +export type BookStatus = 'complete' | 'reading' | 'abandoned' | 'on_hold' | null; + export type KoReaderBook = { id: number; md5: string; @@ -12,6 +14,7 @@ export type KoReaderBook = { // These fields only come from statistics.db sync, not annotation sync total_read_time?: number; total_read_pages?: number; + status?: BookStatus; }; export type DbBook = { @@ -26,4 +29,5 @@ export type DbBook = { export type Book = DbBook & { soft_deleted: boolean; reference_pages: number | null; + status: BookStatus; }; diff --git a/plugins/koinsight.koplugin/call_api.lua b/plugins/koinsight.koplugin/call_api.lua index eae089ad..0ab781d3 100644 --- a/plugins/koinsight.koplugin/call_api.lua +++ b/plugins/koinsight.koplugin/call_api.lua @@ -8,7 +8,10 @@ local JSON = require("json") local InfoMessage = require("ui/widget/infomessage") local _ = require("gettext") -function response_not_valid(content) +local MAX_RETRIES = 3 +local RETRY_DELAYS = {1, 2, 4} -- seconds, exponential backoff + +local function response_not_valid(content) logger.err("[KoInsight] callApi: response was not valid JSON", content) UIManager:show(InfoMessage:new({ text = _("Server response is not valid."), @@ -18,58 +21,83 @@ end return function(method, url, headers, body, filepath, quiet) quiet = quiet or false - local sink = {} - local request = { - method = method, - } + local last_error + for attempt = 1, MAX_RETRIES do + local sink = {} + local request = { + method = method, + url = url, + headers = headers or {}, + sink = ltn12.sink.table(sink), + } - request.url = url - request.headers = headers or {} + if body ~= nil then + request.source = ltn12.source.string(body) + end - request.sink = ltn12.sink.table(sink) - socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT) + if attempt == 1 then + logger.dbg("[KoInsight] callApi:", request.method, request.url) + else + logger.info("[KoInsight] callApi: retry attempt", attempt, "of", MAX_RETRIES) + end - if body ~= nil then - request.source = ltn12.source.string(body) - end + socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, 60) + local code, resp_headers, status = socket.skip(1, http.request(request)) + socketutil:reset_timeout() - logger.dbg("[KoInsight] callApi:", request.method, request.url) + -- If we got a response (success or HTTP error), process it + if resp_headers ~= nil then + -- If the request returned successfully + if code == 200 then + local content = table.concat(sink) - local code, resp_headers, status = socket.skip(1, http.request(request)) - socketutil:reset_timeout() + if content == nil or content == "" or string.sub(content, 1, 1) ~= "{" then + response_not_valid(content) + return false, "empty_response" + end - -- Raise error if network is unavailable - if resp_headers == nil then - logger.err("[KoInsight] callApi: network error", status or code) - return false, "network_error" - end + local ok, result = pcall(JSON.decode, content) - -- If the request returned successfully - if code == 200 then - local content = table.concat(sink) + if ok and result then + return true, result + else + response_not_valid(content) + return false, "invalid_response" + end + else + -- HTTP error - don't retry, server received the request + if not quiet then + local content = table.concat(sink) + local error_detail = "" + local decode_ok, decoded = pcall(JSON.decode, content) + if decode_ok and type(decoded) == "table" and decoded.error then + error_detail = ": " .. tostring(decoded.error) + end + logger.err("[KoInsight] callApi: HTTP error", status or code, resp_headers) + UIManager:show(InfoMessage:new({ + text = _("Server error") .. error_detail, + })) + end - if content == nil or content == "" or string.sub(content, 1, 1) ~= "{" then - response_not_valid(content) - return false, "empty_response" + return false, "http_error", code + end end - local ok, result = pcall(JSON.decode, content) + -- Network error - retry with delay + last_error = status or code + logger.warn("[KoInsight] Network error, attempt", attempt, "of", MAX_RETRIES, ":", last_error) - if ok and result then - return true, result - else - response_not_valid(content) - return false, "invalid_response" - end - else - if not quiet then - logger.err("[KoInsight] callApi: HTTP error", status or code, resp_headers, result) - UIManager:show(InfoMessage:new({ - text = _("Server error" .. (result and ": " .. result["error"] or "")), - })) + if attempt < MAX_RETRIES then + socket.sleep(RETRY_DELAYS[attempt]) end + end - logger.err("[KoInsight] callApi: HTTP error", status or code, resp_headers) - return false, "http_error", code + -- All retries exhausted + logger.err("[KoInsight] callApi: network error after", MAX_RETRIES, "attempts:", last_error) + if not quiet then + UIManager:show(InfoMessage:new({ + text = _("Network error. Please check your connection."), + })) end + return false, "network_error" end diff --git a/plugins/koinsight.koplugin/db_reader.lua b/plugins/koinsight.koplugin/db_reader.lua index 76bd0340..b987bf6a 100644 --- a/plugins/koinsight.koplugin/db_reader.lua +++ b/plugins/koinsight.koplugin/db_reader.lua @@ -1,8 +1,22 @@ local SQ3 = require("lua-ljsqlite3/init") local DataStorage = require("datastorage") +local LuaSettings = require("luasettings") local logger = require("logger") +local lfs = require("libs/libkoreader-lfs") local db_location = DataStorage:getSettingsDir() .. "/statistics.sqlite3" +local docsettings_dir = DataStorage:getDocSettingsDir() +local home_dir = DataStorage:getDataDir() -- Where KOReader is installed + +-- Get user's configured home directory from KOReader settings +local user_home_dir = G_reader_settings:readSetting("home_dir") + +-- Common library paths on different devices +local library_paths = { + "/mnt/us/documents", -- Kindle + "/mnt/onboard", -- Kobo + "/storage/emulated/0", -- Android +} local KoInsightDbReader = {} @@ -55,52 +69,60 @@ end function KoInsightDbReader.bookData() local conn = SQ3.open(db_location) - local result, rows = conn:exec("SELECT * FROM book") - local books = {} + local ok, books_or_err = pcall(function() + local result, rows = conn:exec("SELECT * FROM book") + local books = {} - for i = 1, rows do - local book_md5 = result[10][i] - local db_pages = tonumber(result[7][i]) + for i = 1, rows do + local book_md5 = result[10][i] + local db_pages = tonumber(result[7][i]) - -- Try to get current page count from opened document - -- Falls back to database value if book is not currently open - local current_pages = KoInsightDbReader.getCurrentDocumentPages(book_md5) - local pages = current_pages or db_pages + -- Try to get current page count from opened document + -- Falls back to database value if book is not currently open + local current_pages = KoInsightDbReader.getCurrentDocumentPages(book_md5) + local pages = current_pages or db_pages - -- Log if we're using live data vs stale database data - if current_pages and current_pages ~= db_pages then - logger.info( - string.format( - "[KoInsight] Using live page count for book %s: %d (DB has: %d)", - result[2][i], - current_pages, - db_pages + -- Log if we're using live data vs stale database data + if current_pages and current_pages ~= db_pages then + logger.info( + string.format( + "[KoInsight] Using live page count for book %s: %d (DB has: %d)", + result[2][i], + current_pages, + db_pages + ) ) - ) - end + end - local book = { - id = tonumber(result[1][i]), - title = result[2][i], - authors = result[3][i], - notes = tonumber(result[4][i]), - last_open = tonumber(result[5][i]), - highlights = tonumber(result[6][i]), - pages = pages, -- Use live count if available, otherwise DB value - series = result[8][i], - language = result[9][i], - md5 = book_md5, - total_read_time = tonumber(result[11][i]), - total_read_pages = tonumber(result[12][i]), - } - table.insert(books, book) - end + local book = { + id = tonumber(result[1][i]), + title = result[2][i], + authors = result[3][i], + notes = tonumber(result[4][i]), + last_open = tonumber(result[5][i]), + highlights = tonumber(result[6][i]), + pages = pages, -- Use live count if available, otherwise DB value + series = result[8][i], + language = result[9][i], + md5 = book_md5, + total_read_time = tonumber(result[11][i]), + total_read_pages = tonumber(result[12][i]), + } + table.insert(books, book) + end + return books + end) conn:close() - return books + + if not ok then + logger.err("[KoInsight] Error reading book data:", books_or_err) + return {} + end + return books_or_err end -function get_md5_by_id(books, target_id) +local function get_md5_by_id(books, target_id) for _, book in ipairs(books) do if book.id == target_id then return book.md5 @@ -125,37 +147,159 @@ end function KoInsightDbReader.progressData() flush_statistics_to_db() + local book_data = KoInsightDbReader.bookData() + local device_id = G_reader_settings:readSetting("device_id") + local conn = SQ3.open(db_location) - local result, rows = conn:exec("SELECT * FROM page_stat_data") - local results = {} + local ok, results_or_err = pcall(function() + local result, rows = conn:exec("SELECT * FROM page_stat_data") + local results = {} - local book_data = KoInsightDbReader.bookData() + for i = 1, rows do + local book_id = tonumber(result[1][i]) + local book_md5 = get_md5_by_id(book_data, book_id) - local device_id = G_reader_settings:readSetting("device_id") + if book_md5 == nil then + logger.warn("[KoInsight] Book MD5 not found in book data:" .. book_id) + goto continue + end - for i = 1, rows do - local book_id = tonumber(result[1][i]) - local book_md5 = get_md5_by_id(book_data, book_id) + table.insert(results, { + page = tonumber(result[2][i]), + start_time = tonumber(result[3][i]), + duration = tonumber(result[4][i]), + total_pages = tonumber(result[5][i]), + book_md5 = book_md5, + device_id = device_id, + }) - if book_md5 == nil then - logger.warn("[KoInsight] Book MD5 not found in book data:" .. book_id) - goto continue + ::continue:: end - table.insert(results, { - page = tonumber(result[2][i]), - start_time = tonumber(result[3][i]), - duration = tonumber(result[4][i]), - total_pages = tonumber(result[5][i]), - book_md5 = book_md5, - device_id = device_id, - }) + return results + end) + conn:close() - ::continue:: + if not ok then + logger.err("[KoInsight] Error reading progress data:", results_or_err) + return {} + end + return results_or_err +end + +-- Recursively scan a directory for .sdr folders containing metadata.lua +local function scanForSidecarFiles(dir, results) + results = results or {} + + local ok, iter, dir_obj = pcall(lfs.dir, dir) + if not ok then + return results + end + + for entry in iter, dir_obj do + if entry ~= "." and entry ~= ".." then + local full_path = dir .. "/" .. entry + local mode = lfs.attributes(full_path, "mode") + + if mode == "directory" then + if entry:match("%.sdr$") then + -- This is a sidecar directory, look for metadata*.lua files + local sdr_ok, sdr_iter, sdr_obj = pcall(lfs.dir, full_path) + if sdr_ok then + for sdr_entry in sdr_iter, sdr_obj do + if sdr_entry:match("^metadata.*%.lua$") then + table.insert(results, full_path .. "/" .. sdr_entry) + break -- Only need one metadata file per sdr + end + end + end + else + -- Recurse into subdirectory + scanForSidecarFiles(full_path, results) + end + end + end end - conn:close() return results end +-- Read book statuses from sidecar files and return a mapping of md5 -> status +function KoInsightDbReader.getBookStatuses() + local statuses = {} + local sidecar_files = {} + + -- Build list of directories to scan (avoiding duplicates) + local dirs_to_scan = {} + local seen = {} + + local function add_dir(dir) + if dir and dir ~= "" and not seen[dir] then + seen[dir] = true + table.insert(dirs_to_scan, dir) + end + end + + -- Add core directories + add_dir(docsettings_dir) + add_dir(home_dir) + add_dir(user_home_dir) + + -- Add common library paths + for _, path in ipairs(library_paths) do + add_dir(path) + end + + for _, dir in ipairs(dirs_to_scan) do + logger.info("[KoInsight] Scanning for sidecar files in:", dir) + scanForSidecarFiles(dir, sidecar_files) + end + + logger.info("[KoInsight] Found", #sidecar_files, "sidecar files total") + + for _, metadata_path in ipairs(sidecar_files) do + logger.dbg("[KoInsight] Reading metadata from:", metadata_path) + local ok, settings = pcall(function() + return LuaSettings:open(metadata_path) + end) + + if ok and settings then + -- Get the MD5 checksum (stored at root level as partial_md5_checksum) + local md5 = settings:readSetting("partial_md5_checksum") + local summary = settings:readSetting("summary") + + logger.dbg("[KoInsight] partial_md5_checksum:", md5 or "nil") + logger.dbg("[KoInsight] summary:", summary and "found" or "nil") + + if md5 then + if summary and summary.status then + local status = summary.status + + -- Normalize status values + if status == "complete" or status == "completed" then + status = "complete" + elseif status == "on hold" then + status = "on_hold" + end + + statuses[md5] = status + logger.info("[KoInsight] Found status for", md5, ":", status) + else + logger.dbg("[KoInsight] No status set for book:", md5) + end + end + else + logger.warn("[KoInsight] Failed to read metadata:", metadata_path) + end + end + + -- Count table entries (# doesn't work for tables with string keys) + local count = 0 + for _ in pairs(statuses) do + count = count + 1 + end + logger.info("[KoInsight] Loaded statuses for", count, "books") + return statuses +end + return KoInsightDbReader diff --git a/plugins/koinsight.koplugin/main.lua b/plugins/koinsight.koplugin/main.lua index 1f3c33c9..378aeb50 100644 --- a/plugins/koinsight.koplugin/main.lua +++ b/plugins/koinsight.koplugin/main.lua @@ -244,7 +244,7 @@ function koinsight:performSyncOnSuspend() end) if not success then - message = "Error during auto sync: " .. tostring(error_msg) + local message = "Error during auto sync: " .. tostring(error_msg) logger.err("[KoInsight] " .. message) UIManager:show(InfoMessage:new({ text = _(message), diff --git a/plugins/koinsight.koplugin/upload.lua b/plugins/koinsight.koplugin/upload.lua index 912bc953..a8824c75 100644 --- a/plugins/koinsight.koplugin/upload.lua +++ b/plugins/koinsight.koplugin/upload.lua @@ -53,6 +53,17 @@ end function send_statistics_data(server_url, silent) local url = server_url .. API_UPLOAD_LOCATION + -- Get book data and statuses + local books = KoInsightDbReader.bookData() + local statuses = KoInsightDbReader.getBookStatuses() + + -- Merge statuses into book data + for _, book in ipairs(books) do + if book.md5 and statuses[book.md5] then + book.status = statuses[book.md5] + end + end + -- Get annotations from currently opened book local annotations = KoInsightAnnotationReader.getAnnotationsByBook() @@ -67,7 +78,7 @@ function send_statistics_data(server_url, silent) local body = { stats = KoInsightDbReader.progressData(), - books = KoInsightDbReader.bookData(), + books = books, annotations = annotations, version = const.VERSION, }