diff --git a/.gitea/workflows/build-scan-push.yml b/.gitea/workflows/build-scan-push.yml new file mode 100644 index 0000000..f0a6b35 --- /dev/null +++ b/.gitea/workflows/build-scan-push.yml @@ -0,0 +1,166 @@ +name: Build, Scan & Publish Image + +# Publishing a Gitea release (for example with tag 2026.09.25) builds the image, +# scans it with Grype (report only), and pushes it to this repository's registry. +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Image tag to build and push when run manually + required: false + default: latest + +concurrency: + group: image-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-scan-push: + runs-on: running-man + env: + REGISTRY: git.sysmd.uk + IMAGE: git.sysmd.uk/mdaleo404/kovi + COSIGN_VERSION: v3.0.5 + GRYPE_VERSION: v0.110.0 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + persist-credentials: false + submodules: false + fetch-depth: 0 + + - name: Resolve release tag + env: + EVENT_TAG: ${{ github.event.release.tag_name }} + INPUT_TAG: ${{ github.event.inputs.tag }} + run: | + set -euo pipefail + TAG="${EVENT_TAG:-}" + if [ -z "$TAG" ]; then TAG="${INPUT_TAG:-}"; fi + if [ -z "$TAG" ]; then TAG="${GITHUB_REF_NAME:-}"; fi + if [ -z "$TAG" ]; then TAG="$(git describe --tags --exact-match 2>/dev/null || echo "")"; fi + if [ -z "$TAG" ]; then echo "Could not determine a tag for this run." >&2; exit 1; fi + SAFE_TAG="${TAG//\//-}" + case "$SAFE_TAG" in + [A-Za-z0-9_]*) ;; + *) SAFE_TAG="v$SAFE_TAG" ;; + esac + { + echo "TAG=$SAFE_TAG" + echo "IMAGE_REF=${IMAGE}:${SAFE_TAG}" + } >> "$GITHUB_ENV" + if [ "${{ github.event.release.prerelease }}" != "true" ]; then + echo "PUSH_LATEST=1" >> "$GITHUB_ENV" + fi + echo "Resolved image reference: ${IMAGE}:${SAFE_TAG}" + + - name: Login to registry + run: | + echo "$REGISTRY_TOKEN" | docker login "${REGISTRY}" -u "$REGISTRY_USER" --password-stdin + env: + REGISTRY_USER: ${{ secrets.REGISTRY_USER }} + REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build image + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + TAGS=(-t "${IMAGE_REF}") + if [ "${PUSH_LATEST:-}" = "1" ]; then TAGS+=(-t "${IMAGE}:latest"); fi + docker buildx build \ + --load \ + --no-cache \ + --secret id=gitea_token,env=GITEA_TOKEN \ + "${TAGS[@]}" \ + . + + - name: Install Cosign + run: | + set -euo pipefail + curl -fLO "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" + chmod +x cosign-linux-amd64 + mv cosign-linux-amd64 /usr/local/bin/cosign + + - name: Install Grype + run: | + set -euo pipefail + + VERSION_NO_V="${GRYPE_VERSION#v}" + FILE="grype_${VERSION_NO_V}_linux_amd64.tar.gz" + BASE="https://github.com/anchore/grype/releases/download/${GRYPE_VERSION}" + + curl -fLO "${BASE}/${FILE}" + curl -fLO "${BASE}/grype_${VERSION_NO_V}_checksums.txt" + curl -fLO "${BASE}/grype_${VERSION_NO_V}_checksums.txt.sig" + curl -fLO "${BASE}/grype_${VERSION_NO_V}_checksums.txt.pem" + + cosign verify-blob \ + --signature "grype_${VERSION_NO_V}_checksums.txt.sig" \ + --certificate "grype_${VERSION_NO_V}_checksums.txt.pem" \ + --certificate-identity-regexp "https://github.com/anchore/grype" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "grype_${VERSION_NO_V}_checksums.txt" + + CHECK=$(grep " ${FILE}$" "grype_${VERSION_NO_V}_checksums.txt") + [ -n "$CHECK" ] || { echo "Missing checksum"; exit 1; } + + echo "$CHECK" | sha256sum -c - + + tar -xzf "${FILE}" + mv grype /usr/local/bin/ + + - name: Scan image with Grype (report only) + id: grype + continue-on-error: true + run: | + grype "${IMAGE_REF}" -o json > grype.json + + echo "Fixable HIGH/CRITICAL vulnerabilities:" + jq -r ' + .matches[] + | select( + ( + .vulnerability.severity == "High" or + .vulnerability.severity == "Critical" + ) + and + ( + (.vulnerability.fix.versions | length) > 0 + ) + ) + | "\(.artifact.name)@\(.artifact.version) -> \(.vulnerability.id) [\(.vulnerability.severity)] | fixed: \(.vulnerability.fix.versions[0])" + ' grype.json + + # Report fixable HIGH/CRITICAL without failing the build. + COUNT=$(jq ' + [ + .matches[] + | select( + ( + .vulnerability.severity == "High" or + .vulnerability.severity == "Critical" + ) + and + ( + (.vulnerability.fix.versions | length) > 0 + ) + ) + ] + | length + ' grype.json) + echo "Fixable HIGH/CRITICAL count: ${COUNT}" + if [ "$COUNT" != "0" ]; then + echo "::warning::Grype found ${COUNT} fixable HIGH/CRITICAL vulnerabilities." + fi + + - name: Push image + run: | + docker push "${IMAGE_REF}" + if [ "${PUSH_LATEST:-}" = "1" ]; then docker push "${IMAGE}:latest"; fi diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..55447ec --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-trixie-slim@sha256:b26b04c123d9ff8ab646ceb18b9d75a1173acf64b9a401094b906d27b29338d4 + +# kovi does not vendor or copy Calibre code. The container installs Debian's +# unmodified Calibre package and calls its fetch-ebook-metadata CLI as a separate +# process for cover resolution. kovi uses both fetch-ebook-metadata and +# calibre-debug's headless cover-source path so Calibre's cover-only Google +# Images source can run when identification fails. +RUN apt-get update \ + && apt-get install -y --no-install-recommends calibre ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY package.json server.mjs ./ +COPY lib ./lib +COPY public ./public +COPY plugins ./plugins +RUN mkdir -p /app/data/uploads /app/data/covers && chown -R node:node /app +USER node +ENV HOST=0.0.0.0 PORT=3000 DATA_PATH=/app/data TZ=UTC CALIBRE_COVER_RESOLVER=auto +VOLUME ["/app/data"] +EXPOSE 3000 +CMD ["node","server.mjs"] diff --git a/README.md b/README.md index 58b5c66..b7056be 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,242 @@ +[![Licence](https://img.shields.io/badge/GPL--3.0--only-orange?label=Licence)](https://git.sysmd.uk/mdaleo404/kovi/src/branch/main/LICENSE) +[![Gitea Release](https://img.shields.io/gitea/v/release/mdaleo404/kovi?gitea_url=https%3A%2F%2Fgit.sysmd.uk%2F&style=flat&color=orange&logo=gitea)](https://git.sysmd.uk/mdaleo404/kovi/releases) +[![Node.js](https://img.shields.io/badge/Node.js-22%2B-339933?logo=node.js&logoColor=white&style=flat)](https://nodejs.org/) + # kovi +
+ kovi logo +
+ +**Your KOReader history, beautifully understood.** + +kovi is a local-first, self-hosted reading dashboard for KOReader. It keeps the two deliberately simple ingestion methods—manual upload of KOReader's statistics SQLite database and a KOReader plugin—while making the import path defensive, device sync authenticated, and cover enrichment automatic. + +## Features + +- Manual drag-and-drop of `statistics.sqlite` / `statistics.sqlite3` +- Streamed unique temporary uploads; no shared upload filename +- SQLite header, integrity, table and column validation +- Read-only source DB and transactional/idempotent import +- Automatic exclusion of the KOReader user guide/manual +- Cover-first library and dedicated `/books/:id` book pages +- Persisted library sorting plus unread, in-progress, and completed filters +- Explicit read/unread status that preserves actual document progress, with KOReader sidecar sync and manual kovi overrides +- Date-range Books read metric counting books both first started and completed within the selected range +- Real highlight/note text after KOReader plugin sync +- Filterable, no-scroll reading rhythm heatmap (last week/month/3 months/6 months/year or custom dates) +- Interactive daily reading-time line graph with hover/tap values using the same date range as the heatmap +- Dedicated `/calendar` page with month navigation and per-day book/time details +- Exact embedded ebook covers uploaded from paired KOReader devices when online matching is missing +- ISBN-first cover matching when KOReader sidecar metadata exposes an ISBN +- Parallel Open Library + Google Books cover discovery with local caching and transient-network retries +- Calibre-backed multi-source cover fallback in the container (Google Books, Google Images, Amazon, Open Library, and other configured Calibre sources) +- Edition-aware and language-aware cover matching, including Italian titles +- Per-book and global cover retry actions, with durable queued/running/completed job state +- Cover manager with online alternatives, cover history/revert, and manual upload +- Incremental KOReader plugin sync: full first sync, then a one-day reading-stat overlap plus changed annotation sidecars only +- KOReader plugin sync for books, reading sessions and annotation sidecars +- 10-minute one-use pairing codes + per-device bearer tokens +- Browser pairing state that updates automatically when the reader pairs +- Device list and token revocation +- Status page with recent sync/import diagnostics and local storage totals +- One-click `.tar.gz` backup plus CSV and Markdown exports +- Structured, human-readable server logs for imports, pairing, syncs, covers and failures +- Responsive light/dark interface, including the theme switch on mobile +- One-process / one-SQLite deployment; no Redis or external database + +## Run it + +Requires Node.js 22+ (kovi uses Node's built-in SQLite module, which currently emits an experimental-module warning in Node 22). + +```bash +npm start +``` + +Open `http://localhost:3000` and import your KOReader database. + +### Docker + +The default `docker-compose.yml` uses a container-managed named volume so the app can stay non-root without host-directory UID or SELinux issues. + +The container installs Debian's unmodified Calibre package (GPLv3; see `THIRD_PARTY_NOTICES.md`). kovi first uses `fetch-ebook-metadata` for Calibre's normal identify→cover flow and, if identification returns nothing, runs `calibre-debug`'s cover-only downloader so sources such as Google Images still get a chance. The image is larger because it ships Calibre, but the cover logic stays maintained by Calibre rather than duplicating its provider scrapers in kovi. + +```bash +docker compose up --build -d +``` + +The application DB and cached covers live in the `kovi-data` named volume. Removing/recreating the container does **not** remove that volume. Do not use `down -v` unless you intentionally want to delete kovi's data. + +To follow kovi's event log: + +```bash +docker compose logs -f kovi +``` + +You will see useful events such as `import.sqlite.start`, `import.sqlite.complete`, `plugin.pair.success`, `plugin.sync.complete`, `cover.retry.book`, `cover.lookup.matched`, `cover.download.rejected`, `cover.calibre.start`, `cover.calibre.attempt`, `cover.calibre.matched`, `cover.embedded.saved`, and `cover.lookup.none`. Sensitive pairing codes and bearer tokens are not logged. + +## Manual import + +1. Copy KOReader's statistics database from the reader's settings directory to your computer. Depending on KOReader version/device it is normally named `statistics.sqlite3`. +2. Open kovi and choose **Import KOReader data**. +3. Drop the database file onto the importer. +4. kovi validates/imports it, excludes the KOReader user guide, and queues cover matching. + +The original database is never modified. The temporary upload is deleted after the import attempt. + +### Highlights and manual imports + +KOReader's statistics database contains highlight/note **counts**, but the actual highlighted text is stored in each book's `.sdr` sidecar metadata. Therefore: + +- manual SQLite upload can show that a book has highlights; +- the kovi plugin sync brings the actual highlight/note text into kovi; +- book pages show the synced passages instead of a recent-session list. + +## KOReader plugin + +Copy the entire `plugins/kovi.koplugin` directory to: + +```text +KOReader/plugins/kovi.koplugin +``` + +Restart KOReader. In kovi open **Devices → Pair a KOReader** and generate a code. On the reader open **Tools → kovi → Pair / change server**, enter a URL that the reader can actually reach (normally the kovi server's LAN address, such as `http://192.168.1.23:3000`) and the code. + +**Do not use `localhost`, `127.0.0.1`, or `0.0.0.0` on the reader.** Those point at the reader itself or are not a routable destination. + +When pairing succeeds, the Devices page detects it automatically, closes the pairing dialog and refreshes the device list. + +### Pairing / LAN diagnostics + +Use **Tools → kovi → Test server connection**. A TCP failure means the reader cannot open the kovi host/port; an HTTP failure means TCP worked but the HTTP exchange failed; an API error means kovi was reached and returned an application response. + +From another device on the same network, this should return kovi JSON: + +```text +http://YOUR-KOVI-LAN-IP:3000/api/plugin/ping +``` + +Only expose kovi on a trusted/private network, or place it behind appropriate access control. + +## Incremental KOReader sync + +The first sync is deliberately conservative: it sends the full KOReader statistics history and all annotation sidecars. After kovi commits that import, it returns a high-water cursor which the reader stores locally. Subsequent syncs query `page_stat_data` from one day before that cursor, so normal sync cost no longer grows with years of reading history while the overlap protects against ordering/clock edge cases. kovi's existing event fingerprint keeps the overlap idempotent. + +Annotation sync is incremental independently. The plugin stores each sidecar's modification-time/size signature and book MD5, opens/transmits only changed sidecars, and sends an empty tombstone once when a previously synced sidecar disappears so removed annotations are also removed server-side. If the reader cannot provide filesystem signatures, the plugin safely falls back to opening sidecars as before. Re-pairing resets both cursors, forcing a complete safety sync. + +The dashboard's range-aware **Books read** metric counts a book only when its earliest tracked reading session and effective completion date are both inside the selected range. KOReader completion uses the sidecar's status-modified date, manual completion uses the time it was marked read in kovi, and statistics-only imports fall back to the earliest session reaching 100%. Manual Read overrides without a completion timestamp fall back to the book’s latest tracked session. + +The **Status** page shows each device's last sync mode/cursor, rows sent, new sessions, changed annotation books, recent manual imports, and local storage usage. + +## Automatic covers + +kovi uses a layered resolver. The important design is that **Calibre handles the hard cases instead of kovi trying to recreate Calibre's provider logic**. + +1. **ISBN lookup** — when KOReader sidecar metadata provides a valid ISBN, kovi tries the direct Open Library cover endpoint first, then a deterministic Google Books ISBN query if needed. +2. **Fast native Open Library search** — edition-aware translated title + author, title-only, simplified/series-stripped title variants, and language normalization (`ita`, `it`, `it-IT` → `it`). +3. **Parallel native catalogue search** — Open Library and Google Books are queried together for title/author and translated-title variants; the strongest candidate across both is tried first. An API key is optional and only improves Google quota. +4. **Calibre metadata-source federation** — if the native lookup misses, kovi runs Calibre as the unprivileged container user. If Calibre identifies the book but cannot download an image, kovi preserves the returned Google Books ID/ISBN and tries deterministic Google Books/Open Library cover URLs rather than discarding that useful metadata. +5. **Calibre cover-only sources** — Google Images and other cover-only sources get a separate chance, but failures there no longer erase successful bibliographic identification. +6. **Exact KOReader embedded cover** — paired devices get first chance to upload the exact ebook cover during sync before network fallback begins. Local covers keep priority during normal background enrichment. + +Network lookups retry transient 429/5xx failures briefly, and the background resolver handles two books concurrently by default so library-wide discovery does not become a long serial queue. + +The Calibre call is isolated: kovi invokes a fixed executable with an argument array (`execFile`), **never through a shell**, so a malicious title/author cannot become a command. Each invocation has a time limit, output limit, a private temporary Calibre config directory, and runs as the same non-root `node` user as kovi. Temporary files are removed after every attempt. + +Calibre-style query relaxation is also used: ISBN/title/author first when available, then title+author without ISBN, then title-only. This mirrors Calibre's documented advice to make metadata searches less specific when an exact query fails. + +If Calibre returns no usable image, kovi keeps the typographic placeholder rather than assigning a likely-wrong cover. Use **Retry covers** globally to re-run missing books. **Find online replacement** is an explicit override: it can try online providers even when the book already has a KOReader embedded or manually uploaded cover, while the current cover remains visible until the job finishes. Cover work has its own durable SQLite job state, so a good book cover is never temporarily abused as a queue marker. A failed lookup keeps the current image. + +The book-page **Cover manager** can search several native catalogue candidates and cache up to six useful alternatives for visual selection. Every selected KOReader, online, or browser-uploaded cover is retained in cover history, so an older choice can be restored later. **Upload cover** accepts a local JPEG, PNG, WebP, or GIF up to 4 MB; browser-uploaded covers remain highest priority during normal automatic/KOReader enrichment. Cover files use immutable names, avoiding stale browser-cache results and allowing history to reference old images safely. + +The Dockerfile installs Calibre from Debian at image-build time. If you run kovi directly with `npm start`, Calibre is optional: install Calibre with both `fetch-ebook-metadata` and `calibre-debug` available on the host, or kovi will continue with its native sources when the resolver is unavailable. Set `CALIBRE_COVER_RESOLVER=false` to disable the Calibre subprocess explicitly. + + +### Backup and export + +The **Status → Backup & export** panel provides three local downloads: + +- **kovi backup** — a gzip-compressed tar containing a transactionally consistent `kovi.sqlite` snapshot, `manifest.json`, and all current/historical cover files referenced by the database. +- **Books CSV** — a spreadsheet-friendly library summary with human-readable reading durations and last-opened dates. +- **Highlights Markdown** — book-grouped highlight/note text suitable for notes apps and plain-text archives. + +The backup is created in a private temporary file and removed after the response finishes. + +### Reading-time totals and charts + +The dashboard’s **Reading time** card uses KOReader’s all-time per-book `total_read_time` total. The heatmap and line graph use dated `page_stat_data` session rows for the selected range, so a one-year chart can legitimately be lower than the all-time card. kovi labels those scopes explicitly. + +kovi also deduplicates identical page-stat events across manual SQLite imports and paired-device syncs. Startup deduplicates existing page-stat rows and enforces a unique event index so importing the same KOReader history through both paths does not double-count chart time. + +## Logs + +kovi logs state-changing operations and enrichment work to stdout with timestamp, level, event name and contextual fields. Examples: + +```text +[2026-08-30T08:12:00.000Z] [kovi] INFO import.sqlite.start — Manual KOReader database upload started. {"filename":"statistics.sqlite3","bytes":421888} +[2026-08-30T08:12:00.420Z] [kovi] INFO import.sqlite.complete — Manual KOReader database import completed. {"booksSeen":84,"newBooks":3,"excludedBooks":1} +[2026-08-30T08:12:01.531Z] [kovi] INFO cover.lookup.matched — Cover matched and cached. {"title":"L'amica geniale","language":"it","matchedTitle":"L'amica geniale","score":0.98} +[2026-08-30T08:12:06.100Z] [kovi] INFO cover.calibre.start — Asking Calibre metadata sources for a cover. {"title":"Il ladro linguanera","plans":["title-author","title-only"]} +[2026-08-30T08:12:09.500Z] [kovi] INFO cover.calibre.matched — Accepted Calibre-selected cover. {"title":"Il ladro linguanera","mime":"image/jpeg"} +[2026-08-30T08:15:14.881Z] [kovi] INFO plugin.sync.complete — KOReader sync completed. {"deviceId":"...","annotationsStored":127} +``` + +The logger intentionally redacts fields that look like tokens, authorization values or pairing codes. + +## Configuration + +| Variable | Default | Meaning | +|---|---:|---| +| `HOST` | `127.0.0.1` | Listen address (Docker sets `0.0.0.0`) | +| `PORT` | `3000` | HTTP port | +| `DATA_PATH` | `./data` | Persistent data directory | +| `TZ` | system zone (`UTC` in containers) | IANA time zone used for dashboard and calendar dates, for example `Europe/Rome` | +| `MAX_FILE_SIZE_MB` | `100` | Maximum manual SQLite upload size | +| `MAX_JSON_MB` | `20` | Maximum KOReader plugin JSON request size | +| `CALIBRE_COVER_RESOLVER` | `auto` | Use Calibre's identify and cover-only resolvers after native cover lookup misses; set `false` to disable | +| `CALIBRE_FETCH_BIN` | `fetch-ebook-metadata` | Override the Calibre CLI path for non-container installations | +| `CALIBRE_DEBUG_BIN` | `calibre-debug` | Override the Calibre cover-only CLI path for non-container installations | +| `GOOGLE_BOOKS_API_KEY` | empty | Optional Google Books API key for higher/identified quota; native Google Books discovery also works without one | +| `COVER_WORKER_CONCURRENCY` | `2` | Number of books resolved concurrently (clamped to 1–4) | + +## Tests + +```bash +npm test +npm run fixture -- /tmp/fixture-statistics.sqlite3 +``` + +The suite covers incremental sync cursors/overlap, changed-sidecar/tombstone plumbing, sync diagnostics, cover job/history/candidate behavior, backup archive contents, SQLite validation/idempotence, KOReader-manual filtering, pairing state, authenticated sync, annotation replacement/deletion behavior, multilingual/series-title cover matching, ISBN enrichment, bad-image fallback, embedded-cover plumbing, Calibre CLI argument safety, Calibre identify fallback, direct cover-only/Google Images fallback, preservation of Calibre Google/ISBN identifiers, deterministic Google Books ID cover recovery, Calibre cover acceptance behavior, outdated-plugin warnings, browser CSRF handling, dedicated book routing, live pairing polling, the filterable heatmap and reading-time trend graph, cross-source reading-session deduplication, and plugin network diagnostics. + +## Architecture + +```text +Browser ──raw SQLite PUT──▶ kovi HTTP server ─▶ validated read-only import + │ +KOReader plugin ─Bearer JSON──────┤──▶ app SQLite (`data/kovi.sqlite`) + │ incremental cursor │ ├── sync runs / diagnostics + │ + sidecar versions │ └── durable cover jobs/history + │ │ + ├──── `.sdr` annotations ───┤ + └──── requested ebook cover ┤──▶ validated exact-edition cover cache + │ + ├──▶ ISBN ─▶ Open Library Covers ─────────────┐ + ├──▶ Open Library edition search ───────────────┤ + ├──▶ Calibre metadata-source federation ────────┤ + ├──▶ Calibre IDs ─▶ Google Books cover URL ─────┤──▶ validated local cover cache + └──▶ optional direct Google Books API fallback ─┘ +``` + +The frontend is plain semantic HTML/CSS/ES modules and the kovi backend itself uses Node core modules. Calibre is installed as a separate third-party runtime command in the container solely for difficult cover lookups; see `THIRD_PARTY_NOTICES.md`. + +## Security note + +kovi does not implement web user accounts. Keep the web UI on a trusted LAN/private network, or place it behind an authenticated reverse proxy. Device plugin endpoints are separately authenticated with revocable tokens. See [SECURITY.md](SECURITY.md). + +## Acknowledgements + +kovi is inspired by [KoInsight](https://github.com/Ko-Insight/KoInsight), an MIT-licensed self-hosted KOReader statistics project, and by KOReader's own statistics model. It preserves the useful manual-SQLite + KOReader-plugin workflow while using a fresh application architecture and UX. + +## License + +kovi is released under the GNU General Public License v3.0; see [LICENSE](LICENSE). The container image also bundles Debian's unmodified `calibre` package (GPLv3); see [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..96d4198 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# kovi security model + +kovi is designed for personal/self-hosted deployments. It intentionally avoids accounts in the MVP, so the web UI should be treated like an administrative interface: do not expose it directly to the public internet without an authenticated reverse proxy or private network such as Tailscale. + +## Import safety + +- Browser database uploads use a raw `PUT` body and are streamed into a unique, mode-0600 temporary file. +- The maximum upload size is enforced while streaming. +- The SQLite magic header, `PRAGMA quick_check`, required table names, and required columns are verified before import. +- The source database is opened read-only. +- Imports are transactional and source-file hashes make exact re-uploads idempotent. +- Temporary uploads are removed in a `finally` path after success or failure. + +## Plugin safety + +- KOReader devices pair with a one-use code that expires after 10 minutes. +- Successful pairing issues a random per-device bearer token; only its SHA-256 hash is stored. +- Tokens are independently revocable from the Devices screen. +- Plugin JSON has strict request-size and row-count limits. +- Missing-cover uploads are authenticated with the same per-device token, require the source book MD5, are capped at 4 MB per image, and are accepted only for a book kovi already knows. +- Highlight/note text is read locally from KOReader book sidecars during an authenticated plugin sync; it is stored only in kovi's local SQLite database. +- Pairing status polling uses a random high-entropy request ID; pairing codes and bearer tokens are not returned by the status endpoint or written to logs. + +## Cover safety + +- Automatic cover discovery talks to fixed HTTPS Open Library endpoints and the Google Books API. When Calibre identifies a stable Google Books volume id, kovi may fetch the corresponding fixed `books.google.com` cover URL. `GOOGLE_BOOKS_API_KEY` is optional and raises the available Google Books quota. Google-hosted images are accepted only for high-confidence matches and only from allowlisted hosts. +- A paired KOReader may upload the exact embedded ebook cover only when the authenticated server requests missing covers during sync. +- The browser may upload a manual cover only through a same-origin, CSRF-protected book endpoint; uploads are capped at 4 MB and must match an accepted image magic signature. +- Cover downloads/uploads have timeout and size limits. +- Only JPEG, PNG, WebP, and GIF magic signatures are accepted; SVG/HTML are rejected. +- Covers are served locally with `nosniff` and a restrictive Content Security Policy. +- Cover retries and provider outcomes are logged, but downloaded remote URLs are not exposed through a generic proxy endpoint. + +## Network boundary + +The application does not enable permissive CORS. Browser API access is same-origin. The intentional outbound traffic is automatic cover lookup against Open Library, the Google Books API, Calibre metadata providers, and fixed Google Books cover URLs learned from Calibre identifiers. Plugin cover uploads are inbound, authenticated device requests. + +## Calibre cover resolver + +kovi can invoke Debian's unmodified `fetch-ebook-metadata` and `calibre-debug` executables as separate, non-root subprocesses when native cover sources fail. The second subprocess runs a fixed Calibre cover-downloader command so Calibre's cover-only sources (notably Google Images) can run even when metadata identification fails. Book title/author/ISBN values are passed using Node's `execFile` argument array or fixed environment variables consumed by fixed Python code; kovi does not interpolate ebook metadata into executable source or a shell command. Each invocation has a timeout and output cap, uses a private temporary Calibre configuration directory, and its returned file is independently checked for supported image magic bytes and size before being cached. When Calibre identifies a Google Books ID but cannot download the image itself, kovi may request Calibre's documented fixed Google Books cover URL on `books.google.com`; redirects stay restricted to allowlisted Google Books/Googleusercontent hosts, known Google dummy-cover hashes are rejected, and the same image type/size checks still apply. Set `CALIBRE_COVER_RESOLVER=false` to disable this fallback. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..a7e57e7 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,16 @@ +# Third-party components + +kovi is free software licensed under the GNU General Public License version 3 (see `LICENSE`). The container image also bundles third-party programs under their own licenses. + +## Calibre + +kovi's container build installs the unmodified Debian `calibre` package and invokes its `fetch-ebook-metadata` and `calibre-debug` commands as separate, unprivileged subprocesses for cover discovery. kovi is itself GPLv3 and Calibre is GPLv3, so shipping Calibre in the same image is license-compatible. + +Calibre is Copyright © Kovid Goyal and contributors and is licensed under the GNU General Public License version 3. + +- Project: https://calibre-ebook.com/ +- Source: https://github.com/kovidgoyal/calibre +- License: GPL-3.0 — https://www.gnu.org/licenses/gpl-3.0.html +- Debian's `calibre` package ships its license and copyright files under `/usr/share/doc/calibre/`, and Debian publishes the corresponding source packages. + +When redistributing a prebuilt kovi image, preserve Calibre's license and copyright files inside the image and keep the corresponding source available. Using the unmodified Debian package means Debian's published source packages satisfy the GPLv3 source-availability requirement. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ea1a107 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +name: kovi +services: + kovi: + build: . + restart: unless-stopped + ports: + - "3000:3000" + volumes: + - kovi-data:/app/data + environment: + TZ: "${TZ:-UTC}" + MAX_FILE_SIZE_MB: "100" + CALIBRE_COVER_RESOLVER: "${CALIBRE_COVER_RESOLVER:-auto}" + GOOGLE_BOOKS_API_KEY: "${GOOGLE_BOOKS_API_KEY:-}" + +volumes: + kovi-data: diff --git a/lib/calibre.mjs b/lib/calibre.mjs new file mode 100644 index 0000000..c21ebde --- /dev/null +++ b/lib/calibre.mjs @@ -0,0 +1,355 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFile } from 'node:child_process'; +import { info, warn } from './logger.mjs'; + +const DEFAULT_BIN = process.env.CALIBRE_FETCH_BIN || 'fetch-ebook-metadata'; +const DEFAULT_DEBUG_BIN = process.env.CALIBRE_DEBUG_BIN || 'calibre-debug'; +const DEFAULT_TIMEOUT_MS = 75_000; +const MAX_LOG_CHARS = 4000; + +// Machine-readable identification pass. Calibre's fetch-ebook-metadata CLI +// prints useful bibliographic metadata, but its human-readable/verbose output +// is not stable enough to parse. This fixed calibre-debug command asks +// Calibre's own identify pipeline for the best matches and emits only a JSON +// marker that kovi can consume. User-controlled metadata is supplied via +// environment variables and is never interpolated into Python source. +const IDENTIFY_JSON_CODE = [ + 'import os, json', + 'from io import BytesIO', + 'from threading import Event', + 'from calibre.ebooks.metadata import string_to_authors', + 'from calibre.ebooks.metadata.sources.base import create_log', + 'from calibre.ebooks.metadata.sources.identify import identify', + 'from calibre.ebooks.metadata.sources.update import patch_plugins', + 'patch_plugins()', + "title=os.environ.get('KOVI_CALIBRE_TITLE') or None", + "author=os.environ.get('KOVI_CALIBRE_AUTHORS') or ''", + 'authors=string_to_authors(author) if author else []', + 'identifiers={}', + "isbn=os.environ.get('KOVI_CALIBRE_ISBN') or ''", + "identifiers.update({'isbn': isbn}) if isbn else None", + 'buf=BytesIO(); log=create_log(buf)', + "results=identify(log,Event(),title=title,authors=authors,identifiers=identifiers,timeout=int(os.environ.get('KOVI_CALIBRE_TIMEOUT','45')))", + "payload=[{'title':getattr(m,'title','') or '', 'authors':list(getattr(m,'authors',[]) or []), 'identifiers':m.get_identifiers() if hasattr(m,'get_identifiers') else {}, 'languages':list(getattr(m,'languages',[]) or [])} for m in results[:5]]", + "print('KOVI_METADATA_JSON='+json.dumps(payload,ensure_ascii=False,separators=(',',':')))", +].join(';'); + +// Calibre's fetch-ebook-metadata CLI only reaches the cover phase after the +// identify phase succeeds. Google Images is a cover-only source and is disabled +// by default in Calibre, so hard cases with translated titles can otherwise +// never reach it. This fixed command runs Calibre's own cover downloader in a +// separate calibre-debug process, explicitly enabling Google Images. Metadata +// enters only through environment variables, never interpolated Python/source. +const DIRECT_COVER_CODE = [ + 'import os', + 'from io import BytesIO', + 'from calibre.customize.ui import enable_plugin', + "enable_plugin('Google Images')", + 'from calibre.ebooks.metadata import string_to_authors', + 'from calibre.ebooks.metadata.sources.base import create_log', + 'from calibre.ebooks.metadata.sources.covers import download_cover', + "title=os.environ.get('KOVI_CALIBRE_TITLE') or None", + "author=os.environ.get('KOVI_CALIBRE_AUTHORS') or ''", + 'authors=string_to_authors(author) if author else []', + 'identifiers={}', + "isbn=os.environ.get('KOVI_CALIBRE_ISBN') or ''", + "identifiers.update({'isbn': isbn}) if isbn else None", + 'buf=BytesIO(); log=create_log(buf)', + "cover=download_cover(log,title=title,authors=authors,identifiers=identifiers,timeout=int(os.environ.get('KOVI_CALIBRE_TIMEOUT','60')))", + "out=os.environ['KOVI_CALIBRE_OUTPUT']", + "open(out,'wb').write(cover[-1]) if cover else None", + "print('provider='+cover[0].name if cover else 'no-cover')", + "print(buf.getvalue().decode('utf-8','replace') if hasattr(buf.getvalue(),'decode') else str(buf.getvalue()))", +].join(';'); + +function enabledByEnv() { + const raw = String(process.env.CALIBRE_COVER_RESOLVER ?? 'auto').trim().toLowerCase(); + return !['0', 'false', 'no', 'off', 'disabled'].includes(raw); +} + +function cleanAuthor(value='') { + const s = String(value || '').trim(); + if (!s || /^(n\/?a|unknown|sconosciuto)$/i.test(s)) return ''; + return s; +} + +function stripQtNoise(value='') { + return String(value || '') + .replace(/^.*(?:QRhiGles2|QVulkanInstance|WebEngineContext|Unable to detect GPU vendor|createPlatformVulkanInstance).*$/gmi, '') + .replace(/\n{3,}/g, '\n\n'); +} + +function trimLog(value='') { + const s = stripQtNoise(value).replace(/\r/g, '').trim(); + if (!s) return ''; + return s.length > MAX_LOG_CHARS ? `${s.slice(0, MAX_LOG_CHARS)}…` : s; +} + +function parseMetadataJson(value='') { + const text=String(value||''); + const line=text.split(/\r?\n/).find(x=>x.startsWith('KOVI_METADATA_JSON=')); + if(!line) return []; + try { + const parsed=JSON.parse(line.slice('KOVI_METADATA_JSON='.length)); + if(!Array.isArray(parsed)) return []; + return parsed.slice(0,5).map(x=>({ + title:String(x?.title||'').slice(0,1000), + authors:Array.isArray(x?.authors)?x.authors.map(a=>String(a).slice(0,500)).slice(0,10):[], + identifiers:x?.identifiers && typeof x.identifiers==='object' ? Object.fromEntries(Object.entries(x.identifiers).slice(0,20).map(([k,v])=>[String(k).slice(0,80),String(v).slice(0,500)])) : {}, + languages:Array.isArray(x?.languages)?x.languages.map(v=>String(v).slice(0,40)).slice(0,10):[], + })); + } catch { return []; } +} + +function execFilePromise(file, args, options, impl = execFile) { + return new Promise((resolve, reject) => { + impl(file, args, options, (error, stdout, stderr) => { + if (error) { + error.stdout = stdout; + error.stderr = stderr; + reject(error); + } else resolve({ stdout, stderr }); + }); + }); +} + +function normalizeTitle(value='') { + return String(value).toLocaleLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[^\p{L}\p{N}]+/gu,' ').trim(); +} + +export function calibreTitleVariants(title='') { + const raw=String(title||'').trim(); + const out=[]; + const push=(value)=>{ + const v=String(value||'').trim(); + if(v && !out.some(x=>normalizeTitle(x)===normalizeTitle(v))) out.push(v); + }; + push(raw); + push(raw.replace(/\s*[\[(].*?[\])]\s*$/,'').trim()); + const dot=raw.match(/^.{2,32}\.\s+(.{3,})$/); if(dot) push(dot[1]); + const colon=raw.match(/^.{2,32}:\s+(.{3,})$/); if(colon) push(colon[1]); + return out.slice(0,3); +} + +export function calibreArgsForBook(book, coverPath, {includeAuthor=true, includeIsbn=true, titleOverride=null}={}) { + const args = []; + const title=titleOverride ?? book.title; + if (title) args.push('--title', String(title).trim()); + args.push('--cover', coverPath, '--timeout', '30'); + const author = cleanAuthor(book.authors); + if (includeAuthor && author) args.push('--authors', author); + if (includeIsbn && book.isbn) args.push('--isbn', String(book.isbn)); + args.push('--verbose'); + return args; +} + +export function calibreQueryPlans(book) { + const plans=[]; + const push=(name,opts)=>{ + const key=JSON.stringify(opts); + if(!plans.some(p=>JSON.stringify(p.opts)===key)) plans.push({name,opts}); + }; + if(book.isbn) push('isbn-title-author',{includeAuthor:true,includeIsbn:true,titleOverride:book.title}); + for(const [i,title] of calibreTitleVariants(book.title).entries()) { + const label=i===0?'title':`title-variant-${i}`; + push(`${label}-author`,{includeAuthor:true,includeIsbn:false,titleOverride:title}); + push(`${label}-only`,{includeAuthor:false,includeIsbn:false,titleOverride:title}); + } + return plans.slice(0,5); +} + +function directCoverPlans(book) { + const author=cleanAuthor(book.authors); + const plans=[]; + for(const [i,title] of calibreTitleVariants(book.title).entries()) { + const label=i===0?'direct-title':`direct-title-variant-${i}`; + if(author) plans.push({name:`${label}-author`,title,author}); + plans.push({name:label,title,author:''}); + } + return plans.slice(0,5); +} + +function calibreEnv(baseEnv, configDir, tmpDir, extra={}) { + return { + ...baseEnv, + CALIBRE_CONFIG_DIRECTORY: configDir, + CALIBRE_TEMP_DIR: path.join(tmpDir,'tmp'), + CALIBRE_CACHE_DIRECTORY: path.join(tmpDir,'cache'), + QT_QPA_PLATFORM: baseEnv.QT_QPA_PLATFORM || 'offscreen', + QT_OPENGL: baseEnv.QT_OPENGL || 'software', + QT_QUICK_BACKEND: baseEnv.QT_QUICK_BACKEND || 'software', + LIBGL_ALWAYS_SOFTWARE: baseEnv.LIBGL_ALWAYS_SOFTWARE || '1', + QTWEBENGINE_DISABLE_SANDBOX: baseEnv.QTWEBENGINE_DISABLE_SANDBOX || '1', + QTWEBENGINE_CHROMIUM_FLAGS: baseEnv.QTWEBENGINE_CHROMIUM_FLAGS || '--disable-gpu --disable-dev-shm-usage --no-sandbox', + ...extra, + }; +} + +async function runDirectCoverStage(book,{tmpDir,configDir,execFileImpl,debugBin,timeoutMs,env}) { + const plans=directCoverPlans(book); + info('cover.calibre.direct.start','Running Calibre cover-only sources, including Google Images.',{ + bookId:book.id,title:book.title,plans:plans.map(p=>p.name), + }); + let lastDetail=''; + for(let i=0;iObject.keys(x.identifiers||{})), + }); + return candidates; + } catch(e) { + if(e?.code==='ENOENT') return []; + warn('cover.calibre.metadata.error','Calibre metadata identification failed after cover lookup.',{bookId:book.id,title:book.title,error:trimLog(e?.stderr||e?.stdout||e?.message)}); + return []; + } +} + +/** + * Ask Calibre's own metadata-source framework to find the best cover. First + * use fetch-ebook-metadata (Calibre's identify -> cover path). If identify + * cannot find the book, run Calibre's cover downloader directly so cover-only + * sources such as Google Images still get a chance. + */ +export async function fetchCoverWithCalibre(book, { + dataDir, + execFileImpl = execFile, + bin = DEFAULT_BIN, + debugBin = DEFAULT_DEBUG_BIN, + timeoutMs = DEFAULT_TIMEOUT_MS, + env = process.env, +} = {}) { + if (!enabledByEnv()) return { status:'unavailable', reason:'disabled' }; + if (!book?.title && !book?.isbn) return { status:'none', reason:'insufficient-metadata' }; + + const base = path.join(dataDir || os.tmpdir(), 'uploads'); + fs.mkdirSync(base, { recursive:true }); + const tmpDir = fs.mkdtempSync(path.join(base, 'calibre-cover-')); + const configDir = path.join(tmpDir, 'config'); + fs.mkdirSync(configDir, { recursive:true }); + fs.mkdirSync(path.join(tmpDir,'tmp'),{recursive:true}); + fs.mkdirSync(path.join(tmpDir,'cache'),{recursive:true}); + const started = Date.now(); + const plans = calibreQueryPlans(book); + + info('cover.calibre.start', 'Asking Calibre metadata sources for a cover.', { + bookId:book.id, title:book.title, authors:book.authors, isbn:book.isbn || undefined, plans:plans.map(p=>p.name), + }); + + try { + let lastDetail=''; + for (let i=0;i new Promise(resolve => setTimeout(resolve, ms)); +const normalize = (s='') => String(s).toLocaleLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[^\p{L}\p{N}]+/gu,' ').trim(); +function tokens(s){ return new Set(normalize(s).split(' ').filter(Boolean)); } +function jaccard(a,b){ const A=tokens(a),B=tokens(b); if(!A.size||!B.size)return 0; let i=0; for(const x of A) if(B.has(x)) i++; return i/(A.size+B.size-i); } +function containment(a,b){ const A=tokens(a),B=tokens(b); if(!A.size||!B.size)return 0; let i=0; for(const x of A) if(B.has(x)) i++; return i/Math.min(A.size,B.size); } + +const LANG = { + it:'it', ita:'it', italian:'it', italiano:'it', + en:'en', eng:'en', english:'en', + fr:'fr', fre:'fr', fra:'fr', french:'fr', francais:'fr', français:'fr', + de:'de', ger:'de', deu:'de', german:'de', deutsch:'de', + es:'es', spa:'es', spanish:'es', espanol:'es', español:'es', + pt:'pt', por:'pt', portuguese:'pt', portugues:'pt', português:'pt', + ja:'ja', jpn:'ja', japanese:'ja', +}; +const OL_LANG = {it:'ita',en:'eng',fr:'fre',de:'ger',es:'spa',pt:'por',ja:'jpn'}; +function lang2(value){ + const raw=String(value||'').trim().toLocaleLowerCase(); + if(!raw) return null; + const baseRaw=raw.split(/[-_]/)[0]; + const n=normalize(raw).replace(/\s+/g,''); + const base=normalize(baseRaw).replace(/\s+/g,''); + return LANG[raw] || LANG[n] || LANG[baseRaw] || LANG[base] || (base.length===2 ? base : null); +} +function langMatches(bookLang,candidateLangs=[]){ + const two=lang2(bookLang); if(!two) return false; + const wanted=new Set([two,OL_LANG[two]].filter(Boolean)); + return candidateLangs.some(x=>wanted.has(normalize(x)) || lang2(x)===two); +} + +function authorSimilarity(bookAuthor, candidateAuthors) { + const ba=normalize(bookAuthor||''); + if(!ba || !candidateAuthors?.length) return 0; + const authors=candidateAuthors.map(normalize).filter(Boolean); + if(authors.some(a=>a===ba || a.includes(ba) || ba.includes(a))) return 1; + const bookTokens=[...tokens(ba)]; + for(const a of authors){ + const at=[...tokens(a)]; + if(at.length>=2 && at.every(t=>bookTokens.includes(t))) return 0.96; + } + return Math.max(0,...authors.map(a=>Math.max(jaccard(ba,a), containment(ba,a)*0.9))); +} + +export function scoreCandidate(book, doc) { + if (!doc?.cover_i && !doc?.cover_olid && !doc?.cover_isbn && !doc?.cover_url) return 0; + const bt=normalize(book.title), dt=normalize(doc.title || ''); + const jac=jaccard(bt,dt), contain=containment(bt,dt); + let score = bt && bt===dt ? 0.68 : Math.max(0.52*jac, 0.48*contain); + // Edition catalogues often reorder a series name and translated volume title, + // e.g. "Nevernight. I grandi giochi" vs "I grandi giochi. Nevernight". + // If the complete token sets are identical, the title is effectively exact + // even though word order/punctuation differ. This is safe enough to clear the + // normal threshold without requiring author metadata on the edition record. + const btokens=tokens(bt), dtokens=tokens(dt); + if(jac===1 && btokens.size===dtokens.size && btokens.size>=3) score=Math.max(score,0.76); + else if (contain >= 0.95 && jac >= 0.72) score = Math.max(score, 0.58); + const authorScore=authorSimilarity(book.authors,doc.author_name||doc.authors||[]); + if(authorScore>=0.92) score += 0.28; + else score += 0.18*authorScore; + if(book.language && Array.isArray(doc.language) && langMatches(book.language,doc.language)) score+=0.06; + else if(book.language && doc.language && lang2(book.language)===lang2(doc.language)) score+=0.06; + return Math.min(1,score); +} + +function candidateDocs(data) { + const out=[]; + for(const work of data?.docs||[]){ + if(work.cover_i) out.push(work); + const editions=work?.editions?.docs; + if(Array.isArray(editions)) for(const edition of editions){ + const cover_i=edition.cover_i || work.cover_i; + const cover_olid=String(edition.key||'').replace(/^\/books\//,'') || null; + if(!cover_i && !cover_olid) continue; + out.push({ + ...work, + title:edition.title || work.title, + cover_i, + language:edition.language || work.language, + edition_key:edition.key, + cover_olid, + }); + } + } + return out; +} + +function simplifiedTitle(title='') { + return String(title) + .replace(/\s*[\[(].*?[\])]\s*$/,'') + .replace(/\s*[:–—-]\s+[^:–—-]{3,}$/,'') + .trim(); +} + +function titleVariants(title='') { + const raw=String(title||'').trim(); + const out=[]; + const push=v=>{v=String(v||'').trim(); if(v && !out.some(x=>normalize(x)===normalize(v))) out.push(v);}; + push(raw); + push(simplifiedTitle(raw)); + // Italian publishers often prefix the translated volume title with the series, + // e.g. "Nevernight. I grandi giochi" while catalogues store "I grandi giochi". + const dot=raw.match(/^.{2,32}\.\s+(.{3,})$/); + if(dot) { push(dot[1]); push(simplifiedTitle(dot[1])); } + const colon=raw.match(/^.{2,32}:\s+(.{3,})$/); + if(colon) push(colon[1]); + return out.slice(0,4); +} + +function quoteSolr(value=''){ return `"${String(value).replace(/["\\]/g,' ').replace(/\s+/g,' ').trim()}"`; } +function openLibraryStrategies(book){ + const author=String(book.authors||'').trim(); + const out=[]; + const push=(name,q)=>{ if(q && !out.some(x=>x.q===q)) out.push({name,q}); }; + for(const [i,title] of titleVariants(book.title).entries()) { + const label=i===0?'edition-title':`title-variant-${i}`; + if(author) push(`${label}-author`, `${quoteSolr(title)} author:${quoteSolr(author)}`); + push(label, quoteSolr(title)); + } + const raw=String(book.title||'').trim(); + if(raw&&author) push('relaxed-title-author', `${raw} author:${quoteSolr(author)}`); + return out.slice(0,8); +} + +async function fetchWithRetry(url, options, fetchImpl, { attempts=NETWORK_RETRIES, label='request', timeoutMs=7000 }={}) { + let lastError; + for(let attempt=0; attempt{if(q&&!out.some(x=>x.q===q))out.push({name,q});}; + if(book.isbn) push('google-isbn',`isbn:${book.isbn}`); + for(const [i,title] of titleVariants(book.title).entries()) { + const label=i===0?'google-title':`google-title-variant-${i}`; + if(author) push(`${label}-author`, `intitle:${quoteSolr(title)} inauthor:${quoteSolr(author)}`); + push(label, `intitle:${quoteSolr(title)}`); + } + return out.slice(0,8); +} + +function googleCandidates(data){ + const out=[]; + for(const item of data?.items||[]){ + const v=item?.volumeInfo||{}; + const identifiers=Array.isArray(v.industryIdentifiers)?v.industryIdentifiers:[]; + const isbn13=identifiers.find(x=>x?.type==='ISBN_13')?.identifier; + const isbn10=identifiers.find(x=>x?.type==='ISBN_10')?.identifier; + const isbn=String(isbn13||isbn10||'').replace(/[^0-9Xx]/g,''); + const imageLinks=v.imageLinks||{}; + const coverUrl=imageLinks.extraLarge||imageLinks.large||imageLinks.medium||imageLinks.small||imageLinks.thumbnail||imageLinks.smallThumbnail||null; + if(!isbn && !coverUrl) continue; + out.push({ + title:v.title||'', + author_name:Array.isArray(v.authors)?v.authors:[], + language:v.language||null, + cover_isbn:isbn||null, + cover_url:coverUrl, + google_id:item.id, + }); + } + return out; +} + +async function searchGoogleBooks(book,strategy,fetchImpl,apiKey){ + const params=new URLSearchParams({q:strategy.q,maxResults:'20',printType:'books',projection:'lite'}); + if(apiKey) params.set('key',apiKey); + const preferred=lang2(book.language); if(preferred) params.set('langRestrict',preferred); + const url=`https://www.googleapis.com/books/v1/volumes?${params}`; + const resp=await fetchWithRetry(url,{headers:{'User-Agent':`kovi/${VERSION}`}},fetchImpl,{label:'Google Books search',timeoutMs:6500}); + if(!resp.ok) throw new Error(`Google Books search returned ${resp.status}`); + return {data:await resp.json(),searchUrl:url}; +} + +export function detectImageType(buf){ + if(buf.length>3 && buf[0]===0xff&&buf[1]===0xd8&&buf[2]===0xff) return {ext:'jpg',mime:'image/jpeg'}; + if(buf.length>8 && buf.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]))) return {ext:'png',mime:'image/png'}; + if(buf.length>12 && buf.toString('ascii',0,4)==='RIFF' && buf.toString('ascii',8,12)==='WEBP') return {ext:'webp',mime:'image/webp'}; + if(buf.length>6 && (buf.toString('ascii',0,6)==='GIF87a' || buf.toString('ascii',0,6)==='GIF89a')) return {ext:'gif',mime:'image/gif'}; + return null; +} + +function googleHostAllowed(url) { + try { + const host=new URL(url).hostname.toLowerCase(); + return host==='books.google.com' || host==='books.googleapis.com' || host.endsWith('.googleusercontent.com'); + } catch { return false; } +} + +async function fetchValidatedImage(url, provider, fetchImpl) { + const target=provider==='googlebooks' ? String(url).replace(/^http:/,'https:') : String(url); + if(provider==='googlebooks' && !googleHostAllowed(target)) throw new Error('Google Books returned an unexpected cover host'); + const img=await fetchWithRetry(target,{headers:{'User-Agent':`kovi/${VERSION}`},redirect:'follow'},fetchImpl,{label:`${provider} cover fetch`,timeoutMs:8000}); + if(!img.ok) { + const err=new Error(`${provider} cover fetch returned ${img.status}`); + err.status=img.status; + throw err; + } + if(provider==='googlebooks' && img.url && !googleHostAllowed(img.url)) throw new Error('Google Books cover redirected to an unexpected host'); + const len=Number(img.headers.get('content-length')||0); + if(len>5_000_000) throw new Error('Cover image too large'); + const buf=Buffer.from(await img.arrayBuffer()); + if(buf.length>5_000_000) throw new Error('Cover image too large'); + if(provider==='googlebooks'){ + const md5=createHash('md5').update(buf).digest('hex'); + if(GOOGLE_DUMMY_MD5.has(md5)) throw new Error('Google Books returned a known dummy cover image'); + } + const type=detectImageType(buf); + if(!type){ + const err=new Error('Cover provider returned unsupported image data'); + err.coverMeta={contentType:cleanText(img.headers.get('content-type'),100),bytes:buf.length}; + throw err; + } + return {buf,type,url:target}; +} + +function cacheBuffer(book, dataDir, buf, type, suffix='') { + const coversDir=path.join(dataDir,'covers'); fs.mkdirSync(coversDir,{recursive:true}); + // Cover paths are immutable so cover history can safely point at an older image. + // The random suffix also eliminates browser-cache ambiguity after replacements. + const filename=`${book.id}${suffix}-${Date.now().toString(36)}-${randomId(4)}.${type.ext}`; + const finalPath=path.join(coversDir,filename); + const tempPath=path.join(coversDir,`.${filename}.${randomId(6)}.tmp`); + fs.writeFileSync(tempPath,buf,{mode:0o644,flag:'wx'}); + fs.renameSync(tempPath,finalPath); + return `/covers/${filename}`; +} + +function openLibraryCandidateUrls(candidate){ + const urls=[]; + const push=u=>{if(u&&!urls.includes(u))urls.push(u);}; + if(candidate.cover_i){ + push(`https://covers.openlibrary.org/b/id/${Number(candidate.cover_i)}-L.jpg?default=false`); + push(`https://covers.openlibrary.org/b/id/${Number(candidate.cover_i)}-M.jpg?default=false`); + } + if(candidate.cover_olid) push(`https://covers.openlibrary.org/b/olid/${encodeURIComponent(candidate.cover_olid)}-L.jpg?default=false`); + if(candidate.cover_isbn) push(`https://covers.openlibrary.org/b/isbn/${encodeURIComponent(candidate.cover_isbn)}-L.jpg?default=false`); + return urls; +} + +async function tryCandidateDownload(book,candidate,provider,dataDir,fetchImpl,{strategy='unknown'}={}){ + const attempts=[]; + const urls=[]; + if(provider==='openlibrary') for(const url of openLibraryCandidateUrls(candidate)) urls.push({url,fetchProvider:'openlibrary',source:'openlibrary'}); + if(provider==='googlebooks') { + // Prefer ISBN through Open Library when Google found the bibliographic record, + // but use Google's own returned cover as a validated/cacheable fallback. + for(const url of openLibraryCandidateUrls(candidate)) urls.push({url,fetchProvider:'openlibrary',source:'openlibrary-via-googlebooks'}); + if(candidate.cover_url) urls.push({url:candidate.cover_url,fetchProvider:'googlebooks',source:'googlebooks'}); + for(const url of candidate.cover_urls||[]) if(url) urls.push({url,fetchProvider:'googlebooks',source:'googlebooks-via-calibre-id'}); + } + for(const item of urls){ + try{ + const {buf,type}=await fetchValidatedImage(item.url,item.fetchProvider,fetchImpl); + return {ok:true,path:cacheBuffer(book,dataDir,buf,type),source:item.source}; + }catch(e){ + const failure={provider:item.fetchProvider,strategy,error:e.message,status:e.status||null,...(e.coverMeta||{})}; + attempts.push(failure); + warn('cover.download.rejected','Rejected a cover image and will continue with other candidates.',{bookId:book.id,title:book.title,...failure}); + } + } + return {ok:false,attempts}; +} + +function calibreMetadataDocs(calibre){ + const out=[]; + for(const item of calibre?.metadataCandidates||[]){ + const ids=item?.identifiers||{}; + const google=String(ids.google||ids.Google||'').trim(); + const isbn=String(ids.isbn||ids.ISBN||'').replace(/[^0-9Xx]/g,''); + const cover_urls=[]; + if(google){ + const id=encodeURIComponent(google); + // This is the same stable Google Books cover endpoint used by Calibre's + // Google metadata source. Try both zoom levels because some records only + // expose one of them. + cover_urls.push(`https://books.google.com/books?id=${id}&printsec=frontcover&img=1&zoom=0`); + cover_urls.push(`https://books.google.com/books?id=${id}&printsec=frontcover&img=1&zoom=1`); + } + if(!google && !isbn) continue; + out.push({ + title:item.title||'', + author_name:Array.isArray(item.authors)?item.authors:[], + language:Array.isArray(item.languages)?item.languages:item.languages||null, + cover_isbn:isbn||null, + google_id:google||null, + cover_urls, + }); + } + return out; +} + +async function tryDirectIsbn(book,dataDir,fetchImpl){ + if(!book.isbn) return null; + const candidate={title:book.title,author_name:[book.authors].filter(Boolean),language:book.language,cover_isbn:book.isbn}; + const dl=await tryCandidateDownload(book,candidate,'openlibrary',dataDir,fetchImpl,{strategy:'isbn-direct'}); + const attempt={provider:'openlibrary',strategy:'isbn-direct',isbn:book.isbn,candidates:1,bestScore:1,downloaded:dl.ok}; + info('cover.lookup.attempt','Cover lookup strategy completed.',{bookId:book.id,title:book.title,...attempt}); + return dl.ok ? {status:'matched',path:dl.path,source:dl.source,score:1,matchedTitle:book.title,strategy:'isbn-direct',trace:[attempt]} : {status:'none',score:1,trace:[attempt,...dl.attempts]}; +} + +async function tryGoogleIsbn(book,dataDir,fetchImpl,apiKey){ + if(!book.isbn) return null; + try { + const strategy={name:'google-isbn',q:`isbn:${book.isbn}`}; + const {data}=await searchGoogleBooks(book,strategy,fetchImpl,apiKey); + const docs=googleCandidates(data); + const ranked=docs.map(d=>({doc:d,score:scoreCandidate(book,d)})).sort((a,b)=>b.score-a.score); + const attempt={provider:'googlebooks',strategy:'google-isbn',isbn:book.isbn,results:Number(data?.totalItems||0),candidates:docs.length,bestScore:ranked[0]?.score||0}; + for(const candidate of ranked.slice(0,3)){ + // ISBN queries are deterministic enough to accept a returned cover even if + // translated title metadata differs, while still preferring the best rank. + const dl=await tryCandidateDownload(book,candidate.doc,'googlebooks',dataDir,fetchImpl,{strategy:'google-isbn'}); + if(dl.ok) return {status:'matched',path:dl.path,source:dl.source,score:Math.max(candidate.score,0.95),matchedTitle:candidate.doc.title||book.title,strategy:'google-isbn',trace:[attempt]}; + attempt.downloadFailures=(attempt.downloadFailures||0)+dl.attempts.length; + } + return {status:'none',score:attempt.bestScore,trace:[attempt]}; + } catch(e) { + return {status:'none',score:0,trace:[{provider:'googlebooks',strategy:'google-isbn',error:e.message,status:e.status||null}]}; + } +} + +async function searchNativePair(book, olStrategy, googleStrategy, fetchImpl, apiKey){ + const [ol,google]=await Promise.allSettled([ + olStrategy ? searchOpenLibrary(book,olStrategy,fetchImpl) : Promise.resolve(null), + googleStrategy ? searchGoogleBooks(book,googleStrategy,fetchImpl,apiKey) : Promise.resolve(null), + ]); + return {ol,google}; +} + +export async function resolveCover(book, { dataDir, fetchImpl = fetch, googleBooksApiKey = process.env.GOOGLE_BOOKS_API_KEY || '', calibreResolver = fetchCoverWithCalibre } = {}) { + const trace=[]; + let bestScore=0; + + // Stable identifiers are the quickest and most reliable path. Open Library's + // ISBN cover endpoint is a single request; if it misses, Google Books gets a + // deterministic ISBN query before we fall back to fuzzy title matching. + if(book.isbn){ + const direct=await tryDirectIsbn(book,dataDir,fetchImpl); + if(direct?.status==='matched') return direct; + if(direct) { trace.push(...direct.trace); bestScore=Math.max(bestScore,direct.score||0); } + const googleIsbn=await tryGoogleIsbn(book,dataDir,fetchImpl,googleBooksApiKey); + if(googleIsbn?.status==='matched') { googleIsbn.trace=[...trace,...(googleIsbn.trace||[])]; return googleIsbn; } + if(googleIsbn) { trace.push(...(googleIsbn.trace||[])); bestScore=Math.max(bestScore,googleIsbn.score||0); } + } + + const olStrategies=openLibraryStrategies(book); + const googlePlans=googleStrategies({...book,isbn:null}).filter(x=>x.name!=='google-isbn'); + const maxNative=Math.max(olStrategies.length,googlePlans.length); + + // Query the two lightweight catalogues together. This removes the old + // Open-Library-first waterfall (and its per-query sleeps), so a Google Books + // hit can arrive without waiting through every Open Library title variant. + for(let i=0;i({doc:d,score:scoreCandidate(book,d)})).sort((a,b)=>b.score-a.score); + const top=ranked[0]; bestScore=Math.max(bestScore,top?.score||0); + const attempt={provider:'openlibrary',strategy:olStrategy.name,results:Number(data?.numFound||data?.docs?.length||0),candidates:docs.length,bestScore:top?.score||0,matchedTitle:top?.doc?.title||null}; + trace.push(attempt); groups.push({provider:'openlibrary',strategy:olStrategy.name,ranked}); + info('cover.lookup.attempt','Cover search strategy completed.',{bookId:book.id,title:book.title,...attempt,bestScore:Number((attempt.bestScore||0).toFixed(3))}); + } else trace.push({provider:'openlibrary',strategy:olStrategy.name,error:pair.ol.reason?.message||'request failed',status:pair.ol.reason?.status||null}); + } + if(googleStrategy){ + if(pair.google.status==='fulfilled'){ + const data=pair.google.value?.data; + const docs=googleCandidates(data); + const ranked=docs.map(d=>({doc:d,score:scoreCandidate(book,d)})).sort((a,b)=>b.score-a.score); + const top=ranked[0]; bestScore=Math.max(bestScore,top?.score||0); + const attempt={provider:'googlebooks',strategy:googleStrategy.name,results:Number(data?.totalItems||0),candidates:docs.length,bestScore:top?.score||0,matchedTitle:top?.doc?.title||null}; + trace.push(attempt); groups.push({provider:'googlebooks',strategy:googleStrategy.name,ranked}); + info('cover.lookup.attempt','Cover search strategy completed.',{bookId:book.id,title:book.title,...attempt,bestScore:Number((attempt.bestScore||0).toFixed(3))}); + } else trace.push({provider:'googlebooks',strategy:googleStrategy.name,error:pair.google.reason?.message||'request failed',status:pair.google.reason?.status||null}); + } + + // Try the strongest candidates across both providers first. This avoids + // wasting downloads on a merely acceptable result when another catalogue + // already returned an exact title/author match in the same round. + const candidates=groups.flatMap(g=>g.ranked.slice(0,4).map(x=>({...x,provider:g.provider,strategy:g.strategy}))) + .filter(x=>x.score>=MATCH_THRESHOLD).sort((a,b)=>b.score-a.score); + for(const candidate of candidates.slice(0,6)){ + const dl=await tryCandidateDownload(book,candidate.doc,candidate.provider,dataDir,fetchImpl,{strategy:candidate.strategy}); + if(dl.ok) return {status:'matched',path:dl.path,source:dl.source,score:candidate.score,matchedTitle:candidate.doc.title,strategy:candidate.strategy,trace}; + trace.push(...dl.attempts); + } + } + + // Heavyweight federation is deliberately last. It is excellent for difficult + // books, but spawning Calibre is much slower than the native HTTP catalogues. + if (calibreResolver && (fetchImpl === fetch || calibreResolver !== fetchCoverWithCalibre)) { + const calibre = await calibreResolver(book,{dataDir}); + trace.push({provider:'calibre',strategy:'metadata-source-federation',status:calibre?.status||'unknown'}); + if(calibre?.status==='matched' && calibre.buffer){ + const type=detectImageType(calibre.buffer); + if(type && calibre.buffer.length<=5_000_000){ + const coverPath=cacheBuffer(book,dataDir,calibre.buffer,type,'-calibre'); + info('cover.calibre.matched','Accepted Calibre-selected cover.',{bookId:book.id,title:book.title,bytes:calibre.buffer.length,mime:type.mime,calibreStrategy:calibre.strategy||undefined}); + return {status:'matched',path:coverPath,source:'calibre',score:1,matchedTitle:book.title,strategy:`calibre:${calibre.strategy||'metadata-sources'}`,trace}; + } + warn('cover.calibre.rejected','Calibre returned an unsupported or oversized image; continuing with identifier recovery.',{bookId:book.id,title:book.title,bytes:calibre.buffer.length}); + } + + const metaDocs=calibreMetadataDocs(calibre); + const ranked=metaDocs.map(d=>({doc:d,score:scoreCandidate(book,{...d,cover_url:d.cover_urls?.[0]||null})})).sort((a,b)=>b.score-a.score); + if(ranked.length){ + const top=ranked[0]; + const attempt={provider:'calibre-identifiers',strategy:'google-id-or-isbn',candidates:ranked.length,bestScore:top.score,matchedTitle:top.doc.title||null,googleId:Boolean(top.doc.google_id),isbn:Boolean(top.doc.cover_isbn)}; + trace.push(attempt); + for(const rankedCandidate of ranked.slice(0,5)){ + if(rankedCandidate.score4_000_000) throw Object.assign(new Error('Embedded cover exceeds the 4 MB limit.'),{status:413}); + const type=detectImageType(buffer); + if(!type) throw Object.assign(new Error('Embedded cover is not a supported JPEG, PNG, WebP, or GIF image.'),{status:400}); + const book=db.prepare(`SELECT id,title,cover_status,cover_path,cover_source,cover_checked_at,updated_at FROM books WHERE source_md5=?`).get(sourceMd5); + if(!book) throw Object.assign(new Error('Book for embedded cover was not found.'),{status:404}); + if(book.cover_status==='manual' || book.cover_source==='manual-upload') return {ignored:true,reason:'manual-cover',book}; + const coverPath=cacheBuffer(book,dataDir,buffer,type,'-koreader'); + recordCoverSelection(db,book,{path:coverPath,source:'koreader-embedded',status:'matched',strategy:'koreader-embedded'}); + return {ignored:false,bookId:book.id,title:book.title,path:coverPath,type:type.mime,bytes:buffer.length}; +} + +export function saveManualCover(db, dataDir, bookId, buffer) { + if(!Buffer.isBuffer(buffer)) buffer=Buffer.from(buffer||[]); + if(buffer.length<32) throw Object.assign(new Error('Cover image is empty or too small.'),{status:400}); + if(buffer.length>4_000_000) throw Object.assign(new Error('Cover image exceeds the 4 MB limit.'),{status:413}); + const type=detectImageType(buffer); + if(!type) throw Object.assign(new Error('Cover image must be a JPEG, PNG, WebP, or GIF image.'),{status:400}); + const book=db.prepare(`SELECT id,title,cover_status,cover_path,cover_source,cover_checked_at,updated_at FROM books WHERE id=?`).get(bookId); + if(!book) throw Object.assign(new Error('Book not found.'),{status:404}); + const coverPath=cacheBuffer(book,dataDir,buffer,type,'-manual'); + recordCoverSelection(db,book,{path:coverPath,source:'manual-upload',status:'manual',strategy:'manual-upload'}); + // A later manual choice supersedes any queued/running online request. + db.prepare(`DELETE FROM cover_jobs WHERE book_id=?`).run(book.id); + return {bookId:book.id,title:book.title,path:coverPath,type:type.mime,bytes:buffer.length}; +} + +function queueCoverJob(db,bookId,{reason='automatic',forceOnline=false}={}){ + const ts=nowIso(); + db.prepare(`INSERT INTO cover_jobs(book_id,state,reason,force_online,attempts,requested_at,started_at,finished_at,last_error,result_status,result_source) + VALUES(?,'queued',?,?,0,?,NULL,NULL,NULL,NULL,NULL) + ON CONFLICT(book_id) DO UPDATE SET state='queued',reason=excluded.reason,force_online=excluded.force_online,requested_at=excluded.requested_at,started_at=NULL,finished_at=NULL,last_error=NULL,result_status=NULL,result_source=NULL`) + .run(bookId,reason,forceOnline?1:0,ts); +} + +export function queueOnlineCoverRetry(db, dataDir, bookId, {reason='user-retry-book',delayMs=50}={}) { + const book=db.prepare(`SELECT id,title FROM books WHERE id=?`).get(bookId); + if(!book) return null; + // Do not mutate a perfectly good local/current cover while lookup is running. + db.prepare(`UPDATE books SET cover_retry_mode=NULL WHERE id=?`).run(book.id); + queueCoverJob(db,book.id,{reason,forceOnline:true}); + scheduleCoverWork(db,dataDir,[],{reason,delayMs}); + return book; +} + +export function selectCoverHistory(db,bookId,coverId){ + const book=db.prepare(`SELECT id,title,cover_status,cover_path,cover_source,cover_checked_at,updated_at FROM books WHERE id=?`).get(bookId); + if(!book) return null; + const cover=db.prepare(`SELECT * FROM book_covers WHERE id=? AND book_id=?`).get(coverId,bookId); + if(!cover) return false; + recordCoverSelection(db,book,{path:cover.path,source:cover.source,status:cover.cover_status||coverStatusForSource(cover.source),strategy:cover.strategy,score:cover.score,matchedTitle:cover.matched_title}); + db.prepare(`DELETE FROM cover_jobs WHERE book_id=?`).run(bookId); + return true; +} + +export function selectCoverCandidate(db,bookId,candidateId){ + const book=db.prepare(`SELECT id,title,cover_status,cover_path,cover_source,cover_checked_at,updated_at FROM books WHERE id=?`).get(bookId); + if(!book) return null; + const candidate=db.prepare(`SELECT * FROM cover_candidates WHERE id=? AND book_id=?`).get(candidateId,bookId); + if(!candidate) return false; + recordCoverSelection(db,book,{path:candidate.path,source:candidate.source,status:'matched',strategy:candidate.strategy,score:candidate.score,matchedTitle:candidate.title}); + db.prepare(`DELETE FROM cover_jobs WHERE book_id=?`).run(bookId); + return true; +} + +export async function findCoverCandidates(db,dataDir,bookId,{fetchImpl=fetch,googleBooksApiKey=process.env.GOOGLE_BOOKS_API_KEY||'',limit=6}={}){ + const book=db.prepare(`SELECT id,title,authors,language,isbn,identifiers FROM books WHERE id=?`).get(bookId); + if(!book) return null; + // Candidate images are local cached previews. Remove old, unselected previews + // for this book before building the next set. + const old=db.prepare(`SELECT path FROM cover_candidates WHERE book_id=?`).all(bookId); + db.prepare(`DELETE FROM cover_candidates WHERE book_id=?`).run(bookId); + for(const row of old){ + if(db.prepare(`SELECT 1 ok FROM book_covers WHERE path=?`).get(row.path)) continue; + const local=path.join(dataDir,String(row.path||'').replace(/^\/+/,'')); + try{fs.rmSync(local,{force:true})}catch{} + } + + const pool=[]; + const add=(provider,strategy,docs)=>{ + for(const doc of docs||[]){ + const score=scoreCandidate(book,doc); + if(score<0.5) continue; + const key=`${provider}|${doc.cover_i||doc.cover_olid||doc.cover_isbn||doc.cover_url||doc.google_id||''}|${normalize(doc.title||'')}`; + if(pool.some(x=>x.key===key)) continue; + pool.push({key,provider,strategy,doc,score}); + } + }; + if(book.isbn) add('openlibrary','isbn-direct',[{title:book.title,author_name:[book.authors].filter(Boolean),language:book.language,cover_isbn:book.isbn}]); + const olPlans=openLibraryStrategies(book).slice(0,2),googlePlans=googleStrategies(book).slice(0,2); + for(let i=0;ib.score-a.score); + const rows=[]; + for(const item of pool.slice(0,Math.max(limit*3,12))){ + if(rows.length>=limit) break; + const dl=await tryCandidateDownload(book,item.doc,item.provider,dataDir,fetchImpl,{strategy:item.strategy}); + if(!dl.ok) continue; + const id=randomId(16),createdAt=nowIso(); + const authors=(item.doc.author_name||item.doc.authors||[]); const authorText=Array.isArray(authors)?authors.join(', '):String(authors||''); + db.prepare(`INSERT INTO cover_candidates(id,book_id,path,source,strategy,score,title,authors,created_at) VALUES(?,?,?,?,?,?,?,?,?)`) + .run(id,book.id,dl.path,dl.source||item.provider,item.strategy,item.score,cleanText(item.doc.title,500)||book.title,cleanText(authorText,500),createdAt); + rows.push({id,path:dl.path,source:dl.source||item.provider,strategy:item.strategy,score:item.score,title:item.doc.title||book.title,authors:authorText,created_at:createdAt}); + } + return rows; +} + +let coverWorkerRunning=false; +let coverWorkerTimer=null; +let coverRecoveryDone=false; + +export async function processCoverBook(db,dataDir,book,{resolver=resolveCover,forceOnline=false}={}){ + const before=db.prepare(`SELECT cover_status,cover_path,cover_source,cover_retry_mode,cover_checked_at,updated_at FROM books WHERE id=?`).get(book.id); + forceOnline=Boolean(forceOnline || before?.cover_retry_mode==='online'); + const localPriority=before?.cover_status==='manual' || before?.cover_source==='koreader-embedded' || before?.cover_source==='manual-upload'; + if(localPriority && !forceOnline){ + const restored=coverStatusForSource(before.cover_source); + if(before.cover_status==='pending') db.prepare(`UPDATE books SET cover_status=?,cover_retry_mode=NULL,updated_at=? WHERE id=?`).run(restored,nowIso(),book.id); + info('cover.lookup.local-preserved','Skipped online lookup because a higher-priority local cover is already present.',{bookId:book.id,title:book.title,coverSource:before.cover_source}); + return {status:'preserved',source:before.cover_source}; + } + info('cover.lookup.start','Looking up cover.',{bookId:book.id,title:book.title,authors:book.authors,language:book.language,isbn:book.isbn||undefined,googleBooks:true,forceOnline}); + try{ + const r=await resolver(book,{dataDir}); + const current=db.prepare(`SELECT cover_status,cover_path,cover_source,cover_checked_at,updated_at FROM books WHERE id=?`).get(book.id); + const changedDuringLookup=current && (current.cover_path!==before?.cover_path || current.cover_source!==before?.cover_source); + const currentLocal=current?.cover_status==='manual' || current?.cover_source==='koreader-embedded' || current?.cover_source==='manual-upload'; + if(currentLocal && (changedDuringLookup || !forceOnline)){ + info('cover.lookup.superseded','Online cover result was superseded by a newer/higher-priority local cover.',{bookId:book.id,title:book.title,coverSource:current.cover_source}); + return {status:'superseded',source:current.cover_source}; + } + if(forceOnline && r.status!=='matched' && current?.cover_path){ + const restored=coverStatusForSource(current.cover_source); + db.prepare(`UPDATE books SET cover_status=?,cover_retry_mode=NULL,cover_checked_at=?,updated_at=? WHERE id=?`).run(restored,nowIso(),nowIso(),book.id); + info('cover.lookup.none','No confident online cover match found; keeping the existing cover.',{bookId:book.id,title:book.title,coverSource:current.cover_source,bestScore:Number((r.score||0).toFixed(3)),attempts:r.trace}); + return {status:'none-kept',source:current.cover_source}; + } + if(r.status==='matched'){ + const fullBook={...book,...current}; + recordCoverSelection(db,fullBook,{path:r.path,source:r.source,status:'matched',strategy:r.strategy,score:r.score,matchedTitle:r.matchedTitle}); + info('cover.lookup.matched','Cover matched and cached.',{bookId:book.id,title:book.title,matchedTitle:r.matchedTitle,score:Number((r.score||0).toFixed(3)),provider:r.source,strategy:r.strategy,attempts:r.trace}); + return {status:'matched',source:r.source}; + } + db.prepare(`UPDATE books SET cover_status='none',cover_path=NULL,cover_source=NULL,cover_retry_mode=NULL,cover_checked_at=?,updated_at=? WHERE id=?`).run(nowIso(),nowIso(),book.id); + info('cover.lookup.none','No confident cover match found.',{bookId:book.id,title:book.title,bestScore:Number((r.score||0).toFixed(3)),attempts:r.trace}); + return {status:'none',source:null}; + }catch(e){ + const current=db.prepare(`SELECT cover_status,cover_path,cover_source FROM books WHERE id=?`).get(book.id); + if(current?.cover_path) db.prepare(`UPDATE books SET cover_status=?,cover_retry_mode=NULL,cover_checked_at=?,updated_at=? WHERE id=?`).run(coverStatusForSource(current.cover_source),nowIso(),nowIso(),book.id); + else db.prepare(`UPDATE books SET cover_status='error',cover_retry_mode=NULL,cover_checked_at=?,updated_at=? WHERE id=?`).run(nowIso(),nowIso(),book.id); + warn('cover.lookup.error','Cover lookup failed.',{bookId:book.id,title:book.title,error:e.message}); + return {status:'error',source:current?.cover_source||null,error:e.message}; + } +} + +async function runCoverBatch(db,dataDir){ + const jobs=db.prepare(`SELECT j.book_id,j.reason,j.force_online,b.id,b.title,b.authors,b.language,b.isbn,b.identifiers,b.cover_path,b.cover_source,b.cover_status + FROM cover_jobs j JOIN books b ON b.id=j.book_id WHERE j.state='queued' ORDER BY j.requested_at LIMIT 100`).all(); + if(!jobs.length) return 0; + let cursor=0; + const workers=Array.from({length:Math.min(COVER_WORKER_CONCURRENCY,jobs.length)},async()=>{ + while(cursor{ + coverWorkerTimer=null; + if(coverWorkerRunning) return; + coverWorkerRunning=true; + info('cover.worker.start','Cover worker started.',{pending,concurrency:COVER_WORKER_CONCURRENCY}); + let processed=0; + try{ processed=await runCoverBatch(db,dataDir); } + finally { + coverWorkerRunning=false; + const remaining=Number(db.prepare(`SELECT COUNT(*) n FROM cover_jobs WHERE state='queued'`).get().n); + info('cover.worker.stop','Cover worker finished a batch.',{processed,remaining}); + if(remaining) scheduleCoverWork(db,dataDir,[],{reason:'continue-batch',delayMs:100}); + } + },Math.max(0,Number(delayMs)||0)); +} + diff --git a/lib/db.mjs b/lib/db.mjs new file mode 100644 index 0000000..f209b74 --- /dev/null +++ b/lib/db.mjs @@ -0,0 +1,494 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { randomInt } from 'node:crypto'; +import { nowIso, randomId, randomToken, tokenHash, safeEqualHex } from './security.mjs'; + +const dbTimeZones = new WeakMap(); + +export function resolveTimeZone(value) { + const timeZone = String(value || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC').trim(); + try { new Intl.DateTimeFormat('en-US', { timeZone }).format(0); } + catch { throw new Error(`Invalid TZ value: ${timeZone}. Use an IANA time zone such as Europe/Rome.`); } + return timeZone; +} + +function zonedDateFormatter(timeZone) { + const formatter = new Intl.DateTimeFormat('en-US', { timeZone, year:'numeric', month:'2-digit', day:'2-digit' }); + return epochSeconds => { + const parts = Object.fromEntries(formatter.formatToParts(new Date(Number(epochSeconds) * 1000)).map(part => [part.type, part.value])); + return `${parts.year}-${parts.month}-${parts.day}`; + }; +} + +function tableColumns(db, table) { + return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(r => r.name)); +} + +function ensureColumn(db, table, column, definition) { + if (!tableColumns(db, table).has(column)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); +} + +export function openAppDb(dataDir, { timeZone } = {}) { + fs.mkdirSync(dataDir, { recursive: true }); + const db = new DatabaseSync(path.join(dataDir, 'kovi.sqlite')); + const resolvedTimeZone = resolveTimeZone(timeZone); + const localDay = zonedDateFormatter(resolvedTimeZone); + db.function('kovi_local_day', { deterministic:true }, localDay); + dbTimeZones.set(db, resolvedTimeZone); + db.exec(` + PRAGMA journal_mode=WAL; + PRAGMA foreign_keys=ON; + PRAGMA busy_timeout=5000; + + CREATE TABLE IF NOT EXISTS books ( + id TEXT PRIMARY KEY, + source_md5 TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + authors TEXT, + notes INTEGER NOT NULL DEFAULT 0, + last_open INTEGER NOT NULL DEFAULT 0, + highlights INTEGER NOT NULL DEFAULT 0, + pages INTEGER NOT NULL DEFAULT 0, + series TEXT, + language TEXT, + identifiers TEXT, + isbn TEXT, + total_read_time INTEGER NOT NULL DEFAULT 0, + total_read_pages INTEGER NOT NULL DEFAULT 0, + koreader_status TEXT CHECK(koreader_status IN ('reading','abandoned','complete')), + koreader_status_modified TEXT, + read_override INTEGER CHECK(read_override IN (0,1)), + read_override_at INTEGER, + cover_status TEXT NOT NULL DEFAULT 'pending' CHECK(cover_status IN ('pending','matched','none','error','manual')), + cover_path TEXT, + cover_source TEXT, + cover_retry_mode TEXT, + cover_checked_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS reading_sessions ( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL UNIQUE, + book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, + device_id TEXT, + page INTEGER NOT NULL DEFAULT 0, + start_time INTEGER NOT NULL, + duration INTEGER NOT NULL, + total_pages INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sessions_book_start ON reading_sessions(book_id, start_time DESC); + CREATE INDEX IF NOT EXISTS idx_sessions_start ON reading_sessions(start_time); + + CREATE TABLE IF NOT EXISTS annotations ( + id TEXT PRIMARY KEY, + fingerprint TEXT NOT NULL, + book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, + device_id TEXT NOT NULL, + annotation_type TEXT NOT NULL CHECK(annotation_type IN ('highlight','note','bookmark')), + text TEXT, + note TEXT, + chapter TEXT, + page TEXT, + pageno INTEGER, + total_pages INTEGER, + annotation_datetime TEXT, + color TEXT, + created_at TEXT NOT NULL + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_annotations_source ON annotations(book_id, device_id, fingerprint); + CREATE INDEX IF NOT EXISTS idx_annotations_book_date ON annotations(book_id, annotation_datetime DESC); + + CREATE TABLE IF NOT EXISTS imports ( + id TEXT PRIMARY KEY, + source_hash TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, + filename TEXT, + file_size INTEGER NOT NULL DEFAULT 0, + books_seen INTEGER NOT NULL DEFAULT 0, + new_books INTEGER NOT NULL DEFAULT 0, + sessions_seen INTEGER NOT NULL DEFAULT 0, + new_sessions INTEGER NOT NULL DEFAULT 0, + warnings_json TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS devices ( + id TEXT PRIMARY KEY, + name TEXT, + model TEXT, + plugin_version TEXT, + token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_seen_at TEXT, + last_sync_at TEXT, + sync_cursor INTEGER NOT NULL DEFAULT 0, + last_sync_mode TEXT, + last_sync_books INTEGER NOT NULL DEFAULT 0, + last_sync_stats INTEGER NOT NULL DEFAULT 0, + last_sync_new_sessions INTEGER NOT NULL DEFAULT 0, + last_sync_annotation_sets INTEGER NOT NULL DEFAULT 0, + last_sync_annotations INTEGER NOT NULL DEFAULT 0, + revoked_at TEXT + ); + + CREATE TABLE IF NOT EXISTS sync_runs ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + mode TEXT NOT NULL, + cursor_before INTEGER NOT NULL DEFAULT 0, + cursor_after INTEGER NOT NULL DEFAULT 0, + books_seen INTEGER NOT NULL DEFAULT 0, + sessions_seen INTEGER NOT NULL DEFAULT 0, + new_sessions INTEGER NOT NULL DEFAULT 0, + annotation_sets INTEGER NOT NULL DEFAULT 0, + annotations_stored INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_sync_runs_device_time ON sync_runs(device_id,created_at DESC); + + CREATE TABLE IF NOT EXISTS cover_jobs ( + book_id TEXT PRIMARY KEY REFERENCES books(id) ON DELETE CASCADE, + state TEXT NOT NULL CHECK(state IN ('queued','running','done','error')), + reason TEXT NOT NULL, + force_online INTEGER NOT NULL DEFAULT 0, + attempts INTEGER NOT NULL DEFAULT 0, + requested_at TEXT NOT NULL, + started_at TEXT, + finished_at TEXT, + last_error TEXT, + result_status TEXT, + result_source TEXT + ); + CREATE INDEX IF NOT EXISTS idx_cover_jobs_state_time ON cover_jobs(state,requested_at); + + CREATE TABLE IF NOT EXISTS book_covers ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, + path TEXT NOT NULL, + source TEXT, + strategy TEXT, + score REAL, + matched_title TEXT, + cover_status TEXT NOT NULL, + selected INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_book_covers_book_time ON book_covers(book_id,created_at DESC); + CREATE UNIQUE INDEX IF NOT EXISTS idx_book_covers_selected ON book_covers(book_id) WHERE selected=1; + + CREATE TABLE IF NOT EXISTS cover_candidates ( + id TEXT PRIMARY KEY, + book_id TEXT NOT NULL REFERENCES books(id) ON DELETE CASCADE, + path TEXT NOT NULL, + source TEXT NOT NULL, + strategy TEXT, + score REAL NOT NULL DEFAULT 0, + title TEXT, + authors TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_cover_candidates_book_time ON cover_candidates(book_id,created_at DESC); + + CREATE TABLE IF NOT EXISTS pairing_codes ( + code_hash TEXT PRIMARY KEY, + request_id TEXT, + label TEXT, + expires_at INTEGER NOT NULL, + used_at INTEGER, + paired_device_id TEXT + ); + `); + + // Additive in-place schema upgrades for existing databases. + ensureColumn(db, 'pairing_codes', 'request_id', 'TEXT'); + ensureColumn(db, 'pairing_codes', 'paired_device_id', 'TEXT'); + ensureColumn(db, 'books', 'identifiers', 'TEXT'); + ensureColumn(db, 'books', 'isbn', 'TEXT'); + ensureColumn(db, 'books', 'cover_retry_mode', 'TEXT'); + ensureColumn(db, 'books', 'koreader_status', `TEXT CHECK(koreader_status IN ('reading','abandoned','complete'))`); + ensureColumn(db, 'books', 'koreader_status_modified', 'TEXT'); + ensureColumn(db, 'books', 'read_override', 'INTEGER CHECK(read_override IN (0,1))'); + ensureColumn(db, 'books', 'read_override_at', 'INTEGER'); + ensureColumn(db, 'devices', 'last_sync_at', 'TEXT'); + ensureColumn(db, 'devices', 'sync_cursor', 'INTEGER NOT NULL DEFAULT 0'); + ensureColumn(db, 'devices', 'last_sync_mode', 'TEXT'); + ensureColumn(db, 'devices', 'last_sync_books', 'INTEGER NOT NULL DEFAULT 0'); + ensureColumn(db, 'devices', 'last_sync_stats', 'INTEGER NOT NULL DEFAULT 0'); + ensureColumn(db, 'devices', 'last_sync_new_sessions', 'INTEGER NOT NULL DEFAULT 0'); + ensureColumn(db, 'devices', 'last_sync_annotation_sets', 'INTEGER NOT NULL DEFAULT 0'); + ensureColumn(db, 'devices', 'last_sync_annotations', 'INTEGER NOT NULL DEFAULT 0'); + db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_pairing_request_id ON pairing_codes(request_id) WHERE request_id IS NOT NULL;`); + db.exec(`CREATE INDEX IF NOT EXISTS idx_books_isbn ON books(isbn) WHERE isbn IS NOT NULL;`); + + // The same KOReader page-stat row can arrive via manual upload and via a paired + // device. Fingerprints differ by ingestion path, which would double-count identical + // reading sessions. Normalize once by the event's stable fields, then let SQLite + // enforce uniqueness forever. + const hasSessionEventIndex = db.prepare(`SELECT 1 ok FROM sqlite_master WHERE type='index' AND name='idx_sessions_event'`).get(); + if (!hasSessionEventIndex) { + db.exec('BEGIN IMMEDIATE'); + try { + db.exec(`DELETE FROM reading_sessions + WHERE rowid NOT IN ( + SELECT MIN(rowid) FROM reading_sessions + GROUP BY book_id,page,start_time,duration,total_pages + )`); + db.exec(`CREATE UNIQUE INDEX idx_sessions_event ON reading_sessions(book_id,page,start_time,duration,total_pages);`); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + } + + // Backfill a single selected history entry when no cover history is recorded. + const missingHistory=db.prepare(`SELECT id,cover_path,cover_source,cover_status,cover_checked_at,updated_at FROM books WHERE cover_path IS NOT NULL AND NOT EXISTS (SELECT 1 FROM book_covers c WHERE c.book_id=books.id)`).all(); + const addHistory=db.prepare(`INSERT INTO book_covers(id,book_id,path,source,strategy,score,matched_title,cover_status,selected,created_at) VALUES(?,?,?,?,NULL,NULL,NULL,?,1,?)`); + for(const row of missingHistory) addHistory.run(randomId(16),row.id,row.cover_path,row.cover_source,row.cover_status,row.cover_checked_at||row.updated_at||nowIso()); + return db; +} + +function completionView(book) { + const total=Number(book.last_total_pages||book.pages||0),page=Number(book.last_page||0); + const inferred=total>0 && Math.round(page/total*100)>=100; + const isRead=book.read_override == null ? book.koreader_status==='complete'||inferred : Boolean(book.read_override); + const readSource=book.read_override != null?'manual':book.koreader_status==='complete'?'koreader':inferred?'progress':null; + return {...book,is_read:isRead?1:0,read_source:readSource}; +} + +export function listBooks(db) { + return db.prepare(` + SELECT b.*, + COALESCE((SELECT MAX(start_time) FROM reading_sessions s WHERE s.book_id=b.id), b.last_open) AS recent_read, + COALESCE((SELECT SUM(duration) FROM reading_sessions s WHERE s.book_id=b.id), 0) AS session_read_time, + (SELECT page FROM reading_sessions s WHERE s.book_id=b.id ORDER BY start_time DESC LIMIT 1) AS last_page, + (SELECT total_pages FROM reading_sessions s WHERE s.book_id=b.id ORDER BY start_time DESC LIMIT 1) AS last_total_pages, + (SELECT COUNT(*) FROM (SELECT fingerprint FROM annotations a WHERE a.book_id=b.id AND a.annotation_type IN ('highlight','note') GROUP BY fingerprint)) AS synced_highlights + FROM books b + ORDER BY recent_read DESC, title COLLATE NOCASE ASC + `).all().map(completionView); +} + +export function getBook(db, id) { + const book = db.prepare(` + SELECT b.*, + (SELECT page FROM reading_sessions s WHERE s.book_id=b.id ORDER BY start_time DESC LIMIT 1) AS last_page, + (SELECT total_pages FROM reading_sessions s WHERE s.book_id=b.id ORDER BY start_time DESC LIMIT 1) AS last_total_pages, + (SELECT MAX(start_time) FROM reading_sessions s WHERE s.book_id=b.id) AS recent_read + FROM books b WHERE b.id=? + `).get(id); + if (!book) return null; + const annotations = db.prepare(` + SELECT fingerprint, annotation_type, text, note, chapter, page, pageno, total_pages, annotation_datetime, color, + MAX(created_at) AS synced_at + FROM annotations + WHERE book_id=? AND annotation_type IN ('highlight','note') + GROUP BY fingerprint + ORDER BY COALESCE(annotation_datetime,'') DESC, synced_at DESC + LIMIT 500 + `).all(id); + return { ...completionView(book), annotations }; +} + +export function setBookReadOverride(db, id, read, nowEpoch=Math.floor(Date.now()/1000)) { + const value=read == null?null:read?1:0; + const completedAt=value===1?Math.max(0,Math.floor(Number(nowEpoch)||0)):null; + const result=db.prepare(`UPDATE books SET read_override=?,read_override_at=?,updated_at=? WHERE id=?`).run(value,completedAt,nowIso(),id); + return result.changes?getBook(db,id):null; +} + +function dateKey(date) { + return `${date.getUTCFullYear()}-${String(date.getUTCMonth()+1).padStart(2,'0')}-${String(date.getUTCDate()).padStart(2,'0')}`; +} + +function parseDateKey(value) { + const m=/^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value||'')); + if(!m) return null; + const date=new Date(Date.UTC(Number(m[1]),Number(m[2])-1,Number(m[3]),12,0,0,0)); + if(date.getUTCFullYear()!==Number(m[1]) || date.getUTCMonth()!==Number(m[2])-1 || date.getUTCDate()!==Number(m[3])) return null; + return date; +} + +export function dashboardRange({from,to}={}, now=new Date(), timeZone=resolveTimeZone()) { + const today=parseDateKey(zonedDateFormatter(resolveTimeZone(timeZone))(now.getTime()/1000)); + let end=parseDateKey(to) || today; + let start=parseDateKey(from); + if(!start){start=new Date(end);start.setUTCDate(start.getUTCDate()-364)} + if(start>end) [start,end]=[end,start]; + return {from:dateKey(start),to:dateKey(end)}; +} + +function queryEpochBounds(start, end) { + // IANA UTC offsets are within one day; pad the indexed scan, then filter by local day. + const startEpoch=Date.UTC(start.getUTCFullYear(),start.getUTCMonth(),start.getUTCDate()-1)/1000; + const endExclusive=Date.UTC(end.getUTCFullYear(),end.getUTCMonth(),end.getUTCDate()+2)/1000; + return {startEpoch,endExclusive}; +} + +export function getDashboard(db, range={}) { + const selected=dashboardRange(range,new Date(),dbTimeZones.get(db)); + const start=parseDateKey(selected.from); + const end=parseDateKey(selected.to); + const {startEpoch,endExclusive}=queryEpochBounds(start,end); + const totals = db.prepare(`SELECT COUNT(*) books, COALESCE(SUM(total_read_time),0) total_read_time, COALESCE(SUM(total_read_pages),0) total_read_pages, COALESCE(SUM(highlights),0) highlights FROM books`).get(); + const sessions = db.prepare(`SELECT COUNT(*) sessions, COALESCE(SUM(duration),0) session_seconds FROM reading_sessions`).get(); + const days = db.prepare(` + SELECT kovi_local_day(start_time) day, SUM(duration) seconds + FROM reading_sessions + WHERE start_time >= ? AND start_time < ? + GROUP BY day HAVING day >= ? AND day <= ? ORDER BY day + `).all(startEpoch,endExclusive,selected.from,selected.to); + const rangeSeconds=days.reduce((sum,row)=>sum+Number(row.seconds||0),0); + const allTimeReadingSeconds=Number(totals.total_read_time||0)>0 ? Number(totals.total_read_time) : Number(sessions.session_seconds||0); + const completionRows=db.prepare(`SELECT read_override,koreader_status,pages, + (SELECT page FROM reading_sessions s WHERE s.book_id=books.id ORDER BY start_time DESC LIMIT 1) last_page, + (SELECT total_pages FROM reading_sessions s WHERE s.book_id=books.id ORDER BY start_time DESC LIMIT 1) last_total_pages + FROM books`).all(); + const readBooks=completionRows.reduce((count,book)=>count+completionView(book).is_read,0); + const rangedReadBooks=db.prepare(` + WITH book_dates AS ( + SELECT b.id, + MIN(kovi_local_day(s.start_time)) AS started_on, + CASE + WHEN b.read_override=1 AND b.read_override_at IS NOT NULL THEN kovi_local_day(b.read_override_at) + WHEN b.read_override=1 THEN MAX(kovi_local_day(s.start_time)) + WHEN b.read_override IS NOT NULL THEN NULL + WHEN b.koreader_status='complete' AND b.koreader_status_modified IS NOT NULL THEN b.koreader_status_modified + WHEN COALESCE(( + SELECT ROUND(100.0*c.page/COALESCE(NULLIF(c.total_pages,0),NULLIF(b.pages,0))) + FROM reading_sessions c WHERE c.book_id=b.id ORDER BY c.start_time DESC LIMIT 1 + ),0)>=100 THEN ( + SELECT MIN(kovi_local_day(c.start_time)) FROM reading_sessions c + WHERE c.book_id=b.id + AND COALESCE(NULLIF(c.total_pages,0),NULLIF(b.pages,0)) IS NOT NULL + AND ROUND(100.0*c.page/COALESCE(NULLIF(c.total_pages,0),NULLIF(b.pages,0)))>=100 + ) + END AS completed_on + FROM books b LEFT JOIN reading_sessions s ON s.book_id=b.id + GROUP BY b.id + ) + SELECT COUNT(*) count FROM book_dates + WHERE started_on BETWEEN ? AND ? AND completed_on BETWEEN ? AND ? AND completed_on>=started_on + `).get(selected.from,selected.to,selected.from,selected.to).count; + return { + ...totals, + read_books:readBooks, + ...sessions, + all_time_reading_seconds:allTimeReadingSeconds, + reading_time_source:Number(totals.total_read_time||0)>0?'book_totals':'sessions', + range:{...selected,seconds:rangeSeconds,reading_days:days.filter(row=>Number(row.seconds||0)>0).length,read_books:Number(rangedReadBooks||0)}, + days, + }; +} + +export function getCalendar(db, range={}) { + const selected=dashboardRange(range,new Date(),dbTimeZones.get(db)); + const start=parseDateKey(selected.from),end=parseDateKey(selected.to); + const {startEpoch,endExclusive}=queryEpochBounds(start,end); + const rows=db.prepare(` + SELECT kovi_local_day(s.start_time) day, + b.id book_id,b.title,b.authors,b.cover_path,b.updated_at, + COALESCE(SUM(s.duration),0) seconds,COUNT(*) sessions + FROM reading_sessions s + JOIN books b ON b.id=s.book_id + WHERE s.start_time >= ? AND s.start_time < ? + GROUP BY day,b.id HAVING day >= ? AND day <= ? + ORDER BY day ASC,seconds DESC,b.title COLLATE NOCASE ASC + `).all(startEpoch,endExclusive,selected.from,selected.to); + const dayMap=new Map(); + for(const row of rows){ + let day=dayMap.get(row.day); + if(!day){day={day:row.day,seconds:0,sessions:0,books:[]};dayMap.set(row.day,day)} + const seconds=Number(row.seconds||0),sessions=Number(row.sessions||0);day.seconds+=seconds;day.sessions+=sessions; + day.books.push({id:row.book_id,title:row.title,authors:row.authors,cover_path:row.cover_path,updated_at:row.updated_at,seconds,sessions}); + } + return {range:selected,days:[...dayMap.values()]}; +} + +export function createPairingCode(db, label = 'KOReader') { + const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + let code = ''; + for (let i = 0; i < 8; i++) code += alphabet[randomInt(alphabet.length)]; + const hash = tokenHash(code); + const requestId = randomId(16); + const expiresAt = Date.now() + 10 * 60_000; + db.prepare(`INSERT INTO pairing_codes(code_hash,request_id,label,expires_at,used_at,paired_device_id) VALUES(?,?,?,?,NULL,NULL)`) + .run(hash, requestId, String(label).slice(0,80), expiresAt); + return { code, requestId, expiresAt }; +} + +export function getPairingStatus(db, requestId) { + const row = db.prepare(`SELECT request_id,label,expires_at,used_at,paired_device_id FROM pairing_codes WHERE request_id=?`).get(String(requestId || '').slice(0,80)); + if (!row) return null; + if (!row.used_at && row.expires_at < Date.now()) return { status:'expired', expiresAt:row.expires_at }; + if (!row.used_at) return { status:'pending', expiresAt:row.expires_at }; + const device = row.paired_device_id ? db.prepare(`SELECT id,name,model,plugin_version,last_seen_at FROM devices WHERE id=?`).get(row.paired_device_id) : null; + return { status:'paired', expiresAt:row.expires_at, device:device || null }; +} + +export function consumePairingCode(db, code, { deviceId, model, version }) { + const hash = tokenHash(String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, '')); + const row = db.prepare(`SELECT * FROM pairing_codes WHERE code_hash=?`).get(hash); + if (!row || row.used_at || row.expires_at < Date.now()) return null; + const token = randomToken(); + const tHash = tokenHash(token); + const id = String(deviceId || randomId(8)).slice(0,128); + const ts = nowIso(); + db.exec('BEGIN IMMEDIATE'); + try { + db.prepare(`UPDATE pairing_codes SET used_at=?,paired_device_id=? WHERE code_hash=?`).run(Date.now(), id, hash); + db.prepare(`INSERT INTO devices(id,name,model,plugin_version,token_hash,created_at,last_seen_at,revoked_at) + VALUES(?,?,?,?,?,?,?,NULL) + ON CONFLICT(id) DO UPDATE SET model=excluded.model, plugin_version=excluded.plugin_version, token_hash=excluded.token_hash, last_seen_at=excluded.last_seen_at, revoked_at=NULL` + ).run(id, row.label, model || null, version || null, tHash, ts, ts); + db.exec('COMMIT'); + } catch (e) { db.exec('ROLLBACK'); throw e; } + return { token, deviceId: id, requestId: row.request_id }; +} + +export function authenticateDevice(db, authorization) { + const m = /^Bearer\s+(.+)$/i.exec(authorization || ''); + if (!m) return null; + const hash = tokenHash(m[1]); + const row = db.prepare(`SELECT * FROM devices WHERE token_hash=? AND revoked_at IS NULL`).get(hash); + if (!row || !safeEqualHex(hash, row.token_hash)) return null; + db.prepare(`UPDATE devices SET last_seen_at=? WHERE id=?`).run(nowIso(), row.id); + return row; +} + +export function updateDevicePluginVersion(db, id, version) { + const v=String(version||'').trim().slice(0,40); + if(!id || !v) return false; + return db.prepare(`UPDATE devices SET plugin_version=?, last_seen_at=? WHERE id=?`).run(v,nowIso(),id).changes>0; +} + +export function listDevices(db) { + return db.prepare(`SELECT id,name,model,plugin_version,created_at,last_seen_at,last_sync_at,sync_cursor,last_sync_mode,last_sync_books,last_sync_stats,last_sync_new_sessions,last_sync_annotation_sets,last_sync_annotations,revoked_at FROM devices ORDER BY COALESCE(last_seen_at,created_at) DESC`).all(); +} + +export function recordDeviceSync(db, deviceId, result, {mode='full',cursorBefore=0,cursorAfter=0}={}) { + const ts=nowIso(); + const values=[ts,Math.max(0,Number(cursorAfter)||0),String(mode||'full').slice(0,24),Number(result.booksSeen||0),Number(result.sessionsSeen||0),Number(result.newSessions||0),Number(result.annotationBooks||0),Number(result.annotationsStored||0),deviceId]; + db.prepare(`UPDATE devices SET last_sync_at=?,sync_cursor=?,last_sync_mode=?,last_sync_books=?,last_sync_stats=?,last_sync_new_sessions=?,last_sync_annotation_sets=?,last_sync_annotations=?,last_seen_at=? WHERE id=?`) + .run(...values.slice(0,8),ts,deviceId); + db.prepare(`INSERT INTO sync_runs(id,device_id,mode,cursor_before,cursor_after,books_seen,sessions_seen,new_sessions,annotation_sets,annotations_stored,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`) + .run(randomId(16),deviceId,String(mode||'full').slice(0,24),Math.max(0,Number(cursorBefore)||0),Math.max(0,Number(cursorAfter)||0),Number(result.booksSeen||0),Number(result.sessionsSeen||0),Number(result.newSessions||0),Number(result.annotationBooks||0),Number(result.annotationsStored||0),ts); + return {syncCursor:Math.max(0,Number(cursorAfter)||0),syncAt:ts}; +} + +export function getStatus(db) { + const library=db.prepare(`SELECT COUNT(*) books,COALESCE(SUM(total_read_time),0) reading_seconds,COALESCE(SUM(highlights),0) highlights FROM books`).get(); + const sessions=db.prepare(`SELECT COUNT(*) sessions FROM reading_sessions`).get(); + const annotations=db.prepare(`SELECT COUNT(*) annotations FROM annotations WHERE annotation_type IN ('highlight','note')`).get(); + const covers=db.prepare(`SELECT cover_status status,COUNT(*) count FROM books GROUP BY cover_status`).all(); + const sources=db.prepare(`SELECT COALESCE(cover_source,'none') source,COUNT(*) count FROM books GROUP BY COALESCE(cover_source,'none') ORDER BY count DESC`).all(); + const coverJobs=db.prepare(`SELECT state,COUNT(*) count FROM cover_jobs GROUP BY state`).all(); + const recentImports=db.prepare(`SELECT id,kind,filename,file_size,books_seen,new_books,sessions_seen,new_sessions,warnings_json,created_at FROM imports ORDER BY created_at DESC LIMIT 8`).all().map(r=>({...r,warnings:JSON.parse(r.warnings_json||'[]'),warnings_json:undefined})); + const recentSyncs=db.prepare(`SELECT s.*,d.name device_name,d.model device_model FROM sync_runs s LEFT JOIN devices d ON d.id=s.device_id ORDER BY s.created_at DESC LIMIT 12`).all(); + return {library:{...library,...sessions,...annotations},covers:{statuses:covers,sources,jobs:coverJobs},devices:listDevices(db),recentImports,recentSyncs}; +} + +export function revokeDevice(db, id) { + return db.prepare(`UPDATE devices SET revoked_at=? WHERE id=?`).run(nowIso(), id).changes > 0; +} diff --git a/lib/exports.mjs b/lib/exports.mjs new file mode 100644 index 0000000..5963b1e --- /dev/null +++ b/lib/exports.mjs @@ -0,0 +1,73 @@ +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { createGzip } from 'node:zlib'; + +function sqlString(value){return `'${String(value).replaceAll("'","''")}'`} +function csv(value){const s=String(value??'');return /[",\r\n]/.test(s)?`"${s.replaceAll('"','""')}"`:s} +function md(value){return String(value??'').replace(/\r?\n/g,' ').trim()} +function octal(value,width){return Math.max(0,Number(value)||0).toString(8).padStart(width-1,'0').slice(-(width-1))+'\0'} +function put(buf,offset,length,value){const src=Buffer.from(String(value));src.copy(buf,offset,0,Math.min(length,src.length))} +function tarHeader(name,size,mtime=Math.floor(Date.now()/1000),mode=0o644){ + const h=Buffer.alloc(512,0);put(h,0,100,name);put(h,100,8,octal(mode,8));put(h,108,8,octal(0,8));put(h,116,8,octal(0,8));put(h,124,12,octal(size,12));put(h,136,12,octal(mtime,12));h.fill(0x20,148,156);h[156]='0'.charCodeAt(0);put(h,257,6,'ustar\0');put(h,263,2,'00');put(h,265,32,'kovi');put(h,297,32,'kovi');let sum=0;for(const b of h)sum+=b;put(h,148,8,sum.toString(8).padStart(6,'0')+'\0 ');return h; +} +async function writeChunk(stream,chunk){if(!stream.write(chunk)) await new Promise(resolve=>stream.once('drain',resolve))} +async function addBuffer(stream,name,buffer){await writeChunk(stream,tarHeader(name,buffer.length));await writeChunk(stream,buffer);const pad=(512-(buffer.length%512))%512;if(pad)await writeChunk(stream,Buffer.alloc(pad))} +async function addFile(stream,name,filePath){const st=await fsp.stat(filePath);await writeChunk(stream,tarHeader(name,st.size,Math.floor(st.mtimeMs/1000),st.mode&0o777));for await(const chunk of fs.createReadStream(filePath)) await writeChunk(stream,chunk);const pad=(512-(st.size%512))%512;if(pad)await writeChunk(stream,Buffer.alloc(pad))} + +export async function createBackupArchive(db,dataDir,outputPath,{version='unknown'}={}){ + const workDir=path.join(dataDir,'uploads');await fsp.mkdir(workDir,{recursive:true}); + const snapshot=path.join(workDir,`.backup-${Date.now()}-${Math.random().toString(16).slice(2)}.sqlite`); + const tarPath=`${outputPath}.tar`; + try{ + db.exec(`VACUUM INTO ${sqlString(snapshot)}`); + const manifest={name:'kovi backup',version,generated_at:new Date().toISOString(),database:'kovi.sqlite',covers:[]}; + const referenced=new Set(db.prepare(`SELECT path FROM book_covers UNION SELECT cover_path path FROM books WHERE cover_path IS NOT NULL`).all().map(r=>r.path).filter(Boolean)); + const coverFiles=[]; + for(const webPath of referenced){ + const relative=String(webPath).replace(/^\/+/, ''); + if(!relative.startsWith('covers/'))continue; + const local=path.join(dataDir,relative);try{const st=await fsp.stat(local);if(st.isFile()){coverFiles.push({webPath,local,relative});manifest.covers.push(webPath)}}catch{} + } + const tar=fs.createWriteStream(tarPath,{flags:'wx',mode:0o600}); + await addBuffer(tar,'manifest.json',Buffer.from(JSON.stringify(manifest,null,2)+'\n')); + await addFile(tar,'kovi.sqlite',snapshot); + for(const file of coverFiles) await addFile(tar,file.relative,file.local); + await writeChunk(tar,Buffer.alloc(1024));tar.end();await new Promise((resolve,reject)=>{tar.on('finish',resolve);tar.on('error',reject)}); + await pipeline(fs.createReadStream(tarPath),createGzip({level:6}),fs.createWriteStream(outputPath,{flags:'wx',mode:0o600})); + return {covers:coverFiles.length}; + } finally {await fsp.rm(snapshot,{force:true}).catch(()=>{});await fsp.rm(tarPath,{force:true}).catch(()=>{});} +} + +function humanDuration(seconds){ + let remaining=Math.max(0,Math.round(Number(seconds)||0)); + const hours=Math.floor(remaining/3600);remaining%=3600; + const minutes=Math.floor(remaining/60);const secs=remaining%60; + const parts=[];if(hours)parts.push(`${hours}h`);if(minutes)parts.push(`${minutes}m`);if(secs||!parts.length)parts.push(`${secs}s`); + return parts.join(' '); +} +function humanUnixTime(value){ + const seconds=Number(value);if(!Number.isFinite(seconds)||seconds<=0)return ''; + const d=new Date(seconds*1000);if(Number.isNaN(d.getTime()))return ''; + const pad=n=>String(n).padStart(2,'0'); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth()+1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`; +} + +export function sendBooksCsv(res,db,headers={}){ + res.writeHead(200,{...headers,'Content-Type':'text/csv; charset=utf-8','Content-Disposition':'attachment; filename="kovi-books.csv"','Cache-Control':'no-store'}); + res.write('title,authors,series,language,isbn,status,document_progress,reading_time,read_pages,highlights,last_opened,cover_source\r\n'); + for(const b of db.prepare(`SELECT title,authors,series,language,isbn,total_read_time,total_read_pages,highlights,last_open,cover_source,pages,koreader_status,read_override,(SELECT page FROM reading_sessions s WHERE s.book_id=books.id ORDER BY start_time DESC LIMIT 1) last_page,(SELECT total_pages FROM reading_sessions s WHERE s.book_id=books.id ORDER BY start_time DESC LIMIT 1) last_total_pages FROM books ORDER BY title COLLATE NOCASE`).iterate()){ + const total=Number(b.last_total_pages||b.pages||0),progress=total>0?Math.min(100,Math.round(Number(b.last_page||0)/total*100)):0; + const read=b.read_override==null?b.koreader_status==='complete'||progress===100:Boolean(b.read_override); + const status=read?'read':b.read_override===0?'unread':progress?'reading':'unread'; + res.write([b.title,b.authors,b.series,b.language,b.isbn,status,`${progress}%`,humanDuration(b.total_read_time),b.total_read_pages,b.highlights,humanUnixTime(b.last_open),b.cover_source].map(csv).join(',')+'\r\n'); + } + res.end(); +} + +export function sendHighlightsMarkdown(res,db,headers={}){ + res.writeHead(200,{...headers,'Content-Type':'text/markdown; charset=utf-8','Content-Disposition':'attachment; filename="kovi-highlights.md"','Cache-Control':'no-store'}); + const books=db.prepare(`SELECT id,title,authors FROM books WHERE EXISTS (SELECT 1 FROM annotations a WHERE a.book_id=books.id AND a.annotation_type IN ('highlight','note') AND (a.text IS NOT NULL OR a.note IS NOT NULL)) ORDER BY title COLLATE NOCASE`).all(); + for(const b of books){res.write(`# ${md(b.title)}\n\n${b.authors?`_${md(b.authors)}_\n\n`:''}`);const anns=db.prepare(`SELECT annotation_type,text,note,chapter,page,pageno,annotation_datetime FROM annotations WHERE book_id=? AND annotation_type IN ('highlight','note') ORDER BY COALESCE(annotation_datetime,created_at)`).all(b.id);for(const a of anns){if(a.text)res.write(`> ${String(a.text).replace(/\r?\n/g,'\n> ')}\n\n`);if(a.note)res.write(`**Note:** ${String(a.note).trim()}\n\n`);const loc=[a.chapter,a.pageno?`page ${a.pageno}`:a.page?`page ${a.page}`:null,a.annotation_datetime].filter(Boolean).map(md).join(' · ');if(loc)res.write(`_${loc}_\n\n`);res.write('---\n\n')}}res.end(); +} diff --git a/lib/importer.mjs b/lib/importer.mjs new file mode 100644 index 0000000..bd4d4b5 --- /dev/null +++ b/lib/importer.mjs @@ -0,0 +1,278 @@ +import fs from 'node:fs'; +import { createHash } from 'node:crypto'; +import { DatabaseSync } from 'node:sqlite'; +import { cleanText, asInt, nowIso, randomId, sha256, extractIsbn } from './security.mjs'; + +const REQUIRED_BOOK_COLUMNS = ['id','title','authors','notes','last_open','highlights','pages','series','language','md5','total_read_time','total_read_pages']; +const REQUIRED_STATS_COLUMNS = ['id_book','page','start_time','duration','total_pages']; + +function columns(db, table) { + return db.prepare(`PRAGMA table_info(${table})`).all().map(r => r.name); +} +function hasAll(actual, needed) { const set = new Set(actual); return needed.every(x => set.has(x)); } + +export function isKoReaderManual(bookOrTitle) { + const title = cleanText(typeof bookOrTitle === 'object' ? bookOrTitle?.title : bookOrTitle, 500) || ''; + const folded = title.toLocaleLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g,'').replace(/[^a-z0-9]+/g,' ').trim(); + if (!folded.includes('koreader')) return false; + if (folded === 'koreader') return true; + return /\b(user guide|manual|manuale|guida(?: utente)?|guide(?: utilisateur)?|manuel|handbuch|guia(?: del usuario)?|quickstart guide)\b/.test(folded); +} + +export function removeExcludedBooks(appDb) { + const books = appDb.prepare(`SELECT id,title FROM books`).all(); + let removed = 0; + const del = appDb.prepare(`DELETE FROM books WHERE id=?`); + for (const book of books) if (isKoReaderManual(book)) removed += del.run(book.id).changes; + return removed; +} + +export function validateKoReaderDb(filePath) { + const header = Buffer.alloc(16); + const fd = fs.openSync(filePath, 'r'); + fs.readSync(fd, header, 0, 16, 0); fs.closeSync(fd); + if (header.toString('ascii',0,16) !== 'SQLite format 3\u0000') throw new Error('This is not a valid SQLite 3 database.'); + const src = new DatabaseSync(filePath, { readOnly: true }); + try { + const integrity = src.prepare('PRAGMA quick_check').get(); + if (!integrity || Object.values(integrity)[0] !== 'ok') throw new Error('SQLite integrity check failed.'); + const tables = new Set(src.prepare(`SELECT name FROM sqlite_master WHERE type='table'`).all().map(r => r.name)); + if (!tables.has('book') || !tables.has('page_stat_data')) throw new Error('Not a KOReader statistics database: expected book and page_stat_data tables.'); + const bookCols = columns(src, 'book'); + const statCols = columns(src, 'page_stat_data'); + if (!hasAll(bookCols, REQUIRED_BOOK_COLUMNS)) throw new Error(`Unsupported KOReader book schema. Missing: ${REQUIRED_BOOK_COLUMNS.filter(x=>!bookCols.includes(x)).join(', ')}`); + if (!hasAll(statCols, REQUIRED_STATS_COLUMNS)) throw new Error(`Unsupported KOReader statistics schema. Missing: ${REQUIRED_STATS_COLUMNS.filter(x=>!statCols.includes(x)).join(', ')}`); + return { bookCols, statCols }; + } finally { src.close(); } +} + +export function importKoReaderDb(appDb, filePath, meta = {}) { + validateKoReaderDb(filePath); + const sourceHash = hashFile(filePath); + const prior = appDb.prepare(`SELECT * FROM imports WHERE source_hash=?`).get(sourceHash); + if (prior) return { duplicate: true, importId: prior.id, booksSeen: prior.books_seen, newBooks: 0, sessionsSeen: prior.sessions_seen, newSessions: 0, excludedBooks:0, warnings: JSON.parse(prior.warnings_json) }; + + const src = new DatabaseSync(filePath, { readOnly: true }); + const warnings = []; + let booksSeen = 0, newBooks = 0, sessionsSeen = 0, newSessions = 0, excludedBooks = 0; + const bookIdToMd5 = new Map(); + const newlyAdded = []; + const ts = nowIso(); + try { + const bookCount = Number(src.prepare(`SELECT COUNT(*) n FROM book`).get().n); + if (bookCount > 100000) throw new Error('Database contains an unreasonable number of books.'); + const statCount = Number(src.prepare(`SELECT COUNT(*) n FROM page_stat_data`).get().n); + if (statCount > 5_000_000) throw new Error('Database contains an unreasonable number of reading-stat rows.'); + const books = src.prepare(`SELECT id,title,authors,notes,last_open,highlights,pages,series,language,md5,total_read_time,total_read_pages FROM book`).all(); + const statsStmt = src.prepare(`SELECT id_book,page,start_time,duration,total_pages FROM page_stat_data`); + + appDb.exec('BEGIN IMMEDIATE'); + try { + const findBook = appDb.prepare(`SELECT id FROM books WHERE source_md5=?`); + const insertBook = appDb.prepare(`INSERT INTO books(id,source_md5,title,authors,notes,last_open,highlights,pages,series,language,total_read_time,total_read_pages,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`); + const updateBook = appDb.prepare(`UPDATE books SET title=?,authors=?,notes=?,last_open=?,highlights=?,pages=?,series=?,language=?,total_read_time=?,total_read_pages=?,updated_at=? WHERE source_md5=?`); + const sessionInsert = appDb.prepare(`INSERT OR IGNORE INTO reading_sessions(id,fingerprint,book_id,device_id,page,start_time,duration,total_pages,created_at) VALUES(?,?,?,?,?,?,?,?,?)`); + + for (const b of books) { + booksSeen++; + const md5 = cleanText(b.md5, 128); + const title = cleanText(b.title, 500) || 'Untitled'; + if (isKoReaderManual(title)) { excludedBooks++; continue; } + if (!md5) { warnings.push(`Skipped book #${b.id}: missing MD5.`); continue; } + bookIdToMd5.set(Number(b.id), md5); + const existing = findBook.get(md5); + const vals = [title,cleanText(b.authors,500),asInt(b.notes,{max:1e7}),asInt(b.last_open,{max:4e9}),asInt(b.highlights,{max:1e7}),asInt(b.pages,{max:1e7}),cleanText(b.series,500),cleanText(b.language,64),asInt(b.total_read_time,{max:1e12}),asInt(b.total_read_pages,{max:1e9})]; + if (existing) updateBook.run(...vals, ts, md5); + else { + const id = randomId(16); + insertBook.run(id,md5,...vals,ts,ts); + newBooks++; newlyAdded.push(id); + } + } + + for (const s of statsStmt.iterate()) { + sessionsSeen++; + const md5 = bookIdToMd5.get(Number(s.id_book)); + if (!md5) continue; + const book = findBook.get(md5); + if (!book) continue; + const page = asInt(s.page,{max:1e8}); + const start = asInt(s.start_time,{max:4e9}); + const duration = asInt(s.duration,{max:7*24*3600}); + const totalPages = asInt(s.total_pages,{max:1e8}); + if (!start || duration <= 0) continue; + const fp = sha256(`${md5}|${page}|${start}|${duration}|${totalPages}|manual`); + const r = sessionInsert.run(randomId(16),fp,book.id,'manual',page,start,duration,totalPages,ts); + if (r.changes) newSessions++; + } + + const importId = randomId(16); + appDb.prepare(`INSERT INTO imports(id,source_hash,kind,filename,file_size,books_seen,new_books,sessions_seen,new_sessions,warnings_json,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`) + .run(importId,sourceHash,'sqlite',cleanText(meta.filename,255),Number(meta.fileSize||0),booksSeen,newBooks,sessionsSeen,newSessions,JSON.stringify(warnings.slice(0,100)),ts); + appDb.exec('COMMIT'); + return { duplicate:false, importId, sourceHash, booksSeen,newBooks,sessionsSeen,newSessions,excludedBooks,warnings:warnings.slice(0,100),newBookIds:newlyAdded }; + } catch (e) { appDb.exec('ROLLBACK'); throw e; } + } finally { src.close(); } +} + +function annotationType(a) { + if (cleanText(a?.note, 20000)) return 'note'; + if (cleanText(a?.text, 20000) || a?.drawer || a?.highlighted) return 'highlight'; + return 'bookmark'; +} + +function cleanDateKey(value) { + const key=cleanText(value,40);const match=/^(\d{4})-(\d{2})-(\d{2})$/.exec(key||''); + if(!match) return null; + const date=new Date(Date.UTC(Number(match[1]),Number(match[2])-1,Number(match[3]))); + return date.getUTCFullYear()===Number(match[1])&&date.getUTCMonth()===Number(match[2])-1&&date.getUTCDate()===Number(match[3])?key:null; +} + +function importAnnotationSets(appDb, sets, device, findBook, ts) { + if (!Array.isArray(sets)) return { annotationBooks:0, annotationsSeen:0, annotationsStored:0, metadataEnriched:0, coverRefreshIds:[] }; + if (sets.length > 10000) throw new Error('Plugin annotation payload contains too many books.'); + let annotationsSeen = 0, annotationsStored = 0, annotationBooks = 0, metadataEnriched = 0; + const coverRefreshIds = []; + const del = appDb.prepare(`DELETE FROM annotations WHERE book_id=? AND device_id=?`); + const insert = appDb.prepare(`INSERT OR IGNORE INTO annotations(id,fingerprint,book_id,device_id,annotation_type,text,note,chapter,page,pageno,total_pages,annotation_datetime,color,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`); + const updateMetadata = appDb.prepare(`UPDATE books SET identifiers=COALESCE(?,identifiers),isbn=COALESCE(?,isbn),cover_status=?,cover_checked_at=CASE WHEN ? THEN NULL ELSE cover_checked_at END,updated_at=? WHERE id=?`); + const updateStatus = appDb.prepare(`UPDATE books SET koreader_status=?,koreader_status_modified=?,updated_at=? WHERE id=? AND (koreader_status_modified IS NULL OR (? IS NOT NULL AND koreader_status_modified<=?))`); + + for (const set of sets) { + const md5 = cleanText(set?.book_md5, 128); + if (!md5) continue; + const book = findBook.get(md5); + if (!book) continue; + + const readingStatus=cleanText(set?.reading_status,40); + if (['reading','abandoned','complete'].includes(readingStatus)) { + const statusModified=cleanDateKey(set?.reading_status_modified); + updateStatus.run(readingStatus,statusModified,ts,book.id,statusModified,statusModified); + } + + // KOReader stores document metadata, including dc:identifier/ISBN, in each + // book's doc_props sidecar. Enrich the canonical book record when the plugin + // sends it so translated titles can use deterministic ISBN cover lookup. + const identifiers = cleanText(set?.identifiers ?? set?.metadata?.identifiers, 4000); + const isbn = extractIsbn(set?.isbn ?? identifiers); + if ((identifiers && identifiers !== book.identifiers) || (isbn && isbn !== book.isbn)) { + const learnedIsbn = Boolean(isbn && !book.isbn); + const shouldRetryCover = learnedIsbn && ['none','error'].includes(book.cover_status); + updateMetadata.run( + identifiers || null, + isbn || null, + shouldRetryCover ? 'pending' : book.cover_status, + shouldRetryCover ? 1 : 0, + ts, + book.id, + ); + metadataEnriched++; + if (shouldRetryCover) coverRefreshIds.push(book.id); + } + + const annotations = Array.isArray(set.annotations) ? set.annotations : []; + if (annotations.length > 5000) throw new Error('A book contains an unreasonable number of annotations.'); + annotationBooks++; + del.run(book.id, device.id); + for (const a of annotations) { + annotationsSeen++; + const text = cleanText(a?.text ?? a?.notes, 20000); + const note = cleanText(a?.note, 20000); + const chapter = cleanText(a?.chapter, 2000); + const page = cleanText(a?.page, 1000); + const pageno = a?.pageno == null ? null : asInt(a.pageno,{max:1e8}); + const totalPages = a?.total_pages == null ? null : asInt(a.total_pages,{max:1e8}); + const datetime = cleanText(a?.datetime, 100); + const color = cleanText(a?.color, 80); + const type = annotationType(a); + if (!text && !note) continue; + const fp = sha256(`${datetime||''}|${pageno??''}|${page||''}|${chapter||''}|${text||''}|${note||''}|${type}`); + annotationsStored += insert.run(randomId(16),fp,book.id,device.id,type,text,note,chapter,page,pageno,totalPages,datetime,color,ts).changes; + } + } + return { annotationBooks, annotationsSeen, annotationsStored, metadataEnriched, coverRefreshIds:[...new Set(coverRefreshIds)] }; +} + +export function importPluginPayload(appDb, payload, device) { + const books = Array.isArray(payload?.books) ? payload.books : []; + const stats = Array.isArray(payload?.stats) ? payload.stats : []; + const annotationSets = Array.isArray(payload?.annotation_sets) ? payload.annotation_sets : []; + if (books.length > 10000 || stats.length > 500000) throw new Error('Plugin payload is too large.'); + const syncCursorBefore=Math.max(0,asInt(payload?.sync_cursor,{max:4e9})); + const syncMode=syncCursorBefore>0?'incremental':'full'; + let syncCursorAfter=syncCursorBefore; + const ts = nowIso(); + let newBooks=0,newSessions=0,excludedBooks=0; + const newBookIds=[]; + let annotationResult={annotationBooks:0,annotationsSeen:0,annotationsStored:0,metadataEnriched:0,coverRefreshIds:[]}; + appDb.exec('BEGIN IMMEDIATE'); + try { + const findBook=appDb.prepare(`SELECT id,isbn,identifiers,cover_status,cover_source FROM books WHERE source_md5=?`); + const insert=appDb.prepare(`INSERT INTO books(id,source_md5,title,authors,notes,last_open,highlights,pages,series,language,identifiers,isbn,total_read_time,total_read_pages,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`); + const update=appDb.prepare(`UPDATE books SET title=?,authors=?,notes=?,last_open=?,highlights=?,pages=?,series=?,language=?,identifiers=COALESCE(?,identifiers),isbn=COALESCE(?,isbn),total_read_time=?,total_read_pages=?,updated_at=? WHERE source_md5=?`); + for(const b of books){ + const title=cleanText(b?.title,500)||'Untitled'; + if(isKoReaderManual(title)){excludedBooks++;continue;} + const md5=cleanText(b?.md5,128); if(!md5) continue; + const identifiers=cleanText(b?.identifiers,4000); + const isbn=extractIsbn(b?.isbn ?? identifiers); + const vals=[title,cleanText(b?.authors,500),asInt(b?.notes,{max:1e7}),asInt(b?.last_open,{max:4e9}),asInt(b?.highlights,{max:1e7}),asInt(b?.pages,{max:1e7}),cleanText(b?.series,500),cleanText(b?.language,64),identifiers,isbn,asInt(b?.total_read_time,{max:1e12}),asInt(b?.total_read_pages,{max:1e9})]; + const ex=findBook.get(md5); + if(ex) { + update.run(...vals,ts,md5); + if(isbn && !ex.isbn && ['none','error'].includes(ex.cover_status)) { + appDb.prepare(`UPDATE books SET cover_status='pending',cover_checked_at=NULL WHERE id=?`).run(ex.id); + annotationResult.coverRefreshIds.push(ex.id); + } + } else { + const id=randomId(16); insert.run(id,md5,...vals,ts,ts); newBooks++; newBookIds.push(id); + } + } + const insS=appDb.prepare(`INSERT OR IGNORE INTO reading_sessions(id,fingerprint,book_id,device_id,page,start_time,duration,total_pages,created_at) VALUES(?,?,?,?,?,?,?,?,?)`); + for(const s of stats){ + const md5=cleanText(s?.book_md5,128); if(!md5) continue; + const book=findBook.get(md5); if(!book) continue; + const page=asInt(s.page,{max:1e8}), start=asInt(s.start_time,{max:4e9}), duration=asInt(s.duration,{max:7*24*3600}), totalPages=asInt(s.total_pages,{max:1e8}); + if(start>syncCursorAfter) syncCursorAfter=start; + if(!start||duration<=0) continue; + const fp=sha256(`${md5}|${page}|${start}|${duration}|${totalPages}|${device.id}`); + if(insS.run(randomId(16),fp,book.id,device.id,page,start,duration,totalPages,ts).changes) newSessions++; + } + const ann = importAnnotationSets(appDb,annotationSets,device,findBook,ts); + annotationResult = { + ...ann, + coverRefreshIds:[...new Set([...(annotationResult.coverRefreshIds||[]),...(ann.coverRefreshIds||[])])], + }; + appDb.exec('COMMIT'); + } catch(e){appDb.exec('ROLLBACK');throw e;} + + // Ask the current reader for embedded covers only for books that still need one. + // Cap the list so a single sync remains responsive on slower e-ink devices. + const coverRequests=[]; + const seen=new Set(); + const coverState=appDb.prepare(`SELECT source_md5,cover_status,cover_source FROM books WHERE source_md5=?`); + for(const b of books){ + const md5=cleanText(b?.md5,128); if(!md5 || seen.has(md5)) continue; + seen.add(md5); + const row=coverState.get(md5); + if(row && ['pending','none','error'].includes(row.cover_status) && row.cover_source!=='koreader-embedded') coverRequests.push(md5); + if(coverRequests.length>=20) break; + } + + return { + booksSeen:books.length,newBooks,sessionsSeen:stats.length,newSessions,newBookIds,excludedBooks, + ...annotationResult, + coverRefreshIds:[...new Set(annotationResult.coverRefreshIds||[])], + coverRequests, + syncMode,syncCursorBefore,syncCursorAfter, + }; +} + +function hashFile(filePath) { + const h = createHash('sha256'); + const fd = fs.openSync(filePath, 'r'); + const b = Buffer.allocUnsafe(1024*1024); + try { let n; while ((n=fs.readSync(fd,b,0,b.length,null))>0) h.update(b.subarray(0,n)); } + finally { fs.closeSync(fd); } + return h.digest('hex'); +} diff --git a/lib/logger.mjs b/lib/logger.mjs new file mode 100644 index 0000000..15d5ada --- /dev/null +++ b/lib/logger.mjs @@ -0,0 +1,31 @@ +function cleanValue(value) { + if (value === undefined || value === null || value === '') return undefined; + if (value instanceof Error) return value.message; + if (typeof value === 'string') return value.replace(/[\r\n\t]+/g, ' ').slice(0, 500); + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (Array.isArray(value)) return value.slice(0, 20).map(cleanValue); + if (typeof value === 'object') { + const out = {}; + for (const [key, item] of Object.entries(value)) { + if (/token|authorization|code$/i.test(key)) continue; + const cleaned = cleanValue(item); + if (cleaned !== undefined) out[key] = cleaned; + } + return out; + } + return String(value).slice(0, 500); +} + +export function logEvent(level, event, message, fields = {}) { + const normalized = String(level || 'info').toLowerCase(); + const meta = cleanValue(fields) || {}; + const suffix = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : ''; + const line = `[${new Date().toISOString()}] [kovi] ${normalized.toUpperCase()} ${event} — ${message}${suffix}`; + if (normalized === 'error') console.error(line); + else if (normalized === 'warn' || normalized === 'warning') console.warn(line); + else console.log(line); +} + +export const info = (event, message, fields) => logEvent('info', event, message, fields); +export const warn = (event, message, fields) => logEvent('warn', event, message, fields); +export const error = (event, message, fields) => logEvent('error', event, message, fields); diff --git a/lib/security.mjs b/lib/security.mjs new file mode 100644 index 0000000..87a0921 --- /dev/null +++ b/lib/security.mjs @@ -0,0 +1,68 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +export const nowIso = () => new Date().toISOString(); +export const randomId = (bytes = 16) => randomBytes(bytes).toString('hex'); +export const randomToken = () => `kv_${randomBytes(24).toString('base64url')}`; +export const tokenHash = (token) => createHash('sha256').update(token).digest('hex'); +export const sha256 = (input) => createHash('sha256').update(input).digest('hex'); + +export function safeEqualHex(a, b) { + if (!a || !b || a.length !== b.length) return false; + return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); +} + +export function cleanText(value, max = 500) { + if (value == null) return null; + const s = String(value).replace(/[\u0000-\u001f\u007f]/g, ' ').replace(/\s+/g, ' ').trim(); + return s.slice(0, max) || null; +} + +export function asInt(value, { min = 0, max = Number.MAX_SAFE_INTEGER, fallback = 0 } = {}) { + const n = Number(value); + if (!Number.isFinite(n)) return fallback; + const i = Math.trunc(n); + return Math.max(min, Math.min(max, i)); +} + +function validIsbn10(value) { + if (!/^\d{9}[\dX]$/.test(value)) return false; + let sum = 0; + for (let i = 0; i < 10; i++) { + const d = value[i] === 'X' ? 10 : Number(value[i]); + sum += d * (10 - i); + } + return sum % 11 === 0; +} + +function validIsbn13(value) { + if (!/^97[89]\d{10}$/.test(value)) return false; + let sum = 0; + for (let i = 0; i < 12; i++) sum += Number(value[i]) * (i % 2 ? 3 : 1); + return (10 - (sum % 10)) % 10 === Number(value[12]); +} + +/** Extract a validated ISBN from EPUB/KOReader identifier metadata. + * KOReader's doc_props.identifiers is commonly a newline-separated string such as + * `isbn:978...`, `urn:isbn:...`, `calibre:...`, `uuid:...`. + */ +export function extractIsbn(value) { + if (!value) return null; + const raw = String(value).toUpperCase(); + const candidates = raw.match(/(?:97[89][\d\s-]{9,20}\d|\d[\d\s-]{7,16}[\dX])/g) || []; + const cleaned = [...new Set(candidates.map(x => x.replace(/[^0-9X]/g, '')))]; + const isbn13 = cleaned.find(x => x.length === 13 && validIsbn13(x)); + if (isbn13) return isbn13; + return cleaned.find(x => x.length === 10 && validIsbn10(x)) || null; +} + +export function json(res, status, body, extraHeaders = {}) { + const payload = Buffer.from(JSON.stringify(body)); + res.writeHead(status, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': payload.length, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + ...extraHeaders, + }); + res.end(payload); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a38b858 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "kovi", + "version": "2026.09.25", + "private": true, + "license": "GPL-3.0-only", + "type": "module", + "description": "A safe, polished self-hosted KOReader reading dashboard.", + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "node server.mjs", + "dev": "node --watch server.mjs", + "test": "node --test tests/*.test.mjs", + "fixture": "node scripts/make-fixture.mjs" + } +} diff --git a/plugins/kovi.koplugin/_meta.lua b/plugins/kovi.koplugin/_meta.lua new file mode 100644 index 0000000..eb3f102 --- /dev/null +++ b/plugins/kovi.koplugin/_meta.lua @@ -0,0 +1,2 @@ +local _ = require("gettext") +return { name = "kovi", fullname = _("kovi"), description = _([[Secure kovi reading statistics, completion, and highlights sync plugin (v2026.09.25).]]) } diff --git a/plugins/kovi.koplugin/annotation_reader.lua b/plugins/kovi.koplugin/annotation_reader.lua new file mode 100644 index 0000000..d2d02f7 --- /dev/null +++ b/plugins/kovi.koplugin/annotation_reader.lua @@ -0,0 +1,135 @@ +local DocSettings = require("docsettings") +local logger = require("logger") +local lfs_ok, lfs = pcall(require, "lfs") +local Reader = {} + +local function live_ui() + local ok, ReaderUI = pcall(require, "apps/reader/readerui") + if ok and ReaderUI then return ReaderUI.instance end + return nil +end + +local function sidecar_for(file_path) + if not file_path then return nil end + return DocSettings:findSidecarFile(file_path) +end + +local function sidecar_signature(sidecar) + if not lfs_ok or not sidecar then return nil end + local attrs = lfs.attributes(sidecar) + if not attrs then return nil end + return tostring(attrs.modification or 0) .. ":" .. tostring(attrs.size or 0) +end + +local function clean_annotation(annotation, total_pages, legacy) + if type(annotation) ~= "table" then return nil end + local text = annotation.text + if (not text or text == "") and legacy then text = annotation.notes end + local kind = "bookmark" + if annotation.note and annotation.note ~= "" then kind = "note" + elseif text and text ~= "" then kind = "highlight" + elseif annotation.drawer or annotation.highlighted then kind = "highlight" end + return { + datetime = annotation.datetime, + datetime_updated = annotation.datetime_updated, + drawer = annotation.drawer, + color = annotation.color, + text = text, + note = annotation.note, + chapter = annotation.chapter, + pageno = annotation.pageno, + page = ((type(annotation.page) == "string" or type(annotation.page) == "number") and annotation.page) or nil, + total_pages = total_pages, + annotation_type = kind, + } +end + +local function read_set(sidecar) + if not sidecar then return nil end + local settings = DocSettings.openSettingsFile(sidecar) + local md5 = settings:readSetting("partial_md5_checksum") + if not md5 or md5 == "" then return nil end + local total_pages = settings:readSetting("doc_pages") + local props = settings:readSetting("doc_props") or {} + local summary = settings:readSetting("summary") or {} + local annotations = settings:readSetting("annotations") + local legacy = false + if type(annotations) ~= "table" then + annotations = settings:readSetting("bookmarks") + legacy = true + end + if type(annotations) ~= "table" then annotations = {} end + local cleaned = {} + for _, annotation in ipairs(annotations) do + local item = clean_annotation(annotation, total_pages, legacy) + if item then table.insert(cleaned, item) end + end + return { book_md5 = md5, identifiers = props.identifiers, reading_status = summary.status, reading_status_modified = summary.modified, annotations = cleaned } +end + +-- Scanning history is cheap; opening every sidecar is not. Persist each sidecar's +-- mtime/size signature and only parse/transmit books whose sidecar changed. +function Reader.changed(previous_versions) + local ui = live_ui() + if ui and ui.doc_settings then pcall(function() ui.doc_settings:flush() end) end + previous_versions = type(previous_versions) == "table" and previous_versions or {} + + local ok, ReadHistory = pcall(require, "readhistory") + if not ok or not ReadHistory or type(ReadHistory.hist) ~= "table" then + logger.warn("[kovi] Could not read KOReader history for annotation sync") + return {}, previous_versions + end + + local sets, versions, seen_md5, present_md5, current_paths = {}, {}, {}, {}, {} + local scanned, opened, unchanged, errors = 0, 0, 0, 0 + for _, entry in ipairs(ReadHistory.hist) do + if entry.file and not entry.dim then + scanned = scanned + 1 + local sidecar = sidecar_for(entry.file) + if sidecar then + current_paths[entry.file] = true + local signature = sidecar_signature(sidecar) + local previous = previous_versions[entry.file] + local previous_signature = type(previous) == "table" and previous.signature or previous + local previous_md5 = type(previous) == "table" and previous.book_md5 or nil + if signature and previous_signature == signature and previous_md5 then + versions[entry.file] = { signature = signature, book_md5 = previous_md5 } + present_md5[previous_md5] = true + unchanged = unchanged + 1 + else + local success, set = pcall(read_set, sidecar) + if success and set then + present_md5[set.book_md5] = true + if not seen_md5[set.book_md5] then + seen_md5[set.book_md5] = true + table.insert(sets, set) + end + if signature then versions[entry.file] = { signature = signature, book_md5 = set.book_md5 } end + opened = opened + 1 + elseif not success then + errors = errors + 1 + logger.warn("[kovi] Failed reading annotation sidecar", entry.file) + end + end + end + end + end + + -- If a previously synced sidecar disappeared, send an empty set once so the + -- server can remove annotations that no longer exist on this reader. + for file_path, previous in pairs(previous_versions) do + if not current_paths[file_path] and type(previous) == "table" and previous.book_md5 and not present_md5[previous.book_md5] and not seen_md5[previous.book_md5] then + table.insert(sets, { book_md5 = previous.book_md5, annotations = {} }) + seen_md5[previous.book_md5] = true + end + end + + logger.info("[kovi] Incremental annotation scan", scanned, "history entries,", opened, "changed sidecars,", unchanged, "unchanged,", errors, "errors") + return sets, versions +end + +function Reader.all() + return Reader.changed({}) +end + +return Reader diff --git a/plugins/kovi.koplugin/call_api.lua b/plugins/kovi.koplugin/call_api.lua new file mode 100644 index 0000000..08b0a1c --- /dev/null +++ b/plugins/kovi.koplugin/call_api.lua @@ -0,0 +1,102 @@ +local socketutil = require("socketutil") +local ltn12 = require("ltn12") +local logger = require("logger") +local socket = require("socket") +local http = require("socket.http") +local JSON = require("json") +local RawHttp = require("raw_http") +local const = require("./const") + +local function decode_json(content) + if not content or content == "" then return {} end + local ok, decoded = pcall(JSON.decode, content) + if ok and decoded then return decoded end + return nil +end + +local function format_network_error(detail, stage, host, port) + detail = tostring(detail or "network unreachable") + local where = "" + if host and port then where = " to " .. tostring(host) .. ":" .. tostring(port) end + local hint = " Check the server URL and Wi-Fi." + if stage == "tcp" then + hint = " kovi is not reachable on that TCP port from the reader. Check the host firewall and that both devices are on the same LAN." + elseif stage == "url" then + hint = " Check the server URL." + end + return false, { + error = "kovi " .. tostring(stage or "network") .. " error" .. where .. ": " .. detail .. "." .. hint, + kind = "network", + stage = stage, + detail = detail, + } +end + +local function interpret(code, resp_headers, status, content) + local parsed = decode_json(content) + local numeric_code = tonumber(code) + if numeric_code and numeric_code >= 200 and numeric_code < 300 then + if parsed then return true, parsed end + return false, { error = "kovi returned a successful response that was not valid JSON.", kind = "response" } + end + logger.err("[kovi] HTTP error", code, status, content) + if parsed and parsed.error then return false, parsed end + return false, { + error = "kovi returned HTTP " .. tostring(code) .. (status and (" (" .. tostring(status) .. ")") or ""), + kind = "http", + status = numeric_code, + } +end + +local function koreader_http(method, target, headers, body) + local sink = {} + local request = { + method = method, + url = target, + headers = headers, + sink = ltn12.sink.table(sink), + redirect = false, + } + if body ~= nil then request.source = ltn12.source.string(body) end + + socketutil:set_timeout(socketutil.LARGE_BLOCK_TIMEOUT, socketutil.LARGE_TOTAL_TIMEOUT) + local ok, code, resp_headers, status = pcall(function() + return socket.skip(1, http.request(request)) + end) + socketutil:reset_timeout() + if not ok then + return nil, nil, nil, nil, { stage = "http", detail = tostring(code) } + end + if resp_headers == nil then + return nil, nil, nil, nil, { stage = "http", detail = tostring(status or code or "network unreachable") } + end + return code, resp_headers, status, table.concat(sink), nil +end + +return function(method, target, headers, body) + headers = headers or {} + headers["Accept"] = headers["Accept"] or "application/json" + headers["Accept-Encoding"] = "identity" + headers["User-Agent"] = "kovi-KOReader/" .. tostring(const.VERSION) + + logger.dbg("[kovi] API", method, target) + + -- LAN pairing is normally plain HTTP. Use a minimal direct TCP HTTP client first; + -- this avoids platform-specific LuaSocket HTTP quirks and gives useful TCP errors. + if target:match("^http://") then + local code, resp_headers, status, content, raw_err = RawHttp.request(method, target, headers, body, 12) + if code then return interpret(code, resp_headers, status, content) end + logger.warn("[kovi] direct HTTP failed", raw_err and raw_err.stage, raw_err and raw_err.detail) + + -- Fall back to KOReader's standard HTTP stack, matching built-in plugins. + local f_code, f_headers, f_status, f_content, fallback_err = koreader_http(method, target, headers, body) + if f_code then return interpret(f_code, f_headers, f_status, f_content) end + local best = raw_err or fallback_err or { stage = "network", detail = "network unreachable" } + return format_network_error(best.detail, best.stage, best.host, best.port) + end + + -- KOReader's bundled socket.http is used by built-in plugins for HTTPS URLs. + local code, resp_headers, status, content, err = koreader_http(method, target, headers, body) + if not code then return format_network_error(err and err.detail, err and err.stage) end + return interpret(code, resp_headers, status, content) +end diff --git a/plugins/kovi.koplugin/const.lua b/plugins/kovi.koplugin/const.lua new file mode 100644 index 0000000..b3bd812 --- /dev/null +++ b/plugins/kovi.koplugin/const.lua @@ -0,0 +1 @@ +return { VERSION = "2026.09.25" } diff --git a/plugins/kovi.koplugin/cover_reader.lua b/plugins/kovi.koplugin/cover_reader.lua new file mode 100644 index 0000000..cd302cd --- /dev/null +++ b/plugins/kovi.koplugin/cover_reader.lua @@ -0,0 +1,96 @@ +local DocSettings = require("docsettings") +local FileManagerBookInfo = require("apps/filemanager/filemanagerbookinfo") +local DataStorage = require("datastorage") +local random = require("random") +local logger = require("logger") +local lfs = require("libs/libkoreader-lfs") + +local Reader = {} +local MAX_BYTES = 4 * 1024 * 1024 + +local function sidecar_md5(file_path) + if not file_path then return nil end + local sidecar = DocSettings:findSidecarFile(file_path) + if not sidecar then return nil end + local settings = DocSettings.openSettingsFile(sidecar) + return settings:readSetting("partial_md5_checksum") +end + +local function paths_for(md5s) + local wanted, remaining = {}, 0 + for _, md5 in ipairs(md5s or {}) do + if md5 and md5 ~= "" and not wanted[md5] then + wanted[md5] = false + remaining = remaining + 1 + end + end + if remaining == 0 then return wanted end + + local ok, ReadHistory = pcall(require, "readhistory") + if not ok or not ReadHistory or type(ReadHistory.hist) ~= "table" then + logger.warn("[kovi] Could not read KOReader history for embedded covers") + return wanted + end + + for _, entry in ipairs(ReadHistory.hist) do + if remaining == 0 then break end + if entry.file and not entry.dim then + local success, md5 = pcall(sidecar_md5, entry.file) + if success and md5 and wanted[md5] == false then + wanted[md5] = entry.file + remaining = remaining - 1 + end + end + end + return wanted +end + +local function read_all(file_path) + local f, err = io.open(file_path, "rb") + if not f then return nil, tostring(err or "cannot open temporary cover") end + local data = f:read("*all") + f:close() + if not data or data == "" then return nil, "temporary cover is empty" end + if #data > MAX_BYTES then return nil, "embedded cover is larger than 4 MB" end + return data +end + +local function extract_jpeg(file_path) + local cover = FileManagerBookInfo:getCoverImage(nil, file_path) + if not cover then return nil, "book has no extractable embedded cover" end + + local cache_dir = DataStorage:getDataDir() .. "/cache/" + lfs.mkdir(cache_dir) + local tmp = cache_dir .. "kovi-cover-" .. random.uuid() .. ".jpg" + local ok = cover:writeToFile(tmp, "jpg", 82, false) + cover:free() + if not ok then + os.remove(tmp) + return nil, "KOReader could not encode the cover as JPEG" + end + local data, err = read_all(tmp) + os.remove(tmp) + return data, err +end + +function Reader.extractRequested(md5s) + local paths = paths_for(md5s) + local out = {} + for _, md5 in ipairs(md5s or {}) do + local file_path = paths[md5] + if file_path then + local ok, data, err = pcall(extract_jpeg, file_path) + if ok and data then + table.insert(out, { book_md5 = md5, data = data }) + logger.info("[kovi] Extracted embedded cover", md5, #data, "bytes") + else + logger.warn("[kovi] Embedded cover extraction failed", md5, tostring(ok and err or data)) + end + else + logger.info("[kovi] No local file found for requested cover", md5) + end + end + return out +end + +return Reader diff --git a/plugins/kovi.koplugin/db_reader.lua b/plugins/kovi.koplugin/db_reader.lua new file mode 100644 index 0000000..7af9027 --- /dev/null +++ b/plugins/kovi.koplugin/db_reader.lua @@ -0,0 +1,68 @@ +local SQ3 = require("lua-ljsqlite3/init") +local DataStorage = require("datastorage") +local logger = require("logger") +local db_location = DataStorage:getSettingsDir() .. "/statistics.sqlite3" +local Reader = {} +local OVERLAP_SECONDS = 24 * 60 * 60 + +function Reader.bookData() + local conn = SQ3.open(db_location) + local sql = "SELECT id,title,authors,notes,last_open,highlights,pages,series,language,md5,total_read_time,total_read_pages FROM book" + local result, rows = conn:exec(sql) + local books = {} + for i = 1, rows do + table.insert(books, { + 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 = tonumber(result[7][i]), series = result[8][i], language = result[9][i], md5 = result[10][i], + total_read_time = tonumber(result[11][i]), total_read_pages = tonumber(result[12][i]), + }) + end + conn:close() + return books +end + +local function md5_by_id(books) + local map = {} + for _, b in ipairs(books) do map[b.id] = b.md5 end + return map +end + +local function flush_statistics() + local ok, ReaderUI = pcall(require, "apps/reader/readerui") + if ok and ReaderUI and ReaderUI.instance and ReaderUI.instance.statistics and ReaderUI.instance.statistics.is_doc then + pcall(function() ReaderUI.instance.statistics:insertDB() end) + end +end + +-- The first sync sends the complete statistics history. Successful syncs store a +-- high-water mark on the reader; later syncs only re-send a one-day overlap. +-- kovi de-duplicates identical page_stat_data events, so the overlap protects +-- against clock/order edge cases without double-counting reading time. +function Reader.progressData(sync_cursor, books) + flush_statistics() + books = books or Reader.bookData() + local map = md5_by_id(books) + local cursor = math.max(0, tonumber(sync_cursor) or 0) + local threshold = math.max(0, cursor - OVERLAP_SECONDS) + local conn = SQ3.open(db_location) + local sql = "SELECT id_book,page,start_time,duration,total_pages FROM page_stat_data" + if threshold > 0 then sql = sql .. " WHERE start_time >= " .. tostring(math.floor(threshold)) end + local result, rows = conn:exec(sql) + local stats = {} + local device_id = G_reader_settings:readSetting("device_id") + for i = 1, rows do + local md5 = map[tonumber(result[1][i])] + if md5 then + table.insert(stats, { + book_md5 = md5, page = tonumber(result[2][i]), start_time = tonumber(result[3][i]), + duration = tonumber(result[4][i]), total_pages = tonumber(result[5][i]), device_id = device_id, + }) + else + logger.warn("[kovi] statistic row references unknown book") + end + end + conn:close() + return stats +end +return Reader diff --git a/plugins/kovi.koplugin/main.lua b/plugins/kovi.koplugin/main.lua new file mode 100644 index 0000000..a869969 --- /dev/null +++ b/plugins/kovi.koplugin/main.lua @@ -0,0 +1,58 @@ +local _ = require("gettext") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local UIManager = require("ui/uimanager") +local InfoMessage = require("ui/widget/infomessage") +local NetworkMgr = require("ui/network/manager") +local Settings = require("settings") +local Upload = require("upload") +local const = require("./const") + +local kovi = WidgetContainer:extend({ name="kovi", is_doc_only=false }) +function kovi:init() self.ui.menu:registerToMainMenu(self); self.settings=Settings:new() end + +function kovi:showMessage(text) UIManager:show(InfoMessage:new({text=_(text),timeout=4})) end +function kovi:pair() + self.settings:showSetup(function(url,code) + NetworkMgr:runWhenOnline(function() + local ok,response=Upload.pair(url,code,self.settings) + if ok then self:showMessage("kovi paired successfully.") else self:showMessage("Pairing failed: "..tostring(response and response.error or "unknown error")) end + end) + end) +end +function kovi:sync(silent) + local url=self.settings:getServerURL(); local token=self.settings:getToken() + if url=="" or not token then if not silent then self:showMessage("Pair this reader with kovi first.") end; return end + NetworkMgr:runWhenOnline(function() + local ok,response=Upload.sync(url,token) + if not silent then + if ok then self:showMessage(response and response.message or "kovi sync complete.") + else self:showMessage("Sync failed: "..tostring(response and response.error or "server error")) end + end + end) +end + +function kovi:testConnection() + local url=self.settings:getServerURL() + if url=="" then self:showMessage("Configure the kovi server URL first."); return end + NetworkMgr:runWhenOnline(function() + local ok,response=Upload.diagnose(url) + if ok then + self:showMessage("kovi connection OK"..(response and response.version and (" · v"..tostring(response.version)) or ".")) + else + self:showMessage("Connection test failed: "..tostring(response and response.error or "unknown error")) + end + end) +end + +function kovi:onSuspend() if self.settings:getSyncOnSuspend() then self:sync(true) end end +function kovi:onPowerOff() if self.settings:getSyncOnSuspend() then self:sync(true) end end +function kovi:addToMainMenu(menu_items) + menu_items.kovi={text=_("kovi"),sorting_hint="tools",sub_item_table={ + {text=_("Sync now"),callback=function() self:sync(false) end,separator=true}, + {text=_("Pair / change server"),callback=function() self:pair() end}, + {text=_("Test server connection"),callback=function() self:testConnection() end}, + {text=_("Sync on suspend"),checked_func=function() return self.settings:getSyncOnSuspend() end,callback=function() self.settings:toggleSyncOnSuspend() end}, + {text=_("About kovi"),callback=function() self:showMessage("kovi secure sync plugin · v"..const.VERSION) end}, + }} +end +return kovi diff --git a/plugins/kovi.koplugin/raw_http.lua b/plugins/kovi.koplugin/raw_http.lua new file mode 100644 index 0000000..b71e6fa --- /dev/null +++ b/plugins/kovi.koplugin/raw_http.lua @@ -0,0 +1,176 @@ +local socket = require("socket") +local urlmod = require("socket.url") + +local M = {} + +local function parse_target(target) + local ok, parsed = pcall(urlmod.parse, target) + if not ok or type(parsed) ~= "table" then + return nil, "invalid URL" + end + if parsed.scheme ~= "http" then + return nil, "direct transport only supports http:// URLs" + end + if not parsed.host or parsed.host == "" then + return nil, "URL has no host" + end + local port = tonumber(parsed.port) or 80 + if port < 1 or port > 65535 then + return nil, "invalid port" + end + local path = parsed.path + if not path or path == "" then path = "/" end + if parsed.params and parsed.params ~= "" then path = path .. ";" .. parsed.params end + if parsed.query and parsed.query ~= "" then path = path .. "?" .. parsed.query end + local host_header = parsed.host + if parsed.host:find(":", 1, true) and parsed.host:sub(1, 1) ~= "[" then + host_header = "[" .. parsed.host .. "]" + end + if port ~= 80 then host_header = host_header .. ":" .. tostring(port) end + return { + host = parsed.host, + port = port, + path = path, + host_header = host_header, + } +end + +local function close_quietly(tcp) + if tcp then pcall(function() tcp:close() end) end +end + +function M.probe(target, timeout) + local parsed, parse_err = parse_target(target) + if not parsed then + return false, { stage = "url", detail = parse_err } + end + local tcp, socket_err = socket.tcp() + if not tcp then + return false, { stage = "socket", detail = tostring(socket_err or "cannot create TCP socket") } + end + tcp:settimeout(timeout or 6) + local connected, connect_err = tcp:connect(parsed.host, parsed.port) + close_quietly(tcp) + if not connected then + return false, { + stage = "tcp", + detail = tostring(connect_err or "connection failed"), + host = parsed.host, + port = parsed.port, + } + end + return true, { host = parsed.host, port = parsed.port } +end + +local function decode_chunked(body) + local out = {} + local pos = 1 + while true do + local line_end = body:find("\r\n", pos, true) + if not line_end then return nil, "invalid chunked response" end + local size_line = body:sub(pos, line_end - 1):match("^%s*([^;]+)") + local size = tonumber(size_line, 16) + if not size then return nil, "invalid chunk size" end + pos = line_end + 2 + if size == 0 then return table.concat(out) end + local chunk_end = pos + size - 1 + if chunk_end > #body then return nil, "truncated chunked response" end + out[#out + 1] = body:sub(pos, chunk_end) + pos = chunk_end + 1 + if body:sub(pos, pos + 1) ~= "\r\n" then return nil, "invalid chunk separator" end + pos = pos + 2 + end +end + +function M.request(method, target, headers, body, timeout) + local parsed, parse_err = parse_target(target) + if not parsed then + return nil, nil, nil, nil, { stage = "url", detail = parse_err } + end + + local tcp, socket_err = socket.tcp() + if not tcp then + return nil, nil, nil, nil, { stage = "socket", detail = tostring(socket_err or "cannot create TCP socket") } + end + tcp:settimeout(timeout or 12) + local connected, connect_err = tcp:connect(parsed.host, parsed.port) + if not connected then + close_quietly(tcp) + return nil, nil, nil, nil, { + stage = "tcp", + detail = tostring(connect_err or "connection failed"), + host = parsed.host, + port = parsed.port, + } + end + + headers = headers or {} + local has_host, has_connection, has_length = false, false, false + local lines = { tostring(method or "GET") .. " " .. parsed.path .. " HTTP/1.1" } + for k, v in pairs(headers) do + local lower = tostring(k):lower() + if lower == "host" then has_host = true end + if lower == "connection" then has_connection = true end + if lower == "content-length" then has_length = true end + lines[#lines + 1] = tostring(k) .. ": " .. tostring(v) + end + if not has_host then lines[#lines + 1] = "Host: " .. parsed.host_header end + if not has_connection then lines[#lines + 1] = "Connection: close" end + if body ~= nil and not has_length then lines[#lines + 1] = "Content-Length: " .. tostring(#body) end + lines[#lines + 1] = "" + lines[#lines + 1] = "" + local payload = table.concat(lines, "\r\n") .. (body or "") + + local sent, send_err, sent_partial = tcp:send(payload) + if not sent then + close_quietly(tcp) + return nil, nil, nil, nil, { + stage = "send", + detail = tostring(send_err or "send failed") .. (sent_partial and (" after " .. tostring(sent_partial) .. " bytes") or ""), + } + end + + local chunks = {} + while true do + local chunk, receive_err, partial = tcp:receive(4096) + if chunk and #chunk > 0 then chunks[#chunks + 1] = chunk end + if partial and #partial > 0 then chunks[#chunks + 1] = partial end + if receive_err then + if receive_err ~= "closed" then + close_quietly(tcp) + return nil, nil, nil, nil, { stage = "receive", detail = tostring(receive_err) } + end + break + end + end + close_quietly(tcp) + + local raw = table.concat(chunks) + local header_block, response_body = raw:match("^(.-)\r\n\r\n(.*)$") + if not header_block then + return nil, nil, nil, nil, { stage = "http", detail = "malformed HTTP response" } + end + local status_line, rest = header_block:match("^([^\r\n]+)\r\n(.*)$") + if not status_line then status_line, rest = header_block, "" end + local code = tonumber(status_line:match("^HTTP/%d+%.%d+%s+(%d%d%d)")) + if not code then + return nil, nil, nil, nil, { stage = "http", detail = "invalid status line: " .. tostring(status_line) } + end + + local response_headers = {} + for line in (rest .. "\r\n"):gmatch("(.-)\r\n") do + local key, value = line:match("^([^:]+):%s*(.*)$") + if key then response_headers[key:lower()] = value end + end + if response_headers["transfer-encoding"] and response_headers["transfer-encoding"]:lower():find("chunked", 1, true) then + local decoded, chunk_err = decode_chunked(response_body) + if not decoded then + return nil, nil, nil, nil, { stage = "http", detail = chunk_err } + end + response_body = decoded + end + + return code, response_headers, status_line, response_body, nil +end + +return M diff --git a/plugins/kovi.koplugin/settings.lua b/plugins/kovi.koplugin/settings.lua new file mode 100644 index 0000000..e57074f --- /dev/null +++ b/plugins/kovi.koplugin/settings.lua @@ -0,0 +1,51 @@ +local _ = require("gettext") +local DataStorage = require("datastorage") +local LuaSettings = require("luasettings") +local MultiInputDialog = require("ui/widget/multiinputdialog") +local InfoMessage = require("ui/widget/infomessage") +local UIManager = require("ui/uimanager") + +local Settings = {}; Settings.__index = Settings +local KEY = "kovi" + +local function local_only_url(url) + local lower = url:lower() + return lower:match("^https?://localhost[:/]") + or lower:match("^https?://127%.") + or lower:match("^https?://0%.0%.0%.0[:/]") + or lower:match("^https?://%[::1%][:/]") +end + +function Settings:new() + local o=setmetatable({},self); o.handle=LuaSettings:open(DataStorage:getSettingsDir().."/kovi.lua"); o.data=o.handle:readSetting(KEY,{}) or {}; return o +end +function Settings:save() self.handle:saveSetting(KEY,self.data); self.handle:flush() end +function Settings:getServerURL() return (self.data.server_url or ""):gsub("/*$","") end +function Settings:getToken() return self.data.device_token end +function Settings:isPaired() return self:getToken() ~= nil and self:getToken() ~= "" end +function Settings:setToken(v) self.data.device_token=v; self:save() end +function Settings:getSyncOnSuspend() return self.data.sync_on_suspend == true end +function Settings:toggleSyncOnSuspend() self.data.sync_on_suspend=not self:getSyncOnSuspend(); self:save() end + +function Settings:showSetup(on_pair) + local dialog + dialog = MultiInputDialog:new({ + title = _("kovi setup"), + fields = { + { text=self:getServerURL(), description=_("Server URL:"), hint=_("http://192.168.1.10:3000") }, + { text="", description=_("Pairing code:"), hint=_("8-character code") }, + }, + buttons = {{ + { text=_("Cancel"), id="close", callback=function() UIManager:close(dialog) end }, + { text=_("Pair"), callback=function() + local f=dialog:getFields(); local url=tostring(f[1] or ""):gsub("%s+",""):gsub("/*$",""); local code=tostring(f[2] or ""):upper():gsub("[^A-Z0-9]","") + if not url:match("^https?://") then UIManager:show(InfoMessage:new({text=_("Server URL must start with http:// or https://")})); return end + if local_only_url(url) then UIManager:show(InfoMessage:new({text=_("Use the kovi computer's LAN IP, not localhost/127.0.0.1/0.0.0.0. Example: http://192.168.1.23:3000")})); return end + if #code < 6 then UIManager:show(InfoMessage:new({text=_("Enter the pairing code shown in kovi.")})); return end + self.data.server_url=url; self:save(); UIManager:close(dialog); on_pair(url,code) + end }, + }}, + }) + UIManager:show(dialog); dialog:onShowKeyboard() +end +return Settings diff --git a/plugins/kovi.koplugin/upload.lua b/plugins/kovi.koplugin/upload.lua new file mode 100644 index 0000000..9e75f73 --- /dev/null +++ b/plugins/kovi.koplugin/upload.lua @@ -0,0 +1,128 @@ +local JSON = require("json") +local Device = require("device") +local random = require("random") +local logger = require("logger") +local callApi = require("call_api") +local RawHttp = require("raw_http") +local Db = require("db_reader") +local Annotations = require("annotation_reader") +local Covers = require("cover_reader") +local const = require("./const") +local U = {} + +local function headers(body, token, content_type) + local h = { ["Content-Type"] = content_type or "application/json" } + if body ~= nil then h["Content-Length"] = tostring(#body) end + if token and token ~= "" then h["Authorization"] = "Bearer " .. token end + return h +end + +local function device_id() + local id = G_reader_settings:readSetting("device_id") + if id == nil or id == "" then + id = random.uuid() + G_reader_settings:saveSetting("device_id", id) + if G_reader_settings.flush then G_reader_settings:flush() end + end + return id +end + +function U.ping(server_url) + return callApi("GET", server_url .. "/api/plugin/ping", { ["Accept"] = "application/json" }, nil) +end + +function U.diagnose(server_url) + if server_url:match("^http://") then + local tcp_ok, tcp_info = RawHttp.probe(server_url .. "/api/plugin/ping", 6) + if not tcp_ok then + local host_port = "" + if tcp_info and tcp_info.host and tcp_info.port then + host_port = " " .. tostring(tcp_info.host) .. ":" .. tostring(tcp_info.port) + end + return false, { + error = "TCP connection to" .. host_port .. " failed: " .. tostring(tcp_info and tcp_info.detail or "unknown error") .. ". If kovi works on the computer itself, allow inbound TCP port 3000 (or your configured port) in the computer's firewall and make sure the reader is on the same LAN.", + stage = tcp_info and tcp_info.stage or "tcp", + } + end + end + local ok, response = U.ping(server_url) + if not ok then return false, response end + return true, { + version = response and response.version, + message = "TCP and HTTP checks passed.", + } +end + +function U.pair(server_url, code, settings) + local reachable, diagnostic = U.diagnose(server_url) + if not reachable then return false, diagnostic end + + local body = JSON.encode({ + code = code, + device_id = device_id(), + model = Device.model, + version = const.VERSION, + }) + local ok, response = callApi("POST", server_url .. "/api/plugin/pair", headers(body, nil), body) + if ok and response and response.token then + settings:setToken(response.token) + -- Pairing represents a fresh server-side sync identity. Reset incremental + -- cursors so a rebuilt kovi database receives a complete first sync. + G_reader_settings:saveSetting("kovi_sync_cursor", 0) + G_reader_settings:saveSetting("kovi_annotation_versions", {}) + G_reader_settings:saveSetting("kovi_completion_sync_version", 0) + if G_reader_settings.flush then G_reader_settings:flush() end + return true, response + end + return false, response +end + +function U.sync(server_url, token) + local cursor = tonumber(G_reader_settings:readSetting("kovi_sync_cursor")) or 0 + local previous_versions = G_reader_settings:readSetting("kovi_annotation_versions") or {} + local needs_completion_scan = G_reader_settings:readSetting("kovi_completion_sync_version") ~= 1 + if needs_completion_scan then previous_versions = {} end + local books = Db.bookData() + local stats = Db.progressData(cursor, books) + local annotation_sets, annotation_versions = Annotations.changed(previous_versions) + local body = JSON.encode({ + books = books, stats = stats, annotation_sets = annotation_sets, + sync_cursor = cursor, version = const.VERSION + }) + local ok, response = callApi("POST", server_url .. "/api/plugin/import", headers(body, token), body) + if not ok then return false, response end + + -- Advance only after the server committed the import. If a request fails, the + -- same overlap/change set is retried on the next manual sync. + if response and response.sync_cursor then + G_reader_settings:saveSetting("kovi_sync_cursor", tonumber(response.sync_cursor) or cursor) + end + G_reader_settings:saveSetting("kovi_annotation_versions", annotation_versions) + G_reader_settings:saveSetting("kovi_completion_sync_version", 1) + if G_reader_settings.flush then G_reader_settings:flush() end + + local requests = response and response.cover_requests + local uploaded = 0 + if type(requests) == "table" and #requests > 0 then + local covers = Covers.extractRequested(requests) + for _, cover in ipairs(covers) do + local cover_headers = headers(cover.data, token, "image/jpeg") + cover_headers["X-Kovi-Book-MD5"] = cover.book_md5 + local cover_ok, cover_response = callApi("POST", server_url .. "/api/plugin/cover", cover_headers, cover.data) + if cover_ok then + uploaded = uploaded + 1 + else + local detail = cover_response and cover_response.error or "server rejected cover" + logger.warn("[kovi] Embedded cover upload failed", cover.book_md5, tostring(detail)) + end + end + end + if response then + response.embedded_covers = uploaded + if uploaded > 0 then + response.message = tostring(response.message or "kovi sync complete.") .. " Uploaded " .. tostring(uploaded) .. " embedded cover" .. (uploaded == 1 and "." or "s.") + end + end + return true, response +end +return U diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..7c9262d --- /dev/null +++ b/public/app.js @@ -0,0 +1,383 @@ +const $=(s,r=document)=>r.querySelector(s), $$=(s,r=document)=>[...r.querySelectorAll(s)]; +const LIBRARY_SORTS=['recent','title','author','time','progress'],LIBRARY_FILTERS=['all','unread','progress','completed']; +const storedChoice=(key,choices,fallback)=>{const value=localStorage.getItem(key);return choices.includes(value)?value:fallback}; +const state={books:[],dashboard:null,csrf:null,pairPoll:null,currentBook:null,status:null,rangeKey:'year',calendar:null,calendarMonth:null,calendarSelectedDay:null,coverChooserBookId:null,librarySort:storedChoice('kovi-library-sort',LIBRARY_SORTS,'recent'),libraryFilter:storedChoice('kovi-library-filter',LIBRARY_FILTERS,'all'),libraryQuery:''}; +const VIEW_PATHS={home:'/',library:'/library',calendar:'/calendar',devices:'/devices',status:'/status'}; +const RANGE_LABELS={week:'Last week',month:'Last month','three-months':'Last three months','six-months':'Last six months',year:'Last year',custom:'Custom dates'}; +const RANGE_DAYS={week:7,month:30,'three-months':90,'six-months':180,year:365}; +const HEATMAP_REFERENCE_WEEKS=53; +const fmtSeconds=s=>{s=Math.max(0,Number(s||0));const h=Math.floor(s/3600),m=Math.floor((s%3600)/60);return h?`${h}h ${m}m`:`${m}m`}; +const fmtCompactSeconds=s=>{s=Math.max(0,Number(s||0));const m=Math.max(1,Math.round(s/60));return m>=60?`${(m/60).toFixed(m%60?1:0)}h`:`${m}m`}; +const esc=s=>String(s??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); +const progress=b=>{const page=Number(b.last_page||0),total=Number(b.last_total_pages||b.pages||0);return total>0?Math.min(100,Math.round(page/total*100)):0}; +const bookIsRead=b=>b.is_read==null?progress(b)===100:Boolean(Number(b.is_read)); +const localDateKey=d=>`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; +const parseLocalDateKey=key=>{const m=/^(\d{4})-(\d{2})-(\d{2})$/.exec(String(key||''));if(!m)return null;const d=new Date(Number(m[1]),Number(m[2])-1,Number(m[3]),12,0,0,0);return d.getFullYear()===Number(m[1])&&d.getMonth()===Number(m[2])-1&&d.getDate()===Number(m[3])?d:null}; +const formatDate=(key,opts={month:'short',day:'numeric',year:'numeric'})=>parseLocalDateKey(key)?.toLocaleDateString(undefined,opts)||key; +function toast(msg){const el=$('#toast');el.textContent=msg;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),3200)} + +async function api(url,opts={},retryCsrf=true){ + const method=(opts.method||'GET').toUpperCase(); + const request={...opts,headers:{...(opts.headers||{})}}; + if(method!=='GET'&&!state.csrf){const session=await api('/api/session',{},false);state.csrf=session.csrf} + if(method!=='GET'&&state.csrf)request.headers['X-Kovi-CSRF']=state.csrf; + const r=await fetch(url,request);const data=await r.json().catch(()=>({})); + if(!r.ok&&r.status===403&&retryCsrf&&method!=='GET'&&data.error==='Missing browser request token.'){const session=await api('/api/session',{},false);state.csrf=session.csrf;return api(url,opts,false)} + if(!r.ok)throw new Error(data.error||`Request failed (${r.status})`);return data; +} + +function coverImageSrc(b){if(!b.cover_path)return '';const version=b.updated_at||b.cover_checked_at||'';return version?`${b.cover_path}${b.cover_path.includes('?')?'&':'?'}v=${encodeURIComponent(version)}`:b.cover_path} +function coverMarkup(b){return b.cover_path?`Cover of ${esc(b.title)}`:`
${esc(b.title)}${esc(b.authors||'Unknown author')}
`} +function bookCard(b){const p=progress(b),read=bookIsRead(b);return `
${coverMarkup(b)}${read?'Read':''}

${esc(b.title)}

${esc(b.authors||'Unknown author')}

${p}%
`} + +function bookReadingState(book){if(bookIsRead(book))return 'completed';if(book.read_override!=null&&Number(book.read_override)===0)return 'unread';return progress(book)===0?'unread':'progress'} +function renderLibrary(){ + const collator=new Intl.Collator(undefined,{sensitivity:'base',numeric:true}),title=(a,b)=>collator.compare(a.title||'',b.title||''); + const comparators={recent:(a,b)=>Number(b.recent_read||b.last_open||0)-Number(a.recent_read||a.last_open||0)||title(a,b),title,author:(a,b)=>collator.compare(a.authors||'Unknown author',b.authors||'Unknown author')||title(a,b),time:(a,b)=>Number(b.total_read_time||0)-Number(a.total_read_time||0)||title(a,b),progress:(a,b)=>progress(b)-progress(a)||title(a,b)}; + const query=state.libraryQuery.trim().toLowerCase(); + const books=state.books.filter(book=>(state.libraryFilter==='all'||bookReadingState(book)===state.libraryFilter)&&(!query||`${book.title} ${book.authors||''}`.toLowerCase().includes(query))).sort(comparators[state.librarySort]); + $('#bookSort').value=state.librarySort;$('#bookFilter').value=state.libraryFilter; + $('#librarySummary').textContent=books.length===state.books.length?`${books.length} book${books.length===1?'':'s'}`:`Showing ${books.length} of ${state.books.length} books`; + $('#bookGrid').innerHTML=books.map(bookCard).join('')||`

${state.books.length?'No books match these filters.':'No books yet.'}

`; +} + +function rangeLabel(){return RANGE_LABELS[state.rangeKey]||'Custom dates'} +function rangeDescription(range){return `${formatDate(range.from)} – ${formatDate(range.to)}`} +function buildRangeSeries(days,range){ + const by=new Map((days||[]).map(x=>[x.day,Number(x.seconds||0)])); + const start=parseLocalDateKey(range?.from),end=parseLocalDateKey(range?.to);if(!start||!end)return []; + const out=[];for(const d=new Date(start);d<=end;d.setDate(d.getDate()+1)){const day=localDateKey(d);out.push({day,seconds:by.get(day)||0})}return out; +} + +function render(){ + const d=state.dashboard,b=state.books;const empty=!b.length; + $('#emptyState').classList.toggle('hidden',!empty);$('#dashboardContent').classList.toggle('hidden',empty); + if(!empty&&d){ + $('#metrics').innerHTML=[ + ['Books',d.books,'in your reading history'], + ['Books read',Number(d.range?.read_books||0).toLocaleString(),`started and completed · ${rangeLabel()}`], + ['Reading time',fmtSeconds(d.all_time_reading_seconds??d.total_read_time??d.session_seconds),'all-time KOReader total'], + ['Pages',Number(d.total_read_pages||0).toLocaleString(),'pages turned'], + ['Highlights',Number(d.highlights||0).toLocaleString(),'ideas kept'], + ].map(x=>`
${x[0]}${x[1]}${x[2]}
`).join(''); + renderRangeUi(d.range);renderHeatmap(d.days||[],d.range);renderLineChart(d.days||[],d.range,d); + } + renderLibrary(); +} + +function renderRangeUi(range){ + if(!range)return; + const label=rangeLabel(); + $('#rangeSummary').textContent=state.rangeKey==='custom'?rangeDescription(range):label; + $('#rhythmRangeLabel').textContent=label;$('#trendRangeLabel').textContent=label; + $$('.range-button').forEach(button=>button.classList.toggle('active',button.dataset.range===state.rangeKey)); + $('#customFrom').value=range.from;$('#customTo').value=range.to; + const today=localDateKey(new Date());$('#customFrom').max=today;$('#customTo').max=today; +} + +function heatLevel(sec,active){ + if(!sec)return 0;if(!active.length)return 1; + const sorted=[...active].sort((a,b)=>a-b),q=p=>sorted[Math.min(sorted.length-1,Math.floor((sorted.length-1)*p))]; + if(sec<=q(.25))return 1;if(sec<=q(.5))return 2;if(sec<=q(.75))return 3;return 4; +} +function renderHeatmap(days,range){ + const from=parseLocalDateKey(range?.from),to=parseLocalDateKey(range?.to);if(!from||!to)return; + const by=new Map(days.map(x=>[x.day,Number(x.seconds||0)])),active=[...by.values()].filter(Boolean); + const start=new Date(from);start.setDate(start.getDate()-start.getDay()); + const end=new Date(to);end.setDate(end.getDate()+(6-end.getDay())); + const cellCount=Math.round((end-start)/86400000)+1,weeks=Math.ceil(cellCount/7); + let html='',activeDays=0,total=0; + for(let i=0;ito;const key=localDateKey(dt),sec=outside?0:(by.get(key)||0); + if(sec){activeDays++;total+=sec}const level=heatLevel(sec,active); + html+=``; + } + const heatmap=$('#heatmap'),months=$('#heatmapMonths'),canvas=heatmap.closest('.heatmap-scroll'),layout=heatmap.closest('.heatmap-layout');heatmap.innerHTML=html; + const monthCells=[];let prior=''; + for(let week=0;week180 ? mid.getMonth()===0 : weeks>90 ? (mid.getMonth()===0||mid.getMonth()===6) : true; + monthCells.push(`${changed&&sparse?esc(mid.toLocaleDateString(undefined,{month:weeks>90?'short':'short'})):''}`);prior=monthKey; + } + months.innerHTML=monthCells.join(''); + // Keep the visual scale stable across filters: shorter ranges should reveal fewer + // GitHub-style columns, not stretch those columns to fill the whole panel. + // The selected range therefore uses the same cell size a full 53-week year + // would use at the current viewport. On narrow screens that reference size + // naturally shrinks, so even the full year still fits without scrolling. + const available=Math.max(1,canvas?.clientWidth||0),gap=available<480?1.5:3; + const referenceCell=Math.max(.25,(available-gap*(HEATMAP_REFERENCE_WEEKS-1))/HEATMAP_REFERENCE_WEEKS); + const cell=Math.min(referenceCell,(available-gap*Math.max(0,weeks-1))/Math.max(1,weeks)); + [canvas,layout].filter(Boolean).forEach(el=>{el.style.setProperty('--heat-weeks',String(weeks));el.style.setProperty('--heat-gap',`${gap}px`);el.style.setProperty('--heat-cell',`${cell}px`)}); + $('#heatmapCaption').textContent=activeDays?`${activeDays} reading day${activeDays===1?'':'s'} · ${fmtSeconds(total)} tracked from ${formatDate(range.from)} to ${formatDate(range.to)}.`:`No dated reading sessions in ${rangeDescription(range)}.`; +} + +function niceStep(value){ + if(value<=0)return 15;const power=10**Math.floor(Math.log10(value));const scaled=value/power; + const factor=scaled<=1?1:scaled<=2?2:scaled<=5?5:10;return factor*power; +} +function axisMinutes(minutes){if(minutes>=60){const hours=minutes/60;return Number.isInteger(hours)?`${hours}h`:`${hours.toFixed(1)}h`}return `${Math.round(minutes)}m`} +function chartDateLabel(key,span){const d=parseLocalDateKey(key);if(!d)return key;if(span<=14)return d.toLocaleDateString(undefined,{weekday:'short'});if(span<=120)return d.toLocaleDateString(undefined,{month:'short',day:'numeric'});return d.toLocaleDateString(undefined,{month:'short',year:'2-digit'})} +function chartKeyPointIndices(series){ + const active=series.map((item,index)=>({index,seconds:item.seconds})).filter(x=>x.seconds>0); + if(!active.length)return new Set(); + if(series.length<=45)return new Set(active.map(x=>x.index)); + const values=active.map(x=>x.seconds).sort((a,b)=>a-b),threshold=values[Math.max(0,Math.floor(values.length*.75)-1)]||0; + const keys=new Set();let max=active[0]; + for(const point of active){ + if(point.seconds>max.seconds)max=point; + const prev=series[point.index-1]?.seconds||0,next=series[point.index+1]?.seconds||0; + if(point.seconds>=threshold&&point.seconds>=prev&&point.seconds>=next)keys.add(point.index); + } + keys.add(max.index);return keys; +} +function renderLineChart(days,range,dashboard){ + const series=buildRangeSeries(days,range),total=series.reduce((sum,x)=>sum+x.seconds,0),readingDays=series.filter(x=>x.seconds>0).length; + $('#trendTotal').textContent=fmtSeconds(total);$('#trendDays').textContent=String(readingDays); + const chart=$('#lineChart'); + if(!series.length||!series.some(x=>x.seconds>0)){ + chart.innerHTML='
No reading sessions in this range.
'; + chart.setAttribute('aria-label',`No dated reading sessions from ${formatDate(range.from)} to ${formatDate(range.to)}.`); + }else{ + const width=Math.max(320,Math.round(chart.clientWidth||900)),height=270,left=54,right=18,top=18,bottom=42,plotW=width-left-right,plotH=height-top-bottom; + const maxMinutes=Math.max(...series.map(x=>x.seconds/60)),step=niceStep(maxMinutes/4),yMax=Math.max(step*4,Math.ceil(maxMinutes/step)*step); + const x=i=>left+(series.length===1?plotW/2:(i/(series.length-1))*plotW),y=seconds=>top+plotH-(seconds/60/yMax)*plotH; + const grid=[];for(let i=0;i<=4;i++){const min=(yMax/4)*i,yy=top+plotH-(i/4)*plotH;grid.push(`${axisMinutes(min)}`)} + const points=series.map((item,i)=>`${x(i).toFixed(2)},${y(item.seconds).toFixed(2)}`).join(' '); + const tickIndices=[0,.25,.5,.75,1].map(p=>Math.round((series.length-1)*p)).filter((v,i,a)=>a.indexOf(v)===i); + const ticks=tickIndices.map(i=>`${esc(chartDateLabel(series[i].day,series.length))}`).join(''); + const keyPoints=chartKeyPointIndices(series); + const dots=[...keyPoints].sort((a,b)=>a-b).map(i=>`${esc(`${formatDate(series[i].day)}: ${fmtSeconds(series[i].seconds)}`)}`).join(''); + chart.innerHTML=`${dots}`; + const svg=$('svg',chart),tooltip=$('.chart-tooltip',chart),guide=$('.chart-hover-guide',chart),hoverDot=$('.chart-hover-dot',chart);let pinnedIndex=null; + const showAt=index=>{ + index=Math.max(0,Math.min(series.length-1,index));const item=series[index],xx=x(index),yy=y(item.seconds),rect=svg.getBoundingClientRect(); + guide.setAttribute('x1',xx);guide.setAttribute('x2',xx);guide.classList.remove('hidden');hoverDot.setAttribute('cx',xx);hoverDot.setAttribute('cy',yy);hoverDot.classList.remove('hidden'); + $('strong',tooltip).textContent=formatDate(item.day,{weekday:'short',month:'short',day:'numeric',year:'numeric'});$('span',tooltip).textContent=item.seconds?fmtSeconds(item.seconds):'No reading';tooltip.classList.remove('hidden'); + const cssX=(xx/width)*rect.width,cssY=(yy/height)*rect.height;tooltip.style.left=`${Math.max(70,Math.min(rect.width-70,cssX))}px`;tooltip.style.top=`${Math.max(20,cssY-8)}px`; + }; + const hide=()=>{if(pinnedIndex!==null)return;tooltip.classList.add('hidden');guide.classList.add('hidden');hoverDot.classList.add('hidden')}; + svg.addEventListener('pointermove',event=>{if(pinnedIndex!==null)return;const rect=svg.getBoundingClientRect(),px=(event.clientX-rect.left)*(width/Math.max(1,rect.width)),ratio=(px-left)/Math.max(1,plotW),index=Math.round(Math.max(0,Math.min(1,ratio))*(series.length-1));showAt(index)}); + svg.addEventListener('pointerleave',hide); + svg.addEventListener('click',event=>{const rect=svg.getBoundingClientRect(),px=(event.clientX-rect.left)*(width/Math.max(1,rect.width)),ratio=(px-left)/Math.max(1,plotW),index=Math.round(Math.max(0,Math.min(1,ratio))*(series.length-1));if(pinnedIndex===index){pinnedIndex=null;hide()}else{pinnedIndex=index;showAt(index)}}); + svg.addEventListener('focusin',event=>{const dot=event.target.closest?.('[data-chart-index]');if(dot){pinnedIndex=Number(dot.dataset.chartIndex);showAt(pinnedIndex)}}); + svg.addEventListener('focusout',()=>{pinnedIndex=null;hide()}); + chart.setAttribute('aria-label',`Daily reading time from ${formatDate(range.from)} to ${formatDate(range.to)}. Hover or tap the chart to inspect a day. ${fmtSeconds(total)} across ${readingDays} reading days.`); + } + const allTime=Number(dashboard.all_time_reading_seconds||0),sessionAll=Number(dashboard.session_seconds||0),gap=Math.abs(allTime-sessionAll); + let caption=`Daily session history for ${rangeDescription(range)}. Hover or tap the graph to inspect individual days.`; + if(gap>=60)caption+=` The all-time Reading time card uses KOReader’s per-book total (${fmtSeconds(allTime)}), while dated session rows add up to ${fmtSeconds(sessionAll)} across all dates; this is why the figures can differ.`; + else caption+=' The Reading time card is all-time; this graph is limited to the selected dates.'; + $('#trendCaption').textContent=caption; +} + +function presetRange(key){const days=RANGE_DAYS[key];if(!days)return null;const to=new Date();to.setHours(12,0,0,0);const from=new Date(to);from.setDate(from.getDate()-(days-1));return {from:localDateKey(from),to:localDateKey(to)}} +async function applyDashboardRange(key,range){ + if(!range)return;state.rangeKey=key; + const params=new URLSearchParams({from:range.from,to:range.to}); + try{state.dashboard=await api(`/api/dashboard?${params}`);render()}catch(e){toast(e.message)} +} + +let coverPoll=null; +async function refresh(){ + const dashboardUrl=state.dashboard?.range?`/api/dashboard?${new URLSearchParams({from:state.dashboard.range.from,to:state.dashboard.range.to})}`:'/api/dashboard'; + const [books,dashboard]=await Promise.all([api('/api/books'),api(dashboardUrl)]); + state.books=books.books;state.dashboard=dashboard;render(); + if(coverPoll){clearTimeout(coverPoll);coverPoll=null} + if(state.books.some(b=>b.cover_status==='pending'))coverPoll=setTimeout(()=>refresh().catch(()=>{}),5000); +} + +function monthAnchor(value=new Date()){const d=value instanceof Date?new Date(value):parseLocalDateKey(value)||new Date();return new Date(d.getFullYear(),d.getMonth(),1,12,0,0,0)} +function calendarMonthRange(value){const first=monthAnchor(value),last=new Date(first.getFullYear(),first.getMonth()+1,0,12,0,0,0);return {from:localDateKey(first),to:localDateKey(last)}} +function calendarLevel(seconds,active){return heatLevel(seconds,active)} +function renderCalendarDetail(dayKey){ + const detail=$('#calendarDetail'),data=state.calendar,day=(data?.days||[]).find(x=>x.day===dayKey);state.calendarSelectedDay=dayKey; + const dateInput=$('#calendarDateInput');if(dateInput)dateInput.value=dayKey; + $$('.calendar-day').forEach(el=>el.classList.toggle('selected',el.dataset.day===dayKey)); + const date=formatDate(dayKey,{weekday:'long',month:'long',day:'numeric',year:'numeric'}); + if(!day){detail.innerHTML=`

${esc(date)}

No reading logged.

There are no dated KOReader sessions for this day.

`;return} + const books=(day.books||[]).map(book=>``).join(''); + detail.innerHTML=`

${esc(date)}

Reading time${fmtSeconds(day.seconds)}
Books${day.books.length}
${books}
`; +} +function renderCalendar(){ + const data=state.calendar;if(!data)return;const range=data.range,from=parseLocalDateKey(range.from),to=parseLocalDateKey(range.to);if(!from||!to)return; + $('#calendarMonthTitle').textContent=from.toLocaleDateString(undefined,{month:'long',year:'numeric'}); + const total=(data.days||[]).reduce((sum,day)=>sum+Number(day.seconds||0),0),readingDays=(data.days||[]).filter(day=>Number(day.seconds||0)>0).length; + $('#calendarMonthSummary').textContent=readingDays?`${readingDays} reading day${readingDays===1?'':'s'} · ${fmtSeconds(total)}`:'No reading sessions this month'; + const by=new Map((data.days||[]).map(day=>[day.day,day])),active=(data.days||[]).map(day=>Number(day.seconds||0)).filter(Boolean); + const gridStart=new Date(from);gridStart.setDate(gridStart.getDate()-gridStart.getDay());const gridEnd=new Date(to);gridEnd.setDate(gridEnd.getDate()+(6-gridEnd.getDay())); + const today=localDateKey(new Date());let html=''; + for(const d=new Date(gridStart);d<=gridEnd;d.setDate(d.getDate()+1)){ + const key=localDateKey(d),item=by.get(key),outside=d.getMonth()!==from.getMonth(),seconds=Number(item?.seconds||0),level=calendarLevel(seconds,active),books=item?.books?.length||0; + const label=`${formatDate(key,{weekday:'long',month:'long',day:'numeric',year:'numeric'})}${seconds?`, ${fmtSeconds(seconds)}, ${books} book${books===1?'':'s'}`:', no reading logged'}`; + html+=``; + } + const grid=$('#calendarGrid');grid.innerHTML=html;grid.setAttribute('aria-label',`Reading calendar for ${from.toLocaleDateString(undefined,{month:'long',year:'numeric'})}`); + let selected=state.calendarSelectedDay;if(!selected||selectedrange.to)selected=(data.days||[]).find(day=>day.day===today)?.day||(data.days||[])[0]?.day||range.from; + renderCalendarDetail(selected); +} +async function loadCalendar(value=state.calendarMonth||new Date(),selectDay=null){ + const month=monthAnchor(value),range=calendarMonthRange(month);state.calendarMonth=month;if(selectDay)state.calendarSelectedDay=selectDay; + $('#calendarGrid').innerHTML='
Loading reading days…
'; + try{state.calendar=await api(`/api/calendar?${new URLSearchParams(range)}`);renderCalendar()}catch(e){toast(e.message);$('#calendarGrid').innerHTML='
Could not load this month.
'} +} +function moveCalendarMonth(delta){const base=state.calendarMonth||monthAnchor();const next=new Date(base.getFullYear(),base.getMonth()+delta,1,12,0,0,0);state.calendarSelectedDay=null;loadCalendar(next)} +function jumpToCalendarDate(dayKey){const date=parseLocalDateKey(dayKey);if(!date)return toast('Choose a valid date.');loadCalendar(date,dayKey)} + +function setView(name){ + $$('.view').forEach(v=>v.classList.remove('active')); + $$('.nav-item').forEach(v=>v.classList.toggle('active',v.dataset.view===name)); + const target=$(`#${name}View`);if(target)target.classList.add('active'); + $('#pageTitle').textContent=name==='home'?'Good to see you.':name==='library'?'Your library.':name==='calendar'?'Your calendar.':name==='devices'?'Your devices.':name==='status'?'kovi status.':'Book details.'; + if(name==='home'&&state.dashboard?.range)requestAnimationFrame(()=>{renderHeatmap(state.dashboard.days||[],state.dashboard.range);renderLineChart(state.dashboard.days||[],state.dashboard.range,state.dashboard)}); + if(name==='calendar')loadCalendar(); + if(name==='devices')loadDevices(); + if(name==='status')loadStatus(); +} +function switchView(name,{push=true}={}){setView(name);if(push&&VIEW_PATHS[name]&&location.pathname!==VIEW_PATHS[name])history.pushState({view:name},'',VIEW_PATHS[name]);window.scrollTo(0,0)} + +async function openBook(id,{push=true}={}){ + try{ + const b=await api(`/api/books/${encodeURIComponent(id)}`);if(state.coverChooserBookId&&state.coverChooserBookId!==b.id)state.coverChooserBookId=null;state.currentBook=b;renderBookPage(b);setView('book'); + if(push&&location.pathname!==`/books/${encodeURIComponent(id)}`)history.pushState({bookId:id},'',`/books/${encodeURIComponent(id)}`); + window.scrollTo(0,0); + }catch(error){toast(error.message);switchView('library',{push})} +} +function annotationMarkup(a){ + const text=a.text||'',note=a.note||'',location=[a.chapter,a.pageno?`page ${a.pageno}`:a.page?`page ${a.page}`:''].filter(Boolean).join(' · '); + return `
${a.annotation_type==='note'?'Note':'Highlight'}${a.annotation_datetime?``:''}
${text?`
${esc(text)}
`:''}${note?`

${esc(note)}

`:''}${location?`

${esc(location)}

`:''}
`; +} +let bookCoverPoll=null; +function stopBookCoverPoll(){if(bookCoverPoll){clearTimeout(bookCoverPoll);bookCoverPoll=null}} +function coverSourceLabel(source){return ({'koreader-embedded':'KOReader embedded','manual-upload':'Manual upload',openlibrary:'Open Library',googlebooks:'Google Books','openlibrary-via-googlebooks':'Open Library via Google Books','googlebooks-via-calibre-id':'Google Books via Calibre',calibre:'Calibre'}[source]||source||'No cover')} +function coverJobLabel(job){if(!job)return '';if(job.state==='queued')return 'Queued for online lookup…';if(job.state==='running')return 'Looking for an online cover…';if(job.state==='error')return `Lookup failed${job.last_error?`: ${job.last_error}`:''}`;if(job.state==='done'&&job.result_status==='none-kept')return 'No better cover found — kept the existing cover.';if(job.state==='done'&&job.result_status==='matched')return `Online cover updated${job.result_source?` via ${coverSourceLabel(job.result_source)}`:''}.`;return ''} +function coverOption(c,{history=false}={}){const selected=history&&Number(c.selected)===1;return `
${history?'Previous':'Candidate'} cover
${esc(history?coverSourceLabel(c.source):(c.title||coverSourceLabel(c.source)))}${esc(history?(c.strategy||new Date(c.created_at).toLocaleString()):([c.authors,coverSourceLabel(c.source),Number(c.score||0)?`${Math.round(Number(c.score)*100)}% match`:null].filter(Boolean).join(' · ')))}
${selected?'Current':``}
`} +async function pollBookCoverJob(bookId,attempt=0){ + stopBookCoverPoll(); + try{ + const b=await api(`/api/books/${encodeURIComponent(bookId)}`);state.currentBook=b;renderBookPage(b); + if(b.cover_job&&['queued','running'].includes(b.cover_job.state)&&attempt<90){bookCoverPoll=setTimeout(()=>pollBookCoverJob(bookId,attempt+1),1000);return} + await refresh(); + }catch{if(attempt<20)bookCoverPoll=setTimeout(()=>pollBookCoverJob(bookId,attempt+1),1500)} +} +function renderBookPage(b){ + const p=progress(b),read=bookIsRead(b),annotations=b.annotations||[];let highlights=''; + if(annotations.length)highlights=`
${annotations.map(annotationMarkup).join('')}
`; + else if(Number(b.highlights||0)>0)highlights=`
${Number(b.highlights)} highlight${Number(b.highlights)===1?'':'s'} counted by KOReader

The statistics database stores only the count. Sync the kovi plugin to import the actual highlight text from KOReader’s book sidecars.

`; + else highlights='

No highlights have been synced for this book.

'; + const jobText=coverJobLabel(b.cover_job),jobBusy=b.cover_job&&['queued','running'].includes(b.cover_job.state); + const candidates=b.cover_candidates||[],history=b.cover_history||[],showCoverChoices=state.coverChooserBookId===b.id; + const statusLabel=read?'Read':b.read_override!=null&&Number(b.read_override)===0?'Unread':p>0?'Reading':'Unread'; + const statusDetail=b.read_override!=null?`Manually marked ${Number(b.read_override)?'read':'unread'} in kovi.`:b.read_source==='koreader'?'Marked read by KOReader.':b.read_source==='progress'?'Completed from document progress.':'Completion is tracked separately from document progress.'; + const readControl=`
${statusLabel}

${esc(statusDetail)}

${b.read_override!=null?'':''}
`; + const coverManager=`

Cover manager

Choose the edition you want to see

${esc(coverSourceLabel(b.cover_source))}
${jobText?`
${jobBusy?'':''}${esc(jobText)}
`:''}
${showCoverChoices&&candidates.length?`

Online candidates

${candidates.map(c=>coverOption(c)).join('')}
`:''}${showCoverChoices&&history.length>1?`

Cover history

${history.map(c=>coverOption(c,{history:true})).join('')}
`:''}
`; + $('#bookPage').innerHTML=`

Book

${esc(b.title)}

${readControl}
Document progress${p}%
Reading time${fmtSeconds(b.total_read_time)}
Highlights${Number(b.highlights||0)}
Pages${Number(b.pages||0)}
Language${esc(b.language||'—')}
Cover${esc(coverSourceLabel(b.cover_source))}
${coverManager}

Highlights

Passages worth keeping

${annotations.length?`${annotations.length} synced`:''}
${highlights}
`; + const setRead=async value=>{try{await api(`/api/books/${encodeURIComponent(b.id)}/read`,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({read:value})});toast(value===null?'Using synced reading status.':value?'Book marked as read.':'Book marked as unread.');await refresh();await openBook(b.id,{push:false})}catch(e){toast(e.message)}}; + $('#toggleBookRead')?.addEventListener('click',()=>setRead(!read)); + $('#clearBookRead')?.addEventListener('click',()=>setRead(null)); + $('#retryBookCover')?.addEventListener('click',async()=>{try{state.coverChooserBookId=null;await api(`/api/books/${encodeURIComponent(b.id)}/cover/retry`,{method:'POST'});toast('Online cover lookup queued.');pollBookCoverJob(b.id)}catch(e){toast(e.message)}}); + $('#findCoverCandidates')?.addEventListener('click',async e=>{const button=e.currentTarget;button.disabled=true;button.textContent='Searching catalogues…';try{const d=await api(`/api/books/${encodeURIComponent(b.id)}/cover/candidates`,{method:'POST'});state.coverChooserBookId=b.id;toast(d.candidates?.length?`Found ${d.candidates.length} cover option${d.candidates.length===1?'':'s'}.`:'No useful alternatives found.');await openBook(b.id,{push:false})}catch(error){toast(error.message);button.disabled=false;button.textContent='Find alternatives'}}); + $$('[data-cover-candidate]').forEach(button=>button.addEventListener('click',async()=>{try{await api(`/api/books/${encodeURIComponent(b.id)}/cover/candidates/${encodeURIComponent(button.dataset.coverCandidate)}`,{method:'PUT'});state.coverChooserBookId=null;toast('Cover selected.');await refresh();await openBook(b.id,{push:false})}catch(error){toast(error.message)}})); + $$('[data-cover-history]').forEach(button=>button.addEventListener('click',async()=>{try{await api(`/api/books/${encodeURIComponent(b.id)}/cover/history/${encodeURIComponent(button.dataset.coverHistory)}`,{method:'PUT'});state.coverChooserBookId=null;toast('Previous cover restored.');await refresh();await openBook(b.id,{push:false})}catch(error){toast(error.message)}})); + const coverInput=$('#manualCoverInput'); + $('#uploadBookCover')?.addEventListener('click',()=>coverInput?.click()); + coverInput?.addEventListener('change',async()=>{ + const file=coverInput.files?.[0];if(!file)return; + if(file.size>4*1024*1024){coverInput.value='';return toast('Cover images must be 4 MB or smaller.')} + try{await api(`/api/books/${encodeURIComponent(b.id)}/cover`,{method:'PUT',headers:{'Content-Type':file.type||'application/octet-stream','X-Kovi-Filename':file.name},body:file});state.coverChooserBookId=null;stopBookCoverPoll();toast('Cover uploaded.');await refresh();await openBook(b.id,{push:false})}catch(e){toast(e.message)}finally{coverInput.value=''} + }); +} + +function routeFromLocation(){ + const book=/^\/books\/([^/]+)$/.exec(location.pathname);if(book)return openBook(decodeURIComponent(book[1]),{push:false}); + if(location.pathname==='/library')return switchView('library',{push:false}); + if(location.pathname==='/calendar')return switchView('calendar',{push:false}); + if(location.pathname==='/devices')return switchView('devices',{push:false}); + if(location.pathname==='/status')return switchView('status',{push:false}); + return switchView('home',{push:false}); +} + +$$('[data-view]').forEach(b=>b.addEventListener('click',()=>switchView(b.dataset.view))); +$$('[data-view-jump]').forEach(b=>b.addEventListener('click',()=>switchView(b.dataset.viewJump))); +$('[data-route-view]')?.addEventListener('click',e=>{e.preventDefault();switchView(e.currentTarget.dataset.routeView)}); +$('#bookBack').addEventListener('click',()=>switchView('library')); +$('#calendarPrev').addEventListener('click',()=>moveCalendarMonth(-1));$('#calendarNext').addEventListener('click',()=>moveCalendarMonth(1));$('#calendarToday').addEventListener('click',()=>{state.calendarSelectedDay=localDateKey(new Date());loadCalendar(new Date(),state.calendarSelectedDay)}); +$('#calendarDateForm').addEventListener('submit',e=>{e.preventDefault();const day=$('#calendarDateInput').value;if(!day)return toast('Choose a date to jump to.');jumpToCalendarDate(day)}); +window.addEventListener('popstate',routeFromLocation); +let chartResizeTimer=null;window.addEventListener('resize',()=>{clearTimeout(chartResizeTimer);chartResizeTimer=setTimeout(()=>{if(state.dashboard?.range&&state.books.length&&$('#homeView').classList.contains('active')){renderHeatmap(state.dashboard.days||[],state.dashboard.range);renderLineChart(state.dashboard.days||[],state.dashboard.range,state.dashboard)}},120)}); +document.addEventListener('click',e=>{ + const day=e.target.closest('.calendar-day');if(day){const key=day.dataset.day;if(state.calendar?.range&&(keystate.calendar.range.to))loadCalendar(parseLocalDateKey(key),key);else renderCalendarDetail(key);return} + const calendarBook=e.target.closest('[data-calendar-book-id]');if(calendarBook){openBook(calendarBook.dataset.calendarBookId);return} + const card=e.target.closest('.book-card');if(!card||!card.dataset.bookId)return;e.preventDefault();openBook(card.dataset.bookId) +}); + +$$('.range-button').forEach(button=>button.addEventListener('click',()=>{ + const key=button.dataset.range; + if(key==='custom'){ + state.rangeKey='custom';$('#customRange').classList.remove('hidden');renderRangeUi(state.dashboard?.range||presetRange('year'));$('#customFrom').focus();return; + } + $('#customRange').classList.add('hidden');applyDashboardRange(key,presetRange(key)); +})); +$('#customRange').addEventListener('submit',e=>{ + e.preventDefault();const from=$('#customFrom').value,to=$('#customTo').value; + if(!from||!to)return toast('Choose both custom dates.'); + if(from>to)return toast('The start date must be before the end date.'); + applyDashboardRange('custom',{from,to}); +}); + +const importDialog=$('#importDialog'),fileInput=$('#fileInput'); +function openImport(){importDialog.showModal();$('#importResult').classList.add('hidden');$('#importProgress').classList.add('hidden')} +$('#importButton').addEventListener('click',openImport);$$('[data-open-import]').forEach(b=>b.addEventListener('click',openImport));fileInput.addEventListener('change',()=>fileInput.files[0]&&uploadFile(fileInput.files[0])); +const dz=$('#dropZone');['dragenter','dragover'].forEach(e=>dz.addEventListener(e,x=>{x.preventDefault();dz.classList.add('drag')}));['dragleave','drop'].forEach(e=>dz.addEventListener(e,x=>{x.preventDefault();dz.classList.remove('drag')}));dz.addEventListener('drop',e=>{const f=e.dataTransfer.files[0];if(f)uploadFile(f)}); +async function ensureCsrf(force=false){if(state.csrf&&!force)return state.csrf;const session=await api('/api/session',{},false);state.csrf=session.csrf;return state.csrf} +async function uploadFile(file){ + const p=$('#importProgress'),bar=$('#progressBar'),txt=$('#progressText'),result=$('#importResult');p.classList.remove('hidden');result.classList.add('hidden');bar.style.width='8%';txt.textContent=`Preparing ${file.name}…`; + const send=async(csrf,retried=false)=>{txt.textContent=`Uploading ${file.name}…`;const xhr=new XMLHttpRequest();xhr.open('PUT','/api/import/sqlite');xhr.setRequestHeader('Content-Type','application/vnd.sqlite3');xhr.setRequestHeader('X-Kovi-Filename',file.name);xhr.setRequestHeader('X-Kovi-CSRF',csrf);xhr.upload.onprogress=e=>{if(e.lengthComputable)bar.style.width=`${Math.max(8,Math.round(e.loaded/e.total*82))}%`};xhr.onload=async()=>{bar.style.width='100%';let data={};try{data=JSON.parse(xhr.responseText)}catch{};if(xhr.status===403&&!retried&&data.error==='Missing browser request token.'){try{bar.style.width='8%';const fresh=await ensureCsrf(true);return send(fresh,true)}catch(error){txt.textContent='Upload failed';result.classList.remove('hidden');result.textContent=error.message||'Could not refresh the browser session.';return}}if(xhr.status>=200&&xhr.status<300){txt.textContent='Import complete';result.classList.remove('hidden');result.innerHTML=`${esc(data.message||'Import complete.')}
${data.booksSeen||0} books found · ${data.newBooks||0} new${data.excludedBooks?` · ${data.excludedBooks} KOReader manual excluded`:''}
${data.sessionsSeen||0} reading rows · ${data.newSessions||0} new${data.newBooks?'
Covers are being matched automatically.':''}`;await refresh();setTimeout(refresh,3000)}else{txt.textContent='Import failed';result.classList.remove('hidden');result.textContent=data.error||'Upload failed.'}};xhr.onerror=()=>{txt.textContent='Upload failed';result.classList.remove('hidden');result.textContent='Could not reach kovi.'};xhr.send(file)}; + try{const csrf=await ensureCsrf(true);await send(csrf)}catch(error){txt.textContent='Upload failed';result.classList.remove('hidden');result.textContent=error.message||'Could not prepare the upload.'} +} + +$('#bookSearch').addEventListener('input',e=>{state.libraryQuery=e.target.value;renderLibrary()}); +$('#bookSort').addEventListener('change',e=>{state.librarySort=LIBRARY_SORTS.includes(e.target.value)?e.target.value:'recent';localStorage.setItem('kovi-library-sort',state.librarySort);renderLibrary()}); +$('#bookFilter').addEventListener('change',e=>{state.libraryFilter=LIBRARY_FILTERS.includes(e.target.value)?e.target.value:'all';localStorage.setItem('kovi-library-filter',state.libraryFilter);renderLibrary()}); +const pairOriginHint=$('#pairOriginHint');if(pairOriginHint){const h=location.hostname;pairOriginHint.textContent=(h==='localhost'||h==='127.0.0.1'||h==='0.0.0.0')?'You opened kovi locally, so use this computer’s LAN IP instead (for example http://192.168.1.23:3000).':`This page is at ${location.origin}; if your KOReader can reach that hostname/address, use it.`} +const pairDialog=$('#pairDialog'); +function stopPairPoll(){if(state.pairPoll){clearTimeout(state.pairPoll);state.pairPoll=null}} +async function pollPairing(requestId){ + try{ + const status=await api(`/api/pairing-codes/${encodeURIComponent(requestId)}`); + if(status.status==='paired'){stopPairPoll();$('#pairStatusText').textContent='Paired successfully.';await loadDevices();pairDialog.close();toast(`Paired ${status.device?.name||status.device?.model||'KOReader'} successfully.`);return} + if(status.status==='expired'){stopPairPoll();$('#pairStatusText').textContent='This code expired. Generate another one.';return} + state.pairPoll=setTimeout(()=>pollPairing(requestId),1500); + }catch{state.pairPoll=setTimeout(()=>pollPairing(requestId),2500)} +} +$('#pairButton').addEventListener('click',()=>{stopPairPoll();pairDialog.showModal();$('#pairCodeBox').classList.add('hidden')}); +pairDialog.addEventListener('close',stopPairPoll); +$('#generatePair').addEventListener('click',async()=>{try{stopPairPoll();const d=await api('/api/pairing-codes',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({label:$('#pairLabel').value})});$('#pairCode').textContent=d.code;$('#pairExpiry').textContent='Expires in 10 minutes · one use only';$('#pairStatusText').textContent='Waiting for your KOReader…';$('#pairCodeBox').classList.remove('hidden');pollPairing(d.requestId)}catch(e){toast(e.message)}}); +async function loadDevices(){try{const d=await api('/api/devices');$('#deviceList').innerHTML=d.devices.map(x=>`
▤
${esc(x.name||x.model||'KOReader')}${x.needs_update&&!x.revoked_at?'Plugin update needed':''}
${esc(x.model||x.id)} · plugin ${esc(x.plugin_version||'unknown')} · ${x.last_seen_at?'last synced '+new Date(x.last_seen_at).toLocaleString():'paired, not synced yet'}${x.revoked_at?' · revoked':''}
${x.needs_update&&!x.revoked_at?`
${x.supports_cover_sync?'':'Embedded covers and ISBN enrichment require plugin '+esc(x.min_cover_plugin_version)+'. '}${x.supports_incremental_sync?'':'Incremental statistics and annotation sync require plugin '+esc(x.min_incremental_plugin_version)+'. '}${x.plugin_version&&x.plugin_version!==x.latest_plugin_version?'Update to the current plugin for the latest sync improvements.':''} Download current plugin ${esc(x.latest_plugin_version)}.
`:''}
${x.revoked_at?'':``}
`).join('')||'

No KOReader devices paired yet.

';$$('[data-revoke]').forEach(b=>b.addEventListener('click',async()=>{await api(`/api/devices/${encodeURIComponent(b.dataset.revoke)}/revoke`,{method:'POST'});toast('Device access revoked.');loadDevices()}))}catch(e){toast(e.message)}} + +const fmtBytes=value=>{let n=Math.max(0,Number(value||0));const units=['B','KB','MB','GB'];let i=0;while(n>=1024&&i`
${x[0]}${x[1]}${x[2]}
`).join(''); + $('#statusSyncs').innerHTML=(d.recentSyncs||[]).slice(0,3).map(x=>`
${esc(x.device_name||x.device_model||'KOReader')}${new Date(x.created_at).toLocaleString()} · ${esc(x.mode)} sync · cursor ${Number(x.cursor_after||0).toLocaleString()}
${Number(x.new_sessions||0)} new / ${Number(x.sessions_seen||0)} sent
${Number(x.annotation_sets||0)} changed annotation book${Number(x.annotation_sets||0)===1?'':'s'}
`).join('')||'

No plugin syncs recorded yet.

'; + $('#statusImports').innerHTML=(d.recentImports||[]).slice(0,3).map(x=>`
${esc(x.filename||x.kind||'Import')}${new Date(x.created_at).toLocaleString()} · ${fmtBytes(x.file_size)}
${Number(x.new_books||0)} new books
${Number(x.new_sessions||0)} new sessions${x.warnings?.length?` · ${x.warnings.length} warning${x.warnings.length===1?'':'s'}`:''}
`).join('')||'

No manual imports recorded yet.

'; +} +async function loadStatus({feedback=false}={}){ + const button=$('#refreshStatus');const original=button?.textContent||'Refresh'; + if(button){button.disabled=true;button.textContent='Refreshing…'} + try{ + state.status=await api(`/api/status?fresh=${Date.now()}`,{cache:'no-store'});renderStatus(); + if(feedback)toast(`Status refreshed at ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}.`); + }catch(e){toast(e.message)} + finally{if(button){button.disabled=false;button.textContent=original}} +} +$('#refreshStatus')?.addEventListener('click',()=>loadStatus({feedback:true})); + +$('#retryCovers').addEventListener('click',async()=>{try{const d=await api('/api/covers/retry',{method:'POST'});toast(d.queued?`${d.queued} cover${d.queued===1?'':'s'} queued.`:'No missing covers to retry.');state.books.forEach(b=>{if(b.cover_status==='none'||b.cover_status==='error')b.cover_status='pending'});coverPoll=setTimeout(()=>refresh().catch(()=>{}),3000)}catch(e){toast(e.message)}}); +function setTheme(dark){document.documentElement.classList.toggle('dark',dark);localStorage.setItem('kovi-theme',dark?'dark':'light');const meta=$('#themeColor');if(meta)meta.setAttribute('content',dark?'#0c1320':'#f6f3ec');const button=$('#themeButton');button.textContent=dark?'☀':'◐';button.setAttribute('aria-label',dark?'Switch to light theme':'Switch to dark theme');button.setAttribute('aria-pressed',String(dark))} +const savedTheme=localStorage.getItem('kovi-theme');setTheme(savedTheme==='dark'||(!savedTheme&&matchMedia('(prefers-color-scheme: dark)').matches)); +$('#themeButton').addEventListener('click',()=>setTheme(!document.documentElement.classList.contains('dark'))); + +api('/api/session').then(s=>{state.csrf=s.csrf;return refresh()}).then(routeFromLocation).catch(e=>toast(e.message)); diff --git a/public/favicon.png b/public/favicon.png new file mode 100644 index 0000000..4b82d09 Binary files /dev/null and b/public/favicon.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..ada80d0 --- /dev/null +++ b/public/index.html @@ -0,0 +1,125 @@ + + + + + + + + kovi — Your reading, beautifully understood + + + + + +
+ + +
+
+

Your reading room

Good to see you.

+
+
+ +
+ + +
+
+ +
+
Date rangeLast year
+
+ + + + + + +
+
+ + +
+

Last year

Reading rhythm

LessMore
+
MonWedFri

+
+ +
+

Last year

Daily reading time

+
+
Reading time0m
Reading days0
+
+

+
+
+
+
+ +

Library

Every book you’ve spent time with.

+ +
+
+

Reading calendar

What you read, day by day.

+
+ +
+
+
+
+
+

This month

+ +
+
+ +
+
+ +
+ +

KOReader sync

Your readers, securely paired.

Each device receives its own revocable token. Pairing codes expire after ten minutes.

Download plugin

Plugin setup

Install the kovi plugin

  1. Copy plugins/kovi.koplugin into your KOReader plugins folder.
  2. Restart KOReader and open Tools → kovi.
  3. Enter this server’s URL, then the pairing code generated above.
  4. Tap Sync now. Reading statistics and highlight text are synced together.
+ +
+

System & data

kovi status.

Sync health, storage, recent imports, and portable copies of your reading data.

+
+
+

KOReader

Recent syncs

+

Database

Recent imports

+
+ +
+
+
+ +

Manual import

Upload your KOReader database

Choose statistics.sqlite or statistics.sqlite3 from KOReader’s settings folder. kovi opens it read-only and deletes the temporary upload after import.

+ +

Secure pairing

Pair your KOReader

Generate a short-lived code, then enter it in the kovi plugin on your reader. This page updates automatically when the reader pairs.

Important: the reader must use a network-reachable address for this computer. Do not enter localhost, 127.0.0.1, or 0.0.0.0.
+ +
+ + + diff --git a/public/kovi-logo.png b/public/kovi-logo.png new file mode 100644 index 0000000..a847d4a Binary files /dev/null and b/public/kovi-logo.png differ diff --git a/public/kovi-mark.png b/public/kovi-mark.png new file mode 100644 index 0000000..6f19ed8 Binary files /dev/null and b/public/kovi-mark.png differ diff --git a/public/kovi-plugin.zip b/public/kovi-plugin.zip new file mode 100644 index 0000000..631d295 Binary files /dev/null and b/public/kovi-plugin.zip differ diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..b3bdf80 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,101 @@ +:root{--paper:#f6f3ec;--paper-2:#eae6db;--ink:#111b29;--muted:#5f6c85;--line:#dcd8cc;--primary:#152840;--accent:#077880;--accent-bright:#00adb5;--heatmap-empty:#e7e2d6;--panel:rgba(255,255,255,.62);--shadow:0 20px 50px rgba(17,27,41,.09);--radius:24px;--sidebar:228px;--font-body:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--font-display:ui-rounded,"SF Pro Rounded","Hiragino Maru Gothic ProN","Nunito","Quicksand","Segoe UI",system-ui,sans-serif;font-family:var(--font-body);color:var(--ink);background:var(--paper)} +:root.dark{--paper:#0c1320;--paper-2:#121b2a;--ink:#e9eef5;--muted:#94a3b8;--line:#243146;--primary:#2bb8c1;--accent:#5fd3da;--heatmap-empty:#1c2637;--panel:#131c2b;--shadow:0 20px 50px rgba(0,0,0,.4)} +:root.dark .sidebar{background:#0a1019} +*{box-sizing:border-box}body{margin:0;background:var(--paper);min-height:100vh}.app-shell{display:grid;grid-template-columns:var(--sidebar) 1fr;min-height:100vh}.sidebar{position:fixed;inset:0 auto 0 0;width:var(--sidebar);padding:28px 20px;display:flex;flex-direction:column;border-right:1px solid var(--line);background:color-mix(in srgb,var(--paper) 92%,transparent);backdrop-filter:blur(20px)}.brand{display:flex;gap:11px;align-items:center;text-decoration:none;color:var(--ink);font-family:var(--font-display);font-size:23px;font-weight:800;letter-spacing:-.02em;margin:0 8px 36px}.brand-mark{display:grid;place-items:center;flex:none;width:36px;height:36px;border-radius:12px;background:#fdfbf4;box-shadow:0 4px 14px color-mix(in srgb,var(--primary) 20%,transparent)}.brand-mark img{display:block;width:30px;height:30px;object-fit:contain}.nav{display:grid;gap:6px}.nav-item{appearance:none;border:0;background:transparent;color:var(--muted);font:inherit;font-weight:650;text-align:left;padding:12px 13px;border-radius:12px;cursor:pointer;display:flex;gap:12px;align-items:center}.nav-item:hover,.nav-item.active{color:var(--ink);background:var(--panel)}.nav-item.active{box-shadow:inset 3px 0 var(--primary)}.sidebar-foot{margin-top:auto;color:var(--muted);font-size:12px;display:flex;align-items:center;gap:8px;padding:10px}.privacy-dot{width:7px;height:7px;border-radius:50%;background:var(--accent-bright);box-shadow:0 0 0 4px color-mix(in srgb,var(--accent-bright) 18%,transparent)}.main{grid-column:2;padding:38px clamp(28px,4vw,64px) 70px;max-width:1500px;width:100%;margin:0 auto}.topbar{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin-bottom:42px}.topbar h1,.section-heading h2,.library-toolbar h2,.devices-header h2,.empty-state h2,.modal h2{font-family:var(--font-display);font-weight:500;letter-spacing:-.025em}.topbar h1{font-size:clamp(34px,4vw,52px);margin:3px 0 0}.eyebrow{text-transform:uppercase;letter-spacing:.17em;font-size:11px;font-weight:800;color:var(--muted);margin:0}.top-actions{display:flex;gap:10px}.primary,.icon-button,.text-button{border:0;font:inherit;cursor:pointer}.secondary-link{display:inline-flex;align-items:center;text-decoration:none;color:var(--ink);font-weight:700;border:1px solid var(--line);background:var(--panel);padding:11px 14px;border-radius:13px}.primary{background:var(--primary);color:var(--paper);padding:12px 17px;border-radius:13px;font-weight:750;box-shadow:0 8px 18px color-mix(in srgb,var(--primary) 24%,transparent)}.primary:hover{transform:translateY(-1px)}.primary.large{padding:15px 22px}.primary.full{width:100%}.icon-button{width:44px;height:44px;border-radius:13px;background:var(--panel);border:1px solid var(--line);color:var(--ink)}.text-button{background:none;color:var(--accent);font-weight:700}.view{display:none}.view.active{display:block}.hidden{display:none!important}.metric-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:44px}.metric{padding:21px;border:1px solid var(--line);border-radius:18px;background:var(--panel)}.metric span{display:block;color:var(--muted);font-size:12px;margin-bottom:12px}.metric strong{display:block;font-family:var(--font-display);font-size:29px;font-weight:500}.metric small{color:var(--muted)}.section-block{margin:8px 0 46px}.section-heading{display:flex;align-items:flex-end;justify-content:space-between;margin-bottom:18px}.section-heading h2{margin:4px 0 0;font-size:28px}.section-heading.compact h3{margin:5px 0 0;font-family:var(--font-display);font-size:22px}.shelf{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:18px}.book-card{min-width:0;cursor:pointer}.book-card:hover .cover{transform:translateY(-3px);box-shadow:0 16px 32px rgba(0,0,0,.18)}.cover{aspect-ratio:2/3;transition:.18s ease;border-radius:7px 10px 10px 7px;overflow:hidden;background:linear-gradient(145deg,#2b3f5e,#101a29);box-shadow:0 11px 25px rgba(0,0,0,.14);position:relative;margin-bottom:12px}.cover::after{content:"";position:absolute;inset:0;border-left:1px solid rgba(255,255,255,.16);box-shadow:inset 8px 0 13px rgba(0,0,0,.09);pointer-events:none}.cover img{width:100%;height:100%;object-fit:cover;display:block}.cover-placeholder{height:100%;display:flex;flex-direction:column;justify-content:flex-end;padding:14px;color:#fdfbf4}.cover-placeholder b{font-family:var(--font-display);font-size:16px;line-height:1.05}.cover-placeholder span{font-size:10px;opacity:.75;margin-top:7px}.book-card h3{font-size:14px;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;margin:0 0 4px}.book-card p{font-size:12px;color:var(--muted);margin:0}.book-progress{display:block;width:100%;height:3px;margin-top:9px;border:0;border-radius:9px;overflow:hidden;background:var(--line);appearance:none}.book-progress::-webkit-progress-bar{background:var(--line)}.book-progress::-webkit-progress-value{background:var(--accent)}.book-progress::-moz-progress-bar{background:var(--accent)}.insight-grid{display:grid;grid-template-columns:1.5fr 1fr;gap:16px}.panel{border:1px solid var(--line);background:var(--panel);border-radius:var(--radius);padding:24px}.muted{color:var(--muted);line-height:1.6}.quote-panel{display:flex;flex-direction:column;justify-content:center}.quote-panel blockquote{font:500 26px/1.35 var(--font-display);margin:16px 0}.library-toolbar,.devices-header{display:flex;justify-content:space-between;align-items:flex-end;gap:20px;margin-bottom:30px}.library-toolbar h2,.devices-header h2{font-size:34px;margin:5px 0 0}.library-actions{display:flex;align-items:center;gap:16px}.search{display:flex;align-items:center;gap:8px;border:1px solid var(--line);background:var(--panel);border-radius:13px;padding:0 13px}.search input{width:240px;border:0;outline:0;background:transparent;color:var(--ink);padding:12px 0;font:inherit}.book-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:28px 18px}.device-list{display:grid;gap:10px;margin-bottom:24px}.device-row{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);background:var(--panel);padding:16px 18px;border-radius:16px}.device-id{display:flex;align-items:center;gap:13px}.device-icon{display:grid;place-items:center;width:42px;height:50px;border:2px solid var(--accent);border-radius:6px}.danger{border:1px solid var(--line);background:transparent;color:var(--muted);padding:8px 11px;border-radius:10px;cursor:pointer}.setup-panel{max-width:760px}.setup-panel li{margin:9px 0;line-height:1.5}.empty-state{max-width:700px;margin:8vh auto;text-align:center}.empty-state h2{font-size:48px;margin:15px 0}.empty-state>p:not(.eyebrow){color:var(--muted);font-size:17px;line-height:1.7}.empty-state small{display:block;margin-top:16px;color:var(--muted)}.empty-art{margin:0 auto 26px;width:min(340px,80%)}.empty-art img{display:block;width:100%;height:auto;filter:drop-shadow(0 16px 28px rgba(9,15,25,.22))}:root.dark .empty-art{padding:18px 20px;border-radius:24px;background:#fdfbf4;box-shadow:0 24px 60px rgba(0,0,0,.4)}:root.dark .empty-art img{filter:none}.modal{border:1px solid var(--line);border-radius:26px;background:var(--paper);color:var(--ink);padding:30px;max-width:600px;width:calc(100% - 28px);box-shadow:0 30px 100px rgba(0,0,0,.25)}.modal.small{max-width:470px}.modal::backdrop{background:rgba(8,14,24,.55);backdrop-filter:blur(6px)}.modal-close{position:absolute;right:17px;top:14px;border:0;background:transparent;color:var(--muted);font-size:28px;cursor:pointer}.modal h2{font-size:31px;margin:6px 0 9px}.drop-zone{display:flex;flex-direction:column;align-items:center;justify-content:center;border:1.5px dashed var(--line);background:var(--panel);border-radius:20px;min-height:190px;margin-top:22px;cursor:pointer}.drop-zone:hover,.drop-zone.drag{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 6%,var(--panel))}.drop-zone input{display:none}.drop-icon{font-size:29px;margin-bottom:10px}.drop-zone span:last-child{color:var(--muted);font-size:13px;margin-top:5px}.progress-wrap{margin-top:20px}.progress{height:7px;border-radius:10px;overflow:hidden;background:var(--paper-2)}.progress i{display:block;width:0;height:100%;background:var(--accent);transition:width .25s}.result{margin-top:18px;padding:14px;border-radius:14px;background:var(--panel);border:1px solid var(--line);line-height:1.6}.field{display:grid;gap:8px;margin:20px 0;font-size:13px;font-weight:700}.field input{padding:12px;border:1px solid var(--line);border-radius:11px;background:var(--panel);color:var(--ink);font:inherit}.pair-code{text-align:center;margin-top:20px;border:1px solid var(--line);border-radius:16px;padding:20px;background:var(--panel)}.pair-code span,.pair-code small{display:block;color:var(--muted)}.pair-code strong{display:block;font:600 34px/1.3 ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.13em;margin:5px 0}.toast{position:fixed;right:22px;bottom:22px;transform:translateY(120px);opacity:0;transition:.25s;background:var(--ink);color:var(--paper);padding:12px 15px;border-radius:12px;box-shadow:var(--shadow);z-index:20}.toast.show{transform:translateY(0);opacity:1}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--paper-2);padding:.14em .36em;border-radius:5px} +@media(max-width:1100px){:root{--sidebar:84px}.brand span:last-child,.nav-item:not(.active){font-size:0}.nav-item span{font-size:18px}.nav-item.active{font-size:0}.nav-item.active span{font-size:18px}.sidebar-foot span{display:none}.shelf{grid-template-columns:repeat(4,1fr)}.book-grid{grid-template-columns:repeat(5,1fr)}.metric-grid{grid-template-columns:repeat(2,1fr)}} +@media(max-width:760px){.app-shell{display:block}.sidebar{position:fixed;z-index:10;inset:auto 10px 10px;width:auto;height:62px;flex-direction:row;align-items:center;padding:8px;border:1px solid var(--line);border-radius:18px;box-shadow:var(--shadow)}.brand,.sidebar-foot{display:none}.nav{display:flex;width:100%;gap:4px}.nav-item,.nav-item.active,.nav-item:not(.active){justify-content:center;font-size:0;width:58px}.nav-item span,.nav-item.active span,.nav-item:not(.active) span{font-size:19px}.main{padding:24px 18px 100px}.topbar{align-items:flex-start}.topbar h1{font-size:36px}.top-actions .icon-button{display:none}.primary{padding:11px 12px}.metric-grid{grid-template-columns:1fr 1fr}.shelf{grid-template-columns:repeat(3,1fr);gap:12px}.shelf .book-card:nth-child(n+4){display:none}.insight-grid{grid-template-columns:1fr}.book-grid{grid-template-columns:repeat(3,1fr);gap:24px 12px}.library-toolbar,.devices-header{align-items:flex-start;flex-direction:column}.search{width:100%}.search input{width:100%}.empty-state h2{font-size:36px}.heatmap{gap:5px}} + +.book-detail{display:grid;grid-template-columns:180px 1fr;gap:26px}.book-detail .cover{margin:0}.book-detail h2{font:500 34px/1.08 var(--font-display);margin:6px 0 7px}.book-meta{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin:22px 0}.book-meta div{padding:12px;background:var(--panel);border:1px solid var(--line);border-radius:13px}.book-meta span{display:block;font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);margin-bottom:4px}.book-meta strong{font-size:14px}.session-list{display:grid;gap:7px;max-height:240px;overflow:auto}.session-row{display:flex;justify-content:space-between;gap:15px;padding:9px 0;border-bottom:1px solid var(--line);font-size:13px}.session-row span:last-child{color:var(--muted)} +@media(max-width:620px){.book-detail{grid-template-columns:110px 1fr;gap:16px}.book-detail h2{font-size:25px}.book-meta{grid-template-columns:1fr 1fr}.library-actions{width:100%;align-items:stretch;flex-direction:column}.devices-header .top-actions{width:100%}.devices-header .top-actions>*{flex:1;justify-content:center}} + +.pair-help{margin:1rem 0;padding:.85rem 1rem;border:1px solid var(--line);border-radius:14px;background:var(--paper-2);font-size:.92rem;line-height:1.45}.pair-help strong{font-weight:700}.pair-help code{font-size:.88em} + +/* kovi 0.2 */ +.book-card{display:block;color:inherit;text-decoration:none}.rhythm-block{margin-top:0}.heatmap-panel{padding:22px 24px 18px;overflow:hidden}.heatmap-layout{display:grid;grid-template-columns:32px minmax(0,1fr);gap:8px;align-items:start;overflow-x:auto;padding-bottom:8px}.heatmap-weekdays{display:grid;grid-template-rows:repeat(7,11px);gap:4px;padding-top:0;color:var(--muted);font-size:9px;line-height:11px}.heatmap{display:grid;grid-template-columns:none;grid-template-rows:repeat(7,11px);grid-auto-flow:column;grid-auto-columns:11px;gap:4px;margin:0;width:max-content;min-width:100%;justify-content:space-between}.heatmap .day,.heatmap-legend .day{width:11px;height:11px;aspect-ratio:auto;border-radius:2px;background:var(--heatmap-empty);display:block}.heatmap .day.future{opacity:.28}.heatmap .day.l1,.heatmap-legend .day.l1{background:color-mix(in srgb,var(--accent) 25%,var(--paper))}.heatmap .day.l2,.heatmap-legend .day.l2{background:color-mix(in srgb,var(--accent) 45%,var(--paper))}.heatmap .day.l3,.heatmap-legend .day.l3{background:color-mix(in srgb,var(--accent) 68%,var(--paper))}.heatmap .day.l4,.heatmap-legend .day.l4{background:var(--accent)}.heatmap-months{margin:0 0 7px 40px;display:grid;grid-template-columns:repeat(12,1fr);min-width:760px;color:var(--muted);font-size:10px}.heatmap-legend{display:flex;align-items:center;gap:4px;color:var(--muted);font-size:10px}.heatmap-legend .day{flex:none} +.book-card .cover{position:relative}.book-read-badge{position:absolute;right:8px;bottom:8px;padding:4px 8px;border-radius:999px;background:var(--primary);color:var(--paper);box-shadow:0 3px 12px rgba(0,0,0,.2);font-size:9px;font-weight:850;letter-spacing:.06em;text-transform:uppercase} +.back-button{border:0;background:transparent;color:var(--accent);font:700 13px/1 inherit;padding:0;margin:0 0 24px;cursor:pointer}.book-page{display:grid;grid-template-columns:minmax(190px,220px) minmax(0,760px);gap:44px;align-items:start;max-width:1040px}.book-page aside{position:sticky;top:28px}.book-page-cover{width:100%;margin:0 0 15px}.book-page-content h2{font:500 44px/1.08 var(--font-display);margin:5px 0 9px}.book-byline{color:var(--muted);font-size:16px;margin:0 0 26px}.secondary-action{border:1px solid var(--line);background:var(--panel);color:var(--ink);padding:11px 14px;border-radius:12px;font-weight:700;cursor:pointer}.secondary-action:hover{border-color:var(--accent)}.book-highlights{margin-top:36px}.annotation-list{display:grid;gap:12px}.annotation-card{border:1px solid var(--line);background:var(--panel);border-radius:16px;padding:18px 20px}.annotation-top{display:flex;justify-content:space-between;gap:12px;align-items:center;color:var(--muted);font-size:11px}.annotation-kind{color:var(--accent);text-transform:uppercase;letter-spacing:.1em;font-weight:800}.annotation-card blockquote{margin:14px 0 8px;font:500 18px/1.55 var(--font-display)}.annotation-note{margin:12px 0 6px;padding:11px 13px;border-left:3px solid var(--accent);background:var(--paper-2);border-radius:0 10px 10px 0;line-height:1.55}.annotation-location{margin:12px 0 0;color:var(--muted);font-size:11px}.annotation-empty{padding:18px 20px}.annotation-empty strong{display:block;margin-bottom:7px}.pair-code #pairStatusText{margin:12px 0 0}.full{width:100%} +@media(max-width:760px){.heatmap-panel{padding:18px 14px}.heatmap-months{margin-left:40px}.heatmap-legend{display:none}.book-page{grid-template-columns:110px minmax(0,1fr);gap:18px}.book-page aside{position:static}.book-page-content h2{font-size:29px}.book-page .book-meta{grid-template-columns:1fr 1fr}.annotation-card{padding:15px}.annotation-card blockquote{font-size:16px}.book-page aside .secondary-action{font-size:11px;padding:9px}} +@media(max-width:520px){.book-page{display:block}.book-page aside{display:grid;grid-template-columns:100px 1fr;align-items:end;gap:14px;margin-bottom:24px}.book-page-cover{margin:0}.book-page aside .secondary-action{margin-bottom:4px}.heatmap-layout{grid-template-columns:26px minmax(0,1fr)}.heatmap-months{margin-left:34px}} +.device-title-line{display:flex;align-items:center;gap:9px;flex-wrap:wrap}.status-badge{display:inline-flex;align-items:center;padding:3px 7px;border-radius:999px;font-size:10px;font-weight:800;letter-spacing:.03em}.status-badge.warning{background:color-mix(in srgb,#c98c35 16%,var(--panel));color:#9a641f;border:1px solid color-mix(in srgb,#c98c35 35%,var(--line))}.device-warning{margin-top:5px;font-size:12px;color:var(--muted)}.device-warning a{color:var(--accent);font-weight:750} +.reading-status{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:0 0 22px;padding:13px 15px;border:1px solid var(--line);border-radius:14px;background:var(--panel)}.reading-status>div:first-child{display:flex;align-items:center;gap:10px;min-width:0}.reading-status p{margin:0;color:var(--muted);font-size:12px;line-height:1.4}.status-badge.read-state{flex:none;border:1px solid var(--line);background:var(--paper-2);color:var(--muted)}.status-badge.read-state.complete{border-color:color-mix(in srgb,var(--accent) 38%,var(--line));background:color-mix(in srgb,var(--accent) 13%,var(--panel));color:var(--accent)}.reading-status-actions{display:flex;align-items:center;gap:10px;flex:none}.reading-status-actions .text-button{padding:7px 0;background:transparent;color:var(--accent);font-size:11px;font-weight:750} +@media(max-width:620px){.reading-status{align-items:flex-start;flex-direction:column}.reading-status>div:first-child{align-items:flex-start}.reading-status-actions{width:100%;justify-content:space-between}} + +/* Dashboard date filters and reading trend */ +.icon-button{display:grid;place-items:center;flex:none} +.range-toolbar{display:flex;align-items:center;justify-content:space-between;gap:18px;margin:-18px 0 28px;padding:12px 14px;border:1px solid var(--line);border-radius:16px;background:var(--panel)} +.range-current{display:grid;gap:2px;min-width:max-content}.range-current span{font-size:10px;text-transform:uppercase;letter-spacing:.13em;color:var(--muted);font-weight:800}.range-current strong{font-size:13px;font-weight:750} +.range-buttons{display:flex;align-items:center;gap:6px;min-width:0;overflow-x:auto;padding:1px;scrollbar-width:thin} +.range-button{appearance:none;border:1px solid transparent;background:transparent;color:var(--muted);font:inherit;font-size:12px;font-weight:700;white-space:nowrap;padding:8px 10px;border-radius:10px;cursor:pointer} +.range-button:hover{color:var(--ink);background:var(--paper-2)}.range-button.active{color:var(--ink);border-color:var(--line);background:var(--paper)} +.custom-range{display:flex;align-items:end;gap:10px;margin:-16px 0 28px;padding:14px;border:1px solid var(--line);border-radius:16px;background:var(--panel)}.custom-range label{display:grid;gap:6px;color:var(--muted);font-size:11px;font-weight:750}.custom-range input{min-width:150px;padding:9px 10px;border:1px solid var(--line);border-radius:10px;background:var(--paper);color:var(--ink);font:inherit}.custom-range .primary{padding:10px 14px} + +.heatmap-panel{overflow:hidden}.heatmap-layout{display:grid;grid-template-columns:32px minmax(0,1fr);gap:8px;align-items:start;overflow:hidden;padding-bottom:0}.heatmap-scroll{min-width:0;overflow-x:auto;padding-bottom:8px}.heatmap-months{margin:0 0 7px;display:grid;grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:11px;gap:4px;width:max-content;min-width:0;color:var(--muted);font-size:10px;line-height:11px;justify-content:start}.heatmap-months span{white-space:nowrap;overflow:visible}.heatmap{min-width:0;justify-content:start}.heatmap.stretched,.heatmap-months.stretched{min-width:100%;justify-content:space-between}.heatmap .day.outside{opacity:.22;background:var(--heatmap-empty)} + +.trend-panel{padding:22px 24px 18px}.trend-summary{display:flex;gap:34px;margin-bottom:12px}.trend-summary div{display:grid;gap:3px}.trend-summary span{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted);font-weight:800}.trend-summary strong{font:500 24px/1.2 var(--font-display)}.line-chart{width:100%;min-height:270px;overflow:hidden}.line-chart svg{display:block;width:100%;height:270px;overflow:visible}.chart-grid{stroke:var(--line);stroke-width:1}.chart-axis{fill:var(--muted);font:10px Inter,ui-sans-serif,system-ui,sans-serif}.chart-line{fill:none;stroke:var(--accent);stroke-width:2.5;stroke-linejoin:round;stroke-linecap:round}.chart-dot{fill:var(--paper);stroke:var(--accent);stroke-width:2}.chart-empty{min-height:220px;display:grid;place-items:center;color:var(--muted);font-size:13px;border-top:1px solid var(--line)}.trend-panel>.muted,.heatmap-panel>.muted{margin:12px 0 0;font-size:12px} + +@media(max-width:760px){ + .topbar{align-items:flex-start;gap:12px}.top-actions{gap:8px;flex:none}.top-actions .icon-button{display:grid}.topbar .primary{font-size:12px;padding:11px 10px}.icon-button{width:42px;height:42px} + .range-toolbar{display:block;margin-top:-20px;padding:10px 10px 9px}.range-current{display:flex;align-items:baseline;gap:8px;margin:0 4px 8px}.range-buttons{margin:0 -2px;padding:1px 2px 3px}.range-button{font-size:11px;padding:7px 9px} + .custom-range{align-items:stretch;flex-wrap:wrap}.custom-range label{flex:1 1 130px}.custom-range input{width:100%;min-width:0}.custom-range .primary{flex:1 1 100%;justify-content:center} + .heatmap-layout{grid-template-columns:30px minmax(0,1fr)}.heatmap-months{margin-left:0}.heatmap.stretched,.heatmap-months.stretched{min-width:0;justify-content:start} + .trend-panel{padding:18px 14px 14px}.trend-summary{gap:24px}.line-chart,.line-chart svg{min-height:250px;height:250px}.chart-axis{font-size:9px} +} +@media(max-width:520px){ + .topbar h1{font-size:31px}.topbar .primary{max-width:142px;line-height:1.15}.metric{padding:16px}.metric strong{font-size:25px}.metric small{font-size:11px} + .section-heading h2{font-size:25px}.trend-summary strong{font-size:22px}.heatmap-layout{grid-template-columns:26px minmax(0,1fr)} +} +@media(max-width:520px){.topbar{flex-wrap:wrap}.topbar>div:first-child{flex:1 1 150px;min-width:0}.top-actions{margin-left:auto}} + +/* Responsive, no-scroll reading heatmap. The cell size is based on a 53-week + reference year, so changing the filter reveals fewer columns instead of + zooming those columns to fill the panel. */ +.heatmap-scroll{min-width:0;max-width:100%;overflow:hidden!important;padding-bottom:0;--heat-weeks:53;--heat-gap:3px;--heat-cell:11px} +.heatmap{display:grid;width:max-content;max-width:100%;min-width:0;grid-template-columns:repeat(var(--heat-weeks),var(--heat-cell));grid-template-rows:repeat(7,var(--heat-cell));grid-auto-flow:column;grid-auto-columns:initial;column-gap:var(--heat-gap);row-gap:var(--heat-gap);justify-content:start;margin:0} +.heatmap.stretched{min-width:0;justify-content:start} +.heatmap .day{width:var(--heat-cell);height:var(--heat-cell);min-width:0;aspect-ratio:auto;border-radius:clamp(1px,18%,2px)} +.heatmap-months{display:grid;width:max-content;max-width:100%;min-width:0;grid-template-columns:repeat(var(--heat-weeks),var(--heat-cell));grid-template-rows:11px;grid-auto-flow:row;grid-auto-columns:initial;column-gap:var(--heat-gap);row-gap:0;justify-content:start;margin:0 0 7px;line-height:11px;overflow:visible} +.heatmap-months.stretched{min-width:0;justify-content:start} +.heatmap-months span{min-width:0;white-space:nowrap;overflow:visible} +.heatmap-weekdays{padding-top:18px;grid-template-rows:repeat(7,var(--heat-cell));gap:var(--heat-gap);line-height:var(--heat-cell)} + +/* Interactive reading trend */ +.line-chart{position:relative;touch-action:pan-y} +.chart-hit-area{fill:transparent;pointer-events:all;cursor:crosshair} +.chart-dot.key{cursor:pointer;pointer-events:all} +.chart-dot.key:focus{outline:none;stroke-width:4} +.chart-hover-guide{stroke:var(--muted);stroke-width:1;stroke-dasharray:3 4;opacity:.65;pointer-events:none} +.chart-hover-dot{fill:var(--accent);stroke:var(--paper);stroke-width:3;pointer-events:none} +.chart-tooltip{position:absolute;z-index:3;transform:translate(-50%,-100%);min-width:112px;max-width:190px;padding:8px 10px;border:1px solid var(--line);border-radius:10px;background:var(--ink);color:var(--paper);box-shadow:0 8px 24px rgba(0,0,0,.16);pointer-events:none;text-align:center} +.chart-tooltip strong,.chart-tooltip span{display:block}.chart-tooltip strong{font-size:11px;line-height:1.25}.chart-tooltip span{margin-top:3px;font-size:12px;font-weight:750} + +/* Reading calendar */ +.calendar-header{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:24px}.calendar-header h2{font:500 34px/1.08 var(--font-display);letter-spacing:-.025em;margin:5px 0 0}.calendar-header-actions{display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap;justify-content:flex-end}.calendar-date-search{display:grid;gap:5px}.calendar-date-search label{color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.08em;font-weight:800}.calendar-date-search-controls{display:flex;align-items:center;gap:7px}.calendar-date-search input{height:40px;min-width:155px;padding:0 10px;border:1px solid var(--line);border-radius:11px;background:var(--panel);color:var(--ink);font:inherit;color-scheme:light}:root.dark .calendar-date-search input{color-scheme:dark}.calendar-date-search .secondary-action{height:40px;padding:0 12px}.calendar-controls{display:flex;align-items:center;gap:8px}.calendar-arrow{width:40px;height:40px}.calendar-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,330px);gap:18px;align-items:start}.calendar-panel{padding:20px}.calendar-month-heading{display:flex;align-items:baseline;justify-content:space-between;gap:14px;margin-bottom:18px}.calendar-month-heading h3{font:500 25px/1.15 var(--font-display);margin:0}.calendar-month-heading .muted{font-size:12px}.calendar-weekday-row,.calendar-grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:6px}.calendar-weekday-row{margin:0 0 7px}.calendar-weekday-row span{padding:0 5px;color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.08em;font-weight:800}.calendar-day{appearance:none;min-width:0;min-height:92px;padding:8px;border:1px solid var(--line);border-radius:12px;background:var(--paper);color:var(--ink);font:inherit;text-align:left;cursor:pointer;display:flex;flex-direction:column;align-items:flex-start;gap:3px;transition:border-color .15s,transform .15s,background .15s}.calendar-day:hover{border-color:var(--accent);transform:translateY(-1px)}.calendar-day:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.calendar-day.outside{opacity:.35}.calendar-day.selected{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.calendar-day.has-reading.l1{background:color-mix(in srgb,var(--accent) 8%,var(--paper))}.calendar-day.has-reading.l2{background:color-mix(in srgb,var(--accent) 14%,var(--paper))}.calendar-day.has-reading.l3{background:color-mix(in srgb,var(--accent) 22%,var(--paper))}.calendar-day.has-reading.l4{background:color-mix(in srgb,var(--accent) 32%,var(--paper))}.calendar-day-number{font-size:11px;font-weight:800;line-height:1}.calendar-day.today .calendar-day-number{display:grid;place-items:center;min-width:20px;height:20px;margin:-4px 0 1px -4px;border-radius:50%;background:var(--primary);color:var(--paper)}.calendar-day-time{margin-top:auto;font:500 16px/1.1 var(--font-display)}.calendar-day-books{color:var(--muted);font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}.calendar-day-empty{margin:auto;color:var(--line)}.calendar-loading{grid-column:1/-1;padding:50px 10px;text-align:center}.calendar-detail{position:sticky;top:24px;padding:20px}.calendar-detail h3{font:500 24px/1.2 var(--font-display);margin:7px 0 10px}.calendar-detail-summary{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px;margin:17px 0}.calendar-detail-summary div{min-width:0;padding:9px;border:1px solid var(--line);border-radius:11px;background:var(--paper)}.calendar-detail-summary span{display:block;color:var(--muted);font-size:8px;text-transform:uppercase;letter-spacing:.08em;font-weight:800}.calendar-detail-summary strong{display:block;margin-top:4px;font:500 16px/1.2 var(--font-display)}.calendar-book-list{display:grid;gap:6px}.calendar-book-row{appearance:none;width:100%;display:grid;grid-template-columns:34px minmax(0,1fr) auto;gap:10px;align-items:center;padding:7px;border:0;border-radius:10px;background:transparent;color:var(--ink);font:inherit;text-align:left;cursor:pointer}.calendar-book-row:hover{background:var(--paper-2)}.calendar-book-row:focus-visible{outline:2px solid var(--accent);outline-offset:1px}.calendar-book-cover{width:34px;aspect-ratio:2/3;border-radius:4px;overflow:hidden;display:grid;place-items:center;background:var(--primary);color:var(--paper);font-family:var(--font-display)}.calendar-book-cover img{width:100%;height:100%;object-fit:cover}.calendar-book-copy{min-width:0}.calendar-book-copy strong,.calendar-book-copy small{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.calendar-book-copy strong{font-size:12px}.calendar-book-copy small{margin-top:2px;color:var(--muted);font-size:10px}.calendar-book-time{font-size:11px;font-weight:800;color:var(--accent)} + +@media(max-width:900px){.calendar-layout{grid-template-columns:1fr}.calendar-detail{position:static}.calendar-detail-summary{max-width:430px}} +@media(max-width:760px){.heatmap-weekdays{padding-top:18px}.calendar-header{align-items:flex-start;flex-direction:column}.calendar-header-actions{width:100%;justify-content:stretch}.calendar-date-search{width:100%}.calendar-date-search-controls{width:100%}.calendar-date-search input{flex:1;min-width:0;font-size:16px}.calendar-date-search .secondary-action{flex:0 0 auto}.calendar-controls{width:100%}.calendar-controls #calendarToday{flex:1}.calendar-panel{padding:14px}.calendar-weekday-row,.calendar-grid{gap:4px}.calendar-day{min-height:68px;padding:6px;border-radius:9px}.calendar-day-time{font-size:12px}.calendar-day-books{display:none}.calendar-month-heading{align-items:flex-start;flex-direction:column;gap:4px}.calendar-month-heading h3{font-size:22px}} +@media(max-width:420px){.calendar-weekday-row span{font-size:8px;padding:0 2px}.calendar-day{min-height:58px;padding:5px}.calendar-day-number{font-size:10px}.calendar-day-time{font-size:10px}.calendar-detail-summary{gap:5px}.calendar-detail-summary div{padding:7px}.calendar-detail-summary strong{font-size:14px}} +@media(max-width:520px){.heatmap-layout{grid-template-columns:0 minmax(0,1fr);gap:0}.heatmap-weekdays{display:none}} + +/* Book cover controls */ +.cover-actions{display:grid;gap:8px}.visually-hidden{position:absolute!important;width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important} +@media(max-width:520px){.book-page aside{align-items:start}.book-page aside .cover-actions{align-self:end}.book-page aside .cover-actions .secondary-action{margin-bottom:0}} + +/* Cover manager */ +.book-cover-manager{margin:28px 0 8px;padding-top:22px;border-top:1px solid var(--line)} +.cover-manager-actions{display:flex;gap:8px;margin:12px 0 4px}.cover-job{display:flex;align-items:center;gap:9px;margin:12px 0;padding:10px 12px;border:1px solid var(--line);border-radius:11px;background:var(--paper-2);font-size:12px}.cover-job.error{border-color:#b45c54}.job-spinner{width:13px;height:13px;border:2px solid var(--line);border-top-color:var(--accent);border-radius:50%;animation:kovi-spin .8s linear infinite}@keyframes kovi-spin{to{transform:rotate(360deg)}} +.cover-choice-section{margin-top:20px}.cover-choice-section h4{margin:0 0 9px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}.cover-option-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px}.cover-option{position:relative;display:grid;grid-template-rows:auto auto auto;gap:8px;min-width:0;padding:9px;border:1px solid var(--line);border-radius:12px;background:var(--paper)}.cover-option.selected{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}.option-cover{width:100%;max-width:128px;justify-self:center}.option-cover img{width:100%;height:100%;object-fit:cover}.cover-option-copy{min-width:0}.cover-option-copy strong,.cover-option-copy small{display:block}.cover-option-copy strong{font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.cover-option-copy small{margin-top:3px;color:var(--muted);font-size:9px;line-height:1.35}.cover-option .secondary-action{width:100%;justify-content:center;padding:8px}.cover-option .status-badge{justify-self:start} + +/* Status, diagnostics, backup and export */ +.status-header{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin-bottom:22px}.status-header h2{font:500 34px/1.08 var(--font-display);letter-spacing:-.025em;margin:5px 0}.status-metrics{margin-bottom:18px}.status-layout{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}.status-panel{padding:20px;min-width:0}.status-list{display:grid;gap:3px}.status-row{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;padding:10px 0;border-top:1px solid var(--line)}.status-row:first-child{border-top:0}.status-row>div{min-width:0}.status-row strong,.status-row small{display:block}.status-row strong{font-size:12px}.status-row small{margin-top:2px;color:var(--muted);font-size:9px;line-height:1.4}.status-row>span{text-align:right;font-size:11px;font-weight:750;white-space:nowrap}.status-row>span small{font-weight:500}.export-panel{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-top:18px;padding:22px}.export-panel h3{font:500 25px/1.15 var(--font-display);margin:4px 0 8px}.export-panel p{max-width:650px;margin-bottom:0}.export-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap}.export-actions a{text-decoration:none;white-space:nowrap} +@media(max-width:900px){.status-layout{grid-template-columns:1fr}.export-panel{align-items:flex-start;flex-direction:column}.export-actions{justify-content:flex-start}} +@media(max-width:620px){.status-header{align-items:flex-start;flex-direction:column}.cover-option-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.status-row{display:grid;grid-template-columns:1fr}.status-row>span{text-align:left;white-space:normal}.export-actions{width:100%}.export-actions a{width:100%;justify-content:center;text-align:center}} + +/* Library controls */ +.library-summary{margin:7px 0 0;font-size:12px}.library-actions{flex-wrap:wrap;justify-content:flex-end}.library-controls{display:flex;align-items:end;gap:8px;flex-wrap:wrap;justify-content:flex-end}.library-select{display:grid;gap:4px}.library-select>span{padding-left:3px;color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.09em;font-weight:800}.library-select select{height:42px;min-width:132px;padding:0 30px 0 11px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--ink);font:inherit;font-size:12px;font-weight:700;color-scheme:light}.library-select:first-of-type select{min-width:148px}:root.dark .library-select select{color-scheme:dark}.library-controls .search{height:42px} +@media(max-width:900px){.library-toolbar{align-items:flex-start;flex-direction:column}.library-actions,.library-controls{width:100%;justify-content:flex-start}} +@media(max-width:620px){.library-controls{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.library-controls .search{grid-column:1/-1;width:100%}.library-controls .search input,.library-select select{width:100%;min-width:0}.library-actions #retryCovers{align-self:flex-start}} + +/* The dashboard has one more range-aware metric than the system status grid. */ +#metrics{grid-template-columns:repeat(5,minmax(0,1fr))} +@media(max-width:1100px){#metrics{grid-template-columns:repeat(3,minmax(0,1fr))}} +@media(max-width:760px){#metrics{grid-template-columns:repeat(2,minmax(0,1fr))}} diff --git a/scripts/make-fixture.mjs b/scripts/make-fixture.mjs new file mode 100644 index 0000000..efe795b --- /dev/null +++ b/scripts/make-fixture.mjs @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +const out=path.resolve(process.argv[2]||'tests/fixture-statistics.sqlite3');fs.rmSync(out,{force:true});fs.mkdirSync(path.dirname(out),{recursive:true}); +const db=new DatabaseSync(out); +db.exec(` +CREATE TABLE book (id INTEGER PRIMARY KEY,title TEXT,authors TEXT,notes INTEGER,last_open INTEGER,highlights INTEGER,pages INTEGER,series TEXT,language TEXT,md5 TEXT,total_read_time INTEGER,total_read_pages INTEGER); +CREATE TABLE page_stat_data (id_book INTEGER,page INTEGER,start_time INTEGER,duration INTEGER,total_pages INTEGER); +`); +const now=Math.floor(Date.now()/1000); +const ins=db.prepare(`INSERT INTO book VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`); +ins.run(1,'The Left Hand of Darkness','Ursula K. Le Guin',2,now-3600,5,304,'Hainish Cycle','eng','aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',18000,156); +ins.run(2,'The Hobbit','J.R.R. Tolkien',1,now-86400,3,310,null,'eng','bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',9200,91); +const st=db.prepare(`INSERT INTO page_stat_data VALUES(?,?,?,?,?)`); +st.run(1,150,now-7200,1800,304);st.run(1,156,now-3600,2100,304);st.run(2,91,now-86400,1200,310); +db.close();console.log(out); diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..bd8931a --- /dev/null +++ b/server.mjs @@ -0,0 +1,345 @@ +import http from 'node:http'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { Transform } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { openAppDb, resolveTimeZone, listBooks, getBook, setBookReadOverride, getDashboard, getCalendar, createPairingCode, getPairingStatus, consumePairingCode, authenticateDevice, listDevices, revokeDevice, updateDevicePluginVersion, recordDeviceSync, getStatus } from './lib/db.mjs'; +import { importKoReaderDb, importPluginPayload, removeExcludedBooks } from './lib/importer.mjs'; +import { scheduleCoverWork, saveEmbeddedCover, saveManualCover, queueOnlineCoverRetry, getBookCoverState, findCoverCandidates, selectCoverCandidate, selectCoverHistory } from './lib/covers.mjs'; +import { json, randomId, cleanText } from './lib/security.mjs'; +import { info, warn, error as logError } from './lib/logger.mjs'; +import { createBackupArchive, sendBooksCsv, sendHighlightsMarkdown } from './lib/exports.mjs'; + +const VERSION = '2026.09.25'; +const LATEST_PLUGIN_VERSION = '2026.09.25'; +const MIN_COVER_PLUGIN_VERSION = '2026.09.25'; +const MIN_INCREMENTAL_PLUGIN_VERSION = '2026.09.25'; +const ROOT = path.dirname(fileURLToPath(import.meta.url)); +const DATA_DIR = path.resolve(process.env.DATA_PATH || path.join(ROOT, 'data')); +const PUBLIC_DIR = path.join(ROOT, 'public'); +const UPLOAD_DIR = path.join(DATA_DIR, 'uploads'); +const COVER_DIR = path.join(DATA_DIR, 'covers'); +const HOST = process.env.HOST || '127.0.0.1'; +const PORT = Number(process.env.PORT || 3000); +const TIME_ZONE = resolveTimeZone(process.env.TZ); +const MAX_FILE_BYTES = Number(process.env.MAX_FILE_SIZE_MB || 100) * 1024 * 1024; +const MAX_JSON_BYTES = Number(process.env.MAX_JSON_MB || 20) * 1024 * 1024; +const MAX_COVER_BYTES = 4 * 1024 * 1024; +const WEB_CSRF = randomId(24); +const pairAttempts = new Map(); + +function versionParts(value){return String(value||'0').split('.').map(x=>Number.parseInt(x,10)||0).slice(0,3)} +function versionAtLeast(value,minimum){const a=versionParts(value),b=versionParts(minimum);for(let i=0;i<3;i++){if((a[i]||0)>(b[i]||0))return true;if((a[i]||0)<(b[i]||0))return false}return true} +function deviceView(d){return {...d,needs_update:!versionAtLeast(d.plugin_version,LATEST_PLUGIN_VERSION),supports_cover_sync:versionAtLeast(d.plugin_version,MIN_COVER_PLUGIN_VERSION),supports_incremental_sync:versionAtLeast(d.plugin_version,MIN_INCREMENTAL_PLUGIN_VERSION),latest_plugin_version:LATEST_PLUGIN_VERSION,min_cover_plugin_version:MIN_COVER_PLUGIN_VERSION,min_incremental_plugin_version:MIN_INCREMENTAL_PLUGIN_VERSION}} + +try { + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); + fs.mkdirSync(COVER_DIR, { recursive: true }); + fs.accessSync(DATA_DIR, fs.constants.R_OK | fs.constants.W_OK); +} catch (err) { + if (err?.code === 'EACCES') { + logError('startup.storage','kovi cannot write to its data directory.',{dataDir:DATA_DIR}); + logError('startup.storage.hint','For Docker bind mounts, make sure the host data directory is writable by the container user, or use the default named-volume docker-compose.yml.'); + } + throw err; +} +const db = openAppDb(DATA_DIR, {timeZone:TIME_ZONE}); +const excludedOnStartup = removeExcludedBooks(db); +if (excludedOnStartup) info('library.cleanup','Removed excluded KOReader manual entries already present in the library.',{removed:excludedOnStartup}); +scheduleCoverWork(db, DATA_DIR, [], {reason:'startup'}); + +function securityHeaders(extra={}) { + return { + 'X-Content-Type-Options':'nosniff', + 'X-Frame-Options':'DENY', + 'Referrer-Policy':'no-referrer', + 'Permissions-Policy':'camera=(), microphone=(), geolocation=()', + 'Cross-Origin-Resource-Policy':'same-origin', + 'Content-Security-Policy':"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'", + ...extra, + }; +} + +async function readJson(req, max=MAX_JSON_BYTES) { + const chunks=[]; let total=0; + for await (const chunk of req) { + total += chunk.length; + if(total > max) throw Object.assign(new Error('JSON payload too large.'),{status:413}); + chunks.push(chunk); + } + if(!total) return {}; + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } + catch { throw Object.assign(new Error('Invalid JSON.'),{status:400}); } +} + +async function readBinary(req, max=MAX_COVER_BYTES) { + const declared=Number(req.headers['content-length']||0); + if(declared && declared>max) throw Object.assign(new Error('Cover upload is too large.'),{status:413}); + const chunks=[]; let total=0; + for await (const chunk of req) { + total += chunk.length; + if(total > max) throw Object.assign(new Error('Cover upload is too large.'),{status:413}); + chunks.push(chunk); + } + if(!total) throw Object.assign(new Error('Cover upload was empty.'),{status:400}); + return Buffer.concat(chunks); +} + +function routeParam(pathname, pattern) { + const m = pattern.exec(pathname); return m ? m.slice(1).map(decodeURIComponent) : null; +} + +function requireWebCsrf(req) { + if (req.headers['x-kovi-csrf'] !== WEB_CSRF) throw Object.assign(new Error('Missing browser request token.'), {status:403}); +} +function checkPairRate(req) { + const key=req.socket.remoteAddress || 'unknown', now=Date.now(), windowMs=10*60_000; + const row=pairAttempts.get(key) || {start:now,count:0}; + if(now-row.start>windowMs){row.start=now;row.count=0} + row.count++;pairAttempts.set(key,row); + if(row.count>30) throw Object.assign(new Error('Too many pairing attempts. Try again later.'),{status:429}); +} + +function storageStats(){ + let databaseBytes=0,coverBytes=0,coverFiles=0; + try{databaseBytes=fs.statSync(path.join(DATA_DIR,'kovi.sqlite')).size}catch{} + try{for(const name of fs.readdirSync(COVER_DIR)){try{const st=fs.statSync(path.join(COVER_DIR,name));if(st.isFile()){coverBytes+=st.size;coverFiles++}}catch{}}}catch{} + return {database_bytes:databaseBytes,cover_bytes:coverBytes,cover_files:coverFiles,total_bytes:databaseBytes+coverBytes}; +} + +async function handleApi(req,res,url) { + if(req.method==='GET' && url.pathname==='/api/session') return json(res,200,{csrf:WEB_CSRF},securityHeaders()); + if(req.method==='GET' && url.pathname==='/api/health') return json(res,200,{ok:true,name:'kovi',version:VERSION},securityHeaders()); + if(req.method==='GET' && url.pathname==='/api/plugin/ping') { + info('plugin.ping','KOReader ping received.',{remote:req.socket.remoteAddress || 'unknown',userAgent:cleanText(req.headers['user-agent'],120) || 'none'}); + return json(res,200,{ok:true,name:'kovi',version:VERSION},securityHeaders()); + } + if(req.method==='GET' && url.pathname==='/api/books') return json(res,200,{books:listBooks(db)},securityHeaders()); + const bookRoute=routeParam(url.pathname,/^\/api\/books\/([^/]+)$/); + if(req.method==='GET' && bookRoute){ const book=getBook(db,bookRoute[0]); if(!book)return json(res,404,{error:'Book not found.'},securityHeaders()); const covers=getBookCoverState(db,book.id); return json(res,200,{...book,cover_history:covers?.history||[],cover_candidates:covers?.candidates||[],cover_job:covers?.job||null},securityHeaders()); } + const bookReadRoute=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/read$/); + if(req.method==='PUT' && bookReadRoute){ + requireWebCsrf(req); + const body=await readJson(req,4096); + if(body.read!==null && typeof body.read!=='boolean') return json(res,400,{error:'read must be true, false, or null.'},securityHeaders()); + const book=setBookReadOverride(db,bookReadRoute[0],body.read); + return json(res,book?200:404,book||{error:'Book not found.'},securityHeaders()); + } + if(req.method==='GET' && url.pathname==='/api/dashboard') return json(res,200,getDashboard(db,{from:url.searchParams.get('from'),to:url.searchParams.get('to')}),securityHeaders()); + if(req.method==='GET' && url.pathname==='/api/calendar') return json(res,200,getCalendar(db,{from:url.searchParams.get('from'),to:url.searchParams.get('to')}),securityHeaders()); + if(req.method==='GET' && url.pathname==='/api/devices') return json(res,200,{devices:listDevices(db).map(deviceView),latest_plugin_version:LATEST_PLUGIN_VERSION},securityHeaders()); + if(req.method==='GET' && url.pathname==='/api/status') return json(res,200,{...getStatus(db),storage:storageStats(),version:VERSION,latest_plugin_version:LATEST_PLUGIN_VERSION,time_zone:TIME_ZONE,generated_at:new Date().toISOString()},securityHeaders({'Cache-Control':'no-store'})); + if(req.method==='GET' && url.pathname==='/api/export/books.csv'){sendBooksCsv(res,db,securityHeaders());return true} + if(req.method==='GET' && url.pathname==='/api/export/highlights.md'){sendHighlightsMarkdown(res,db,securityHeaders());return true} + if(req.method==='GET' && url.pathname==='/api/backup'){ + const backupPath=path.join(UPLOAD_DIR,`kovi-backup-${Date.now()}-${randomId(6)}.tar.gz`); + await createBackupArchive(db,DATA_DIR,backupPath,{version:VERSION}); + try{const st=await fsp.stat(backupPath);res.writeHead(200,securityHeaders({'Content-Type':'application/gzip','Content-Disposition':`attachment; filename="kovi-backup-${new Date().toISOString().slice(0,10)}.tar.gz"`,'Content-Length':st.size,'Cache-Control':'no-store'}));await pipeline(fs.createReadStream(backupPath),res)}finally{await fsp.rm(backupPath,{force:true}).catch(()=>{})} + return true; + } + + if(req.method==='POST' && url.pathname==='/api/pairing-codes') { + requireWebCsrf(req); + const body=await readJson(req,4096); + const label=cleanText(body.label,80)||'KOReader'; + const pair=createPairingCode(db,label); + info('pairing.code.created','Created a short-lived pairing request.',{requestId:pair.requestId,label,expiresAt:new Date(pair.expiresAt).toISOString()}); + return json(res,201,pair,securityHeaders()); + } + + const pairingStatus=routeParam(url.pathname,/^\/api\/pairing-codes\/([^/]+)$/); + if(req.method==='GET' && pairingStatus){ + const status=getPairingStatus(db,pairingStatus[0]); + return json(res,status?200:404,status||{error:'Pairing request not found.'},securityHeaders()); + } + + const revoke=routeParam(url.pathname,/^\/api\/devices\/([^/]+)\/revoke$/); + if(req.method==='POST' && revoke) { + requireWebCsrf(req); + const changed=revokeDevice(db,revoke[0]); + if(changed) info('device.revoked','Revoked a KOReader device token.',{deviceId:revoke[0]}); + return json(res,changed?200:404,{ok:changed},securityHeaders()); + } + + if(req.method==='POST' && url.pathname==='/api/plugin/pair') { + checkPairRate(req); + const body=await readJson(req,32*1024); + const paired=consumePairingCode(db,body.code,{deviceId:body.device_id,model:cleanText(body.model,120),version:cleanText(body.version,40)}); + if(!paired) { + warn('pairing.rejected','Pairing attempt rejected because the code was invalid, expired, or already used.',{remote:req.socket.remoteAddress || 'unknown',deviceId:cleanText(body.device_id,128),model:cleanText(body.model,120)}); + return json(res,401,{error:'Pairing code is invalid, expired, or already used.'},securityHeaders()); + } + info('device.paired','KOReader paired successfully.',{deviceId:paired.deviceId,model:cleanText(body.model,120),pluginVersion:cleanText(body.version,40),remote:req.socket.remoteAddress || 'unknown'}); + if(!versionAtLeast(body.version,MIN_COVER_PLUGIN_VERSION)) warn('plugin.outdated','Paired KOReader plugin is too old for embedded-cover and ISBN sync.',{deviceId:paired.deviceId,pluginVersion:cleanText(body.version,40),minimumVersion:MIN_COVER_PLUGIN_VERSION,latestVersion:LATEST_PLUGIN_VERSION}); + return json(res,200,{message:'KOReader paired with kovi.',token:paired.token,device_id:paired.deviceId,latest_plugin_version:LATEST_PLUGIN_VERSION,update_recommended:!versionAtLeast(body.version,LATEST_PLUGIN_VERSION)},securityHeaders()); + } + + if(req.method==='POST' && url.pathname==='/api/plugin/import') { + const device=authenticateDevice(db,req.headers.authorization); + if(!device) { + warn('plugin.sync.rejected','Rejected plugin sync with missing or invalid device token.',{remote:req.socket.remoteAddress || 'unknown'}); + return json(res,401,{error:'Device token is missing or invalid.'},securityHeaders()); + } + const body=await readJson(req); + const reportedVersion=cleanText(body?.version,40)||device.plugin_version||''; + if(reportedVersion) updateDevicePluginVersion(db,device.id,reportedVersion); + info('plugin.sync.start','KOReader sync started.',{deviceId:device.id,books:Array.isArray(body?.books)?body.books.length:0,stats:Array.isArray(body?.stats)?body.stats.length:0,annotationSets:Array.isArray(body?.annotation_sets)?body.annotation_sets.length:0}); + const result=importPluginPayload(db,body,device); + const coverQueueIds=[...new Set([...(result.newBookIds||[]),...(result.coverRefreshIds||[])])]; + scheduleCoverWork(db,DATA_DIR,coverQueueIds,{reason:`plugin-sync:${device.id}`,delayMs:(result.coverRequests?.length||0)>0?4000:50}); + if((result.coverRequests?.length||0)>0 && !versionAtLeast(reportedVersion,MIN_COVER_PLUGIN_VERSION)) warn('plugin.outdated.cover-sync','KOReader ignored server cover requests because its plugin predates embedded-cover sync. Update the kovi plugin on the reader.',{deviceId:device.id,pluginVersion:reportedVersion||'unknown',minimumVersion:MIN_COVER_PLUGIN_VERSION,missingCoverRequests:result.coverRequests.length}); + recordDeviceSync(db,device.id,result,{mode:result.syncMode,cursorBefore:result.syncCursorBefore,cursorAfter:result.syncCursorAfter}); + info('plugin.sync.complete','KOReader sync completed.',{deviceId:device.id,...result,coverRequests:result.coverRequests?.length||0,newBookIds:undefined,coverRefreshIds:undefined}); + return json(res,200,{ + message:`Synced ${result.booksSeen} books, ${result.sessionsSeen} reading rows, and ${result.annotationsStored} annotations.`, + ...result, + sync_cursor:result.syncCursorAfter, + sync_mode:result.syncMode, + cover_requests:result.coverRequests||[], + latest_plugin_version:LATEST_PLUGIN_VERSION, + update_recommended:!versionAtLeast(reportedVersion,LATEST_PLUGIN_VERSION), + coverRequests:undefined, + },securityHeaders()); + } + + if(req.method==='POST' && url.pathname==='/api/plugin/cover') { + const device=authenticateDevice(db,req.headers.authorization); + if(!device) { + warn('cover.embedded.rejected','Rejected embedded cover upload with missing or invalid device token.',{remote:req.socket.remoteAddress || 'unknown'}); + return json(res,401,{error:'Device token is missing or invalid.'},securityHeaders()); + } + const md5=cleanText(req.headers['x-kovi-book-md5'],128); + if(!md5) return json(res,400,{error:'Book MD5 header is required.'},securityHeaders()); + const payload=await readBinary(req); + const saved=saveEmbeddedCover(db,DATA_DIR,md5,payload); + if(saved.ignored) { + info('cover.embedded.ignored','Ignored embedded cover because a manual cover has priority.',{deviceId:device.id,bookId:saved.book?.id,title:saved.book?.title,reason:saved.reason}); + return json(res,200,{ok:true,ignored:true,reason:saved.reason},securityHeaders()); + } + info('cover.embedded.saved','Stored an embedded cover supplied by KOReader.',{deviceId:device.id,bookId:saved.bookId,title:saved.title,bytes:saved.bytes,type:saved.type}); + return json(res,201,{ok:true,book_id:saved.bookId,path:saved.path},securityHeaders()); + } + + if(req.method==='PUT' && url.pathname==='/api/import/sqlite') { + requireWebCsrf(req); + const contentLength=Number(req.headers['content-length']||0); + const filename=cleanText(req.headers['x-kovi-filename'],255)||'statistics.sqlite3'; + if(contentLength && contentLength>MAX_FILE_BYTES) return json(res,413,{error:`File exceeds ${Math.round(MAX_FILE_BYTES/1024/1024)} MB limit.`},securityHeaders()); + const tempPath=path.join(UPLOAD_DIR,`${Date.now()}-${randomId(12)}.sqlite`); + let size=0; let first=Buffer.alloc(0); + const limiter=new Transform({ + transform(chunk,enc,cb){ + size+=chunk.length; + if(first.length<16) first=Buffer.concat([first,chunk]).subarray(0,16); + if(size>MAX_FILE_BYTES) return cb(Object.assign(new Error('File too large.'),{status:413})); + cb(null,chunk); + } + }); + info('import.sqlite.start','Manual KOReader database upload started.',{filename,contentLength:contentLength||undefined,remote:req.socket.remoteAddress || 'unknown'}); + try { + await pipeline(req,limiter,fs.createWriteStream(tempPath,{flags:'wx',mode:0o600})); + if(size<16 || first.toString('ascii',0,16)!=='SQLite format 3\u0000') throw Object.assign(new Error('The selected file is not a SQLite database.'),{status:400}); + const result=importKoReaderDb(db,tempPath,{filename,fileSize:size}); + scheduleCoverWork(db,DATA_DIR,result.newBookIds||[],{reason:'manual-import'}); + info(result.duplicate?'import.sqlite.duplicate':'import.sqlite.complete',result.duplicate?'Manual import matched an already-imported database.':'Manual KOReader database import completed.',{filename,fileSize:size,...result,newBookIds:undefined,sourceHash:undefined,warnings:result.warnings?.length||0}); + return json(res,200,{message:result.duplicate?'This exact database was already imported.':'Import complete.',...result},securityHeaders()); + } finally { + await fsp.rm(tempPath,{force:true}).catch(()=>{}); + } + } + + if(req.method==='POST' && url.pathname==='/api/covers/retry') { + requireWebCsrf(req); + const ids=db.prepare(`SELECT id FROM books WHERE cover_path IS NULL AND cover_status IN ('none','error','pending')`).all().map(r=>r.id); + info('cover.retry.all','User requested retry for missing covers.',{queued:ids.length}); + scheduleCoverWork(db,DATA_DIR,ids,{reason:'user-retry-all'}); + return json(res,202,{message:'Cover lookup queued.',queued:ids.length},securityHeaders()); + } + + const manualBookCover=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover$/); + if(req.method==='PUT' && manualBookCover) { + requireWebCsrf(req); + const payload=await readBinary(req); + const saved=saveManualCover(db,DATA_DIR,manualBookCover[0],payload); + info('cover.manual.saved','Stored a cover uploaded from the browser.',{bookId:saved.bookId,title:saved.title,bytes:saved.bytes,type:saved.type}); + return json(res,201,{ok:true,book_id:saved.bookId,path:saved.path,cover_source:'manual-upload'},securityHeaders()); + } + + const candidateSearch=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover\/candidates$/); + if(req.method==='POST' && candidateSearch){ + requireWebCsrf(req); + info('cover.candidates.start','User requested alternative cover candidates.',{bookId:candidateSearch[0]}); + const candidates=await findCoverCandidates(db,DATA_DIR,candidateSearch[0]); + if(candidates===null)return json(res,404,{error:'Book not found.'},securityHeaders()); + info('cover.candidates.complete','Alternative cover search completed.',{bookId:candidateSearch[0],candidates:candidates.length}); + return json(res,200,{candidates},securityHeaders()); + } + + const candidateSelect=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover\/candidates\/([^/]+)$/); + if(req.method==='PUT' && candidateSelect){ + requireWebCsrf(req);const selected=selectCoverCandidate(db,candidateSelect[0],candidateSelect[1]); + if(selected===null)return json(res,404,{error:'Book not found.'},securityHeaders()); + if(!selected)return json(res,404,{error:'Cover candidate not found.'},securityHeaders()); + info('cover.candidate.selected','User selected an alternative cover.',{bookId:candidateSelect[0],candidateId:candidateSelect[1]}); + return json(res,200,{ok:true},securityHeaders()); + } + + const historySelect=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover\/history\/([^/]+)$/); + if(req.method==='PUT' && historySelect){ + requireWebCsrf(req);const selected=selectCoverHistory(db,historySelect[0],historySelect[1]); + if(selected===null)return json(res,404,{error:'Book not found.'},securityHeaders()); + if(!selected)return json(res,404,{error:'Cover history item not found.'},securityHeaders()); + info('cover.history.selected','User restored a previous cover.',{bookId:historySelect[0],coverId:historySelect[1]}); + return json(res,200,{ok:true},securityHeaders()); + } + + const retryBookCover=routeParam(url.pathname,/^\/api\/books\/([^/]+)\/cover\/retry$/); + if(req.method==='POST' && retryBookCover) { + requireWebCsrf(req); + const book=queueOnlineCoverRetry(db,DATA_DIR,retryBookCover[0],{reason:'user-retry-book'}); + if(!book) return json(res,404,{error:'Book not found.'},securityHeaders()); + info('cover.retry.book','User requested a cover retry for one book.',{bookId:book.id,title:book.title}); + return json(res,202,{message:'Cover lookup queued for this book.'},securityHeaders()); + } + + return false; +} + +const MIME={'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.svg':'image/svg+xml','.png':'image/png','.jpg':'image/jpeg','.jpeg':'image/jpeg','.webp':'image/webp','.gif':'image/gif','.ico':'image/x-icon','.zip':'application/zip'}; +async function serveFile(res,filePath,{cache=false}={}) { + try { + const st=await fsp.stat(filePath); if(!st.isFile()) return false; + res.writeHead(200,securityHeaders({'Content-Type':MIME[path.extname(filePath).toLowerCase()]||'application/octet-stream','Content-Length':st.size,'Cache-Control':cache?'public, max-age=86400':'no-cache'})); + fs.createReadStream(filePath).pipe(res); return true; + } catch { return false; } +} + +const server=http.createServer(async(req,res)=>{ + try{ + const url=new URL(req.url,`http://${req.headers.host||'localhost'}`); + if(url.pathname.startsWith('/api/')){ + const handled=await handleApi(req,res,url); if(handled!==false) return; + return json(res,404,{error:'Not found.'},securityHeaders()); + } + if(url.pathname.startsWith('/covers/')){ + const name=path.basename(url.pathname); if(name!==url.pathname.slice('/covers/'.length)) return json(res,400,{error:'Bad path.'},securityHeaders()); + if(await serveFile(res,path.join(COVER_DIR,name),{cache:true})) return; + return json(res,404,{error:'Cover not found.'},securityHeaders()); + } + const requested=url.pathname==='/'?'index.html':url.pathname.slice(1); + const normalized=path.normalize(requested); + if(normalized.startsWith('..')) return json(res,400,{error:'Bad path.'},securityHeaders()); + if(await serveFile(res,path.join(PUBLIC_DIR,normalized))) return; + if(await serveFile(res,path.join(PUBLIC_DIR,'index.html'))) return; + res.writeHead(404); res.end('Not found'); + }catch(err){ + const status=err.status||500; + if(status>=500) logError('request.error','Request failed unexpectedly.',{method:req.method,url:req.url,error:err.message,stack:err.stack}); + else warn('request.rejected','Request rejected.',{method:req.method,url:req.url,status,error:err.message}); + if(!res.headersSent) json(res,status,{error:err.status?err.message:'Something went wrong.'},securityHeaders()); else res.destroy(); + } +}); + +server.listen(PORT,HOST,()=>info('startup.ready','kovi is ready.',{version:VERSION,url:`http://${HOST}:${PORT}`,dataDir:DATA_DIR,timeZone:TIME_ZONE,maxUploadMb:Math.round(MAX_FILE_BYTES/1024/1024)})); diff --git a/tests/exports.test.mjs b/tests/exports.test.mjs new file mode 100644 index 0000000..ca77a2d --- /dev/null +++ b/tests/exports.test.mjs @@ -0,0 +1,24 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { sendBooksCsv } from '../lib/exports.mjs'; + +function captureResponse(){ + return {status:null,headers:null,chunks:[],writeHead(status,headers){this.status=status;this.headers=headers},write(chunk){this.chunks.push(String(chunk));return true},end(chunk=''){if(chunk)this.chunks.push(String(chunk))},body(){return this.chunks.join('')}}; +} + +test('books CSV exports readable durations and UTC dates instead of raw seconds/timestamps',()=>{ + const rows=[{title:'The Hobbit',authors:'J.R.R. Tolkien',series:null,language:'eng',isbn:'9780000000000',total_read_time:5000,total_read_pages:80,highlights:2,last_open:1700000000,cover_source:'manual-upload',pages:310,last_page:164,last_total_pages:310,koreader_status:'complete',read_override:null}]; + const db={prepare(){return {iterate(){return rows}}}}; + const res=captureResponse();sendBooksCsv(res,db); + assert.equal(res.status,200);assert.match(res.headers['Content-Type'],/text\/csv/); + const body=res.body(); + assert.match(body,/status,document_progress,reading_time/);assert.match(body,/,read,53%,/);assert.match(body,/last_opened/);assert.match(body,/1h 23m 20s/);assert.match(body,/2023-11-14 22:13:20 UTC/); + assert.doesNotMatch(body,/reading_seconds/);assert.doesNotMatch(body,/,5000,/);assert.doesNotMatch(body,/,1700000000,/); +}); + +test('books CSV leaves an unknown last-opened date blank',()=>{ + const rows=[{title:'Unread',authors:'',series:'',language:'',isbn:'',total_read_time:0,total_read_pages:0,highlights:0,last_open:0,cover_source:null}]; + const db={prepare(){return {iterate(){return rows}}}}; + const res=captureResponse();sendBooksCsv(res,db); + assert.match(res.body(),/Unread,,,,,unread,0%,0s,0,0,,/); +}); diff --git a/tests/frontend.test.mjs b/tests/frontend.test.mjs new file mode 100644 index 0000000..045011c --- /dev/null +++ b/tests/frontend.test.mjs @@ -0,0 +1,164 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const source=fs.readFileSync(new URL('../public/app.js', import.meta.url),'utf8'); +const html=fs.readFileSync(new URL('../public/index.html', import.meta.url),'utf8'); +const css=fs.readFileSync(new URL('../public/styles.css', import.meta.url),'utf8'); + +test('manual SQLite upload attaches the browser CSRF token',()=>{ + assert.match(source,/setRequestHeader\('X-Kovi-CSRF',csrf\)/); + assert.match(source,/ensureCsrf\(true\)/); +}); + +test('state-changing fetch requests can refresh a stale CSRF token',()=>{ + assert.match(source,/r\.status===403/); + assert.match(source,/Missing browser request token/); +}); + +test('book cards route to a dedicated book page instead of a dialog',()=>{ + assert.match(source,/href=\"\/books\//); + assert.match(source,/function openBook/); + assert.match(html,/id="bookView"/); + assert.doesNotMatch(html,/id="bookDialog"/); +}); + +test('library supports persisted sorting, reading-state filters, and combined search',()=>{ + assert.match(html,/id="bookSort"/);assert.match(html,/Recently read/);assert.match(html,/Reading time/);assert.match(html,/id="bookFilter"/);assert.match(html,/In progress/);assert.match(html,/Completed/); + assert.match(source,/kovi-library-sort/);assert.match(source,/kovi-library-filter/);assert.match(source,/localStorage\.setItem\('kovi-library-sort'/);assert.match(source,/localStorage\.setItem\('kovi-library-filter'/); + assert.match(source,/function bookReadingState/);assert.match(source,/function renderLibrary/);assert.match(source,/state\.libraryQuery/);assert.match(source,/total_read_time/);assert.match(source,/recent_read/); + assert.match(css,/\.library-controls\{/);assert.match(css,/\.library-select select\{/);assert.match(css,/@media\(max-width:620px\)[^}]*\.library-controls/); +}); + +test('book page can retry an online cover and upload a manual cover',()=>{ + assert.match(source,/\/cover\/retry/); + assert.match(source,/id="uploadBookCover"/); + assert.match(source,/id="manualCoverInput"/); + assert.match(source,/accept="image\/jpeg,image\/png,image\/webp,image\/gif"/); + assert.match(source,/method:'PUT'/); + assert.match(source,/Cover images must be 4 MB or smaller/); +}); + +test('completion is independent from document progress and can be overridden',()=>{ + assert.match(source,/const bookIsRead=/);assert.match(source,/b\.is_read/);assert.match(source,/book-read-badge/); + assert.match(source,/Document progress/);assert.match(source,/id="toggleBookRead"/);assert.match(source,/id="clearBookRead"/);assert.match(source,/\/read/);assert.match(source,/Use synced status/); + assert.match(css,/\.reading-status\{/);assert.match(css,/\.book-read-badge\{/); +}); + +test('pairing page polls its one-time request and refreshes the device list on success',()=>{ + assert.match(source,/function pollPairing/); + assert.match(source,/\/api\/pairing-codes\//); + assert.match(source,/await loadDevices\(\)/); + assert.match(source,/pairDialog\.close\(\)/); +}); + +test('reading dashboard defaults to last year and offers all requested date filters',()=>{ + assert.match(html,/data-range="year"/); + assert.match(html,/data-range="week"/); + assert.match(html,/data-range="month"/); + assert.match(html,/data-range="three-months"/); + assert.match(html,/data-range="six-months"/); + assert.match(html,/data-range="custom"/); + assert.match(source,/RANGE_DAYS=.*year:365/); + assert.match(source,/\/api\/dashboard\?\$\{params\}/); +}); + +test('dashboard shows books both started and completed in the selected range',()=>{assert.match(source,/Books read/);assert.match(source,/d\.range\?\.read_books/);assert.match(source,/started and completed/);assert.match(css,/#metrics\{grid-template-columns:repeat\(5/)}); + +test('recently read is replaced by a daily reading-time line graph',()=>{ + assert.doesNotMatch(html,/

Recently read<\/h2>/i); + assert.doesNotMatch(html,/id="recentShelf"/); + assert.match(html,/id="lineChart"/); + assert.match(html,/Daily reading time/); + assert.match(source,/function renderLineChart/); + assert.match(source,/chart-line/); +}); + +test('reading rhythm renders a range-aware GitHub-style heatmap without horizontal scrolling',()=>{ + assert.match(html,/heatmap-weekdays/); + assert.match(source,/function renderHeatmap\(days,range\)/); + assert.match(source,/--heat-weeks/); + assert.match(source,/HEATMAP_REFERENCE_WEEKS=53/); + assert.match(source,/referenceCell/); + assert.match(source,/class=\"day \$\{outside\?'outside'/); + assert.match(css,/\.heatmap-scroll\{[^}]*overflow:hidden!important/); + assert.match(css,/grid-template-columns:repeat\(var\(--heat-weeks\),var\(--heat-cell\)\)/); + assert.match(css,/\.heatmap\{[^}]*width:max-content/); + assert.match(css,/--heatmap-empty:/); +}); + +test('mobile keeps the light-dark theme switch visible',()=>{ + assert.match(source,/function setTheme/); + assert.match(css,/@media\(max-width:760px\)[\s\S]*?\.top-actions \.icon-button\{display:grid\}/); +}); + +test('reading time card uses the explicit all-time total instead of taking an arbitrary max',()=>{ + assert.match(source,/all_time_reading_seconds/); + assert.doesNotMatch(source,/Math\.max\(d\.total_read_time/); + assert.match(source,/all-time KOReader total/); +}); + +test('device page visibly warns when the KOReader plugin is too old for embedded cover sync',()=>{ + assert.match(source,/Plugin update needed/); + assert.match(source,/Embedded covers and ISBN enrichment require plugin/); + assert.match(source,/\/kovi-plugin\.zip/); +}); + + +test('reading trend exposes interactive hover and tap values, including highlighted spike points',()=>{ + assert.match(source,/function chartKeyPointIndices/); + assert.match(source,/chart-tooltip/); + assert.match(source,/addEventListener\('pointermove'/); + assert.match(source,/addEventListener\('click'/); + assert.match(source,/data-chart-index/); + assert.match(css,/\.chart-tooltip\{/); +}); + +test('calendar is a dedicated routed page with direct date jumping and per-day reading details',()=>{ + assert.match(html,/data-view="calendar"/); + assert.match(html,/id="calendarView"/); + assert.match(html,/id="calendarGrid"/); + assert.match(html,/id="calendarDetail"/); + assert.match(html,/id="calendarDateForm"/); + assert.match(html,/id="calendarDateInput" type="date"/); + assert.match(source,/calendar:'\/calendar'/); + assert.match(source,/\/api\/calendar\?/); + assert.match(source,/function renderCalendarDetail/); + assert.match(source,/function jumpToCalendarDate/); + assert.match(source,/calendarDateForm/); + assert.doesNotMatch(source,/Sessions<\/span>/); + assert.match(css,/\.calendar-date-search\{/); + assert.match(css,/\.calendar-grid\{/); +}); + + +test('cover manager exposes transient candidates, history, and live job polling',()=>{ + assert.match(source,/Find online replacement/);assert.match(source,/Find alternatives/);assert.match(source,/cover\/candidates/);assert.match(source,/cover\/history/);assert.match(source,/function pollBookCoverJob/);assert.doesNotMatch(source,/2600/); + assert.match(source,/Cover manager/);assert.match(css,/\.cover-option-grid\{/); + assert.match(source,/coverChooserBookId:null/);assert.match(source,/showCoverChoices=state\.coverChooserBookId===b\.id/); + assert.match(source,/state\.coverChooserBookId=b\.id/);assert.ok((source.match(/state\.coverChooserBookId=null/g)||[]).length>=4); +}); + +test('status page exposes sync diagnostics plus backup and human-friendly exports',()=>{ + assert.match(html,/data-view="status"/);assert.match(html,/id="statusView"/);assert.match(html,/Download kovi backup/);assert.match(source,/status:'\/status'/);assert.match(source,/\/api\/status/);assert.match(source,/function renderStatus/); + assert.match(html,/\/api\/backup/);assert.doesNotMatch(html,/reading\.json/);assert.match(html,/\/api\/export\/books\.csv/);assert.match(html,/\/api\/export\/highlights\.md/); + assert.match(source,/Time zone/);assert.match(source,/d\.time_zone/);assert.match(source,/dashboard and calendar dates/); +}); + +test('status refresh bypasses cache and shows visible progress and completion feedback',()=>{ + assert.match(source,/Refreshing…/);assert.match(source,/cache:'no-store'/);assert.match(source,/fresh=\$\{Date\.now\(\)\}/);assert.match(source,/Status refreshed at/); +}); + +test('device page explains the incremental sync upgrade',()=>{ + assert.match(source,/Incremental statistics and annotation sync require plugin/);assert.match(source,/min_incremental_plugin_version/); +}); + +test('sidebar and empty state use the kovi logo assets instead of split legacy branding',()=>{ + assert.match(html,/\/kovi-mark\.png/);assert.match(html,/\/kovi-logo\.png/); + assert.doesNotMatch(html,/KO<\/span>/);assert.doesNotMatch(html,/DATA<\/b>/); + assert.match(html,/id="themeColor"/);assert.match(source,/themeColor/); +}); + +test('the favicon uses the kovi mascot instead of the old svg mark',()=>{ + assert.match(html,/rel="icon" href="\/favicon\.png"/);assert.doesNotMatch(html,/logo\.svg/); +}); diff --git a/tests/importer.test.mjs b/tests/importer.test.mjs new file mode 100644 index 0000000..2d62a37 --- /dev/null +++ b/tests/importer.test.mjs @@ -0,0 +1,448 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { openAppDb, resolveTimeZone, createPairingCode, getPairingStatus, consumePairingCode, authenticateDevice, getBook, setBookReadOverride, getDashboard, getCalendar, dashboardRange, recordDeviceSync, getStatus } from '../lib/db.mjs'; +import { importKoReaderDb, importPluginPayload, validateKoReaderDb, isKoReaderManual, removeExcludedBooks } from '../lib/importer.mjs'; +import { scoreCandidate, resolveCover, saveEmbeddedCover, saveManualCover, processCoverBook, getBookCoverState, selectCoverHistory, selectCoverCandidate } from '../lib/covers.mjs'; +import { extractIsbn } from '../lib/security.mjs'; +import { calibreArgsForBook, fetchCoverWithCalibre } from '../lib/calibre.mjs'; +import { createBackupArchive } from '../lib/exports.mjs'; +import { gunzipSync } from 'node:zlib'; + +function fixture(dir,{includeManual=false}={}){const p=path.join(dir,'statistics.sqlite3');const d=new DatabaseSync(p);d.exec(`CREATE TABLE book(id INTEGER PRIMARY KEY,title TEXT,authors TEXT,notes INTEGER,last_open INTEGER,highlights INTEGER,pages INTEGER,series TEXT,language TEXT,md5 TEXT,total_read_time INTEGER,total_read_pages INTEGER);CREATE TABLE page_stat_data(id_book INTEGER,page INTEGER,start_time INTEGER,duration INTEGER,total_pages INTEGER);`);d.prepare(`INSERT INTO book VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(1,'The Hobbit','J.R.R. Tolkien',1,1700000000,2,310,null,'eng','abc123',5000,80);d.prepare(`INSERT INTO page_stat_data VALUES(?,?,?,?,?)`).run(1,80,1700000000,600,310);if(includeManual){d.prepare(`INSERT INTO book VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(2,'KOReader User Guide','KOReader Team',0,1700000001,0,100,null,'eng','manual123',20,1);d.prepare(`INSERT INTO page_stat_data VALUES(?,?,?,?,?)`).run(2,1,1700000001,20,100)}d.close();return p} + +test('manual importer validates, imports, and is idempotent',()=>{const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-'));const app=openAppDb(path.join(dir,'data'));const p=fixture(dir);assert.doesNotThrow(()=>validateKoReaderDb(p));const one=importKoReaderDb(app,p,{filename:'statistics.sqlite3',fileSize:123});assert.equal(one.newBooks,1);assert.equal(one.newSessions,1);const two=importKoReaderDb(app,p,{filename:'statistics.sqlite3',fileSize:123});assert.equal(two.duplicate,true);assert.equal(app.prepare('SELECT COUNT(*) n FROM books').get().n,1);assert.equal(app.prepare('SELECT COUNT(*) n FROM reading_sessions').get().n,1);app.close();fs.rmSync(dir,{recursive:true,force:true})}); + + + +test('dashboard defaults to a rolling 365-day range and filters daily sessions exactly',()=>{ + assert.deepEqual(dashboardRange({},new Date(2026,7,31,12,0,0)),{from:'2025-09-01',to:'2026-08-31'}); + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-dashboard-'));const app=openAppDb(dir); + app.prepare(`INSERT INTO books(id,source_md5,title,total_read_time,created_at,updated_at) VALUES('b','m','Book',7200,'x','x')`).run(); + const ins=app.prepare(`INSERT INTO reading_sessions(id,fingerprint,book_id,device_id,page,start_time,duration,total_pages,created_at) VALUES(?,?,?,?,?,?,?,?,?)`); + const epoch=(y,m,d)=>Math.floor(new Date(y,m-1,d,12,0,0).getTime()/1000); + ins.run('s1','f1','b','d',1,epoch(2026,8,1),1800,100,'x'); + ins.run('s2','f2','b','d',2,epoch(2026,8,15),3600,100,'x'); + ins.run('s3','f3','b','d',3,epoch(2026,7,31),600,100,'x'); + const dash=getDashboard(app,{from:'2026-08-01',to:'2026-08-31'}); + assert.equal(dash.range.seconds,5400);assert.equal(dash.range.reading_days,2);assert.equal(dash.days.length,2); + assert.equal(dash.all_time_reading_seconds,7200);assert.equal(dash.session_seconds,6000); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('range books read requires both first tracked reading and completion inside the range',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-range-read-'));const app=openAppDb(dir,{timeZone:'UTC'}); + const addBook=app.prepare(`INSERT INTO books(id,source_md5,title,koreader_status,koreader_status_modified,read_override,read_override_at,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`); + const addSession=app.prepare(`INSERT INTO reading_sessions(id,fingerprint,book_id,device_id,page,start_time,duration,total_pages,created_at) VALUES(?,?,?,?,?,?,?,?,?)`); + const epoch=(day,hour=12)=>Date.parse(`${day}T${String(hour).padStart(2,'0')}:00:00Z`)/1000; + addBook.run('ko','ko-md5','KOReader complete','complete','2026-08-10',null,null,'x','x');addSession.run('ks','kf','ko','d',53,epoch('2026-08-02'),60,100,'x'); + addBook.run('manual','manual-md5','Manual complete',null,null,null,null,'x','x');addSession.run('ms','mf','manual','d',40,epoch('2026-08-03'),60,100,'x');setBookReadOverride(app,'manual',true,epoch('2026-08-12')); + addBook.run('legacy-manual','legacy-manual-md5','Legacy manual complete',null,null,1,null,'x','x');addSession.run('lms1','lmf1','legacy-manual','d',20,epoch('2026-08-05'),60,100,'x');addSession.run('lms2','lmf2','legacy-manual','d',53,epoch('2026-08-18'),60,100,'x'); + addBook.run('progress','progress-md5','Progress complete',null,null,null,null,'x','x');addSession.run('ps1','pf1','progress','d',1,epoch('2026-08-04'),60,100,'x');addSession.run('ps2','pf2','progress','d',100,epoch('2026-08-15'),60,100,'x'); + addBook.run('boundary','boundary-md5','Boundary complete','complete','2026-08-31',null,null,'x','x');addSession.run('bs','bf','boundary','d',20,epoch('2026-08-01'),60,100,'x'); + addBook.run('old','old-md5','Started before','complete','2026-08-05',null,null,'x','x');addSession.run('os','of','old','d',20,epoch('2026-07-31'),60,100,'x'); + addBook.run('late','late-md5','Completed after','complete','2026-09-01',null,null,'x','x');addSession.run('ls','lf','late','d',20,epoch('2026-08-05'),60,100,'x'); + addBook.run('reverse','reverse-md5','Completion before start','complete','2026-08-10',null,null,'x','x');addSession.run('rs','rf','reverse','d',20,epoch('2026-08-20'),60,100,'x'); + addBook.run('resumed','resumed-md5','Moved after final page',null,null,null,null,'x','x');addSession.run('rr1','rrf1','resumed','d',100,epoch('2026-08-07'),60,100,'x');addSession.run('rr2','rrf2','resumed','d',20,epoch('2026-08-08'),60,100,'x'); + addBook.run('unknown','unknown-md5','Unknown completion','complete',null,null,null,'x','x');addSession.run('us','uf','unknown','d',53,epoch('2026-08-06'),60,100,'x'); + assert.equal(getDashboard(app,{from:'2026-08-01',to:'2026-08-31'}).range.read_books,5); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('calendar groups each day by book and totals its reading sessions',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-calendar-'));const app=openAppDb(dir); + app.prepare(`INSERT INTO books(id,source_md5,title,authors,cover_path,created_at,updated_at) VALUES('a','ma','Book A','Author A','/covers/a.jpg','x','x')`).run(); + app.prepare(`INSERT INTO books(id,source_md5,title,authors,created_at,updated_at) VALUES('b','mb','Book B','Author B','x','x')`).run(); + const ins=app.prepare(`INSERT INTO reading_sessions(id,fingerprint,book_id,device_id,page,start_time,duration,total_pages,created_at) VALUES(?,?,?,?,?,?,?,?,?)`); + const epoch=(y,m,d,h=12)=>Math.floor(new Date(y,m-1,d,h,0,0).getTime()/1000); + ins.run('c1','cf1','a','d',1,epoch(2026,8,10,9),600,100,'x'); + ins.run('c2','cf2','a','d',2,epoch(2026,8,10,10),900,100,'x'); + ins.run('c3','cf3','b','d',3,epoch(2026,8,10,11),1200,200,'x'); + ins.run('c4','cf4','b','d',4,epoch(2026,8,11,11),300,200,'x'); + const cal=getCalendar(app,{from:'2026-08-01',to:'2026-08-31'}); + assert.equal(cal.days.length,2);assert.equal(cal.days[0].day,'2026-08-10');assert.equal(cal.days[0].seconds,2700);assert.equal(cal.days[0].sessions,3); + assert.equal(cal.days[0].books.length,2);assert.equal(cal.days[0].books[0].title,'Book A');assert.equal(cal.days[0].books[0].seconds,1500);assert.equal(cal.days[0].books[0].cover_path,'/covers/a.jpg'); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('dashboard and calendar group sessions in the configured IANA time zone across DST',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-timezone-'));const app=openAppDb(dir,{timeZone:'America/New_York'}); + app.prepare(`INSERT INTO books(id,source_md5,title,created_at,updated_at) VALUES('tz','tz-md5','Night Reader','x','x')`).run(); + const ins=app.prepare(`INSERT INTO reading_sessions(id,fingerprint,book_id,device_id,page,start_time,duration,total_pages,created_at) VALUES(?,?,?,?,?,?,?,?,?)`); + ins.run('tz1','tzf1','tz','d',1,Date.parse('2026-03-08T04:30:00Z')/1000,600,100,'x'); + ins.run('tz2','tzf2','tz','d',2,Date.parse('2026-03-08T07:30:00Z')/1000,900,100,'x'); + const dash=getDashboard(app,{from:'2026-03-07',to:'2026-03-08'}); + assert.deepEqual(dash.days.map(row=>[row.day,row.seconds]),[['2026-03-07',600],['2026-03-08',900]]); + const calendar=getCalendar(app,{from:'2026-03-07',to:'2026-03-08'}); + assert.deepEqual(calendar.days.map(row=>[row.day,row.seconds]),[['2026-03-07',600],['2026-03-08',900]]); + assert.equal(dashboardRange({},new Date('2026-01-01T01:00:00Z'),'America/Los_Angeles').to,'2025-12-31'); + setBookReadOverride(app,'tz',true,Date.parse('2026-03-08T04:45:00Z')/1000); + assert.equal(getDashboard(app,{from:'2026-03-07',to:'2026-03-07'}).range.read_books,1); + assert.throws(()=>resolveTimeZone('Not/A_Time_Zone'),/Invalid TZ value/); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('the same page-stat row is not double-counted across manual upload and plugin sync',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-session-dedupe-'));const app=openAppDb(path.join(dir,'data'));const p=fixture(dir); + const manual=importKoReaderDb(app,p);assert.equal(manual.newSessions,1); + const plugin=importPluginPayload(app,{books:[{md5:'abc123',title:'The Hobbit',authors:'J.R.R. Tolkien',total_read_time:5000,total_read_pages:80}],stats:[{book_md5:'abc123',page:80,start_time:1700000000,duration:600,total_pages:310}],annotation_sets:[]},{id:'reader1'}); + assert.equal(plugin.newSessions,0);assert.equal(app.prepare(`SELECT COUNT(*) n FROM reading_sessions`).get().n,1); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('plugin import advances an incremental high-water mark while preserving overlap idempotency',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-sync-cursor-'));const app=openAppDb(dir); + const payload={sync_cursor:1699999900,books:[{md5:'cursor-book',title:'Cursor Book'}],stats:[ + {book_md5:'cursor-book',page:1,start_time:1700000000,duration:60,total_pages:100}, + {book_md5:'cursor-book',page:2,start_time:1700000300,duration:90,total_pages:100}, + ],annotation_sets:[]}; + const first=importPluginPayload(app,payload,{id:'reader-cursor'}); + assert.equal(first.syncMode,'incremental');assert.equal(first.syncCursorBefore,1699999900);assert.equal(first.syncCursorAfter,1700000300);assert.equal(first.newSessions,2); + const repeated=importPluginPayload(app,{...payload,sync_cursor:first.syncCursorAfter},{id:'reader-cursor'}); + assert.equal(repeated.syncMode,'incremental');assert.equal(repeated.newSessions,0);assert.equal(repeated.syncCursorAfter,1700000300); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('device sync diagnostics record cursors and appear in status',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-sync-status-'));const app=openAppDb(dir); + const {code}=createPairingCode(app,'Libra');consumePairingCode(app,code,{deviceId:'reader-status',model:'Kobo Libra',version:'2026.09.25'}); + recordDeviceSync(app,'reader-status',{booksSeen:12,sessionsSeen:4,newSessions:2,annotationBooks:3,annotationsStored:5},{mode:'incremental',cursorBefore:100,cursorAfter:200}); + const status=getStatus(app);const device=status.devices.find(d=>d.id==='reader-status'); + assert.equal(device.sync_cursor,200);assert.equal(device.last_sync_mode,'incremental');assert.equal(device.last_sync_new_sessions,2); + assert.equal(status.recentSyncs[0].cursor_before,100);assert.equal(status.recentSyncs[0].cursor_after,200);assert.equal(status.recentSyncs[0].annotations_stored,5); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('invalid sqlite schema is rejected',()=>{const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-'));const p=path.join(dir,'bad.sqlite');const d=new DatabaseSync(p);d.exec('CREATE TABLE nope(x TEXT)');d.close();assert.throws(()=>validateKoReaderDb(p),/Not a KOReader/);fs.rmSync(dir,{recursive:true,force:true})}); + +test('pairing request exposes status and mints a revocable bearer identity',()=>{const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-'));const app=openAppDb(dir);const {code,requestId}=createPairingCode(app,'Kobo');assert.equal(getPairingStatus(app,requestId).status,'pending');const paired=consumePairingCode(app,code,{deviceId:'dev1',model:'Kobo Libra',version:'2026.09.25'});assert.ok(paired.token.startsWith('kv_'));assert.equal(consumePairingCode(app,code,{deviceId:'dev2'}),null);assert.equal(getPairingStatus(app,requestId).status,'paired');const dev=authenticateDevice(app,`Bearer ${paired.token}`);assert.equal(dev.id,'dev1');app.close();fs.rmSync(dir,{recursive:true,force:true})}); + +test('plugin import deduplicates sessions and replaces synced highlight text per device',()=>{const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-'));const app=openAppDb(dir);const payload={books:[{md5:'bookmd5',title:'Dune',authors:'Frank Herbert',pages:500,highlights:1}],stats:[{book_md5:'bookmd5',page:5,start_time:1700000000,duration:30,total_pages:500}],annotation_sets:[{book_md5:'bookmd5',annotations:[{datetime:'2026-08-30 08:00:00',text:'Fear is the mind-killer.',chapter:'Litany',pageno:20,total_pages:500}]}]};const device={id:'reader1'};const first=importPluginPayload(app,payload,device);assert.equal(first.newSessions,1);assert.equal(first.annotationsStored,1);assert.equal(getBook(app,app.prepare(`SELECT id FROM books WHERE source_md5='bookmd5'`).get().id).annotations[0].text,'Fear is the mind-killer.');const changed=structuredClone(payload);changed.annotation_sets[0].annotations=[{datetime:'2026-08-30 08:05:00',text:'A beginning is the time for taking the most delicate care.'}];const second=importPluginPayload(app,changed,device);assert.equal(second.newSessions,0);const anns=getBook(app,app.prepare(`SELECT id FROM books WHERE source_md5='bookmd5'`).get().id).annotations;assert.equal(anns.length,1);assert.match(anns[0].text,/beginning/);app.close();fs.rmSync(dir,{recursive:true,force:true})}); + +test('KOReader completion sync preserves actual progress and manual overrides win',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-read-status-'));const app=openAppDb(dir);const device={id:'reader-status'}; + const payload={books:[{md5:'finished-md5',title:'Fahrenheit 451',pages:100}],stats:[{book_md5:'finished-md5',page:53,start_time:1700000000,duration:60,total_pages:100}],annotation_sets:[{book_md5:'finished-md5',reading_status:'complete',reading_status_modified:'2023-11-14',annotations:[]}]}; + importPluginPayload(app,payload,device);const id=app.prepare(`SELECT id FROM books WHERE source_md5='finished-md5'`).get().id; + let book=getBook(app,id);assert.equal(book.last_page,53);assert.equal(book.koreader_status,'complete');assert.equal(book.koreader_status_modified,'2023-11-14');assert.equal(book.is_read,1);assert.equal(book.read_source,'koreader'); + payload.annotation_sets[0].reading_status='reading';payload.annotation_sets[0].reading_status_modified='2023-11-13';importPluginPayload(app,payload,device);assert.equal(getBook(app,id).koreader_status,'complete'); + payload.annotation_sets[0].reading_status='complete';payload.annotation_sets[0].reading_status_modified='2023-11-14'; + book=setBookReadOverride(app,id,false);assert.equal(book.is_read,0);assert.equal(book.read_source,'manual');assert.equal(book.read_override_at,null); + importPluginPayload(app,payload,device);assert.equal(getBook(app,id).is_read,0); + book=setBookReadOverride(app,id,true,1700001000);assert.equal(book.read_override_at,1700001000); + book=setBookReadOverride(app,id,null);assert.equal(book.is_read,1);assert.equal(book.read_source,'koreader');assert.equal(book.read_override_at,null);assert.equal(getDashboard(app).read_books,1); + payload.annotation_sets[0].reading_status='reading';payload.annotation_sets[0].reading_status_modified='2023-11-15';importPluginPayload(app,payload,device);assert.equal(getBook(app,id).is_read,0); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('KOReader manual is excluded from imports and existing libraries',()=>{assert.equal(isKoReaderManual('KOReader User Guide'),true);assert.equal(isKoReaderManual('Manuale utente KOReader'),true);assert.equal(isKoReaderManual('koreader'),true);const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-'));const app=openAppDb(path.join(dir,'data'));const p=fixture(dir,{includeManual:true});const r=importKoReaderDb(app,p);assert.equal(r.excludedBooks,1);assert.equal(app.prepare(`SELECT COUNT(*) n FROM books`).get().n,1);app.prepare(`INSERT INTO books(id,source_md5,title,created_at,updated_at) VALUES('m','m','KOReader Manual','x','x')`).run();assert.equal(removeExcludedBooks(app),1);app.close();fs.rmSync(dir,{recursive:true,force:true})}); + +test('cover matching prefers exact title and author',()=>{const score=scoreCandidate({title:'The Left Hand of Darkness',authors:'Ursula K. Le Guin',language:'eng'},{title:'The Left Hand of Darkness',author_name:['Ursula K. Le Guin'],cover_i:123,language:['eng']});assert.ok(score>.9);const wrong=scoreCandidate({title:'The Hobbit',authors:'J.R.R. Tolkien'},{title:'Hobbit Cookbook',author_name:['Someone Else'],cover_i:22});assert.ok(wrong<.72)}); + +test('cover resolver checks native catalogues in parallel and caches the strongest Open Library image', async()=>{const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-'));let calls=0;const fakeFetch=async(url)=>{calls++;if(String(url).startsWith('https://openlibrary.org/search.json')) return new Response(JSON.stringify({docs:[{title:'Dune',author_name:['Frank Herbert'],cover_i:42,language:['eng']}]}),{status:200,headers:{'Content-Type':'application/json'}});if(String(url).startsWith('https://www.googleapis.com/books/v1/volumes')) return new Response(JSON.stringify({totalItems:0,items:[]}),{status:200,headers:{'Content-Type':'application/json'}});const jpg=Buffer.from([0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0xff,0xd9]);return new Response(jpg,{status:200,headers:{'Content-Type':'image/jpeg','Content-Length':String(jpg.length)}})};const r=await resolveCover({id:'book1',title:'Dune',authors:'Frank Herbert',language:'eng'},{dataDir:dir,fetchImpl:fakeFetch});assert.equal(r.status,'matched');assert.equal(r.source,'openlibrary');assert.equal(calls,3);assert.ok(fs.existsSync(path.join(dir,r.path.replace(/^\//,''))));fs.rmSync(dir,{recursive:true,force:true})}); + +test('Italian edition titles and language preference can provide the cover', async()=>{const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-it-'));let searchUrl='';const jpg=Buffer.from([0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0xff,0xd9]);const fakeFetch=async(url)=>{if(String(url).startsWith('https://openlibrary.org/search.json')){searchUrl=String(url);return new Response(JSON.stringify({docs:[{title:'The Name of the Rose',author_name:['Umberto Eco'],editions:{docs:[{key:'/books/OL123M',title:'Il nome della rosa',cover_i:99,language:['ita']}]}}]}),{status:200})}return new Response(jpg,{status:200,headers:{'Content-Length':String(jpg.length)}})};const r=await resolveCover({id:'eco',title:'Il nome della rosa',authors:'Umberto Eco',language:'ita'},{dataDir:dir,fetchImpl:fakeFetch});assert.equal(r.status,'matched');assert.equal(r.matchedTitle,'Il nome della rosa');assert.match(searchUrl,/lang=it/);assert.match(searchUrl,/[?&]q=/);assert.doesNotMatch(searchUrl,/[?&]title=/);fs.rmSync(dir,{recursive:true,force:true})}); + + +test('Italian locale tags such as it-IT are normalized for cover lookup', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-itit-'));let searchUrl=''; + const jpg=Buffer.from([0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0xff,0xd9]); + const fakeFetch=async(url)=>{if(String(url).startsWith('https://openlibrary.org/search.json')){searchUrl=String(url);return new Response(JSON.stringify({docs:[{title:'Piranesi',author_name:['Susanna Clarke'],cover_i:4,language:['ita']}]}),{status:200})}return new Response(jpg,{status:200,headers:{'Content-Length':String(jpg.length)}})}; + const r=await resolveCover({id:'piranesi',title:'Piranesi',authors:'Susanna Clarke',language:'it-IT'},{dataDir:dir,fetchImpl:fakeFetch}); + assert.equal(r.status,'matched');assert.match(searchUrl,/lang=it/);fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('cover lookup falls back from author-constrained edition search to title-only edition search', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-fallback-'));const urls=[]; + const jpg=Buffer.from([0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0xff,0xd9]); + const fakeFetch=async(url)=>{urls.push(String(url));if(String(url).startsWith('https://openlibrary.org/search.json')){const u=new URL(url);const q=u.searchParams.get('q')||'';if(q.includes('author:'))return new Response(JSON.stringify({numFound:0,docs:[]}),{status:200});return new Response(JSON.stringify({numFound:1,docs:[{title:'Days at the Morisaki Bookshop',author_name:['Satoshi Yagisawa'],editions:{docs:[{key:'/books/OL40163582M',title:'I miei giorni alla libreria Morisaki',cover_i:401,language:['ita']}]}}]}),{status:200})}return new Response(jpg,{status:200,headers:{'Content-Length':String(jpg.length)}})}; + const r=await resolveCover({id:'morisaki',title:'I miei giorni alla libreria Morisaki',authors:'Satoshi Yagisawa',language:'it'},{dataDir:dir,fetchImpl:fakeFetch}); + assert.equal(r.status,'matched');assert.equal(r.matchedTitle,'I miei giorni alla libreria Morisaki');assert.ok(r.trace.some(x=>x.strategy==='edition-title'&&x.candidates>0));fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('Google Books joins the native fallback when Open Library has no candidate', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-google-'));const urls=[]; + const jpg=Buffer.from([0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0xff,0xd9]); + const fakeFetch=async(url)=>{urls.push(String(url));if(String(url).startsWith('https://openlibrary.org/search.json'))return new Response(JSON.stringify({numFound:0,docs:[]}),{status:200});if(String(url).startsWith('https://www.googleapis.com/books/v1/volumes'))return new Response(JSON.stringify({totalItems:1,items:[{id:'g1',volumeInfo:{title:'Il ladro linguanera',authors:['Christopher Buehlman'],language:'it',industryIdentifiers:[{type:'ISBN_13',identifier:'9788804717973'}]}}]}),{status:200});return new Response(jpg,{status:200,headers:{'Content-Length':String(jpg.length)}})}; + const r=await resolveCover({id:'blacktongue',title:'Il ladro linguanera',authors:'Christopher Buehlman',language:'it-IT'},{dataDir:dir,fetchImpl:fakeFetch,googleBooksApiKey:'test-key'}); + assert.equal(r.status,'matched');assert.equal(r.source,'openlibrary-via-googlebooks');assert.ok(urls.some(u=>u.includes('googleapis.com')&&u.includes('key=test-key')&&u.includes('langRestrict=it')));fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('Google Books native search works without requiring an API key', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-google-anon-'));const urls=[]; + const jpg=Buffer.from([0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0xff,0xd9]); + const fakeFetch=async(url)=>{urls.push(String(url));if(String(url).startsWith('https://openlibrary.org/search.json'))return new Response(JSON.stringify({numFound:0,docs:[]}),{status:200});if(String(url).startsWith('https://www.googleapis.com/books/v1/volumes'))return new Response(JSON.stringify({totalItems:1,items:[{id:'g2',volumeInfo:{title:'Piranesi',authors:['Susanna Clarke'],language:'en',imageLinks:{thumbnail:'https://books.google.com/cover.jpg'}}}]}),{status:200});return new Response(jpg,{status:200,headers:{'Content-Length':String(jpg.length)}})}; + const r=await resolveCover({id:'piranesi-anon',title:'Piranesi',authors:'Susanna Clarke',language:'en'},{dataDir:dir,fetchImpl:fakeFetch}); + assert.equal(r.status,'matched');assert.equal(r.source,'googlebooks');const googleUrl=urls.find(u=>u.includes('googleapis.com'));assert.ok(googleUrl);assert.doesNotMatch(googleUrl,/[?&]key=/);fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('KOReader sidecar identifiers enrich an existing book with a validated ISBN and requeue its cover',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-isbn-')); + const app=openAppDb(dir); + const device={id:'reader-isbn'}; + importPluginPayload(app,{books:[{md5:'translated-md5',title:'Il ladro linguanera',authors:'Christopher Buehlman',language:'it-IT'}],stats:[],annotation_sets:[]},device); + app.prepare(`UPDATE books SET cover_status='none' WHERE source_md5='translated-md5'`).run(); + const r=importPluginPayload(app,{books:[{md5:'translated-md5',title:'Il ladro linguanera',authors:'Christopher Buehlman',language:'it-IT'}],stats:[],annotation_sets:[{book_md5:'translated-md5',identifiers:'uuid:abc\\nisbn:9791259676450',annotations:[]}]},device); + const book=app.prepare(`SELECT isbn,cover_status FROM books WHERE source_md5='translated-md5'`).get(); + assert.equal(book.isbn,'9791259676450'); + assert.equal(book.cover_status,'pending'); + assert.deepEqual(r.coverRequests,['translated-md5']); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('Open Library cover lookup continues after a bad placeholder image and uses the next candidate', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-bad-image-')); + const jpg=Buffer.from([0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0xff,0xd9]); + const fakeFetch=async(url)=>{ + const u=String(url); + if(u.startsWith('https://openlibrary.org/search.json')) return new Response(JSON.stringify({numFound:2,docs:[ + {title:'Oltre le tenebre',author_name:['Brent Weeks'],cover_i:1,language:['ita']}, + {title:'Oltre le tenebre',author_name:['Brent Weeks'],cover_i:2,language:['ita']}, + ]}),{status:200}); + if(u.includes('/id/1-')) return new Response(Buffer.from('not an image'),{status:200,headers:{'Content-Type':'text/html'}}); + return new Response(jpg,{status:200,headers:{'Content-Type':'image/jpeg','Content-Length':String(jpg.length)}}); + }; + const r=await resolveCover({id:'weeks',title:'Oltre le tenebre',authors:'Brent Weeks',language:'it'},{dataDir:dir,fetchImpl:fakeFetch}); + assert.equal(r.status,'matched'); + assert.equal(r.source,'openlibrary'); + assert.ok(fs.existsSync(path.join(dir,r.path.replace(/^\//,'')))); + fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('series-prefixed translated titles try the volume title as a search variant', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-series-prefix-')); + const queries=[]; + const jpg=Buffer.from([0xff,0xd8,0xff,0xe0,0x00,0x10,0x4a,0x46,0x49,0x46,0x00,0xff,0xd9]); + const fakeFetch=async(url)=>{ + const u=new URL(String(url)); + if(u.hostname==='openlibrary.org'){ + const q=u.searchParams.get('q')||''; queries.push(q); + if(q.includes('"I grandi giochi"')) return new Response(JSON.stringify({numFound:1,docs:[{title:'I grandi giochi',author_name:['Jay Kristoff'],cover_i:77,language:['ita']}]}),{status:200}); + return new Response(JSON.stringify({numFound:0,docs:[]}),{status:200}); + } + return new Response(jpg,{status:200,headers:{'Content-Type':'image/jpeg','Content-Length':String(jpg.length)}}); + }; + const r=await resolveCover({id:'nevernight2',title:'Nevernight. I grandi giochi',authors:'Jay Kristoff',language:'it'},{dataDir:dir,fetchImpl:fakeFetch}); + assert.equal(r.status,'matched'); + assert.ok(queries.some(q=>q.includes('"I grandi giochi"'))); + fs.rmSync(dir,{recursive:true,force:true}); +}); + + +test('ISBN extraction validates common KOReader identifier forms',()=>{ + assert.equal(extractIsbn('uuid:abc\nisbn:9791259676450'),'9791259676450'); + assert.equal(extractIsbn('ISBN 978-88-04-71795-9'),'9788804717959'); + assert.equal(extractIsbn('isbn:1234567890123'),null); +}); + +test('authenticated embedded cover storage prefers the exact KOReader edition',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-embedded-cover-')); + const app=openAppDb(dir); + importPluginPayload(app,{books:[{md5:'exact-cover-md5',title:'Il Re Giallo',authors:'Robert W. Chambers',language:'it'}],stats:[],annotation_sets:[]},{id:'reader-cover'}); + const jpg=Buffer.alloc(128);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[126]=0xff;jpg[127]=0xd9; + const saved=saveEmbeddedCover(app,dir,'exact-cover-md5',jpg); + assert.equal(saved.ignored,false); + const book=app.prepare(`SELECT cover_status,cover_source,cover_path FROM books WHERE source_md5='exact-cover-md5'`).get(); + assert.equal(book.cover_status,'matched'); + assert.equal(book.cover_source,'koreader-embedded'); + assert.match(book.cover_path,/-koreader-.*\.jpg$/); + assert.ok(fs.existsSync(path.join(dir,book.cover_path.replace(/^\//,'')))); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('manual browser cover storage has highest priority',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-manual-cover-')); + const app=openAppDb(dir); + importPluginPayload(app,{books:[{md5:'manual-cover-md5',title:'Fahrenheit 451',authors:'Ray Bradbury'}],stats:[],annotation_sets:[]},{id:'reader-manual'}); + const jpg=Buffer.alloc(128);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[126]=0xff;jpg[127]=0xd9; + const saved=saveManualCover(app,dir,app.prepare(`SELECT id FROM books WHERE source_md5='manual-cover-md5'`).get().id,jpg); + assert.match(saved.path,/-manual-.*\.jpg$/); + const book=app.prepare(`SELECT cover_status,cover_source,cover_path,cover_retry_mode FROM books WHERE source_md5='manual-cover-md5'`).get(); + assert.equal(book.cover_status,'manual');assert.equal(book.cover_source,'manual-upload');assert.equal(book.cover_retry_mode,null); + const embedded=saveEmbeddedCover(app,dir,'manual-cover-md5',jpg); + assert.equal(embedded.ignored,true);assert.equal(embedded.reason,'manual-cover'); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + + +test('cover history can restore an older selection and promote a cached candidate',()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-history-'));const app=openAppDb(dir); + importPluginPayload(app,{books:[{md5:'history-md5',title:'History Book',authors:'A. Writer'}],stats:[],annotation_sets:[]},{id:'reader-history'}); + const bookId=app.prepare(`SELECT id FROM books WHERE source_md5='history-md5'`).get().id; + const jpg=Buffer.alloc(128);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[126]=0xff;jpg[127]=0xd9; + saveEmbeddedCover(app,dir,'history-md5',jpg);const embedded=getBookCoverState(app,bookId).history.find(h=>h.source==='koreader-embedded'); + saveManualCover(app,dir,bookId,jpg);let state=getBookCoverState(app,bookId);assert.equal(state.history.length,2);assert.equal(state.book.cover_source,'manual-upload'); + assert.equal(selectCoverHistory(app,bookId,embedded.id),true);state=getBookCoverState(app,bookId);assert.equal(state.book.cover_source,'koreader-embedded'); + const candidatePath='/covers/history-candidate.jpg';fs.writeFileSync(path.join(dir,'covers','history-candidate.jpg'),jpg); + app.prepare(`INSERT INTO cover_candidates(id,book_id,path,source,strategy,score,title,authors,created_at) VALUES('candidate-1',?,?,?,?,?,?,?,?)`).run(bookId,candidatePath,'openlibrary','test-candidate',0.98,'History Book','A. Writer',new Date().toISOString()); + assert.equal(selectCoverCandidate(app,bookId,'candidate-1'),true);state=getBookCoverState(app,bookId);assert.equal(state.book.cover_source,'openlibrary');assert.equal(state.history[0].path,candidatePath);assert.equal(state.history[0].selected,1); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('explicit online retry can replace a KOReader embedded cover',async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-online-retry-')); + const app=openAppDb(dir); + importPluginPayload(app,{books:[{md5:'retry-md5',title:'Fahrenheit 451',authors:'Ray Bradbury',language:'it-IT'}],stats:[],annotation_sets:[]},{id:'reader-retry'}); + const jpg=Buffer.alloc(128);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[126]=0xff;jpg[127]=0xd9; + saveEmbeddedCover(app,dir,'retry-md5',jpg); + const book=app.prepare(`SELECT id,title,authors,language,isbn,identifiers FROM books WHERE source_md5='retry-md5'`).get(); + app.prepare(`UPDATE books SET cover_status='pending',cover_retry_mode='online' WHERE id=?`).run(book.id); + await processCoverBook(app,dir,book,{resolver:async()=>({status:'matched',path:'/covers/retry-online.jpg',source:'openlibrary',score:1,matchedTitle:'Fahrenheit 451',strategy:'test',trace:[]})}); + const after=app.prepare(`SELECT cover_status,cover_source,cover_path,cover_retry_mode FROM books WHERE id=?`).get(book.id); + assert.equal(after.cover_status,'matched');assert.equal(after.cover_source,'openlibrary');assert.equal(after.cover_path,'/covers/retry-online.jpg');assert.equal(after.cover_retry_mode,null); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('failed explicit online retry keeps the existing KOReader cover and clears pending state',async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-retry-fallback-')); + const app=openAppDb(dir); + importPluginPayload(app,{books:[{md5:'retry-fallback-md5',title:'Fahrenheit 451',authors:'Ray Bradbury'}],stats:[],annotation_sets:[]},{id:'reader-retry-fallback'}); + const jpg=Buffer.alloc(128);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[126]=0xff;jpg[127]=0xd9; + saveEmbeddedCover(app,dir,'retry-fallback-md5',jpg); + const before=app.prepare(`SELECT id,title,authors,language,isbn,identifiers,cover_path FROM books WHERE source_md5='retry-fallback-md5'`).get(); + app.prepare(`UPDATE books SET cover_status='pending',cover_retry_mode='online' WHERE id=?`).run(before.id); + await processCoverBook(app,dir,before,{resolver:async()=>({status:'none',score:0.4,trace:[{provider:'test'}]})}); + const after=app.prepare(`SELECT cover_status,cover_source,cover_path,cover_retry_mode FROM books WHERE id=?`).get(before.id); + assert.equal(after.cover_status,'matched');assert.equal(after.cover_source,'koreader-embedded');assert.equal(after.cover_path,before.cover_path);assert.equal(after.cover_retry_mode,null); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('stale pending local cover is repaired instead of being retried forever',async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-cover-pending-local-')); + const app=openAppDb(dir); + importPluginPayload(app,{books:[{md5:'stale-pending-md5',title:'Book'}],stats:[],annotation_sets:[]},{id:'reader-stale'}); + const jpg=Buffer.alloc(128);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[126]=0xff;jpg[127]=0xd9; + saveEmbeddedCover(app,dir,'stale-pending-md5',jpg); + const book=app.prepare(`SELECT id,title,authors,language,isbn,identifiers FROM books WHERE source_md5='stale-pending-md5'`).get(); + app.prepare(`UPDATE books SET cover_status='pending',cover_retry_mode=NULL WHERE id=?`).run(book.id); + let called=false; + await processCoverBook(app,dir,book,{resolver:async()=>{called=true;throw new Error('should not run')}}); + const after=app.prepare(`SELECT cover_status,cover_source FROM books WHERE id=?`).get(book.id); + assert.equal(called,false);assert.equal(after.cover_status,'matched');assert.equal(after.cover_source,'koreader-embedded'); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); + + +test('Calibre CLI arguments are passed as data, not a shell command',()=>{ + const args=calibreArgsForBook({title:'Book; touch /tmp/pwned',authors:'Author $(id)',isbn:'9791259676450'},'/tmp/cover.jpg'); + assert.deepEqual(args.slice(0,4),['--title','Book; touch /tmp/pwned','--cover','/tmp/cover.jpg']); + assert.ok(args.includes('Author $(id)')); + assert.ok(args.includes('9791259676450')); +}); + +test('Calibre resolver accepts the cover produced by fetch-ebook-metadata', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-calibre-cli-')); + const jpg=Buffer.alloc(256);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[254]=0xff;jpg[255]=0xd9; + const fakeExec=(file,args,opts,cb)=>{ + assert.equal(file,'fetch-ebook-metadata'); + const idx=args.indexOf('--cover'); assert.ok(idx>=0); + fs.writeFileSync(args[idx+1],jpg); + cb(null,'metadata ok',''); + }; + const r=await fetchCoverWithCalibre({id:'cal1',title:'Il ladro linguanera',authors:'Christopher Buehlman'},{dataDir:dir,execFileImpl:fakeExec}); + assert.equal(r.status,'matched');assert.equal(r.buffer.length,jpg.length); + fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('cover resolver falls back to an injected Calibre-style multi-source result after native catalogue misses', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-calibre-cover-')); + const jpg=Buffer.alloc(512);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[510]=0xff;jpg[511]=0xd9; + let nativeCalls=0; + const fakeFetch=async()=>{nativeCalls++;return new Response(JSON.stringify({numFound:0,docs:[]}),{status:200})}; + const calibreResolver=async()=>({status:'matched',buffer:jpg}); + const r=await resolveCover({id:'calibrebook',title:'Il Re Giallo',authors:'Robert W. Chambers',language:'it'},{dataDir:dir,fetchImpl:fakeFetch,calibreResolver}); + assert.equal(r.status,'matched');assert.equal(r.source,'calibre');assert.ok(nativeCalls>0); + assert.match(r.path,/-calibre-.*\.jpg$/); + fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('reordered translated series titles clear the confidence threshold',()=>{ + const score=scoreCandidate( + {title:'Nevernight. I grandi giochi',authors:'Jay Kristoff',language:'it'}, + {title:'I grandi giochi. Nevernight',author_name:[],cover_i:77,language:['ita']} + ); + assert.ok(score>=.72,`expected reordered exact token set to match, got ${score}`); +}); + +test('Calibre resolver runs cover-only stage when identify cannot find a book', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-calibre-direct-')); + const jpg=Buffer.alloc(320);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[318]=0xff;jpg[319]=0xd9; + let identifyCalls=0,directCalls=0; + const fakeExec=(file,args,opts,cb)=>{ + if(file==='fetch-ebook-metadata'){ + identifyCalls++; + const err=Object.assign(new Error('no results'),{code:1}); + return cb(err,'','No results found'); + } + if(file==='calibre-debug'){ + directCalls++; + assert.deepEqual(args.slice(0,1),['--command']); + assert.equal(opts.env.KOVI_CALIBRE_TITLE,'Il diavolo nella bottiglia'); + fs.writeFileSync(opts.env.KOVI_CALIBRE_OUTPUT,jpg); + return cb(null,'provider=Google Images',''); + } + cb(Object.assign(new Error('unexpected binary'),{code:'ENOENT'}),'',''); + }; + const r=await fetchCoverWithCalibre({id:'cal-direct',title:'Il diavolo nella bottiglia',authors:'Robert Louis Stevenson'},{dataDir:dir,execFileImpl:fakeExec}); + assert.equal(r.status,'matched'); + assert.equal(r.buffer.length,jpg.length); + assert.ok(identifyCalls>=1); + assert.equal(directCalls,1); + fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('Calibre preserves machine-readable identifiers when cover plugins fail', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-calibre-meta-')); + let directCalls=0,metadataCalls=0; + const fakeExec=(file,args,opts,cb)=>{ + if(file==='fetch-ebook-metadata') return cb(null,'','No cover found'); + if(file==='calibre-debug' && opts.env.KOVI_CALIBRE_OUTPUT){ + directCalls++; + return cb(null,'no-cover',''); + } + if(file==='calibre-debug'){ + metadataCalls++; + return cb(null,'KOVI_METADATA_JSON=[{"title":"La montagna dei gatti. Fiabe e leggende del terzo fratello Grimm","authors":["Ferdinand Grimm"],"identifiers":{"google":"abc123","isbn":"9781234567897"},"languages":["ita"]}]',''); + } + cb(Object.assign(new Error('unexpected binary'),{code:'ENOENT'}),'',''); + }; + const r=await fetchCoverWithCalibre({id:'meta1',title:'La montagna dei gatti',authors:'Ferdinand Grimm'},{dataDir:dir,execFileImpl:fakeExec}); + assert.equal(r.status,'none'); + assert.equal(r.metadataCandidates.length,1); + assert.equal(r.metadataCandidates[0].identifiers.google,'abc123'); + assert.ok(directCalls>=1);assert.equal(metadataCalls,1); + fs.rmSync(dir,{recursive:true,force:true}); +}); + +test('cover resolver reuses Calibre Google Books identifiers after Calibre image download fails', async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-calibre-google-id-')); + const jpg=Buffer.alloc(420);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[418]=0xff;jpg[419]=0xd9; + const urls=[]; + const fakeFetch=async(url)=>{ + urls.push(String(url)); + if(String(url).startsWith('https://openlibrary.org/search.json')) return new Response(JSON.stringify({numFound:0,docs:[]}),{status:200}); + if(String(url).startsWith('https://covers.openlibrary.org/')) return new Response('',{status:404}); + if(String(url).startsWith('https://books.google.com/books?id=gvol1')) return new Response(jpg,{status:200,headers:{'Content-Type':'image/jpeg','Content-Length':String(jpg.length)}}); + return new Response('',{status:404}); + }; + const calibreResolver=async()=>({status:'none',metadataCandidates:[{title:'La montagna dei gatti',authors:['Ferdinand Grimm'],identifiers:{google:'gvol1'},languages:['ita']} ]}); + const r=await resolveCover({id:'gmeta',title:'La montagna dei gatti',authors:'Ferdinand Grimm',language:'it'},{dataDir:dir,fetchImpl:fakeFetch,calibreResolver}); + assert.equal(r.status,'matched'); + assert.equal(r.source,'googlebooks-via-calibre-id'); + assert.ok(urls.some(u=>u.includes('books.google.com/books?id=gvol1'))); + fs.rmSync(dir,{recursive:true,force:true}); +}); + + +test('backup archive contains a consistent database snapshot, manifest, and selected cover files',async()=>{ + const dir=fs.mkdtempSync(path.join(os.tmpdir(),'kovi-backup-'));const app=openAppDb(dir); + importPluginPayload(app,{books:[{md5:'backup-md5',title:'Backup Book'}],stats:[],annotation_sets:[]},{id:'reader-backup'}); + const bookId=app.prepare(`SELECT id FROM books WHERE source_md5='backup-md5'`).get().id; + const jpg=Buffer.alloc(128);jpg[0]=0xff;jpg[1]=0xd8;jpg[2]=0xff;jpg[3]=0xe0;jpg[126]=0xff;jpg[127]=0xd9; + const saved=saveManualCover(app,dir,bookId,jpg);const output=path.join(dir,'kovi-backup.tar.gz'); + const result=await createBackupArchive(app,dir,output,{version:'2026.09.25'});assert.equal(result.covers,1);assert.ok(fs.statSync(output).size>512); + const tar=gunzipSync(fs.readFileSync(output));const text=tar.toString('latin1');assert.ok(text.includes('manifest.json'));assert.ok(text.includes('kovi.sqlite'));assert.ok(text.includes(saved.path.replace(/^\//,'')));assert.ok(text.includes('2026.09.25')); + app.close();fs.rmSync(dir,{recursive:true,force:true}); +}); diff --git a/tests/plugin.test.mjs b/tests/plugin.test.mjs new file mode 100644 index 0000000..29c034d --- /dev/null +++ b/tests/plugin.test.mjs @@ -0,0 +1,63 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const callApi=fs.readFileSync(new URL('../plugins/kovi.koplugin/call_api.lua', import.meta.url),'utf8'); +const rawHttp=fs.readFileSync(new URL('../plugins/kovi.koplugin/raw_http.lua', import.meta.url),'utf8'); +const upload=fs.readFileSync(new URL('../plugins/kovi.koplugin/upload.lua', import.meta.url),'utf8'); +const annotations=fs.readFileSync(new URL('../plugins/kovi.koplugin/annotation_reader.lua', import.meta.url),'utf8'); +const settings=fs.readFileSync(new URL('../plugins/kovi.koplugin/settings.lua', import.meta.url),'utf8'); +const server=fs.readFileSync(new URL('../server.mjs', import.meta.url),'utf8'); +const main=fs.readFileSync(new URL('../plugins/kovi.koplugin/main.lua', import.meta.url),'utf8'); + +test('plugin has a direct TCP/HTTP path plus KOReader HTTP fallback',()=>{assert.match(rawHttp,/socket\.tcp\(\)/);assert.match(rawHttp,/tcp:connect\(parsed\.host, parsed\.port\)/);assert.match(rawHttp,/HTTP\/1\.1/);assert.match(callApi,/RawHttp\.request/);assert.match(callApi,/Fall back to KOReader's standard HTTP stack/);assert.match(callApi,/socket\.skip\(1, http\.request\(request\)\)/)}); + +test('plugin reports the network stage instead of generic Network Error',()=>{assert.match(callApi,/kovi .* error/);assert.match(callApi,/stage == "tcp"/);assert.match(upload,/TCP connection to/);assert.doesNotMatch(callApi,/error = "Network Error"/)}); + +test('pairing performs layered diagnostics before consuming the code',()=>{assert.match(upload,/function U\.diagnose/);assert.match(upload,/RawHttp\.probe/);assert.match(upload,/local reachable, diagnostic = U\.diagnose\(server_url\)/);assert.match(upload,/\/api\/plugin\/ping/);assert.match(server,/plugin\.ping/)}); + +test('plugin rejects addresses that cannot point to the kovi host',()=>{assert.match(settings,/localhost/);assert.match(settings,/127%\./);assert.match(settings,/0%\.0%\.0%\.0/);assert.match(settings,/LAN IP/)}); + +test('plugin ensures a non-empty KOReader device id',()=>{assert.match(upload,/require\("random"\)/);assert.match(upload,/random\.uuid\(\)/);assert.match(upload,/saveSetting\("device_id", id\)/)}); + +test('plugin exposes a standalone layered connection test',()=>{assert.match(main,/Test server connection/);assert.match(main,/Upload\.diagnose\(url\)/)}); + +test('plugin scans KOReader sidecars and sends annotation sets with normal sync',()=>{assert.match(annotations,/require\("docsettings"\)/);assert.match(annotations,/require, "readhistory"/);assert.match(annotations,/readSetting\("annotations"\)/);assert.match(annotations,/partial_md5_checksum/);assert.match(upload,/Annotations\.changed\(previous_versions\)/)}); + +test('plugin syncs dated KOReader completion status and rescans existing sidecars once after upgrade',()=>{assert.match(annotations,/readSetting\("summary"\)/);assert.match(annotations,/reading_status = summary\.status/);assert.match(annotations,/reading_status_modified = summary\.modified/);assert.match(upload,/kovi_completion_sync_version/);assert.match(upload,/needs_completion_scan/)}); + +const coverReader=fs.readFileSync(new URL('../plugins/kovi.koplugin/cover_reader.lua', import.meta.url),'utf8'); + +test('plugin enriches sync from doc_props identifiers and can upload exact embedded covers',()=>{ + assert.match(annotations,/readSetting\("doc_props"\)/); + assert.match(annotations,/identifiers = props\.identifiers/); + assert.match(coverReader,/FileManagerBookInfo:getCoverImage\(nil, file_path\)/); + assert.match(coverReader,/writeToFile\(tmp, "jpg", 82, false\)/); + assert.match(upload,/response\.cover_requests/); + assert.match(upload,/X-Kovi-Book-MD5/); + assert.match(upload,/\/api\/plugin\/cover/); + assert.match(server,/cover\.embedded\.saved/); +}); + +test('server detects plugins too old for embedded-cover requests',()=>{ + assert.match(server,/MIN_COVER_PLUGIN_VERSION/); + assert.match(server,/plugin\.outdated\.cover-sync/); + assert.match(server,/update_recommended/); +}); + +test('packaged plugin version is aligned with kovi 2026.09.25',()=>{ + const constants=fs.readFileSync(new URL('../plugins/kovi.koplugin/const.lua', import.meta.url),'utf8'); + assert.match(constants,/2026\.09\.25/); +}); + + +test('plugin uses a persisted sync cursor with a one-day reading overlap',()=>{ + const dbReader=fs.readFileSync(new URL('../plugins/kovi.koplugin/db_reader.lua', import.meta.url),'utf8'); + assert.match(upload,/kovi_sync_cursor/);assert.match(upload,/sync_cursor = cursor/);assert.match(upload,/response\.sync_cursor/); + assert.match(dbReader,/function Reader\.progressData\(sync_cursor, books\)/);assert.match(dbReader,/OVERLAP_SECONDS = 24 \* 60 \* 60/);assert.match(dbReader,/cursor - OVERLAP_SECONDS/);assert.match(dbReader,/start_time >=/); +}); + +test('plugin only opens changed annotation sidecars and sends deletion tombstones',()=>{ + assert.match(upload,/kovi_annotation_versions/);assert.match(upload,/Annotations\.changed\(previous_versions\)/);assert.match(upload,/saveSetting\("kovi_annotation_versions", annotation_versions\)/); + assert.match(annotations,/lfs/);assert.match(annotations,/signature/);assert.match(annotations,/previous_versions/);assert.match(annotations,/present_md5/);assert.match(annotations,/annotations = \{\}/); +});