diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7380755 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,22 @@ +node_modules +.git +.gitignore +.env +.env.* +!.env.example +unformatted-books +formatted-books +test +FilesPage.html +*.epub +*.mobi +*.azw +*.azw3 +*.kepub +*.fb2 +*.pdf +*.cbz +*.cbr +*.djvu +.DS_Store +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..599dbcb --- /dev/null +++ b/.env.example @@ -0,0 +1,37 @@ +# --- atproto account ------------------------------------------------------- +# Used by `sinker login`; you can also pass --handle. +ATPROTO_HANDLE=you.bsky.social + +# --- BookHive -------------------------------------------------------------- +BOOKHIVE_URL=https://bookhive.buzz +BOOKHIVE_DID=did:plc:enu2j5xjlqsjaylv3du4myh4 + +# --- Folders --------------------------------------------------------------- +# Relative paths resolve against the working directory. +SINKER_SOURCE_DIR=./unformatted-books +SINKER_OUTPUT_DIR=./formatted-books +SINKER_STATE_DIR=~/.local/state/cross-reader-sinker +SINKER_OAUTH_DIR=~/.config/cross-reader-sinker + +# --- Conversion ------------------------------------------------------------ +# X4 = 480x800, X3 = 528x792 +SINKER_DEVICE=X4 +SINKER_QUALITY=85 +SINKER_AUTO_CROP=false +# Upload/archive as "Title - Author.epub" +SINKER_RENAME=true + +# --- Login ----------------------------------------------------------------- +SINKER_LOGIN_PORT=8765 +# 0.0.0.0 is only needed in Docker (published port -> container). +SINKER_LOGIN_HOST=127.0.0.1 + +# --- Watch ----------------------------------------------------------------- +# Poll the filesystem on network volumes / SMB shares. +SINKER_WATCH_POLL=false +# Scan existing files on startup. +SINKER_BACKFILL=true +# Also catch missed events and retry transient failures. +SINKER_RESCAN_MS=300000 +SINKER_RETRY_MAX=5 +SINKER_TIMEOUT_MS=120000 diff --git a/.gitignore b/.gitignore index a1c08fd..87f7c67 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ node_modules/ .DS_Store +# local config (keep .env.example) +.env +.env.local + # ebooks formatted-books/ unformatted-books/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5269959 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 + +# --------------------------------------------------------------------------- +# Dependencies (sharp ships prebuilt linux-x64/arm64 binaries, so no build +# toolchain is needed on glibc images). +# --------------------------------------------------------------------------- +FROM node:22-bookworm-slim AS deps + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + + +# --------------------------------------------------------------------------- +# Runtime +# --------------------------------------------------------------------------- +FROM node:22-bookworm-slim + +ENV NODE_ENV=production \ + SINKER_SOURCE_DIR=/books/in \ + SINKER_OUTPUT_DIR=/books/out \ + SINKER_STATE_DIR=/data/state \ + SINKER_OAUTH_DIR=/data/oauth \ + SINKER_LOGIN_HOST=0.0.0.0 + +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY src ./src + +RUN groupadd --system sinker \ + && useradd --system --gid sinker --home-dir /data --shell /usr/sbin/nologin sinker \ + && mkdir -p /data/state /data/oauth /books/in /books/out \ + && chown -R sinker:sinker /data /books /app + +USER sinker + +VOLUME ["/data", "/books/in", "/books/out"] + +ENTRYPOINT ["node", "src/sinker-cli.js"] +CMD ["watch"] diff --git a/README.md b/README.md index 5b61b25..5ac6696 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,231 @@ # Cross Reader Sinker -Some of us may remember the joke "I'm going rename my iPod to the titanic so iTunes says "syncing the titanic." Well I took this the opposite way and called it "Sinker" like syncer, but funnily enough, it does no syncing yet. So it's all a lie. +Should be called CrossPoint Reader Sinker, but eh. Forgot what the firmware was called there for a min. +Optimizes EPUB files for xteink e-ink readers running CrossPoint firmware, and syncs them to a +[BookHive](https://bookhive.buzz) personal library so they can be pulled down over OPDS. -@nickthesick.com added OPDS support to @bookhive.buzz a while back and this is how I load ebooks onto my CrossReader now, Nick was even kind of enough to add XRPC endpoints so I could finish building this out and automate uploading them to Bookhive's OPDS, but I um. I haven't added that yet... +There are two commands: -So what does this do for now? Well, it runs the compression and organization that CrossReader does when you upload a book to the device via the web portal, then I manually upload it to Bookhive's OPDS. I want the optimizations ran because it greatly lower the book size and some other things to help the book read on the resource constrained xteink devices. The plan is a folder I just drop ebooks in and it auto optimizes them, checks if they have been uploaded, then uploads them so I can download them onto my reader. +| Command | What it does | +| --------------- | -------------------------------------------------------------------------------- | +| `epub-optimize` | The original one-shot converter. Takes a folder or file, writes optimized EPUBs. | +| `sinker` | OAuth login, BookHive sync, folder watcher, status/logout. | -The source code for the optimizations is found in [FilesPage.html](./FilesPage.html) which was taken from [crosspoint-reader/crosspoint-reader](https://github.com/crosspoint-reader/crosspoint-reader/blob/develop/src/network/html/FilesPage.html), all of the amazing optimizations are done by them. This just puts it as a CLI tool +## What the optimizer does + +A close port of the compression pipeline in CrossPoint's +[`FilesPage.html`](./FilesPage.html): + +- resizes images to the target screen and re-encodes them as grayscale baseline JPEG +- fixes SVG-wrapped covers and SVG image references +- removes stale `width`/`height` attributes +- patches the OPF manifest and NCX identifier +- injects defensive CSS so images/tables cannot overflow the page +- writes `mimetype` first and uncompressed, per the EPUB OCF spec + +Originals are never modified. + +## Install + +```sh +npm install +``` + +Requires Node 20+ (developed on Node 24). `sharp` ships prebuilt binaries; no build toolchain is +needed on macOS, Linux or Windows. + +## Optimize without syncing + +```sh +npx epub-optimize ./unformatted-books --out ./formatted-books --device X4 --quality 85 --rename +``` + +| Flag | Meaning | +| ----------------- | ---------------------------------------------------- | +| `--out DIR` | output folder (default `/optimized`) | +| `--device X4\|X3` | X4 = 480x800 (default), X3 = 528x792 | +| `--quality N` | JPEG quality, 1–100 (default 85) | +| `--auto-crop` | trim uniform margins before resizing | +| `--rename` | name output `Title - Author.epub` from EPUB metadata | + +## Sync to BookHive + +### 1. Log in once + +```sh +cp .env.example .env +# edit ATPROTO_HANDLE +mkdir -p unformatted-books formatted-books +npx sinker login +``` + +This opens a browser, authorizes a loopback OAuth client, and stores the session under +`~/.config/cross-reader-sinker`. The requested scopes are deliberately narrow: + +``` +atproto +rpc:buzz.bookhive.getPersonalLibrary?aud=* +rpc:buzz.bookhive.getPersonalBook?aud=* +rpc:buzz.bookhive.uploadPersonalBook?aud=* +``` + +BookHive does not accept OAuth access tokens directly — its XRPC methods take an atproto +inter-service auth JWT. So each call uses the OAuth session to mint a 60-second token from your PDS +(`com.atproto.server.getServiceAuth`) and posts that to `bookhive.buzz`. + +> Sessions for public loopback clients refresh indefinitely while used, but expire after roughly two +> weeks of inactivity. If `sinker` says the session can no longer be refreshed, run `sinker login` +> again. + +### 2. Sync once, or keep watching + +```sh +# one pass: compress and upload anything new +npx sinker sync + +# or run as a service +npx sinker watch +``` + +`watch` keeps running until Ctrl+C, waits for files to finish copying, serializes uploads, and +rescans periodically to catch missed filesystem events and retry transient failures. + +### 3. Pull books on the reader + +BookHive's OPDS catalog serves whatever is in your personal library — no extra step needed. Uploaded +books are named `Title - Author.epub` so cross-reader filename matching and KOSync progress work. + +## How "don't upload it twice" works + +BookHive identifies personal-library files by a **KOReader partial MD5** (12 × 1 KB windows), which it +calls `contentHash`. `sinker` computes the same value and checks, in order: + +1. **Local ledger** — `(path, size, mtime)` already uploaded or skipped. +2. **Source hash** — the unchanged source file is already in BookHive (catches books you uploaded by + hand before using this tool). +3. **Title + author** — normalized the way BookHive parses EPUB metadata (`dc:title`, all + `dc:creator` joined with `, `). Catches compressed copies uploaded from another machine, where the + bytes differ. +4. **Compressed hash** — the exact compressed output is already there. + +Anything matched is skipped. `--force` overrides all of it. Nothing is ever deleted from BookHive. + +The ledger lives in `SINKER_STATE_DIR` (one JSON file per atproto account) and is written atomically. + +## Configuration + +CLI flags override `.env`, which overrides defaults. `.env` is loaded from the working directory; +use `--env-file FILE` for another file. + +| Variable | Default | Meaning | +| ------------------- | ------------------------------------ | ------------------------------------------ | +| `ATPROTO_HANDLE` | — | account to log in as | +| `BOOKHIVE_URL` | `https://bookhive.buzz` | BookHive base URL | +| `BOOKHIVE_DID` | `did:plc:enu2j5xjlqsjaylv3du4myh4` | BookHive service DID | +| `SINKER_SOURCE_DIR` | `./unformatted-books` | folder to watch (top level only) | +| `SINKER_OUTPUT_DIR` | `./formatted-books` | compressed archive | +| `SINKER_STATE_DIR` | `~/.local/state/cross-reader-sinker` | sync ledger | +| `SINKER_OAUTH_DIR` | `~/.config/cross-reader-sinker` | OAuth session | +| `SINKER_DEVICE` | `X4` | `X4` or `X3` | +| `SINKER_QUALITY` | `85` | JPEG quality | +| `SINKER_AUTO_CROP` | `false` | trim margins | +| `SINKER_RENAME` | `true` | upload/archive as `Title - Author.epub` | +| `SINKER_LOGIN_PORT` | `8765` | loopback callback port | +| `SINKER_LOGIN_HOST` | `127.0.0.1` | callback bind address | +| `SINKER_WATCH_POLL` | `false` | poll instead of FSEvents (network volumes) | +| `SINKER_BACKFILL` | `true` | scan existing files on startup | +| `SINKER_RESCAN_MS` | `300000` | periodic rescan interval | +| `SINKER_RETRY_MAX` | `5` | upload attempts | +| `SINKER_TIMEOUT_MS` | `120000` | per-request timeout | + +### Commands + +```sh +npx sinker login [--handle HANDLE] [--port 8765] [--no-open] +npx sinker logout +npx sinker status [--json] [--check] +npx sinker sync [file…] [--dry-run] [--force] [--retry-failed] +npx sinker watch [--no-backfill] [--poll] [--dry-run] +``` + +`--dry-run` authenticates and reports what it _would_ upload, but writes no files, uploads nothing, +and does not touch the ledger. Good for a first run. `--check` verifies the session against BookHive +and reports library size and storage usage. + +## Docker + +```sh +docker compose build + +# one-time login; the published port forwards the browser callback into the container +docker compose run --rm -it --service-ports sinker login --no-open + +# run the watcher +docker compose up -d +docker compose logs -f +``` + +The compose file mounts `./unformatted-books`, `./formatted-books`, and a named volume for the OAuth +session + ledger. Set `SINKER_LOGIN_HOST=0.0.0.0` (already set in the image) so the callback reaches +the container. + +Standalone: + +```sh +docker run --rm -it \ + -p 8765:8765 \ + -v "$PWD/unformatted-books:/books/in" \ + -v "$PWD/formatted-books:/books/out" \ + -v sinker-data:/data \ + cross-reader-sinker login --no-open +``` + +## Limits and behavior + +- Only `.epub` files at the top level of the source folder are processed; everything else is ignored. +- BookHive accepts up to 100 MB per file (default 2 GiB library quota). +- `Busy`, 429s, 5xx and network errors are retried with exponential backoff. +- Permanent failures (quota, unsupported format, unreadable EPUB) are recorded and skipped on later + runs. Fix the cause and use `--retry-failed` to try again. +- A book that already exists but whose source changed (e.g. a new edition) is skipped by metadata; + use `--force` to upload it as a second entry. +- No remote deletion, ever. +- The login port is part of the OAuth client identity. It is saved with the session, so later commands + reuse the port that `login` used; changing it only affects a _new_ login. + +## Troubleshooting + +| Message | Fix | +| ----------------------------------------------------- | --------------------------------------------------------------------- | +| `not signed in` | `npx sinker login` | +| `session ... can no longer be refreshed` | Log in again; sessions expire after ~2 weeks idle | +| `port 8765 is already in use` | `npx sinker login --port 8766` | +| Docker login URL opens but the callback never arrives | Use `docker compose run --rm --service-ports`, or `-p 8765:8765` | +| `QuotaExceeded` | Remove books in BookHive, or retry later with `--retry-failed` | +| A file is ignored | Only top-level `.epub` files are processed; check `SINKER_SOURCE_DIR` | + +## Development + +```sh +npm test # unit + pipeline tests (no network) +npm run fixture # build EPUB fixtures +npm run verify # verify optimizer output +``` + +Layout: + +``` +src/sinker-cli.js command dispatch +src/sinker/config.js .env + flag merge +src/sinker/auth.js OAuth loopback client, file stores, login/logout +src/sinker/bookhive.js service JWT minting, XRPC calls, retries +src/sinker/hash.js KOReader partial MD5 +src/sinker/metadata.js OPF title/creators +src/sinker/dedupe.js normalization + library index +src/sinker/state.js atomic per-account ledger +src/sinker/pipeline.js compress → dedupe → upload +src/sinker/watcher.js chokidar watcher + queue +``` + +The optimization logic comes from [crosspoint-reader/crosspoint-reader](https://github.com/crosspoint-reader/crosspoint-reader/blob/develop/src/network/html/FilesPage.html); +the CLI is a faithful Node port. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..2b1c872 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,27 @@ +services: + sinker: + build: . + image: cross-reader-sinker + restart: unless-stopped + command: ["watch"] + ports: + # Only used by `docker compose run --service-ports ... login`; the + # watcher itself does not listen on anything. + - "8765:8765" + volumes: + # Drop EPUBs here and they are compressed + uploaded automatically. + - ./unformatted-books:/books/in + # Compressed archive copies land here. + - ./formatted-books:/books/out + # OAuth session + sync ledger. Keep this volume across rebuilds. + - sinker-data:/data + environment: + # Optional while watching; only used by `login`. + ATPROTO_HANDLE: ${ATPROTO_HANDLE:-} + SINKER_DEVICE: ${SINKER_DEVICE:-X4} + SINKER_QUALITY: ${SINKER_QUALITY:-85} + SINKER_RENAME: ${SINKER_RENAME:-true} + SINKER_WATCH_POLL: ${SINKER_WATCH_POLL:-false} + +volumes: + sinker-data: diff --git a/package-lock.json b/package-lock.json index bbbff51..d9e6bef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,182 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@atcute/identity-resolver": "^2.0.3", + "@atcute/identity-resolver-node": "^2.0.1", + "@atcute/lexicons": "^2.1.1", + "@atcute/oauth-node-client": "^2.0.1", + "chokidar": "^4.0.3", + "dotenv": "^16.6.1", "jszip": "^3.10.1", "linkedom": "^0.18.13", "sharp": "^0.35.3" }, "bin": { - "epub-optimize": "src/cli.js" + "epub-optimize": "src/cli.js", + "sinker": "src/sinker-cli.js" }, "engines": { "node": ">=20" } }, + "node_modules/@atcute/client": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@atcute/client/-/client-5.1.2.tgz", + "integrity": "sha512-V3MOJudmmbuiFfrmLR3/ZGA+PKcI4E4ZNEDGKMX/YrvFrHyElD1/gb8NxLesrlIcHefiulqfX9dAl6hOkIt7CA==", + "license": "0BSD", + "dependencies": { + "@atcute/identity": "^2.0.2", + "@atcute/lexicons": "^2.1.0" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/identity": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@atcute/identity/-/identity-2.0.2.tgz", + "integrity": "sha512-amr/EQceqVtBVmjBK4uUF7nKKYuRttadigpvOcAn4dnO6SNSwSjQi8KDH9LnukEotPsSP4UDsQvNfHWEoUcslw==", + "license": "0BSD", + "peer": true, + "dependencies": { + "@atcute/lexicons": "^2.0.3", + "valibot": "^1.4.2" + }, + "peerDependencies": { + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/identity-resolver": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver/-/identity-resolver-2.0.3.tgz", + "integrity": "sha512-yiVeMExbZvPbnFSfJCSnseb4V/N7/mYANMBLQAnkfGLXzUKZ+8dqhXroMAMl3jj07lyfSXiN1nI2rAfLijhCPQ==", + "license": "0BSD", + "peer": true, + "dependencies": { + "@atcute/lexicons": "^2.1.1", + "@atcute/util-fetch": "^2.1.0", + "valibot": "^1.5.0" + }, + "peerDependencies": { + "@atcute/identity": "^2.0.0", + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/identity-resolver-node": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@atcute/identity-resolver-node/-/identity-resolver-node-2.0.1.tgz", + "integrity": "sha512-UtcmhY4Oxk9tk1elTHmilcIXA1EFtGQae7mp3l8Xqs6EZsZyH3cUDf/2jS1CoRT1GE/i3WfnLaxYmdMIVau1kg==", + "license": "0BSD", + "dependencies": { + "@atcute/lexicons": "^2.0.2" + }, + "peerDependencies": { + "@atcute/identity": "^2.0.0", + "@atcute/identity-resolver": "^2.0.0", + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/lexicons": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@atcute/lexicons/-/lexicons-2.1.1.tgz", + "integrity": "sha512-kHyqUW8g/Fq9LTctGh0TVBx0AmtecV6VSrRprrSbqKqHe9q8C6y+YPL3EMNq2l93XA1iDHkkvYwLdTcKL/lNbA==", + "license": "0BSD", + "dependencies": { + "@atcute/uint8array": "^1.1.5", + "@atcute/util-text": "^1.3.4", + "@oomfware/eval": "^0.1.0", + "@standard-schema/spec": "^1.1.0", + "esm-env": "^1.2.2" + } + }, + "node_modules/@atcute/multibase": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@atcute/multibase/-/multibase-1.2.5.tgz", + "integrity": "sha512-cReTONgYpQo/VHD3ZmzPNoyBKJgSk1J4h//cvvdVVJBMar+SjlQ/sUXeTjQfuyfmDKv+TLKhmutLcvbMcQ9Rvw==", + "license": "0BSD", + "dependencies": { + "@atcute/uint8array": "^1.1.5" + } + }, + "node_modules/@atcute/oauth-crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@atcute/oauth-crypto/-/oauth-crypto-1.0.1.tgz", + "integrity": "sha512-ghC8ceFx0r6pi5X3dNoET1K6PNg8afeAOZLD4PKLhrpVYdRtRrgY4uqAe3ojtSIjoS6GXmVm063CoJ2xtg/MoQ==", + "license": "0BSD", + "dependencies": { + "@atcute/multibase": "^1.2.4", + "@atcute/uint8array": "^1.1.3", + "nanoid": "^5.1.16", + "valibot": "^1.4.1" + } + }, + "node_modules/@atcute/oauth-keyset": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@atcute/oauth-keyset/-/oauth-keyset-0.1.3.tgz", + "integrity": "sha512-VSNUb2TLUTyntgzfn58kwNL4kp4TD+M868tyyrFWlCaDVy0pDnVFfUXa1z1W+DBWLL9ICy2qTdPitRokAZKivw==", + "license": "0BSD", + "dependencies": { + "@atcute/oauth-crypto": "^1.0.1" + } + }, + "node_modules/@atcute/oauth-node-client": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@atcute/oauth-node-client/-/oauth-node-client-2.0.1.tgz", + "integrity": "sha512-MgqN9Rnt5nuUH5zriETtbQrwcx/tWaELKaFVQtqggMtRI4k4IP5+ZbnG40tiJjbZfSclapA2kwOnT14kO0nAxg==", + "license": "0BSD", + "dependencies": { + "@atcute/client": "^5.1.1", + "@atcute/identity": "^2.0.1", + "@atcute/identity-resolver": "^2.0.1", + "@atcute/lexicons": "^2.0.2", + "@atcute/oauth-crypto": "^1.0.1", + "@atcute/oauth-keyset": "^0.1.2", + "@atcute/oauth-types": "^1.0.1", + "@atcute/util-fetch": "^2.0.1", + "nanoid": "^5.1.16", + "valibot": "^1.4.1" + }, + "peerDependencies": { + "@atcute/identity-resolver": "^2.0.0", + "@atcute/lexicons": "^2.0.0" + } + }, + "node_modules/@atcute/oauth-types": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@atcute/oauth-types/-/oauth-types-1.0.1.tgz", + "integrity": "sha512-9uFP/nK0PgPDsaNbLArzrCHvTZO1k04cSEX5jZ75W2Jh7Jxbh/CSTenSrad3CZXuX6y6hDquEtl6H/IqLtTmHw==", + "license": "0BSD", + "dependencies": { + "@atcute/identity": "^2.0.1", + "@atcute/lexicons": "^2.0.2", + "@atcute/oauth-keyset": "^0.1.2", + "valibot": "^1.4.1" + } + }, + "node_modules/@atcute/uint8array": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@atcute/uint8array/-/uint8array-1.2.0.tgz", + "integrity": "sha512-KoBGTbV4lS8zXNu91osM3FecrH3NUnxNPsBAkaNtn6uf/p240245qoLn7keFpRM0npX522X8R+XBBb2rvtE7Rg==", + "license": "0BSD" + }, + "node_modules/@atcute/util-fetch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@atcute/util-fetch/-/util-fetch-2.1.0.tgz", + "integrity": "sha512-zn1+4iHoIo7E5XLSCBkPYUd0IwE/tgdLdbqqLrxZrC3e9lB8vpA+v3TryivD+cB6mO4kSJXjCQoaAs0/cbwtyA==", + "license": "0BSD", + "dependencies": { + "valibot": "^1.5.0" + } + }, + "node_modules/@atcute/util-text": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@atcute/util-text/-/util-text-1.3.4.tgz", + "integrity": "sha512-u2UAM7iSM09sQaSG9jtxWFSPgB8boVj50/BoyMvYnhVgGBu+nXIuAcdDUQCsZA44YZgTPPhN/b86JX+jH7SPzQ==", + "license": "0BSD", + "dependencies": { + "unicode-segmenter": "^0.17.0" + } + }, "node_modules/@emnapi/runtime": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", @@ -40,9 +205,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", "cpu": [ "arm64" ], @@ -58,13 +223,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.3.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", "cpu": [ "x64" ], @@ -80,20 +245,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" + "@img/sharp-libvips-darwin-x64": "1.3.4" } }, "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", "license": "Apache-2.0", "optional": true, "os": [ "freebsd" ], "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -103,9 +268,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", "cpu": [ "arm64" ], @@ -119,9 +284,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", "cpu": [ "x64" ], @@ -135,9 +300,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", "cpu": [ "arm" ], @@ -151,9 +316,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", "cpu": [ "arm64" ], @@ -167,9 +332,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", "cpu": [ "ppc64" ], @@ -183,9 +348,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", "cpu": [ "riscv64" ], @@ -199,9 +364,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", "cpu": [ "s390x" ], @@ -215,9 +380,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", "cpu": [ "x64" ], @@ -231,9 +396,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", "cpu": [ "arm64" ], @@ -247,9 +412,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", "cpu": [ "x64" ], @@ -263,9 +428,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", "cpu": [ "arm" ], @@ -281,13 +446,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.3.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", "cpu": [ "arm64" ], @@ -303,13 +468,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.3.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", "cpu": [ "ppc64" ], @@ -325,13 +490,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.3.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", "cpu": [ "riscv64" ], @@ -347,13 +512,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.3.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", "cpu": [ "s390x" ], @@ -369,13 +534,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.3.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", "cpu": [ "x64" ], @@ -391,13 +556,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", "cpu": [ "arm64" ], @@ -413,13 +578,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", "cpu": [ "x64" ], @@ -435,17 +600,17 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.11.1" + "@emnapi/runtime": "^1.11.3" }, "engines": { "node": ">=20.9.0" @@ -455,16 +620,16 @@ } }, "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", "cpu": [ "wasm32" ], "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@img/sharp-wasm32": "0.35.5" }, "engines": { "node": ">=20.9.0" @@ -474,9 +639,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", "cpu": [ "arm64" ], @@ -493,9 +658,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", "cpu": [ "ia32" ], @@ -512,9 +677,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", "cpu": [ "x64" ], @@ -530,6 +695,18 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@oomfware/eval": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@oomfware/eval/-/eval-0.1.0.tgz", + "integrity": "sha512-EIkukTd3zDQEHUjcfFRDq79Jgm4OEyUrEUvp/SwgjfoH2POaE5cvaswIonFwdt8fSzJF5xtAdgcgIoKD8sPpjg==", + "license": "0BSD" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz", @@ -543,6 +720,21 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -664,6 +856,18 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -676,6 +880,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "license": "MIT" + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -843,6 +1053,24 @@ } } }, + "node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/nth-check": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz", @@ -886,6 +1114,19 @@ "util-deprecate": "~1.0.1" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", @@ -911,9 +1152,9 @@ "license": "MIT" }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.1.0", @@ -927,31 +1168,31 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" }, "peerDependenciesMeta": { "@types/node": { @@ -981,11 +1222,31 @@ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==", "license": "ISC" }, + "node_modules/unicode-segmenter": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.17.3.tgz", + "integrity": "sha512-hKZwqBjJDmqNrq1+LjDxck1qLzJFcLLJM2Xq92ORRHOuau6GSHeELmn+uyk6zNH1CK9mogrUJGP9WJCJUQXMAw==", + "license": "MIT" + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" + }, + "node_modules/valibot": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.5.0.tgz", + "integrity": "sha512-nil6AkP2TChWL43Z5uJ6GTxX01CUA+g8LWUM+N/rB9NBbkUMaUsi9PUNzlUPgoKASgmx9f7eGOYpJ04/fSa6FQ==", + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 2bbb3da..9a065fa 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,19 @@ { "name": "cross-reader-sinker", "version": "1.0.0", - "description": "Optimize EPUB files for xteink e-ink readers (CrossPoint firmware) — resize/grayscale/re-encode images, fix SVG covers, patch OPF/NCX.", + "description": "Optimize EPUB files for xteink e-ink readers (CrossPoint firmware) and sync them to a BookHive personal library over atproto.", "type": "module", "bin": { - "epub-optimize": "./src/cli.js" + "epub-optimize": "./src/cli.js", + "sinker": "./src/sinker-cli.js" }, "scripts": { "fixture": "node test/make-fixture.js", - "verify": "node test/verify.js" + "verify": "node test/verify.js", + "test": "node --test test/sinker/*.test.js", + "sinker": "node src/sinker-cli.js", + "sync": "node src/sinker-cli.js sync", + "watch": "node src/sinker-cli.js watch" }, "engines": { "node": ">=20" @@ -16,6 +21,12 @@ "license": "MIT", "private": true, "dependencies": { + "@atcute/identity-resolver": "^2.0.3", + "@atcute/identity-resolver-node": "^2.0.1", + "@atcute/lexicons": "^2.1.1", + "@atcute/oauth-node-client": "^2.0.1", + "chokidar": "^4.0.3", + "dotenv": "^16.6.1", "jszip": "^3.10.1", "linkedom": "^0.18.13", "sharp": "^0.35.3" diff --git a/src/sinker-cli.js b/src/sinker-cli.js new file mode 100755 index 0000000..813a6c2 --- /dev/null +++ b/src/sinker-cli.js @@ -0,0 +1,386 @@ +#!/usr/bin/env node +// sinker — optimize EPUBs for CrossPoint readers and sync them to BookHive. +// +// `epub-optimize` remains the standalone converter; this entry point adds the +// OAuth session, BookHive client, ledger and watcher. + +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { createInterface } from 'node:readline/promises'; +import { resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +import { DEVICE_PROFILES } from './constants.js'; +import { formatBytes } from './util.js'; +import { + AuthError, login, logout, readSessionPointer, restoreSession, +} from './sinker/auth.js'; +import { createBookHiveClient } from './sinker/bookhive.js'; +import { + ConfigError, describeConfig, loadConfig, +} from './sinker/config.js'; +import { createLogger } from './sinker/log.js'; +import { createContext, syncOnce, summarize } from './sinker/pipeline.js'; +import { Ledger, stateFileFor } from './sinker/state.js'; +import { watch } from './sinker/watcher.js'; + +const USAGE = ` +sinker — optimize EPUBs for CrossPoint readers and sync them to BookHive + +Usage: + sinker [options] + +Commands: + login Authenticate with your atproto account (opens a browser) + logout Revoke the saved session and forget it + status Show config, session and sync ledger + sync [file…] Compress and upload new books once, then exit + watch Watch the source folder and keep BookHive in sync + +Options: + --env-file FILE load configuration from FILE (default: ./.env) + --verbose log per-image detail and retry decisions + --quiet only warnings and errors + -h, --help show this message + +Run \`sinker --help\` for command options. +`.trim(); + +const COMMAND_USAGE = { + login: ` +Usage: sinker login [options] + +Options: + --handle HANDLE atproto handle (default: ATPROTO_HANDLE) + --port PORT loopback callback port (default: 8765) + --login-host HOST interface to bind (default: 127.0.0.1; use 0.0.0.0 in Docker) + --no-open print the URL instead of opening a browser +`.trim(), + + logout: ` +Usage: sinker logout [options] +`.trim(), + + status: ` +Usage: sinker status [options] + +Options: + --json machine-readable output + --check verify the saved session against BookHive +`.trim(), + + sync: ` +Usage: sinker sync [file…] [options] + +Compresses and uploads new books once. With no files, scans the source folder. + +Options: + --source DIR source folder (default: SINKER_SOURCE_DIR) + --out DIR output folder (default: SINKER_OUTPUT_DIR) + --device X4|X3 target screen (default: X4) + --quality N JPEG quality 1-100 (default: 85) + --auto-crop trim uniform margins before resizing + --no-rename keep source filenames instead of "Title - Author.epub" + --dry-run do everything except write files, upload, or update state + --force upload even if the book is already in BookHive + --retry-failed retry files that previously failed permanently +`.trim(), + + watch: ` +Usage: sinker watch [options] + +Watches the source folder until Ctrl+C. + +Options: (same as sync, plus) + --no-backfill only process files added after startup + --poll poll the filesystem (network volumes) + --rescan-ms N periodic rescan interval in ms (default: 300000) + --retry-max N upload retry attempts (default: 5) + --timeout-ms N per-request timeout in ms (default: 120000) + --verbose log per-image detail and retry decisions +`.trim(), +}; + +class UsageError extends Error {} + +function buildOverrides(values) { + return { + handle: values.handle, + sourceDir: values.source, + outputDir: values.out, + device: values.device, + quality: values.quality, + autoCrop: values['auto-crop'] ? true : undefined, + rename: values['no-rename'] ? false : undefined, + loginPort: values.port, + loginHost: values['login-host'], + openBrowser: values['no-open'] ? false : undefined, + poll: values.poll ? true : undefined, + backfill: values['no-backfill'] ? false : undefined, + rescanMs: values['rescan-ms'], + retryMax: values['retry-max'], + timeoutMs: values['timeout-ms'], + dryRun: values['dry-run'] ? true : undefined, + force: values.force ? true : undefined, + retryFailed: values['retry-failed'] ? true : undefined, + verbose: values.verbose ? true : undefined, + quiet: values.quiet ? true : undefined, + }; +} + +function parseCli(argv) { + let parsed; + try { + parsed = parseArgs({ + args: argv, + allowPositionals: true, + strict: true, + options: { + 'env-file': { type: 'string' }, + help: { type: 'boolean', short: 'h' }, + + verbose: { type: 'boolean' }, + quiet: { type: 'boolean' }, + + handle: { type: 'string' }, + port: { type: 'string' }, + 'login-host': { type: 'string' }, + 'no-open': { type: 'boolean' }, + + source: { type: 'string' }, + out: { type: 'string' }, + device: { type: 'string' }, + quality: { type: 'string' }, + 'auto-crop': { type: 'boolean' }, + 'no-rename': { type: 'boolean' }, + 'dry-run': { type: 'boolean' }, + force: { type: 'boolean' }, + 'retry-failed': { type: 'boolean' }, + + poll: { type: 'boolean' }, + 'no-backfill': { type: 'boolean' }, + 'rescan-ms': { type: 'string' }, + 'retry-max': { type: 'string' }, + 'timeout-ms': { type: 'string' }, + + json: { type: 'boolean' }, + check: { type: 'boolean' }, + }, + }); + } catch (error) { + throw new UsageError(error.message); + } + + return parsed; +} + +function helpFor(command) { + if (command && COMMAND_USAGE[command]) return `${COMMAND_USAGE[command]}\n\n${USAGE}`; + return USAGE; +} + +async function promptHandle() { + if (!process.stdin.isTTY) return null; + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question('Bluesky handle (e.g. you.bsky.social): '); + return answer.trim() || null; + } finally { + rl.close(); + } +} + +function ledgerFor(config, did) { + return new Ledger(stateFileFor(config.stateDir, did ?? 'anonymous')).load(); +} + +async function runLogin(config, log) { + let handle = config.handle; + if (!handle) handle = await promptHandle(); + if (!handle) throw new UsageError('a handle is required: pass --handle or set ATPROTO_HANDLE'); + + const { did } = await login(config, { handle, log }); + log.ok(`Signed in as ${did}.`); + return 0; +} + +async function runLogout(config, log) { + const did = await logout(config); + if (did) log.ok(`Signed out ${did}.`); + else log.info('No saved session.'); + return 0; +} + +function sourceSummary(config) { + if (!existsSync(config.sourceDir)) { + return { exists: false, epubs: 0, other: 0 }; + } + let epubs = 0; + let other = 0; + for (const entry of readdirSync(config.sourceDir, { withFileTypes: true })) { + if (entry.name.startsWith('.') || entry.isDirectory()) continue; + let isFile = entry.isFile(); + if (!isFile) { + try { isFile = statSync(resolve(config.sourceDir, entry.name)).isFile(); } catch { continue; } + } + if (!isFile) continue; + if (/\.epub$/i.test(entry.name)) epubs++; + else other++; + } + return { exists: true, epubs, other }; +} + +async function runStatus(config, log, values) { + const pointer = readSessionPointer(config); + const did = pointer?.did ?? null; + const ledger = ledgerFor(config, did); + const source = sourceSummary(config); + + const data = { + signedIn: Boolean(did), + did, + handle: pointer?.handle ?? config.handle ?? null, + signedInAt: pointer?.signedInAt ?? null, + bookhive: { url: config.bookhiveUrl, did: config.bookhiveDid }, + source: { ...source, dir: config.sourceDir }, + output: { dir: config.outputDir, exists: existsSync(config.outputDir) }, + device: config.device, + quality: config.quality, + autoCrop: config.autoCrop, + rename: config.rename, + ledger: { file: stateFileFor(config.stateDir, did ?? 'anonymous'), counts: ledger.counts() }, + }; + + if (values.check) { + if (!did) throw new AuthError('not signed in — run `sinker login` first'); + const { session } = await restoreSession(config); + const client = createBookHiveClient({ config, session, logger: log }); + const library = await client.refreshLibrary(); + data.library = { + books: library.size, + storage: client.storage, + }; + } + + if (values.json) { + console.log(JSON.stringify(data, null, 2)); + return 0; + } + + console.log(describeConfig(config)); + console.log(''); + if (did) { + console.log(`session ${pointer.handle ? `@${pointer.handle} ` : ''}${did}`); + console.log(`signed in ${pointer.signedInAt ?? 'unknown'}`); + } else { + console.log('session not signed in (run `sinker login`)'); + } + + const counts = data.ledger.counts; + const parts = Object.entries(counts).map(([status, count]) => `${count} ${status}`); + console.log(`ledger ${parts.length ? parts.join(', ') : 'empty'}`); + console.log(`files ${source.epubs} epub(s)${source.other ? `, ${source.other} other file(s)` : ''}`); + if (data.library) { + const storage = data.library.storage + ? ` (${formatBytes(data.library.storage.usedBytes)} / ${formatBytes(data.library.storage.quotaBytes)})` + : ''; + console.log(`library ${data.library.books} book(s)${storage}`); + } + return 0; +} + +async function makeClient(config, log) { + const { did, session } = await restoreSession(config); + const client = createBookHiveClient({ config, session, logger: log }); + return { did, client }; +} + +async function runSync(config, log, paths) { + const { did, client } = await makeClient(config, log); + const ctx = createContext({ config, bookhive: client, logger: log, did }); + + const explicit = paths.length + ? paths.map(path => { + const resolved = resolve(config.cwd, path); + if (!existsSync(resolved) || !statSync(resolved).isFile()) { + throw new UsageError(`not a file: ${resolved}`); + } + return resolved; + }) + : undefined; + + log.info(`Signed in as ${did}`); + log.info(`Target ${DEVICE_PROFILES[config.device].label} | quality ${config.quality}` + + `${config.autoCrop ? ' | auto-crop' : ''}${config.dryRun ? ' | DRY RUN' : ''}`); + + const stats = await syncOnce(ctx, explicit ? { paths: explicit } : {}); + log.info(`Done: ${summarize(stats)}`); + return stats.failed > 0 ? 1 : 0; +} + +async function runWatch(config, log) { + const { did, client } = await makeClient(config, log); + const ctx = createContext({ config, bookhive: client, logger: log, did }); + + log.info(`Signed in as ${did}`); + log.info(`Target ${DEVICE_PROFILES[config.device].label} | quality ${config.quality}` + + `${config.autoCrop ? ' | auto-crop' : ''}${config.dryRun ? ' | DRY RUN' : ''}`); + + return watch(ctx); +} + +async function main(argv) { + const { values, positionals } = parseCli(argv); + const command = positionals[0]; + + if (values.help || command === 'help') { + console.log(helpFor(command === 'help' ? null : command)); + return 0; + } + + if (!command) { + console.log(USAGE); + return 2; + } + + if (!['login', 'logout', 'status', 'sync', 'watch'].includes(command)) { + throw new UsageError(`unknown command "${command}"`); + } + + const log = createLogger({ verbose: values.verbose, quiet: values.quiet }); + + const config = loadConfig({ + envFile: values['env-file'] ? resolve(values['env-file']) : undefined, + cwd: process.cwd(), + overrides: buildOverrides(values), + }); + + const extra = positionals.slice(1); + if (extra.length && command !== 'sync') { + throw new UsageError(`unexpected argument "${extra[0]}"`); + } + + switch (command) { + case 'login': return runLogin(config, log); + case 'logout': return runLogout(config, log); + case 'status': return runStatus(config, log, values); + case 'sync': return runSync(config, log, extra); + case 'watch': return runWatch(config, log); + default: throw new UsageError(`unknown command "${command}"`); + } +} + +main(process.argv.slice(2)) + .then(code => { process.exitCode = code; }) + .catch(error => { + if (error instanceof UsageError) { + console.error(`error: ${error.message}`); + console.error(''); + console.error(USAGE); + } else if (error instanceof ConfigError || error instanceof AuthError) { + console.error(`error: ${error.message}`); + } else { + console.error(`error: ${error?.message ?? error}`); + if (process.env.DEBUG) console.error(error.stack); + } + process.exitCode = 1; + }); diff --git a/src/sinker/auth.js b/src/sinker/auth.js new file mode 100644 index 0000000..b78cc66 --- /dev/null +++ b/src/sinker/auth.js @@ -0,0 +1,323 @@ +// atproto OAuth for a loopback CLI client. +// +// We authenticate the user once with OAuth (public loopback client, no hosted +// metadata or keyset), persist the session, and later use that session only to +// mint short-lived inter-service auth tokens for BookHive's XRPC methods. + +import { execFile } from 'node:child_process'; +import { createServer } from 'node:http'; +import { + chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, + unlinkSync, writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { + OAuthClient, TokenInvalidError, TokenRefreshError, TokenRevokedError, +} from '@atcute/oauth-node-client'; +import { + CompositeDidDocumentResolver, CompositeHandleResolver, LocalActorResolver, + PlcDidDocumentResolver, WebDidDocumentResolver, WellKnownHandleResolver, +} from '@atcute/identity-resolver'; +import { NodeDnsHandleResolver } from '@atcute/identity-resolver-node'; + +/** BookHive RPC methods this tool needs to call. */ +export const BOOKHIVE_RPC_METHODS = [ + 'buzz.bookhive.getPersonalLibrary', + 'buzz.bookhive.getPersonalBook', + 'buzz.bookhive.uploadPersonalBook', +]; + +/** Granular scopes. BookHive's published permission set predates these RPCs. */ +export function oauthScope() { + return [ + 'atproto', + ...BOOKHIVE_RPC_METHODS.map(method => `rpc:${method}?aud=*`), + ]; +} + +export class AuthError extends Error {} + +function atomicWriteJson(file, value) { + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + renameSync(tmp, file); + try { chmodSync(file, 0o600); } catch { /* best effort */ } +} + +function readJson(file) { + try { + return JSON.parse(readFileSync(file, 'utf8')); + } catch { + return undefined; + } +} + +/** File-backed atcute Store implementation. */ +function fileStore(dir, prefix, { ttlMs } = {}) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const fileFor = key => join(dir, `${prefix}-${encodeURIComponent(String(key))}.json`); + + return { + async get(key) { + const value = readJson(fileFor(key)); + if (!value) return undefined; + if (ttlMs && value.expiresAt && Date.now() > value.expiresAt) { + try { unlinkSync(fileFor(key)); } catch { /* ignore */ } + return undefined; + } + return value; + }, + async set(key, value) { + atomicWriteJson(fileFor(key), value); + }, + async delete(key) { + try { unlinkSync(fileFor(key)); } catch { /* ignore */ } + }, + async clear() { + if (!existsSync(dir)) return; + for (const name of readdirSync(dir)) { + if (name.startsWith(`${prefix}-`)) { + try { unlinkSync(join(dir, name)); } catch { /* ignore */ } + } + } + }, + }; +} + +export function sessionPointerPath(config) { + return join(config.oauthDir, 'session.json'); +} + +/** The loopback port is part of the client id, so reuse the one from login. */ +function oauthConfigFor(config, pointer) { + const port = Number(pointer?.loginPort); + if (!Number.isInteger(port) || port === config.loginPort) return config; + return { ...config, loginPort: port }; +} + +export function readSessionPointer(config) { + return readJson(sessionPointerPath(config)); +} + +function writeSessionPointer(config, pointer) { + atomicWriteJson(sessionPointerPath(config), pointer); +} + +function clearSessionPointer(config) { + rmSync(sessionPointerPath(config), { force: true }); +} + +/** + * Build the OAuth client. `loginPort` is baked into the redirect URI, so it + * must match the port the callback server actually binds. + */ +export function createOAuth(config) { + const redirectUri = `http://127.0.0.1:${config.loginPort}/callback`; + const oauthDir = join(config.oauthDir, 'oauth'); + + const sessions = fileStore(join(oauthDir, 'sessions'), 'session'); + const states = fileStore(join(oauthDir, 'states'), 'state', { ttlMs: 15 * 60_000 }); + + const actorResolver = new LocalActorResolver({ + handleResolver: new CompositeHandleResolver({ + methods: { + dns: new NodeDnsHandleResolver(), + http: new WellKnownHandleResolver(), + }, + }), + didDocumentResolver: new CompositeDidDocumentResolver({ + methods: { + plc: new PlcDidDocumentResolver(), + web: new WebDidDocumentResolver(), + }, + }), + }); + + const oauth = new OAuthClient({ + metadata: { + redirect_uris: [redirectUri], + scope: oauthScope(), + }, + stores: { sessions, states }, + actorResolver, + }); + + return { oauth, sessions, states, redirectUri }; +} + +function openBrowser(url) { + const [command, args] = process.platform === 'darwin' + ? ['open', [url]] + : process.platform === 'win32' + ? ['cmd', ['/c', 'start', '', url]] + : ['xdg-open', [url]]; + + return new Promise(resolve => { + execFile(command, args, error => resolve(!error)); + }); +} + +const CALLBACK_TIMEOUT_MS = 10 * 60_000; + +/** + * Run the interactive login flow. + * @param {object} config + * @param {{handle: string, log: object}} options + * @returns {Promise<{did: string, redirectUri: string}>} + */ +export async function login(config, { handle, log }) { + if (!handle) throw new AuthError('a handle is required (pass --handle or set ATPROTO_HANDLE)'); + + const { oauth, redirectUri } = createOAuth(config); + const host = config.loginHost; + + return new Promise((resolve, reject) => { + let settled = false; + let timer; + + const finish = (error, did) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (server.listening) server.close(); + if (error) reject(error); + else resolve({ did, redirectUri }); + }; + + const server = createServer(async (req, res) => { + const url = new URL(req.url ?? '/', redirectUri); + if (url.pathname !== '/callback') { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('Not found'); + return; + } + + const oauthError = url.searchParams.get('error'); + if (oauthError) { + const description = url.searchParams.get('error_description') ?? ''; + res.writeHead(400, { 'content-type': 'text/plain' }); + res.end(`Authorization failed: ${oauthError} ${description}`); + finish(new AuthError(`authorization failed: ${oauthError} ${description}`.trim())); + return; + } + + try { + const { session } = await oauth.callback(url.searchParams); + const did = session.did; + writeSessionPointer(config, { + did, + handle: handle.replace(/^@/, ''), + loginPort: config.loginPort, + signedInAt: new Date().toISOString(), + }); + + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); + res.end( + 'Signed in' + + '' + + '

Signed in to BookHive

You can close this tab and return to the terminal.

', + ); + finish(null, did); + } catch (error) { + res.writeHead(500, { 'content-type': 'text/plain' }); + res.end(`Login failed: ${error.message}`); + finish(new AuthError(`login failed: ${error.message}`)); + } + }); + + server.on('error', error => { + finish(new AuthError( + error.code === 'EADDRINUSE' + ? `port ${config.loginPort} is already in use; pass --port with a free port` + : `could not start the login server: ${error.message}`, + )); + }); + + timer = setTimeout(() => { + finish(new AuthError('login timed out after 10 minutes')); + }, CALLBACK_TIMEOUT_MS); + + server.listen(config.loginPort, host, async () => { + try { + const { url } = await oauth.authorize({ + target: { type: 'account', identifier: handle.replace(/^@/, '') }, + state: { source: 'cross-reader-sinker' }, + }); + + log.always(''); + log.always('Open this URL to authorize Cross Reader Sinker:'); + log.always(''); + log.always(` ${url}`); + log.always(''); + log.always(`Waiting for the callback on ${redirectUri} ...`); + + if (config.openBrowser) { + const opened = await openBrowser(url.toString()); + if (!opened) log.warn('could not open a browser automatically; open the URL above.'); + } + } catch (error) { + finish(new AuthError(`could not start authorization: ${error.message}`)); + } + }); + }); +} + +/** + * Restore the saved session (refreshing tokens if needed). + * @returns {Promise<{did: string, session: import('@atcute/oauth-node-client').OAuthSession, oauth: OAuthClient}>} + */ +export async function restoreSession(config) { + const pointer = readSessionPointer(config); + if (!pointer?.did) { + throw new AuthError('not signed in — run `sinker login` first'); + } + + const { oauth, sessions } = createOAuth(oauthConfigFor(config, pointer)); + const stored = await sessions.get(pointer.did); + if (!stored) { + throw new AuthError( + `no stored session for ${pointer.did} — run \`sinker login\` again`, + ); + } + try { + const session = await oauth.restore(pointer.did); + return { did: session.did, session, oauth }; + } catch (error) { + const message = error?.message ?? ''; + if (/not\s+found|no\s+(stored\s+)?session|missing/i.test(message)) { + throw new AuthError( + `no stored session for ${pointer.did} — run \`sinker login\` again`, + ); + } + if (error instanceof TokenRefreshError + || error instanceof TokenInvalidError + || error instanceof TokenRevokedError + || /refresh|session|token/i.test(message)) { + throw new AuthError( + `the saved session for ${pointer.did} can no longer be refreshed ` + + `(sessions expire after about two weeks of inactivity). Run \`sinker login\` again.`, + ); + } + throw new AuthError(`could not restore the saved session: ${error?.message ?? error}`); + } +} + +/** Revoke the saved session, if any. Returns the DID that was signed out. */ +export async function logout(config) { + const pointer = readSessionPointer(config); + if (!pointer?.did) { + clearSessionPointer(config); + return null; + } + + const { oauth } = createOAuth(oauthConfigFor(config, pointer)); + try { + await oauth.revoke(pointer.did); + } catch { + // Tokens may already be invalid; removing the local pointer is what matters. + } + clearSessionPointer(config); + return pointer.did; +} diff --git a/src/sinker/bookhive.js b/src/sinker/bookhive.js new file mode 100644 index 0000000..d705c6c --- /dev/null +++ b/src/sinker/bookhive.js @@ -0,0 +1,242 @@ +// BookHive XRPC client. +// +// BookHive does not accept OAuth access tokens directly. It accepts an atproto +// inter-service auth JWT, minted by the user's PDS for one method (`lxm`) and +// one audience. So every call is: +// +// OAuth session -> com.atproto.server.getServiceAuth -> BookHive /xrpc/ +// +// Tokens are 60-second tokens; we cache them for 50 seconds per method. + +import { LibraryIndex } from './dedupe.js'; + +const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]); +const TOKEN_TTL_MS = 50_000; + +export class BookHiveError extends Error { + constructor(message, { code = 'RequestFailed', status = 0, retryable = false } = {}) { + super(message); + this.name = 'BookHiveError'; + this.code = code; + this.status = status; + this.retryable = retryable; + } +} + +const defaultSleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +export function createBookHiveClient({ + config, + session, + logger, + fetchImpl = fetch, + sleep = defaultSleep, +}) { + const tokenCache = new Map(); + let library = new LibraryIndex(); + let libraryMeta = { total: 0, storage: null }; + + async function mintToken(lxm) { + const exp = Math.floor(Date.now() / 1000) + 60; + const path = '/xrpc/com.atproto.server.getServiceAuth' + + `?aud=${encodeURIComponent(config.bookhiveDid)}` + + `&lxm=${encodeURIComponent(lxm)}` + + `&exp=${exp}`; + + let response; + try { + response = await session.handle(path, { + method: 'GET', + signal: AbortSignal.timeout(config.timeoutMs), + }); + } catch (error) { + throw new BookHiveError(`could not mint a service token: ${error.message}`, { + code: 'TokenError', + retryable: true, + }); + } + + const text = await response.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON error body */ } + + if (!response.ok || !data?.token) { + throw new BookHiveError( + `could not mint a service token: ${data?.message ?? data?.error ?? `HTTP ${response.status}`}`, + { + code: data?.error ?? 'TokenError', + status: response.status, + retryable: response.status >= 500, + }, + ); + } + + tokenCache.set(lxm, { token: data.token, expiresAt: Date.now() + TOKEN_TTL_MS }); + return data.token; + } + + async function tokenFor(lxm) { + const cached = tokenCache.get(lxm); + if (cached && cached.expiresAt > Date.now()) return cached.token; + return mintToken(lxm); + } + + function backoff(attempt) { + return Math.min(30_000, 500 * 2 ** (attempt - 1)) + Math.floor(Math.random() * 250); + } + + async function call(method, { params, body, contentType } = {}) { + const url = new URL(`${config.bookhiveUrl}/xrpc/${method}`); + for (const [key, value] of Object.entries(params ?? {})) { + if (value !== undefined && value !== null) url.searchParams.set(key, String(value)); + } + + let attempt = 0; + let authRetried = false; + + for (;;) { + attempt++; + let token; + try { + token = await tokenFor(method); + } catch (error) { + if (error instanceof BookHiveError && error.retryable && attempt < config.retryMax) { + await sleep(backoff(attempt)); + continue; + } + throw error; + } + + let response; + try { + response = await fetchImpl(url, { + method: body === undefined ? 'GET' : 'POST', + headers: { + authorization: `Bearer ${token}`, + ...(contentType ? { 'content-type': contentType } : {}), + }, + body, + signal: AbortSignal.timeout(config.timeoutMs), + }); + } catch (error) { + if (attempt < config.retryMax) { + logger.debug(`${method}: ${error.message}; retrying (${attempt}/${config.retryMax})`); + await sleep(backoff(attempt)); + continue; + } + throw new BookHiveError(`${method} failed: ${error.message}`, { + code: 'NetworkError', + retryable: true, + }); + } + + // A cached token can be revoked or clock-skewed. Mint a fresh one once, + // without consuming a retry attempt. + if (response.status === 401 && !authRetried) { + authRetried = true; + attempt--; + tokenCache.delete(method); + continue; + } + + const text = await response.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { /* non-JSON body */ } + + if (response.ok) { + return data ?? {}; + } + + const code = data?.error ?? `HTTP${response.status}`; + const message = data?.message ?? (text ? text.slice(0, 200) : response.statusText); + const retryable = RETRYABLE_STATUS.has(response.status) || code === 'Busy'; + + if (retryable && attempt < config.retryMax) { + logger.debug(`${method}: ${code} (${message}); retrying (${attempt}/${config.retryMax})`); + await sleep(backoff(attempt)); + continue; + } + + throw new BookHiveError(`${method}: ${code}${message ? ` — ${message}` : ''}`, { + code, + status: response.status, + retryable, + }); + } + } + + return { + get library() { + return library; + }, + + get storage() { + return libraryMeta.storage; + }, + + /** Fetch (or 404 -> null) one personal-library book by content hash. */ + async getPersonalBook(contentHash) { + try { + const data = await call('buzz.bookhive.getPersonalBook', { params: { contentHash } }); + return data?.book ?? null; + } catch (error) { + if (error instanceof BookHiveError && (error.status === 404 || error.code === 'NotFound')) { + return null; + } + throw error; + } + }, + + /** Load every page of the personal library into the dedupe index. */ + async refreshLibrary() { + const books = []; + let cursor; + let total = 0; + let storage = null; + let pages = 0; + + do { + const data = await call('buzz.bookhive.getPersonalLibrary', { + params: { limit: 100, sort: 'recent', ...(cursor ? { cursor } : {}) }, + }); + if (Array.isArray(data?.books)) books.push(...data.books); + if (typeof data?.total === 'number') total = data.total; + if (data?.storage) storage = data.storage; + cursor = data?.cursor; + pages++; + } while (cursor && pages < 500); + + library = new LibraryIndex(books); + libraryMeta = { total: total || books.length, storage }; + logger.debug(`library index: ${library.size} book(s)`); + return library; + }, + + /** + * Upload raw EPUB bytes. Treats AlreadyExists as success — the caller's + * content hash is the same value BookHive used for its duplicate check. + */ + async upload(buffer, filename) { + try { + const data = await call('buzz.bookhive.uploadPersonalBook', { + params: { filename }, + body: buffer, + contentType: 'application/epub+zip', + }); + if (data?.book) library.add(data.book); + if (data?.storageUsedBytes !== undefined) { + libraryMeta.storage = { + usedBytes: data.storageUsedBytes, + quotaBytes: data.storageQuotaBytes, + }; + } + return { alreadyExists: false, ...data }; + } catch (error) { + if (error instanceof BookHiveError && (error.status === 409 || error.code === 'AlreadyExists')) { + return { alreadyExists: true }; + } + throw error; + } + }, + }; +} diff --git a/src/sinker/config.js b/src/sinker/config.js new file mode 100644 index 0000000..d01102c --- /dev/null +++ b/src/sinker/config.js @@ -0,0 +1,225 @@ +// Configuration: .env -> environment -> CLI flags. +// +// Flags win over the environment, which wins over defaults. Paths are +// resolved against the working directory (except `~`, which is expanded). + +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve } from 'node:path'; +import dotenv from 'dotenv'; + +import { DEVICE_PROFILES, DEFAULT_DEVICE, DEFAULT_JPEG_QUALITY } from '../constants.js'; + +export const BOOKHIVE_URL = 'https://bookhive.buzz'; +export const BOOKHIVE_DID = 'did:plc:enu2j5xjlqsjaylv3du4myh4'; + +export class ConfigError extends Error {} + +const DEFAULTS = { + sourceDir: './unformatted-books', + outputDir: './formatted-books', + device: DEFAULT_DEVICE, + quality: DEFAULT_JPEG_QUALITY, + autoCrop: false, + rename: true, + loginPort: 8765, + loginHost: '127.0.0.1', + poll: false, + backfill: true, + rescanMs: 5 * 60_000, + retryMax: 5, + timeoutMs: 120_000, +}; + +function expandHome(value) { + if (value === '~') return homedir(); + if (value.startsWith('~/')) return join(homedir(), value.slice(2)); + return value; +} + +function resolvePath(value, cwd) { + const expanded = expandHome(String(value)); + return isAbsolute(expanded) ? expanded : resolve(cwd, expanded); +} + +function defaultOauthDir(env) { + const base = env.XDG_CONFIG_HOME || join(homedir(), '.config'); + return join(base, 'cross-reader-sinker'); +} + +function defaultStateDir(env) { + const base = env.XDG_STATE_HOME || join(homedir(), '.local', 'state'); + return join(base, 'cross-reader-sinker'); +} + +function optionalString(value) { + if (value === undefined || value === null) return undefined; + const trimmed = String(value).trim(); + return trimmed === '' ? undefined : trimmed; +} + +function parseBool(name, value) { + if (value === undefined || value === null || value === '') return undefined; + if (typeof value === 'boolean') return value; + const normalized = String(value).trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + throw new ConfigError(`${name} must be a boolean (got "${value}")`); +} + +function parseInteger(name, value, { min, max } = {}) { + if (value === undefined || value === null || value === '') return undefined; + const number = Number(value); + if (!Number.isInteger(number)) throw new ConfigError(`${name} must be an integer (got "${value}")`); + if (min !== undefined && number < min) throw new ConfigError(`${name} must be >= ${min} (got ${number})`); + if (max !== undefined && number > max) throw new ConfigError(`${name} must be <= ${max} (got ${number})`); + return number; +} + +/** Pick the first defined value. */ +function firstDefined(...values) { + return values.find(value => value !== undefined); +} + +/** + * @param {object} [options] + * @param {string} [options.envFile] explicit env file; missing file is an error + * @param {Record} [options.env] environment source + * @param {string} [options.cwd] + * @param {object} [options.overrides] CLI values (undefined = not passed) + */ +export function loadConfig({ envFile, env = process.env, cwd = process.cwd(), overrides = {} } = {}) { + // Copy so callers (and tests) don't observe dotenv's mutations. + const mergedEnv = { ...env }; + const envPath = envFile ?? join(cwd, '.env'); + const result = dotenv.config({ path: envPath, processEnv: mergedEnv }); + if (result.error && result.error.code !== 'ENOENT') { + throw new ConfigError(`could not read ${envPath}: ${result.error.message}`); + } + if (envFile && result.error) throw new ConfigError(`env file not found: ${envFile}`); + + const device = String(firstDefined(overrides.device, mergedEnv.SINKER_DEVICE, DEFAULTS.device)).toUpperCase(); + if (!DEVICE_PROFILES[device]) { + throw new ConfigError(`device must be one of ${Object.keys(DEVICE_PROFILES).join(', ')} (got "${device}")`); + } + + const quality = firstDefined( + parseInteger('--quality', overrides.quality, { min: 1, max: 100 }), + parseInteger('SINKER_QUALITY', mergedEnv.SINKER_QUALITY, { min: 1, max: 100 }), + DEFAULTS.quality, + ); + + const autoCrop = firstDefined( + overrides.autoCrop, + parseBool('SINKER_AUTO_CROP', mergedEnv.SINKER_AUTO_CROP), + DEFAULTS.autoCrop, + ); + + const rename = firstDefined( + overrides.rename, + parseBool('SINKER_RENAME', mergedEnv.SINKER_RENAME), + DEFAULTS.rename, + ); + + const poll = firstDefined( + overrides.poll, + parseBool('SINKER_WATCH_POLL', mergedEnv.SINKER_WATCH_POLL), + DEFAULTS.poll, + ); + + const backfill = firstDefined( + overrides.backfill, + parseBool('SINKER_BACKFILL', mergedEnv.SINKER_BACKFILL), + DEFAULTS.backfill, + ); + + const rescanMs = firstDefined( + parseInteger('--rescan-ms', overrides.rescanMs, { min: 10_000 }), + parseInteger('SINKER_RESCAN_MS', mergedEnv.SINKER_RESCAN_MS, { min: 10_000 }), + DEFAULTS.rescanMs, + ); + + const retryMax = firstDefined( + parseInteger('--retry-max', overrides.retryMax, { min: 1, max: 20 }), + parseInteger('SINKER_RETRY_MAX', mergedEnv.SINKER_RETRY_MAX, { min: 1, max: 20 }), + DEFAULTS.retryMax, + ); + + const timeoutMs = firstDefined( + parseInteger('--timeout-ms', overrides.timeoutMs, { min: 1_000 }), + parseInteger('SINKER_TIMEOUT_MS', mergedEnv.SINKER_TIMEOUT_MS, { min: 1_000 }), + DEFAULTS.timeoutMs, + ); + + const loginPort = firstDefined( + parseInteger('--port', overrides.loginPort, { min: 1, max: 65535 }), + parseInteger('SINKER_LOGIN_PORT', mergedEnv.SINKER_LOGIN_PORT, { min: 1, max: 65535 }), + DEFAULTS.loginPort, + ); + + const bookhiveUrl = String(firstDefined(mergedEnv.BOOKHIVE_URL, BOOKHIVE_URL)).replace(/\/+$/, ''); + + const sourceDir = resolvePath(firstDefined(overrides.sourceDir, mergedEnv.SINKER_SOURCE_DIR, DEFAULTS.sourceDir), cwd); + const outputDir = resolvePath(firstDefined(overrides.outputDir, mergedEnv.SINKER_OUTPUT_DIR, DEFAULTS.outputDir), cwd); + if (sourceDir === outputDir) { + throw new ConfigError('the source and output folders must be different'); + } + + return Object.freeze({ + cwd, + envFile: envFile ?? null, + + handle: optionalString(firstDefined(overrides.handle, mergedEnv.ATPROTO_HANDLE)) ?? null, + + bookhiveUrl, + bookhiveDid: String(firstDefined(mergedEnv.BOOKHIVE_DID, BOOKHIVE_DID)).trim(), + + sourceDir, + outputDir, + oauthDir: resolvePath(firstDefined(mergedEnv.SINKER_OAUTH_DIR, defaultOauthDir(mergedEnv)), cwd), + stateDir: resolvePath(firstDefined(mergedEnv.SINKER_STATE_DIR, defaultStateDir(mergedEnv)), cwd), + + device, + quality, + autoCrop, + rename, + + loginPort, + loginHost: String(firstDefined(overrides.loginHost, mergedEnv.SINKER_LOGIN_HOST, DEFAULTS.loginHost)), + poll, + backfill, + rescanMs, + retryMax, + timeoutMs, + + dryRun: Boolean(overrides.dryRun), + force: Boolean(overrides.force), + retryFailed: Boolean(overrides.retryFailed), + verbose: Boolean(overrides.verbose), + quiet: Boolean(overrides.quiet), + openBrowser: overrides.openBrowser !== false, + + // Effective image conversion options, threaded into convertEpub(). + convertOptions: Object.freeze({ + maxWidth: DEVICE_PROFILES[device].width, + maxHeight: DEVICE_PROFILES[device].height, + quality, + autoCrop, + }), + }); +} + +export function describeConfig(config) { + return [ + `source ${config.sourceDir}`, + `output ${config.outputDir}`, + `device ${DEVICE_PROFILES[config.device].label} (${DEVICE_PROFILES[config.device].width}x${DEVICE_PROFILES[config.device].height})`, + `quality ${config.quality}`, + `auto-crop ${config.autoCrop ? 'on' : 'off'}`, + `rename ${config.rename ? 'on' : 'off'}`, + `bookhive ${config.bookhiveUrl} (${config.bookhiveDid})`, + `login http://127.0.0.1:${config.loginPort}/callback` + + (config.loginHost === '127.0.0.1' ? '' : ` (bind ${config.loginHost})`), + `oauth dir ${config.oauthDir}`, + `state dir ${config.stateDir}`, + ].join('\n'); +} diff --git a/src/sinker/dedupe.js b/src/sinker/dedupe.js new file mode 100644 index 0000000..c36936d --- /dev/null +++ b/src/sinker/dedupe.js @@ -0,0 +1,104 @@ +// Matching helpers for "is this book already in the library?". +// +// BookHive identifies a personal-library file by its KOReader partial MD5, so +// a hash match is exact. Compression is not byte-deterministic across runs +// (JSZip stamps fresh timestamps), so a previously uploaded *compressed* copy +// can only be recognised by metadata. BookHive parses EPUB metadata as: +// +// title = first dc:title, falling back to the file name +// authors = every dc:creator, joined with ", " +// +// which is what `normalizeText` is applied to on both sides. + +/** Lowercase, strip diacritics and punctuation, collapse whitespace. */ +export function normalizeText(value) { + return String(value ?? '') + .normalize('NFKD') + .replace(/\p{M}/gu, '') + .toLowerCase() + .replace(/['\u2018\u2019\u0060\u00b4]/g, '') + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim() + .replace(/\s+/g, ' '); +} + +/** Accepts an array of creators or BookHive's ", "-joined string. */ +export function authorsString(authors) { + if (Array.isArray(authors)) return authors.filter(Boolean).join(', '); + return String(authors ?? ''); +} + +/** Stable comparison key for a book: normalized title + normalized authors. */ +export function bookKey({ title, authors } = {}) { + return `${normalizeText(title)}::${normalizeText(authorsString(authors))}`; +} + +/** + * In-memory index of the remote personal library, kept in sync as uploads + * succeed so two files in one run can't both upload the same book. + */ +export class LibraryIndex { + constructor(books = []) { + this.books = []; + this.byHash = new Map(); + this.byKey = new Map(); + this.byTitle = new Map(); + for (const book of books) this.add(book); + } + + get size() { + return this.books.length; + } + + add(book) { + if (!book) return; + this.books.push(book); + if (book.contentHash) this.byHash.set(book.contentHash, book); + + const key = bookKey(book); + if (!this.byKey.has(key)) this.byKey.set(key, book); + + const title = normalizeText(book.title); + if (title) { + if (!this.byTitle.has(title)) this.byTitle.set(title, []); + this.byTitle.get(title).push(book); + } + } + + hasHash(contentHash) { + return Boolean(contentHash) && this.byHash.has(contentHash); + } + + getByHash(contentHash) { + return this.byHash.get(contentHash); + } + + /** + * Find a metadata match. Returns `{ reason, book }` or null. + * + * Exact title+authors wins. When either side has no authors at all, fall + * back to a title-only match — the alternative is silently uploading a + * duplicate of every book whose OPF lacks a creator. + */ + find({ title, authors } = {}) { + const wantedTitle = normalizeText(title); + if (!wantedTitle) return null; + + const exact = this.byKey.get(bookKey({ title, authors })); + if (exact) return { reason: 'title-author', book: exact }; + + const candidates = this.byTitle.get(wantedTitle) ?? []; + if (candidates.length === 0) return null; + + // No creator on the local side: the title is all we have. + if (!normalizeText(authorsString(authors))) { + return { reason: 'title', book: candidates[0] }; + } + + // Remote entry has no creator either (common for filename-only uploads). + const withoutAuthors = candidates.find(book => !normalizeText(authorsString(book.authors))); + if (withoutAuthors) return { reason: 'title', book: withoutAuthors }; + + return null; + } +} diff --git a/src/sinker/hash.js b/src/sinker/hash.js new file mode 100644 index 0000000..48ed04f --- /dev/null +++ b/src/sinker/hash.js @@ -0,0 +1,89 @@ +// KOReader document identifier (partial MD5). +// +// This is the exact value BookHive stores as `contentHash` for a personal +// library upload (despite the lexicon describing it as SHA-256). Ported from +// KOReader's util.partialMD5 and BookHive's core/bookMetadata/hash.ts. +// +// 12 x 1 KB windows at exponentially increasing offsets, so a byte-identical +// file produces the same hash no matter where it was uploaded from. + +import { createHash } from 'node:crypto'; +import { closeSync, openSync, readSync, statSync } from 'node:fs'; +import { open } from 'node:fs/promises'; + +const WINDOW = 1024; + +/** + * Offsets of the twelve sampled windows. + * + * The original Lua shifts a 32-bit integer: for i = -1 the shift count wraps + * to 30, so `1024 << 30` overflows to 0. Expressed directly as `1024 * 4^i` + * (i >= 0) with an explicit leading 0, which is the same sequence. + */ +export function partialMd5Offsets() { + const offsets = [0]; + for (let i = 0; i < 11; i++) offsets.push(1024 * 4 ** i); + return offsets; +} + +/** + * KOReader partial MD5 of an in-memory buffer. + * @param {Uint8Array} buffer + * @returns {string} lowercase hex digest + */ +export function partialMd5(buffer) { + const hasher = createHash('md5'); + for (const offset of partialMd5Offsets()) { + if (offset >= buffer.length) break; + hasher.update(buffer.subarray(offset, Math.min(offset + WINDOW, buffer.length))); + } + return hasher.digest('hex'); +} + +/** + * Synchronous variant of {@link partialMd5File}, for the few places where an + * existing output file has to be compared before deciding on a name. + * @param {string} filePath + * @returns {string} + */ +export function partialMd5FileSync(filePath) { + const hasher = createHash('md5'); + const size = statSync(filePath).size; + const handle = openSync(filePath, 'r'); + try { + const scratch = Buffer.alloc(WINDOW); + for (const offset of partialMd5Offsets()) { + if (offset >= size) break; + const length = Math.min(WINDOW, size - offset); + const bytesRead = readSync(handle, scratch, 0, length, offset); + hasher.update(scratch.subarray(0, bytesRead)); + } + } finally { + closeSync(handle); + } + return hasher.digest('hex'); +} + +/** + * The same hash, read straight off disk. At most 12 KB of reads regardless of + * file size, so this is cheap enough to run before deciding to compress. + * @param {string} filePath + * @returns {Promise} + */ +export async function partialMd5File(filePath) { + const hasher = createHash('md5'); + const handle = await open(filePath, 'r'); + try { + const stat = await handle.stat(); + const scratch = Buffer.alloc(WINDOW); + for (const offset of partialMd5Offsets()) { + if (offset >= stat.size) break; + const length = Math.min(WINDOW, stat.size - offset); + const { bytesRead } = await handle.read(scratch, 0, length, offset); + hasher.update(scratch.subarray(0, bytesRead)); + } + } finally { + await handle.close(); + } + return hasher.digest('hex'); +} diff --git a/src/sinker/log.js b/src/sinker/log.js new file mode 100644 index 0000000..6708781 --- /dev/null +++ b/src/sinker/log.js @@ -0,0 +1,41 @@ +// Tiny timestamped logger. One line per event; `verbose` turns on debug lines +// (per-image conversion detail) and `quiet` silences info. + +function stamp() { + const now = new Date(); + const pad = n => String(n).padStart(2, '0'); + return `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`; +} + +export function createLogger({ verbose = false, quiet = false, out = console.log, err = console.error } = {}) { + const write = (stream, level, message) => { + stream(`[${stamp()}] ${level ? `${level} ` : ''}${message}`); + }; + + return { + verbose, + quiet, + info(message) { + if (!quiet) write(out, '', message); + }, + /** For output that must never be suppressed (e.g. a login URL). */ + always(message) { + write(out, '', message); + }, + step(message) { + if (!quiet) write(out, '→', message); + }, + ok(message) { + if (!quiet) write(out, '✓', message); + }, + warn(message) { + write(err, 'warning:', message); + }, + error(message) { + write(err, 'error:', message); + }, + debug(message) { + if (verbose) write(out, 'debug:', message); + }, + }; +} diff --git a/src/sinker/metadata.js b/src/sinker/metadata.js new file mode 100644 index 0000000..992ec27 --- /dev/null +++ b/src/sinker/metadata.js @@ -0,0 +1,109 @@ +// EPUB metadata in the shape BookHive stores it. +// +// Reads dc:title / dc:creator from the package document. Kept separate from +// rename.js (which only needs the derived filename) because dedupe compares +// the full creator list, not just the primary author used in the filename. + +import JSZip from 'jszip'; +import { basename } from 'node:path'; + +import { findOPFPath } from '../rename.js'; +import { safeReadText } from '../util.js'; +import { parseXml, byLocalName, getAttrNS } from '../xml.js'; +import { OPF_NS } from '../constants.js'; + +const ENTITIES = { + amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', +}; + +function decodeEntities(value) { + return String(value ?? '').replace(/&(#x?[0-9a-f]+|[a-z]+);/gi, (match, body) => { + if (body[0] === '#') { + const code = body[1] === 'x' || body[1] === 'X' + ? Number.parseInt(body.slice(2), 16) + : Number.parseInt(body.slice(1), 10); + return Number.isFinite(code) ? String.fromCodePoint(code) : match; + } + return ENTITIES[body.toLowerCase()] ?? match; + }); +} + +function textOf(el) { + return decodeEntities(el?.textContent ?? '').replace(/\s+/g, ' ').trim(); +} + +/** Regex fallbacks for OPFs that linkedom can't parse. */ +function fallbackMetadata(opfText) { + const title = opfText.match(/]*>([\s\S]*?)<\/dc:title>/i)?.[1] + ?? opfText.match(/]*>([\s\S]*?)<\/title>/i)?.[1]; + const authors = [...opfText.matchAll(/]*>([\s\S]*?)<\/dc:creator>/gi)] + .map(m => m[1]); + return { + title: title ? decodeEntities(title).replace(/\s+/g, ' ').trim() : '', + authors: authors.map(a => decodeEntities(a).replace(/\s+/g, ' ').trim()).filter(Boolean), + }; +} + +/** + * Extract title / authors from an already-loaded EPUB. + * + * @param {JSZip} zip + * @param {string} [fallbackTitle] used when the OPF has no title, mirroring + * BookHive's filename fallback. + * @returns {Promise<{title:string, authors:string[], primaryAuthor:string}>} + */ +export async function readMetadataFromZip(zip, fallbackTitle = '') { + const empty = { title: fallbackTitle, authors: [], primaryAuthor: '' }; + const opfPath = await findOPFPath(zip); + if (!opfPath || !zip.files[opfPath]) return empty; + + const opfText = await safeReadText(zip.files[opfPath]); + const { doc, ok } = parseXml(opfText); + + let title = ''; + let authors = []; + let primaryAuthor = ''; + + if (ok) { + title = textOf(byLocalName(doc, 'title')[0]); + const creators = byLocalName(doc, 'creator'); + authors = creators.map(textOf).filter(Boolean); + + const role = el => (getAttrNS(el, 'role', OPF_NS, ['opf']) || '').toLowerCase(); + const primary = creators.find(el => role(el) === 'aut') + ?? creators.find(el => !role(el)); + primaryAuthor = textOf(primary) + || (primary ? decodeEntities(getAttrNS(primary, 'file-as', OPF_NS, ['opf']) ?? '').trim() : ''); + } + + if (!title || authors.length === 0) { + const fallback = fallbackMetadata(opfText); + if (!title) title = fallback.title; + if (authors.length === 0) authors = fallback.authors; + if (!primaryAuthor && authors.length > 0) primaryAuthor = authors[0]; + } + + return { + title: title || fallbackTitle, + authors, + primaryAuthor: primaryAuthor || authors[0] || '', + }; +} + +/** + * Extract metadata from raw EPUB bytes. + * @param {Buffer} buffer + * @param {string} [filePath] used for the title fallback + */ +export async function readEpubMetadata(buffer, filePath = '') { + const fallbackTitle = filePath + ? basename(filePath).replace(/\.epub$/i, '') + : ''; + let zip; + try { + zip = await JSZip.loadAsync(buffer); + } catch { + return { title: fallbackTitle, authors: [], primaryAuthor: '' }; + } + return readMetadataFromZip(zip, fallbackTitle); +} diff --git a/src/sinker/pipeline.js b/src/sinker/pipeline.js new file mode 100644 index 0000000..bf4f547 --- /dev/null +++ b/src/sinker/pipeline.js @@ -0,0 +1,375 @@ +// Compression + upload pipeline. +// +// One file at a time, deliberately: sharp is CPU-bound and BookHive asks +// clients not to hammer uploads. The order of checks is cheapest-first: +// ledger -> source hash -> metadata -> compress -> output hash -> upload. + +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; + +import { convertEpub } from '../convert.js'; +import { buildMetadataFilename, reserveAvailableFilename } from '../rename.js'; +import { formatBytes } from '../util.js'; +import { AuthError } from './auth.js'; +import { BookHiveError } from './bookhive.js'; +import { authorsString } from './dedupe.js'; +import { partialMd5, partialMd5File, partialMd5FileSync } from './hash.js'; +import { readEpubMetadata } from './metadata.js'; +import { Ledger, stateFileFor } from './state.js'; + +const TERMINAL_SKIP = new Set(['uploaded', 'skipped-existing', 'skipped-duplicate']); +const PERMANENT_FAILURE = new Set(['failed-permanent']); + +/** Top-level .epub files, sorted, plus the count of ignored files. */ +export function scanSource(sourceDir) { + const epubs = []; + let ignored = 0; + for (const entry of readdirSync(sourceDir, { withFileTypes: true })) { + if (entry.name.startsWith('.')) continue; + if (entry.isDirectory()) continue; + + // Dirent types can be UNKNOWN on network filesystems, and symlinked books + // should be followed. Fall back to stat() when the dirent doesn't say. + let isFile = entry.isFile(); + if (!isFile) { + try { isFile = statSync(join(sourceDir, entry.name)).isFile(); } catch { continue; } + } + if (!isFile) continue; + + if (/\.epub$/i.test(entry.name)) epubs.push(join(sourceDir, entry.name)); + else ignored++; + } + epubs.sort(); + return { epubs, ignored }; +} + +function shouldSkip(entry, stat, config) { + if (!entry || config.force) return false; + if (entry.size !== stat.size || entry.mtimeMs !== stat.mtimeMs) return false; + if (TERMINAL_SKIP.has(entry.status)) return true; + if (PERMANENT_FAILURE.has(entry.status) && !config.retryFailed) return true; + return false; +} + +/** Small synchronous hash used only for "is this existing output identical?". */ +function partialMd5Sync(filePath) { + return partialMd5FileSync(filePath); +} + +function percentChange(before, after) { + if (!before) return '0%'; + const delta = Math.round(((after - before) / before) * 100); + return `${delta > 0 ? '+' : ''}${delta}%`; +} + +function chooseOutputName(ctx, desiredName, outputHash) { + const candidate = join(ctx.config.outputDir, desiredName); + + // A previous run (or `epub-optimize`) may already have written this exact + // output. Reuse the name instead of creating "Title (2).epub" forever. + if (existsSync(candidate)) { + try { + if (partialMd5Sync(candidate) === outputHash) return desiredName; + } catch { /* fall through to a fresh name */ } + } + + return reserveAvailableFilename(desiredName, ctx.usedOutputNames); +} + +function record(ctx, sourcePath, stat, entry) { + if (ctx.config.dryRun) return; + ctx.ledger.record(sourcePath, { + size: stat.size, + mtimeMs: stat.mtimeMs, + ...entry, + }); +} + +/** + * Build the per-run context: ledger, output-name bookkeeping, counters. + */ +export function createContext({ config, bookhive, logger, did }) { + const ledger = new Ledger(stateFileFor(config.stateDir, did), did).load(); + const usedOutputNames = new Set(); + if (existsSync(config.outputDir)) { + for (const name of readdirSync(config.outputDir)) usedOutputNames.add(name.toLowerCase()); + } + + return { + config, + bookhive, + ledger, + logger, + did, + usedOutputNames, + lastLibraryRefresh: 0, + stats: { uploaded: 0, already: 0, failed: 0, ignored: 0, wouldUpload: 0 }, + }; +} + +/** + * Process one EPUB: dedupe, compress, save, upload. + * @returns {Promise<{status: string, outputName?: string}>} + */ +export async function processFile(ctx, sourcePath) { + const { config, bookhive, ledger, logger } = ctx; + const fileName = basename(sourcePath); + + let stat; + try { + stat = statSync(sourcePath); + } catch (error) { + if (error.code === 'ENOENT') return { status: 'gone' }; + throw error; + } + + const previous = ledger.get(sourcePath); + if (shouldSkip(previous, stat, config)) { + logger.debug(`${fileName}: unchanged (${previous.status})`); + return { status: 'unchanged' }; + } + + logger.step(fileName); + + // 1. Exact-file check, before paying for a compress. + const sourceHash = await partialMd5File(sourcePath); + if (!config.force) { + const existing = await bookhive.getPersonalBook(sourceHash); + if (existing) { + record(ctx, sourcePath, stat, { + status: 'skipped-existing', + sourceHash, + title: existing.title, + authors: existing.authors ?? '', + contentHash: existing.contentHash, + }); + ctx.stats.already++; + logger.info(` already in BookHive as "${existing.title}" (identical file) — skipped`); + return { status: 'skipped-existing' }; + } + } + + // 2. Metadata. BookHive's own parse is dc:title / all dc:creator joined ", ". + const sourceBuffer = readFileSync(sourcePath); + const metadata = await readEpubMetadata(sourceBuffer, sourcePath); + const title = metadata.title || fileName.replace(/\.epub$/i, ''); + const authors = authorsString(metadata.authors); + + // 3. Metadata check, still before compressing. + if (!config.force) { + const match = bookhive.library.find({ title, authors }); + if (match) { + record(ctx, sourcePath, stat, { + status: 'skipped-duplicate', + sourceHash, + title, + authors, + contentHash: match.book.contentHash, + matchedBy: match.reason, + }); + ctx.stats.already++; + const how = match.reason === 'title-author' ? 'title + author' : 'title'; + logger.info(` already in BookHive as "${match.book.title}" (${how}) — skipped`); + return { status: 'skipped-duplicate' }; + } + } + + // 4. Compress. + const hooks = { + image: () => {}, + fix: () => {}, + warn: message => logger.warn(message), + }; + const { buffer, stats } = await convertEpub(sourceBuffer, config.convertOptions, hooks); + const outputHash = partialMd5(buffer); + + // 5. Compressed-file check (catches copies uploaded from another machine, + // where the source hash differs but the bytes do not). + if (!config.force) { + if (bookhive.library.hasHash(outputHash)) { + const book = bookhive.library.getByHash(outputHash); + record(ctx, sourcePath, stat, { + status: 'skipped-existing', sourceHash, outputHash, + title, authors, contentHash: outputHash, + }); + ctx.stats.already++; + logger.info(` identical compressed file is already in BookHive ("${book?.title ?? title}") — skipped`); + return { status: 'skipped-existing' }; + } + + const existing = await bookhive.getPersonalBook(outputHash); + if (existing) { + record(ctx, sourcePath, stat, { + status: 'skipped-existing', sourceHash, outputHash, + title, authors, contentHash: outputHash, + }); + ctx.stats.already++; + logger.info(` identical compressed file is already in BookHive — skipped`); + return { status: 'skipped-existing' }; + } + } + + // 6. Name + write the archive copy. + const desiredName = config.rename + ? (buildMetadataFilename(title, metadata.primaryAuthor) || fileName) + : fileName; + const outputName = chooseOutputName(ctx, desiredName, outputHash); + const outputPath = join(config.outputDir, outputName); + + if (!config.dryRun) { + mkdirSync(config.outputDir, { recursive: true }); + let alreadyWritten = false; + if (existsSync(outputPath)) { + try { alreadyWritten = partialMd5Sync(outputPath) === outputHash; } catch { /* rewrite */ } + } + if (!alreadyWritten) writeFileSync(outputPath, buffer); + } + + logger.info( + ` ${formatBytes(stats.originalSize)} → ${formatBytes(stats.newSize)} ` + + `(${percentChange(stats.originalSize, stats.newSize)}) → ${outputName}`, + ); + + if (config.dryRun) { + ctx.stats.wouldUpload++; + logger.info(' would upload (dry run)'); + return { status: 'dry-run', outputName }; + } + + // 7. Upload. + const result = await bookhive.upload(buffer, outputName); + const storage = result.storageUsedBytes !== undefined && result.storageQuotaBytes + ? ` | storage ${formatBytes(result.storageUsedBytes)} / ${formatBytes(result.storageQuotaBytes)}` + : ''; + + if (result.alreadyExists) { + logger.ok(` uploaded as "${outputName}" (BookHive already had it)${storage}`); + } else { + logger.ok(` uploaded as "${outputName}"${storage}`); + } + + // Keep the in-run index accurate even when BookHive returned 409 without a + // book view, so a second copy in the same batch is skipped rather than + // uploaded and rejected again. + if (!result.book) { + bookhive.library.add({ contentHash: outputHash, title, authors }); + } + + record(ctx, sourcePath, stat, { + status: 'uploaded', + sourceHash, + outputHash, + outputName, + title: result.book?.title ?? title, + authors: result.book?.authors ?? authors, + contentHash: result.book?.contentHash ?? outputHash, + }); + ctx.stats.uploaded++; + return { status: 'uploaded', outputName }; +} + +/** + * One sync pass. + * + * @param {object} ctx from createContext() + * @param {{paths?: string[]}} [options] explicit file list (watcher events); + * omit for a full top-level scan. + * @returns {Promise} ctx.stats + */ +export async function syncOnce(ctx, { paths, silent = false } = {}) { + const { config, logger } = ctx; + ctx.stats = { uploaded: 0, already: 0, failed: 0, ignored: 0, wouldUpload: 0 }; + + if (!existsSync(config.sourceDir)) { + throw new Error(`source directory not found: ${config.sourceDir}`); + } + if (!config.dryRun) mkdirSync(config.outputDir, { recursive: true }); + + let candidates; + let ignored = 0; + + if (paths) { + candidates = paths.filter(path => /\.epub$/i.test(path)); + } else { + const scan = scanSource(config.sourceDir); + candidates = scan.epubs; + ignored = scan.ignored; + ctx.ledger.prune(new Set(candidates)); + } + + const pending = []; + for (const sourcePath of candidates) { + let stat; + try { + stat = statSync(sourcePath); + } catch { + continue; + } + if (shouldSkip(ctx.ledger.get(sourcePath), stat, config)) continue; + pending.push(sourcePath); + } + + ctx.stats.ignored += ignored; + + if (pending.length === 0) { + if (paths || !silent) logger.info(paths ? 'Nothing new to sync.' : 'No new EPUBs.'); + else logger.debug('rescan: nothing new'); + return ctx.stats; + } + + logger.info( + `Scanning ${config.sourceDir} — ${pending.length} new/changed file${pending.length === 1 ? '' : 's'}`, + ); + + // Reuse the index for a burst of watcher events; a full refresh costs one + // paginated round-trip per 100 books. + if (!ctx.lastLibraryRefresh || Date.now() - ctx.lastLibraryRefresh > 60_000) { + await ctx.bookhive.refreshLibrary(); + ctx.lastLibraryRefresh = Date.now(); + logger.debug(`BookHive library: ${ctx.bookhive.library.size} book(s)`); + } else { + logger.debug(`BookHive library: ${ctx.bookhive.library.size} book(s) (cached)`); + } + + for (const sourcePath of pending) { + try { + const result = await processFile(ctx, sourcePath); + if (result.status === 'unchanged' || result.status === 'gone') continue; + } catch (error) { + if (error instanceof BookHiveError + && (error.status === 401 || error.code === 'TokenError' || error.status === 403)) { + throw new AuthError( + `BookHive rejected the session (${error.message}). Run \`sinker login\` again.`, + ); + } + + const permanent = error instanceof BookHiveError ? !error.retryable : true; + let stat = null; + try { stat = statSync(sourcePath); } catch { /* file may be gone */ } + if (stat) { + record(ctx, sourcePath, stat, { + status: permanent ? 'failed-permanent' : 'failed', + error: error.message, + }); + } + ctx.stats.failed++; + logger.error(`${basename(sourcePath)}: ${error.message}`); + } finally { + ctx.ledger.save(); + } + } + + ctx.ledger.save(); + return ctx.stats; +} + +/** Human-readable end-of-run summary. */ +export function summarize(stats) { + const parts = []; + if (stats.uploaded) parts.push(`${stats.uploaded} uploaded`); + if (stats.already) parts.push(`${stats.already} already there`); + if (stats.wouldUpload) parts.push(`${stats.wouldUpload} would upload`); + if (stats.failed) parts.push(`${stats.failed} failed`); + if (stats.ignored) parts.push(`${stats.ignored} non-EPUB ignored`); + return parts.length ? parts.join(', ') : 'nothing to do'; +} diff --git a/src/sinker/state.js b/src/sinker/state.js new file mode 100644 index 0000000..ffc1b47 --- /dev/null +++ b/src/sinker/state.js @@ -0,0 +1,107 @@ +// Local ledger of what has been seen and uploaded. +// +// One JSON file per atproto account, keyed by absolute source path. Writes are +// atomic (temp file + rename) because a service that dies mid-write must never +// leave a truncated ledger behind. + +import { + existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync, rmSync, +} from 'node:fs'; +import { join, dirname } from 'node:path'; + +const VERSION = 1; + +export function stateFileName(did) { + const safe = did ? encodeURIComponent(did) : 'anonymous'; + return `state-${safe}.json`; +} + +export function stateFileFor(stateDir, did) { + return join(stateDir, stateFileName(did)); +} + +function writeJsonAtomic(file, value) { + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + const tmp = `${file}.${process.pid}.tmp`; + writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); + renameSync(tmp, file); + try { chmodSync(file, 0o600); } catch { /* best effort on platforms without modes */ } +} + +export class Ledger { + constructor(file, did = null) { + this.file = file; + this.did = did; + this.files = {}; + this.dirty = false; + } + + load() { + if (!existsSync(this.file)) return this; + try { + const parsed = JSON.parse(readFileSync(this.file, 'utf8')); + if (parsed && typeof parsed === 'object' && parsed.files && typeof parsed.files === 'object') { + this.files = parsed.files; + this.did = parsed.did ?? this.did; + } + } catch { + // A corrupt ledger is not worth failing a sync over: the remote checks + // still prevent duplicate uploads. Start from empty and overwrite it on + // the next save. + this.files = {}; + } + return this; + } + + get(filePath) { + return this.files[filePath]; + } + + record(filePath, entry) { + this.files[filePath] = { ...entry, recordedAt: new Date().toISOString() }; + this.dirty = true; + } + + remove(filePath) { + if (filePath in this.files) { + delete this.files[filePath]; + this.dirty = true; + } + } + + /** Forget entries whose source file no longer exists. */ + prune(existingPaths) { + for (const key of Object.keys(this.files)) { + if (!existingPaths.has(key)) { + delete this.files[key]; + this.dirty = true; + } + } + return this; + } + + save() { + if (!this.dirty) return; + writeJsonAtomic(this.file, { + version: VERSION, + did: this.did, + updatedAt: new Date().toISOString(), + files: this.files, + }); + this.dirty = false; + } + + /** Stats used by `sinker status`. */ + counts() { + const counts = {}; + for (const entry of Object.values(this.files)) { + const status = entry.status ?? 'unknown'; + counts[status] = (counts[status] ?? 0) + 1; + } + return counts; + } + + destroy() { + rmSync(this.file, { force: true }); + } +} diff --git a/src/sinker/watcher.js b/src/sinker/watcher.js new file mode 100644 index 0000000..a95015c --- /dev/null +++ b/src/sinker/watcher.js @@ -0,0 +1,143 @@ +// Folder watcher. +// +// chokidar handles the platform specifics (FSEvents on macOS) and waits for +// writes to settle, so a half-copied EPUB never reaches the compressor. +// Events are serialised through one queue; a periodic rescan catches missed +// events (network volumes) and retries transient failures. + +import { existsSync } from 'node:fs'; + +import chokidar from 'chokidar'; + +import { syncOnce, summarize } from './pipeline.js'; + +/** + * Run forever until SIGINT/SIGTERM. Throws on a fatal (auth) error. + * @returns {Promise} exit code + */ +export async function watch(ctx) { + const { config, logger } = ctx; + + if (!existsSync(config.sourceDir)) { + throw new Error(`source directory not found: ${config.sourceDir}`); + } + let watcher = null; + let rescan = null; + let active = false; + let stopping = false; + let closed = false; + let fatal = null; + const queue = []; + + let resolveDone; + const done = new Promise(resolve => { resolveDone = resolve; }); + + const finish = () => { + clearInterval(rescan); + if (watcher && !closed) { + closed = true; + void watcher.close(); + } + resolveDone(); + }; + + const stop = () => { + if (stopping) return; + stopping = true; + // If a book is mid-upload, let it finish; runSync calls finish() when it + // is no longer active. + if (!active) finish(); + }; + + async function drain() { + if (active || stopping) return; + while (queue.length && !stopping) { + const batch = queue.splice(0, queue.length); + const stats = await runSync({ paths: batch }); + if (stats) logger.info(`sync complete: ${summarize(stats)}`); + if (fatal) return; + } + } + + const runSync = async ({ paths, silent = false } = {}) => { + if (active || stopping) return null; + active = true; + try { + const stats = await syncOnce(ctx, { ...(paths ? { paths } : {}), silent }); + if (silent) logger.debug(`rescan: ${summarize(stats)}`); + return stats; + } catch (error) { + fatal = error; + logger.error(error.message); + stop(); + return null; + } finally { + active = false; + if (stopping) finish(); + else if (queue.length) void drain(); + } + }; + + const onFile = path => { + if (stopping) return; + if (!/\.epub$/i.test(path)) return; + queue.push(path); + void drain(); + }; + + logger.info(`Watching ${config.sourceDir} (top level) → ${config.outputDir}`); + logger.info('Press Ctrl+C to stop.'); + + const onSignal = () => { + logger.info('Stopping after the current book…'); + stop(); + }; + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + + try { + // Start watching before the backfill scan so a file added during the scan + // is either seen by the scan or emitted as an event — never missed. + watcher = chokidar.watch(config.sourceDir, { + depth: 0, + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 2_000, pollInterval: 200 }, + usePolling: config.poll, + ignored: /(^|[/\\])\./, + }); + + watcher.on('add', onFile); + watcher.on('change', onFile); + watcher.on('error', error => { + logger.error(`watcher: ${error.message}`); + }); + + await new Promise(resolve => watcher.once('ready', resolve)); + + if (!stopping && config.backfill) { + const stats = await syncOnce(ctx, { silent: true }); + logger.info(`Initial scan: ${summarize(stats)}`); + } + + if (!stopping) { + rescan = setInterval(() => { + if (active || stopping || queue.length > 0) return; + void runSync({ silent: true }); + }, config.rescanMs); + rescan.unref?.(); + } + + await done; + } finally { + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + clearInterval(rescan); + if (watcher && !closed) { + closed = true; + await watcher.close(); + } + } + + if (fatal) throw fatal; + return 0; +} diff --git a/test/sinker/auth.test.js b/test/sinker/auth.test.js new file mode 100644 index 0000000..46595a1 --- /dev/null +++ b/test/sinker/auth.test.js @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + BOOKHIVE_RPC_METHODS, createOAuth, logout, oauthScope, readSessionPointer, +} from '../../src/sinker/auth.js'; +import { loadConfig } from '../../src/sinker/config.js'; + +function configFor(dir) { + return loadConfig({ + cwd: dir, + env: { + SINKER_SOURCE_DIR: join(dir, 'in'), + SINKER_OUTPUT_DIR: join(dir, 'out'), + SINKER_STATE_DIR: join(dir, 'state'), + SINKER_OAUTH_DIR: join(dir, 'oauth'), + }, + }); +} + +test('requests only BookHive RPCs, with the required atproto scope', () => { + const scopes = oauthScope(); + assert.equal(scopes[0], 'atproto'); + assert.equal(scopes.length, BOOKHIVE_RPC_METHODS.length + 1); + for (const method of BOOKHIVE_RPC_METHODS) { + assert.ok(scopes.includes(`rpc:${method}?aud=*`)); + } +}); + +test('builds a loopback client with a 127.0.0.1 redirect', () => { + const dir = mkdtempSync(join(tmpdir(), 'sinker-auth-')); + try { + const config = configFor(dir); + const { oauth, redirectUri } = createOAuth(config); + + assert.equal(redirectUri, 'http://127.0.0.1:8765/callback'); + assert.match(oauth.metadata.client_id, /^http:\/\/localhost\?/); + assert.deepEqual(oauth.metadata.redirect_uris, [redirectUri]); + assert.equal(oauth.metadata.token_endpoint_auth_method, 'none'); + assert.equal(oauth.metadata.scope, oauthScope().join(' ')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('logout without a session is a no-op', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sinker-auth-')); + try { + const config = configFor(dir); + assert.equal(readSessionPointer(config), undefined); + assert.equal(await logout(config), null); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/sinker/bookhive.test.js b/test/sinker/bookhive.test.js new file mode 100644 index 0000000..86f8e15 --- /dev/null +++ b/test/sinker/bookhive.test.js @@ -0,0 +1,232 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { BookHiveError, createBookHiveClient } from '../../src/sinker/bookhive.js'; + +const config = { + bookhiveUrl: 'https://bookhive.test', + bookhiveDid: 'did:plc:bookhive', + retryMax: 3, + timeoutMs: 5_000, +}; + +const logger = { debug() {}, info() {}, warn() {}, error() {} }; + +function response(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function fakeSession(tokens = ['token-1', 'token-2', 'token-3']) { + const calls = []; + let index = 0; + return { + calls, + async handle(path) { + calls.push(path); + const token = tokens[Math.min(index++, tokens.length - 1)]; + return response({ token }); + }, + }; +} + +test('mints a service token per method with the BookHive audience', async () => { + const session = fakeSession(); + const client = createBookHiveClient({ + config, + session, + logger, + fetchImpl: async () => response({ book: null }), + }); + + await client.getPersonalBook('abc'); + const mint = new URL(`https://pds.test${session.calls[0]}`); + assert.equal(mint.pathname, '/xrpc/com.atproto.server.getServiceAuth'); + assert.equal(mint.searchParams.get('aud'), config.bookhiveDid); + assert.equal(mint.searchParams.get('lxm'), 'buzz.bookhive.getPersonalBook'); + assert.ok(Number(mint.searchParams.get('exp')) > Math.floor(Date.now() / 1000)); +}); + +test('getPersonalBook returns null on NotFound and the book otherwise', async () => { + const session = fakeSession(); + + const missing = createBookHiveClient({ + config, + session, + logger, + fetchImpl: async () => response({ error: 'NotFound', message: 'nope' }, 404), + }); + assert.equal(await missing.getPersonalBook('missing'), null); + + const found = createBookHiveClient({ + config, + session, + logger, + fetchImpl: async () => response({ book: { contentHash: 'abc', title: 'Dune' } }), + }); + assert.equal((await found.getPersonalBook('abc')).title, 'Dune'); +}); + +test('refreshLibrary paginates and exposes storage', async () => { + const session = fakeSession(); + const requests = []; + const fetchImpl = async url => { + const parsed = new URL(url); + requests.push(parsed); + if (!parsed.searchParams.get('cursor')) { + return response({ + books: [{ contentHash: 'a', title: 'A', authors: 'One' }], + cursor: 'page-2', + total: 2, + storage: { usedBytes: 10, quotaBytes: 100 }, + }); + } + return response({ + books: [{ contentHash: 'b', title: 'B', authors: 'Two' }], + total: 2, + }); + }; + + const client = createBookHiveClient({ config, session, logger, fetchImpl }); + await client.refreshLibrary(); + + assert.equal(client.library.size, 2); + assert.deepEqual(client.storage, { usedBytes: 10, quotaBytes: 100 }); + assert.equal(requests.length, 2); + assert.equal(requests[0].searchParams.get('limit'), '100'); + assert.equal(requests[0].searchParams.get('sort'), 'recent'); + assert.equal(requests[1].searchParams.get('cursor'), 'page-2'); +}); + +test('upload sends raw bytes with the filename and a bearer token', async () => { + const session = fakeSession(); + const requests = []; + const payload = Buffer.from('epub-bytes'); + + const client = createBookHiveClient({ + config, + session, + logger, + fetchImpl: async (url, init) => { + requests.push({ url: new URL(url), init }); + return response({ + book: { contentHash: 'hash', title: 'Dune' }, + storageUsedBytes: 100, + storageQuotaBytes: 1000, + }); + }, + }); + + const result = await client.upload(payload, 'Dune.epub'); + + assert.equal(result.alreadyExists, false); + assert.equal(client.library.size, 1); + assert.deepEqual(client.storage, { usedBytes: 100, quotaBytes: 1000 }); + + const { url, init } = requests[0]; + assert.equal(url.pathname, '/xrpc/buzz.bookhive.uploadPersonalBook'); + assert.equal(url.searchParams.get('filename'), 'Dune.epub'); + assert.equal(init.method, 'POST'); + assert.equal(init.headers.authorization, 'Bearer token-1'); + assert.equal(init.headers['content-type'], 'application/epub+zip'); + assert.equal(Buffer.from(init.body).toString(), 'epub-bytes'); +}); + +test('AlreadyExists is treated as success', async () => { + const client = createBookHiveClient({ + config, + session: fakeSession(), + logger, + fetchImpl: async () => response({ error: 'AlreadyExists' }, 409), + }); + + const result = await client.upload(Buffer.from('x'), 'x.epub'); + assert.deepEqual(result, { alreadyExists: true }); +}); + +test('retries Busy responses', async () => { + let calls = 0; + const client = createBookHiveClient({ + config, + session: fakeSession(), + logger, + sleep: async () => {}, + fetchImpl: async () => { + calls++; + if (calls === 1) return response({ error: 'Busy' }, 503); + return response({ book: { contentHash: 'h', title: 'T' } }); + }, + }); + + const result = await client.upload(Buffer.from('x'), 'x.epub'); + assert.equal(result.alreadyExists, false); + assert.equal(calls, 2); +}); + +test('does not retry permanent failures', async () => { + let calls = 0; + const client = createBookHiveClient({ + config, + session: fakeSession(), + logger, + sleep: async () => {}, + fetchImpl: async () => { + calls++; + return response({ error: 'QuotaExceeded', message: 'full' }, 413); + }, + }); + + await assert.rejects( + () => client.upload(Buffer.from('x'), 'x.epub'), + error => { + assert.ok(error instanceof BookHiveError); + assert.equal(error.code, 'QuotaExceeded'); + assert.equal(error.retryable, false); + return true; + }, + ); + assert.equal(calls, 1); +}); + +test('a 401 mints a fresh token and retries without consuming a retry', async () => { + const session = fakeSession(); + let calls = 0; + const client = createBookHiveClient({ + config, + session, + logger, + sleep: async () => {}, + fetchImpl: async () => { + calls++; + if (calls === 1) return response({ error: 'AuthenticationRequired' }, 401); + return response({ book: { contentHash: 'h', title: 'T' } }); + }, + }); + + const book = await client.getPersonalBook('h'); + assert.equal(book.title, 'T'); + assert.equal(calls, 2); + assert.equal(session.calls.length, 2); +}); + +test('network errors are retried and then surfaced', async () => { + let calls = 0; + const client = createBookHiveClient({ + config, + session: fakeSession(), + logger, + sleep: async () => {}, + fetchImpl: async () => { + calls++; + throw new Error('ECONNRESET'); + }, + }); + + await assert.rejects( + () => client.getPersonalBook('h'), + error => error.code === 'NetworkError' && error.retryable === true, + ); + assert.equal(calls, config.retryMax); +}); diff --git a/test/sinker/config.test.js b/test/sinker/config.test.js new file mode 100644 index 0000000..8af97e3 --- /dev/null +++ b/test/sinker/config.test.js @@ -0,0 +1,104 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { ConfigError, loadConfig } from '../../src/sinker/config.js'; + +function tempDir() { + return mkdtempSync(join(tmpdir(), 'sinker-config-')); +} + +test('defaults resolve against the working directory', () => { + const dir = tempDir(); + try { + const config = loadConfig({ cwd: dir, env: {} }); + assert.equal(config.sourceDir, join(dir, 'unformatted-books')); + assert.equal(config.outputDir, join(dir, 'formatted-books')); + assert.equal(config.device, 'X4'); + assert.equal(config.quality, 85); + assert.equal(config.rename, true); + assert.equal(config.loginPort, 8765); + assert.equal(config.loginHost, '127.0.0.1'); + assert.equal(config.bookhiveUrl, 'https://bookhive.buzz'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('environment overrides defaults and expands ~', () => { + const dir = tempDir(); + try { + const config = loadConfig({ + cwd: dir, + env: { + SINKER_SOURCE_DIR: '~/books/in', + SINKER_OUTPUT_DIR: '/tmp/sinker-out', + SINKER_DEVICE: 'x3', + SINKER_QUALITY: '72', + SINKER_AUTO_CROP: 'yes', + SINKER_RENAME: 'false', + SINKER_LOGIN_HOST: '0.0.0.0', + }, + }); + + assert.equal(config.sourceDir, join(homedir(), 'books', 'in')); + assert.equal(config.outputDir, '/tmp/sinker-out'); + assert.equal(config.device, 'X3'); + assert.equal(config.quality, 72); + assert.equal(config.autoCrop, true); + assert.equal(config.rename, false); + assert.equal(config.loginHost, '0.0.0.0'); + assert.deepEqual(config.convertOptions, { + maxWidth: 528, + maxHeight: 792, + quality: 72, + autoCrop: true, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('CLI overrides beat the environment', () => { + const dir = tempDir(); + try { + const config = loadConfig({ + cwd: dir, + env: { SINKER_QUALITY: '72', SINKER_DEVICE: 'X3' }, + overrides: { quality: '90', device: 'X4' }, + }); + assert.equal(config.quality, 90); + assert.equal(config.device, 'X4'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('rejects identical source and output folders', () => { + const dir = tempDir(); + try { + assert.throws( + () => loadConfig({ + cwd: dir, + env: { SINKER_SOURCE_DIR: 'books', SINKER_OUTPUT_DIR: 'books' }, + }), + ConfigError, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('rejects invalid values', () => { + const dir = tempDir(); + try { + assert.throws(() => loadConfig({ cwd: dir, env: { SINKER_DEVICE: 'X5' } }), ConfigError); + assert.throws(() => loadConfig({ cwd: dir, env: { SINKER_QUALITY: '0' } }), ConfigError); + assert.throws(() => loadConfig({ cwd: dir, env: { SINKER_QUALITY: 'high' } }), ConfigError); + assert.throws(() => loadConfig({ cwd: dir, env: { SINKER_AUTO_CROP: 'sometimes' } }), ConfigError); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/sinker/dedupe.test.js b/test/sinker/dedupe.test.js new file mode 100644 index 0000000..a5996b3 --- /dev/null +++ b/test/sinker/dedupe.test.js @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + LibraryIndex, authorsString, bookKey, normalizeText, +} from '../../src/sinker/dedupe.js'; + +test('normalizes case, punctuation, whitespace and diacritics', () => { + assert.equal(normalizeText(' The Handmaid\'s Tale '), 'the handmaids tale'); + assert.equal(normalizeText('Café Society'), 'cafe society'); + assert.equal(normalizeText('Dune: Messiah'), 'dune messiah'); +}); + +test('joins creators like BookHive', () => { + assert.equal(authorsString(['Frank Herbert']), 'Frank Herbert'); + assert.equal(authorsString(['A', 'B']), 'A, B'); + assert.equal(authorsString('A, B'), 'A, B'); +}); + +test('key ignores formatting differences', () => { + assert.equal( + bookKey({ title: 'The Test Book', authors: ['Jane Author'] }), + bookKey({ title: 'the test book', authors: 'Jane Author' }), + ); +}); + +test('index matches hash, title+author and title-only fallback', () => { + const index = new LibraryIndex([ + { contentHash: 'abc', title: 'The Test Book', authors: 'Jane Author' }, + { contentHash: 'def', title: 'Anonymous Work', authors: '' }, + ]); + + assert.equal(index.size, 2); + assert.ok(index.hasHash('abc')); + assert.equal(index.find({ title: 'The Test Book', authors: 'Jane Author' }).reason, 'title-author'); + assert.equal(index.find({ title: 'Anonymous Work', authors: '' }).reason, 'title-author'); + + // A creator mismatch is not a match unless one side has no creator at all. + assert.equal(index.find({ title: 'Anonymous Work', authors: ['Someone'] }).reason, 'title'); + assert.equal(index.find({ title: 'The Test Book', authors: 'Someone Else' }), null); + + // A missing creator on the local side still matches a title-only remote book. + assert.equal(index.find({ title: 'Anonymous Work', authors: [] }).reason, 'title-author'); + assert.equal(index.find({ title: 'Something Else', authors: '' }), null); +}); + +test('title-only fallback also applies when the remote entry lacks authors', () => { + const index = new LibraryIndex([{ contentHash: 'x', title: 'Nameless', authors: '' }]); + assert.equal(index.find({ title: 'Nameless', authors: 'A. Writer' }).reason, 'title'); +}); + +test('adding a book is immediately visible to later matches', () => { + const index = new LibraryIndex(); + index.add({ contentHash: 'one', title: 'New Book', authors: 'Writer' }); + assert.ok(index.hasHash('one')); + assert.ok(index.find({ title: 'New Book', authors: 'Writer' })); +}); diff --git a/test/sinker/hash.test.js b/test/sinker/hash.test.js new file mode 100644 index 0000000..7bf735b --- /dev/null +++ b/test/sinker/hash.test.js @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { createHash, randomBytes } from 'node:crypto'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { partialMd5, partialMd5File, partialMd5FileSync, partialMd5Offsets } from '../../src/sinker/hash.js'; + +const OFFSETS = [ + 0, 1024, 4096, 16384, 65536, 262144, 1048576, 4194304, 16777216, + 67108864, 268435456, 1073741824, +]; + +function referenceHash(buffer) { + const hasher = createHash('md5'); + for (const offset of OFFSETS) { + if (offset >= buffer.length) break; + hasher.update(buffer.subarray(offset, Math.min(offset + 1024, buffer.length))); + } + return hasher.digest('hex'); +} + +test('offsets match KOReader partialMD5', () => { + assert.deepEqual(partialMd5Offsets(), OFFSETS); +}); + +test('hashes only the sampled windows, not the whole file', () => { + const buffer = randomBytes(200_000); + assert.equal(partialMd5(buffer), referenceHash(buffer)); + // Changing a byte outside every sampled window must not change the hash. + buffer[50_000] ^= 0xff; + assert.equal(partialMd5(buffer), referenceHash(buffer)); +}); + +test('handles files shorter than one window', () => { + const buffer = Buffer.from('tiny epub'); + assert.equal(partialMd5(buffer), createHash('md5').update(buffer).digest('hex')); +}); + +test('file and buffer variants agree', async () => { + const dir = mkdtempSync(join(tmpdir(), 'sinker-hash-')); + try { + const buffer = randomBytes(3_000_000); + const file = join(dir, 'book.epub'); + writeFileSync(file, buffer); + assert.equal(await partialMd5File(file), partialMd5(buffer)); + assert.equal(partialMd5FileSync(file), partialMd5(buffer)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/sinker/integration.test.js b/test/sinker/integration.test.js new file mode 100644 index 0000000..ec096f4 --- /dev/null +++ b/test/sinker/integration.test.js @@ -0,0 +1,137 @@ +// End-to-end over real HTTP: syncOnce -> BookHive client -> fake BookHive +// server. The OAuth session is stubbed (minting a real token requires a PDS +// login), but everything from the XRPC request shape down is exercised. + +import assert from 'node:assert/strict'; +import { once } from 'node:events'; +import { copyFileSync, mkdtempSync, mkdirSync, rmSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { createBookHiveClient } from '../../src/sinker/bookhive.js'; +import { loadConfig } from '../../src/sinker/config.js'; +import { createContext, syncOnce } from '../../src/sinker/pipeline.js'; + +const FIXTURE = fileURLToPath(new URL('../fixtures/sample.epub', import.meta.url)); +const silent = { + debug() {}, info() {}, step() {}, ok() {}, warn() {}, error() {}, +}; + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +async function startBookHive({ library = [] } = {}) { + const uploads = []; + const requests = []; + + const server = createServer(async (req, res) => { + const url = new URL(req.url, 'http://127.0.0.1'); + requests.push({ method: req.method, path: url.pathname, query: Object.fromEntries(url.searchParams) }); + + const send = (status, body) => { + res.writeHead(status, { 'content-type': 'application/json' }); + res.end(JSON.stringify(body)); + }; + + if (url.pathname === '/xrpc/buzz.bookhive.getPersonalLibrary') { + send(200, { books: library, total: library.length }); + return; + } + + if (url.pathname === '/xrpc/buzz.bookhive.getPersonalBook') { + send(404, { error: 'NotFound', message: 'not in library' }); + return; + } + + if (url.pathname === '/xrpc/buzz.bookhive.uploadPersonalBook' && req.method === 'POST') { + const body = await readBody(req); + uploads.push({ + filename: url.searchParams.get('filename'), + contentType: req.headers['content-type'], + authorization: req.headers.authorization, + body, + }); + send(200, { + book: { + contentHash: `uploaded-${uploads.length}`, + title: 'The Test Book', + authors: 'Jane Author', + format: 'epub', + mime: 'application/epub+zip', + sizeBytes: body.length, + }, + storageUsedBytes: body.length, + storageQuotaBytes: 1_000_000, + }); + return; + } + + send(404, { error: 'NotFound' }); + }); + + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + return { server, uploads, requests, port: server.address().port }; +} + +test('full pipeline: mint token, upload bytes, then skip on the next pass', async () => { + const root = mkdtempSync(join(tmpdir(), 'sinker-e2e-')); + const sourceDir = join(root, 'in'); + mkdirSync(sourceDir, { recursive: true }); + copyFileSync(FIXTURE, join(sourceDir, 'sample.epub')); + + const { server, uploads, requests, port } = await startBookHive(); + + try { + const config = loadConfig({ + cwd: root, + env: { + BOOKHIVE_URL: `http://127.0.0.1:${port}`, + SINKER_SOURCE_DIR: sourceDir, + SINKER_OUTPUT_DIR: join(root, 'out'), + SINKER_STATE_DIR: join(root, 'state'), + SINKER_OAUTH_DIR: join(root, 'oauth'), + }, + }); + + const session = { + async handle() { + return new Response(JSON.stringify({ token: 'fake-service-token' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }; + + const client = createBookHiveClient({ config, session, logger: silent }); + const ctx = createContext({ config, bookhive: client, logger: silent, did: 'did:plc:test' }); + + const first = await syncOnce(ctx); + assert.equal(first.uploaded, 1); + assert.equal(uploads.length, 1); + assert.equal(uploads[0].filename, 'The Test Book - Jane Author.epub'); + assert.equal(uploads[0].contentType, 'application/epub+zip'); + assert.equal(uploads[0].authorization, 'Bearer fake-service-token'); + assert.equal(uploads[0].body.subarray(0, 2).toString(), 'PK'); + + const second = await syncOnce(ctx); + assert.equal(second.uploaded, 0); + assert.equal(uploads.length, 1); + + // The service-auth mint calls are the only non-BookHive requests. + assert.ok(requests.some(r => r.path === '/xrpc/buzz.bookhive.getPersonalLibrary')); + assert.ok(requests.some(r => r.path === '/xrpc/buzz.bookhive.uploadPersonalBook')); + } finally { + server.close(); + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/sinker/pipeline.test.js b/test/sinker/pipeline.test.js new file mode 100644 index 0000000..1e34342 --- /dev/null +++ b/test/sinker/pipeline.test.js @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { fileURLToPath } from 'node:url'; + +import { loadConfig } from '../../src/sinker/config.js'; +import { LibraryIndex } from '../../src/sinker/dedupe.js'; +import { partialMd5File } from '../../src/sinker/hash.js'; +import { createContext, syncOnce } from '../../src/sinker/pipeline.js'; +import { stateFileFor } from '../../src/sinker/state.js'; + +const FIXTURE = fileURLToPath(new URL('../fixtures/sample.epub', import.meta.url)); +const OUTPUT_NAME = 'The Test Book - Jane Author.epub'; + +const silent = { + debug() {}, info() {}, step() {}, ok() {}, warn() {}, error() {}, +}; + +function makeConfig(overrides = {}) { + const root = mkdtempSync(join(tmpdir(), 'sinker-pipeline-')); + const sourceDir = join(root, 'in'); + mkdirSync(sourceDir, { recursive: true }); + + const config = loadConfig({ + cwd: root, + env: { + SINKER_SOURCE_DIR: sourceDir, + SINKER_OUTPUT_DIR: join(root, 'out'), + SINKER_STATE_DIR: join(root, 'state'), + SINKER_OAUTH_DIR: join(root, 'oauth'), + }, + overrides, + }); + + return { root, config, sourceDir }; +} + +function fakeClient({ library = new LibraryIndex(), personal = new Map() } = {}) { + const uploads = []; + return { + uploads, + get library() { return library; }, + storage: null, + async getPersonalBook(hash) { return personal.get(hash) ?? null; }, + async refreshLibrary() { return library; }, + async upload(buffer, filename) { + uploads.push({ filename, size: buffer.length }); + const book = { + contentHash: `uploaded-${uploads.length}`, + title: 'The Test Book', + authors: 'Jane Author', + }; + library.add(book); + return { alreadyExists: false, book }; + }, + }; +} + +function contextFor(config, client) { + return createContext({ config, bookhive: client, logger: silent, did: 'did:plc:test' }); +} + +test('compresses and uploads a new book, then leaves it alone', async () => { + const { root, config, sourceDir } = makeConfig(); + try { + copyFileSync(FIXTURE, join(sourceDir, 'sample.epub')); + const client = fakeClient(); + const ctx = contextFor(config, client); + + const first = await syncOnce(ctx); + assert.equal(first.uploaded, 1); + assert.equal(client.uploads.length, 1); + assert.equal(client.uploads[0].filename, OUTPUT_NAME); + assert.ok(existsSync(join(config.outputDir, OUTPUT_NAME))); + assert.ok(existsSync(stateFileFor(config.stateDir, 'did:plc:test'))); + + const second = await syncOnce(ctx); + assert.equal(second.uploaded, 0); + assert.equal(second.already, 0); + assert.equal(client.uploads.length, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('skips a book already in the library by title + author', async () => { + const { root, config, sourceDir } = makeConfig(); + try { + copyFileSync(FIXTURE, join(sourceDir, 'sample.epub')); + const library = new LibraryIndex([ + { contentHash: 'existing', title: 'The Test Book', authors: 'Jane Author' }, + ]); + const client = fakeClient({ library }); + const ctx = contextFor(config, client); + + const stats = await syncOnce(ctx); + assert.equal(stats.uploaded, 0); + assert.equal(stats.already, 1); + assert.equal(client.uploads.length, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('skips a source file BookHive already has by content hash', async () => { + const { root, config, sourceDir } = makeConfig(); + try { + const source = join(sourceDir, 'sample.epub'); + copyFileSync(FIXTURE, source); + + const hash = await partialMd5File(source); + const personal = new Map([[ + hash, + { contentHash: hash, title: 'The Test Book', authors: 'Jane Author' }, + ]]); + const client = fakeClient({ personal }); + const ctx = contextFor(config, client); + + const stats = await syncOnce(ctx); + assert.equal(stats.uploaded, 0); + assert.equal(stats.already, 1); + assert.equal(client.uploads.length, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('--force uploads despite a metadata match', async () => { + const { root, config, sourceDir } = makeConfig({ force: true }); + try { + copyFileSync(FIXTURE, join(sourceDir, 'sample.epub')); + const library = new LibraryIndex([ + { contentHash: 'existing', title: 'The Test Book', authors: 'Jane Author' }, + ]); + const client = fakeClient({ library }); + const ctx = contextFor(config, client); + + const stats = await syncOnce(ctx); + assert.equal(stats.uploaded, 1); + assert.equal(client.uploads.length, 1); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('--dry-run writes nothing, uploads nothing and updates no ledger', async () => { + const { root, config, sourceDir } = makeConfig({ dryRun: true }); + try { + copyFileSync(FIXTURE, join(sourceDir, 'sample.epub')); + const client = fakeClient(); + const ctx = contextFor(config, client); + + const stats = await syncOnce(ctx); + assert.equal(stats.wouldUpload, 1); + assert.equal(stats.uploaded, 0); + assert.equal(client.uploads.length, 0); + assert.ok(!existsSync(join(config.outputDir, OUTPUT_NAME))); + assert.ok(!existsSync(stateFileFor(config.stateDir, 'did:plc:test'))); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test('--no-rename keeps the source filename', async () => { + const { root, config, sourceDir } = makeConfig({ rename: false }); + try { + copyFileSync(FIXTURE, join(sourceDir, 'sample.epub')); + const client = fakeClient(); + const ctx = contextFor(config, client); + + await syncOnce(ctx); + assert.equal(client.uploads[0].filename, 'sample.epub'); + assert.ok(existsSync(join(config.outputDir, 'sample.epub'))); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/sinker/state.test.js b/test/sinker/state.test.js new file mode 100644 index 0000000..e13ea76 --- /dev/null +++ b/test/sinker/state.test.js @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Ledger, stateFileName, stateFileFor } from '../../src/sinker/state.js'; + +function tempDir() { + return mkdtempSync(join(tmpdir(), 'sinker-state-')); +} + +test('state file names are per-account and filesystem-safe', () => { + const name = stateFileName('did:plc:abc'); + assert.equal(name, 'state-did%3Aplc%3Aabc.json'); + assert.equal(stateFileFor('/x', 'did:plc:abc'), join('/x', name)); + assert.equal(stateFileName(null), 'state-anonymous.json'); +}); + +test('records survive a reload and are written atomically', () => { + const dir = tempDir(); + try { + const file = join(dir, 'state.json'); + const ledger = new Ledger(file, 'did:plc:test'); + ledger.record('/books/a.epub', { status: 'uploaded', size: 1, mtimeMs: 2 }); + ledger.save(); + + assert.ok(existsSync(file)); + + const reloaded = new Ledger(file, 'did:plc:test').load(); + assert.equal(reloaded.get('/books/a.epub').status, 'uploaded'); + assert.deepEqual(reloaded.counts(), { uploaded: 1 }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('prune drops entries for files that no longer exist', () => { + const dir = tempDir(); + try { + const ledger = new Ledger(join(dir, 'state.json'), 'did:plc:test'); + ledger.record('/books/a.epub', { status: 'uploaded' }); + ledger.record('/books/b.epub', { status: 'uploaded' }); + ledger.prune(new Set(['/books/a.epub'])); + ledger.save(); + + const reloaded = new Ledger(ledger.file).load(); + assert.ok(reloaded.get('/books/a.epub')); + assert.equal(reloaded.get('/books/b.epub'), undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('a corrupt ledger is discarded rather than crashing the sync', () => { + const dir = tempDir(); + try { + const file = join(dir, 'state.json'); + writeFileSync(file, '{ not json'); + const ledger = new Ledger(file, 'did:plc:test').load(); + assert.deepEqual(ledger.files, {}); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('dry saves are no-ops', () => { + const dir = tempDir(); + try { + const file = join(dir, 'state.json'); + const ledger = new Ledger(file, 'did:plc:test'); + ledger.record('/books/a.epub', { status: 'uploaded' }); + assert.ok(!existsSync(file)); + ledger.save(); + assert.ok(existsSync(file)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/sinker/watcher.test.js b/test/sinker/watcher.test.js new file mode 100644 index 0000000..472de82 --- /dev/null +++ b/test/sinker/watcher.test.js @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { loadConfig } from '../../src/sinker/config.js'; +import { LibraryIndex } from '../../src/sinker/dedupe.js'; +import { createContext } from '../../src/sinker/pipeline.js'; +import { watch } from '../../src/sinker/watcher.js'; + +const FIXTURE = fileURLToPath(new URL('../fixtures/sample.epub', import.meta.url)); +const silent = { + debug() {}, info() {}, step() {}, ok() {}, warn() {}, error() {}, +}; + +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); + +async function waitFor(predicate, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await sleep(100); + } + return false; +} + +test('watch uploads a book dropped in after startup, then shuts down cleanly', async () => { + const root = mkdtempSync(join(tmpdir(), 'sinker-watch-')); + const sourceDir = join(root, 'in'); + mkdirSync(sourceDir, { recursive: true }); + + const config = loadConfig({ + cwd: root, + env: { + SINKER_SOURCE_DIR: sourceDir, + SINKER_OUTPUT_DIR: join(root, 'out'), + SINKER_STATE_DIR: join(root, 'state'), + SINKER_OAUTH_DIR: join(root, 'oauth'), + }, + overrides: { backfill: false }, + }); + + const library = new LibraryIndex(); + const uploads = []; + const client = { + get library() { return library; }, + storage: null, + async getPersonalBook() { return null; }, + async refreshLibrary() { return library; }, + async upload(buffer, filename) { + uploads.push({ filename, size: buffer.length }); + const book = { contentHash: 'uploaded', title: 'The Test Book', authors: 'Jane Author' }; + library.add(book); + return { alreadyExists: false, book }; + }, + }; + + const ctx = createContext({ config, bookhive: client, logger: silent, did: 'did:plc:test' }); + const running = watch(ctx); + let signaled = false; + const signal = () => { + if (signaled) return; + signaled = true; + if (process.listenerCount('SIGINT') > 0) process.emit('SIGINT'); + }; + + try { + // Give chokidar time to become ready before creating the file. + await sleep(500); + copyFileSync(FIXTURE, join(sourceDir, 'sample.epub')); + + assert.ok( + await waitFor(() => uploads.length === 1, 15_000), + 'the watcher did not upload the new file in time', + ); + assert.equal(uploads[0].filename, 'The Test Book - Jane Author.epub'); + + signal(); + assert.equal(await running, 0); + } finally { + signal(); + rmSync(root, { recursive: true, force: true }); + } +});