Compare commits
42
Commits
9155129ccd
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0220f2d970 | ||
|
|
55e00ba960 | ||
|
|
85367daf7e | ||
|
|
7e33a8fe05 | ||
|
|
202cbed30e | ||
|
|
a3443d36f1 | ||
|
|
75fe41e266 | ||
|
|
d16a90f08f | ||
|
|
904d8fc76f | ||
|
|
315149e8a2 | ||
|
|
699a1a7fa2 | ||
|
|
22539644a9 | ||
|
|
a48be517e4 | ||
|
|
2e0d556c33 | ||
|
|
ff75f2c758 | ||
|
|
e967019964 | ||
|
|
f6bb06ac6e | ||
|
|
8ee406533c | ||
|
|
373c96d6d6 | ||
|
|
fc6b97bf38 | ||
|
|
5047277845 | ||
|
|
6d1890ce04 | ||
|
|
523117ec28 | ||
|
|
86e1d096ef | ||
|
|
3f39f1f8ae | ||
|
|
d321315acf | ||
|
|
b124a65d6e | ||
|
|
d78b21c27f | ||
|
|
968166c1fd | ||
|
|
8589adbd1b | ||
|
|
510306f24d | ||
|
|
bd8d68b9ba | ||
|
|
540522e828 | ||
|
|
5305d3bb5e | ||
|
|
7e04826fa5 | ||
|
|
96789620bb | ||
|
|
d4bdb5ed42 | ||
|
|
5f2d68694d | ||
|
|
d6207b5743 | ||
|
|
51c31e6bf6 | ||
|
|
961a63480e | ||
|
|
92ffa4f7c2 |
@@ -5,6 +5,15 @@ CHITAI_TOKEN_SECRET=secret
|
|||||||
CHITAI_DEFAULT_LIBRARY_NAME=Books
|
CHITAI_DEFAULT_LIBRARY_NAME=Books
|
||||||
CHITAI_DEFAULT_LIBRARY_PATH="libraries/books"
|
CHITAI_DEFAULT_LIBRARY_PATH="libraries/books"
|
||||||
|
|
||||||
|
# Duplicate detection when importing files (optional).
|
||||||
|
# Scope: "library" compares against the library being uploaded to, "global" against
|
||||||
|
# every library, "off" disables the check.
|
||||||
|
CHITAI_DUPLICATE_SCOPE=library
|
||||||
|
|
||||||
|
# Where the consume watcher parks files it refused as duplicates. Keep it outside
|
||||||
|
# CHITAI_CONSUME_PATH, or the watcher picks them straight back up.
|
||||||
|
CHITAI_DUPLICATE_PATH="duplicates"
|
||||||
|
|
||||||
# You probably should not change these
|
# You probably should not change these
|
||||||
CHITAI_API_URL="http://backend:8000"
|
CHITAI_API_URL="http://backend:8000"
|
||||||
CHITAI_API_DEBUG=false
|
CHITAI_API_DEBUG=false
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
.postgres/
|
.postgres/
|
||||||
.venv
|
.venv
|
||||||
|
tmp/
|
||||||
|
|||||||
@@ -10,9 +10,11 @@ a KOSync-compatible endpoint.
|
|||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
| Path | What |
|
| Path | What |
|
||||||
| --- | --- |
|
| -------------------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `backend/` | Litestar REST API + PostgreSQL. See `backend/AGENTS.md`. |
|
| `backend/` | Litestar REST API + PostgreSQL. See `backend/AGENTS.md`. |
|
||||||
| `frontend/` | SvelteKit SSR web app. See `frontend/AGENTS.md`. |
|
| `frontend/` | SvelteKit SSR web app. See `frontend/AGENTS.md`. |
|
||||||
|
| `frontend/src/lib/vendor/` | Vendored `foliate-js` (the EPUB engine), copied by `frontend/scripts/vendor-foliate.sh`. |
|
||||||
|
| `frontend/static/pdfjs/` | Vendored pdf.js viewer, used by the PDF reader in an iframe. |
|
||||||
| `docker-compose.yml` | Production stack: `db` (postgres:17), `backend`, `frontend`. |
|
| `docker-compose.yml` | Production stack: `db` (postgres:17), `backend`, `frontend`. |
|
||||||
| `docs/screenshots/` | Images used by `README.md`. |
|
| `docs/screenshots/` | Images used by `README.md`. |
|
||||||
| `shell.nix` | Root dev shell; composes the two sub-shells. |
|
| `shell.nix` | Root dev shell; composes the two sub-shells. |
|
||||||
@@ -71,6 +73,9 @@ pytest tests/ # needs Docker (pytest-data
|
|||||||
ruff format src/
|
ruff format src/
|
||||||
alchemy --config chitai.database.config.config make-migrations
|
alchemy --config chitai.database.config.config make-migrations
|
||||||
alchemy --config chitai.database.config.config upgrade
|
alchemy --config chitai.database.config.config upgrade
|
||||||
|
|
||||||
|
# Import a Calibre library. Copies files; --dry-run reports without writing.
|
||||||
|
litestar --app-dir src/chitai/ calibre-import <path> --library <slug>
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend (from `frontend/`):
|
Frontend (from `frontend/`):
|
||||||
|
|||||||
@@ -49,8 +49,263 @@ Worth adding at the same time:
|
|||||||
- Deriving ISBN-10 from ISBN-13 when only the latter is present. It is a pure checksum
|
- Deriving ISBN-10 from ISBN-13 when only the latter is present. It is a pure checksum
|
||||||
conversion and doubles the chance of an external lookup matching.
|
conversion and doubles the chance of an external lookup matching.
|
||||||
|
|
||||||
|
### Any authenticated user can delete any library or book
|
||||||
|
|
||||||
|
`backend/src/chitai/database/models/library.py`, `backend/src/chitai/database/models/user.py`
|
||||||
|
|
||||||
|
There is no authorization tier. `Library` has no owner column, and `User` carries only
|
||||||
|
`email` and `password` — no role, no `is_active`. So every authenticated account can create
|
||||||
|
and delete libraries, and delete books along with their files on disk. Per-user scoping
|
||||||
|
exists only for reading progress and bookshelves, which `provide_book_service` restricts
|
||||||
|
correctly.
|
||||||
|
|
||||||
|
For a single-household deployment that may well be acceptable. The point is that it is
|
||||||
|
emergent rather than chosen. The cheapest meaningful step is an `is_admin` flag gating
|
||||||
|
library deletion and `delete_books` — a model change plus a migration.
|
||||||
|
|
||||||
|
### Basic auth answers a malformed header with a 500
|
||||||
|
|
||||||
|
`backend/src/chitai/middleware/basic_auth.py` — line 22
|
||||||
|
|
||||||
|
```python
|
||||||
|
username, password = b64decode(auth_header.split("Basic ")[1]).decode().split(":")
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing guards the parse. A `Bearer` token raises `IndexError`, non-base64 raises
|
||||||
|
`binascii.Error`, and a credential with no colon raises `ValueError` — as does a password
|
||||||
|
that *contains* one, since there is no `maxsplit=1`. Every case surfaces as a 500 on an
|
||||||
|
unauthenticated endpoint. All of them should be 401.
|
||||||
|
|
||||||
|
### An unknown KOSync API key returns 404
|
||||||
|
|
||||||
|
`backend/src/chitai/middleware/kosync_auth.py` — line 32
|
||||||
|
|
||||||
|
`KosyncDeviceService.get_by_api_key` uses `get_one`, which raises `NotFoundError`, but the
|
||||||
|
middleware catches only `PermissionDeniedException`. The global handler in
|
||||||
|
`exceptions/handlers.py` then renders it as a 404, so a device presenting a bad key is told
|
||||||
|
the route does not exist rather than that it is unauthorized. The same file still carries a
|
||||||
|
leftover `print(exc)`.
|
||||||
|
|
||||||
|
Worth doing at the same time: `KosyncDeviceService._generate_api_key` uses
|
||||||
|
`secrets.token_hex(8)`. 64 bits is thin for a long-lived bearer credential where 32 bytes
|
||||||
|
is the convention.
|
||||||
|
|
||||||
|
### The multi-book download cannot be driven from a test
|
||||||
|
|
||||||
|
`backend/src/chitai/services/book.py` — `BookService.get_files`
|
||||||
|
|
||||||
|
`/books/download` is the only handler returning a Litestar `Stream`, and it cannot be
|
||||||
|
exercised through `AsyncTestClient`. The request itself succeeds, then fixture teardown
|
||||||
|
hangs: the test transport never sends the `http.disconnect` that the streaming response
|
||||||
|
waits on, so the app's lifespan shutdown never completes. Coverage therefore sits at the
|
||||||
|
service level, on `get_files` directly.
|
||||||
|
|
||||||
|
Unresolved whether the endpoint also stalls behind a real ASGI server, where that
|
||||||
|
disconnect does arrive. Worth one manual check against `litestar run` before relying on it.
|
||||||
|
|
||||||
|
### The production image runs the development server
|
||||||
|
|
||||||
|
`backend/Dockerfile` — the final `CMD`
|
||||||
|
|
||||||
|
```
|
||||||
|
CMD ["litestar", "--app-dir", "chitai", "run", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
```
|
||||||
|
|
||||||
|
`litestar run` is the CLI development runner. Production should invoke uvicorn or granian
|
||||||
|
directly, with a worker count.
|
||||||
|
|
||||||
|
### Nothing gates formatting, linting or types
|
||||||
|
|
||||||
|
`ruff format --check src/` reports 27 of 61 files unformatted, and `ruff check src/` finds
|
||||||
|
114 errors — 100 of them unused imports, the rest bare `except`, unused variables and
|
||||||
|
`== True` comparisons. `ruff check --fix` clears 51 automatically.
|
||||||
|
|
||||||
|
There is no `[tool.ruff]` section in `pyproject.toml`, so only ruff's default `E4/E7/E9/F`
|
||||||
|
rules run, and no type checker is configured at all despite `# type: ignore` comments in
|
||||||
|
the tree. Individually these are trivial; collectively they say nothing runs on commit.
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
|
|
||||||
|
### Scripted EPUBs run against the app origin
|
||||||
|
|
||||||
|
**This is a regression from the foliate-js migration, not a pre-existing gap.**
|
||||||
|
|
||||||
|
The old epub.js reader never passed `allowScriptedContent`. epub.js defaults it to
|
||||||
|
`false`, which sets `iframe.sandbox = "allow-same-origin"` — no `allow-scripts` — so
|
||||||
|
script inside a book never ran. The vendored foliate-js sets, unconditionally:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// paginator.js — and the same in fixed-layout.js
|
||||||
|
// `allow-scripts` is needed for events because of WebKit bug
|
||||||
|
this.#iframe.setAttribute("sandbox", "allow-same-origin allow-scripts");
|
||||||
|
```
|
||||||
|
|
||||||
|
`allow-same-origin` together with `allow-scripts` is the combination that makes the
|
||||||
|
sandbox attribute do nothing. Sections are served as same-origin `blob:` URLs, so script
|
||||||
|
in a book can reach `/api/*` with the session cookie attached. foliate's README says as
|
||||||
|
much and tells you to use a CSP instead; we have not added one.
|
||||||
|
|
||||||
|
This is not theoretical. Audiobookshelf shipped the same combination and got
|
||||||
|
**CVE-2024-35236** — scripted EPUB plus an unrestricted upload gave remote code
|
||||||
|
execution; fixed in 2.10.0 by making scripted content a per-library opt-in, off by
|
||||||
|
default. Kavita (CVE-2024-39307) and Jellyfin (fixed 10.9.8) are variations on it.
|
||||||
|
Write-up: <https://gebir.ge/blog/every-trick-in-the-book/>.
|
||||||
|
|
||||||
|
**An app-wide CSP is the wrong shape.** `kit.csp` with `script-src: ['self']` also blocks
|
||||||
|
`mode-watcher`'s inline `setInitialMode`, which sets the dark class before first paint —
|
||||||
|
SvelteKit only nonces the bootstrap script it injects itself, so every page load would
|
||||||
|
flash the light theme. Pinning a hash of a third-party inline script breaks silently on
|
||||||
|
upgrade.
|
||||||
|
|
||||||
|
**Grimmory solves it properly**, and it runs foliate-js too. Rather than handing foliate
|
||||||
|
the whole file, it serves each EPUB entry from its own endpoint and puts the strict
|
||||||
|
policy on that response:
|
||||||
|
|
||||||
|
```java
|
||||||
|
// EpubReaderController.java
|
||||||
|
response.setHeader("Content-Security-Policy", "script-src 'none'");
|
||||||
|
```
|
||||||
|
|
||||||
|
The app shell keeps its own, more permissive policy. That works because each section is
|
||||||
|
then a real same-origin document with its own header, rather than a `blob:` — and a
|
||||||
|
`blob:` inherits the CSP of the document that created it, which is exactly why a header
|
||||||
|
on `/api/books/download/…` would achieve nothing today.
|
||||||
|
|
||||||
|
Two ways forward:
|
||||||
|
|
||||||
|
1. **Cheap.** Patch the vendored `sandbox` attribute to drop `allow-scripts`, restoring
|
||||||
|
what epub.js gave us. Cost is the WebKit bug the upstream comment cites: events inside
|
||||||
|
the iframe get swallowed, which would likely break touch/swipe paging and possibly the
|
||||||
|
in-iframe keyboard handling in `foliate-view.svelte`. Needs testing before trusting.
|
||||||
|
2. **Right.** Follow grimmory: serve individual EPUB entries from the backend with
|
||||||
|
`script-src 'none'` on each response, and drive foliate through its loader hooks
|
||||||
|
instead of a whole-file blob. This is **net-new capability on both sides**, not a
|
||||||
|
rewiring of something that exists — see below.
|
||||||
|
|
||||||
|
Option 2 also fixes the memory cost below, which is why it is worth more than it looks.
|
||||||
|
|
||||||
|
#### What option 2 actually involves
|
||||||
|
|
||||||
|
Today the browser fetches the whole `.epub` from `download/{book_id}/{file_id}`, which
|
||||||
|
returns a Litestar `File` and knows nothing about the archive's contents. foliate then
|
||||||
|
opens the zip **in the browser** (`makeZipLoader` in `view.js`) and turns every chapter,
|
||||||
|
image and stylesheet into a `blob:` URL via `Loader.createURL` in `epub.js`. A `blob:`
|
||||||
|
carries no headers of its own — it inherits the CSP of the document that created it —
|
||||||
|
which is why there is nowhere to attach a policy except the app shell.
|
||||||
|
|
||||||
|
foliate's parser never touches the zip directly. `EPUB` is constructed with a loader:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// view.js — makeZipLoader is one implementation; makeDirectoryLoader below is another
|
||||||
|
return { entries, loadText, loadBlob, getSize };
|
||||||
|
```
|
||||||
|
|
||||||
|
`name` is the **zip entry path**, because `makeZipLoader` keys its map on
|
||||||
|
`entry.filename` — so `OEBPS/Text/chapter01.xhtml`, `OEBPS/Images/cover.jpg`. The parser
|
||||||
|
resolves hrefs from the OPF manifest into those names and asks the loader for them,
|
||||||
|
without caring where the bytes come from. A third implementation that fetches over HTTP
|
||||||
|
is the same shape.
|
||||||
|
|
||||||
|
**Backend.** An endpoint taking a path inside the archive, e.g.
|
||||||
|
`GET books/{book_id}/files/{file_id}/entry/{path:path}`, returning a `Stream` over
|
||||||
|
`zipfile.ZipFile.open(name)` so an entry never lands in memory whole, with the content
|
||||||
|
type from the manifest and `Content-Security-Policy: script-src 'none'` on the response.
|
||||||
|
|
||||||
|
Two things to get right:
|
||||||
|
|
||||||
|
- **`path` is caller-supplied.** Resolve it against the archive's `namelist()` and reject
|
||||||
|
anything absent, rather than trusting the string — `../` traversal is the hazard.
|
||||||
|
- **`getSize` is synchronous** in foliate's loader contract, and it feeds `SectionProgress`,
|
||||||
|
which produces the reading percentage. So the endpoint needs a companion that returns
|
||||||
|
entry names and sizes up front — one extra call at open — because sizes cannot be
|
||||||
|
discovered per request.
|
||||||
|
|
||||||
|
Opening the zip per request costs a central-directory read each time. Probably fine for
|
||||||
|
chapter-sized reads, worth measuring rather than assuming.
|
||||||
|
|
||||||
|
Note the backend already reads inside EPUBs — `metadata_extractor.py` uses ebooklib at
|
||||||
|
ingest for title, authors, identifiers and the cover. What is missing is serving an
|
||||||
|
arbitrary entry by path, not the ability to open the archive.
|
||||||
|
|
||||||
|
**Frontend.** `foliate-view.svelte` stops calling `view.open(file)` and builds an `EPUB`
|
||||||
|
around a loader backed by that endpoint. The whole-file fetch in `epub-reader.svelte`
|
||||||
|
goes away with it.
|
||||||
|
|
||||||
|
### The proxy buffers whole files and drops range headers
|
||||||
|
|
||||||
|
Two separate problems that both live in `frontend/src/routes/api/[...path]/+server.ts`.
|
||||||
|
|
||||||
|
**Buffering.** Litestar already streams: `ASGIFileResponse` reads in 1 MB chunks
|
||||||
|
(`response/file.py`), so the backend never holds a file whole. The proxy then undoes it
|
||||||
|
with `await response.arrayBuffer()`, which does not resolve until the last byte arrives —
|
||||||
|
so the whole file sits in the node process, per concurrent reader, and the browser gets
|
||||||
|
nothing until it completes. Passing `response.body` straight through restores the stream
|
||||||
|
and is a small change.
|
||||||
|
|
||||||
|
This is now **responses only**. The request side was fixed for the Calibre archive upload,
|
||||||
|
which cannot be held in memory: POST and PATCH pass `request.body` through with
|
||||||
|
`duplex: 'half'` (`bodyOf` in the same file). The response side is the same shape of fix.
|
||||||
|
|
||||||
|
**Range.** The proxy forwards only `Content-Type`, `Content-Disposition` and
|
||||||
|
`Content-Length`. It never sends the client's `Range` upstream, and would drop
|
||||||
|
`Accept-Ranges` and `Content-Range` coming back — a 206 without `Content-Range` is
|
||||||
|
broken. So range support cannot work until the proxy is fixed, whatever the backend does.
|
||||||
|
|
||||||
|
**Litestar has no range support of its own.** In 2.21.1 the only mention of 206 in the
|
||||||
|
whole package is the `HTTP_206_PARTIAL_CONTENT` constant; there is no `Accept-Ranges` or
|
||||||
|
`Content-Range` handling anywhere. This has to be written.
|
||||||
|
|
||||||
|
#### What pdf.js actually needs
|
||||||
|
|
||||||
|
It decides from the **initial 200 response**, not from anything on a 206.
|
||||||
|
`validateRangeRequestCapabilities` in `frontend/static/pdfjs/build/pdf.mjs`:
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (responseHeaders.get("Accept-Ranges") !== "bytes") {
|
||||||
|
return returnValues; // allowRangeRequests stays false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
It also needs a parseable `Content-Length`, `Content-Encoding: identity`, and a length
|
||||||
|
greater than twice `rangeChunkSize`. Miss any of those and it downloads the whole file
|
||||||
|
however good the range support is.
|
||||||
|
|
||||||
|
So the single highest-value header is **`Accept-Ranges: bytes` on the ordinary 200** —
|
||||||
|
that is what makes pdf.js switch to fetching progressively at all.
|
||||||
|
|
||||||
|
#### Approach
|
||||||
|
|
||||||
|
Put it on the existing `get_file` handler in `controllers/book.py`, which already resolves
|
||||||
|
`book_id`/`file_id` through the service with library scoping and auth:
|
||||||
|
|
||||||
|
- No `Range` → `Stream` the file with `Accept-Ranges: bytes` and `Content-Length`.
|
||||||
|
- `Range` present → parse, seek, `Stream` with 206 and `Content-Range`.
|
||||||
|
- Proxy: forward `Range` up; pass `response.body` through; forward `Accept-Ranges`,
|
||||||
|
`Content-Range` and the status back.
|
||||||
|
|
||||||
|
There are `RangeRequestMiddleware` snippets circulating for Litestar that wrap
|
||||||
|
`create_static_files_router`. They are the wrong shape here — book files are served by an
|
||||||
|
authenticated handler resolving database ids, not by a directory mapping, and using one
|
||||||
|
would mean exposing disk paths as URLs and re-solving ownership checks that already
|
||||||
|
exist. The common version also only sets `Accept-Ranges` on the 206, so it would not
|
||||||
|
switch pdf.js over, and it derives its path with `str.lstrip(prefix)`, which strips a
|
||||||
|
character set rather than a prefix — `/static/castle.pdf` becomes `le.pdf`. Worth reading
|
||||||
|
its `parse_range_header` for the parsing rules and writing the rest fresh.
|
||||||
|
|
||||||
|
### Remove the epub.js locations-cache purge
|
||||||
|
|
||||||
|
`frontend/src/lib/reader/legacy-cache.ts` — `purgeLegacyLocationCache`
|
||||||
|
|
||||||
|
The epub.js reader cached generated locations in `localStorage` under
|
||||||
|
`${bookId}-locations`, a few hundred KB of JSON per long book. foliate computes
|
||||||
|
progress from section byte sizes at open time, so nothing writes those keys any
|
||||||
|
more, but existing browsers still hold them — and a reader near the 5–10 MB
|
||||||
|
origin quota would make the new reader-settings write throw `QuotaExceededError`.
|
||||||
|
|
||||||
|
The reader clears them once per browser, behind a `chitai:locations-purged` flag.
|
||||||
|
Delete the module, its call in `epub-reader.svelte` and the flag once deployments
|
||||||
|
have had a release or two to run it — after roughly 2026-12.
|
||||||
|
|
||||||
### Cover dimensions are unknown until load
|
### Cover dimensions are unknown until load
|
||||||
|
|
||||||
`book-cover.svelte` renders covers at a fixed height with natural width so nothing is
|
`book-cover.svelte` renders covers at a fixed height with natural width so nothing is
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ The backend owns files on disk, not just rows:
|
|||||||
- **Layout** — `services/filesystem_library.py` (`BookPathGenerator`) renders a Jinja2 template
|
- **Layout** — `services/filesystem_library.py` (`BookPathGenerator`) renders a Jinja2 template
|
||||||
against book metadata to decide where a book lives under the library's `root_path`
|
against book metadata to decide where a book lives under the library's `root_path`
|
||||||
(default: `author/series/position - title/`).
|
(default: `author/series/position - title/`).
|
||||||
|
- **One directory per book, never shared.** The generated path is a pure function of the metadata,
|
||||||
|
so two books with the same author and title produce the same one — two editions, or an
|
||||||
|
`allow_duplicates` copy. `BookService._reserve_book_path` moves the later one to `title (2)`
|
||||||
|
before anything is written, and `update_book` reserves the same way so a rename cannot move a
|
||||||
|
book in on top of another. This matters because `book.path` is what deletes, moves and file
|
||||||
|
lookups act on: books sharing a directory means one overwrites the other's files, and deleting
|
||||||
|
either takes both. `_unused_path` does the same job for filenames within a directory.
|
||||||
|
A book that already has a `path` keeps it — `add_files` must follow the book, not the template.
|
||||||
- **Metadata extraction** — `services/metadata_extractor.py` reads EPUB (ebooklib) and PDF
|
- **Metadata extraction** — `services/metadata_extractor.py` reads EPUB (ebooklib) and PDF
|
||||||
(pypdfium2) files; extracted values fill only *empty* fields on the incoming payload.
|
(pypdfium2) files; extracted values fill only *empty* fields on the incoming payload.
|
||||||
- **Covers** — converted to WebP with a UUID filename under `settings.book_cover_path`, served by a
|
- **Covers** — converted to WebP with a UUID filename under `settings.book_cover_path`, served by a
|
||||||
@@ -100,6 +108,235 @@ The backend owns files on disk, not just rows:
|
|||||||
if it differs, moves the directory contents and prunes empty parents. Keep that in mind before
|
if it differs, moves the directory contents and prunes empty parents. Keep that in mind before
|
||||||
changing metadata handling.
|
changing metadata handling.
|
||||||
|
|
||||||
|
### Content types come from the extension, and null means null
|
||||||
|
|
||||||
|
Every ingest path names a file's format with `guess_content_type` (`services/utils.py`), never with
|
||||||
|
`mimetypes.guess_type` directly and never with what the client said. Python's built-in map answers
|
||||||
|
`None` for `.mobi`, `.azw`, `.prc`, `.fb2`, `.fbz`, `.lit`, `.lrf` and `.cb7` — most of what a
|
||||||
|
library imported from elsewhere carries — so `EBOOK_CONTENT_TYPES` fills those in. A browser's
|
||||||
|
`application/octet-stream` is discarded rather than used as a fallback: it is the client saying it
|
||||||
|
does not know, and storing it is indistinguishable from having determined a format.
|
||||||
|
|
||||||
|
When nothing can name the extension the column **stays null**. That is the honest answer, and only
|
||||||
|
one consumer cannot take it: OPDS `Link.type` is a required string, so
|
||||||
|
`services/opds/opds.py` substitutes `application/octet-stream` at that boundary. Litestar's
|
||||||
|
`ASGIFileResponse` already does its own fallback, so `get_file` can pass a null straight through.
|
||||||
|
`FileMetadataRead.content_type` is nullable for the same reason — it was once required, which
|
||||||
|
turned a stored null into a 500 on a book that was otherwise fine.
|
||||||
|
|
||||||
|
## Duplicate detection
|
||||||
|
|
||||||
|
Every ingest path screens incoming files against what is already stored, keyed on
|
||||||
|
**`(hash, size)`** — never the hash alone, because it samples 12 KiB (see below) and
|
||||||
|
EPUBs from one toolchain often share their first window. `FileMetadata.hash` carries a
|
||||||
|
plain, deliberately **non-unique** index: a collision must not be able to fail an import,
|
||||||
|
and older databases may already hold duplicates.
|
||||||
|
|
||||||
|
Scope comes from `CHITAI_DUPLICATE_SCOPE` (`library`, the default | `global` | `off`).
|
||||||
|
|
||||||
|
The policy differs by how deliberate the import is:
|
||||||
|
|
||||||
|
| Path | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| `create_many_from_files` (browser bulk) | Skip per file, skip a whole group whose files are all known, report everything skipped in `ImportResult.duplicates`. Re-dropping a folder to pick up what is new is the case this serves. |
|
||||||
|
| `create_book` (single, with metadata) | All-or-nothing: raises `DuplicateFilesError`, which `controllers/book.py` renders as a **409** carrying the refused files in `extra`. |
|
||||||
|
| `add_files` | A file the book already carries is a no-op; one stored under another book raises `DuplicateFilesError`. |
|
||||||
|
| `create_many_from_existing_files` (consume watcher) | Skips, and **moves the file to `CHITAI_DUPLICATE_PATH/<library slug>/`** — nothing is deleted, and it cannot stay put because `watchfiles` only reports additions. That path must stay outside `consume_path` or the watcher re-imports it and tries to read the directory name as a library slug. |
|
||||||
|
| `create_many_from_calibre` (Calibre import) | Skips per file, and skips a whole book whose files are all known. Nothing is moved — the source is somebody else's library. This is what makes a re-run a no-op and an interrupted import resumable by running it again. |
|
||||||
|
|
||||||
|
`allow_duplicates=true` overrides all of it, on every endpoint. Keep that working — the
|
||||||
|
hash is not proof of identity, so a wrong verdict has to be recoverable, and a scripted
|
||||||
|
import needs a way through. The **web UI deliberately does not offer it**: storing the
|
||||||
|
same bytes twice splits reading progress and shelf membership across two records that
|
||||||
|
can never converge, which is nothing anyone wants on purpose.
|
||||||
|
|
||||||
|
Three things to preserve when touching this code:
|
||||||
|
|
||||||
|
- **A match only counts while the file is on disk.** `find_duplicate_files` stats each
|
||||||
|
candidate, and `add_files` writes a missing file back into the row that already
|
||||||
|
describes it (`_restore_file`) instead of adding a second row beside it. The hash
|
||||||
|
lives in the database and the file does not, so without this a file deleted behind
|
||||||
|
the app's back would go on refusing its own replacement.
|
||||||
|
|
||||||
|
- **Screening runs before anything is written.** `fingerprint_upload` reads the spooled
|
||||||
|
upload and rewinds it; the resulting fingerprints are handed to `_save_book_files`,
|
||||||
|
which skips its own `StreamingHasher` when it already has the answer. Passing them
|
||||||
|
through is what keeps the file from being read twice.
|
||||||
|
- **`_screen_for_duplicates` extends the `known` dict as it goes**, so the same bytes
|
||||||
|
submitted twice in one request are caught. Those duplicates report `book_id: None` —
|
||||||
|
there is no row to point at yet.
|
||||||
|
|
||||||
|
`POST /books/duplicate-files` answers the same question from fingerprints alone, for
|
||||||
|
clients that want to ask before uploading anything.
|
||||||
|
|
||||||
|
### Book-level detection — a different question
|
||||||
|
|
||||||
|
The file check answers "are these the same bytes?". `find_duplicate_books` answers "is
|
||||||
|
this the same book?", which a re-scan, a re-zipped EPUB or another edition cannot be
|
||||||
|
asked with a hash. Two signals, either sufficient: a **shared identifier**, or a
|
||||||
|
**matching normalized title with at least one shared author**.
|
||||||
|
|
||||||
|
It **never blocks**. A metadata match is a guess — a work shares title and author with
|
||||||
|
its own translation, its own second edition and its own audiobook — so the book is
|
||||||
|
created and the candidates are reported alongside it in
|
||||||
|
`ImportResult.possible_duplicates`. File-level dedupe keeps its refuse/skip behaviour;
|
||||||
|
that one is near-certain and this one is not. Do not "improve" this into a refusal.
|
||||||
|
|
||||||
|
Two rules narrow it, both applied in Python over the small candidate set:
|
||||||
|
|
||||||
|
- **A shared author is required for a title match.** Without it every book the
|
||||||
|
extractors gave up on and titled `Unknown` is a duplicate of every other one. A book
|
||||||
|
with no authors can therefore only match on an identifier.
|
||||||
|
- **The same series at a different `series_position` disqualifies a match.** A trilogy
|
||||||
|
shares an author and often most of its title; the position is the library saying
|
||||||
|
outright that these are two books.
|
||||||
|
|
||||||
|
A book is compared under **several title keys, not one** (`_title_keys`). Ebook files
|
||||||
|
are overwhelmingly named `Title - Author.epub`, and wherever nothing inside the file
|
||||||
|
overrode that name the author ended up in the title column — so one copy is stored as
|
||||||
|
`Building Microservices` and another as `Building Microservices Sam Newman`. Both
|
||||||
|
directions are generated, the author stripped off and the author added on, which is why
|
||||||
|
the query is `normalized_title.in_(keys)` rather than `==`. This is still exact matching
|
||||||
|
on an indexed column: no similarity score, nothing to tune. It does not weaken the
|
||||||
|
shared-author requirement, which is a separate condition.
|
||||||
|
|
||||||
|
`find_duplicate_book_groups` is the library-wide pass behind
|
||||||
|
`GET /books/duplicate-books`, since the import-time check says nothing about a
|
||||||
|
collection someone already has. It buckets books by every key they carry and merges the
|
||||||
|
buckets with union-find, so A~B by ISBN and B~C by title land in one group. Pairs in
|
||||||
|
`duplicate_dismissals` are never merged — a reader disagreeing with one pairing must not
|
||||||
|
silently break a group that stands on other evidence.
|
||||||
|
|
||||||
|
### Author names have one stored form
|
||||||
|
|
||||||
|
`Author.name` is always the canonical form, produced by `format_author_name`. Extractors
|
||||||
|
hand over whatever the file said — `Newman, Sam;` from a `DC:creator` list, `Sam Newman`
|
||||||
|
from a PDF, `Sam Newman.epub` from a filename — and storing those verbatim is how one
|
||||||
|
person becomes four rows in the sidebar, four entries in the author filter, and four
|
||||||
|
books that never look like each other.
|
||||||
|
|
||||||
|
This is **display** canonicalization, distinct from `normalize_author`, which throws
|
||||||
|
away case, accents and spacing to build a comparison key nobody sees. Tidying only
|
||||||
|
removes what an extractor added: a trailing separator, a file extension, and the
|
||||||
|
`Surname, Given` ordering. It never touches case or accents — `Michał Płachta` and
|
||||||
|
`Steve McConnell` are the author's own spelling, not something to correct.
|
||||||
|
|
||||||
|
Three places have to agree, and `Author` keeps them together: the `@validates("name")`
|
||||||
|
hook, `unique_hash`, and `unique_filter`. `as_unique_async` looks a row up with the
|
||||||
|
filter and then constructs with the validator, so if the lookup used the raw name and
|
||||||
|
the insert used the tidy one, every variant spelling would miss the existing row and
|
||||||
|
then collide with it on the unique index.
|
||||||
|
|
||||||
|
A form the rule does not recognise is **left exactly as it was found** — `Dave Thomas,
|
||||||
|
Andy Hunt` is two people in one string, and flipping it would invent a third. Leaving a
|
||||||
|
mess visible beats rewriting it wrongly.
|
||||||
|
|
||||||
|
### The normalized columns are written by validators
|
||||||
|
|
||||||
|
`Book.normalized_title`, `Author.normalized_name` and `Identifier.normalized_value` are
|
||||||
|
derived from `services/matching.py` and kept current by **SQLAlchemy `@validates` hooks
|
||||||
|
on the models**, not by any service. Assigning them directly is always wrong.
|
||||||
|
|
||||||
|
This is deliberate and it is invisible at the call sites: `BookService` writes titles
|
||||||
|
through at least three paths (`to_model_on_create`, `to_model_on_update`, and the
|
||||||
|
`setattr` loop in `_populate_with_unique_relationships`), and a validator is the only
|
||||||
|
thing a fourth cannot bypass. The columns carry plain, **non-unique** btree indexes —
|
||||||
|
two spellings collapsing onto one value is the entire point.
|
||||||
|
|
||||||
|
`services/matching.py` is imported *inside* those validators rather than at module
|
||||||
|
scope: reaching it initialises the `chitai.services` package, which imports the
|
||||||
|
services, which import the models. Keep the local import.
|
||||||
|
|
||||||
|
The validators only fire on write, so a migration that adds one of these columns must
|
||||||
|
backfill existing rows through the same helpers — see the `data_upgrades()` hook in
|
||||||
|
`2026-08-15_add_book_matching_keys_and_duplicate__4358e7d4743a.py`.
|
||||||
|
|
||||||
|
**Changing anything in `services/matching.py` needs a revision that recomputes them.**
|
||||||
|
The keys are derived and already written, so a normalization change silently invalidates
|
||||||
|
every stored row: a book written under the old rules just stops matching one written
|
||||||
|
under the new rules, with nothing to show that anything is wrong. Copy
|
||||||
|
`2026-08-15_recompute_book_matching_keys_ed41acf21270.py`, which exists because
|
||||||
|
`normalize_title` learned to strip compact edition markers (`2E`, `5e`). It is
|
||||||
|
idempotent and safe to re-run.
|
||||||
|
|
||||||
|
## Importing from Calibre
|
||||||
|
|
||||||
|
Two pieces, deliberately separated:
|
||||||
|
|
||||||
|
- **`services/calibre.py`** reads `metadata.db` and the tree beside it. It knows nothing about
|
||||||
|
`Book`, `BookService` or a session, so it is testable without Postgres, and it reports what
|
||||||
|
Calibre wrote rather than what Chitai wants — identifiers come back keyed by `identifiers.type`
|
||||||
|
verbatim. It also unpacks a zipped library (`extract_calibre_archive`).
|
||||||
|
- **`BookService.create_many_from_calibre`** does the ingest, next to the two other ingest paths
|
||||||
|
because it needs the same privates they do (`_reserve_book_path`, `_save_cover_image`,
|
||||||
|
`_screen_for_duplicates`). The CLI in `cli.py` is a thin wrapper over it.
|
||||||
|
- **`services/calibre_import.py`** is lifecycle only — the job registry behind the endpoints:
|
||||||
|
state, progress, cancellation, and a session of its own.
|
||||||
|
|
||||||
|
`docs/calibre-import.md` is the full brief, including the phases not built yet. What matters here:
|
||||||
|
|
||||||
|
- **Files are copied, never moved.** `metadata.db` would go on pointing at files that are gone,
|
||||||
|
which quietly ruins a library somebody still uses. `copy_file` streams rather than using
|
||||||
|
`shutil.copy`, which would block the loop for a 40 MB read.
|
||||||
|
- **The extractors are not run.** This is the one ingest path that trusts its input: Calibre's
|
||||||
|
catalogue is curated and its filenames are truncated to ~42 characters, so `data.name` locates a
|
||||||
|
file and the database carries the metadata. The title is stored verbatim for the same reason —
|
||||||
|
no edition split out, no subtitle guessed.
|
||||||
|
- **The catalogue is copied before it is read**, and the copy is opened read-write. Calibre may be
|
||||||
|
running; opening the live file either sees a torn state or needs to recover a write-ahead log,
|
||||||
|
which read-only access cannot do. `close()` removes the copy in a `finally`, or a failure leaves
|
||||||
|
a catalogue-sized file in the temp directory.
|
||||||
|
- **`check_same_thread=False` plus an `asyncio.Lock`.** Every query runs through
|
||||||
|
`asyncio.to_thread`, which hands out whichever worker is free, so the connection outlives the
|
||||||
|
thread that opened it. The lock is what makes that safe. Removing either one reintroduces
|
||||||
|
`SQLite objects created in a thread can only be used in that same thread`, intermittently —
|
||||||
|
the pool often reuses one thread, so it passes until it does not.
|
||||||
|
- **One book never costs the run.** A failure is recorded in `CalibreImportResult.failed`, the
|
||||||
|
session is rolled back so the next book can use it, and the files that book had already copied
|
||||||
|
are deleted — an orphaned directory would make the next attempt reserve `title (2)` and look as
|
||||||
|
though it had worked. A cover that PIL cannot open costs the cover, not the book.
|
||||||
|
- **`calibre-uuid`, not `uuid`.** `books.uuid` is stable for the life of the row, so it is the
|
||||||
|
durable link back to the source and worth matching on. `uuid` is the name `normalize_identifier`
|
||||||
|
refuses, because an EPUB regenerates one per build.
|
||||||
|
|
||||||
|
### The API takes an uploaded archive; the CLI takes a path
|
||||||
|
|
||||||
|
**`POST /libraries/{id}/imports/calibre/upload`** is the only way in over HTTP. It takes a zipped
|
||||||
|
Calibre library, answers **202** with a job handle, and unpacks into a temp directory the job owns.
|
||||||
|
`GET /libraries/imports/{job_id}` is polled; `DELETE` on the same path stops it.
|
||||||
|
|
||||||
|
There is deliberately **no endpoint that imports from a server path**. A desktop Calibre install is
|
||||||
|
not on the server, and importing from a path the server can already see is a server-side operation
|
||||||
|
— which is what `litestar --app-dir src/chitai/ calibre-import <path> --library <slug>` is for,
|
||||||
|
including its `--dry-run`. Do not add the path endpoint back without being asked: it was built,
|
||||||
|
then removed on purpose.
|
||||||
|
|
||||||
|
- **The registry is in memory, so it assumes one worker process.** That holds today (`litestar run`
|
||||||
|
is single-process, and the consume watcher is already an in-process singleton), but the day
|
||||||
|
`TODO.md`'s "production image runs the development server" item is fixed with a worker count, a
|
||||||
|
poll can land on a worker that never heard of the job. `services/calibre_import.py` says so at
|
||||||
|
the top; an `import_jobs` table is the answer when that happens.
|
||||||
|
- **The job opens its own session.** The request that started it is long gone and its session
|
||||||
|
closed with it.
|
||||||
|
- **Cancelling is not aborting.** A flag is read between books, never during one, so a cancelled
|
||||||
|
import leaves whole books behind and never half of one. `task.cancel()` would abandon a book
|
||||||
|
mid-copy and leave files with no row describing them.
|
||||||
|
- **The job deletes its workspace** — the unpacked archive is a second copy of the whole library,
|
||||||
|
and the books worth keeping have been copied into the library proper by the time it ends. Removed
|
||||||
|
even when the run failed, since nothing will come back for it.
|
||||||
|
- **Extraction refuses** an entry pointing outside the archive (zip slip), an archive that will not
|
||||||
|
fit on disk, and one with no `metadata.db` within three levels. All three answer 400 before a job
|
||||||
|
exists, rather than as a job that reports FAILED a moment later.
|
||||||
|
- **The upload is streamed both sides.** The archive reaches disk in chunks rather than being read
|
||||||
|
whole, and the SvelteKit proxy passes `request.body` through instead of buffering it — see
|
||||||
|
`frontend/AGENTS.md`.
|
||||||
|
|
||||||
|
Things Calibre does that will produce wrong data if you forget them are documented at the top of
|
||||||
|
`services/calibre.py` — the `0101-01-01` date sentinel, `|` for a comma in an author name, the
|
||||||
|
REAL `series_index` that defaults to 1.0 for every book, HTML in `comments`, the views that need
|
||||||
|
SQLite functions Calibre registers from Python, and `books_pages_link` being both recent and
|
||||||
|
usually empty. `tests/calibre_fixtures.py` builds a library exercising all of them.
|
||||||
|
|
||||||
## KOReader hashing
|
## KOReader hashing
|
||||||
|
|
||||||
`services/utils.py` reimplements KOReader's partial-MD5 document identifier: 1 KiB samples at
|
`services/utils.py` reimplements KOReader's partial-MD5 document identifier: 1 KiB samples at
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""add file hash index
|
||||||
|
|
||||||
|
Revision ID: e9c2c7e875ae
|
||||||
|
Revises: 6d72d1bbc0ee
|
||||||
|
Create Date: 2026-08-13 14:52:09.341906
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
|
||||||
|
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
|
||||||
|
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
|
||||||
|
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
|
||||||
|
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
|
||||||
|
from sqlalchemy import Text # noqa: F401
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
|
||||||
|
|
||||||
|
sa.GUID = GUID
|
||||||
|
sa.DateTimeUTC = DateTimeUTC
|
||||||
|
sa.ORA_JSONB = ORA_JSONB
|
||||||
|
sa.EncryptedString = EncryptedString
|
||||||
|
sa.EncryptedText = EncryptedText
|
||||||
|
sa.StoredObject = StoredObject
|
||||||
|
sa.PasswordHash = PasswordHash
|
||||||
|
sa.Argon2Hasher = Argon2Hasher
|
||||||
|
sa.PasslibHasher = PasslibHasher
|
||||||
|
sa.PwdlibHasher = PwdlibHasher
|
||||||
|
sa.FernetBackend = FernetBackend
|
||||||
|
sa.PGCryptoBackend = PGCryptoBackend
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'e9c2c7e875ae'
|
||||||
|
down_revision = '6d72d1bbc0ee'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
schema_upgrades()
|
||||||
|
data_upgrades()
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
data_downgrades()
|
||||||
|
schema_downgrades()
|
||||||
|
|
||||||
|
def schema_upgrades() -> None:
|
||||||
|
"""schema upgrade migrations go here."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('file_metadata', schema=None) as batch_op:
|
||||||
|
batch_op.create_index('ix_file_metadata_hash', ['hash'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
def schema_downgrades() -> None:
|
||||||
|
"""schema downgrade migrations go here."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('file_metadata', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index('ix_file_metadata_hash')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
def data_upgrades() -> None:
|
||||||
|
"""Add any optional data upgrade migrations here!"""
|
||||||
|
|
||||||
|
def data_downgrades() -> None:
|
||||||
|
"""Add any optional data downgrade migrations here!"""
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
"""add book matching keys and duplicate dismissals
|
||||||
|
|
||||||
|
Revision ID: 4358e7d4743a
|
||||||
|
Revises: e9c2c7e875ae
|
||||||
|
Create Date: 2026-08-15 15:09:02.708914
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
|
||||||
|
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
|
||||||
|
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
|
||||||
|
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
|
||||||
|
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
|
||||||
|
from sqlalchemy import Text # noqa: F401
|
||||||
|
|
||||||
|
__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
|
||||||
|
|
||||||
|
sa.GUID = GUID
|
||||||
|
sa.DateTimeUTC = DateTimeUTC
|
||||||
|
sa.ORA_JSONB = ORA_JSONB
|
||||||
|
sa.EncryptedString = EncryptedString
|
||||||
|
sa.EncryptedText = EncryptedText
|
||||||
|
sa.StoredObject = StoredObject
|
||||||
|
sa.PasswordHash = PasswordHash
|
||||||
|
sa.Argon2Hasher = Argon2Hasher
|
||||||
|
sa.PasslibHasher = PasslibHasher
|
||||||
|
sa.PwdlibHasher = PwdlibHasher
|
||||||
|
sa.FernetBackend = FernetBackend
|
||||||
|
sa.PGCryptoBackend = PGCryptoBackend
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '4358e7d4743a'
|
||||||
|
down_revision = 'e9c2c7e875ae'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
schema_upgrades()
|
||||||
|
data_upgrades()
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
data_downgrades()
|
||||||
|
schema_downgrades()
|
||||||
|
|
||||||
|
def schema_upgrades() -> None:
|
||||||
|
"""schema upgrade migrations go here."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('duplicate_dismissals',
|
||||||
|
sa.Column('id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
||||||
|
sa.Column('book_a_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
||||||
|
sa.Column('book_b_id', sa.BigInteger().with_variant(sa.Integer(), 'sqlite'), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['book_a_id'], ['books.id'], name=op.f('fk_duplicate_dismissals_book_a_id_books'), ondelete='cascade'),
|
||||||
|
sa.ForeignKeyConstraint(['book_b_id'], ['books.id'], name=op.f('fk_duplicate_dismissals_book_b_id_books'), ondelete='cascade'),
|
||||||
|
sa.PrimaryKeyConstraint('id', name=op.f('pk_duplicate_dismissals')),
|
||||||
|
sa.UniqueConstraint('book_a_id', 'book_b_id', name=op.f('uq_duplicate_dismissals_book_a_id'))
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('duplicate_dismissals', schema=None) as batch_op:
|
||||||
|
batch_op.create_index(batch_op.f('ix_duplicate_dismissals_book_a_id'), ['book_a_id'], unique=False)
|
||||||
|
batch_op.create_index(batch_op.f('ix_duplicate_dismissals_book_b_id'), ['book_b_id'], unique=False)
|
||||||
|
|
||||||
|
# `server_default` so the column can be added to a table that already has rows;
|
||||||
|
# `data_upgrades` fills in the real keys immediately afterwards.
|
||||||
|
with op.batch_alter_table('authors', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('normalized_name', sa.String(), nullable=False, server_default=''))
|
||||||
|
batch_op.create_index(batch_op.f('ix_authors_normalized_name'), ['normalized_name'], unique=False)
|
||||||
|
|
||||||
|
with op.batch_alter_table('books', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('normalized_title', sa.String(), nullable=False, server_default=''))
|
||||||
|
batch_op.create_index(batch_op.f('ix_books_normalized_title'), ['normalized_title'], unique=False)
|
||||||
|
|
||||||
|
with op.batch_alter_table('identifiers', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('normalized_value', sa.String(), nullable=True))
|
||||||
|
batch_op.create_index(batch_op.f('ix_identifiers_normalized_value'), ['normalized_value'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
def schema_downgrades() -> None:
|
||||||
|
"""schema downgrade migrations go here."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('identifiers', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_identifiers_normalized_value'))
|
||||||
|
batch_op.drop_column('normalized_value')
|
||||||
|
|
||||||
|
with op.batch_alter_table('books', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_books_normalized_title'))
|
||||||
|
batch_op.drop_column('normalized_title')
|
||||||
|
|
||||||
|
with op.batch_alter_table('authors', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_authors_normalized_name'))
|
||||||
|
batch_op.drop_column('normalized_name')
|
||||||
|
|
||||||
|
with op.batch_alter_table('duplicate_dismissals', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index(batch_op.f('ix_duplicate_dismissals_book_b_id'))
|
||||||
|
batch_op.drop_index(batch_op.f('ix_duplicate_dismissals_book_a_id'))
|
||||||
|
|
||||||
|
op.drop_table('duplicate_dismissals')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
def data_upgrades() -> None:
|
||||||
|
"""
|
||||||
|
Fill the matching keys in for rows that already exist.
|
||||||
|
|
||||||
|
The validators on the models only fire when something is written, so without this
|
||||||
|
every book imported before today is invisible to duplicate detection. Run through
|
||||||
|
the same helpers the validators use, so a backfilled row and a freshly written one
|
||||||
|
are guaranteed to agree.
|
||||||
|
"""
|
||||||
|
from chitai.services.matching import (
|
||||||
|
normalize_author,
|
||||||
|
normalize_identifier,
|
||||||
|
normalize_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
books = connection.execute(sa.text("SELECT id, title FROM books")).fetchall()
|
||||||
|
_apply(
|
||||||
|
connection,
|
||||||
|
"UPDATE books SET normalized_title = :key WHERE id = :id",
|
||||||
|
[{"id": id, "key": normalize_title(title)} for id, title in books],
|
||||||
|
)
|
||||||
|
|
||||||
|
authors = connection.execute(sa.text("SELECT id, name FROM authors")).fetchall()
|
||||||
|
_apply(
|
||||||
|
connection,
|
||||||
|
"UPDATE authors SET normalized_name = :key WHERE id = :id",
|
||||||
|
[{"id": id, "key": normalize_author(name)} for id, name in authors],
|
||||||
|
)
|
||||||
|
|
||||||
|
identifiers = connection.execute(
|
||||||
|
sa.text("SELECT id, name, value FROM identifiers")
|
||||||
|
).fetchall()
|
||||||
|
_apply(
|
||||||
|
connection,
|
||||||
|
"UPDATE identifiers SET normalized_value = :key WHERE id = :id",
|
||||||
|
[
|
||||||
|
{"id": id, "key": normalize_identifier(name, value)}
|
||||||
|
for id, name, value in identifiers
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(connection, statement: str, parameters: list[dict]) -> None:
|
||||||
|
"""Run one update per row, in batches, skipping the work when there are none."""
|
||||||
|
batch_size = 1000
|
||||||
|
|
||||||
|
for start in range(0, len(parameters), batch_size):
|
||||||
|
connection.execute(sa.text(statement), parameters[start : start + batch_size])
|
||||||
|
|
||||||
|
|
||||||
|
def data_downgrades() -> None:
|
||||||
|
"""Add any optional data downgrade migrations here!"""
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"""canonicalize author names
|
||||||
|
|
||||||
|
Revision ID: 49a9e85a0ffc
|
||||||
|
Revises: ed41acf21270
|
||||||
|
Create Date: 2026-08-15 15:59:47.331545
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
|
||||||
|
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
|
||||||
|
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
|
||||||
|
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
|
||||||
|
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
|
||||||
|
from sqlalchemy import Text # noqa: F401
|
||||||
|
|
||||||
|
__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
|
||||||
|
|
||||||
|
sa.GUID = GUID
|
||||||
|
sa.DateTimeUTC = DateTimeUTC
|
||||||
|
sa.ORA_JSONB = ORA_JSONB
|
||||||
|
sa.EncryptedString = EncryptedString
|
||||||
|
sa.EncryptedText = EncryptedText
|
||||||
|
sa.StoredObject = StoredObject
|
||||||
|
sa.PasswordHash = PasswordHash
|
||||||
|
sa.Argon2Hasher = Argon2Hasher
|
||||||
|
sa.PasslibHasher = PasslibHasher
|
||||||
|
sa.PwdlibHasher = PwdlibHasher
|
||||||
|
sa.FernetBackend = FernetBackend
|
||||||
|
sa.PGCryptoBackend = PGCryptoBackend
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '49a9e85a0ffc'
|
||||||
|
down_revision = 'ed41acf21270'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
schema_upgrades()
|
||||||
|
data_upgrades()
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
data_downgrades()
|
||||||
|
schema_downgrades()
|
||||||
|
|
||||||
|
def schema_upgrades() -> None:
|
||||||
|
"""schema upgrade migrations go here."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def schema_downgrades() -> None:
|
||||||
|
"""schema downgrade migrations go here."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def data_upgrades() -> None:
|
||||||
|
"""
|
||||||
|
Rewrite every author into the canonical form, merging the rows that collide.
|
||||||
|
|
||||||
|
`Author.name` is only now guaranteed tidy — until this revision extractors wrote
|
||||||
|
whatever the file said, so one person could hold several rows: "Sam Newman" beside
|
||||||
|
"Newman, Sam;" beside "Sam Newman.epub" (the last from a filename whose extension
|
||||||
|
was never stripped). Each showed up as its own author in the sidebar and its own
|
||||||
|
filter, and no amount of fixing the extractors repairs a row already written.
|
||||||
|
|
||||||
|
Rows that canonicalize onto one name are merged into the lowest id, which keeps
|
||||||
|
whichever row the library has been referring to longest. `book.path` is stored, not
|
||||||
|
derived, so renaming an author moves nothing on disk.
|
||||||
|
"""
|
||||||
|
from chitai.services.matching import format_author_name, normalize_author
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
authors = connection.execute(sa.text("SELECT id, name FROM authors")).fetchall()
|
||||||
|
|
||||||
|
groups: dict[str, list[int]] = {}
|
||||||
|
for id, name in sorted(authors):
|
||||||
|
# A name with nothing left of it after tidying is left exactly as it was:
|
||||||
|
# merging those together would invent one author out of several unrelated
|
||||||
|
# broken rows, which is worse than leaving the mess visible.
|
||||||
|
if canonical := format_author_name(name):
|
||||||
|
groups.setdefault(canonical, []).append(id)
|
||||||
|
|
||||||
|
for canonical, ids in groups.items():
|
||||||
|
winner, losers = ids[0], ids[1:]
|
||||||
|
|
||||||
|
for loser in losers:
|
||||||
|
# A book credited to both rows would otherwise breach the
|
||||||
|
# (book_id, author_id) unique constraint the moment the link is repointed.
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"DELETE FROM book_author_links WHERE author_id = :loser AND book_id IN"
|
||||||
|
" (SELECT book_id FROM book_author_links WHERE author_id = :winner)"
|
||||||
|
),
|
||||||
|
{"loser": loser, "winner": winner},
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE book_author_links SET author_id = :winner"
|
||||||
|
" WHERE author_id = :loser"
|
||||||
|
),
|
||||||
|
{"loser": loser, "winner": winner},
|
||||||
|
)
|
||||||
|
connection.execute(
|
||||||
|
sa.text("DELETE FROM authors WHERE id = :loser"), {"loser": loser}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only after the losers are gone, or this collides with the unique index.
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE authors SET name = :name, normalized_name = :key WHERE id = :id"
|
||||||
|
),
|
||||||
|
{"id": winner, "name": canonical, "key": normalize_author(canonical)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def data_downgrades() -> None:
|
||||||
|
"""
|
||||||
|
Nothing to undo.
|
||||||
|
|
||||||
|
The rows a merge removed are gone, and the spellings it replaced were never
|
||||||
|
recorded anywhere else — there is nothing to restore them from.
|
||||||
|
"""
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""recompute book matching keys
|
||||||
|
|
||||||
|
Revision ID: ed41acf21270
|
||||||
|
Revises: 4358e7d4743a
|
||||||
|
Create Date: 2026-08-15 15:44:28.341020
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
|
||||||
|
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
|
||||||
|
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
|
||||||
|
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
|
||||||
|
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
|
||||||
|
from sqlalchemy import Text # noqa: F401
|
||||||
|
|
||||||
|
__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
|
||||||
|
|
||||||
|
sa.GUID = GUID
|
||||||
|
sa.DateTimeUTC = DateTimeUTC
|
||||||
|
sa.ORA_JSONB = ORA_JSONB
|
||||||
|
sa.EncryptedString = EncryptedString
|
||||||
|
sa.EncryptedText = EncryptedText
|
||||||
|
sa.StoredObject = StoredObject
|
||||||
|
sa.PasswordHash = PasswordHash
|
||||||
|
sa.Argon2Hasher = Argon2Hasher
|
||||||
|
sa.PasslibHasher = PasslibHasher
|
||||||
|
sa.PwdlibHasher = PwdlibHasher
|
||||||
|
sa.FernetBackend = FernetBackend
|
||||||
|
sa.PGCryptoBackend = PGCryptoBackend
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'ed41acf21270'
|
||||||
|
down_revision = '4358e7d4743a'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
schema_upgrades()
|
||||||
|
data_upgrades()
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
data_downgrades()
|
||||||
|
schema_downgrades()
|
||||||
|
|
||||||
|
def schema_upgrades() -> None:
|
||||||
|
"""schema upgrade migrations go here."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def schema_downgrades() -> None:
|
||||||
|
"""schema downgrade migrations go here."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def data_upgrades() -> None:
|
||||||
|
"""
|
||||||
|
Recompute every matching key against the current normalization.
|
||||||
|
|
||||||
|
The keys are derived, so changing a helper in `services/matching.py` silently
|
||||||
|
invalidates every row already written — a book stored under the old rules simply
|
||||||
|
stops matching one stored under the new ones, with nothing to show that anything
|
||||||
|
is wrong. `normalize_title` learned to strip the compact edition markers a cover
|
||||||
|
actually carries ("2E", "5e"), which moved `Building Microservices, 2E` onto the
|
||||||
|
same key as `Building Microservices`.
|
||||||
|
|
||||||
|
Any later change to those helpers wants a revision that looks exactly like this
|
||||||
|
one. It is idempotent and safe to re-run.
|
||||||
|
"""
|
||||||
|
from chitai.services.matching import (
|
||||||
|
normalize_author,
|
||||||
|
normalize_identifier,
|
||||||
|
normalize_title,
|
||||||
|
)
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
books = connection.execute(sa.text("SELECT id, title FROM books")).fetchall()
|
||||||
|
_apply(
|
||||||
|
connection,
|
||||||
|
"UPDATE books SET normalized_title = :key WHERE id = :id",
|
||||||
|
[{"id": id, "key": normalize_title(title)} for id, title in books],
|
||||||
|
)
|
||||||
|
|
||||||
|
authors = connection.execute(sa.text("SELECT id, name FROM authors")).fetchall()
|
||||||
|
_apply(
|
||||||
|
connection,
|
||||||
|
"UPDATE authors SET normalized_name = :key WHERE id = :id",
|
||||||
|
[{"id": id, "key": normalize_author(name)} for id, name in authors],
|
||||||
|
)
|
||||||
|
|
||||||
|
identifiers = connection.execute(
|
||||||
|
sa.text("SELECT id, name, value FROM identifiers")
|
||||||
|
).fetchall()
|
||||||
|
_apply(
|
||||||
|
connection,
|
||||||
|
"UPDATE identifiers SET normalized_value = :key WHERE id = :id",
|
||||||
|
[
|
||||||
|
{"id": id, "key": normalize_identifier(name, value)}
|
||||||
|
for id, name, value in identifiers
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(connection, statement: str, parameters: list[dict]) -> None:
|
||||||
|
"""Run one update per row, in batches, skipping the work when there are none."""
|
||||||
|
batch_size = 1000
|
||||||
|
|
||||||
|
for start in range(0, len(parameters), batch_size):
|
||||||
|
connection.execute(sa.text(statement), parameters[start : start + batch_size])
|
||||||
|
|
||||||
|
|
||||||
|
def data_downgrades() -> None:
|
||||||
|
"""Add any optional data downgrade migrations here!"""
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""backfill file content types
|
||||||
|
|
||||||
|
Revision ID: d2d69065ede3
|
||||||
|
Revises: 49a9e85a0ffc
|
||||||
|
Create Date: 2026-08-17 11:37:58.959135
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
from advanced_alchemy.types import EncryptedString, EncryptedText, GUID, ORA_JSONB, DateTimeUTC, StoredObject, PasswordHash, FernetBackend
|
||||||
|
from advanced_alchemy.types.encrypted_string import PGCryptoBackend
|
||||||
|
from advanced_alchemy.types.password_hash.argon2 import Argon2Hasher
|
||||||
|
from advanced_alchemy.types.password_hash.passlib import PasslibHasher
|
||||||
|
from advanced_alchemy.types.password_hash.pwdlib import PwdlibHasher
|
||||||
|
from sqlalchemy import Text # noqa: F401
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
__all__ = ["downgrade", "upgrade", "schema_upgrades", "schema_downgrades", "data_upgrades", "data_downgrades"]
|
||||||
|
|
||||||
|
sa.GUID = GUID
|
||||||
|
sa.DateTimeUTC = DateTimeUTC
|
||||||
|
sa.ORA_JSONB = ORA_JSONB
|
||||||
|
sa.EncryptedString = EncryptedString
|
||||||
|
sa.EncryptedText = EncryptedText
|
||||||
|
sa.StoredObject = StoredObject
|
||||||
|
sa.PasswordHash = PasswordHash
|
||||||
|
sa.Argon2Hasher = Argon2Hasher
|
||||||
|
sa.PasslibHasher = PasslibHasher
|
||||||
|
sa.PwdlibHasher = PwdlibHasher
|
||||||
|
sa.FernetBackend = FernetBackend
|
||||||
|
sa.PGCryptoBackend = PGCryptoBackend
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'd2d69065ede3'
|
||||||
|
down_revision = '49a9e85a0ffc'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
schema_upgrades()
|
||||||
|
data_upgrades()
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.filterwarnings("ignore", category=UserWarning)
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
data_downgrades()
|
||||||
|
schema_downgrades()
|
||||||
|
|
||||||
|
def schema_upgrades() -> None:
|
||||||
|
"""schema upgrade migrations go here."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def schema_downgrades() -> None:
|
||||||
|
"""schema downgrade migrations go here."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def data_upgrades() -> None:
|
||||||
|
"""
|
||||||
|
Name the format of every file whose content type was never worked out.
|
||||||
|
|
||||||
|
`create_many_from_existing_files` filled the column from `mimetypes.guess_type`,
|
||||||
|
which answers None for `.mobi`, `.azw`, `.fb2` and `.lit` — so a consume-directory
|
||||||
|
import of any of those stored a null, and the OPDS acquisition link a reader app
|
||||||
|
uses to decide what it can open carried nothing.
|
||||||
|
|
||||||
|
Both write paths now go through `guess_content_type`, which this uses too, so the
|
||||||
|
formats in its table get named retroactively. A row it still cannot name is **left
|
||||||
|
null** rather than filled with a placeholder: null is the truth, the column is
|
||||||
|
nullable, and the one consumer that needs a string substitutes one itself.
|
||||||
|
Idempotent: it only looks at rows that carry nothing.
|
||||||
|
"""
|
||||||
|
from chitai.services.utils import guess_content_type
|
||||||
|
|
||||||
|
connection = op.get_bind()
|
||||||
|
|
||||||
|
files = connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT id, path FROM file_metadata "
|
||||||
|
"WHERE content_type IS NULL OR content_type = ''"
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
parameters = [
|
||||||
|
{"id": id, "content_type": content_type}
|
||||||
|
for id, path in files
|
||||||
|
if (content_type := guess_content_type(path)) is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
if not parameters:
|
||||||
|
return
|
||||||
|
|
||||||
|
batch_size = 1000
|
||||||
|
for start in range(0, len(parameters), batch_size):
|
||||||
|
connection.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE file_metadata SET content_type = :content_type WHERE id = :id"
|
||||||
|
),
|
||||||
|
parameters[start : start + batch_size],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def data_downgrades() -> None:
|
||||||
|
"""Add any optional data downgrade migrations here!"""
|
||||||
@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
|
|
||||||
from chitai import controllers as c
|
from chitai import controllers as c
|
||||||
|
from chitai.cli import CalibreCLIPlugin
|
||||||
from chitai.config import settings
|
from chitai.config import settings
|
||||||
from chitai.database.config import alchemy
|
from chitai.database.config import alchemy
|
||||||
from chitai.database.models.user import User
|
from chitai.database.models.user import User
|
||||||
@@ -133,7 +134,7 @@ def create_app() -> Litestar:
|
|||||||
],
|
],
|
||||||
exception_handlers=exception_handlers,
|
exception_handlers=exception_handlers,
|
||||||
lifespan=[setup_db_connection, setup_directory_watcher],
|
lifespan=[setup_db_connection, setup_directory_watcher],
|
||||||
plugins=[alchemy],
|
plugins=[alchemy, CalibreCLIPlugin()],
|
||||||
on_app_init=[oauth2_auth.on_app_init],
|
on_app_init=[oauth2_auth.on_app_init],
|
||||||
openapi_config=OpenAPIConfig(
|
openapi_config=OpenAPIConfig(
|
||||||
title="Chitai",
|
title="Chitai",
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# src/chitai/cli.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
Extra commands on the `litestar` CLI.
|
||||||
|
|
||||||
|
Registered through `CalibreCLIPlugin` in `app.py`, so they run as
|
||||||
|
`litestar --app-dir src/chitai/ calibre-import …` and get the app's own configuration
|
||||||
|
without a second way to load it.
|
||||||
|
|
||||||
|
The Calibre import lives here as well as behind an endpoint because the case it exists
|
||||||
|
for is a one-time migration of a library that may be hundreds of gigabytes. That should
|
||||||
|
not depend on a browser tab staying open.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
from click import Group
|
||||||
|
from litestar.plugins import CLIPluginProtocol
|
||||||
|
|
||||||
|
from chitai.config import settings
|
||||||
|
from chitai.database.models import Library
|
||||||
|
from chitai.services.book import BookService, CalibreImportProgress, CalibreImportResult
|
||||||
|
from chitai.services.calibre import CalibreLibrary, CalibreLibraryError
|
||||||
|
from chitai.services.library import LibraryService
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreCLIPlugin(CLIPluginProtocol):
|
||||||
|
"""Adds `calibre-import` to the Litestar CLI."""
|
||||||
|
|
||||||
|
def on_cli_init(self, cli: Group) -> None:
|
||||||
|
cli.add_command(calibre_import)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command(name="calibre-import")
|
||||||
|
@click.argument(
|
||||||
|
"source",
|
||||||
|
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--library",
|
||||||
|
"library_slug",
|
||||||
|
required=True,
|
||||||
|
help="Slug of the Chitai library to import into.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--allow-duplicates",
|
||||||
|
is_flag=True,
|
||||||
|
help="Import books whose files the library already holds.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--dry-run",
|
||||||
|
is_flag=True,
|
||||||
|
help="Read the catalogue and report what it holds, without writing anything.",
|
||||||
|
)
|
||||||
|
def calibre_import(
|
||||||
|
source: Path, library_slug: str, allow_duplicates: bool, dry_run: bool
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Import a Calibre library from SOURCE, the directory holding its metadata.db.
|
||||||
|
|
||||||
|
Files are copied, never moved: the Calibre library is left exactly as it is, and
|
||||||
|
re-running skips whatever is already stored.
|
||||||
|
"""
|
||||||
|
asyncio.run(_import(source, library_slug, allow_duplicates, dry_run))
|
||||||
|
|
||||||
|
|
||||||
|
async def _import(
|
||||||
|
source: Path, library_slug: str, allow_duplicates: bool, dry_run: bool
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
library_source = CalibreLibrary(source)
|
||||||
|
await library_source.open()
|
||||||
|
except CalibreLibraryError as exc:
|
||||||
|
raise click.ClickException(str(exc)) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
if dry_run:
|
||||||
|
await _report(library_source)
|
||||||
|
return
|
||||||
|
|
||||||
|
async with settings.alchemy_config.get_session() as session:
|
||||||
|
library = await _library(session, library_slug)
|
||||||
|
|
||||||
|
result = await BookService(session=session).create_many_from_calibre(
|
||||||
|
library_source,
|
||||||
|
library,
|
||||||
|
allow_duplicates=allow_duplicates,
|
||||||
|
on_progress=_print_progress,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await library_source.close()
|
||||||
|
|
||||||
|
_print_summary(result)
|
||||||
|
|
||||||
|
if result.failed:
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
async def _library(session: object, slug: str) -> Library:
|
||||||
|
"""Resolve the target library, or explain what the options were."""
|
||||||
|
service = LibraryService(session=session) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
library = await service.get_one_or_none(Library.slug == slug)
|
||||||
|
|
||||||
|
if library is None:
|
||||||
|
available = ", ".join(sorted(item.slug for item in await service.list()))
|
||||||
|
raise click.ClickException(
|
||||||
|
f"No library with slug '{slug}'. Available: {available or 'none'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A read-only library is one pointing at a tree Chitai does not own. Copying books
|
||||||
|
# into it would write into somebody else's directory.
|
||||||
|
if library.read_only:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"Library '{slug}' is read-only, so nothing can be imported into it"
|
||||||
|
)
|
||||||
|
|
||||||
|
return library
|
||||||
|
|
||||||
|
|
||||||
|
async def _report(source: CalibreLibrary) -> None:
|
||||||
|
"""Describe the catalogue without touching the database."""
|
||||||
|
books = await source.books()
|
||||||
|
|
||||||
|
click.echo(f"{len(books)} book(s) in {source.root}\n")
|
||||||
|
|
||||||
|
for book in books:
|
||||||
|
authors = ", ".join(book.authors) or "unknown author"
|
||||||
|
formats = ", ".join(file.format for file in book.files) or "no files"
|
||||||
|
click.echo(f" #{book.calibre_id:<6} {book.title}")
|
||||||
|
click.echo(f" {'':<7} {authors} · {formats}")
|
||||||
|
|
||||||
|
missing = [
|
||||||
|
book
|
||||||
|
for book in books
|
||||||
|
if any(not file.path.is_file() for file in book.files) or not book.files
|
||||||
|
]
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
click.echo(
|
||||||
|
f"\n{len(missing)} book(s) have files the catalogue lists "
|
||||||
|
"but disk does not:"
|
||||||
|
)
|
||||||
|
for book in missing:
|
||||||
|
click.echo(f" #{book.calibre_id} {book.title}")
|
||||||
|
|
||||||
|
|
||||||
|
def _print_progress(progress: CalibreImportProgress) -> None:
|
||||||
|
marker = {"created": "+", "skipped": "-", "failed": "!"}.get(progress.outcome, " ")
|
||||||
|
detail = f" ({progress.detail})" if progress.detail else ""
|
||||||
|
|
||||||
|
click.echo(
|
||||||
|
f"[{progress.processed:>5}/{progress.total}] {marker} {progress.title}{detail}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(result: CalibreImportResult) -> None:
|
||||||
|
click.echo(
|
||||||
|
f"\n{len(result.created)} created, {len(result.skipped)} skipped, "
|
||||||
|
f"{len(result.failed)} failed, of {result.total}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.duplicate_files:
|
||||||
|
click.echo(
|
||||||
|
f"{len(result.duplicate_files)} individual file(s) were already stored and "
|
||||||
|
"were left out of books that imported otherwise."
|
||||||
|
)
|
||||||
|
|
||||||
|
for failure in result.failed:
|
||||||
|
click.echo(f" failed #{failure.calibre_id} {failure.title}: {failure.reason}")
|
||||||
|
|
||||||
|
# Imported all the same — a metadata match is a guess, and the duplicates screen is
|
||||||
|
# where these get decided.
|
||||||
|
for possible in result.possible_duplicates:
|
||||||
|
names = ", ".join(
|
||||||
|
f"{candidate.title} (#{candidate.book_id})"
|
||||||
|
for candidate in possible.candidates
|
||||||
|
)
|
||||||
|
click.echo(
|
||||||
|
f" possible duplicate {possible.title} (#{possible.book_id}) "
|
||||||
|
f"may already be in the library as: {names}"
|
||||||
|
)
|
||||||
@@ -1,9 +1,25 @@
|
|||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
from pydantic import Field, PostgresDsn, computed_field
|
from pydantic import Field, PostgresDsn, computed_field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from advanced_alchemy.extensions.litestar import (
|
from advanced_alchemy.extensions.litestar import (
|
||||||
SQLAlchemyAsyncConfig,
|
SQLAlchemyAsyncConfig,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateScope(StrEnum):
|
||||||
|
"""How widely an incoming file is compared against what is already stored."""
|
||||||
|
|
||||||
|
LIBRARY = "library"
|
||||||
|
"""Only files in the library being uploaded to count as duplicates."""
|
||||||
|
|
||||||
|
GLOBAL = "global"
|
||||||
|
"""A file already held by any library counts as a duplicate."""
|
||||||
|
|
||||||
|
OFF = "off"
|
||||||
|
"""No duplicate detection at all."""
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
version: str = Field("0.0.1")
|
version: str = Field("0.0.1")
|
||||||
project_name: str = Field("chitai")
|
project_name: str = Field("chitai")
|
||||||
@@ -33,6 +49,14 @@ class Settings(BaseSettings):
|
|||||||
# Path to consume directory
|
# Path to consume directory
|
||||||
consume_path: str = Field("./consume")
|
consume_path: str = Field("./consume")
|
||||||
|
|
||||||
|
# Duplicate detection
|
||||||
|
duplicate_scope: DuplicateScope = Field(DuplicateScope.LIBRARY)
|
||||||
|
|
||||||
|
# Where the consume watcher parks files it refused as duplicates. Must sit
|
||||||
|
# outside `consume_path`, or the watcher picks them straight back up and
|
||||||
|
# tries to resolve the directory name as a library slug.
|
||||||
|
duplicate_path: str = Field("./duplicates")
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def postgres_uri(self) -> PostgresDsn:
|
def postgres_uri(self) -> PostgresDsn:
|
||||||
|
|||||||
@@ -12,7 +12,12 @@ from litestar.params import Dependency, Body
|
|||||||
from litestar.enums import RequestEncodingType
|
from litestar.enums import RequestEncodingType
|
||||||
from litestar.response import File, Stream
|
from litestar.response import File, Stream
|
||||||
from litestar.exceptions import HTTPException
|
from litestar.exceptions import HTTPException
|
||||||
from litestar.status_codes import HTTP_400_BAD_REQUEST
|
from litestar.status_codes import (
|
||||||
|
HTTP_200_OK,
|
||||||
|
HTTP_204_NO_CONTENT,
|
||||||
|
HTTP_400_BAD_REQUEST,
|
||||||
|
HTTP_409_CONFLICT,
|
||||||
|
)
|
||||||
from litestar.datastructures import UploadFile
|
from litestar.datastructures import UploadFile
|
||||||
from advanced_alchemy.service.pagination import OffsetPagination
|
from advanced_alchemy.service.pagination import OffsetPagination
|
||||||
from advanced_alchemy.filters import CollectionFilter
|
from advanced_alchemy.filters import CollectionFilter
|
||||||
@@ -23,6 +28,24 @@ from chitai.services import dependencies as deps
|
|||||||
from chitai import schemas as s
|
from chitai import schemas as s
|
||||||
from chitai.database import models as m
|
from chitai.database import models as m
|
||||||
from chitai.services import BookService, BookProgressService
|
from chitai.services import BookService, BookProgressService
|
||||||
|
from chitai.services.book import DuplicateFilesError
|
||||||
|
|
||||||
|
|
||||||
|
def _duplicate_conflict(exc: DuplicateFilesError) -> HTTPException:
|
||||||
|
"""
|
||||||
|
Turn refused files into a 409 the caller can act on.
|
||||||
|
|
||||||
|
The files ride along in `extra` so the client can name them and offer to send them
|
||||||
|
again with `allow_duplicates`, rather than being told only that something clashed.
|
||||||
|
"""
|
||||||
|
return HTTPException(
|
||||||
|
status_code=HTTP_409_CONFLICT,
|
||||||
|
detail="These files are already in the library",
|
||||||
|
extra=[
|
||||||
|
s.DuplicateFileRead.model_validate(duplicate).model_dump(mode="json")
|
||||||
|
for duplicate in exc.duplicates
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BookController(Controller):
|
class BookController(Controller):
|
||||||
@@ -63,6 +86,7 @@ class BookController(Controller):
|
|||||||
books_service: BookService,
|
books_service: BookService,
|
||||||
library: m.Library,
|
library: m.Library,
|
||||||
data: Annotated[s.BookCreate, Body(media_type=RequestEncodingType.MULTI_PART)],
|
data: Annotated[s.BookCreate, Body(media_type=RequestEncodingType.MULTI_PART)],
|
||||||
|
allow_duplicates: bool = False,
|
||||||
) -> s.BookRead:
|
) -> s.BookRead:
|
||||||
"""
|
"""
|
||||||
Create a new book with metadata and files.
|
Create a new book with metadata and files.
|
||||||
@@ -73,6 +97,10 @@ class BookController(Controller):
|
|||||||
Path Parameters:
|
Path Parameters:
|
||||||
library_id: The ID of the library the book belongs to.
|
library_id: The ID of the library the book belongs to.
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
allow_duplicates: If True, store the files even if the library already
|
||||||
|
holds them.
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
data: Book creation data including metadata and files.
|
data: Book creation data including metadata and files.
|
||||||
|
|
||||||
@@ -83,9 +111,17 @@ class BookController(Controller):
|
|||||||
Returns:
|
Returns:
|
||||||
The created book as a BookRead schema.
|
The created book as a BookRead schema.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 409 if any of the files is already in the library.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = await books_service.create_book(data, library)
|
try:
|
||||||
|
result = await books_service.create_book(
|
||||||
|
data, library, screen_duplicates=not allow_duplicates
|
||||||
|
)
|
||||||
|
except DuplicateFilesError as exc:
|
||||||
|
raise _duplicate_conflict(exc)
|
||||||
|
|
||||||
book = await books_service.get(result.id)
|
book = await books_service.get(result.id)
|
||||||
return books_service.to_schema(book, schema_type=s.BookRead)
|
return books_service.to_schema(book, schema_type=s.BookRead)
|
||||||
|
|
||||||
@@ -97,13 +133,20 @@ class BookController(Controller):
|
|||||||
data: Annotated[
|
data: Annotated[
|
||||||
s.BooksCreateFromFiles, Body(media_type=RequestEncodingType.MULTI_PART)
|
s.BooksCreateFromFiles, Body(media_type=RequestEncodingType.MULTI_PART)
|
||||||
],
|
],
|
||||||
) -> OffsetPagination[s.BookRead]:
|
allow_duplicates: bool = False,
|
||||||
|
) -> s.BooksUploadResult:
|
||||||
"""
|
"""
|
||||||
Create multiple books from uploaded files.
|
Create multiple books from uploaded files.
|
||||||
|
|
||||||
Groups files by directory and creates separate books for each group.
|
Groups files by directory and creates separate books for each group.
|
||||||
Metadata is automatically extracted from the files.
|
Metadata is automatically extracted from the files.
|
||||||
|
|
||||||
|
Files the library already holds are skipped rather than refused, and reported
|
||||||
|
back so the caller can say which ones did not make it in and why.
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
allow_duplicates: If True, store every file, even one already held.
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
data: Container with list of uploaded files.
|
data: Container with list of uploaded files.
|
||||||
|
|
||||||
@@ -112,19 +155,200 @@ class BookController(Controller):
|
|||||||
library: The library the books belong to.
|
library: The library the books belong to.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Paginated list of created books.
|
The books created, and the files skipped as duplicates.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
results = await books_service.create_many_from_files(data, library)
|
result = await books_service.create_many_from_files(
|
||||||
|
data, library, allow_duplicates=allow_duplicates
|
||||||
|
)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=HTTP_400_BAD_REQUEST, detail="Must upload at least one file"
|
status_code=HTTP_400_BAD_REQUEST, detail="Must upload at least one file"
|
||||||
)
|
)
|
||||||
|
|
||||||
books = await books_service.list(
|
books = (
|
||||||
CollectionFilter("id", [result.id for result in results])
|
await books_service.list(
|
||||||
|
CollectionFilter("id", [book.id for book in result.books])
|
||||||
)
|
)
|
||||||
return books_service.to_schema(books, schema_type=s.BookRead)
|
if result.books
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
return s.BooksUploadResult(
|
||||||
|
created=[
|
||||||
|
books_service.to_schema(book, schema_type=s.BookRead) for book in books
|
||||||
|
],
|
||||||
|
skipped=[
|
||||||
|
s.DuplicateFileRead.model_validate(duplicate)
|
||||||
|
for duplicate in result.duplicates
|
||||||
|
],
|
||||||
|
possible_duplicates=[
|
||||||
|
s.PossibleDuplicateRead.model_validate(possible)
|
||||||
|
for possible in result.possible_duplicates
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@get(path="duplicate-books")
|
||||||
|
async def list_duplicate_books(
|
||||||
|
self, books_service: BookService, library: m.Library
|
||||||
|
) -> list[s.DuplicateBookGroupRead]:
|
||||||
|
"""
|
||||||
|
Report books already in the library that look like copies of one another.
|
||||||
|
|
||||||
|
The import-time check only ever sees what is arriving, so this is what covers
|
||||||
|
a collection someone already has. Matching is on metadata and therefore a
|
||||||
|
guess: a group is a question for the reader, not a verdict.
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
library_id: The library to review.
|
||||||
|
|
||||||
|
Injected Dependencies:
|
||||||
|
books_service: The book service for database operations.
|
||||||
|
library: The library to review.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One entry per group of two or more books. Empty when there is nothing to
|
||||||
|
review, or when duplicate detection is switched off.
|
||||||
|
"""
|
||||||
|
groups = await books_service.find_duplicate_book_groups(library)
|
||||||
|
|
||||||
|
return [
|
||||||
|
s.DuplicateBookGroupRead(
|
||||||
|
books=[s.DuplicateBookRead.model_validate(book) for book in group]
|
||||||
|
)
|
||||||
|
for group in groups
|
||||||
|
]
|
||||||
|
|
||||||
|
@post(path="merge")
|
||||||
|
async def merge_books(
|
||||||
|
self, books_service: BookService, library: m.Library, data: s.BookMerge
|
||||||
|
) -> s.BookRead:
|
||||||
|
"""
|
||||||
|
Fold several books into one and delete the records folded in.
|
||||||
|
|
||||||
|
The survivor keeps its id, so links and bookmarks still resolve. Files, reading
|
||||||
|
progress, shelves, tags and unheld identifiers move onto it; metadata is only
|
||||||
|
changed by what `metadata` names, because choosing between two titles is the
|
||||||
|
reader's judgement rather than this endpoint's.
|
||||||
|
|
||||||
|
Nothing is removed from disk — a wrong merge should cost metadata that can be
|
||||||
|
retyped, not a book.
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
library_id: The library the books belong to.
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
data: The survivor, the books to fold in, and the resolved metadata.
|
||||||
|
|
||||||
|
Injected Dependencies:
|
||||||
|
books_service: The book service for database operations.
|
||||||
|
library: The library the books belong to.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The surviving book.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if fewer than two distinct books were named, one is
|
||||||
|
unknown, or they do not all belong to one library.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
book = await books_service.merge_books(
|
||||||
|
data.survivor_id,
|
||||||
|
data.merged_ids,
|
||||||
|
library,
|
||||||
|
metadata=data.metadata.model_dump(exclude_unset=True)
|
||||||
|
if data.metadata
|
||||||
|
else None,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
|
return books_service.to_schema(book, schema_type=s.BookRead)
|
||||||
|
|
||||||
|
@post(path="duplicate-books/dismissals", status_code=HTTP_204_NO_CONTENT)
|
||||||
|
async def dismiss_duplicate_books(
|
||||||
|
self, books_service: BookService, data: s.DuplicateDismissal
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Record that two books are not the same book.
|
||||||
|
|
||||||
|
Without this the review screen proposes the same wrong pair forever, which is
|
||||||
|
how a reader learns to stop looking at it.
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
data: The two book IDs. Order does not matter.
|
||||||
|
|
||||||
|
Injected Dependencies:
|
||||||
|
books_service: The book service for database operations.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if the two IDs are the same or either book is unknown.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await books_service.dismiss_duplicates(data.book_a_id, data.book_b_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||||
|
|
||||||
|
@delete(path="duplicate-books/dismissals")
|
||||||
|
async def restore_duplicate_books(
|
||||||
|
self, books_service: BookService, book_a_id: int, book_b_id: int
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Undo a dismissal, so the pair is proposed again.
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
book_a_id: One of the two books.
|
||||||
|
book_b_id: The other. Order does not matter.
|
||||||
|
|
||||||
|
Injected Dependencies:
|
||||||
|
books_service: The book service for database operations.
|
||||||
|
"""
|
||||||
|
await books_service.restore_duplicates(book_a_id, book_b_id)
|
||||||
|
|
||||||
|
# A question, not a change: 200 rather than the 201 a POST would default to.
|
||||||
|
@post(path="duplicate-files", status_code=HTTP_200_OK)
|
||||||
|
async def check_duplicate_files(
|
||||||
|
self,
|
||||||
|
books_service: BookService,
|
||||||
|
library: m.Library,
|
||||||
|
data: list[s.FileFingerprint],
|
||||||
|
) -> list[s.DuplicateFileRead]:
|
||||||
|
"""
|
||||||
|
Report which of the given files the library already holds.
|
||||||
|
|
||||||
|
Lets a client ask before it uploads anything, which is the difference between
|
||||||
|
re-sending a folder of books and re-sending twelve kilobytes of hashes.
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
library_id: The library to check against.
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
data: Hash and size for each file, optionally with the name to echo back.
|
||||||
|
|
||||||
|
Injected Dependencies:
|
||||||
|
books_service: The book service for database operations.
|
||||||
|
library: The library to check against.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One entry per submitted file that is already stored. Files that are not
|
||||||
|
are absent.
|
||||||
|
"""
|
||||||
|
matches = await books_service.find_duplicate_files(
|
||||||
|
((item.hash, item.size) for item in data), library
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
s.DuplicateFileRead(
|
||||||
|
filename=item.filename or match.filename,
|
||||||
|
hash=match.hash,
|
||||||
|
size=match.size,
|
||||||
|
library_id=match.library_id,
|
||||||
|
book_id=match.book_id,
|
||||||
|
book_title=match.book_title,
|
||||||
|
)
|
||||||
|
for item in data
|
||||||
|
if (match := matches.get((item.hash, item.size))) is not None
|
||||||
|
]
|
||||||
|
|
||||||
@get(path="/{book_id:int}")
|
@get(path="/{book_id:int}")
|
||||||
async def get_book_by_id(
|
async def get_book_by_id(
|
||||||
@@ -303,13 +527,20 @@ class BookController(Controller):
|
|||||||
],
|
],
|
||||||
library: m.Library,
|
library: m.Library,
|
||||||
books_service: BookService,
|
books_service: BookService,
|
||||||
|
allow_duplicates: bool = False,
|
||||||
) -> s.BookRead:
|
) -> s.BookRead:
|
||||||
"""
|
"""
|
||||||
Add files to an existing book.
|
Add files to an existing book.
|
||||||
|
|
||||||
|
A file the book already carries is ignored, so re-sending one is harmless.
|
||||||
|
|
||||||
Path Parameters:
|
Path Parameters:
|
||||||
book_id: The ID of the book to modify
|
book_id: The ID of the book to modify
|
||||||
|
|
||||||
|
Query Parameters:
|
||||||
|
allow_duplicates: If True, store the files even if the library already
|
||||||
|
holds them.
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
files: The files to add to the book
|
files: The files to add to the book
|
||||||
|
|
||||||
@@ -320,9 +551,17 @@ class BookController(Controller):
|
|||||||
Returns:
|
Returns:
|
||||||
The modified book
|
The modified book
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 409 if a file is already stored under a different book.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
await books_service.add_files(book_id, data, library)
|
try:
|
||||||
|
await books_service.add_files(
|
||||||
|
book_id, data, library, allow_duplicates=allow_duplicates
|
||||||
|
)
|
||||||
|
except DuplicateFilesError as exc:
|
||||||
|
raise _duplicate_conflict(exc)
|
||||||
|
|
||||||
book = await books_service.get(book_id)
|
book = await books_service.get(book_id)
|
||||||
return books_service.to_schema(book, schema_type=s.BookRead)
|
return books_service.to_schema(book, schema_type=s.BookRead)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
# src/chitai/controllers/library.py
|
# src/chitai/controllers/library.py
|
||||||
|
|
||||||
# Standard library
|
# Standard library
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
# Third-party libraries
|
# Third-party libraries
|
||||||
|
import aiofiles
|
||||||
|
from aiofiles import os as aios
|
||||||
from litestar import Controller, post, get, patch, delete
|
from litestar import Controller, post, get, patch, delete
|
||||||
from litestar.params import Dependency
|
from litestar.enums import RequestEncodingType
|
||||||
|
from litestar.params import Body, Dependency
|
||||||
from litestar.exceptions import HTTPException
|
from litestar.exceptions import HTTPException
|
||||||
|
from litestar.status_codes import HTTP_200_OK, HTTP_202_ACCEPTED
|
||||||
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
from advanced_alchemy.extensions.litestar.providers import create_service_dependencies
|
||||||
from advanced_alchemy.service.pagination import OffsetPagination
|
from advanced_alchemy.service.pagination import OffsetPagination
|
||||||
from advanced_alchemy.service import FilterTypeT
|
from advanced_alchemy.service import FilterTypeT
|
||||||
@@ -14,10 +22,21 @@ from advanced_alchemy.service import FilterTypeT
|
|||||||
# Local imports
|
# Local imports
|
||||||
from chitai.database import models as m
|
from chitai.database import models as m
|
||||||
from chitai.services import LibraryService
|
from chitai.services import LibraryService
|
||||||
from chitai.schemas.library import LibraryCreate, LibraryRead
|
from chitai.schemas.library import (
|
||||||
|
CalibreArchiveUpload,
|
||||||
|
CalibreImportRead,
|
||||||
|
LibraryCreate,
|
||||||
|
LibraryRead,
|
||||||
|
)
|
||||||
|
from chitai.services.calibre import CalibreLibraryError, extract_calibre_archive
|
||||||
|
from chitai.services.calibre_import import registry
|
||||||
from chitai.services.utils import DirectoryDoesNotExist
|
from chitai.services.utils import DirectoryDoesNotExist
|
||||||
|
|
||||||
|
|
||||||
|
# How much of an uploaded archive is held in memory at a time on its way to disk.
|
||||||
|
UPLOAD_CHUNK_SIZE = 262144 # 256 KiB
|
||||||
|
|
||||||
|
|
||||||
class LibraryController(Controller):
|
class LibraryController(Controller):
|
||||||
"""Controller for managing library operations."""
|
"""Controller for managing library operations."""
|
||||||
|
|
||||||
@@ -74,3 +93,147 @@ class LibraryController(Controller):
|
|||||||
return library_service.to_schema(
|
return library_service.to_schema(
|
||||||
results, total, filters, schema_type=LibraryRead
|
results, total, filters, schema_type=LibraryRead
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@post(
|
||||||
|
path="{library_id:int}/imports/calibre/upload",
|
||||||
|
status_code=HTTP_202_ACCEPTED,
|
||||||
|
request_max_body_size=None,
|
||||||
|
)
|
||||||
|
async def upload_calibre_import(
|
||||||
|
self,
|
||||||
|
library_service: LibraryService,
|
||||||
|
library_id: int,
|
||||||
|
data: Annotated[
|
||||||
|
CalibreArchiveUpload, Body(media_type=RequestEncodingType.MULTI_PART)
|
||||||
|
],
|
||||||
|
) -> CalibreImportRead:
|
||||||
|
"""
|
||||||
|
Import a zipped Calibre library that was uploaded rather than named on disk.
|
||||||
|
|
||||||
|
For the case where the library is not on the server: zip the Calibre folder and
|
||||||
|
send it. Unpacked into a temp directory the job owns and deletes when it ends —
|
||||||
|
by which time the books worth keeping have been copied into the library proper.
|
||||||
|
|
||||||
|
The server-folder route stays the one for a very large library. This one has to
|
||||||
|
carry the whole archive over HTTP first.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
library_id: The library to import into.
|
||||||
|
|
||||||
|
Request Body:
|
||||||
|
data: The `.zip` holding the Calibre library.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The job, already running.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 400 if the archive is not a zip, holds no `metadata.db`,
|
||||||
|
names an entry outside itself, or would not fit on disk; 409 if an
|
||||||
|
import into this library is already running.
|
||||||
|
"""
|
||||||
|
library = await self._importable(library_service, library_id)
|
||||||
|
|
||||||
|
if (running := registry.running_for(library_id)) is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="An import into this library is already running",
|
||||||
|
extra={"job_id": running.id},
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace = Path(await asyncio.to_thread(tempfile.mkdtemp))
|
||||||
|
|
||||||
|
try:
|
||||||
|
archive = workspace / "upload.zip"
|
||||||
|
await data.archive.seek(0)
|
||||||
|
|
||||||
|
async with aiofiles.open(archive, "wb") as destination:
|
||||||
|
while chunk := await data.archive.read(UPLOAD_CHUNK_SIZE):
|
||||||
|
await destination.write(chunk)
|
||||||
|
|
||||||
|
unpacked = workspace / "library"
|
||||||
|
unpacked.mkdir()
|
||||||
|
|
||||||
|
catalogue = await extract_calibre_archive(archive, unpacked)
|
||||||
|
|
||||||
|
# The archive itself is dead weight once unpacked, and the library it
|
||||||
|
# unpacked to can be large.
|
||||||
|
await aios.remove(archive)
|
||||||
|
except CalibreLibraryError as exc:
|
||||||
|
await asyncio.to_thread(shutil.rmtree, workspace, True)
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except Exception:
|
||||||
|
await asyncio.to_thread(shutil.rmtree, workspace, True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
job = registry.start(
|
||||||
|
library,
|
||||||
|
catalogue,
|
||||||
|
workspace=workspace,
|
||||||
|
label=data.archive.filename or "uploaded archive",
|
||||||
|
allow_duplicates=data.allow_duplicates,
|
||||||
|
)
|
||||||
|
|
||||||
|
return CalibreImportRead.model_validate(job)
|
||||||
|
|
||||||
|
@get(path="imports/{job_id:str}")
|
||||||
|
async def get_import(self, job_id: str) -> CalibreImportRead:
|
||||||
|
"""
|
||||||
|
Report on an import.
|
||||||
|
|
||||||
|
Polled by the client while a run is going. Jobs are held in memory, so this is
|
||||||
|
answered by the process that started it — see `services/calibre_import.py`.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
job_id: The job to report on.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if this process holds no such job.
|
||||||
|
"""
|
||||||
|
if (job := registry.get(job_id)) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No such import")
|
||||||
|
|
||||||
|
return CalibreImportRead.model_validate(job)
|
||||||
|
|
||||||
|
@delete(path="imports/{job_id:str}", status_code=HTTP_200_OK)
|
||||||
|
async def cancel_import(self, job_id: str) -> CalibreImportRead:
|
||||||
|
"""
|
||||||
|
Ask an import to stop after the book it is on.
|
||||||
|
|
||||||
|
Deliberately not an abort: a book abandoned mid-copy would leave files on disk
|
||||||
|
with no row describing them. Whatever it has imported stays imported.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
job_id: The job to stop.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if this process holds no such job.
|
||||||
|
"""
|
||||||
|
if (job := registry.cancel(job_id)) is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No such import")
|
||||||
|
|
||||||
|
return CalibreImportRead.model_validate(job)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _importable(
|
||||||
|
library_service: LibraryService, library_id: int
|
||||||
|
) -> m.Library:
|
||||||
|
"""
|
||||||
|
The library, if it can be imported into at all.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 404 if there is no such library, 400 if it is read-only —
|
||||||
|
a read-only library points at a tree Chitai does not own, so copying
|
||||||
|
books into it would write into somebody else's directory.
|
||||||
|
"""
|
||||||
|
library = await library_service.get_one_or_none(m.Library.id == library_id)
|
||||||
|
|
||||||
|
if library is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No such library")
|
||||||
|
|
||||||
|
if library.read_only:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This library is read-only, so nothing can be imported into it",
|
||||||
|
)
|
||||||
|
|
||||||
|
return library
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from .book import Book, Identifier, FileMetadata
|
|||||||
from .book_list import BookList, BookListLink
|
from .book_list import BookList, BookListLink
|
||||||
from .book_progress import BookProgress
|
from .book_progress import BookProgress
|
||||||
from .book_series import BookSeries
|
from .book_series import BookSeries
|
||||||
|
from .duplicate_dismissal import DuplicateDismissal
|
||||||
from .kosync_device import KosyncDevice
|
from .kosync_device import KosyncDevice
|
||||||
from .kosync_progress import KosyncProgress
|
from .kosync_progress import KosyncProgress
|
||||||
from .library import Library
|
from .library import Library
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy import ColumnElement, ForeignKey, UniqueConstraint
|
|||||||
from sqlalchemy.orm import Mapped
|
from sqlalchemy.orm import Mapped
|
||||||
from sqlalchemy.orm import mapped_column
|
from sqlalchemy.orm import mapped_column
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.orm import validates
|
||||||
|
|
||||||
from advanced_alchemy.base import BigIntAuditBase, BigIntBase
|
from advanced_alchemy.base import BigIntAuditBase, BigIntBase
|
||||||
from advanced_alchemy.mixins import UniqueMixin
|
from advanced_alchemy.mixins import UniqueMixin
|
||||||
@@ -16,18 +17,55 @@ if TYPE_CHECKING:
|
|||||||
class Author(BigIntAuditBase, UniqueMixin):
|
class Author(BigIntAuditBase, UniqueMixin):
|
||||||
__tablename__ = "authors"
|
__tablename__ = "authors"
|
||||||
|
|
||||||
|
# Always the canonical form — see `_canonicalize`. Extractors hand over whatever
|
||||||
|
# the file happened to say: "Newman, Sam;" from a `DC:creator` list, or
|
||||||
|
# "Sam Newman.epub" from a filename. Storing those verbatim is how one person ends
|
||||||
|
# up as several rows in the sidebar.
|
||||||
name: Mapped[str] = mapped_column(unique=True, index=True)
|
name: Mapped[str] = mapped_column(unique=True, index=True)
|
||||||
|
|
||||||
|
# Kept current by `_canonicalize` too — never assign it directly. Not unique: two
|
||||||
|
# spellings that survive canonicalization, "Steve Mcconnell" and "Steve McConnell",
|
||||||
|
# are still one person to a reader, which is what this column exists to express.
|
||||||
|
normalized_name: Mapped[str] = mapped_column(default="", index=True)
|
||||||
|
|
||||||
description: Mapped[Optional[str]]
|
description: Mapped[Optional[str]]
|
||||||
|
|
||||||
|
@validates("name")
|
||||||
|
def _canonicalize(self, _key: str, name: str) -> str:
|
||||||
|
"""
|
||||||
|
Store the tidied name, and derive the matching key from it.
|
||||||
|
|
||||||
|
A validator so no write can get around it, and `unique_hash` / `unique_filter`
|
||||||
|
below tidy the same way so `as_unique_async` looks the row up under the name it
|
||||||
|
would actually be stored as. All three have to agree: if the lookup used the
|
||||||
|
raw name and the insert used the tidy one, every variant spelling would miss
|
||||||
|
the existing row and then collide with it on the unique index.
|
||||||
|
"""
|
||||||
|
# Imported here rather than at module scope: `chitai.services.matching` cannot
|
||||||
|
# be reached without initialising the `chitai.services` package, which imports
|
||||||
|
# the services, which import this module.
|
||||||
|
from chitai.services.matching import format_author_name, normalize_author
|
||||||
|
|
||||||
|
name = format_author_name(name)
|
||||||
|
self.normalized_name = normalize_author(name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _tidy(cls, name: str) -> str:
|
||||||
|
"""The name as `_canonicalize` would store it."""
|
||||||
|
from chitai.services.matching import format_author_name
|
||||||
|
|
||||||
|
return format_author_name(name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def unique_hash(cls, name: str) -> Hashable:
|
def unique_hash(cls, name: str) -> Hashable:
|
||||||
"""Generate a unique hash for deduplication."""
|
"""Generate a unique hash for deduplication."""
|
||||||
return name
|
return cls._tidy(name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def unique_filter(cls, name: str) -> ColumnElement[bool]:
|
def unique_filter(cls, name: str) -> ColumnElement[bool]:
|
||||||
"""SQL filter for finding existing records."""
|
"""SQL filter for finding existing records."""
|
||||||
return cls.name == name
|
return cls.name == cls._tidy(name)
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Author({self.name!r})"
|
return f"Author({self.name!r})"
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ from datetime import date
|
|||||||
from typing import TYPE_CHECKING, Any, Optional
|
from typing import TYPE_CHECKING, Any, Optional
|
||||||
|
|
||||||
from sqlalchemy import Index, ForeignKey, UniqueConstraint
|
from sqlalchemy import Index, ForeignKey, UniqueConstraint
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship, validates
|
||||||
from sqlalchemy.orm import mapped_column
|
|
||||||
from sqlalchemy.orm import relationship
|
|
||||||
from sqlalchemy.ext.orderinglist import ordering_list
|
from sqlalchemy.ext.orderinglist import ordering_list
|
||||||
from sqlalchemy.ext.associationproxy import association_proxy
|
from sqlalchemy.ext.associationproxy import association_proxy
|
||||||
from sqlalchemy.ext.associationproxy import AssociationProxy
|
from sqlalchemy.ext.associationproxy import AssociationProxy
|
||||||
from sqlalchemy.orm.collections import attribute_keyed_dict
|
|
||||||
|
|
||||||
from advanced_alchemy.base import BigIntAuditBase, BigIntBase
|
from advanced_alchemy.base import BigIntAuditBase, BigIntBase
|
||||||
|
|
||||||
@@ -44,6 +41,13 @@ class Book(BigIntAuditBase):
|
|||||||
library: Mapped["Library"] = relationship(back_populates="books")
|
library: Mapped["Library"] = relationship(back_populates="books")
|
||||||
|
|
||||||
title: Mapped[str]
|
title: Mapped[str]
|
||||||
|
|
||||||
|
# Kept current by `_normalize_title` below — never assign it directly.
|
||||||
|
#
|
||||||
|
# Deliberately not unique: two spellings collapsing onto one value is the whole
|
||||||
|
# point of the column, and a second edition is allowed to exist.
|
||||||
|
normalized_title: Mapped[str] = mapped_column(default="", index=True)
|
||||||
|
|
||||||
subtitle: Mapped[Optional[str]]
|
subtitle: Mapped[Optional[str]]
|
||||||
description: Mapped[Optional[str]]
|
description: Mapped[Optional[str]]
|
||||||
published_date: Mapped[Optional[date]]
|
published_date: Mapped[Optional[date]]
|
||||||
@@ -111,6 +115,24 @@ class Book(BigIntAuditBase):
|
|||||||
def progress(self) -> Optional["BookProgress"]:
|
def progress(self) -> Optional["BookProgress"]:
|
||||||
return self.progress_records[0] if self.progress_records else None
|
return self.progress_records[0] if self.progress_records else None
|
||||||
|
|
||||||
|
@validates("title")
|
||||||
|
def _normalize_title(self, _key: str, title: str) -> str:
|
||||||
|
"""
|
||||||
|
Derive `normalized_title` from whatever writes the title.
|
||||||
|
|
||||||
|
A validator rather than a service call because `BookService` sets titles from
|
||||||
|
at least three places — `to_model_on_create`, `to_model_on_update` and the
|
||||||
|
`setattr` loop in `_populate_with_unique_relationships` — and a fourth would
|
||||||
|
otherwise leave the key silently stale.
|
||||||
|
"""
|
||||||
|
# Imported here rather than at module scope: `chitai.services.matching` cannot
|
||||||
|
# be reached without initialising the `chitai.services` package, which imports
|
||||||
|
# the services, which import this module.
|
||||||
|
from chitai.services.matching import normalize_title
|
||||||
|
|
||||||
|
self.normalized_title = normalize_title(title)
|
||||||
|
return title
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Book({self.title=!r})"
|
return f"Book({self.title=!r})"
|
||||||
|
|
||||||
@@ -132,6 +154,24 @@ class Identifier(BigIntBase):
|
|||||||
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
|
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
|
||||||
value: Mapped[str]
|
value: Mapped[str]
|
||||||
|
|
||||||
|
# Kept current by `_normalize` below — never assign it directly. Null for an
|
||||||
|
# identifier that cannot carry a match: a per-build UUID, or an ISBN that fails
|
||||||
|
# its own checksum.
|
||||||
|
normalized_value: Mapped[Optional[str]] = mapped_column(index=True)
|
||||||
|
|
||||||
|
@validates("name", "value")
|
||||||
|
def _normalize(self, key: str, value: str) -> str:
|
||||||
|
"""Recompute `normalized_value` whenever either half of the pair changes."""
|
||||||
|
from chitai.services.matching import normalize_identifier
|
||||||
|
|
||||||
|
name = value if key == "name" else self.name
|
||||||
|
raw = value if key == "value" else self.value
|
||||||
|
|
||||||
|
self.normalized_value = (
|
||||||
|
normalize_identifier(name, raw) if name and raw else None
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"Identifier({self.name!r} : {self.value!r})"
|
return f"Identifier({self.name!r} : {self.value!r})"
|
||||||
|
|
||||||
@@ -139,6 +179,15 @@ class Identifier(BigIntBase):
|
|||||||
class FileMetadata(BigIntBase):
|
class FileMetadata(BigIntBase):
|
||||||
__tablename__ = "file_metadata"
|
__tablename__ = "file_metadata"
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Deliberately not unique. The hash is KOReader's partial MD5, which samples
|
||||||
|
# 12 KiB of the file, so two genuinely different files can collide — and an
|
||||||
|
# existing database may already hold duplicates, which a unique index would
|
||||||
|
# refuse to build over. Duplicate detection pairs it with `size` and treats a
|
||||||
|
# match as advisory, so this only has to make the lookup cheap.
|
||||||
|
Index("ix_file_metadata_hash", "hash"),
|
||||||
|
)
|
||||||
|
|
||||||
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
|
book_id: Mapped[int] = mapped_column(ForeignKey("books.id", ondelete="cascade"))
|
||||||
book: Mapped[Book] = relationship(back_populates="files")
|
book: Mapped[Book] = relationship(back_populates="files")
|
||||||
hash: Mapped[str]
|
hash: Mapped[str]
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from sqlalchemy import ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from advanced_alchemy.base import BigIntBase
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateDismissal(BigIntBase):
|
||||||
|
"""
|
||||||
|
Two books a reader has said are not the same book.
|
||||||
|
|
||||||
|
Title and author matching is probabilistic, so it will keep proposing a second
|
||||||
|
edition, a translation and a sequel that shares its predecessor's name. A review
|
||||||
|
screen with no way to disagree with it nags forever, which is how people learn to
|
||||||
|
ignore a screen.
|
||||||
|
|
||||||
|
The pair is stored ordered — `book_a_id` is always the lower id — so "A and B" and
|
||||||
|
"B and A" are one row and the unique constraint can do its job. Use `pair()` rather
|
||||||
|
than assigning the columns directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "duplicate_dismissals"
|
||||||
|
__table_args__ = (UniqueConstraint("book_a_id", "book_b_id"),)
|
||||||
|
|
||||||
|
book_a_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("books.id", ondelete="cascade"), index=True
|
||||||
|
)
|
||||||
|
book_b_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("books.id", ondelete="cascade"), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def pair(first: int, second: int) -> tuple[int, int]:
|
||||||
|
"""The two book ids in the order this table stores them."""
|
||||||
|
return (first, second) if first <= second else (second, first)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"DuplicateDismissal({self.book_a_id!r}, {self.book_b_id!r})"
|
||||||
@@ -4,7 +4,15 @@ from .book import (
|
|||||||
BookProgressCreate,
|
BookProgressCreate,
|
||||||
BookProgressRead,
|
BookProgressRead,
|
||||||
BooksCreateFromFiles,
|
BooksCreateFromFiles,
|
||||||
|
BooksUploadResult,
|
||||||
|
BookMerge,
|
||||||
BookMetadataUpdate,
|
BookMetadataUpdate,
|
||||||
|
DuplicateBookGroupRead,
|
||||||
|
DuplicateBookRead,
|
||||||
|
DuplicateDismissal,
|
||||||
|
DuplicateFileRead,
|
||||||
|
FileFingerprint,
|
||||||
|
PossibleDuplicateRead,
|
||||||
FileMetadataRead,
|
FileMetadataRead,
|
||||||
BookSeriesRead,
|
BookSeriesRead,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ class FileMetadataRead(BaseModel):
|
|||||||
path: str
|
path: str
|
||||||
hash: str
|
hash: str
|
||||||
size: int
|
size: int
|
||||||
content_type: str
|
|
||||||
|
# Nullable, though every ingest path now writes one through
|
||||||
|
# `guess_content_type`. Rows predating it can hold null, and a required field here
|
||||||
|
# turns one of those into a 500 on a book the reader can otherwise open.
|
||||||
|
content_type: str | None = None
|
||||||
|
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
@@ -129,6 +133,83 @@ class BooksCreateFromFiles(BaseModel):
|
|||||||
model_config = ConfigDict(arbitrary_types_allowed=True)
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
|
||||||
|
class FileFingerprint(BaseModel):
|
||||||
|
"""What a client can say about a file it has not uploaded yet."""
|
||||||
|
|
||||||
|
hash: str
|
||||||
|
size: int
|
||||||
|
filename: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateFileRead(BaseModel):
|
||||||
|
"""A file that was not stored because the library already holds its bytes."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
filename: str
|
||||||
|
hash: str
|
||||||
|
size: int
|
||||||
|
library_id: int
|
||||||
|
|
||||||
|
# Null when the match was another file in the same upload, which has no row yet.
|
||||||
|
book_id: int | None = None
|
||||||
|
book_title: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateBookRead(BaseModel):
|
||||||
|
"""
|
||||||
|
A stored book that may be the same book as another one.
|
||||||
|
|
||||||
|
Unlike `DuplicateFileRead` this is a guess: the evidence is metadata two editions
|
||||||
|
of one work legitimately share. Nothing was refused on the strength of it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
book_id: int
|
||||||
|
title: str
|
||||||
|
authors: list[str]
|
||||||
|
library_id: int
|
||||||
|
cover_image: Path | None = None
|
||||||
|
|
||||||
|
# Why it matched: "identifier" and/or "title-author".
|
||||||
|
matched_on: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class PossibleDuplicateRead(BaseModel):
|
||||||
|
"""A book that was imported, together with what it might be a second copy of."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
book_id: int
|
||||||
|
title: str
|
||||||
|
candidates: list[DuplicateBookRead]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateBookGroupRead(BaseModel):
|
||||||
|
"""Books the library holds that all look like copies of one book."""
|
||||||
|
|
||||||
|
books: list[DuplicateBookRead]
|
||||||
|
|
||||||
|
|
||||||
|
class DuplicateDismissal(BaseModel):
|
||||||
|
"""Two books a reader is saying are not the same book."""
|
||||||
|
|
||||||
|
book_a_id: int
|
||||||
|
book_b_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class BooksUploadResult(BaseModel):
|
||||||
|
"""The outcome of a multi-file upload: what was created, and what was skipped."""
|
||||||
|
|
||||||
|
created: list["BookRead"]
|
||||||
|
skipped: list[DuplicateFileRead]
|
||||||
|
|
||||||
|
# Created, not skipped — these are books that went in and look like something the
|
||||||
|
# library already had. The reader decides what to do about it.
|
||||||
|
possible_duplicates: list[PossibleDuplicateRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class BookMetadataUpdate(BaseModel):
|
class BookMetadataUpdate(BaseModel):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
subtitle: str | None = None
|
subtitle: str | None = None
|
||||||
@@ -170,6 +251,20 @@ class BookMetadataUpdate(BaseModel):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class BookMerge(BaseModel):
|
||||||
|
"""
|
||||||
|
Fold several books into one.
|
||||||
|
|
||||||
|
`metadata` is the reader's resolution of the fields the records disagreed on.
|
||||||
|
Anything it does not name keeps the survivor's value — merging metadata is a
|
||||||
|
judgement, so nothing is guessed on the caller's behalf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
survivor_id: int
|
||||||
|
merged_ids: list[int]
|
||||||
|
metadata: Optional["BookMetadataUpdate"] = None
|
||||||
|
|
||||||
|
|
||||||
class BookProgressCreate(BaseModel):
|
class BookProgressCreate(BaseModel):
|
||||||
percentage: float
|
percentage: float
|
||||||
epub_cfi: str | None = None
|
epub_cfi: str | None = None
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from pydantic import BaseModel, Field, computed_field
|
from pydantic import BaseModel, ConfigDict, Field, SkipValidation, computed_field
|
||||||
|
from litestar.datastructures import UploadFile
|
||||||
from advanced_alchemy.utils.text import slugify
|
from advanced_alchemy.utils.text import slugify
|
||||||
|
|
||||||
class LibraryCreate(BaseModel):
|
class LibraryCreate(BaseModel):
|
||||||
@@ -27,6 +28,57 @@ class LibraryRead(BaseModel):
|
|||||||
total: int | None = None
|
total: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreArchiveUpload(BaseModel):
|
||||||
|
"""
|
||||||
|
A zipped Calibre library.
|
||||||
|
|
||||||
|
The only way in through the API: a desktop Calibre install is usually not on the
|
||||||
|
server at all. Importing from a path the server can already see is a server-side
|
||||||
|
operation, and stays one — `litestar calibre-import` does that.
|
||||||
|
"""
|
||||||
|
|
||||||
|
archive: Annotated[UploadFile, SkipValidation]
|
||||||
|
allow_duplicates: bool = False
|
||||||
|
|
||||||
|
model_config = ConfigDict(arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportFailureRead(BaseModel):
|
||||||
|
"""A book the import could not store."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
calibre_id: int
|
||||||
|
title: str
|
||||||
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreImportRead(BaseModel):
|
||||||
|
"""A running or finished import."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: str
|
||||||
|
library_id: int
|
||||||
|
source: str
|
||||||
|
state: str
|
||||||
|
|
||||||
|
total: int
|
||||||
|
processed: int
|
||||||
|
created: int
|
||||||
|
skipped: int
|
||||||
|
failed: int
|
||||||
|
|
||||||
|
current_title: str | None = None
|
||||||
|
failures: list[ImportFailureRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
# A count, not the records. The library's duplicates screen is what shows them.
|
||||||
|
possible_duplicates: int = 0
|
||||||
|
|
||||||
|
# Set when the run itself broke, as opposed to individual books failing.
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class LibraryUpdate(BaseModel):
|
class LibraryUpdate(BaseModel):
|
||||||
name: str | None
|
name: str | None
|
||||||
root_path: str | None
|
root_path: str | None
|
||||||
|
|||||||
+1812
-70
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,570 @@
|
|||||||
|
# src/chitai/services/calibre.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
Read a Calibre library.
|
||||||
|
|
||||||
|
This knows about `metadata.db` and the tree beside it, and nothing about `Book`,
|
||||||
|
`BookService` or a database session — it is a file-format reader, and it is testable
|
||||||
|
without Postgres or an app. Interpretation belongs to whoever imports what it returns:
|
||||||
|
identifiers come back exactly as Calibre wrote them, not folded onto Chitai's schemes.
|
||||||
|
|
||||||
|
Things about Calibre that are load-bearing here:
|
||||||
|
|
||||||
|
- **Never query the views.** `meta` and the `tag_browser_*` family call SQLite functions
|
||||||
|
Calibre registers from Python at connection time, so `SELECT * FROM meta` fails with
|
||||||
|
`no such function: sortconcat`. Only base tables are touched below.
|
||||||
|
- **An unknown date is a sentinel, not a null** — `0101-01-01`, Calibre's
|
||||||
|
`UNDEFINED_DATE`. It parses fine as a date, so nothing complains; it just makes every
|
||||||
|
book without a publication date look like it was published in the year 101.
|
||||||
|
- **`data.name` is not the title.** It is the on-disk stem, truncated to Calibre's
|
||||||
|
filename limit and sanitised, so the file is `The Project Gutenberg eBook #33283_
|
||||||
|
Calcul - Silvanus Phillips Thompson.pdf` for a book titled `The Project Gutenberg
|
||||||
|
eBook #33283: Calculus Made Easy, 2nd Edition`. Names locate files; the database
|
||||||
|
carries the metadata.
|
||||||
|
- **`authors.name` escapes a comma as `|`**, which Calibre reverses on read
|
||||||
|
(`AuthorsTable.unserialize` in its `db/tables.py`).
|
||||||
|
- **`series_index` defaults to 1.0 whether or not the book is in a series**, so a
|
||||||
|
position is only meaningful alongside a series.
|
||||||
|
- **`books_pages_link` is recent and often empty.** It is treated as optional both ways:
|
||||||
|
the table may not exist, and where it does the rows are frequently `pages = 0` with
|
||||||
|
`needs_scan = 1`.
|
||||||
|
|
||||||
|
Nothing walks the tree: every file is located through `books.path`, which is why
|
||||||
|
`.caltrash` — where Calibre keeps deleted books, still on disk — cannot be picked up by
|
||||||
|
accident.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime
|
||||||
|
from html.parser import HTMLParser
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
|
||||||
|
|
||||||
|
METADATA_DB = "metadata.db"
|
||||||
|
COVER_FILENAME = "cover.jpg"
|
||||||
|
|
||||||
|
# Calibre writes `0101-01-01` for "no date". Any year this early is that sentinel rather
|
||||||
|
# than a publication date somebody meant.
|
||||||
|
EARLIEST_REAL_YEAR = 1000
|
||||||
|
|
||||||
|
# The sidecars a WAL-mode database keeps beside itself. Copied along with it so the
|
||||||
|
# snapshot can be recovered, since Calibre may be running while this reads.
|
||||||
|
_DATABASE_SIDECARS = ("-wal", "-shm")
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreLibraryError(Exception):
|
||||||
|
"""The library cannot be read at all — wrong directory, or no catalogue in it."""
|
||||||
|
|
||||||
|
|
||||||
|
# How far down an archive to look for `metadata.db`. Zipping a Calibre library gives
|
||||||
|
# either the directory itself or its contents, and a file manager may add a wrapper
|
||||||
|
# folder on top, so two levels of nesting is normal and more is somebody's backup tree.
|
||||||
|
_ARCHIVE_SEARCH_DEPTH = 3
|
||||||
|
|
||||||
|
# Extraction is refused unless the destination has the uncompressed size plus this
|
||||||
|
# much headroom. Filling the disk would take the whole application down, not just the
|
||||||
|
# import.
|
||||||
|
_DISK_HEADROOM = 256 * 1024 * 1024 # 256 MiB
|
||||||
|
|
||||||
|
|
||||||
|
def _archive_members(archive: zipfile.ZipFile) -> list[zipfile.ZipInfo]:
|
||||||
|
"""
|
||||||
|
The entries worth extracting, refusing any that would escape the destination.
|
||||||
|
|
||||||
|
`ZipFile.extract` does sanitise names, but relying on that silently is how the next
|
||||||
|
person to swap the extraction call reintroduces zip-slip. An archive naming
|
||||||
|
`../../etc/anything` is malformed or hostile, and either way there is nothing to
|
||||||
|
salvage by continuing.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CalibreLibraryError: If any entry points outside the archive root.
|
||||||
|
"""
|
||||||
|
members = []
|
||||||
|
|
||||||
|
for member in archive.infolist():
|
||||||
|
if member.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
name = PurePosixPath(member.filename)
|
||||||
|
|
||||||
|
if name.is_absolute() or ".." in name.parts:
|
||||||
|
raise CalibreLibraryError(
|
||||||
|
f"The archive contains an entry outside itself: {member.filename!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
members.append(member)
|
||||||
|
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_calibre_archive(archive: Path, destination: Path) -> Path:
|
||||||
|
"""
|
||||||
|
Unpack a zipped Calibre library and find the catalogue inside it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
archive: The `.zip` to unpack.
|
||||||
|
destination: An empty directory to unpack into. The caller owns it and is
|
||||||
|
responsible for removing it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The directory holding `metadata.db`, which is what `CalibreLibrary` takes.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CalibreLibraryError: If the file is not a zip, names an entry outside itself,
|
||||||
|
would not fit on disk, or holds no `metadata.db`.
|
||||||
|
"""
|
||||||
|
return await asyncio.to_thread(_extract_calibre_archive, archive, destination)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_calibre_archive(archive: Path, destination: Path) -> Path:
|
||||||
|
if not zipfile.is_zipfile(archive):
|
||||||
|
raise CalibreLibraryError(
|
||||||
|
"That is not a zip file. A Calibre library has to be zipped, not tarred."
|
||||||
|
)
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive) as opened:
|
||||||
|
members = _archive_members(opened)
|
||||||
|
|
||||||
|
if not any(
|
||||||
|
PurePosixPath(member.filename).name == METADATA_DB for member in members
|
||||||
|
):
|
||||||
|
raise CalibreLibraryError(
|
||||||
|
f"The archive holds no {METADATA_DB}, so it is not a Calibre library"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Checked before writing rather than discovered part-way through: a full disk
|
||||||
|
# takes the whole application down, and the number is in the archive already.
|
||||||
|
needed = sum(member.file_size for member in members)
|
||||||
|
free = shutil.disk_usage(destination).free
|
||||||
|
|
||||||
|
if needed + _DISK_HEADROOM > free:
|
||||||
|
raise CalibreLibraryError(
|
||||||
|
f"Unpacking needs {needed // (1024 * 1024)} MiB and only "
|
||||||
|
f"{free // (1024 * 1024)} MiB is free"
|
||||||
|
)
|
||||||
|
|
||||||
|
opened.extractall(destination, members=members)
|
||||||
|
|
||||||
|
return _find_catalogue(destination)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_catalogue(root: Path) -> Path:
|
||||||
|
"""The shallowest directory under `root` holding a `metadata.db`."""
|
||||||
|
candidates = sorted(
|
||||||
|
(path.parent for path in root.rglob(METADATA_DB) if path.is_file()),
|
||||||
|
key=lambda path: len(path.relative_to(root).parts),
|
||||||
|
)
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
if len(candidate.relative_to(root).parts) <= _ARCHIVE_SEARCH_DEPTH:
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
raise CalibreLibraryError(
|
||||||
|
f"No {METADATA_DB} within {_ARCHIVE_SEARCH_DEPTH} levels of the archive root"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CalibreFile:
|
||||||
|
"""One row of Calibre's `data` table: a book in one format."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
"""Absolute path, resolved against the library root. Not checked for existence."""
|
||||||
|
|
||||||
|
format: str
|
||||||
|
"""As Calibre stores it, upper case: `EPUB`, `PDF`, `AZW3`."""
|
||||||
|
|
||||||
|
size: int
|
||||||
|
"""`data.uncompressed_size` — Calibre's claim, not a fresh stat."""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CalibreBook:
|
||||||
|
"""One book, with everything Chitai has a column for and nothing it does not."""
|
||||||
|
|
||||||
|
calibre_id: int
|
||||||
|
uuid: str
|
||||||
|
title: str
|
||||||
|
authors: list[str] = field(default_factory=list)
|
||||||
|
description: str | None = None
|
||||||
|
published_date: date | None = None
|
||||||
|
series: str | None = None
|
||||||
|
series_position: str | None = None
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
publisher: str | None = None
|
||||||
|
language: str | None = None
|
||||||
|
|
||||||
|
identifiers: dict[str, str] = field(default_factory=dict)
|
||||||
|
"""Keyed by `identifiers.type` verbatim — `isbn`, `mobi-asin`, `amazon`."""
|
||||||
|
|
||||||
|
pages: int | None = None
|
||||||
|
cover: Path | None = None
|
||||||
|
files: list[CalibreFile] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreLibrary:
|
||||||
|
"""
|
||||||
|
A Calibre library on disk, opened for reading.
|
||||||
|
|
||||||
|
The catalogue is **copied** before it is read. Calibre may be running and writing,
|
||||||
|
and opening the live file either sees a torn state or needs to recover a write-ahead
|
||||||
|
log, which read-only access cannot do. The copy is small — hundreds of kilobytes for
|
||||||
|
a handful of books, single-digit megabytes for thousands — so this costs nothing and
|
||||||
|
removes the question. The original is never opened by SQLite at all.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, root: Path | str) -> None:
|
||||||
|
self.root = Path(root)
|
||||||
|
self._connection: sqlite3.Connection | None = None
|
||||||
|
self._workspace: Path | None = None
|
||||||
|
|
||||||
|
# Every query runs in a worker thread, and `asyncio.to_thread` hands out
|
||||||
|
# whichever one is free — so the connection outlives the thread that opened it
|
||||||
|
# and `check_same_thread` has to be off. The lock is what makes that safe: it
|
||||||
|
# keeps two queries off the connection at once, which is the thing that check
|
||||||
|
# was standing in for.
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def database(self) -> Path:
|
||||||
|
return self.root / METADATA_DB
|
||||||
|
|
||||||
|
async def open(self) -> None:
|
||||||
|
"""
|
||||||
|
Copy the catalogue aside and connect to the copy.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
CalibreLibraryError: If there is no `metadata.db` under the root.
|
||||||
|
"""
|
||||||
|
if self._connection is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if not await asyncio.to_thread(self.database.is_file):
|
||||||
|
raise CalibreLibraryError(
|
||||||
|
f"No {METADATA_DB} in '{self.root}' — that is not a Calibre library"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._workspace = Path(await asyncio.to_thread(tempfile.mkdtemp))
|
||||||
|
copy = self._workspace / METADATA_DB
|
||||||
|
|
||||||
|
await asyncio.to_thread(self._copy_database, copy)
|
||||||
|
|
||||||
|
# Read-write on our own copy, deliberately: that is what lets SQLite recover a
|
||||||
|
# write-ahead log the source may have been mid-way through.
|
||||||
|
self._connection = sqlite3.connect(str(copy), check_same_thread=False)
|
||||||
|
|
||||||
|
def _copy_database(self, destination: Path) -> None:
|
||||||
|
shutil.copy2(self.database, destination)
|
||||||
|
|
||||||
|
for suffix in _DATABASE_SIDECARS:
|
||||||
|
sidecar = self.database.with_name(self.database.name + suffix)
|
||||||
|
if sidecar.is_file():
|
||||||
|
shutil.copy2(sidecar, destination.with_name(destination.name + suffix))
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""
|
||||||
|
Disconnect and remove the copy. Safe to call more than once.
|
||||||
|
|
||||||
|
The copy is removed even if closing the connection fails — otherwise a failure
|
||||||
|
here leaves a catalogue-sized file in the temp directory, and the caller that
|
||||||
|
failed is exactly the one that will not come back to tidy up.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if self._connection is not None:
|
||||||
|
async with self._lock:
|
||||||
|
self._connection.close()
|
||||||
|
self._connection = None
|
||||||
|
finally:
|
||||||
|
if self._workspace is not None:
|
||||||
|
await asyncio.to_thread(shutil.rmtree, self._workspace, True)
|
||||||
|
self._workspace = None
|
||||||
|
|
||||||
|
async def __aenter__(self) -> CalibreLibrary:
|
||||||
|
await self.open()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *_exception: object) -> None:
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def count(self) -> int:
|
||||||
|
"""How many books the catalogue holds, without reading any of them."""
|
||||||
|
rows = await self._in_thread(lambda: self._execute("SELECT count(*) FROM books"))
|
||||||
|
return int(rows[0][0])
|
||||||
|
|
||||||
|
async def books(self) -> list[CalibreBook]:
|
||||||
|
"""
|
||||||
|
Read the whole catalogue.
|
||||||
|
|
||||||
|
One query per table and the joining done in Python, rather than a per-book query
|
||||||
|
across ten tables. Everything Chitai stores about a book is small, so a
|
||||||
|
self-hosted catalogue fits in memory comfortably.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Every book, in Calibre id order.
|
||||||
|
"""
|
||||||
|
return await self._in_thread(self._read_books)
|
||||||
|
|
||||||
|
async def _in_thread[T](self, work: Callable[[], T]) -> T:
|
||||||
|
"""Run one unit of SQLite work off the event loop, and only one at a time."""
|
||||||
|
async with self._lock:
|
||||||
|
return await asyncio.to_thread(work)
|
||||||
|
|
||||||
|
def _execute(self, statement: str) -> list[tuple]:
|
||||||
|
if self._connection is None:
|
||||||
|
raise CalibreLibraryError("The library is not open")
|
||||||
|
|
||||||
|
return self._connection.execute(statement).fetchall()
|
||||||
|
|
||||||
|
def _has_table(self, name: str) -> bool:
|
||||||
|
return bool(
|
||||||
|
self._execute(
|
||||||
|
f"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '{name}'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _read_books(self) -> list[CalibreBook]:
|
||||||
|
authors = self._grouped(
|
||||||
|
"SELECT bal.book, a.name FROM books_authors_link bal "
|
||||||
|
"JOIN authors a ON a.id = bal.author ORDER BY bal.id"
|
||||||
|
)
|
||||||
|
tags = self._grouped(
|
||||||
|
"SELECT btl.book, t.name FROM books_tags_link btl "
|
||||||
|
"JOIN tags t ON t.id = btl.tag ORDER BY t.name"
|
||||||
|
)
|
||||||
|
# Calibre's link tables are unique per book for these two, so the last write
|
||||||
|
# wins and there is nothing to choose between.
|
||||||
|
series = self._mapped(
|
||||||
|
"SELECT bsl.book, s.name FROM books_series_link bsl "
|
||||||
|
"JOIN series s ON s.id = bsl.series"
|
||||||
|
)
|
||||||
|
publishers = self._mapped(
|
||||||
|
"SELECT bpl.book, p.name FROM books_publishers_link bpl "
|
||||||
|
"JOIN publishers p ON p.id = bpl.publisher"
|
||||||
|
)
|
||||||
|
# A book can carry several languages; Chitai holds one, so the first wins.
|
||||||
|
languages = self._grouped(
|
||||||
|
"SELECT bll.book, l.lang_code FROM books_languages_link bll "
|
||||||
|
"JOIN languages l ON l.id = bll.lang_code ORDER BY bll.item_order"
|
||||||
|
)
|
||||||
|
descriptions = self._mapped("SELECT book, text FROM comments")
|
||||||
|
|
||||||
|
identifiers: dict[int, dict[str, str]] = defaultdict(dict)
|
||||||
|
for book_id, name, value in self._execute(
|
||||||
|
"SELECT book, type, val FROM identifiers"
|
||||||
|
):
|
||||||
|
if name and value:
|
||||||
|
identifiers[book_id][str(name)] = str(value)
|
||||||
|
|
||||||
|
files: dict[int, list[tuple[str, str, int]]] = defaultdict(list)
|
||||||
|
for book_id, format, name, size in self._execute(
|
||||||
|
"SELECT book, format, name, uncompressed_size FROM data ORDER BY id"
|
||||||
|
):
|
||||||
|
files[book_id].append((str(format), str(name), int(size or 0)))
|
||||||
|
|
||||||
|
pages: dict[int, int] = {}
|
||||||
|
if self._has_table("books_pages_link"):
|
||||||
|
pages = {
|
||||||
|
book_id: int(count)
|
||||||
|
for book_id, count in self._execute(
|
||||||
|
"SELECT book, pages FROM books_pages_link WHERE pages > 0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
books = []
|
||||||
|
for row in self._execute(
|
||||||
|
"SELECT id, title, pubdate, series_index, path, uuid, has_cover "
|
||||||
|
"FROM books ORDER BY id"
|
||||||
|
):
|
||||||
|
book_id, title, pubdate, series_index, path, uuid, has_cover = row
|
||||||
|
directory = self.root / Path(str(path))
|
||||||
|
in_series = series.get(book_id)
|
||||||
|
|
||||||
|
books.append(
|
||||||
|
CalibreBook(
|
||||||
|
calibre_id=book_id,
|
||||||
|
uuid=str(uuid or ""),
|
||||||
|
title=str(title or ""),
|
||||||
|
authors=[unescape_author(name) for name in authors.get(book_id, [])],
|
||||||
|
description=strip_html(descriptions.get(book_id)),
|
||||||
|
published_date=parse_date(pubdate),
|
||||||
|
series=in_series,
|
||||||
|
# Meaningless without a series: Calibre defaults the index to 1.0 for
|
||||||
|
# every book, in a series or not.
|
||||||
|
series_position=(
|
||||||
|
format_series_index(series_index) if in_series else None
|
||||||
|
),
|
||||||
|
tags=tags.get(book_id, []),
|
||||||
|
publisher=publishers.get(book_id),
|
||||||
|
language=next(iter(languages.get(book_id, [])), None),
|
||||||
|
identifiers=dict(identifiers.get(book_id, {})),
|
||||||
|
pages=pages.get(book_id),
|
||||||
|
cover=directory / COVER_FILENAME if has_cover else None,
|
||||||
|
files=[
|
||||||
|
CalibreFile(
|
||||||
|
path=directory / f"{name}.{format.lower()}",
|
||||||
|
format=format,
|
||||||
|
size=size,
|
||||||
|
)
|
||||||
|
for format, name, size in files.get(book_id, [])
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return books
|
||||||
|
|
||||||
|
def _grouped(self, statement: str) -> dict[int, list[str]]:
|
||||||
|
"""Run a `(book, value)` query into one list per book, keeping row order."""
|
||||||
|
grouped: dict[int, list[str]] = defaultdict(list)
|
||||||
|
|
||||||
|
for book_id, value in self._execute(statement):
|
||||||
|
if value is not None:
|
||||||
|
grouped[book_id].append(str(value))
|
||||||
|
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
def _mapped(self, statement: str) -> dict[int, str]:
|
||||||
|
"""Run a `(book, value)` query into one value per book."""
|
||||||
|
return {
|
||||||
|
book_id: str(value)
|
||||||
|
for book_id, value in self._execute(statement)
|
||||||
|
if value is not None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def unescape_author(name: str) -> str:
|
||||||
|
"""
|
||||||
|
Undo Calibre's comma escaping.
|
||||||
|
|
||||||
|
`authors.name` stores a comma as `|`, and Calibre reverses it on the way out. Left
|
||||||
|
alone, `Doyle, Sir Arthur Conan` comes back as `Doyle| Sir Arthur Conan`.
|
||||||
|
"""
|
||||||
|
return name.replace("|", ",").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(value: object) -> date | None:
|
||||||
|
"""
|
||||||
|
Read one of Calibre's timestamps, discarding its "unknown" sentinel.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The stored column, which is text in practice but need not be.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The date, or None for a null, an unparseable value, or Calibre's
|
||||||
|
`0101-01-01` placeholder.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
parsed = value.date()
|
||||||
|
elif isinstance(value, date):
|
||||||
|
parsed = value
|
||||||
|
else:
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(text).date()
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
parsed = date.fromisoformat(text[:10])
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return parsed if parsed.year >= EARLIEST_REAL_YEAR else None
|
||||||
|
|
||||||
|
|
||||||
|
def format_series_index(index: object) -> str | None:
|
||||||
|
"""
|
||||||
|
Render `series_index` as the string `Book.series_position` holds.
|
||||||
|
|
||||||
|
Calibre stores a REAL, so volume seven arrives as `7.0` — which would be stored
|
||||||
|
verbatim and then compared as a string against the `7` everything else writes.
|
||||||
|
Fractional positions are real and are kept: `1.5` is a novella between two novels.
|
||||||
|
"""
|
||||||
|
if index is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
number = float(index)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
return str(int(number)) if number.is_integer() else f"{number:g}"
|
||||||
|
|
||||||
|
|
||||||
|
class _TextExtractor(HTMLParser):
|
||||||
|
"""Flatten markup to text, keeping the line breaks that carried meaning."""
|
||||||
|
|
||||||
|
# Tags whose boundaries are a line break rather than nothing at all. Without these
|
||||||
|
# a description of three paragraphs comes out as one run-on sentence.
|
||||||
|
_BREAKS = frozenset(
|
||||||
|
{
|
||||||
|
"p", "br", "div", "li", "tr", "blockquote", "hr",
|
||||||
|
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__(convert_charrefs=True)
|
||||||
|
self._parts: list[str] = []
|
||||||
|
|
||||||
|
def handle_data(self, data: str) -> None:
|
||||||
|
self._parts.append(data)
|
||||||
|
|
||||||
|
def handle_starttag(self, tag: str, _attrs: object) -> None:
|
||||||
|
self._break(tag)
|
||||||
|
|
||||||
|
def handle_endtag(self, tag: str) -> None:
|
||||||
|
self._break(tag)
|
||||||
|
|
||||||
|
def _break(self, tag: str) -> None:
|
||||||
|
"""
|
||||||
|
End the current line, once.
|
||||||
|
|
||||||
|
Both halves of `</p><p>` are a boundary, and the open tag of the very first
|
||||||
|
block is not one at all — so emitting a newline per tag turns two paragraphs
|
||||||
|
into two blank-line-separated ones with a leading gap. One break per boundary
|
||||||
|
is what the plain text wants.
|
||||||
|
"""
|
||||||
|
if tag in self._BREAKS and self._parts and not self._parts[-1].endswith("\n"):
|
||||||
|
self._parts.append("\n")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
lines = [line.strip() for line in "".join(self._parts).splitlines()]
|
||||||
|
|
||||||
|
return "\n".join(line for line in lines if line).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def strip_html(html: str | None) -> str | None:
|
||||||
|
"""
|
||||||
|
Turn Calibre's `comments` into plain text.
|
||||||
|
|
||||||
|
`comments.text` is always HTML, and `Book.description` is rendered as text — so the
|
||||||
|
tags would show literally on the book page.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html: The stored comment, if there is one.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The text, or None when there was nothing or nothing survived.
|
||||||
|
"""
|
||||||
|
if not html:
|
||||||
|
return None
|
||||||
|
|
||||||
|
parser = _TextExtractor()
|
||||||
|
parser.feed(html)
|
||||||
|
parser.close()
|
||||||
|
|
||||||
|
return parser.text or None
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# src/chitai/services/calibre_import.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
Run a Calibre import in the background and report on it.
|
||||||
|
|
||||||
|
The import outlives its request — a real library takes minutes to hours — so the handler
|
||||||
|
starts a task and hands back a handle to poll. The work itself is
|
||||||
|
`BookService.create_many_from_calibre`; everything here is lifecycle: state, progress,
|
||||||
|
cancellation, and a session of its own.
|
||||||
|
|
||||||
|
**This registry lives in memory, and therefore assumes one worker process.** That holds
|
||||||
|
today: the production `CMD` is `litestar run`, which is single-process, and the consume
|
||||||
|
watcher is already an in-process singleton with the same constraint. `TODO.md` records
|
||||||
|
that the production image should move to uvicorn with a worker count — the day that
|
||||||
|
happens, a poll can land on a worker that has never heard of the job, and this needs an
|
||||||
|
`import_jobs` table instead. It is written down here because nothing else will say so.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from chitai.config import settings
|
||||||
|
from chitai.database.models import Library
|
||||||
|
from chitai.services.book import (
|
||||||
|
BookService,
|
||||||
|
CalibreImportProgress,
|
||||||
|
CalibreImportResult,
|
||||||
|
UnimportedBook,
|
||||||
|
)
|
||||||
|
from chitai.services.calibre import CalibreLibrary
|
||||||
|
|
||||||
|
|
||||||
|
class ImportState(StrEnum):
|
||||||
|
"""Where a job has got to."""
|
||||||
|
|
||||||
|
RUNNING = "running"
|
||||||
|
FINISHED = "finished"
|
||||||
|
|
||||||
|
# Stopped on request. What it imported is complete.
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
# The run itself broke — an unreadable catalogue, a missing library. Distinct from
|
||||||
|
# individual books failing, which `failures` carries and which never stops the run.
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportJob:
|
||||||
|
"""One import, running or finished."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
library_id: int
|
||||||
|
source: str
|
||||||
|
|
||||||
|
state: ImportState = ImportState.RUNNING
|
||||||
|
total: int = 0
|
||||||
|
processed: int = 0
|
||||||
|
created: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
failed: int = 0
|
||||||
|
|
||||||
|
current_title: str | None = None
|
||||||
|
failures: list[UnimportedBook] = field(default_factory=list)
|
||||||
|
|
||||||
|
# Books imported that look like something the library already had. A count, not the
|
||||||
|
# records: the duplicates screen is what shows them, and a big import would make
|
||||||
|
# this the largest thing in the response for no benefit.
|
||||||
|
possible_duplicates: int = 0
|
||||||
|
|
||||||
|
# Why the whole run stopped, when `state` is FAILED.
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
# The directory the job owns and must delete when it ends: what the uploaded archive
|
||||||
|
# was unpacked into.
|
||||||
|
workspace: Path | None = None
|
||||||
|
|
||||||
|
_stop: bool = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def finished(self) -> bool:
|
||||||
|
return self.state is not ImportState.RUNNING
|
||||||
|
|
||||||
|
def absorb(self, result: CalibreImportResult) -> None:
|
||||||
|
"""Take the final counts from a finished run."""
|
||||||
|
self.total = result.total
|
||||||
|
self.created = len(result.created)
|
||||||
|
self.skipped = len(result.skipped)
|
||||||
|
self.failed = len(result.failed)
|
||||||
|
self.failures = list(result.failed)
|
||||||
|
self.possible_duplicates = len(result.possible_duplicates)
|
||||||
|
self.current_title = None
|
||||||
|
|
||||||
|
self.state = ImportState.CANCELLED if result.stopped else ImportState.FINISHED
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreImportRegistry:
|
||||||
|
"""
|
||||||
|
The imports this process knows about.
|
||||||
|
|
||||||
|
One instance, held at module scope below. Jobs are kept after they finish so the
|
||||||
|
screen that started one can still read its result; nothing evicts them, which is
|
||||||
|
fine for a handful of one-time migrations and is the other reason a table would be
|
||||||
|
the answer if this ever needed to be durable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._jobs: dict[str, ImportJob] = {}
|
||||||
|
|
||||||
|
# Held only to keep the tasks referenced. Without this the event loop is free to
|
||||||
|
# garbage-collect a running task mid-import.
|
||||||
|
self._tasks: set[asyncio.Task] = set()
|
||||||
|
|
||||||
|
def get(self, job_id: str) -> ImportJob | None:
|
||||||
|
return self._jobs.get(job_id)
|
||||||
|
|
||||||
|
def running_for(self, library_id: int) -> ImportJob | None:
|
||||||
|
"""The unfinished import for a library, if it has one."""
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
job
|
||||||
|
for job in self._jobs.values()
|
||||||
|
if job.library_id == library_id and not job.finished
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def cancel(self, job_id: str) -> ImportJob | None:
|
||||||
|
"""
|
||||||
|
Ask a job to stop after the book it is on.
|
||||||
|
|
||||||
|
Not `task.cancel()`: that would abandon a book mid-copy, leaving files on disk
|
||||||
|
with no row describing them. The flag is read between books.
|
||||||
|
"""
|
||||||
|
job = self._jobs.get(job_id)
|
||||||
|
|
||||||
|
if job is not None and not job.finished:
|
||||||
|
job._stop = True
|
||||||
|
|
||||||
|
return job
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
library: Library,
|
||||||
|
source: Path,
|
||||||
|
workspace: Path,
|
||||||
|
label: str,
|
||||||
|
allow_duplicates: bool = False,
|
||||||
|
) -> ImportJob:
|
||||||
|
"""
|
||||||
|
Begin importing, and return the handle to poll.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
library: The library to import into.
|
||||||
|
source: The unpacked Calibre library's directory.
|
||||||
|
workspace: A directory the job owns and deletes when it ends — what the
|
||||||
|
uploaded archive was unpacked into. The books have been copied into the
|
||||||
|
library by then, so nothing is lost with it.
|
||||||
|
label: What to report as the source. `source` is a temp directory that would
|
||||||
|
mean nothing to the reader, so this is the archive's own name.
|
||||||
|
allow_duplicates: Import books whose files are already stored.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The job, already running.
|
||||||
|
"""
|
||||||
|
job = ImportJob(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
library_id=library.id,
|
||||||
|
source=label,
|
||||||
|
workspace=workspace,
|
||||||
|
)
|
||||||
|
self._jobs[job.id] = job
|
||||||
|
|
||||||
|
task = asyncio.create_task(self._run(job, library.id, source, allow_duplicates))
|
||||||
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
|
||||||
|
return job
|
||||||
|
|
||||||
|
async def _run(
|
||||||
|
self, job: ImportJob, library_id: int, source: Path, allow_duplicates: bool
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Do the import, recording everything on the job.
|
||||||
|
|
||||||
|
Opens a **session of its own**: the request that started this is long gone, and
|
||||||
|
its session was closed with it.
|
||||||
|
"""
|
||||||
|
from chitai.services.library import LibraryService
|
||||||
|
|
||||||
|
def on_progress(progress: CalibreImportProgress) -> None:
|
||||||
|
job.total = progress.total
|
||||||
|
job.processed = progress.processed
|
||||||
|
job.current_title = progress.title
|
||||||
|
|
||||||
|
if progress.outcome == "created":
|
||||||
|
job.created += 1
|
||||||
|
elif progress.outcome == "skipped":
|
||||||
|
job.skipped += 1
|
||||||
|
else:
|
||||||
|
job.failed += 1
|
||||||
|
|
||||||
|
catalogue = CalibreLibrary(source)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await catalogue.open()
|
||||||
|
|
||||||
|
async with settings.alchemy_config.get_session() as session:
|
||||||
|
library = await LibraryService(session=session).get(library_id)
|
||||||
|
|
||||||
|
result = await BookService(session=session).create_many_from_calibre(
|
||||||
|
catalogue,
|
||||||
|
library,
|
||||||
|
allow_duplicates=allow_duplicates,
|
||||||
|
on_progress=on_progress,
|
||||||
|
should_stop=lambda: job._stop,
|
||||||
|
)
|
||||||
|
|
||||||
|
job.absorb(result)
|
||||||
|
except Exception as exc:
|
||||||
|
job.state = ImportState.FAILED
|
||||||
|
job.error = f"{type(exc).__name__}: {exc}"
|
||||||
|
finally:
|
||||||
|
await catalogue.close()
|
||||||
|
|
||||||
|
# An unpacked archive is a second copy of the whole library, and the books
|
||||||
|
# worth keeping have been copied into the library proper by now. Removed
|
||||||
|
# even when the run failed — especially then, since nothing will come back
|
||||||
|
# for it.
|
||||||
|
if job.workspace is not None:
|
||||||
|
await asyncio.to_thread(shutil.rmtree, job.workspace, True)
|
||||||
|
|
||||||
|
|
||||||
|
registry = CalibreImportRegistry()
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from chitai.config import settings
|
||||||
from chitai.database.models.library import Library
|
from chitai.database.models.library import Library
|
||||||
from chitai.services import BookService, LibraryService
|
from chitai.services import BookService, LibraryService
|
||||||
from chitai.services.metadata_extractor import Extractor
|
from chitai.services.metadata_extractor import Extractor
|
||||||
@@ -111,13 +112,32 @@ class ConsumeDirectoryWatcher:
|
|||||||
"""Process a batch of files."""
|
"""Process a batch of files."""
|
||||||
try:
|
try:
|
||||||
|
|
||||||
books = await self.book_service.create_many_from_existing_files(
|
result = await self.book_service.create_many_from_existing_files(
|
||||||
list(file_paths),
|
list(file_paths),
|
||||||
self.watch_path / Path(library_slug),
|
self.watch_path / Path(library_slug),
|
||||||
library=await self._get_library(library_slug),
|
library=await self._get_library(library_slug),
|
||||||
)
|
)
|
||||||
|
|
||||||
print(f"Created {len(books)} books!")
|
print(f"Created {len(result.books)} books!")
|
||||||
|
|
||||||
|
if result.duplicates:
|
||||||
|
print(
|
||||||
|
f"Moved {len(result.duplicates)} already-stored file(s) "
|
||||||
|
f"to {settings.duplicate_path}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Imported all the same — a metadata match is a guess, and there is nobody
|
||||||
|
# here to ask. The library's duplicates screen is where these get decided.
|
||||||
|
for possible in result.possible_duplicates:
|
||||||
|
names = ", ".join(
|
||||||
|
f"{candidate.title} (#{candidate.book_id})"
|
||||||
|
for candidate in possible.candidates
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f"Imported {possible.title!r} (#{possible.book_id}), which may "
|
||||||
|
f"already be in the library as: {names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error processing batch: {e}")
|
print(f"Error processing batch: {e}")
|
||||||
|
|||||||
@@ -127,6 +127,31 @@ def create_book_filter_dependencies(
|
|||||||
# Get base filters first
|
# Get base filters first
|
||||||
filters = create_filter_dependencies(config, dep_defaults)
|
filters = create_filter_dependencies(config, dep_defaults)
|
||||||
|
|
||||||
|
# OVERRIDE: id filter typed by the configured id type, not always `str`
|
||||||
|
#
|
||||||
|
# advanced_alchemy's `provide_id_filter` annotates `ids` as `list[str]` and
|
||||||
|
# ignores `config["id_filter"]` entirely, so `?ids=12` reaches the database as
|
||||||
|
# the string "12" and Postgres refuses `bigint = character varying`. Nothing
|
||||||
|
# called `?ids=` until the duplicates screen needed to fetch a handful of books
|
||||||
|
# by id, which is why it went unnoticed.
|
||||||
|
if id_type := config.get("id_filter"):
|
||||||
|
id_field = config.get("id_field", "id")
|
||||||
|
|
||||||
|
def provide_typed_id_filter(
|
||||||
|
ids=Parameter(query="ids", default=None, required=False),
|
||||||
|
) -> CollectionFilter:
|
||||||
|
return CollectionFilter(field_name=id_field, values=ids)
|
||||||
|
|
||||||
|
# Attached as a type object rather than written as an annotation: this module
|
||||||
|
# has `from __future__ import annotations`, so a written one is stored as the
|
||||||
|
# string "Optional[list[id_type]]" and resolved against module globals, where
|
||||||
|
# a local named `id_type` does not exist.
|
||||||
|
provide_typed_id_filter.__annotations__["ids"] = Optional[list[id_type]]
|
||||||
|
|
||||||
|
filters[dep_defaults.ID_FILTER_DEPENDENCY_KEY] = Provide(
|
||||||
|
provide_typed_id_filter, sync_to_thread=False
|
||||||
|
)
|
||||||
|
|
||||||
# OVERRIDE: Custom search filter with trigram search
|
# OVERRIDE: Custom search filter with trigram search
|
||||||
if config.get("search"):
|
if config.get("search"):
|
||||||
search_fields = config.get("search")
|
search_fields = config.get("search")
|
||||||
|
|||||||
@@ -16,6 +16,43 @@ import chitai.database.models as m
|
|||||||
# - Auto-handle missing values (e.g., skip {series}/ if series is empty)
|
# - Auto-handle missing values (e.g., skip {series}/ if series is empty)
|
||||||
|
|
||||||
|
|
||||||
|
# Characters that cannot survive being interpolated into a path. The forward slash is
|
||||||
|
# the one that matters: titles legitimately contain it — "AC/DC", "Him/Her" — and the
|
||||||
|
# template writes the title straight into a directory name, so an unsanitised one
|
||||||
|
# silently adds a level and puts the book somewhere `book.path` does not describe.
|
||||||
|
# Calibre strips these from its own on-disk names and keeps the real title in its
|
||||||
|
# database, which is how an import surfaces them.
|
||||||
|
_UNSAFE_IN_PATH = re.compile(r"[/\\\x00-\x1f]")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_path_component(value: str) -> str:
|
||||||
|
"""Make one metadata value safe to use as a single directory or file name."""
|
||||||
|
return _UNSAFE_IN_PATH.sub("_", value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_components(book_data: dict) -> dict:
|
||||||
|
"""
|
||||||
|
A shallow copy of the metadata with the values a path is built from sanitised.
|
||||||
|
|
||||||
|
Only strings are touched, and only the fields the default template interpolates. A
|
||||||
|
caller's own template can reach anything else in the dict, which is a reason to keep
|
||||||
|
this conservative rather than to walk the whole structure.
|
||||||
|
"""
|
||||||
|
safe = dict(book_data)
|
||||||
|
|
||||||
|
for key in ("title", "series", "series_position"):
|
||||||
|
if isinstance(safe.get(key), str):
|
||||||
|
safe[key] = sanitize_path_component(safe[key])
|
||||||
|
|
||||||
|
if isinstance(safe.get("authors"), list):
|
||||||
|
safe["authors"] = [
|
||||||
|
sanitize_path_component(author) if isinstance(author, str) else author
|
||||||
|
for author in safe["authors"]
|
||||||
|
]
|
||||||
|
|
||||||
|
return safe
|
||||||
|
|
||||||
|
|
||||||
default_path_template = """
|
default_path_template = """
|
||||||
/{{book.authors[0] if book.authors else 'Unknown'}}
|
/{{book.authors[0] if book.authors else 'Unknown'}}
|
||||||
{%- if book.series -%}
|
{%- if book.series -%}
|
||||||
@@ -101,7 +138,12 @@ class BookPathGenerator:
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = self.root_path / Path(self.path_template.render(book=book_data))
|
# Sanitised per value, never on the rendered result: the separators the template
|
||||||
|
# puts *between* author, series and title are the whole point of it, and only the
|
||||||
|
# values interpolated into it must not contribute any of their own.
|
||||||
|
result = self.root_path / Path(
|
||||||
|
self.path_template.render(book=_safe_components(book_data))
|
||||||
|
)
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
result = re.sub(r"/+", "/", str(result)) # Remove consecutive backslashes
|
result = re.sub(r"/+", "/", str(result)) # Remove consecutive backslashes
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
# src/chitai/services/matching.py
|
||||||
|
|
||||||
|
"""
|
||||||
|
Normalisation for book-level duplicate detection.
|
||||||
|
|
||||||
|
Two copies of one book rarely agree on how it is written down. One says
|
||||||
|
`The Metamorphosis`, the other `Metamorphosis`; one credits `Kafka, Franz`, the other
|
||||||
|
`Franz Kafka`; one carries the ISBN-10 and the other the ISBN-13 of the same edition.
|
||||||
|
These functions reduce each of those to a single key, so the comparison is an equality
|
||||||
|
test the database can index rather than a similarity score nobody can explain.
|
||||||
|
|
||||||
|
Everything here is pure: the keys are computed once and stored on the row (see
|
||||||
|
`Book.normalized_title`, `Author.normalized_name`, `Identifier.normalized_value`), so
|
||||||
|
no Postgres extension is needed at query time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
|
from chitai.services.utils import is_valid_isbn, isbn10_to_isbn13
|
||||||
|
|
||||||
|
|
||||||
|
# Asides a title carries that say nothing about which book it is:
|
||||||
|
# "Frankenstein (Illustrated)", "Dune [Deluxe]".
|
||||||
|
_BRACKETED = re.compile(r"[(\[{][^)\]}]*[)\]}]")
|
||||||
|
|
||||||
|
# Edition and format qualifiers, matched only as a *trailing* run of words. Anchoring
|
||||||
|
# to the end is what keeps "The Illustrated Man" a book and "Moby Dick Illustrated" a
|
||||||
|
# format note — a qualifier trails the title, it is never the thing the title is about.
|
||||||
|
_EDITION_NOISE = re.compile(
|
||||||
|
r"\s+(?:"
|
||||||
|
# "2nd edition", but also the compact forms publishers actually print on a
|
||||||
|
# cover: "2E", "3 Ed", "5e". The number alone is never enough — "Catch 22" is
|
||||||
|
# a title and must survive.
|
||||||
|
r"\d+(?:st|nd|rd|th)?\s*(?:edition|edn|ed|e)"
|
||||||
|
r"|(?:first|second|third|fourth|fifth|sixth|new|revised|expanded|updated|"
|
||||||
|
r"annotated|illustrated|unabridged|abridged|complete|definitive|deluxe|"
|
||||||
|
r"anniversary|collectors|international|kindle|paperback|hardcover|hardback|"
|
||||||
|
r"ebook|audiobook)"
|
||||||
|
r"(?:\s+(?:and|&)\s+\w+)*"
|
||||||
|
r"(?:\s+ed(?:ition|n)?)?"
|
||||||
|
r")$"
|
||||||
|
)
|
||||||
|
|
||||||
|
_LEADING_ARTICLE = re.compile(r"^(?:the|a|an)\s+")
|
||||||
|
|
||||||
|
# Anything that is not a letter, a digit or a space, once accents are gone.
|
||||||
|
_PUNCTUATION = re.compile(r"[^0-9a-z ]+")
|
||||||
|
|
||||||
|
_WHITESPACE = re.compile(r"\s+")
|
||||||
|
|
||||||
|
# Junk an extractor leaves on the end of a name: the `;` from a `DC:creator` list, the
|
||||||
|
# `.epub` from a filename the author's name was read out of.
|
||||||
|
_TRAILING_SEPARATORS = " ;,&/"
|
||||||
|
_FILE_EXTENSION = re.compile(
|
||||||
|
r"\.(?:epub|pdf|mobi|azw3?|djvu|fb2|txt|cbz|cbr)$", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
# `J. R. R.` survives punctuation stripping as three one-letter words; `J.R.R.` as one.
|
||||||
|
# Joining any run of them makes both `jrr`.
|
||||||
|
_INITIAL_RUN = re.compile(r"\b(?:[a-z] )+[a-z]\b")
|
||||||
|
|
||||||
|
# ISBNs are the same number under several names; everything else keeps its own.
|
||||||
|
_ISBN_NAMES = {"isbn", "isbn-10", "isbn10", "isbn-13", "isbn13"}
|
||||||
|
|
||||||
|
# Generated fresh for every build of a file, so two copies of one book never share one.
|
||||||
|
# Matching on them would only re-find files the hash check already catches.
|
||||||
|
_PER_BUILD_NAMES = {"uuid", "urn:uuid"}
|
||||||
|
|
||||||
|
# Below this an identifier is not specific enough to be evidence: a Calibre `id` of
|
||||||
|
# "42" would otherwise pair two unrelated books.
|
||||||
|
_MIN_IDENTIFIER_LENGTH = 4
|
||||||
|
|
||||||
|
|
||||||
|
def _fold(text: str) -> str:
|
||||||
|
"""Casefolded, accent-free, punctuation-free, single-spaced."""
|
||||||
|
decomposed = unicodedata.normalize("NFKD", text.casefold())
|
||||||
|
unaccented = "".join(c for c in decomposed if not unicodedata.combining(c))
|
||||||
|
|
||||||
|
return _WHITESPACE.sub(" ", _PUNCTUATION.sub(" ", unaccented)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_title(title: str | None) -> str:
|
||||||
|
"""
|
||||||
|
Reduce a title to the key two copies of one book should share.
|
||||||
|
|
||||||
|
`Book.subtitle` is already split off by `Extractor.format_book_title`, so only what
|
||||||
|
is left in the title column is considered here.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: The title as it was stored.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The comparison key, or an empty string if nothing survives normalisation —
|
||||||
|
which is the signal not to match on the title at all.
|
||||||
|
"""
|
||||||
|
if not title:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
folded = _fold(_BRACKETED.sub(" ", title).replace("&", " and "))
|
||||||
|
|
||||||
|
# Repeated because qualifiers stack: "Dune Deluxe Edition Illustrated".
|
||||||
|
while (trimmed := _EDITION_NOISE.sub("", folded)) != folded:
|
||||||
|
folded = trimmed
|
||||||
|
|
||||||
|
# An article says nothing, but a title that is only an article is not improved by
|
||||||
|
# having none, and neither is one that noise removal emptied out.
|
||||||
|
return _LEADING_ARTICLE.sub("", folded, count=1) or folded
|
||||||
|
|
||||||
|
|
||||||
|
def format_author_name(name: str | None) -> str:
|
||||||
|
"""
|
||||||
|
Tidy an author's name into the one form the library writes them in.
|
||||||
|
|
||||||
|
Distinct from `normalize_author`, which throws away case, accents and spacing to
|
||||||
|
build a comparison key. This one is what a reader sees, so it keeps everything
|
||||||
|
that belongs to the name and only removes what an extractor added: a trailing
|
||||||
|
separator left over from a creator list, a file extension carried in from a
|
||||||
|
filename, and the `Surname, Given` ordering that EPUBs file names under.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The name as the file or filename gave it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The name to store, or an empty string if there is nothing left of it.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
tidied = _FILE_EXTENSION.sub("", name.strip().strip(_TRAILING_SEPARATORS).strip())
|
||||||
|
|
||||||
|
if tidied.count(",") == 1:
|
||||||
|
surname, given = (part.strip() for part in tidied.split(","))
|
||||||
|
|
||||||
|
# Only when the part before the comma is a single word. "Dave Thomas, Andy
|
||||||
|
# Hunt" is two people in one string, and flipping it would invent a third
|
||||||
|
# person who does not exist. Leaving an unrecognised form alone is the safe
|
||||||
|
# failure; rewriting it wrongly is not.
|
||||||
|
if surname and given and " " not in surname:
|
||||||
|
tidied = f"{given} {surname}"
|
||||||
|
|
||||||
|
return _WHITESPACE.sub(" ", tidied).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_author(name: str | None) -> str:
|
||||||
|
"""
|
||||||
|
Reduce an author's name to the key their other books should share.
|
||||||
|
|
||||||
|
Deliberately not reduced to surname plus initial: that collides unrelated people,
|
||||||
|
and a wrong match here is a book pointed at a stranger's shelf.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: The name as it was stored, in either `Franz Kafka` or `Kafka, Franz` form.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The comparison key, or an empty string if nothing survives normalisation.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# `Kafka, Franz` is one name written backwards. More than one comma is a list, or a
|
||||||
|
# suffix, and guessing at either does more harm than leaving it alone.
|
||||||
|
if name.count(",") == 1:
|
||||||
|
surname, forename = name.split(",")
|
||||||
|
name = f"{forename.strip()} {surname.strip()}"
|
||||||
|
|
||||||
|
folded = _fold(name)
|
||||||
|
|
||||||
|
return _INITIAL_RUN.sub(lambda run: run.group().replace(" ", ""), folded)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_identifier(name: str, value: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Reduce one identifier to a `scheme:value` key, if it can carry a match at all.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: What kind of identifier it is, as stored.
|
||||||
|
value: The identifier itself.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The key, or None when the identifier is no use for matching: a per-build UUID,
|
||||||
|
something too short to be evidence, or an ISBN that fails its own checksum.
|
||||||
|
"""
|
||||||
|
name = (name or "").strip().casefold()
|
||||||
|
value = (value or "").strip()
|
||||||
|
|
||||||
|
if not name or not value or name in _PER_BUILD_NAMES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if name in _ISBN_NAMES:
|
||||||
|
digits = re.sub(r"[^0-9Xx]", "", value).upper()
|
||||||
|
|
||||||
|
if not is_valid_isbn(digits):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# One scheme for both forms: a publisher prints whichever it likes, and the
|
||||||
|
# ISBN-10 and ISBN-13 of an edition are the same number written twice.
|
||||||
|
isbn = digits if len(digits) == 13 else isbn10_to_isbn13(digits)
|
||||||
|
return f"isbn:{isbn}" if isbn else None
|
||||||
|
|
||||||
|
folded = _fold(value) or value.casefold()
|
||||||
|
if len(folded) < _MIN_IDENTIFIER_LENGTH:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return f"{name}:{folded}"
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
# TODO: Code is a mess. Clean it up and add docstrings
|
# TODO: Code is a mess. Clean it up and add docstrings
|
||||||
|
|
||||||
# Standard library
|
# Standard library
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
import datetime
|
import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
@@ -31,6 +30,147 @@ from chitai.services.utils import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Identifier schemes an EPUB can declare, mapped onto the names the rest of the app
|
||||||
|
# uses. A scheme arrives either as an `opf:scheme` attribute or as a prefix on the
|
||||||
|
# value itself (`urn:isbn:…`, `calibre:…`), and the two say the same thing.
|
||||||
|
_IDENTIFIER_SCHEMES = {
|
||||||
|
"isbn": "isbn",
|
||||||
|
"isbn10": "isbn-10",
|
||||||
|
"isbn-10": "isbn-10",
|
||||||
|
"isbn13": "isbn-13",
|
||||||
|
"isbn-13": "isbn-13",
|
||||||
|
"uuid": "uuid",
|
||||||
|
"calibre": "calibre",
|
||||||
|
"doi": "doi",
|
||||||
|
"asin": "asin",
|
||||||
|
"amazon": "asin",
|
||||||
|
"mobi-asin": "asin",
|
||||||
|
"google": "google",
|
||||||
|
"goodreads": "goodreads",
|
||||||
|
}
|
||||||
|
|
||||||
|
# `scheme:rest`, with an optional `urn:` in front of it.
|
||||||
|
_SCHEME_PREFIX = re.compile(r"^(?:urn:)?([A-Za-z][A-Za-z0-9.-]*):(.+)$")
|
||||||
|
|
||||||
|
_UUID = re.compile(r"^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_identifier(value: str, scheme: str | None = None) -> tuple[str, str] | None:
|
||||||
|
"""
|
||||||
|
Work out what one raw identifier is, and what it is worth storing as.
|
||||||
|
|
||||||
|
EPUBs write the same ISBN as `9780486282114`, `978-0-486-28211-4` and
|
||||||
|
`urn:isbn:978-0-486-28211-4`, and carry plenty of identifiers that are not ISBNs
|
||||||
|
at all. Validating the string verbatim keeps only the first form and throws the
|
||||||
|
rest away, so normalise first and name whatever survives.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The identifier as the file wrote it.
|
||||||
|
scheme: What the file said it is, if it said anything — an `opf:scheme`
|
||||||
|
attribute. A prefix on the value takes precedence over this.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The `(name, value)` to store, or `None` when there is nothing usable: an
|
||||||
|
empty value, or one declared to be an ISBN that fails its own checksum.
|
||||||
|
"""
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = _IDENTIFIER_SCHEMES.get((scheme or "").strip().casefold())
|
||||||
|
|
||||||
|
# An unrecognised prefix is part of the value rather than a scheme —
|
||||||
|
# "http://example.com/book" is not an identifier called "http".
|
||||||
|
if (match := _SCHEME_PREFIX.match(value)) and (
|
||||||
|
prefixed := _IDENTIFIER_SCHEMES.get(match.group(1).casefold())
|
||||||
|
):
|
||||||
|
name = prefixed
|
||||||
|
value = match.group(2).strip()
|
||||||
|
|
||||||
|
if name is None or name.startswith("isbn"):
|
||||||
|
digits = re.sub(r"[^0-9Xx]", "", value).upper()
|
||||||
|
if is_valid_isbn(digits):
|
||||||
|
return f"isbn-{len(digits)}", digits
|
||||||
|
|
||||||
|
# Something that announced itself as an ISBN and is not one carries no
|
||||||
|
# information: storing it would link the reader to a page that does not exist.
|
||||||
|
if name is not None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return name or ("uuid" if _UUID.match(value) else "id"), value
|
||||||
|
|
||||||
|
|
||||||
|
# Numbered editions, in the forms covers and catalogue records actually use:
|
||||||
|
# "3rd Edition", "2E", "4e", "8_e", "/6e", "(2nd edition)", "Third International Edition".
|
||||||
|
_ORDINAL_WORDS = {
|
||||||
|
"first": 1, "second": 2, "third": 3, "fourth": 4, "fifth": 5, "sixth": 6,
|
||||||
|
"seventh": 7, "eighth": 8, "ninth": 9, "tenth": 10, "eleventh": 11, "twelfth": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Words that sit between the number and "Edition" and belong to the same statement.
|
||||||
|
_EDITION_QUALIFIER = (
|
||||||
|
r"(?:international|global|revised|updated|expanded|anniversary|deluxe|student|"
|
||||||
|
r"instructors?|annotated|illustrated|reprint)"
|
||||||
|
)
|
||||||
|
|
||||||
|
_EDITION = re.compile(
|
||||||
|
rf"""
|
||||||
|
[\s,;:/\-–—(\[]+ # the separator the statement hangs off
|
||||||
|
(?:
|
||||||
|
(?P<num>\d{{1,2}})\s*(?:st|nd|rd|th)?[\s_]*
|
||||||
|
(?:{_EDITION_QUALIFIER}\s+)*(?:edition\b|edn\b|ed\b\.?|e\b)
|
||||||
|
| (?P<word>{"|".join(_ORDINAL_WORDS)})\s+
|
||||||
|
(?:{_EDITION_QUALIFIER}\s+)*(?:edition\b|edn\b|ed\b\.?)
|
||||||
|
)
|
||||||
|
[\s)\]]* # and its closing bracket, if it had one
|
||||||
|
""",
|
||||||
|
re.IGNORECASE | re.VERBOSE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def split_edition(title: str | None) -> tuple[str | None, int | None]:
|
||||||
|
"""
|
||||||
|
Separate a numbered edition statement from the title it is written into.
|
||||||
|
|
||||||
|
"Fluent Python, 2nd Edition" is one book with a field for the edition, not a
|
||||||
|
title. Left in place it also splits the library: the second edition never looks
|
||||||
|
like the first, and neither matches the copy whose file simply did not mention it.
|
||||||
|
|
||||||
|
The number is what makes this safe. Nothing is stripped without one, so
|
||||||
|
"Catch 22" and "Blade Runner 2049" keep their numbers and "Global Edition" —
|
||||||
|
which is a variant, not a numbered edition, and has nowhere to go in an
|
||||||
|
integer column — is left in the title where it can still be read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: The title as the file or filename gave it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The title without the edition statement, and the edition number. The title
|
||||||
|
unchanged and None when there is no numbered edition in it, or when removing
|
||||||
|
it would leave nothing behind.
|
||||||
|
"""
|
||||||
|
if not title:
|
||||||
|
return title, None
|
||||||
|
|
||||||
|
if (match := _EDITION.search(title)) is None:
|
||||||
|
return title, None
|
||||||
|
|
||||||
|
edition = (
|
||||||
|
int(match["num"]) if match["num"] else _ORDINAL_WORDS[match["word"].casefold()]
|
||||||
|
)
|
||||||
|
|
||||||
|
stripped = _EDITION.sub(" ", title)
|
||||||
|
stripped = re.sub(r"\s{2,}", " ", stripped)
|
||||||
|
stripped = re.sub(r"\s+([,;:.!?])", r"\1", stripped) # "Works : What" → "Works: What"
|
||||||
|
stripped = stripped.strip(" ,;:-–—/")
|
||||||
|
|
||||||
|
# A title that is only an edition statement is not improved by having none.
|
||||||
|
if not stripped:
|
||||||
|
return title, None
|
||||||
|
|
||||||
|
return stripped, edition
|
||||||
|
|
||||||
|
|
||||||
class FileExtractor(Protocol):
|
class FileExtractor(Protocol):
|
||||||
@classmethod
|
@classmethod
|
||||||
async def extract_metadata(
|
async def extract_metadata(
|
||||||
@@ -54,25 +194,56 @@ class Extractor:
|
|||||||
# EPUB tends to give better metadata results over pdf
|
# EPUB tends to give better metadata results over pdf
|
||||||
sorted_files = sorted(files, key=lambda f: Extractor._get_file_priority(f))
|
sorted_files = sorted(files, key=lambda f: Extractor._get_file_priority(f))
|
||||||
|
|
||||||
|
# Identifiers accumulate across formats instead of replacing each other. Every
|
||||||
|
# other field is a single value where the later, better-trusted format simply
|
||||||
|
# wins, but identifiers are a *collection*: a book holding an EPUB and a PDF
|
||||||
|
# genuinely carries what both of them declare, and merging the dict wholesale
|
||||||
|
# threw away everything the earlier format found. An EPUB that declares an
|
||||||
|
# ASIN, a Google volume id and a Calibre id kept none of them once a PDF
|
||||||
|
# contributed a single ISBN.
|
||||||
|
identifiers: dict[str, str] = {}
|
||||||
|
|
||||||
for file in sorted_files:
|
for file in sorted_files:
|
||||||
match get_file_extension(file):
|
match get_file_extension(file):
|
||||||
case "epub":
|
case "epub":
|
||||||
metadata = metadata | await EpubExtractor.extract_metadata(file)
|
extracted = await EpubExtractor.extract_metadata(file)
|
||||||
case "pdf":
|
case "pdf":
|
||||||
metadata = metadata | await PdfExtractor.extract_metadata(file)
|
extracted = await PdfExtractor.extract_metadata(file)
|
||||||
case _:
|
case _:
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# First writer wins per name, and the files are already ordered by how
|
||||||
|
# far their metadata can be trusted. A `dc:identifier` the publisher
|
||||||
|
# declared outranks an ISBN scraped out of a PDF's copyright page, which
|
||||||
|
# routinely prints the ISBNs of other formats and older editions too.
|
||||||
|
for name, value in (extracted.pop("identifiers", None) or {}).items():
|
||||||
|
identifiers.setdefault(name, value)
|
||||||
|
|
||||||
|
metadata = metadata | extracted
|
||||||
|
|
||||||
|
if identifiers:
|
||||||
|
metadata["identifiers"] = identifiers
|
||||||
|
|
||||||
# Get metadata from file names
|
# Get metadata from file names
|
||||||
for file in files:
|
for file in files:
|
||||||
metadata = FilenameExtractor.extract_metadata(file) | metadata
|
metadata = FilenameExtractor.extract_metadata(file) | metadata
|
||||||
|
|
||||||
# Get metadata from filepath
|
# Get metadata from filepath. Kept on the left so that anything the file
|
||||||
metadata = metadata | FilepathExtractor.extract_metadata(files[0], root_path)
|
# itself declared outranks a guess made from its directory names — a folder
|
||||||
|
# called "Fluent Python - Luciano Ramalho" must not overwrite the title the
|
||||||
|
# EPUB already carries.
|
||||||
|
metadata = FilepathExtractor.extract_metadata(files[0], root_path) | metadata
|
||||||
|
|
||||||
# format the title
|
# format the title
|
||||||
if metadata.get('title', None):
|
if metadata.get('title', None):
|
||||||
title, subtitle = Extractor.format_book_title(metadata["title"])
|
# Before the subtitle split, so the edition cannot be mistaken for one:
|
||||||
|
# "How Linux Works, 3rd Edition: What Every Superuser Should Know" has to
|
||||||
|
# lose the edition first for the colon count to mean anything.
|
||||||
|
title, edition = split_edition(metadata["title"])
|
||||||
|
if edition is not None:
|
||||||
|
metadata.setdefault("edition", edition)
|
||||||
|
|
||||||
|
title, subtitle = Extractor.format_book_title(title)
|
||||||
metadata["title"] = title
|
metadata["title"] = title
|
||||||
metadata["subtitle"] = subtitle
|
metadata["subtitle"] = subtitle
|
||||||
|
|
||||||
@@ -233,7 +404,7 @@ class PdfExtractor(FileExtractor):
|
|||||||
try:
|
try:
|
||||||
return datetime.datetime.strptime(date_portion, "%Y%m%d").date()
|
return datetime.datetime.strptime(date_portion, "%Y%m%d").date()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -360,15 +531,23 @@ class EpubExtractor(FileExtractor):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _extract_identifiers(cls, epub: epub.EpubBook) -> dict[str, str]:
|
def _extract_identifiers(cls, epub: epub.EpubBook) -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Every `DC:identifier` the file carries, keyed by what kind of thing it is.
|
||||||
|
|
||||||
|
Non-ISBN identifiers are kept: `Identifier` is a free-form name/value pair, so
|
||||||
|
a Calibre id or an ASIN costs nothing to store and is one more thing two copies
|
||||||
|
of a book can be recognised by.
|
||||||
|
"""
|
||||||
identifiers = {}
|
identifiers = {}
|
||||||
|
|
||||||
for id in epub.get_metadata("DC", "identifier"):
|
for value, attributes in epub.get_metadata("DC", "identifier"):
|
||||||
if is_valid_isbn(id[0]):
|
scheme = None
|
||||||
if len(id[0]) == 13:
|
if isinstance(attributes, dict):
|
||||||
identifiers.update({"isbn-13": id[0]})
|
scheme = attributes.get("opf:scheme") or attributes.get("scheme")
|
||||||
|
|
||||||
elif len(id[0]) == 10:
|
if (parsed := parse_identifier(value, scheme)) is not None:
|
||||||
identifiers.update({"isbn-10": id[0]})
|
name, parsed_value = parsed
|
||||||
|
identifiers[name] = parsed_value
|
||||||
|
|
||||||
return identifiers
|
return identifiers
|
||||||
|
|
||||||
@@ -377,7 +556,7 @@ class EpubExtractor(FileExtractor):
|
|||||||
try:
|
try:
|
||||||
return epub.get_metadata("DC", "description")[0][0]
|
return epub.get_metadata("DC", "description")[0][0]
|
||||||
|
|
||||||
except:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -386,15 +565,15 @@ class EpubExtractor(FileExtractor):
|
|||||||
date_str = epub.get_metadata("DC", "date")[0][0].split("T")[0]
|
date_str = epub.get_metadata("DC", "date")[0][0].split("T")[0]
|
||||||
return datetime.date.fromisoformat(date_str)
|
return datetime.date.fromisoformat(date_str)
|
||||||
|
|
||||||
except:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _extract_publisher(cls, epub: epub.EpubBook) -> str | None:
|
def _extract_publisher(cls, epub: epub.EpubBook) -> str | None:
|
||||||
try:
|
try:
|
||||||
epub.get_metadata("DC", "publisher")[0][0]
|
return epub.get_metadata("DC", "publisher")[0][0]
|
||||||
|
|
||||||
except:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -418,7 +597,7 @@ class EpubExtractor(FileExtractor):
|
|||||||
cover_item = epub.get_item_with_id(cover_id)
|
cover_item = epub.get_item_with_id(cover_id)
|
||||||
if cover_item:
|
if cover_item:
|
||||||
return PIL.Image.open(BytesIO(cover_item.content))
|
return PIL.Image.open(BytesIO(cover_item.content))
|
||||||
except Exception as e:
|
except Exception:
|
||||||
pass # Fallback to next strategy
|
pass # Fallback to next strategy
|
||||||
|
|
||||||
# Strategy 2: Search image filenames for "cover" keyword
|
# Strategy 2: Search image filenames for "cover" keyword
|
||||||
@@ -486,7 +665,11 @@ class FilenameExtractor(FileExtractor):
|
|||||||
elif isinstance(input, Path):
|
elif isinstance(input, Path):
|
||||||
filename = get_filename(input, ext=False)
|
filename = get_filename(input, ext=False)
|
||||||
elif isinstance(input, UploadFile):
|
elif isinstance(input, UploadFile):
|
||||||
filename = Path(input.filename).name
|
# `.stem`, not `.name`: the extension is not part of the metadata, and
|
||||||
|
# this is the browser upload path, so keeping it is how a library fills
|
||||||
|
# up with authors called "Sam Newman.epub". The other two branches have
|
||||||
|
# always stripped it.
|
||||||
|
filename = Path(input.filename).stem
|
||||||
else:
|
else:
|
||||||
raise ValueError("Input type not supported")
|
raise ValueError("Input type not supported")
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,13 @@ def convert_book_to_entry(book: m.Book) -> Entry:
|
|||||||
link=[
|
link=[
|
||||||
ImageLink(href=f"/{book.cover_image}", type="image/webp"),
|
ImageLink(href=f"/{book.cover_image}", type="image/webp"),
|
||||||
*[
|
*[
|
||||||
|
# The only place a content type has to be a string: `Link.type` is
|
||||||
|
# required, and a null fails the whole feed rather than one entry.
|
||||||
|
# `application/octet-stream` is the registered way to say "opaque
|
||||||
|
# bytes", which is exactly what an unnamed format is.
|
||||||
AcquisitionLink(
|
AcquisitionLink(
|
||||||
href=f"/opds/download/{book.id}/{file.id}", type=file.content_type
|
href=f"/opds/download/{book.id}/{file.id}",
|
||||||
|
type=file.content_type or "application/octet-stream",
|
||||||
)
|
)
|
||||||
for file in book.files
|
for file in book.files
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
# Standard library
|
# Standard library
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import errno
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import mimetypes
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
from typing import TYPE_CHECKING, BinaryIO
|
from typing import BinaryIO
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from hashlib import _Hash
|
|
||||||
|
|
||||||
# Third-party libraries
|
# Third-party libraries
|
||||||
import PIL
|
import PIL
|
||||||
@@ -32,6 +31,9 @@ KO_STEP = 1024
|
|||||||
KO_SAMPLE_SIZE = 1024
|
KO_SAMPLE_SIZE = 1024
|
||||||
KO_INDICES = range(-1, 11) # -1 to 10 inclusive
|
KO_INDICES = range(-1, 11) # -1 to 10 inclusive
|
||||||
|
|
||||||
|
# How much is read at a time while hashing.
|
||||||
|
HASH_CHUNK_SIZE = 262144 # 256 KiB
|
||||||
|
|
||||||
|
|
||||||
def _lshift32(val: int, shift: int) -> int:
|
def _lshift32(val: int, shift: int) -> int:
|
||||||
"""
|
"""
|
||||||
@@ -100,10 +102,9 @@ async def calculate_koreader_hash(file_path: Path) -> str:
|
|||||||
offsets = _get_koreader_offsets()
|
offsets = _get_koreader_offsets()
|
||||||
|
|
||||||
file_pos = 0
|
file_pos = 0
|
||||||
chunk_size = 262144 # 256 KiB
|
|
||||||
|
|
||||||
async with aiofiles.open(file_path, "rb") as f:
|
async with aiofiles.open(file_path, "rb") as f:
|
||||||
while chunk := await f.read(chunk_size):
|
while chunk := await f.read(HASH_CHUNK_SIZE):
|
||||||
_partial_md5_from_chunk(chunk, hasher, offsets, file_pos)
|
_partial_md5_from_chunk(chunk, hasher, offsets, file_pos)
|
||||||
file_pos += len(chunk)
|
file_pos += len(chunk)
|
||||||
|
|
||||||
@@ -132,6 +133,49 @@ class StreamingHasher:
|
|||||||
"""Return the final hash."""
|
"""Return the final hash."""
|
||||||
return self.hasher.hexdigest()
|
return self.hasher.hexdigest()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
"""Total number of bytes fed in so far."""
|
||||||
|
return self.position
|
||||||
|
|
||||||
|
|
||||||
|
async def fingerprint_upload(file: UploadFile) -> tuple[str, int]:
|
||||||
|
"""
|
||||||
|
Calculate the hash and byte size of an uploaded file without storing it.
|
||||||
|
|
||||||
|
Duplicate detection has to answer before anything is written to the library, so
|
||||||
|
the file is read here and rewound for whoever writes it afterwards.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: The uploaded file to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The file's `(hash, size)` pair.
|
||||||
|
"""
|
||||||
|
hasher = StreamingHasher()
|
||||||
|
|
||||||
|
await file.seek(0)
|
||||||
|
while chunk := await file.read(HASH_CHUNK_SIZE):
|
||||||
|
hasher.update(chunk)
|
||||||
|
await file.seek(0)
|
||||||
|
|
||||||
|
return hasher.hexdigest(), hasher.size
|
||||||
|
|
||||||
|
|
||||||
|
async def fingerprint_file(file_path: Path) -> tuple[str, int]:
|
||||||
|
"""
|
||||||
|
Calculate the hash and byte size of a file already on disk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the file to read.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The file's `(hash, size)` pair.
|
||||||
|
"""
|
||||||
|
stats = await aios.stat(file_path)
|
||||||
|
return await calculate_koreader_hash(file_path), stats.st_size
|
||||||
|
|
||||||
|
|
||||||
##################################
|
##################################
|
||||||
# Filesystem related utilities #
|
# Filesystem related utilities #
|
||||||
##################################
|
##################################
|
||||||
@@ -184,7 +228,39 @@ async def move_file(src_path: Path, dest_path: Path, create_dirs=True) -> None:
|
|||||||
if dest_dir: # Only create if there's a directory path
|
if dest_dir: # Only create if there's a directory path
|
||||||
await aios.makedirs(dest_dir, exist_ok=True)
|
await aios.makedirs(dest_dir, exist_ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
await aios.rename(src_path, dest_path)
|
await aios.rename(src_path, dest_path)
|
||||||
|
except OSError as exc:
|
||||||
|
if exc.errno != errno.EXDEV:
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Source and destination are on different filesystems, which rename cannot
|
||||||
|
# cross. Libraries, the consume directory and the duplicates directory are
|
||||||
|
# all configured separately, so they can easily be separate mounts.
|
||||||
|
shutil.move(str(src_path), str(dest_path))
|
||||||
|
|
||||||
|
async def copy_file(src_path: Path, dest_path: Path, create_dirs: bool = True) -> None:
|
||||||
|
"""
|
||||||
|
Copy a file, streaming it rather than reading it whole.
|
||||||
|
|
||||||
|
`shutil.copy` would block the event loop for as long as the read takes, which for a
|
||||||
|
40 MB ebook — and a few thousand of them in a row — is not acceptable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
src_path: The file to copy. Left exactly as it is.
|
||||||
|
dest_path: Where the copy goes.
|
||||||
|
create_dirs: Create the destination's parent directories first.
|
||||||
|
"""
|
||||||
|
if create_dirs and dest_path.parent:
|
||||||
|
await aios.makedirs(dest_path.parent, exist_ok=True)
|
||||||
|
|
||||||
|
async with (
|
||||||
|
aiofiles.open(src_path, "rb") as source,
|
||||||
|
aiofiles.open(dest_path, "wb") as destination,
|
||||||
|
):
|
||||||
|
while chunk := await source.read(HASH_CHUNK_SIZE):
|
||||||
|
await destination.write(chunk)
|
||||||
|
|
||||||
|
|
||||||
async def move_dir_contents(source_dir: Path | str, target_dir: Path | str) -> None:
|
async def move_dir_contents(source_dir: Path | str, target_dir: Path | str) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -407,6 +483,66 @@ def get_filename(file: Path | str, ext: bool = True) -> str:
|
|||||||
return filename.stem
|
return filename.stem
|
||||||
|
|
||||||
|
|
||||||
|
# Content types for the ebook formats `mimetypes` does not know. Python's built-in map
|
||||||
|
# covers `.epub`, `.pdf`, `.azw3`, `.cbz`, `.cbr` and `.djvu`, and answers `None` for
|
||||||
|
# every format below — so a library imported from elsewhere, which is where MOBI and
|
||||||
|
# AZW files come from, stores nothing for them.
|
||||||
|
#
|
||||||
|
# That matters downstream because an OPDS acquisition link is how a reader app decides
|
||||||
|
# whether it can open a file at all.
|
||||||
|
|
||||||
|
# What a client sends when it does not know either. Treated as an absence rather than
|
||||||
|
# an answer: storing it would be indistinguishable from having determined a format, and
|
||||||
|
# it is the value browsers post for every extension they do not recognise.
|
||||||
|
_UNSPECIFIED = "application/octet-stream"
|
||||||
|
|
||||||
|
EBOOK_CONTENT_TYPES = {
|
||||||
|
"mobi": "application/x-mobipocket-ebook",
|
||||||
|
"prc": "application/x-mobipocket-ebook",
|
||||||
|
"azw": "application/vnd.amazon.ebook",
|
||||||
|
"fb2": "application/x-fictionbook+xml",
|
||||||
|
"fbz": "application/x-zip-compressed-fb2",
|
||||||
|
"lit": "application/x-ms-reader",
|
||||||
|
"lrf": "application/x-sony-bbeb",
|
||||||
|
"cb7": "application/x-cb7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def guess_content_type(
|
||||||
|
file: Path | str | UploadFile, fallback: str | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
Name a file's format from its extension.
|
||||||
|
|
||||||
|
The extension is trusted ahead of anything a client said: a browser posts
|
||||||
|
`application/octet-stream` for every format it does not recognise, which is most
|
||||||
|
ebook formats, and that answer is worth less than the `.mobi` on the end of the
|
||||||
|
name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: The file to name, as a path or an upload.
|
||||||
|
fallback: What to use when neither table knows the extension — a client-supplied
|
||||||
|
content type, if there is one. `application/octet-stream` is discarded: it
|
||||||
|
is the client saying it does not know, which is not information.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The content type, or None when nothing can name it. Null is the honest answer
|
||||||
|
and the column is nullable: a caller that structurally needs a string should
|
||||||
|
substitute one where it needs it, rather than have an invented value stored.
|
||||||
|
"""
|
||||||
|
extension = get_file_extension(file)
|
||||||
|
|
||||||
|
if known := EBOOK_CONTENT_TYPES.get(extension):
|
||||||
|
return known
|
||||||
|
|
||||||
|
guessed, _ = mimetypes.guess_type(get_filename(file))
|
||||||
|
|
||||||
|
if fallback == _UNSPECIFIED:
|
||||||
|
fallback = None
|
||||||
|
|
||||||
|
return guessed or fallback
|
||||||
|
|
||||||
|
|
||||||
###############################
|
###############################
|
||||||
# ISBN Validation utilities #
|
# ISBN Validation utilities #
|
||||||
###############################
|
###############################
|
||||||
@@ -432,7 +568,7 @@ def is_valid_isbn(isbn: str) -> bool:
|
|||||||
return is_valid_isbn13(isbn)
|
return is_valid_isbn13(isbn)
|
||||||
else:
|
else:
|
||||||
return False
|
return False
|
||||||
except:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@@ -457,6 +593,29 @@ def is_valid_isbn10(isbn: str) -> bool:
|
|||||||
return str(check_digit) == isbn[-1] or (check_digit == 10 and isbn[-1] in "Xx")
|
return str(check_digit) == isbn[-1] or (check_digit == 10 and isbn[-1] in "Xx")
|
||||||
|
|
||||||
|
|
||||||
|
def isbn10_to_isbn13(isbn: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Convert an ISBN-10 to the ISBN-13 naming the same edition.
|
||||||
|
|
||||||
|
The two are the same number written twice: prefix `978`, drop the ISBN-10 check
|
||||||
|
digit, recompute the check digit under the ISBN-13 rule. Matching only works if
|
||||||
|
both forms collapse onto one, since a publisher may print either.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
isbn: A 10-character ISBN, digits and an optional trailing `X` only.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The equivalent ISBN-13, or None if the input is not a valid ISBN-10.
|
||||||
|
"""
|
||||||
|
if not is_valid_isbn(isbn) or len(isbn) != 10:
|
||||||
|
return None
|
||||||
|
|
||||||
|
digits = f"978{isbn[:9]}"
|
||||||
|
total = sum(int(digit) * (1 if i % 2 == 0 else 3) for i, digit in enumerate(digits))
|
||||||
|
|
||||||
|
return f"{digits}{(10 - total % 10) % 10}"
|
||||||
|
|
||||||
|
|
||||||
def is_valid_isbn13(isbn: str) -> bool:
|
def is_valid_isbn13(isbn: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Validate an ISBN-13 number using its check digit.
|
Validate an ISBN-13 number using its check digit.
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""
|
||||||
|
Build a Calibre library on disk, for tests to read.
|
||||||
|
|
||||||
|
Generated rather than committed as a binary `metadata.db`, because the rows worth
|
||||||
|
testing are the awkward ones — the year-101 pubdate, a `|` in an author name, a REAL
|
||||||
|
series index, HTML in a comment — and those are clearer written out in Python than
|
||||||
|
hidden inside a blob.
|
||||||
|
|
||||||
|
The schema below is Calibre's own, copied from a real library's `sqlite_master`, reduced
|
||||||
|
to the tables the reader touches. `books_pages_link` is created separately by
|
||||||
|
`add_pages`: it is recent, and a library made by an older Calibre will not have it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE books (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL DEFAULT 'Unknown' COLLATE NOCASE,
|
||||||
|
sort TEXT COLLATE NOCASE,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
pubdate TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
series_index REAL NOT NULL DEFAULT 1.0,
|
||||||
|
author_sort TEXT COLLATE NOCASE,
|
||||||
|
path TEXT NOT NULL DEFAULT '',
|
||||||
|
uuid TEXT,
|
||||||
|
has_cover BOOL DEFAULT 0,
|
||||||
|
last_modified TIMESTAMP NOT NULL DEFAULT '2000-01-01 00:00:00+00:00'
|
||||||
|
);
|
||||||
|
CREATE TABLE authors (
|
||||||
|
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
|
||||||
|
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE(name)
|
||||||
|
);
|
||||||
|
CREATE TABLE books_authors_link (
|
||||||
|
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, author INTEGER NOT NULL,
|
||||||
|
UNIQUE(book, author)
|
||||||
|
);
|
||||||
|
CREATE TABLE publishers (
|
||||||
|
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
|
||||||
|
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE(name)
|
||||||
|
);
|
||||||
|
CREATE TABLE books_publishers_link (
|
||||||
|
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, publisher INTEGER NOT NULL,
|
||||||
|
UNIQUE(book)
|
||||||
|
);
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
|
||||||
|
link TEXT NOT NULL DEFAULT '', UNIQUE (name)
|
||||||
|
);
|
||||||
|
CREATE TABLE books_tags_link (
|
||||||
|
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, tag INTEGER NOT NULL,
|
||||||
|
UNIQUE(book, tag)
|
||||||
|
);
|
||||||
|
CREATE TABLE series (
|
||||||
|
id INTEGER PRIMARY KEY, name TEXT NOT NULL COLLATE NOCASE,
|
||||||
|
sort TEXT COLLATE NOCASE, link TEXT NOT NULL DEFAULT '', UNIQUE (name)
|
||||||
|
);
|
||||||
|
CREATE TABLE books_series_link (
|
||||||
|
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, series INTEGER NOT NULL,
|
||||||
|
UNIQUE(book)
|
||||||
|
);
|
||||||
|
CREATE TABLE languages (
|
||||||
|
id INTEGER PRIMARY KEY, lang_code TEXT NOT NULL COLLATE NOCASE,
|
||||||
|
link TEXT NOT NULL DEFAULT '', UNIQUE(lang_code)
|
||||||
|
);
|
||||||
|
CREATE TABLE books_languages_link (
|
||||||
|
id INTEGER PRIMARY KEY, book INTEGER NOT NULL, lang_code INTEGER NOT NULL,
|
||||||
|
item_order INTEGER NOT NULL DEFAULT 0, UNIQUE(book, lang_code)
|
||||||
|
);
|
||||||
|
CREATE TABLE comments (
|
||||||
|
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
|
||||||
|
text TEXT NOT NULL COLLATE NOCASE, UNIQUE(book)
|
||||||
|
);
|
||||||
|
CREATE TABLE identifiers (
|
||||||
|
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL DEFAULT 'isbn' COLLATE NOCASE,
|
||||||
|
val TEXT NOT NULL COLLATE NOCASE, UNIQUE(book, type)
|
||||||
|
);
|
||||||
|
CREATE TABLE data (
|
||||||
|
id INTEGER PRIMARY KEY, book INTEGER NOT NULL,
|
||||||
|
format TEXT NOT NULL COLLATE NOCASE, uncompressed_size INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL, UNIQUE(book, format)
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Calibre's own "no date". Stored, never null, and a valid date — which is exactly why
|
||||||
|
# it has to be recognised rather than parsed.
|
||||||
|
UNDEFINED_DATE = "0101-01-01 00:00:00+00:00"
|
||||||
|
|
||||||
|
# What a `cover.jpg` that PIL cannot read looks like. Real libraries hold these, from
|
||||||
|
# an interrupted download or a failed conversion.
|
||||||
|
CORRUPT_COVER = b"\xff\xd8\xff\xe0 not really a jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def write_cover(path: Path) -> None:
|
||||||
|
"""
|
||||||
|
Write a real, readable JPEG.
|
||||||
|
|
||||||
|
Generated with PIL rather than embedded as a hex blob: a hand-rolled JPEG that is
|
||||||
|
subtly malformed fails inside the import as an unrelated error, which is exactly the
|
||||||
|
confusion this avoids.
|
||||||
|
"""
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
Image.new("RGB", (2, 3), (10, 20, 30)).save(path, "JPEG")
|
||||||
|
|
||||||
|
|
||||||
|
class CalibreFixture:
|
||||||
|
"""A Calibre library being assembled under `root`."""
|
||||||
|
|
||||||
|
def __init__(self, root: Path) -> None:
|
||||||
|
self.root = root
|
||||||
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.connection = sqlite3.connect(self.root / "metadata.db")
|
||||||
|
self.connection.executescript(SCHEMA)
|
||||||
|
|
||||||
|
def add_pages_table(self) -> None:
|
||||||
|
"""Add `books_pages_link`, which only a recent Calibre creates."""
|
||||||
|
self.connection.executescript(
|
||||||
|
"""
|
||||||
|
CREATE TABLE books_pages_link (
|
||||||
|
book INTEGER PRIMARY KEY,
|
||||||
|
pages INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
algorithm INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
format TEXT DEFAULT '' NOT NULL COLLATE NOCASE,
|
||||||
|
format_size INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
needs_scan INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_book(
|
||||||
|
self,
|
||||||
|
book_id: int,
|
||||||
|
title: str,
|
||||||
|
*,
|
||||||
|
authors: list[str] | None = None,
|
||||||
|
pubdate: str = UNDEFINED_DATE,
|
||||||
|
series: str | None = None,
|
||||||
|
series_index: float = 1.0,
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
publisher: str | None = None,
|
||||||
|
languages: list[str] | None = None,
|
||||||
|
comment: str | None = None,
|
||||||
|
identifiers: dict[str, str] | None = None,
|
||||||
|
uuid: str | None = None,
|
||||||
|
pages: int | None = None,
|
||||||
|
cover: bool = False,
|
||||||
|
corrupt_cover: bool = False,
|
||||||
|
formats: dict[str, Path] | None = None,
|
||||||
|
directory: str | None = None,
|
||||||
|
) -> Path:
|
||||||
|
"""
|
||||||
|
Add one book, with its files laid out the way Calibre lays them out.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
formats: Format name (`EPUB`) to a real file to copy in. Its on-disk stem is
|
||||||
|
Calibre's, not the title — that is the point of the `data` table.
|
||||||
|
directory: Override the `books.path` value, for testing a row whose
|
||||||
|
directory is not where the convention would put it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The book's directory.
|
||||||
|
"""
|
||||||
|
author_names = authors or ["Unknown"]
|
||||||
|
relative = directory or f"{author_names[0]}/{title} ({book_id})"
|
||||||
|
book_directory = self.root / relative
|
||||||
|
book_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT INTO books (id, title, pubdate, series_index, path, uuid, has_cover) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(
|
||||||
|
book_id,
|
||||||
|
title,
|
||||||
|
pubdate,
|
||||||
|
series_index,
|
||||||
|
relative,
|
||||||
|
uuid or f"uuid-{book_id}",
|
||||||
|
int(cover or corrupt_cover),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in author_names:
|
||||||
|
self._link("authors", "books_authors_link", "author", book_id, name)
|
||||||
|
|
||||||
|
for name in tags or []:
|
||||||
|
self._link("tags", "books_tags_link", "tag", book_id, name)
|
||||||
|
|
||||||
|
if series:
|
||||||
|
self._link("series", "books_series_link", "series", book_id, series)
|
||||||
|
|
||||||
|
if publisher:
|
||||||
|
self._link(
|
||||||
|
"publishers", "books_publishers_link", "publisher", book_id, publisher
|
||||||
|
)
|
||||||
|
|
||||||
|
for order, code in enumerate(languages or []):
|
||||||
|
language_id = self._lookup("languages", "lang_code", code)
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT INTO books_languages_link (book, lang_code, item_order) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
(book_id, language_id, order),
|
||||||
|
)
|
||||||
|
|
||||||
|
if comment is not None:
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT INTO comments (book, text) VALUES (?, ?)", (book_id, comment)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, value in (identifiers or {}).items():
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT INTO identifiers (book, type, val) VALUES (?, ?, ?)",
|
||||||
|
(book_id, name, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
if pages is not None:
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT INTO books_pages_link (book, pages) VALUES (?, ?)",
|
||||||
|
(book_id, pages),
|
||||||
|
)
|
||||||
|
|
||||||
|
if corrupt_cover:
|
||||||
|
(book_directory / "cover.jpg").write_bytes(CORRUPT_COVER)
|
||||||
|
elif cover:
|
||||||
|
write_cover(book_directory / "cover.jpg")
|
||||||
|
|
||||||
|
for format, origin in (formats or {}).items():
|
||||||
|
# Calibre's on-disk stem: sanitised, truncated, and not the title.
|
||||||
|
stem = f"{title[:40]} - {author_names[0]}".replace(":", "_")
|
||||||
|
destination = book_directory / f"{stem}.{format.lower()}"
|
||||||
|
shutil.copy(origin, destination)
|
||||||
|
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT INTO data (book, format, uncompressed_size, name) "
|
||||||
|
"VALUES (?, ?, ?, ?)",
|
||||||
|
(book_id, format, destination.stat().st_size, stem),
|
||||||
|
)
|
||||||
|
|
||||||
|
return book_directory
|
||||||
|
|
||||||
|
def add_missing_format(self, book_id: int, format: str, stem: str) -> None:
|
||||||
|
"""Record a file in the catalogue without putting one on disk."""
|
||||||
|
self.connection.execute(
|
||||||
|
"INSERT INTO data (book, format, uncompressed_size, name) VALUES (?, ?, ?, ?)",
|
||||||
|
(book_id, format, 1234, stem),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _link(
|
||||||
|
self, table: str, link_table: str, column: str, book_id: int, name: str
|
||||||
|
) -> None:
|
||||||
|
item_id = self._lookup(table, "name", name)
|
||||||
|
self.connection.execute(
|
||||||
|
f"INSERT INTO {link_table} (book, {column}) VALUES (?, ?)",
|
||||||
|
(book_id, item_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _lookup(self, table: str, column: str, value: str) -> int:
|
||||||
|
row = self.connection.execute(
|
||||||
|
f"SELECT id FROM {table} WHERE {column} = ?", (value,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row:
|
||||||
|
return int(row[0])
|
||||||
|
|
||||||
|
cursor = self.connection.execute(
|
||||||
|
f"INSERT INTO {table} ({column}) VALUES (?)", (value,)
|
||||||
|
)
|
||||||
|
return int(cursor.lastrowid or 0)
|
||||||
|
|
||||||
|
def commit(self) -> Path:
|
||||||
|
"""Finish writing and return the library root."""
|
||||||
|
self.connection.commit()
|
||||||
|
self.connection.close()
|
||||||
|
return self.root
|
||||||
@@ -40,7 +40,13 @@ pytest_plugins = [
|
|||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def _patch_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def _patch_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr(settings, "book_cover_path", f"{tmp_path}/covers")
|
# PIL will not create the directory it is asked to save into, so anything that
|
||||||
|
# imports a file carrying a cover needs it to exist first.
|
||||||
|
covers = tmp_path / "covers"
|
||||||
|
covers.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "book_cover_path", str(covers))
|
||||||
|
monkeypatch.setattr(settings, "duplicate_path", str(tmp_path / "duplicates"))
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="engine")
|
@pytest.fixture(name="engine")
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ from pathlib import Path
|
|||||||
(
|
(
|
||||||
Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"),
|
Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf"),
|
||||||
2,
|
2,
|
||||||
"The Project Gutenberg eBook #33283: Calculus Made Easy, 2nd Edition",
|
# The ", 2nd Edition" is split off into `edition`, not kept in the title.
|
||||||
|
"The Project Gutenberg eBook #33283: Calculus Made Easy",
|
||||||
["Silvanus Phillips Thompson"],
|
["Silvanus Phillips Thompson"],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -208,6 +209,21 @@ async def test_get_book_file(
|
|||||||
assert downloaded_content == file_content
|
assert downloaded_content == file_content
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_books_by_id(populated_authenticated_client: AsyncClient) -> None:
|
||||||
|
"""
|
||||||
|
`?ids=` has to reach the database as integers.
|
||||||
|
|
||||||
|
advanced_alchemy's stock id filter annotates the parameter as `list[str]` whatever
|
||||||
|
the configured id type, so the ids arrived as strings and Postgres refused to
|
||||||
|
compare a bigint primary key against them. Nothing called it until a screen needed
|
||||||
|
to fetch a handful of books by id.
|
||||||
|
"""
|
||||||
|
response = await populated_authenticated_client.get("/books?ids=1&ids=2&pageSize=10")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert sorted(book["id"] for book in response.json()["items"]) == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> None:
|
async def test_get_book_by_id(populated_authenticated_client: AsyncClient) -> None:
|
||||||
"""Test retrieving a specific book by ID."""
|
"""Test retrieving a specific book by ID."""
|
||||||
|
|
||||||
@@ -366,7 +382,395 @@ async def test_create_multiple_books_from_directory(
|
|||||||
|
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert len(data.get("items") or data.get("data")) >= 1
|
assert len(data["created"]) == 2
|
||||||
|
assert data["skipped"] == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_books_from_parent_directory_keeps_embedded_title(
|
||||||
|
authenticated_client: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""A folder name in the upload path must not override the file's own metadata.
|
||||||
|
|
||||||
|
The browser sends webkitRelativePath, so picking the shelf above a book's folder
|
||||||
|
submits one more path component than picking the folder itself. That extra level
|
||||||
|
used to make the directory name win over the title inside the EPUB.
|
||||||
|
"""
|
||||||
|
source = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||||
|
files = [
|
||||||
|
(
|
||||||
|
"files",
|
||||||
|
(
|
||||||
|
"Shelf/Metamorphosis - Franz Kafka/Metamorphosis.epub",
|
||||||
|
source.read_bytes(),
|
||||||
|
"application/epub+zip",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=files, data={"library_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
books = response.json()["created"]
|
||||||
|
assert len(books) == 1
|
||||||
|
assert books[0]["title"] == "Metamorphosis"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_books_groups_formats_within_one_folder(
|
||||||
|
authenticated_client: AsyncClient,
|
||||||
|
) -> None:
|
||||||
|
"""Picking a book's own folder yields one book with both formats, not two books."""
|
||||||
|
epub = Path("tests/data_files/Metamorphosis - Franz Kafka.epub").read_bytes()
|
||||||
|
pdf = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf").read_bytes()
|
||||||
|
|
||||||
|
files = [
|
||||||
|
("files", ("Metamorphosis/Metamorphosis.epub", epub, "application/epub+zip")),
|
||||||
|
("files", ("Metamorphosis/Metamorphosis.pdf", pdf, "application/pdf")),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=files, data={"library_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
books = response.json()["created"]
|
||||||
|
assert len(books) == 1
|
||||||
|
assert len(books[0]["files"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestDuplicateHandling:
|
||||||
|
"""A file the library already holds must not be stored a second time."""
|
||||||
|
|
||||||
|
epub_path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||||
|
|
||||||
|
def upload(self, name: str | None = None) -> list[tuple[str, tuple]]:
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
"files",
|
||||||
|
(
|
||||||
|
name or self.epub_path.name,
|
||||||
|
self.epub_path.read_bytes(),
|
||||||
|
"application/epub+zip",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_bulk_upload_reports_skipped_files(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Re-dropping a folder must import what is new and name what was not."""
|
||||||
|
first = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload()
|
||||||
|
)
|
||||||
|
assert first.status_code == 201
|
||||||
|
created = first.json()["created"][0]
|
||||||
|
|
||||||
|
second = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert second.status_code == 201
|
||||||
|
result = second.json()
|
||||||
|
assert result["created"] == []
|
||||||
|
assert len(result["skipped"]) == 1
|
||||||
|
|
||||||
|
skipped = result["skipped"][0]
|
||||||
|
assert skipped["filename"] == self.epub_path.name
|
||||||
|
assert skipped["book_id"] == created["id"]
|
||||||
|
assert skipped["book_title"] == created["title"]
|
||||||
|
|
||||||
|
async def test_bulk_upload_can_be_forced(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload()
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1&allow_duplicates=true", files=self.upload()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert len(response.json()["created"]) == 1
|
||||||
|
assert response.json()["skipped"] == []
|
||||||
|
|
||||||
|
async def test_single_book_create_conflicts(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Naming files deliberately earns a refusal rather than a silent drop."""
|
||||||
|
await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload()
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books?library_id=1", files=self.upload(), data={"library_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["extra"][0]["filename"] == self.epub_path.name
|
||||||
|
|
||||||
|
forced = await authenticated_client.post(
|
||||||
|
"/books?library_id=1&allow_duplicates=true",
|
||||||
|
files=self.upload(),
|
||||||
|
data={"library_id": 1},
|
||||||
|
)
|
||||||
|
assert forced.status_code == 201
|
||||||
|
|
||||||
|
async def test_adding_another_books_file_conflicts(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
created = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload()
|
||||||
|
)
|
||||||
|
book_id = created.json()["created"][0]["id"]
|
||||||
|
|
||||||
|
other = await authenticated_client.post(
|
||||||
|
"/books?library_id=1",
|
||||||
|
files=[
|
||||||
|
(
|
||||||
|
"files",
|
||||||
|
(
|
||||||
|
"war.epub",
|
||||||
|
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
|
||||||
|
"application/epub+zip",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
data={"library_id": 1},
|
||||||
|
)
|
||||||
|
other_id = other.json()["id"]
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
f"/books/{book_id}/files",
|
||||||
|
files=[
|
||||||
|
(
|
||||||
|
"files",
|
||||||
|
(
|
||||||
|
"war.epub",
|
||||||
|
Path("tests/data_files/The Art of War - Sun Tzu.epub").read_bytes(),
|
||||||
|
"application/epub+zip",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["extra"][0]["book_id"] == other_id
|
||||||
|
|
||||||
|
async def test_resending_a_books_own_file_changes_nothing(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
created = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload()
|
||||||
|
)
|
||||||
|
book_id = created.json()["created"][0]["id"]
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
f"/books/{book_id}/files", files=self.upload()
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
assert len(response.json()["files"]) == 1
|
||||||
|
|
||||||
|
async def test_duplicates_can_be_checked_before_uploading(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""The pre-flight check answers from hashes alone, with no file sent."""
|
||||||
|
created = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload()
|
||||||
|
)
|
||||||
|
book = created.json()["created"][0]
|
||||||
|
stored = book["files"][0]
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books/duplicate-files?library_id=1",
|
||||||
|
json=[
|
||||||
|
{
|
||||||
|
"hash": stored["hash"],
|
||||||
|
"size": stored["size"],
|
||||||
|
"filename": "local-copy.epub",
|
||||||
|
},
|
||||||
|
{"hash": stored["hash"], "size": stored["size"] + 1},
|
||||||
|
{"hash": "0" * 32, "size": 1234},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
matches = response.json()
|
||||||
|
assert len(matches) == 1
|
||||||
|
assert matches[0]["filename"] == "local-copy.epub"
|
||||||
|
assert matches[0]["book_id"] == book["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestDuplicateBooks:
|
||||||
|
"""A second copy of a book is imported and reported, never refused."""
|
||||||
|
|
||||||
|
epub_path = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||||
|
|
||||||
|
def upload(self, name: str, pad: bool = False) -> list[tuple[str, tuple]]:
|
||||||
|
"""
|
||||||
|
The fixture, optionally padded so it is a different file and the same book.
|
||||||
|
|
||||||
|
Padding the archive changes its size and its sampled hash without disturbing
|
||||||
|
the metadata, which is exactly the case the file-level check cannot see.
|
||||||
|
"""
|
||||||
|
data = self.epub_path.read_bytes() + (b"\0" * 64 if pad else b"")
|
||||||
|
|
||||||
|
return [("files", (name, data, "application/epub+zip"))]
|
||||||
|
|
||||||
|
async def test_a_second_edition_is_created_and_reported(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
first = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
|
||||||
|
)
|
||||||
|
original = first.json()["created"][0]
|
||||||
|
|
||||||
|
second = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert second.status_code == 201
|
||||||
|
body = second.json()
|
||||||
|
|
||||||
|
# Created, not skipped: a metadata match is a guess, and refusing a legitimate
|
||||||
|
# second edition costs more than a note does.
|
||||||
|
assert len(body["created"]) == 1
|
||||||
|
assert body["skipped"] == []
|
||||||
|
|
||||||
|
assert len(body["possible_duplicates"]) == 1
|
||||||
|
possible = body["possible_duplicates"][0]
|
||||||
|
assert possible["book_id"] == body["created"][0]["id"]
|
||||||
|
|
||||||
|
candidate = possible["candidates"][0]
|
||||||
|
assert candidate["book_id"] == original["id"]
|
||||||
|
assert candidate["title"] == original["title"]
|
||||||
|
assert candidate["authors"] == ["Franz Kafka"]
|
||||||
|
assert "title-author" in candidate["matched_on"]
|
||||||
|
|
||||||
|
async def test_the_review_screen_groups_them(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
first = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
|
||||||
|
)
|
||||||
|
second = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
book_ids = sorted(
|
||||||
|
[first.json()["created"][0]["id"], second.json()["created"][0]["id"]]
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await authenticated_client.get("/books/duplicate-books?library_id=1")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
groups = response.json()
|
||||||
|
assert len(groups) == 1
|
||||||
|
assert [book["book_id"] for book in groups[0]["books"]] == book_ids
|
||||||
|
|
||||||
|
async def test_a_dismissed_group_stays_dismissed(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
first = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
|
||||||
|
)
|
||||||
|
second = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
pair = {
|
||||||
|
"book_a_id": first.json()["created"][0]["id"],
|
||||||
|
"book_b_id": second.json()["created"][0]["id"],
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissed = await authenticated_client.post(
|
||||||
|
"/books/duplicate-books/dismissals", json=pair
|
||||||
|
)
|
||||||
|
assert dismissed.status_code == 204
|
||||||
|
|
||||||
|
response = await authenticated_client.get("/books/duplicate-books?library_id=1")
|
||||||
|
assert response.json() == []
|
||||||
|
|
||||||
|
restored = await authenticated_client.delete(
|
||||||
|
"/books/duplicate-books/dismissals"
|
||||||
|
f"?book_a_id={pair['book_b_id']}&book_b_id={pair['book_a_id']}"
|
||||||
|
)
|
||||||
|
assert restored.status_code == 204
|
||||||
|
|
||||||
|
response = await authenticated_client.get("/books/duplicate-books?library_id=1")
|
||||||
|
assert len(response.json()) == 1
|
||||||
|
|
||||||
|
async def test_two_books_merge_into_one(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""The survivor keeps its id and gains the other's file; the other is gone."""
|
||||||
|
first = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
|
||||||
|
)
|
||||||
|
second = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload("second.epub", pad=True)
|
||||||
|
)
|
||||||
|
keep = first.json()["created"][0]
|
||||||
|
fold = second.json()["created"][0]
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books/merge?library_id=1",
|
||||||
|
json={
|
||||||
|
"survivor_id": keep["id"],
|
||||||
|
"merged_ids": [fold["id"]],
|
||||||
|
"metadata": {"title": "Metamorphosis", "edition": 2},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
merged = response.json()
|
||||||
|
|
||||||
|
assert merged["id"] == keep["id"]
|
||||||
|
assert merged["edition"] == 2
|
||||||
|
assert len(merged["files"]) == 2
|
||||||
|
|
||||||
|
# The folded record is gone, and the group it formed with it.
|
||||||
|
assert (await authenticated_client.get(f"/books/{fold['id']}")).status_code == 404
|
||||||
|
groups = await authenticated_client.get("/books/duplicate-books?library_id=1")
|
||||||
|
assert groups.json() == []
|
||||||
|
|
||||||
|
async def test_merging_an_unknown_book_is_refused(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
created = await authenticated_client.post(
|
||||||
|
"/books/fromFiles?library_id=1", files=self.upload("first.epub")
|
||||||
|
)
|
||||||
|
keep = created.json()["created"][0]["id"]
|
||||||
|
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books/merge?library_id=1",
|
||||||
|
json={"survivor_id": keep, "merged_ids": [9999]},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
async def test_dismissing_an_unknown_book_is_refused(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books/duplicate-books/dismissals",
|
||||||
|
json={"book_a_id": 1, "book_b_id": 9999},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: the multi-book ZIP download is covered at the service level, in
|
||||||
|
# tests/unit/test_services/test_book_service.py. Driving `/books/download` through
|
||||||
|
# AsyncTestClient hangs in fixture teardown: it is the only `Stream` endpoint in the
|
||||||
|
# app, and the test transport never sends the `http.disconnect` that Litestar's
|
||||||
|
# streaming response waits on, so the app's lifespan shutdown never completes.
|
||||||
|
|
||||||
|
|
||||||
# async def test_delete_book_metadata(authenticated_client: AsyncClient) -> None:
|
# async def test_delete_book_metadata(authenticated_client: AsyncClient) -> None:
|
||||||
@@ -868,3 +1272,94 @@ class TestFileManagement:
|
|||||||
)
|
)
|
||||||
# Should succeed (idempotent)
|
# Should succeed (idempotent)
|
||||||
assert response2.status_code == 204
|
assert response2.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
class TestUnnameableFormats:
|
||||||
|
"""
|
||||||
|
A file whose format nothing can name must still round-trip.
|
||||||
|
|
||||||
|
`mimetypes.guess_type` answers None for `.mobi`, `.azw`, `.fb2` and `.lit`, which
|
||||||
|
is most of what a library imported from elsewhere carries alongside its EPUBs.
|
||||||
|
`FileMetadataRead.content_type` used to be a required string, so such a book was
|
||||||
|
created and then failed serialisation on its way back out — a 500 on a book the
|
||||||
|
reader can otherwise download.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def upload(self, name: str) -> list[tuple[str, tuple]]:
|
||||||
|
# `application/octet-stream` is what a browser posts for these, and it is not
|
||||||
|
# an answer — the extension is what names the format.
|
||||||
|
return [("files", (name, b"BOOKMOBI\x00 payload", "application/octet-stream"))]
|
||||||
|
|
||||||
|
async def test_a_mobi_is_named_from_its_extension(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books?library_id=1", files=self.upload("Dune.mobi"), data={"library_id": 1}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
book = response.json()
|
||||||
|
assert book["files"][0]["content_type"] == "application/x-mobipocket-ebook"
|
||||||
|
|
||||||
|
detail = await authenticated_client.get(f"/books/{book['id']}")
|
||||||
|
assert detail.status_code == 200
|
||||||
|
|
||||||
|
async def test_an_unknown_extension_stores_no_content_type(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Null, not a placeholder — and the book still serialises either way."""
|
||||||
|
response = await authenticated_client.post(
|
||||||
|
"/books?library_id=1",
|
||||||
|
files=self.upload("Notes.xyzzy"),
|
||||||
|
data={"library_id": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 201
|
||||||
|
book = response.json()
|
||||||
|
assert book["files"][0]["content_type"] is None
|
||||||
|
|
||||||
|
detail = await authenticated_client.get(f"/books/{book['id']}")
|
||||||
|
assert detail.status_code == 200
|
||||||
|
assert detail.json()["files"][0]["content_type"] is None
|
||||||
|
|
||||||
|
async def test_the_file_downloads(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""Litestar supplies its own media type when the row carries none."""
|
||||||
|
created = await authenticated_client.post(
|
||||||
|
"/books?library_id=1",
|
||||||
|
files=self.upload("Notes.xyzzy"),
|
||||||
|
data={"library_id": 1},
|
||||||
|
)
|
||||||
|
book = created.json()
|
||||||
|
|
||||||
|
response = await authenticated_client.get(
|
||||||
|
f"/books/download/{book['id']}/{book['files'][0]['id']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"] == "application/octet-stream"
|
||||||
|
|
||||||
|
async def test_the_opds_feed_survives_a_null_content_type(
|
||||||
|
self, authenticated_client: AsyncClient
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
The one place the type has to be a string.
|
||||||
|
|
||||||
|
`Link.type` is required, so a null fails the whole feed rather than one entry.
|
||||||
|
OPDS clients speak Basic, not the JWT the rest of the API uses.
|
||||||
|
"""
|
||||||
|
await authenticated_client.post(
|
||||||
|
"/books?library_id=1",
|
||||||
|
files=self.upload("Notes.xyzzy"),
|
||||||
|
data={"library_id": 1},
|
||||||
|
)
|
||||||
|
|
||||||
|
feed = await authenticated_client.get(
|
||||||
|
"/opds/acquisition?feed_id=all&feed_title=All+Books",
|
||||||
|
auth=("user1@example.com", "password123"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert feed.status_code == 200
|
||||||
|
assert 'type="application/octet-stream"' in feed.text
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Calibre import endpoints.
|
||||||
|
|
||||||
|
The API takes a zipped library and nothing else — a desktop Calibre install is usually
|
||||||
|
not on the server, and importing from a path the server can already see stays a
|
||||||
|
server-side operation (`litestar calibre-import`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import AsyncClient
|
||||||
|
|
||||||
|
from chitai.services.calibre_import import registry
|
||||||
|
|
||||||
|
from tests.calibre_fixtures import CalibreFixture
|
||||||
|
|
||||||
|
|
||||||
|
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||||
|
OTHER_EPUB = Path("tests/data_files/The Art of War - Sun Tzu.epub")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_registry():
|
||||||
|
"""The registry is a module-level singleton, so it leaks between tests."""
|
||||||
|
registry._jobs.clear()
|
||||||
|
yield
|
||||||
|
registry._jobs.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="source")
|
||||||
|
def fx_source(tmp_path: Path) -> Path:
|
||||||
|
fixture = CalibreFixture(tmp_path / "calibre")
|
||||||
|
fixture.add_book(
|
||||||
|
1,
|
||||||
|
"The Metamorphosis",
|
||||||
|
authors=["Franz Kafka"],
|
||||||
|
tags=["Fiction"],
|
||||||
|
cover=True,
|
||||||
|
formats={"EPUB": EPUB},
|
||||||
|
)
|
||||||
|
fixture.add_book(2, "The Art of War", authors=["Sun Tzu"], formats={"EPUB": OTHER_EPUB})
|
||||||
|
fixture.add_book(3, "Metadata Only", authors=["Nobody"])
|
||||||
|
|
||||||
|
return fixture.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def zip_of(root: Path, into: Path, prefix: str = "") -> Path:
|
||||||
|
"""Zip a directory the way a file manager would."""
|
||||||
|
into.mkdir(parents=True, exist_ok=True)
|
||||||
|
archive = into / "library.zip"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive, "w") as writing:
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if path.is_file():
|
||||||
|
writing.write(path, f"{prefix}{path.relative_to(root)}")
|
||||||
|
|
||||||
|
return archive
|
||||||
|
|
||||||
|
|
||||||
|
async def upload(
|
||||||
|
client: AsyncClient,
|
||||||
|
archive: Path,
|
||||||
|
library_id: int = 1,
|
||||||
|
allow_duplicates: bool = False,
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
response = await client.post(
|
||||||
|
f"/libraries/{library_id}/imports/calibre/upload",
|
||||||
|
files=[("archive", (archive.name, archive.read_bytes(), "application/zip"))],
|
||||||
|
data={"allow_duplicates": str(allow_duplicates).lower()},
|
||||||
|
)
|
||||||
|
|
||||||
|
return response.status_code, response.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def wait_for(client: AsyncClient, job_id: str) -> dict:
|
||||||
|
"""Poll until the job is no longer running, the way the screen does."""
|
||||||
|
for _ in range(200):
|
||||||
|
response = await client.get(f"/libraries/imports/{job_id}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
job = response.json()
|
||||||
|
if job["state"] != "running":
|
||||||
|
return job
|
||||||
|
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
raise AssertionError("the import never finished")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_uploaded_library_imports(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
status, job = await upload(
|
||||||
|
authenticated_client, zip_of(source, tmp_path / "out", prefix="Calibre Library/")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status == 202
|
||||||
|
assert job["state"] == "running"
|
||||||
|
assert job["library_id"] == 1
|
||||||
|
|
||||||
|
# The archive's name, not the temp directory it was unpacked into.
|
||||||
|
assert job["source"] == "library.zip"
|
||||||
|
|
||||||
|
finished = await wait_for(authenticated_client, job["id"])
|
||||||
|
|
||||||
|
assert finished["state"] == "finished"
|
||||||
|
assert finished["total"] == 3
|
||||||
|
assert finished["created"] == 2
|
||||||
|
assert finished["skipped"] == 1
|
||||||
|
assert finished["failed"] == 0
|
||||||
|
assert finished["error"] is None
|
||||||
|
assert finished["current_title"] is None
|
||||||
|
|
||||||
|
listed = await authenticated_client.get("/books?library_id=1")
|
||||||
|
titles = [book["title"] for book in listed.json()["items"]]
|
||||||
|
assert "The Metamorphosis" in titles
|
||||||
|
assert "The Art of War" in titles
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_library_zipped_without_a_wrapping_folder(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Zipping the contents is as common as zipping the folder."""
|
||||||
|
status, job = await upload(authenticated_client, zip_of(source, tmp_path / "out"))
|
||||||
|
|
||||||
|
assert status == 202
|
||||||
|
finished = await wait_for(authenticated_client, job["id"])
|
||||||
|
assert finished["created"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_unpacked_copy_is_cleaned_up(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
An unpacked archive is a second copy of the whole library.
|
||||||
|
|
||||||
|
The books worth keeping have been copied into the library by the time the job ends,
|
||||||
|
so nothing is lost with it — and nothing will come back for it.
|
||||||
|
"""
|
||||||
|
status, job = await upload(authenticated_client, zip_of(source, tmp_path / "out"))
|
||||||
|
assert status == 202
|
||||||
|
|
||||||
|
workspace = registry.get(job["id"]).workspace
|
||||||
|
assert workspace is not None
|
||||||
|
|
||||||
|
await wait_for(authenticated_client, job["id"])
|
||||||
|
|
||||||
|
assert not workspace.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_uploading_the_same_library_twice_imports_nothing_new(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Re-running is safe, which is what makes an interrupted import resumable."""
|
||||||
|
archive = zip_of(source, tmp_path / "out")
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
_, job = await upload(authenticated_client, archive)
|
||||||
|
finished = await wait_for(authenticated_client, job["id"])
|
||||||
|
|
||||||
|
assert finished["created"] == 0
|
||||||
|
assert finished["skipped"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_allow_duplicates_stores_the_files_again(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
The one option the screen offers, and it has to reach the import.
|
||||||
|
|
||||||
|
Without it the second pass skips everything, which is the previous test.
|
||||||
|
"""
|
||||||
|
archive = zip_of(source, tmp_path / "out")
|
||||||
|
|
||||||
|
_, first = await upload(authenticated_client, archive)
|
||||||
|
await wait_for(authenticated_client, first["id"])
|
||||||
|
|
||||||
|
_, second = await upload(authenticated_client, archive, allow_duplicates=True)
|
||||||
|
finished = await wait_for(authenticated_client, second["id"])
|
||||||
|
|
||||||
|
assert finished["created"] == 2
|
||||||
|
assert finished["skipped"] == 1 # still the book with no files
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_imports_into_one_library_conflict(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
archive = zip_of(source, tmp_path / "out")
|
||||||
|
|
||||||
|
first_status, first = await upload(authenticated_client, archive)
|
||||||
|
assert first_status == 202
|
||||||
|
|
||||||
|
second_status, second = await upload(authenticated_client, archive)
|
||||||
|
|
||||||
|
assert second_status == 409
|
||||||
|
assert second["extra"]["job_id"] == first["id"]
|
||||||
|
|
||||||
|
await wait_for(authenticated_client, first["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_finished_import_does_not_block_the_next_one(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
archive = zip_of(source, tmp_path / "out")
|
||||||
|
|
||||||
|
_, first = await upload(authenticated_client, archive)
|
||||||
|
await wait_for(authenticated_client, first["id"])
|
||||||
|
|
||||||
|
status, second = await upload(authenticated_client, archive)
|
||||||
|
|
||||||
|
assert status == 202
|
||||||
|
await wait_for(authenticated_client, second["id"])
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancelling_stops_after_the_current_book(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Cancelling is not aborting: a book abandoned mid-copy would leave files with no row.
|
||||||
|
|
||||||
|
Whether this cancels before any book, after one, or after the lot is a race — the
|
||||||
|
catalogue is three books long. What must hold either way is that the state is
|
||||||
|
terminal and every book it did import is complete.
|
||||||
|
"""
|
||||||
|
_, job = await upload(authenticated_client, zip_of(source, tmp_path / "out"))
|
||||||
|
|
||||||
|
cancelled = await authenticated_client.delete(f"/libraries/imports/{job['id']}")
|
||||||
|
assert cancelled.status_code == 200
|
||||||
|
|
||||||
|
final = await wait_for(authenticated_client, job["id"])
|
||||||
|
|
||||||
|
assert final["state"] in {"cancelled", "finished"}
|
||||||
|
|
||||||
|
listed = await authenticated_client.get("/books?library_id=1")
|
||||||
|
for book in listed.json()["items"]:
|
||||||
|
assert book["files"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failures_are_reported_on_the_job(
|
||||||
|
authenticated_client: AsyncClient, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""One book failing is recorded and does not stop the run."""
|
||||||
|
fixture = CalibreFixture(tmp_path / "calibre")
|
||||||
|
fixture.add_book(1, "Fine", authors=["A"], formats={"EPUB": EPUB})
|
||||||
|
fixture.add_book(2, "Doomed", authors=["B"], formats={"EPUB": OTHER_EPUB})
|
||||||
|
root = fixture.commit()
|
||||||
|
|
||||||
|
from chitai.services.book import BookService
|
||||||
|
|
||||||
|
original = BookService.create
|
||||||
|
|
||||||
|
async def fail_on_the_second(self, data, *args, **kwargs):
|
||||||
|
if isinstance(data, dict) and data.get("title") == "Doomed":
|
||||||
|
raise RuntimeError("no room on the shelf")
|
||||||
|
return await original(self, data, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(BookService, "create", fail_on_the_second)
|
||||||
|
|
||||||
|
_, job = await upload(authenticated_client, zip_of(root, tmp_path / "out"))
|
||||||
|
finished = await wait_for(authenticated_client, job["id"])
|
||||||
|
|
||||||
|
assert finished["state"] == "finished"
|
||||||
|
assert finished["created"] == 1
|
||||||
|
assert finished["failed"] == 1
|
||||||
|
assert finished["failures"][0]["calibre_id"] == 2
|
||||||
|
assert "no room on the shelf" in finished["failures"][0]["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_second_copy_is_counted_as_a_possible_duplicate(
|
||||||
|
authenticated_client: AsyncClient, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""A count, not the records — the duplicates screen is what shows them."""
|
||||||
|
padded = tmp_path / "padded.epub"
|
||||||
|
padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)
|
||||||
|
|
||||||
|
fixture = CalibreFixture(tmp_path / "calibre")
|
||||||
|
fixture.add_book(1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB})
|
||||||
|
fixture.add_book(
|
||||||
|
2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded}
|
||||||
|
)
|
||||||
|
root = fixture.commit()
|
||||||
|
|
||||||
|
_, job = await upload(authenticated_client, zip_of(root, tmp_path / "out"))
|
||||||
|
finished = await wait_for(authenticated_client, job["id"])
|
||||||
|
|
||||||
|
assert finished["created"] == 2
|
||||||
|
assert finished["possible_duplicates"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_polling_an_unknown_job(authenticated_client: AsyncClient) -> None:
|
||||||
|
response = await authenticated_client.get("/libraries/imports/not-a-job")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancelling_an_unknown_job(authenticated_client: AsyncClient) -> None:
|
||||||
|
response = await authenticated_client.delete("/libraries/imports/not-a-job")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_importing_into_a_library_that_does_not_exist(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
status, _ = await upload(
|
||||||
|
authenticated_client, zip_of(source, tmp_path / "out"), library_id=999
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_importing_into_a_read_only_library(
|
||||||
|
authenticated_client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""A read-only library points at a tree Chitai does not own."""
|
||||||
|
root = tmp_path / "read-only"
|
||||||
|
root.mkdir()
|
||||||
|
|
||||||
|
created = await authenticated_client.post(
|
||||||
|
"/libraries",
|
||||||
|
json={"name": "Read Only", "root_path": str(root), "read_only": True},
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
|
||||||
|
status, body = await upload(
|
||||||
|
authenticated_client,
|
||||||
|
zip_of(source, tmp_path / "out"),
|
||||||
|
library_id=created.json()["id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status == 400
|
||||||
|
assert "read-only" in body["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_import_needs_authentication(
|
||||||
|
client: AsyncClient, source: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
status, _ = await upload(client, zip_of(source, tmp_path / "out"))
|
||||||
|
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefusedArchives:
|
||||||
|
"""Everything wrong with an archive is answered now, not as a job that fails later."""
|
||||||
|
|
||||||
|
async def test_a_hostile_archive(
|
||||||
|
self, authenticated_client: AsyncClient, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Zip slip."""
|
||||||
|
archive = tmp_path / "hostile.zip"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive, "w") as writing:
|
||||||
|
writing.writestr("metadata.db", "not really")
|
||||||
|
writing.writestr("../../escaped.txt", "gotcha")
|
||||||
|
|
||||||
|
status, body = await upload(authenticated_client, archive)
|
||||||
|
|
||||||
|
assert status == 400
|
||||||
|
assert "outside itself" in body["detail"]
|
||||||
|
assert registry._jobs == {}
|
||||||
|
|
||||||
|
async def test_something_that_is_not_a_zip(
|
||||||
|
self, authenticated_client: AsyncClient, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
archive = tmp_path / "notes.txt"
|
||||||
|
archive.write_bytes(b"just some text")
|
||||||
|
|
||||||
|
status, body = await upload(authenticated_client, archive)
|
||||||
|
|
||||||
|
assert status == 400
|
||||||
|
assert "not a zip" in body["detail"]
|
||||||
|
|
||||||
|
async def test_an_archive_with_no_catalogue(
|
||||||
|
self, authenticated_client: AsyncClient, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
archive = tmp_path / "books.zip"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive, "w") as writing:
|
||||||
|
writing.writestr("Some Book.epub", "content")
|
||||||
|
|
||||||
|
status, body = await upload(authenticated_client, archive)
|
||||||
|
|
||||||
|
assert status == 400
|
||||||
|
assert "no metadata.db" in body["detail"]
|
||||||
|
|
||||||
|
async def test_a_refusal_leaves_no_temp_files(
|
||||||
|
self, authenticated_client: AsyncClient, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Every refusal path removes the workspace it had already made."""
|
||||||
|
before = set(Path(tempfile.gettempdir()).glob("tmp*"))
|
||||||
|
|
||||||
|
archive = tmp_path / "books.zip"
|
||||||
|
with zipfile.ZipFile(archive, "w") as writing:
|
||||||
|
writing.writestr("Some Book.epub", "content")
|
||||||
|
|
||||||
|
await upload(authenticated_client, archive)
|
||||||
|
|
||||||
|
assert set(Path(tempfile.gettempdir()).glob("tmp*")) == before
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
import zipfile
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chitai.services.calibre import (
|
||||||
|
CalibreLibrary,
|
||||||
|
CalibreLibraryError,
|
||||||
|
extract_calibre_archive,
|
||||||
|
format_series_index,
|
||||||
|
parse_date,
|
||||||
|
strip_html,
|
||||||
|
unescape_author,
|
||||||
|
)
|
||||||
|
|
||||||
|
from tests.calibre_fixtures import UNDEFINED_DATE, CalibreFixture
|
||||||
|
|
||||||
|
|
||||||
|
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||||
|
PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="library_root")
|
||||||
|
def fx_library_root(tmp_path: Path) -> Path:
|
||||||
|
"""A small Calibre library covering the rows that are easy to read wrongly."""
|
||||||
|
fixture = CalibreFixture(tmp_path / "Calibre Library")
|
||||||
|
fixture.add_pages_table()
|
||||||
|
|
||||||
|
fixture.add_book(
|
||||||
|
1,
|
||||||
|
"The Metamorphosis",
|
||||||
|
authors=["Franz Kafka"],
|
||||||
|
pubdate="1915-10-15 00:00:00+00:00",
|
||||||
|
tags=["Fiction", "Absurdist"],
|
||||||
|
publisher="Kurt Wolff Verlag",
|
||||||
|
languages=["deu", "eng"],
|
||||||
|
comment="<p>A travelling salesman.</p><p>He wakes up <i>changed</i>.</p>",
|
||||||
|
identifiers={"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"},
|
||||||
|
uuid="11111111-2222-3333-4444-555555555555",
|
||||||
|
pages=201,
|
||||||
|
cover=True,
|
||||||
|
formats={"EPUB": EPUB},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Volume seven of a series, and no publication date — the two values most likely to
|
||||||
|
# be carried through verbatim when they should not be.
|
||||||
|
fixture.add_book(
|
||||||
|
2,
|
||||||
|
"Persepolis Rising",
|
||||||
|
# Calibre escapes the comma and nothing else, so the space after it is stored
|
||||||
|
# as-is: `Corey, Jr.` is written `Corey| Jr.`.
|
||||||
|
authors=["Corey| Jr., James S. A."],
|
||||||
|
series="The Expanse",
|
||||||
|
series_index=7.0,
|
||||||
|
formats={"EPUB": EPUB},
|
||||||
|
)
|
||||||
|
|
||||||
|
# A novella between two novels: a fractional position is real and must survive.
|
||||||
|
fixture.add_book(
|
||||||
|
3, "Strange Dogs", series="The Expanse", series_index=6.5, formats={"PDF": PDF}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Every row Calibre will happily hold and Chitai cannot use: no files at all.
|
||||||
|
fixture.add_book(4, "Metadata Only")
|
||||||
|
|
||||||
|
# A catalogue row whose file is not on disk.
|
||||||
|
fixture.add_book(5, "Lost Book")
|
||||||
|
fixture.add_missing_format(5, "EPUB", "Lost Book - Unknown")
|
||||||
|
|
||||||
|
return fixture.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reads_a_book_whole(library_root: Path) -> None:
|
||||||
|
async with CalibreLibrary(library_root) as library:
|
||||||
|
assert await library.count() == 5
|
||||||
|
books = await library.books()
|
||||||
|
|
||||||
|
book = books[0]
|
||||||
|
|
||||||
|
assert book.calibre_id == 1
|
||||||
|
assert book.title == "The Metamorphosis"
|
||||||
|
assert book.authors == ["Franz Kafka"]
|
||||||
|
assert book.published_date == date(1915, 10, 15)
|
||||||
|
assert book.tags == ["Absurdist", "Fiction"]
|
||||||
|
assert book.publisher == "Kurt Wolff Verlag"
|
||||||
|
assert book.pages == 201
|
||||||
|
assert book.uuid == "11111111-2222-3333-4444-555555555555"
|
||||||
|
|
||||||
|
# One language, and the one Calibre put first.
|
||||||
|
assert book.language == "deu"
|
||||||
|
|
||||||
|
# Reported as Calibre wrote them: folding `amazon` onto `asin` is the importer's
|
||||||
|
# job, not the reader's.
|
||||||
|
assert book.identifiers == {"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"}
|
||||||
|
|
||||||
|
assert book.cover is not None
|
||||||
|
assert book.cover.is_file()
|
||||||
|
|
||||||
|
assert len(book.files) == 1
|
||||||
|
assert book.files[0].format == "EPUB"
|
||||||
|
assert book.files[0].path.is_file()
|
||||||
|
# The stem is Calibre's, truncated and sanitised — never the title.
|
||||||
|
assert book.files[0].path.name != f"{book.title}.epub"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_undefined_date_is_not_a_date(library_root: Path) -> None:
|
||||||
|
"""`0101-01-01` parses fine, which is exactly the problem."""
|
||||||
|
async with CalibreLibrary(library_root) as library:
|
||||||
|
books = {book.calibre_id: book for book in await library.books()}
|
||||||
|
|
||||||
|
assert books[2].published_date is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_series_position_is_a_plain_string(library_root: Path) -> None:
|
||||||
|
async with CalibreLibrary(library_root) as library:
|
||||||
|
books = {book.calibre_id: book for book in await library.books()}
|
||||||
|
|
||||||
|
assert books[2].series == "The Expanse"
|
||||||
|
assert books[2].series_position == "7"
|
||||||
|
|
||||||
|
assert books[3].series_position == "6.5"
|
||||||
|
|
||||||
|
# `series_index` defaults to 1.0 for every book, so a position without a series
|
||||||
|
# would invent a volume one out of nothing.
|
||||||
|
assert books[1].series is None
|
||||||
|
assert books[1].series_position is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_author_commas_are_unescaped(library_root: Path) -> None:
|
||||||
|
async with CalibreLibrary(library_root) as library:
|
||||||
|
books = {book.calibre_id: book for book in await library.books()}
|
||||||
|
|
||||||
|
assert books[2].authors == ["Corey, Jr., James S. A."]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_comments_come_back_as_text(library_root: Path) -> None:
|
||||||
|
async with CalibreLibrary(library_root) as library:
|
||||||
|
books = {book.calibre_id: book for book in await library.books()}
|
||||||
|
|
||||||
|
assert books[1].description == "A travelling salesman.\nHe wakes up changed."
|
||||||
|
assert books[2].description is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_files_are_reported_whether_or_not_they_exist(library_root: Path) -> None:
|
||||||
|
"""
|
||||||
|
The reader says what the catalogue says. Whether the bytes are there is a question
|
||||||
|
for whoever is about to copy them, which stats them anyway.
|
||||||
|
"""
|
||||||
|
async with CalibreLibrary(library_root) as library:
|
||||||
|
books = {book.calibre_id: book for book in await library.books()}
|
||||||
|
|
||||||
|
assert books[4].files == []
|
||||||
|
|
||||||
|
assert len(books[5].files) == 1
|
||||||
|
assert not books[5].files[0].path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_library_without_the_pages_table_still_reads(tmp_path: Path) -> None:
|
||||||
|
"""`books_pages_link` is recent; an older library simply does not have it."""
|
||||||
|
fixture = CalibreFixture(tmp_path / "Old Library")
|
||||||
|
fixture.add_book(1, "Old Book", formats={"EPUB": EPUB})
|
||||||
|
root = fixture.commit()
|
||||||
|
|
||||||
|
async with CalibreLibrary(root) as library:
|
||||||
|
books = await library.books()
|
||||||
|
|
||||||
|
assert books[0].pages is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_original_is_never_opened(library_root: Path) -> None:
|
||||||
|
"""
|
||||||
|
The catalogue is copied before it is read, and the copy goes away afterwards.
|
||||||
|
|
||||||
|
Calibre may be running and writing; this is what keeps a live library out of it.
|
||||||
|
"""
|
||||||
|
before = (library_root / "metadata.db").read_bytes()
|
||||||
|
|
||||||
|
library = CalibreLibrary(library_root)
|
||||||
|
await library.open()
|
||||||
|
workspace = library._workspace
|
||||||
|
|
||||||
|
assert workspace is not None and (workspace / "metadata.db").is_file()
|
||||||
|
|
||||||
|
await library.close()
|
||||||
|
|
||||||
|
assert not workspace.exists()
|
||||||
|
assert (library_root / "metadata.db").read_bytes() == before
|
||||||
|
|
||||||
|
|
||||||
|
async def test_closing_twice_is_harmless(library_root: Path) -> None:
|
||||||
|
library = CalibreLibrary(library_root)
|
||||||
|
await library.open()
|
||||||
|
await library.close()
|
||||||
|
await library.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_directory_that_is_not_a_calibre_library(tmp_path: Path) -> None:
|
||||||
|
with pytest.raises(CalibreLibraryError, match="not a Calibre library"):
|
||||||
|
await CalibreLibrary(tmp_path).open()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("stored", "expected"),
|
||||||
|
[
|
||||||
|
("2017-12-04 04:00:00+00:00", date(2017, 12, 4)),
|
||||||
|
("2001-07-02 00:00:00+00:00", date(2001, 7, 2)),
|
||||||
|
("1999-01-31", date(1999, 1, 31)),
|
||||||
|
# Calibre's sentinel, and anything else implausibly early.
|
||||||
|
(UNDEFINED_DATE, None),
|
||||||
|
("0101-01-01", None),
|
||||||
|
(None, None),
|
||||||
|
("", None),
|
||||||
|
("not a date", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_date(stored: str | None, expected: date | None) -> None:
|
||||||
|
assert parse_date(stored) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("index", "expected"),
|
||||||
|
[
|
||||||
|
(7.0, "7"),
|
||||||
|
(1.0, "1"),
|
||||||
|
(6.5, "6.5"),
|
||||||
|
(0.0, "0"),
|
||||||
|
(12.25, "12.25"),
|
||||||
|
(None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_format_series_index(index: float | None, expected: str | None) -> None:
|
||||||
|
assert format_series_index(index) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("stored", "expected"),
|
||||||
|
[
|
||||||
|
("Doyle| Sir Arthur Conan", "Doyle, Sir Arthur Conan"),
|
||||||
|
("Franz Kafka", "Franz Kafka"),
|
||||||
|
(" Herman Melville ", "Herman Melville"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unescape_author(stored: str, expected: str) -> None:
|
||||||
|
assert unescape_author(stored) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("html", "expected"),
|
||||||
|
[
|
||||||
|
("<p>One.</p><p>Two.</p>", "One.\nTwo."),
|
||||||
|
("Plain text", "Plain text"),
|
||||||
|
("<div>A<br>B</div>", "A\nB"),
|
||||||
|
("<p>Café & bar</p>", "Café & bar"),
|
||||||
|
("<ul><li>One</li><li>Two</li></ul>", "One\nTwo"),
|
||||||
|
# Markup carrying no text at all is nothing, not an empty description.
|
||||||
|
("<p></p>", None),
|
||||||
|
("", None),
|
||||||
|
(None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_strip_html(html: str | None, expected: str | None) -> None:
|
||||||
|
assert strip_html(html) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestArchives:
|
||||||
|
"""A Calibre library that arrives zipped rather than as a path."""
|
||||||
|
|
||||||
|
def zipped(self, root: Path, into: Path, prefix: str = "") -> Path:
|
||||||
|
"""Zip a directory the way a file manager would."""
|
||||||
|
archive = into / "library.zip"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive, "w") as writing:
|
||||||
|
for path in sorted(root.rglob("*")):
|
||||||
|
if path.is_file():
|
||||||
|
writing.write(path, f"{prefix}{path.relative_to(root)}")
|
||||||
|
|
||||||
|
return archive
|
||||||
|
|
||||||
|
async def test_a_library_zipped_at_its_root(
|
||||||
|
self, library_root: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
archive = self.zipped(library_root, tmp_path)
|
||||||
|
destination = tmp_path / "unpacked"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
catalogue = await extract_calibre_archive(archive, destination)
|
||||||
|
|
||||||
|
assert catalogue == destination
|
||||||
|
async with CalibreLibrary(catalogue) as library:
|
||||||
|
assert await library.count() == 5
|
||||||
|
|
||||||
|
async def test_a_library_zipped_inside_a_folder(
|
||||||
|
self, library_root: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Zipping the folder itself is at least as common as zipping its contents."""
|
||||||
|
archive = self.zipped(library_root, tmp_path, prefix="Calibre Library/")
|
||||||
|
destination = tmp_path / "unpacked"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
catalogue = await extract_calibre_archive(archive, destination)
|
||||||
|
|
||||||
|
assert catalogue == destination / "Calibre Library"
|
||||||
|
async with CalibreLibrary(catalogue) as library:
|
||||||
|
assert await library.count() == 5
|
||||||
|
|
||||||
|
async def test_an_entry_pointing_outside_the_archive_is_refused(
|
||||||
|
self, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Zip slip. `ZipFile.extract` sanitises names itself, but relying on that silently
|
||||||
|
is how the next person to change the extraction call reintroduces it.
|
||||||
|
"""
|
||||||
|
archive = tmp_path / "hostile.zip"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive, "w") as writing:
|
||||||
|
writing.writestr("metadata.db", "not really")
|
||||||
|
writing.writestr("../../escaped.txt", "gotcha")
|
||||||
|
|
||||||
|
destination = tmp_path / "unpacked"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(CalibreLibraryError, match="outside itself"):
|
||||||
|
await extract_calibre_archive(archive, destination)
|
||||||
|
|
||||||
|
assert not (tmp_path.parent / "escaped.txt").exists()
|
||||||
|
|
||||||
|
async def test_something_that_is_not_a_zip(self, tmp_path: Path) -> None:
|
||||||
|
archive = tmp_path / "not.zip"
|
||||||
|
archive.write_bytes(b"PK-ish, but no")
|
||||||
|
|
||||||
|
destination = tmp_path / "unpacked"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(CalibreLibraryError, match="not a zip file"):
|
||||||
|
await extract_calibre_archive(archive, destination)
|
||||||
|
|
||||||
|
async def test_an_archive_with_no_catalogue(self, tmp_path: Path) -> None:
|
||||||
|
archive = tmp_path / "books.zip"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive, "w") as writing:
|
||||||
|
writing.writestr("Some Book.epub", "content")
|
||||||
|
|
||||||
|
destination = tmp_path / "unpacked"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(CalibreLibraryError, match="no metadata.db"):
|
||||||
|
await extract_calibre_archive(archive, destination)
|
||||||
|
|
||||||
|
# Refused before anything was written.
|
||||||
|
assert list(destination.iterdir()) == []
|
||||||
|
|
||||||
|
async def test_a_catalogue_buried_too_deep(self, tmp_path: Path) -> None:
|
||||||
|
"""Somebody's whole backup tree is not a library, however much it contains one."""
|
||||||
|
archive = tmp_path / "backup.zip"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive, "w") as writing:
|
||||||
|
writing.writestr("backups/2026/january/library/metadata.db", "not really")
|
||||||
|
|
||||||
|
destination = tmp_path / "unpacked"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
with pytest.raises(CalibreLibraryError, match="within 3 levels"):
|
||||||
|
await extract_calibre_archive(archive, destination)
|
||||||
|
|
||||||
|
async def test_an_archive_too_big_for_the_disk(
|
||||||
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Checked before writing, not discovered part-way through.
|
||||||
|
|
||||||
|
A full disk takes the whole application down, and the size is in the archive
|
||||||
|
already.
|
||||||
|
"""
|
||||||
|
archive = tmp_path / "huge.zip"
|
||||||
|
|
||||||
|
with zipfile.ZipFile(archive, "w") as writing:
|
||||||
|
writing.writestr("metadata.db", "not really")
|
||||||
|
|
||||||
|
destination = tmp_path / "unpacked"
|
||||||
|
destination.mkdir()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"chitai.services.calibre.shutil.disk_usage",
|
||||||
|
lambda _path: SimpleNamespace(total=1024, used=1024, free=0),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CalibreLibraryError, match="only"):
|
||||||
|
await extract_calibre_archive(archive, destination)
|
||||||
|
|
||||||
|
assert list(destination.iterdir()) == []
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chitai.services.utils import guess_content_type
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("filename", "expected"),
|
||||||
|
[
|
||||||
|
# What `mimetypes` already knows, kept here so a host with a thin
|
||||||
|
# /etc/mime.types cannot change the answer without a test noticing.
|
||||||
|
("Frankenstein.epub", "application/epub+zip"),
|
||||||
|
("Calculus.pdf", "application/pdf"),
|
||||||
|
("Persepolis.azw3", "application/vnd.amazon.mobi8-ebook"),
|
||||||
|
("Watchmen.cbz", "application/vnd.comicbook+zip"),
|
||||||
|
# What it does not, and where a Calibre library's older formats live.
|
||||||
|
("Dune.mobi", "application/x-mobipocket-ebook"),
|
||||||
|
("Dune.prc", "application/x-mobipocket-ebook"),
|
||||||
|
("Dune.azw", "application/vnd.amazon.ebook"),
|
||||||
|
("Voyna i Mir.fb2", "application/x-fictionbook+xml"),
|
||||||
|
("Voyna i Mir.fbz", "application/x-zip-compressed-fb2"),
|
||||||
|
("Reader.lit", "application/x-ms-reader"),
|
||||||
|
("Reader.lrf", "application/x-sony-bbeb"),
|
||||||
|
("Watchmen.cb7", "application/x-cb7"),
|
||||||
|
# Case is not part of the answer, and Calibre writes formats uppercase.
|
||||||
|
("Dune.MOBI", "application/x-mobipocket-ebook"),
|
||||||
|
# Nothing can name these, and None is the answer rather than a placeholder.
|
||||||
|
("Notes.xyzzy", None),
|
||||||
|
("README", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_guess_content_type(filename: str, expected: str | None) -> None:
|
||||||
|
assert guess_content_type(Path(filename)) == expected
|
||||||
|
# A str and a Path must agree, and an upload's `filename` carries its relative
|
||||||
|
# path, so a name with directories in front of it has to resolve the same way.
|
||||||
|
assert guess_content_type(filename) == expected
|
||||||
|
assert guess_content_type(f"Some Author/Some Book/{filename}") == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_fallback_is_used_only_when_the_extension_says_nothing() -> None:
|
||||||
|
"""A client's claim fills a gap; it never overrides the name."""
|
||||||
|
assert (
|
||||||
|
guess_content_type(Path("Dune.mobi"), fallback="application/pdf")
|
||||||
|
== "application/x-mobipocket-ebook"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
guess_content_type(Path("Notes.xyzzy"), fallback="application/epub+zip")
|
||||||
|
== "application/epub+zip"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unspecified_fallback_is_not_an_answer() -> None:
|
||||||
|
"""
|
||||||
|
`application/octet-stream` from a client is it saying it does not know.
|
||||||
|
|
||||||
|
Browsers post exactly that for every extension they do not recognise, which is most
|
||||||
|
ebook formats. Storing it would be indistinguishable from having determined a
|
||||||
|
format, so it is discarded and the column keeps its null.
|
||||||
|
"""
|
||||||
|
assert (
|
||||||
|
guess_content_type(Path("Notes.xyzzy"), fallback="application/octet-stream")
|
||||||
|
is None
|
||||||
|
)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Tests for BookPathGenerator."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from chitai.services.filesystem_library import BookPathGenerator, sanitize_path_component
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path("/library")
|
||||||
|
|
||||||
|
|
||||||
|
def path_for(**book) -> Path:
|
||||||
|
return BookPathGenerator(ROOT).generate_path(book)
|
||||||
|
|
||||||
|
|
||||||
|
def test_author_and_title() -> None:
|
||||||
|
assert path_for(title="Dune", authors=["Frank Herbert"]) == (
|
||||||
|
ROOT / "Frank Herbert" / "Dune"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_book_with_no_authors() -> None:
|
||||||
|
assert path_for(title="Beowulf", authors=[]) == ROOT / "Unknown" / "Beowulf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_series_adds_a_level_and_pads_the_position() -> None:
|
||||||
|
assert path_for(
|
||||||
|
title="Persepolis Rising",
|
||||||
|
authors=["James S. A. Corey"],
|
||||||
|
series="The Expanse",
|
||||||
|
series_position="7",
|
||||||
|
) == ROOT / "James S. A. Corey" / "The Expanse" / "07 - Persepolis Rising"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_slash_in_a_title_does_not_add_a_directory() -> None:
|
||||||
|
"""
|
||||||
|
The separators in the path come from the template, never from the metadata.
|
||||||
|
|
||||||
|
A title with a slash in it — "AC/DC", "Him/Her" — would otherwise put the book one
|
||||||
|
level below where `book.path` says it is, which is what deletes, moves and file
|
||||||
|
lookups all act on. Calibre keeps the real title in its database and strips this
|
||||||
|
from its own directory names, so an import is where they surface.
|
||||||
|
"""
|
||||||
|
generated = path_for(title="Back in Black: AC/DC", authors=["Murray Engleheart"])
|
||||||
|
|
||||||
|
assert generated == ROOT / "Murray Engleheart" / "Back in Black: AC_DC"
|
||||||
|
assert generated.relative_to(ROOT).parts == ("Murray Engleheart", "Back in Black: AC_DC")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_slash_in_an_author_or_series_is_handled_too() -> None:
|
||||||
|
assert path_for(title="Split", authors=["A/B Collective"]) == (
|
||||||
|
ROOT / "A_B Collective" / "Split"
|
||||||
|
)
|
||||||
|
assert path_for(
|
||||||
|
title="Volume One", authors=["Someone"], series="Either/Or", series_position="1"
|
||||||
|
) == ROOT / "Someone" / "Either_Or" / "01 - Volume One"
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_characters_are_removed() -> None:
|
||||||
|
assert path_for(title="Line\nBreak", authors=["Someone"]) == (
|
||||||
|
ROOT / "Someone" / "Line_Break"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sanitize_path_component() -> None:
|
||||||
|
assert sanitize_path_component("AC/DC") == "AC_DC"
|
||||||
|
assert sanitize_path_component("back\\slash") == "back_slash"
|
||||||
|
assert sanitize_path_component(" padded ") == "padded"
|
||||||
|
# Colons and other punctuation are legal in a path and are left alone.
|
||||||
|
assert sanitize_path_component("Title: Subtitle") == "Title: Subtitle"
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
"""Tests for the normalization behind book-level duplicate detection."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chitai.services.matching import (
|
||||||
|
format_author_name,
|
||||||
|
normalize_author,
|
||||||
|
normalize_identifier,
|
||||||
|
normalize_title,
|
||||||
|
)
|
||||||
|
from chitai.services.metadata_extractor import parse_identifier
|
||||||
|
from chitai.services.utils import isbn10_to_isbn13
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeTitle:
|
||||||
|
"""Two copies of one book rarely agree on how the title is written."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("title", "expected"),
|
||||||
|
[
|
||||||
|
("The Metamorphosis", "metamorphosis"),
|
||||||
|
("Metamorphosis", "metamorphosis"),
|
||||||
|
("METAMORPHOSIS", "metamorphosis"),
|
||||||
|
("A Tale of Two Cities", "tale of two cities"),
|
||||||
|
("An Enquiry", "enquiry"),
|
||||||
|
# Accents, punctuation and ampersands are spelling, not identity.
|
||||||
|
("Les Misérables", "les miserables"),
|
||||||
|
("Moby Dick; Or, The Whale", "moby dick or the whale"),
|
||||||
|
("Sense & Sensibility", "sense and sensibility"),
|
||||||
|
# Bracketed asides and trailing edition noise say nothing about the book.
|
||||||
|
("Frankenstein (Illustrated)", "frankenstein"),
|
||||||
|
("Frankenstein [Kindle Edition]", "frankenstein"),
|
||||||
|
("Frankenstein, 2nd Edition", "frankenstein"),
|
||||||
|
# The compact forms a cover actually carries.
|
||||||
|
("Building Microservices, 2E", "building microservices"),
|
||||||
|
("Building Microservices 2e", "building microservices"),
|
||||||
|
("Frankenstein 3 Ed", "frankenstein"),
|
||||||
|
("Dungeons & Dragons 5e", "dungeons and dragons"),
|
||||||
|
("Frankenstein Revised Edition", "frankenstein"),
|
||||||
|
("Dune Deluxe Edition Illustrated", "dune"),
|
||||||
|
("", ""),
|
||||||
|
(None, ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_titles_that_should_agree(self, title: str | None, expected: str) -> None:
|
||||||
|
assert normalize_title(title) == expected
|
||||||
|
|
||||||
|
def test_a_qualifier_that_is_the_title_survives(self) -> None:
|
||||||
|
"""A trailing qualifier is noise; the same word at the front is the book."""
|
||||||
|
assert normalize_title("The Illustrated Man") == "illustrated man"
|
||||||
|
|
||||||
|
def test_normalization_never_empties_a_title(self) -> None:
|
||||||
|
"""An article-only title is not improved by having no article left."""
|
||||||
|
assert normalize_title("The") == "the"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"title", ["Catch 22", "Fahrenheit 451", "Blade Runner 2049", "1984", "Apollo 13"]
|
||||||
|
)
|
||||||
|
def test_a_number_is_not_an_edition(self, title: str) -> None:
|
||||||
|
"""Edition stripping keys on the `e`; a bare number is part of the title."""
|
||||||
|
assert normalize_title(title) == title.casefold()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeAuthor:
|
||||||
|
"""One person, written down several ways."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("name", "expected"),
|
||||||
|
[
|
||||||
|
("Franz Kafka", "franz kafka"),
|
||||||
|
("Kafka, Franz", "franz kafka"),
|
||||||
|
("KAFKA, FRANZ", "franz kafka"),
|
||||||
|
("Émile Zola", "emile zola"),
|
||||||
|
("Doyle, Arthur Conan", "arthur conan doyle"),
|
||||||
|
# Runs of initials are joined, so spacing them out changes nothing.
|
||||||
|
("J.R.R. Tolkien", "jrr tolkien"),
|
||||||
|
("J. R. R. Tolkien", "jrr tolkien"),
|
||||||
|
("JRR Tolkien", "jrr tolkien"),
|
||||||
|
("", ""),
|
||||||
|
(None, ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_names_that_should_agree(self, name: str | None, expected: str) -> None:
|
||||||
|
assert normalize_author(name) == expected
|
||||||
|
|
||||||
|
def test_two_people_are_not_reduced_together(self) -> None:
|
||||||
|
"""Surname plus initial would collide unrelated writers; it is not used."""
|
||||||
|
assert normalize_author("Charles Dickens") != normalize_author("Colin Dexter")
|
||||||
|
|
||||||
|
def test_a_list_is_left_alone(self) -> None:
|
||||||
|
"""More than one comma is a list or a suffix, and guessing does more harm."""
|
||||||
|
assert normalize_author("Smith, John, Jr.") == "smith john jr"
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatAuthorName:
|
||||||
|
"""What gets stored and shown, as opposed to what gets compared."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("written", "expected"),
|
||||||
|
[
|
||||||
|
# A leftover separator from a `DC:creator` list.
|
||||||
|
("Newman, Sam;", "Sam Newman"),
|
||||||
|
("Sam Newman ", "Sam Newman"),
|
||||||
|
(" Dan Vanderkam ", "Dan Vanderkam"),
|
||||||
|
# `Surname, Given` is how EPUBs file a name, not how anyone reads it.
|
||||||
|
("Kleppmann, Martin", "Martin Kleppmann"),
|
||||||
|
("Huxley, Aldous", "Aldous Huxley"),
|
||||||
|
("Liu, Cixin", "Cixin Liu"),
|
||||||
|
# An extension carried in from the filename the name was read out of.
|
||||||
|
("Sam Newman.epub", "Sam Newman"),
|
||||||
|
("Franz Kafka.mobi", "Franz Kafka"),
|
||||||
|
("Brian W. Kernighan.epub", "Brian W. Kernighan"),
|
||||||
|
("", ""),
|
||||||
|
(None, ""),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_names_are_tidied(self, written: str | None, expected: str) -> None:
|
||||||
|
assert format_author_name(written) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"written",
|
||||||
|
[
|
||||||
|
# Two people in one string. Flipping it would invent a third.
|
||||||
|
"Dave Thomas, Andy Hunt",
|
||||||
|
"Mark Richards, Neal Ford",
|
||||||
|
# A compound surname is not recognised, and is left alone rather than
|
||||||
|
# rearranged wrongly.
|
||||||
|
"García Márquez, Gabriel",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_an_unrecognised_form_is_left_alone(self, written: str) -> None:
|
||||||
|
assert format_author_name(written) == written
|
||||||
|
|
||||||
|
def test_case_and_accents_belong_to_the_author(self) -> None:
|
||||||
|
"""Tidying removes what an extractor added; it does not correct spelling."""
|
||||||
|
assert format_author_name("Michał Płachta.epub") == "Michał Płachta"
|
||||||
|
assert format_author_name("Steve McConnell") == "Steve McConnell"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"written", ["Newman, Sam;", "Sam Newman.epub", "Kleppmann, Martin"]
|
||||||
|
)
|
||||||
|
def test_tidying_is_idempotent(self, written: str) -> None:
|
||||||
|
"""`unique_filter` tidies a name that may already be tidy; it must not drift."""
|
||||||
|
once = format_author_name(written)
|
||||||
|
assert format_author_name(once) == once
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeIdentifier:
|
||||||
|
"""Identifiers only help if the same edition produces the same key."""
|
||||||
|
|
||||||
|
def test_isbn_10_and_isbn_13_are_one_key(self) -> None:
|
||||||
|
assert normalize_identifier("isbn-10", "0486282112") == "isbn:9780486282114"
|
||||||
|
assert normalize_identifier("isbn-13", "9780486282114") == "isbn:9780486282114"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"written", ["978-0-486-28211-4", "978 0 486 28211 4", "9780486282114"]
|
||||||
|
)
|
||||||
|
def test_formatting_is_not_part_of_an_isbn(self, written: str) -> None:
|
||||||
|
assert normalize_identifier("isbn", written) == "isbn:9780486282114"
|
||||||
|
|
||||||
|
def test_an_isbn_that_fails_its_checksum_is_no_evidence(self) -> None:
|
||||||
|
assert normalize_identifier("isbn-13", "9780486282115") is None
|
||||||
|
|
||||||
|
def test_uuids_are_refused(self) -> None:
|
||||||
|
"""Generated per build, so they only re-find what the hash check catches."""
|
||||||
|
assert normalize_identifier("uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
|
||||||
|
assert normalize_identifier("urn:uuid", "3f2b1c4e-1111-2222-3333-444455556666") is None
|
||||||
|
|
||||||
|
def test_other_schemes_keep_their_own_key(self) -> None:
|
||||||
|
assert normalize_identifier("asin", "B000FC0PDA") == "asin:b000fc0pda"
|
||||||
|
assert normalize_identifier("ASIN", "b000fc0pda") == "asin:b000fc0pda"
|
||||||
|
|
||||||
|
def test_something_too_short_is_not_evidence(self) -> None:
|
||||||
|
"""A Calibre id of "42" would otherwise pair two unrelated books."""
|
||||||
|
assert normalize_identifier("calibre", "42") is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("name", "value"), [("", "1234567"), ("asin", "")])
|
||||||
|
def test_half_an_identifier_is_no_identifier(self, name: str, value: str) -> None:
|
||||||
|
assert normalize_identifier(name, value) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsbnConversion:
|
||||||
|
def test_isbn_10_converts_to_its_isbn_13(self) -> None:
|
||||||
|
assert isbn10_to_isbn13("0486282112") == "9780486282114"
|
||||||
|
|
||||||
|
def test_a_trailing_x_is_a_digit(self) -> None:
|
||||||
|
assert isbn10_to_isbn13("043942089X") == "9780439420891"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("isbn", ["0486282113", "9780486282114", "nonsense"])
|
||||||
|
def test_anything_that_is_not_an_isbn_10_converts_to_nothing(self, isbn: str) -> None:
|
||||||
|
assert isbn10_to_isbn13(isbn) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseIdentifier:
|
||||||
|
"""What an EPUB writes, and what is worth storing for it."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"written",
|
||||||
|
[
|
||||||
|
"9780486282114",
|
||||||
|
"978-0-486-28211-4",
|
||||||
|
"urn:isbn:9780486282114",
|
||||||
|
"urn:isbn:978-0-486-28211-4",
|
||||||
|
"ISBN:978-0-486-28211-4",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_isbns_survive_however_they_are_written(self, written: str) -> None:
|
||||||
|
assert parse_identifier(written) == ("isbn-13", "9780486282114")
|
||||||
|
|
||||||
|
def test_the_scheme_attribute_is_read_too(self) -> None:
|
||||||
|
assert parse_identifier("0-486-28211-2", "ISBN") == ("isbn-10", "0486282112")
|
||||||
|
|
||||||
|
def test_non_isbn_identifiers_are_kept(self) -> None:
|
||||||
|
assert parse_identifier("urn:uuid:3f2b1c4e-1111-2222-3333-444455556666") == (
|
||||||
|
"uuid",
|
||||||
|
"3f2b1c4e-1111-2222-3333-444455556666",
|
||||||
|
)
|
||||||
|
assert parse_identifier("calibre:1234") == ("calibre", "1234")
|
||||||
|
assert parse_identifier("B000FC0PDA", "mobi-asin") == ("asin", "B000FC0PDA")
|
||||||
|
|
||||||
|
def test_an_unrecognised_prefix_is_part_of_the_value(self) -> None:
|
||||||
|
""""http://example.com/book" is not an identifier called "http"."""
|
||||||
|
assert parse_identifier("http://www.gutenberg.org/5200") == (
|
||||||
|
"id",
|
||||||
|
"http://www.gutenberg.org/5200",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_a_declared_isbn_that_is_not_one_is_dropped(self) -> None:
|
||||||
|
assert parse_identifier("urn:isbn:not-an-isbn") is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("written", ["", " ", None])
|
||||||
|
def test_nothing_yields_nothing(self, written: str | None) -> None:
|
||||||
|
assert parse_identifier(written) is None
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
from ebooklib import epub
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from chitai.services.metadata_extractor import EpubExtractor
|
from chitai.services.metadata_extractor import (
|
||||||
|
EpubExtractor,
|
||||||
|
Extractor,
|
||||||
|
PdfExtractor,
|
||||||
|
split_edition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio()
|
@pytest.mark.asyncio()
|
||||||
@@ -15,3 +21,172 @@ class TestEpubExtractor:
|
|||||||
assert metadata["authors"] == ["Herman Melville"]
|
assert metadata["authors"] == ["Herman Melville"]
|
||||||
assert metadata["published_date"] == date(year=2001, month=7, day=1)
|
assert metadata["published_date"] == date(year=2001, month=7, day=1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
EPUB = Path("tests/data_files/Metamorphosis - Franz Kafka.epub")
|
||||||
|
PDF = Path("tests/data_files/Calculus Made Easy - Silvanus Thompson.pdf")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio()
|
||||||
|
class TestIdentifierMerging:
|
||||||
|
"""A book's formats each contribute identifiers; none of them replaces the rest."""
|
||||||
|
|
||||||
|
async def test_every_format_contributes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""
|
||||||
|
Identifiers are a collection, not a single value.
|
||||||
|
|
||||||
|
Merging the whole dict meant the last format to report won outright: an EPUB
|
||||||
|
declaring an ASIN, a Google volume id and a Calibre id kept none of them once
|
||||||
|
a PDF contributed one ISBN.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def epub(_file):
|
||||||
|
return {
|
||||||
|
"title": "How Linux Works",
|
||||||
|
"identifiers": {"isbn-13": "9781718500419", "asin": "1718500408"},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def pdf(_file):
|
||||||
|
return {"identifiers": {"isbn-13": "9781718500402", "isbn-10": "1593270356"}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(EpubExtractor, "extract_metadata", epub)
|
||||||
|
monkeypatch.setattr(PdfExtractor, "extract_metadata", pdf)
|
||||||
|
|
||||||
|
metadata = await Extractor.extract_metadata(
|
||||||
|
[Path("How Linux Works.epub"), Path("How Linux Works.pdf")]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert metadata["identifiers"] == {
|
||||||
|
# Declared by the publisher's toolchain, so it outranks the PDF's, which
|
||||||
|
# was scraped off a copyright page that also prints the print edition's.
|
||||||
|
"isbn-13": "9781718500419",
|
||||||
|
"asin": "1718500408",
|
||||||
|
"isbn-10": "1593270356",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_a_second_format_that_finds_nothing_erases_nothing(self) -> None:
|
||||||
|
"""The PDF fixture carries no ISBN, so it must leave the EPUB's alone."""
|
||||||
|
metadata = await Extractor.extract_metadata([EPUB, PDF])
|
||||||
|
|
||||||
|
assert metadata["identifiers"] == {"id": "http://www.gutenberg.org/5200"}
|
||||||
|
|
||||||
|
async def test_one_format_on_its_own_is_unaffected(self) -> None:
|
||||||
|
metadata = await Extractor.extract_metadata([EPUB])
|
||||||
|
|
||||||
|
assert metadata["identifiers"] == {"id": "http://www.gutenberg.org/5200"}
|
||||||
|
|
||||||
|
async def test_no_identifiers_anywhere_leaves_the_field_absent(self) -> None:
|
||||||
|
"""An empty dict would count as extracted metadata and overwrite nothing."""
|
||||||
|
metadata = await Extractor.extract_metadata([PDF])
|
||||||
|
|
||||||
|
assert "identifiers" not in metadata
|
||||||
|
|
||||||
|
|
||||||
|
class TestSplitEdition:
|
||||||
|
"""An edition is a field on the book, not part of what the book is called."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("title", "stripped", "edition"),
|
||||||
|
[
|
||||||
|
# Every form below is one that turned up in a real library.
|
||||||
|
("Fluent Python, 2nd Edition", "Fluent Python", 2),
|
||||||
|
("Building Microservices, 2E", "Building Microservices", 2),
|
||||||
|
("Digital Image Processing, 4e", "Digital Image Processing", 4),
|
||||||
|
(
|
||||||
|
"Network Security Essentials: Applications and Standards/6e",
|
||||||
|
"Network Security Essentials: Applications and Standards",
|
||||||
|
6,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Refactoring: Improving the Design of Existing Code (2nd edition)",
|
||||||
|
"Refactoring: Improving the Design of Existing Code",
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
# Ordinal words, including a qualifier sitting inside the statement.
|
||||||
|
(
|
||||||
|
"The Art of Computer Programming: Volume 1 / Fundamental Algorithms, Third Edition",
|
||||||
|
"The Art of Computer Programming: Volume 1 / Fundamental Algorithms",
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Introduction to the Theory of Computation, Third International Edition",
|
||||||
|
"Introduction to the Theory of Computation",
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
# Mid-title, before a subtitle and before a trailing author.
|
||||||
|
(
|
||||||
|
"How Linux Works, 3rd Edition: What Every Superuser Should Know",
|
||||||
|
"How Linux Works: What Every Superuser Should Know",
|
||||||
|
3,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Code Complete, 2nd Edition - Steve McConnell",
|
||||||
|
"Code Complete - Steve McConnell",
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
# An underscore between the number and the "e", beside an unnumbered
|
||||||
|
# qualifier that has nowhere to go in an integer column and so stays put.
|
||||||
|
(
|
||||||
|
"Cryptography and Network Security, Global Edition, 8_e - Stallings",
|
||||||
|
"Cryptography and Network Security, Global Edition - Stallings",
|
||||||
|
8,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_editions_are_split_out(self, title: str, stripped: str, edition: int) -> None:
|
||||||
|
assert split_edition(title) == (stripped, edition)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"title",
|
||||||
|
[
|
||||||
|
# A number alone is never an edition — these are titles.
|
||||||
|
"Catch 22",
|
||||||
|
"Fahrenheit 451",
|
||||||
|
"Blade Runner 2049",
|
||||||
|
"1984",
|
||||||
|
"Apollo 13",
|
||||||
|
"Slaughterhouse 5",
|
||||||
|
"The Art of Computer Programming: Volume 1",
|
||||||
|
# "Edition" with no number cannot be stored, so it stays where it can
|
||||||
|
# still be read.
|
||||||
|
"Cryptography and Network Security: Principles and Practice, Global Edition",
|
||||||
|
"Building Microservices",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_titles_are_left_alone(self, title: str) -> None:
|
||||||
|
assert split_edition(title) == (title, None)
|
||||||
|
|
||||||
|
def test_a_title_that_is_only_an_edition_is_kept(self) -> None:
|
||||||
|
"""Stripping must never leave a book with no title at all."""
|
||||||
|
assert split_edition("2nd Edition") == ("2nd Edition", None)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("title", ["", None])
|
||||||
|
def test_nothing_yields_nothing(self, title: str | None) -> None:
|
||||||
|
assert split_edition(title) == (title, None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio()
|
||||||
|
class TestEditionFromFiles:
|
||||||
|
async def test_extraction_moves_the_edition_off_the_title(self) -> None:
|
||||||
|
"""The PDF fixture calls itself a 2nd edition in its own metadata title."""
|
||||||
|
metadata = await Extractor.extract_metadata([PDF])
|
||||||
|
|
||||||
|
assert metadata["title"] == "The Project Gutenberg eBook #33283: Calculus Made Easy"
|
||||||
|
assert metadata["edition"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestEpubPublisher:
|
||||||
|
"""The publisher was looked up and then dropped on the floor."""
|
||||||
|
|
||||||
|
def test_a_declared_publisher_is_returned(self) -> None:
|
||||||
|
"""
|
||||||
|
The lookup discarded its own result and fell off the end of the function, so
|
||||||
|
every EPUB reported no publisher no matter what it said.
|
||||||
|
"""
|
||||||
|
book = epub.EpubBook()
|
||||||
|
book.add_metadata("DC", "publisher", "No Starch Press")
|
||||||
|
|
||||||
|
assert EpubExtractor._extract_publisher(book) == "No Starch Press"
|
||||||
|
|
||||||
|
def test_no_publisher_is_none(self) -> None:
|
||||||
|
assert EpubExtractor._extract_publisher(epub.EpubBook()) is None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
|||||||
|
"""Tests for importing a Calibre library through BookService."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from chitai.config import settings
|
||||||
|
from chitai.database import models as m
|
||||||
|
from chitai.services import BookService
|
||||||
|
from chitai.services.calibre import CalibreLibrary
|
||||||
|
|
||||||
|
from tests.calibre_fixtures import CalibreFixture
|
||||||
|
|
||||||
|
|
||||||
|
DATA_FILES = Path("tests/data_files")
|
||||||
|
EPUB = DATA_FILES / "Metamorphosis - Franz Kafka.epub"
|
||||||
|
OTHER_EPUB = DATA_FILES / "The Art of War - Sun Tzu.epub"
|
||||||
|
PDF = DATA_FILES / "Calculus Made Easy - Silvanus Thompson.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(name="calibre_root")
|
||||||
|
def fx_calibre_root(tmp_path: Path) -> Path:
|
||||||
|
"""Three books: one plain, one in two formats, one Chitai cannot use."""
|
||||||
|
fixture = CalibreFixture(tmp_path / "source")
|
||||||
|
fixture.add_pages_table()
|
||||||
|
|
||||||
|
fixture.add_book(
|
||||||
|
1,
|
||||||
|
"The Metamorphosis",
|
||||||
|
authors=["Franz Kafka"],
|
||||||
|
pubdate="1915-10-15 00:00:00+00:00",
|
||||||
|
tags=["Fiction", "Absurdist"],
|
||||||
|
publisher="Kurt Wolff Verlag",
|
||||||
|
languages=["deu"],
|
||||||
|
comment="<p>He wakes up <i>changed</i>.</p>",
|
||||||
|
identifiers={"isbn": "978-0-486-29030-0", "amazon": "B01N5IB20Q"},
|
||||||
|
uuid="11111111-2222-3333-4444-555555555555",
|
||||||
|
pages=201,
|
||||||
|
cover=True,
|
||||||
|
formats={"EPUB": EPUB},
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture.add_book(
|
||||||
|
2,
|
||||||
|
"The Art of War",
|
||||||
|
authors=["Sun Tzu"],
|
||||||
|
series="Classics",
|
||||||
|
series_index=3.0,
|
||||||
|
formats={"EPUB": OTHER_EPUB, "PDF": PDF},
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture.add_book(3, "Metadata Only", authors=["Nobody"])
|
||||||
|
|
||||||
|
return fixture.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def library_of(root: Path) -> CalibreLibrary:
|
||||||
|
source = CalibreLibrary(root)
|
||||||
|
await source.open()
|
||||||
|
return source
|
||||||
|
|
||||||
|
|
||||||
|
async def test_imports_a_catalogue(
|
||||||
|
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||||
|
) -> None:
|
||||||
|
source = await library_of(calibre_root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
assert result.total == 3
|
||||||
|
assert len(result.created) == 2
|
||||||
|
|
||||||
|
# The book with no files is left out: a record with nothing to read, and a directory
|
||||||
|
# to match, is worse than not importing it.
|
||||||
|
assert [skipped.calibre_id for skipped in result.skipped] == [3]
|
||||||
|
assert result.skipped[0].reason == "no files in the catalogue"
|
||||||
|
assert result.failed == []
|
||||||
|
|
||||||
|
book = await books_service.get(result.created[0])
|
||||||
|
|
||||||
|
assert book.title == "The Metamorphosis"
|
||||||
|
assert [author.name for author in book.authors] == ["Franz Kafka"]
|
||||||
|
assert sorted(tag.name for tag in book.tags) == ["Absurdist", "Fiction"]
|
||||||
|
assert book.publisher is not None and book.publisher.name == "Kurt Wolff Verlag"
|
||||||
|
assert book.published_date is not None and book.published_date.year == 1915
|
||||||
|
assert book.language == "deu"
|
||||||
|
assert book.pages == 201
|
||||||
|
assert book.cover_image is not None
|
||||||
|
|
||||||
|
# The HTML is gone; `Book.description` is rendered as text.
|
||||||
|
assert book.description == "He wakes up changed."
|
||||||
|
|
||||||
|
|
||||||
|
async def test_two_formats_are_one_book(
|
||||||
|
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||||
|
) -> None:
|
||||||
|
source = await library_of(calibre_root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
book = await books_service.get(result.created[1])
|
||||||
|
|
||||||
|
assert book.title == "The Art of War"
|
||||||
|
assert sorted(Path(file.path).suffix for file in book.files) == [".epub", ".pdf"]
|
||||||
|
|
||||||
|
# A REAL series index reaches the column as the string everything else writes.
|
||||||
|
assert book.series is not None and book.series.title == "Classics"
|
||||||
|
assert book.series_position == "3"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_identifiers_are_folded_onto_chitai_schemes(
|
||||||
|
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
`amazon` becomes `asin`, a hyphenated ISBN survives, and the Calibre uuid is kept.
|
||||||
|
|
||||||
|
The uuid is deliberately not stored under `uuid`, which duplicate matching ignores
|
||||||
|
because an EPUB regenerates one per build. Calibre's is stable, so it is the durable
|
||||||
|
link back to the row it came from.
|
||||||
|
"""
|
||||||
|
source = await library_of(calibre_root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
book = await books_service.get(result.created[0])
|
||||||
|
identifiers = {identifier.name: identifier.value for identifier in book.identifiers}
|
||||||
|
|
||||||
|
assert identifiers["asin"] == "B01N5IB20Q"
|
||||||
|
assert identifiers["isbn-13"] == "9780486290300"
|
||||||
|
assert identifiers["calibre-uuid"] == "11111111-2222-3333-4444-555555555555"
|
||||||
|
|
||||||
|
matching = {
|
||||||
|
identifier.name: identifier.normalized_value for identifier in book.identifiers
|
||||||
|
}
|
||||||
|
|
||||||
|
# Stored under its own name, matched under one scheme for both ISBN forms.
|
||||||
|
assert matching["isbn-13"] == "isbn:9780486290300"
|
||||||
|
|
||||||
|
# And the uuid carries a real matching key, which is the whole reason it is not
|
||||||
|
# filed under `uuid`.
|
||||||
|
assert matching["calibre-uuid"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_source_library_is_left_alone(
|
||||||
|
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||||
|
) -> None:
|
||||||
|
"""Files are copied. Moving them would leave `metadata.db` pointing at nothing."""
|
||||||
|
before = {
|
||||||
|
path: path.stat().st_mtime_ns
|
||||||
|
for path in sorted(calibre_root.rglob("*"))
|
||||||
|
if path.is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
source = await library_of(calibre_root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
after = {
|
||||||
|
path: path.stat().st_mtime_ns
|
||||||
|
for path in sorted(calibre_root.rglob("*"))
|
||||||
|
if path.is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
assert after == before
|
||||||
|
|
||||||
|
# And the copies are really there, under the library's own layout.
|
||||||
|
for book_id in result.created:
|
||||||
|
book = await books_service.get(book_id)
|
||||||
|
for file in book.files:
|
||||||
|
assert (Path(book.path or "") / file.path).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_importing_twice_creates_nothing(
|
||||||
|
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Re-running is safe with no bookkeeping: the bytes are recognised wherever they sit.
|
||||||
|
|
||||||
|
This is what makes an interrupted import resumable by simply running it again.
|
||||||
|
"""
|
||||||
|
for _ in range(2):
|
||||||
|
source = await library_of(calibre_root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
assert result.created == []
|
||||||
|
assert sorted(skipped.reason for skipped in result.skipped) == [
|
||||||
|
"already stored",
|
||||||
|
"already stored",
|
||||||
|
"no files in the catalogue",
|
||||||
|
]
|
||||||
|
|
||||||
|
held_by = [
|
||||||
|
skipped.book_id for skipped in result.skipped if skipped.reason == "already stored"
|
||||||
|
]
|
||||||
|
assert all(book_id is not None for book_id in held_by)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_file_the_catalogue_lists_but_disk_does_not(
|
||||||
|
books_service: BookService, test_library: m.Library, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Calibre keeps the row when a file is moved away behind its back."""
|
||||||
|
fixture = CalibreFixture(tmp_path / "source")
|
||||||
|
fixture.add_book(1, "Present", authors=["A"], formats={"EPUB": EPUB})
|
||||||
|
fixture.add_book(2, "Absent", authors=["B"])
|
||||||
|
fixture.add_missing_format(2, "EPUB", "Absent - B")
|
||||||
|
root = fixture.commit()
|
||||||
|
|
||||||
|
source = await library_of(root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
assert len(result.created) == 1
|
||||||
|
assert [(s.calibre_id, s.reason) for s in result.skipped] == [(2, "no files on disk")]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_one_broken_book_does_not_stop_the_import(
|
||||||
|
books_service: BookService, test_library: m.Library, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
A failure is recorded and the run continues, leaving no files behind for it.
|
||||||
|
|
||||||
|
An orphaned directory would make the next attempt reserve `title (2)` and look as
|
||||||
|
though it had worked.
|
||||||
|
"""
|
||||||
|
fixture = CalibreFixture(tmp_path / "source")
|
||||||
|
fixture.add_book(1, "First", authors=["A"], formats={"EPUB": EPUB})
|
||||||
|
fixture.add_book(2, "Doomed", authors=["B"], formats={"EPUB": OTHER_EPUB})
|
||||||
|
fixture.add_book(3, "Third", authors=["C"], formats={"PDF": PDF})
|
||||||
|
root = fixture.commit()
|
||||||
|
|
||||||
|
original = books_service.create
|
||||||
|
|
||||||
|
async def fail_on_the_second(data, *args, **kwargs):
|
||||||
|
if isinstance(data, dict) and data.get("title") == "Doomed":
|
||||||
|
raise RuntimeError("no room on the shelf")
|
||||||
|
return await original(data, *args, **kwargs)
|
||||||
|
|
||||||
|
books_service.create = fail_on_the_second # type: ignore[method-assign]
|
||||||
|
|
||||||
|
source = await library_of(root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
books_service.create = original # type: ignore[method-assign]
|
||||||
|
|
||||||
|
assert len(result.created) == 2
|
||||||
|
assert len(result.failed) == 1
|
||||||
|
assert result.failed[0].calibre_id == 2
|
||||||
|
assert "no room on the shelf" in result.failed[0].reason
|
||||||
|
|
||||||
|
# Nothing of the failed book was left in the library. Checked against the path the
|
||||||
|
# template would have produced, rather than by walking the root — the Calibre source
|
||||||
|
# sits under it in these tests, and its own files are meant to still be there.
|
||||||
|
assert not (Path(test_library.root_path) / "B").exists()
|
||||||
|
|
||||||
|
# And the books either side of it are where they should be.
|
||||||
|
for book_id in result.created:
|
||||||
|
book = await books_service.get(book_id)
|
||||||
|
for file in book.files:
|
||||||
|
assert (Path(book.path or "") / file.path).is_file()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_cover_that_cannot_be_read_is_not_fatal(
|
||||||
|
books_service: BookService, test_library: m.Library, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
A truncated `cover.jpg` costs the cover, not the book.
|
||||||
|
|
||||||
|
Real libraries hold them, from an interrupted download or a failed conversion, and
|
||||||
|
the cover is the one thing in the directory that can be replaced from the book page.
|
||||||
|
"""
|
||||||
|
fixture = CalibreFixture(tmp_path / "source")
|
||||||
|
fixture.add_book(
|
||||||
|
1, "Unreadable Cover", authors=["A"], corrupt_cover=True, formats={"EPUB": EPUB}
|
||||||
|
)
|
||||||
|
root = fixture.commit()
|
||||||
|
|
||||||
|
source = await library_of(root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
assert result.failed == []
|
||||||
|
assert len(result.created) == 1
|
||||||
|
|
||||||
|
book = await books_service.get(result.created[0])
|
||||||
|
assert book.cover_image is None
|
||||||
|
assert len(book.files) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_shared_authors_and_tags_are_one_row_each(
|
||||||
|
books_service: BookService, test_library: m.Library, tmp_path: Path, session
|
||||||
|
) -> None:
|
||||||
|
"""Two books by one author must not produce two `Author` rows."""
|
||||||
|
fixture = CalibreFixture(tmp_path / "source")
|
||||||
|
fixture.add_book(
|
||||||
|
1, "One", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": EPUB}
|
||||||
|
)
|
||||||
|
fixture.add_book(
|
||||||
|
2, "Two", authors=["Franz Kafka"], tags=["Fiction"], formats={"EPUB": OTHER_EPUB}
|
||||||
|
)
|
||||||
|
root = fixture.commit()
|
||||||
|
|
||||||
|
source = await library_of(root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
assert len(result.created) == 2
|
||||||
|
|
||||||
|
first, second = [await books_service.get(book_id) for book_id in result.created]
|
||||||
|
|
||||||
|
assert first.authors[0].id == second.authors[0].id
|
||||||
|
assert first.tags[0].id == second.tags[0].id
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_second_copy_is_reported_not_refused(
|
||||||
|
books_service: BookService, test_library: m.Library, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Two catalogue rows for one book, with different bytes, both import.
|
||||||
|
|
||||||
|
File-level dedupe cannot see it — the archives differ — so book-level detection
|
||||||
|
reports the pair and leaves the decision to the reader.
|
||||||
|
"""
|
||||||
|
padded = tmp_path / "padded.epub"
|
||||||
|
padded.write_bytes(EPUB.read_bytes() + b"\0" * 64)
|
||||||
|
|
||||||
|
fixture = CalibreFixture(tmp_path / "source")
|
||||||
|
fixture.add_book(
|
||||||
|
1, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": EPUB}
|
||||||
|
)
|
||||||
|
fixture.add_book(
|
||||||
|
2, "The Metamorphosis", authors=["Franz Kafka"], formats={"EPUB": padded}
|
||||||
|
)
|
||||||
|
root = fixture.commit()
|
||||||
|
|
||||||
|
source = await library_of(root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
assert len(result.created) == 2
|
||||||
|
assert len(result.possible_duplicates) == 1
|
||||||
|
assert result.possible_duplicates[0].candidates[0].book_id == result.created[0]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_allow_duplicates_stores_the_same_bytes_again(
|
||||||
|
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||||
|
) -> None:
|
||||||
|
for allow in (False, True):
|
||||||
|
source = await library_of(calibre_root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(
|
||||||
|
source, test_library, allow_duplicates=allow
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
assert len(result.created) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_duplicate_scope_off_imports_everything(
|
||||||
|
books_service: BookService,
|
||||||
|
test_library: m.Library,
|
||||||
|
calibre_root: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(settings, "duplicate_scope", "off")
|
||||||
|
|
||||||
|
for _ in range(2):
|
||||||
|
source = await library_of(calibre_root)
|
||||||
|
try:
|
||||||
|
result = await books_service.create_many_from_calibre(source, test_library)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
assert len(result.created) == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_progress_is_reported_per_book(
|
||||||
|
books_service: BookService, test_library: m.Library, calibre_root: Path
|
||||||
|
) -> None:
|
||||||
|
"""The import is long enough that its progress is the only thing worth watching."""
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
source = await library_of(calibre_root)
|
||||||
|
try:
|
||||||
|
await books_service.create_many_from_calibre(
|
||||||
|
source, test_library, on_progress=seen.append
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await source.close()
|
||||||
|
|
||||||
|
assert [progress.processed for progress in seen] == [1, 2, 3]
|
||||||
|
assert all(progress.total == 3 for progress in seen)
|
||||||
|
assert [progress.outcome for progress in seen] == ["created", "created", "skipped"]
|
||||||
|
assert seen[0].title == "The Metamorphosis"
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
# Implementation brief: importing a Calibre library
|
||||||
|
|
||||||
|
Written for an agent picking this up cold. Read the repo-root `AGENTS.md` and
|
||||||
|
`backend/AGENTS.md` first — this brief assumes both, particularly the **Filesystem behaviour**
|
||||||
|
and **Duplicate detection** sections.
|
||||||
|
|
||||||
|
Every claim about Calibre's schema and on-disk layout below was checked against a real library at
|
||||||
|
`~/Documents/Calibre Library` (6 books, current Calibre). Where a fact came from Calibre's source
|
||||||
|
rather than that library, it says so.
|
||||||
|
|
||||||
|
## Feasibility: high, and most of the machinery already exists
|
||||||
|
|
||||||
|
`metadata.db` is plain SQLite with a schema that has been stable for a decade, and the files sit
|
||||||
|
beside it in a predictable tree. Chitai already has every piece an import needs:
|
||||||
|
|
||||||
|
| Needed | Already in the tree |
|
||||||
|
| --- | --- |
|
||||||
|
| Ingest files that are already on disk | `BookService.create_many_from_existing_files` (`services/book.py:1280`) |
|
||||||
|
| Deduplicate authors/tags/publishers/series | `_populate_with_unique_relationships` (`services/book.py:1752`), via `as_unique_async` |
|
||||||
|
| Generic identifiers with a scheme map | `Identifier`, and `parse_identifier` (`services/metadata_extractor.py:58`) — whose map already covers `isbn`, `amazon`, `mobi-asin`, `google`, `goodreads`, `doi`, `calibre` |
|
||||||
|
| Decide where a book lives on disk | `BookPathGenerator`, `_reserve_book_path` (`services/book.py:1066`) |
|
||||||
|
| Not import the same book twice | `find_duplicate_files` (`:461`) and `find_duplicate_books` (`:535`) |
|
||||||
|
| Store a cover | `_save_cover_image` (`:1994`) |
|
||||||
|
|
||||||
|
So this is **a reader, not new ingest machinery**: turn Calibre rows into the metadata dict
|
||||||
|
`BookService` already accepts, and hand it to a slightly generalised version of the consume-directory
|
||||||
|
path. The parsing is the easy half.
|
||||||
|
|
||||||
|
The hard parts are elsewhere, and all three are addressed below:
|
||||||
|
|
||||||
|
1. Calibre libraries hold formats Chitai cannot describe, let alone read — and one of them
|
||||||
|
**currently 500s the book detail endpoint** (see prerequisites).
|
||||||
|
2. A 5,000-book import is a long-running job, and the app has no job/progress concept.
|
||||||
|
3. Whether files are **copied** into the library or **referenced in place** — which is a
|
||||||
|
product decision with a large blast radius, because in-place means Chitai's write paths point
|
||||||
|
at somebody's Calibre library.
|
||||||
|
|
||||||
|
## What a Calibre library actually is
|
||||||
|
|
||||||
|
```
|
||||||
|
Calibre Library/
|
||||||
|
├── metadata.db the whole catalogue
|
||||||
|
├── metadata_db_prefs_backup.json ignore
|
||||||
|
├── .caltrash/ .calnotes/ ignore — deleted books still live in .caltrash
|
||||||
|
└── <Author Name>/
|
||||||
|
└── <Title> (<book id>)/ == books.path
|
||||||
|
├── cover.jpg iff books.has_cover
|
||||||
|
├── metadata.opf ignore; the db is authoritative
|
||||||
|
└── <data.name>.<format> one per row in `data`
|
||||||
|
```
|
||||||
|
|
||||||
|
The tables that matter, and nothing else: `books`, `authors` + `books_authors_link`,
|
||||||
|
`publishers` + `books_publishers_link`, `tags` + `books_tags_link`, `series` +
|
||||||
|
`books_series_link`, `languages` + `books_languages_link`, `comments`, `identifiers`, `data`,
|
||||||
|
`books_pages_link`, `last_read_positions`.
|
||||||
|
|
||||||
|
Ten things that will produce wrong data if you do not know them:
|
||||||
|
|
||||||
|
- **Never query the views.** `meta`, `tag_browser_*` and friends call SQLite functions Calibre
|
||||||
|
registers from Python at connection time. Verified: `SELECT * FROM meta` fails with
|
||||||
|
`no such function: sortconcat`. Query base tables only.
|
||||||
|
|
||||||
|
- **`pubdate` has a sentinel, not a null.** An unknown publication date is stored as
|
||||||
|
`0101-01-01 00:00:00+00:00` (Calibre's `UNDEFINED_DATE`, year 101). It parses fine as a
|
||||||
|
`date`, so nothing will complain — two of the six books in the reference library carry it. Drop
|
||||||
|
any `pubdate` with year < 1000. The same sentinel appears in `timestamp`.
|
||||||
|
|
||||||
|
- **`data.name` is lossy and is not the title.** It is the on-disk stem, truncated to Calibre's
|
||||||
|
filename limit and sanitised. Verified in the reference library: the book titled
|
||||||
|
`The Project Gutenberg eBook #33283: Calculus Made Easy, 2nd Edition` is stored as
|
||||||
|
`The Project Gutenberg eBook #33283_ Calcul - Silvanus Phillips Thompson.pdf`. So the file
|
||||||
|
extractors must not be consulted for metadata (see decision 2), and `books.path`/`data.name`
|
||||||
|
are for *locating* files only.
|
||||||
|
|
||||||
|
- **`books.title` may contain characters Calibre strips from its own paths** — `:` became `_`
|
||||||
|
above, and titles legitimately contain `/` (`AC/DC`). `BookPathGenerator` interpolates the
|
||||||
|
title straight into a path and only collapses repeated slashes
|
||||||
|
(`services/filesystem_library.py`), so an unsanitised Calibre title can silently add a
|
||||||
|
directory level. Sanitise `/` and control characters out of `title` before path generation.
|
||||||
|
|
||||||
|
- **`authors.name` escapes commas as `|`.** Calibre's `AuthorsTable` unserialises with
|
||||||
|
`name.replace('|', ',')` (from Calibre's `db/tables.py`; the reference library has no such
|
||||||
|
name, so this one is unverified locally). Do the same replacement, and pass `authors.name` —
|
||||||
|
**not** `authors.sort`, which is `Melville, Herman`. `format_author_name` would flip the sort
|
||||||
|
form correctly anyway, but there is no reason to hand it the worse input.
|
||||||
|
|
||||||
|
- **`series_index` is a REAL.** `7.0` must become `"7"`, not `"7.0"` — `Book.series_position` is
|
||||||
|
a string, and `find_duplicate_books`'s series-position disqualifier compares it as one.
|
||||||
|
|
||||||
|
- **`languages.lang_code` is ISO 639-2/B** (`eng`), while `EpubExtractor` stores raw
|
||||||
|
`DC:language` (`en`). Both will coexist in the column. `Book.language` is free text and the
|
||||||
|
edit form is a plain `<input>`, so nothing breaks; normalising to two letters is optional
|
||||||
|
polish, not part of this work.
|
||||||
|
|
||||||
|
- **`comments.text` is HTML.** `Book.description` is rendered as plain text by
|
||||||
|
`CollapsibleText`, so `<p>` tags will show literally. Strip to text on import.
|
||||||
|
|
||||||
|
- **`books_pages_link` is usually empty of real data.** It carries `needs_scan` and, in the
|
||||||
|
reference library, `pages = 0` for all six books. Only use it when `pages > 0`.
|
||||||
|
|
||||||
|
- **`identifiers.type` is free text.** The reference library holds `isbn`, `amazon` and
|
||||||
|
`mobi-asin`, all of which `parse_identifier` already maps. Feed every identifier through it and
|
||||||
|
keep whatever survives; do not filter to a known list.
|
||||||
|
|
||||||
|
## Decisions to settle before writing code
|
||||||
|
|
||||||
|
1. **Copy files into the library. Do not move, do not reference in place** — for the first
|
||||||
|
version. Moving leaves `metadata.db` pointing at files that are gone, which quietly destroys a
|
||||||
|
library the user still uses. Referencing in place is genuinely desirable (nobody wants two
|
||||||
|
copies of 80 GB) but it points `book.path` at the Calibre tree, and `update_book` **moves
|
||||||
|
directories** while `delete_books` **deletes files** — so a metadata edit in Chitai would
|
||||||
|
rearrange somebody's Calibre library. `Library.read_only` exists but is enforced in exactly one
|
||||||
|
place (`services/library.py:54`, at creation). See phase 3.
|
||||||
|
|
||||||
|
2. **Trust Calibre's metadata; do not run the extractors.** Calibre's catalogue is curated, its
|
||||||
|
filenames are truncated garbage, and running `Extractor.extract_metadata` over thousands of
|
||||||
|
files means opening every EPUB and rendering a cover page from every PDF. Take the cover from
|
||||||
|
`cover.jpg` directly. The one exception worth allowing: fill `pages` from the file when
|
||||||
|
Calibre has no useful value, behind a flag, off by default.
|
||||||
|
|
||||||
|
3. ~~**The source is a server-side path, not an upload.**~~ **Reversed in review, and the reason
|
||||||
|
this brief was wrong is worth keeping.** The premise — "the library lives on the same host as
|
||||||
|
the backend in every realistic deployment" — is false for the common case: Calibre is a desktop
|
||||||
|
application, and its library is on the desktop. So the split is by *surface*, not by preference:
|
||||||
|
|
||||||
|
- **Over HTTP: an uploaded zip only.** `…/imports/calibre/upload`. There is no endpoint taking a
|
||||||
|
server path; one was built and then removed deliberately.
|
||||||
|
- **On the server: a path only.** `litestar calibre-import <path>`, which is where a very large
|
||||||
|
library or a headless migration belongs — an upload has to carry the whole archive across
|
||||||
|
first.
|
||||||
|
|
||||||
|
A CLI taking a path needs no justification. An *endpoint* taking one would have: it would let any
|
||||||
|
authenticated caller read any directory the backend can, and `TODO.md` records there is no
|
||||||
|
authorization tier at all. Not adding it is one less thing to gate later.
|
||||||
|
|
||||||
|
4. **Import into an existing Chitai library**, chosen by the caller. Creating a library is
|
||||||
|
already one action, and the Calibre tree is rewritten by `BookPathGenerator` regardless.
|
||||||
|
|
||||||
|
5. **Re-running an import must be safe, and file-level dedupe already makes it so.** The same
|
||||||
|
bytes are recognised by `(hash, size)` whatever their path, so a second run over the same
|
||||||
|
library skips everything. No import bookkeeping is needed for idempotency.
|
||||||
|
|
||||||
|
## Prerequisite: a `.mobi` file breaks the book endpoint — **done**
|
||||||
|
|
||||||
|
> Landed ahead of the import itself. `guess_content_type` in `services/utils.py` now names every
|
||||||
|
> format from its extension, the column keeps a null when nothing can name one, and the OPDS feed
|
||||||
|
> substitutes `application/octet-stream` at the one place a string is required. The Read control is
|
||||||
|
> driven by `isReadable` rather than by the file count. See the section below for why it mattered,
|
||||||
|
> and `backend/AGENTS.md` for the rule as it now stands.
|
||||||
|
|
||||||
|
|
||||||
|
`FileMetadataRead.content_type` is a required `str` (`schemas/book.py:33`), but every ingest path
|
||||||
|
fills it from `mimetypes.guess_type`, which returns `None` for `.mobi`, `.azw`, `.fb2`, `.lit`
|
||||||
|
and `.htmlz` (verified). `FileMetadata.content_type` is nullable in the model, so the row stores
|
||||||
|
fine and then fails response validation on the way out — a book whose only file is a MOBI would
|
||||||
|
be unreadable through the API.
|
||||||
|
|
||||||
|
Nothing in the tree hits this today because the browser upload path is used with EPUBs and PDFs.
|
||||||
|
A Calibre library is full of MOBI and AZW3. Fix it first, either way round:
|
||||||
|
|
||||||
|
- make the schema field `str | None`, and/or
|
||||||
|
- add a small extension→MIME table for the ebook formats `mimetypes` does not know
|
||||||
|
(`application/x-mobipocket-ebook`, `application/vnd.amazon.ebook`, `application/x-fictionbook+xml`).
|
||||||
|
|
||||||
|
Do both, in fact: the table is the right answer for OPDS clients, which choose an acquisition link
|
||||||
|
by MIME type, and the nullable field is the safety net.
|
||||||
|
|
||||||
|
**Related, but not a blocker:** Chitai reads EPUB and PDF only. `openBookInReader`
|
||||||
|
(`book/[bookId]/+page.svelte:66`) branches on `getFileType(...) === 'EPUB' | 'PDF'` and does
|
||||||
|
nothing for anything else, so an AZW3-only book gets a Read button that silently fails. Importing
|
||||||
|
those files is still right — they are downloadable and they are the user's — but the button
|
||||||
|
should be disabled for a book with no readable file. One `$derived` on the page, worth doing in
|
||||||
|
the same branch.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. `services/calibre.py` — a pure reader, no Chitai types
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CalibreFile:
|
||||||
|
path: Path # absolute, resolved against the library root
|
||||||
|
format: str # "EPUB", as stored
|
||||||
|
size: int # data.uncompressed_size, for a cheap sanity check
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CalibreBook:
|
||||||
|
calibre_id: int
|
||||||
|
uuid: str
|
||||||
|
title: str
|
||||||
|
authors: list[str]
|
||||||
|
... # one field per row of the mapping table below
|
||||||
|
cover: Path | None
|
||||||
|
files: list[CalibreFile]
|
||||||
|
|
||||||
|
class CalibreLibrary:
|
||||||
|
def __init__(self, root: Path) -> None: ...
|
||||||
|
async def open(self) -> None: ... # copy + connect, see below
|
||||||
|
async def books(self) -> AsyncIterator[CalibreBook]: ...
|
||||||
|
async def close(self) -> None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Deliberately knows nothing about `Book`, `BookService` or the session — it is a file-format
|
||||||
|
reader, unit-testable against a fixture database with no Postgres and no app.
|
||||||
|
|
||||||
|
Two implementation notes:
|
||||||
|
|
||||||
|
- **Copy `metadata.db` to a temp file and read the copy.** Calibre may be running and writing;
|
||||||
|
opening the live file read-only either sees a torn state or needs the `-wal` sidecar. The
|
||||||
|
database is small (438 KB for six books, single-digit MB for thousands), so a copy costs
|
||||||
|
nothing and removes the whole problem.
|
||||||
|
- **`sqlite3` inside `asyncio.to_thread`, not a new dependency.** The connection is used for a
|
||||||
|
handful of queries. Do not add `aiosqlite` for this.
|
||||||
|
|
||||||
|
Read the whole catalogue in **one query per table** and join in Python — six or so `SELECT`s and
|
||||||
|
a few dicts, versus a per-book N+1 across ten tables. At self-hosted scale the entire catalogue
|
||||||
|
minus descriptions fits in memory comfortably; if `comments.text` for 20k books is a concern,
|
||||||
|
fetch that one table per batch.
|
||||||
|
|
||||||
|
### 2. The mapping
|
||||||
|
|
||||||
|
| Calibre | Chitai | Notes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `books.title` | `title` | Sanitise `/` and control chars for path generation. `Extractor.format_book_title` may still be worth applying to split a subtitle at the second colon — but **do not** run `split_edition`, Calibre's title is the curated one. |
|
||||||
|
| `authors.name` via `books_authors_link` | `authors` | `\|` → `,`. Order by `books_authors_link.id`; `Book.author_links` is an `ordering_list`, so insertion order is the displayed order. |
|
||||||
|
| `comments.text` | `description` | Strip HTML to text. |
|
||||||
|
| `books.pubdate` | `published_date` | Drop the year-101 sentinel. |
|
||||||
|
| `series.name`, `books.series_index` | `series`, `series_position` | `7.0` → `"7"`. |
|
||||||
|
| `tags.name` | `tags` | |
|
||||||
|
| `publishers.name` | `publisher` | `books_publishers_link` is unique per book. |
|
||||||
|
| `languages.lang_code` (lowest `item_order`) | `language` | Chitai holds one. |
|
||||||
|
| `identifiers.type` / `.val` | `identifiers` | Through `parse_identifier`; keep what survives. |
|
||||||
|
| `books.uuid` | `identifiers["calibre-uuid"]` | The one durable link back to the source row. `normalize_identifier` returns a key for it (it is not in `_PER_BUILD_NAMES`), which is *desirable*: a book re-imported from the same Calibre library matches on it exactly. |
|
||||||
|
| `books_pages_link.pages` | `pages` | Only when `> 0`. |
|
||||||
|
| `cover.jpg` when `has_cover` | `cover_image` | |
|
||||||
|
| `data` rows | `files` | |
|
||||||
|
| `books.timestamp` | — | `Book.created_at` is audit-managed; do not fight it. |
|
||||||
|
| `ratings`, `annotations`, `custom_columns` | — | No home in the model. Out of scope. |
|
||||||
|
| `last_read_positions` | `BookProgress` | Phase 2. |
|
||||||
|
|
||||||
|
### 3. The ingest
|
||||||
|
|
||||||
|
> **As built, this went on `BookService` as `create_many_from_calibre`, not into a separate
|
||||||
|
> `services/calibre_import.py`.** The orchestration needs `_reserve_book_path`,
|
||||||
|
> `_save_cover_image`, `_screen_for_duplicates` and `_record_possible_duplicates`, and reaching
|
||||||
|
> into four privates from another module is worse than one more method in the file where the other
|
||||||
|
> two ingest paths already live. `_record_possible_duplicates` was changed to take the list it
|
||||||
|
> appends to rather than an `ImportResult`, so every ingest path can share it whatever its own
|
||||||
|
> result type is. Two other deviations: `CalibreLibrary.books()` returns a list rather than an
|
||||||
|
> async iterator, because the caller needs the total up front anyway; and the reader reports
|
||||||
|
> identifiers exactly as Calibre keyed them, with the fold onto Chitai's schemes done by the
|
||||||
|
> importer, which keeps the reader free of Chitai imports.
|
||||||
|
|
||||||
|
Per book, in this order — it mirrors `create_many_from_existing_files`, which is the closest
|
||||||
|
existing shape:
|
||||||
|
|
||||||
|
1. `fingerprint_file` each source file (`services/utils.py:164`).
|
||||||
|
2. `find_duplicate_files` against the target library. All files known → skip the book entirely,
|
||||||
|
recording it. Some known → import the rest.
|
||||||
|
3. Build the metadata dict from the `CalibreBook`.
|
||||||
|
4. `_reserve_book_path(path_gen.generate_path(data))`.
|
||||||
|
5. **Copy** each file to `parent / _unused_path(...)`, building `FileMetadata` from the
|
||||||
|
fingerprint already computed. `services/utils.py` has `move_file` but no copy — add
|
||||||
|
`copy_file` beside it, streaming through `aiofiles` in `CHUNK_SIZE` blocks like
|
||||||
|
`_save_book_files` does, not `shutil.copy` (a 40 MB blocking read inside the event loop).
|
||||||
|
6. Cover: open `cover.jpg` with PIL and hand the `Image` to `_save_cover_image`, which already
|
||||||
|
accepts one and converts to WebP.
|
||||||
|
7. `super().create(data)` through `BookService`, then `find_duplicate_books` and record
|
||||||
|
candidates — same as `_record_possible_duplicates` (`services/book.py:1253`).
|
||||||
|
8. **Commit per book.** A 5,000-book import inside one transaction is one failure away from
|
||||||
|
nothing, and per-book commits are what lets the library page show books arriving — which is
|
||||||
|
the behaviour commit `85367da` deliberately built.
|
||||||
|
|
||||||
|
Report an `ImportResult`-shaped outcome; reuse `ImportResult` itself if it fits, extending it with
|
||||||
|
a `failures: list[tuple[int, str]]` keyed by Calibre id. **One book must never fail the run** —
|
||||||
|
a missing file, an unreadable cover or a `NOT NULL` violation gets recorded and skipped.
|
||||||
|
|
||||||
|
### 4. Progress, and where the import runs
|
||||||
|
|
||||||
|
> **As built, the HTTP surface is an uploaded archive and nothing else** — see decision 3.
|
||||||
|
>
|
||||||
|
> `POST …/imports/calibre/upload` takes a zipped library. The job owns the temp directory it is
|
||||||
|
> unpacked into and deletes it when it ends. Extraction refuses zip slip, an archive too big for the
|
||||||
|
> disk, and one with no `metadata.db` within three levels — all answered 400 before a job exists.
|
||||||
|
>
|
||||||
|
> This forced a fix to the SvelteKit proxy, which buffered request bodies with `arrayBuffer()`:
|
||||||
|
> survivable for one book, not for a multi-gigabyte archive. POST and PATCH now stream
|
||||||
|
> `request.body` through with `duplex: 'half'`.
|
||||||
|
>
|
||||||
|
> **A preview endpoint was built and then removed with the path route.** It read a server-side
|
||||||
|
> catalogue and reported its size before writing anything, which is only useful when the caller
|
||||||
|
> named a directory. An upload has already been carried across by the time anything can be read, so
|
||||||
|
> unpacking it *is* the validation step — an archive that is not a Calibre library is refused there.
|
||||||
|
|
||||||
|
The import outlives its request, so the handler starts it and returns a handle:
|
||||||
|
|
||||||
|
- `POST /libraries/{library_id:int}/imports/calibre` — body `{path, copy_files: true}`, returns
|
||||||
|
`{job_id, total}`. 202.
|
||||||
|
- `GET /libraries/imports/{job_id}` — `{state, total, processed, created, skipped, failed,
|
||||||
|
current_title, errors}`.
|
||||||
|
- `DELETE /libraries/imports/{job_id}` — cancel; the task checks a flag between books.
|
||||||
|
|
||||||
|
Keep the registry **in memory**, a `dict[str, ImportJob]` on a module-level singleton, with the
|
||||||
|
task created by `asyncio.create_task`. This matches what the app already does — the consume
|
||||||
|
watcher is an in-process singleton started from a lifespan hook — and it is roughly thirty lines
|
||||||
|
against a model, a migration and a service for the alternative.
|
||||||
|
|
||||||
|
State that limitation explicitly in the docstring: **it assumes one worker process.** The
|
||||||
|
production `CMD` is `litestar run`, which is single-process, so this holds today; `TODO.md`
|
||||||
|
already records that the production image should move to uvicorn with a worker count, and doing
|
||||||
|
that would mean a poll landing on a worker that has never heard of the job. The consume watcher
|
||||||
|
has the same problem, so this is not a new constraint — but the next person to add workers needs
|
||||||
|
to find it written down. If import *history* is ever wanted, that is when an `import_jobs` table
|
||||||
|
earns its migration.
|
||||||
|
|
||||||
|
**Also add a CLI entry point.** A 200 GB library imported through a browser tab that must stay
|
||||||
|
open is a bad experience, and `pyproject.toml` already declares a `chitai` script. A Litestar CLI
|
||||||
|
command (`litestar --app-dir src/chitai/ calibre-import <path> --library <slug>`) is ~20 lines
|
||||||
|
over the same service and is the right tool for the initial migration, which is the case this
|
||||||
|
whole feature exists for. The endpoint is for people who would rather click.
|
||||||
|
|
||||||
|
### 5. Frontend
|
||||||
|
|
||||||
|
Model it on the duplicates screen, which is the closest precedent in shape and placement:
|
||||||
|
|
||||||
|
- Route `(root)/settings/libraries/[libraryId]/import` — beside
|
||||||
|
`settings/libraries/[libraryId]/duplicates`, reached from the library settings page.
|
||||||
|
- `getCalibreImport` (a `query`) and `cancelCalibreImport` (a `command`) in
|
||||||
|
`src/lib/api/calibre-import.remote.ts`, re-exported from `src/lib/api/index.ts`. **Starting an
|
||||||
|
import is not a remote function**: the archive goes to the backend through the proxy so the
|
||||||
|
browser streams straight through, where a remote function would put the whole thing through the
|
||||||
|
SvelteKit process first. The screen uses `XMLHttpRequest` for it, which is the only way to get
|
||||||
|
upload progress.
|
||||||
|
- A file input, an upload progress bar, then a progress bar polling `getCalibreImport` every second
|
||||||
|
or two, a running count, and the failures listed at the end with their Calibre ids.
|
||||||
|
- Finish with a link to the library's duplicates screen. An import into a non-empty library is
|
||||||
|
the single most likely way to produce duplicate books, and that screen already handles them.
|
||||||
|
|
||||||
|
Do **not** route this through the upload tray. The tray reports on a client-driven queue it owns
|
||||||
|
(`upload-queue.svelte.ts`); this is server-side work whose state survives a page reload, and
|
||||||
|
conflating the two would mean teaching the tray to poll.
|
||||||
|
|
||||||
|
Regenerate `src/lib/schema/openapi/schema.d.ts` against a backend running **your** branch —
|
||||||
|
a stale server silently writes a stale file.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
The fixture is the interesting part. Build a Calibre library in a `tmp_path` fixture rather than
|
||||||
|
committing a binary `metadata.db`: a helper that executes the subset of Calibre's `CREATE TABLE`
|
||||||
|
statements (they are in this document's shape, and in any real library's `sqlite_master`), inserts
|
||||||
|
a handful of books, and lays out `<Author>/<Title> (id)/` directories containing the existing
|
||||||
|
EPUB and PDF fixtures from `backend/tests/data_files/` plus a copy of `cover.jpg`. Generated
|
||||||
|
beats committed here because the tests need to assert on *odd* rows — the pubdate sentinel, a
|
||||||
|
`|` in an author name, a title with a colon — and those are clearer written in Python than hidden
|
||||||
|
in a blob.
|
||||||
|
|
||||||
|
- **Unit** (`tests/unit/test_calibre.py`) — the reader alone: field mapping; the year-101 pubdate
|
||||||
|
dropped; `series_index` 7.0 → `"7"`; `|` unescaped in an author name; HTML stripped from
|
||||||
|
`comments`; `pages = 0` ignored; identifiers passed through `parse_identifier`; a `data` row
|
||||||
|
whose file is missing from disk reported rather than raised; `.caltrash` never walked.
|
||||||
|
- **Service** (`tests/unit/test_services/test_calibre_import.py`) — a book with two formats lands
|
||||||
|
as one record with two files; the source files still exist afterwards; a second run over the
|
||||||
|
same library creates nothing; a library with one broken book imports the rest; authors and tags
|
||||||
|
shared between two books produce one `Author` / `Tag` row each; `possible_duplicates` reported
|
||||||
|
when the target library already holds the same book.
|
||||||
|
- **Integration** (`tests/integration/test_calibre_import.py`) — `POST` returns 202 with a job id,
|
||||||
|
polling reaches a terminal state, and the books are then listable through `GET /books`. A
|
||||||
|
`.mobi`-only book must come back from `GET /books/{id}` without a 500 — that is the
|
||||||
|
prerequisite's regression test.
|
||||||
|
|
||||||
|
`pytest` needs Docker (`pytest-databases`).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix-shell # postgres + migrations applied
|
||||||
|
cd backend
|
||||||
|
pytest tests/ # take your own baseline first
|
||||||
|
uv run litestar --app-dir src/chitai/ run --port 8001 # for the OpenAPI regeneration
|
||||||
|
cd ../frontend && pnpm check # baseline: 30 errors, 1 warning, 8 files
|
||||||
|
```
|
||||||
|
|
||||||
|
Neither `pnpm check` nor `pnpm lint` is clean on this repo — baseline before assuming an error is
|
||||||
|
yours.
|
||||||
|
|
||||||
|
End to end, against a real library (`~/Documents/Calibre Library` will do): import into an empty
|
||||||
|
Chitai library and confirm the six books arrive with their covers, authors, tags, series
|
||||||
|
positions and identifiers intact; that the AZW3 book is listed and downloadable; that the source
|
||||||
|
library is byte-for-byte untouched (`diff -r` a copy taken beforehand); and that re-running the
|
||||||
|
import creates nothing and reports six skipped books. Then import the same library into a
|
||||||
|
library that already holds one of those books by upload, and confirm it lands on the duplicates
|
||||||
|
screen rather than as a second copy.
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
| Phase | Scope |
|
||||||
|
| --- | --- |
|
||||||
|
| **0** ✅ | The `content_type` prerequisite, plus the Read button. **Done** — see below. |
|
||||||
|
| **1** ✅ | `services/calibre.py`, the ingest, the CLI command, copy-only. **Done** — a headless one-time migration works today. |
|
||||||
|
| **2** ◐ | The upload endpoint, the job registry and the settings screen — **done**. The HTTP surface is an uploaded zip only; see decision 3, which this reversed. `last_read_positions` → `BookProgress` is **not** done: it needs a Calibre-user → Chitai-user mapping, and the answer differs between the endpoint (which has a `current_user`) and the CLI (which has none). That decision is the next thing to make. |
|
||||||
|
| **3** | Reference-in-place import. Its real content is **enforcing `Library.read_only`** across `update_book`, `delete_books`, `add_files` and `remove_files` — which is a feature of its own and should not be smuggled in under an import. |
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Annotations and highlights (no model to put them in), custom columns, ratings, virtual libraries
|
||||||
|
and saved searches → bookshelves, format conversion, writing anything back to Calibre, and any
|
||||||
|
form of continuing two-way sync. This is a one-way migration.
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
# Implementation brief: move Duplicates into library settings
|
||||||
|
|
||||||
|
Written for an agent picking this up cold. Read the repo-root `AGENTS.md` and
|
||||||
|
`frontend/AGENTS.md` first — this brief assumes both.
|
||||||
|
|
||||||
|
**This is a frontend-only change.** The backend already scopes everything by library
|
||||||
|
(`GET /books/duplicate-books?library_id=`), so no endpoint, schema or migration is
|
||||||
|
involved.
|
||||||
|
|
||||||
|
## Where this starts from
|
||||||
|
|
||||||
|
The duplicates review screen exists and works. It currently lives at
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/routes/(root)/(library)/library/[libraryId]/duplicates/
|
||||||
|
+page.server.ts loads the groups, plus the full Book records merge needs
|
||||||
|
+page.svelte group cards, "Not duplicates", "Merge…"
|
||||||
|
```
|
||||||
|
|
||||||
|
and is reached from a **Duplicates entry in the main sidebar**
|
||||||
|
(`frontend/src/lib/components/layout/nav-main.svelte`), which is what this change
|
||||||
|
removes.
|
||||||
|
|
||||||
|
Settings today is a flat, entirely global four-item nav
|
||||||
|
(`frontend/src/routes/(root)/settings/+layout.svelte`): Account, Appearance, Libraries,
|
||||||
|
Devices. `settings/libraries/+page.svelte` is a single table of every library whose rows
|
||||||
|
link *out* to the library itself. **There is nowhere that means "settings for this
|
||||||
|
library"** — that is the gap this change fills.
|
||||||
|
|
||||||
|
## What to build — option B
|
||||||
|
|
||||||
|
Libraries expands in the settings nav. Every library is a sub-item; selecting one swaps
|
||||||
|
the pane; Duplicates is a section inside that pane. All libraries and all their sections
|
||||||
|
end up one click apart.
|
||||||
|
|
||||||
|
```
|
||||||
|
/settings/libraries the existing table (leave it as the index)
|
||||||
|
/settings/libraries/[libraryId] redirects to the first section
|
||||||
|
/settings/libraries/[libraryId]/duplicates the review screen, moved
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggested files:
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
| --- | --- |
|
||||||
|
| `settings/libraries/[libraryId]/+layout.svelte` | Library name, and the section tabs |
|
||||||
|
| `settings/libraries/[libraryId]/+page.ts` | `redirect(303, …/duplicates)` |
|
||||||
|
| `settings/libraries/[libraryId]/duplicates/+page.server.ts` | Moved verbatim |
|
||||||
|
| `settings/libraries/[libraryId]/duplicates/+page.svelte` | Moved verbatim |
|
||||||
|
|
||||||
|
Duplicates is the **only** real section today. Build the tab strip so General and Danger
|
||||||
|
zone have somewhere obvious to land, but do not invent them now — an empty tab is worse
|
||||||
|
than no tab.
|
||||||
|
|
||||||
|
## The nav
|
||||||
|
|
||||||
|
In `settings/+layout.svelte`, `items` is a flat `as const` array matched on
|
||||||
|
`page.route.id`. Libraries needs to render its children beneath it:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
{#each libraryState.libraries as library (library.id)}
|
||||||
|
<a href={resolve('/(root)/settings/libraries/[libraryId]/duplicates', {
|
||||||
|
libraryId: String(library.id) })}> … </a>
|
||||||
|
{/each}
|
||||||
|
```
|
||||||
|
|
||||||
|
`getLibraryState()` **is** available under `/settings` — it is set in
|
||||||
|
`(root)/+layout.svelte`, above the settings group, and `settings/libraries/+page.svelte`
|
||||||
|
already uses it. No new load function is needed to list the libraries.
|
||||||
|
|
||||||
|
**Active state is matched on route id, not pathname.** There is a comment in
|
||||||
|
`settings/+layout.svelte` explaining why: `resolve()` returns an absolute path on the
|
||||||
|
client and a relative one during SSR, so a pathname comparison is false on the server and
|
||||||
|
true after hydration, and the highlight flashes in. A nested library item is active when
|
||||||
|
the route id matches **and** `page.params.libraryId === String(library.id)` — both, or
|
||||||
|
every library lights up at once.
|
||||||
|
|
||||||
|
## Things that will bite
|
||||||
|
|
||||||
|
1. **Remove the sidebar entry in the same change.** `nav-main.svelte` gained a
|
||||||
|
`Duplicates` item and a `CopyCheck` import when the screen was built. Delete both, and
|
||||||
|
delete the old route directory. Doing the removal and the move together is the point —
|
||||||
|
split across two commits the screen is unreachable in between.
|
||||||
|
|
||||||
|
2. **Delete the old route, do not leave it.** Two live copies of a screen that both write
|
||||||
|
is how they drift.
|
||||||
|
|
||||||
|
3. **`setBookSelectionState` is not available under `/settings`.** It is set in
|
||||||
|
`(root)/(library)/+layout.svelte`, which the settings group is not inside. This is
|
||||||
|
fine — the duplicates page uses `BookImage` directly, not `book-thumbnail.svelte`, and
|
||||||
|
`MergeBooks` takes its `libraryId` as a prop. **Verify this stays true** if you touch
|
||||||
|
either component; a `getBookSelectionState()` under settings returns `undefined` and
|
||||||
|
fails at the first access, not at import.
|
||||||
|
|
||||||
|
4. **Keep `depends('app:duplicate-books')`.** Both the dismiss action and `MergeBooks`
|
||||||
|
call `invalidate('app:duplicate-books')` to make a resolved group leave the screen.
|
||||||
|
Drop it and the page silently stops refreshing. `MergeBooks` also invalidates
|
||||||
|
`app:books`, which is a no-op under settings and should stay that way.
|
||||||
|
|
||||||
|
5. **The settings shell is height-constrained.** `settings/+layout.svelte` is
|
||||||
|
`h-[calc(100vh-var(--header-height)-2rem)]` with `overflow-auto` on the content pane.
|
||||||
|
The review screen is a long list of cards — it must scroll *inside* that pane. Its
|
||||||
|
current `mx-auto max-w-5xl` wrapper will want revisiting.
|
||||||
|
|
||||||
|
6. **Three levels of nav is option B's known cost.** Nav → library → section, and the
|
||||||
|
pane is narrower than the full-width route the screen was designed against. The group
|
||||||
|
cards are `w-36` covers in a wrapping flex row, so they reflow, but check a group of
|
||||||
|
four at a narrow window before calling it done.
|
||||||
|
|
||||||
|
7. **`resolve()` must be a direct call in markup** for `svelte/no-navigation-without-resolve`.
|
||||||
|
Where `nav-main.svelte` computes a url through a variable it carries an
|
||||||
|
`eslint-disable-next-line`; prefer the direct call over inheriting that.
|
||||||
|
|
||||||
|
8. **The loader depends on the `?ids=` fix.** `+page.server.ts` fetches full `Book`
|
||||||
|
records with `listBooks({ ids, pageSize })` because the merge workbench needs
|
||||||
|
identifiers, description and publisher, which `DuplicateBookRead` does not carry.
|
||||||
|
advanced_alchemy's stock id filter types that parameter as `list[str]` regardless of
|
||||||
|
config, which made Postgres refuse `bigint = character varying`; the override lives in
|
||||||
|
`backend/src/chitai/services/dependencies.py` (`create_book_filter_dependencies`).
|
||||||
|
If `GET /books?ids=1&ids=2` 500s, that override is missing — do not work around it in
|
||||||
|
the loader.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
The **General** and **Danger zone** sections (rename, path template, read-only, consume
|
||||||
|
directory, delete), and any change to the merge workbench itself. The toolbar entry point
|
||||||
|
for merge — select 2+ books in the library view — is unrelated and stays where it is.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
pnpm check # baseline: 30 errors, 1 warning, 8 files — none of them yours
|
||||||
|
pnpm lint # not clean either; check the files you touched, not the tree
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
By hand, with a library that has a duplicate group:
|
||||||
|
|
||||||
|
- Settings → Libraries lists every library beneath it; clicking one opens its pane.
|
||||||
|
- Duplicates shows the same groups the old route did, and scrolls inside the settings pane.
|
||||||
|
- **Not duplicates** removes the group and it stays gone after a reload.
|
||||||
|
- **Merge…** opens the workbench, merges, and the group leaves the screen.
|
||||||
|
- The main sidebar no longer has a Duplicates entry, and
|
||||||
|
`/library/<id>/duplicates` no longer resolves.
|
||||||
|
- A library with no duplicates shows the empty state, not a blank pane.
|
||||||
+80
-17
@@ -4,13 +4,16 @@ SvelteKit web app for the eBook library. See the repo-root `AGENTS.md` for the o
|
|||||||
dev-environment setup.
|
dev-environment setup.
|
||||||
|
|
||||||
**Stack:** SvelteKit 2 with `adapter-node` · Svelte 5 (runes) · Tailwind v4 · Zod v4 ·
|
**Stack:** SvelteKit 2 with `adapter-node` · Svelte 5 (runes) · Tailwind v4 · Zod v4 ·
|
||||||
`epubjs` · `mode-watcher` (dark mode) · `svelte-sonner` (toasts) · pnpm.
|
vendored `foliate-js` (EPUB) · vendored `pdf.js` (PDF) · `mode-watcher` (dark mode) ·
|
||||||
|
`svelte-sonner` (toasts) · pnpm.
|
||||||
|
|
||||||
Two experimental flags are on in `svelte.config.js` and the codebase depends on both:
|
Two experimental flags are on in `svelte.config.js` and the codebase depends on both:
|
||||||
`kit.experimental.remoteFunctions` and `compilerOptions.experimental.async` (`await` in components).
|
`kit.experimental.remoteFunctions` and `compilerOptions.experimental.async` (`await` in components).
|
||||||
|
|
||||||
Tailwind v4 has **no config file** — the theme, oklch colour tokens and `@custom-variant dark` all
|
Tailwind v4 has **no config file** — the theme, colour tokens (hex, not oklch) and
|
||||||
live in `src/app.css`.
|
`@custom-variant dark` all live in `src/app.css`. `dark` is the _only_ custom variant defined, so
|
||||||
|
generated components that assume others — shadcn's slider ships `data-horizontal:` / `data-vertical:`
|
||||||
|
classes — silently produce no styles. Use the `data-[orientation=…]` form instead.
|
||||||
|
|
||||||
## Talking to the backend
|
## Talking to the backend
|
||||||
|
|
||||||
@@ -65,8 +68,12 @@ Svelte context with a module-level `Symbol` key and a `setXState` / `getXState`
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
const LIBRARY_KEY = Symbol('LIBRARY');
|
const LIBRARY_KEY = Symbol('LIBRARY');
|
||||||
export function setLibraryState(libraries: Library[]) { return setContext(LIBRARY_KEY, new LibraryState(libraries)); }
|
export function setLibraryState(libraries: Library[]) {
|
||||||
export function getLibraryState() { return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY); }
|
return setContext(LIBRARY_KEY, new LibraryState(libraries));
|
||||||
|
}
|
||||||
|
export function getLibraryState() {
|
||||||
|
return getContext<ReturnType<typeof setLibraryState>>(LIBRARY_KEY);
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Follow that pattern rather than introducing stores. `library.svelte.ts` is the reference — including
|
Follow that pattern rather than introducing stores. `library.svelte.ts` is the reference — including
|
||||||
@@ -79,10 +86,56 @@ its optimistic-delete-with-rollback and toast handling. `bookCollection` / `book
|
|||||||
`@ieedan/shadcn-svelte-extras` (`jsrepo.json`). Treat as generated: add components with the CLIs
|
`@ieedan/shadcn-svelte-extras` (`jsrepo.json`). Treat as generated: add components with the CLIs
|
||||||
rather than hand-writing them, and prefer wrapping over editing.
|
rather than hand-writing them, and prefer wrapping over editing.
|
||||||
- App components live in `forms/`, `layout/`, `view/` (browser, grid/list/table, filters, sort) and
|
- App components live in `forms/`, `layout/`, `view/` (browser, grid/list/table, filters, sort) and
|
||||||
`reader/` (epub reader + chapter sidebar).
|
`reader/` (see [The readers](#the-readers)).
|
||||||
- `cn()` from `$lib/utils` merges Tailwind classes; the `WithElementRef` / `WithoutChild` helpers
|
- `cn()` from `$lib/utils` merges Tailwind classes; the `WithElementRef` / `WithoutChild` helpers
|
||||||
there are the shadcn prop-typing conventions.
|
there are the shadcn prop-typing conventions.
|
||||||
|
|
||||||
|
## The readers
|
||||||
|
|
||||||
|
**PDF** is the pdf.js viewer vendored under `static/pdfjs/`, pointed at by an iframe. Untouched by
|
||||||
|
the EPUB work; leave it alone unless the task is about PDFs.
|
||||||
|
|
||||||
|
**EPUB** is built on `foliate-js`, copied verbatim into `src/lib/vendor/foliate-js/` by
|
||||||
|
`scripts/vendor-foliate.sh` (pinned commit; see `src/lib/vendor/foliate-js/README.chitai.md`).
|
||||||
|
Upstream has no npm release and recommends a submodule; this repo has none and already vendors
|
||||||
|
pdf.js the same way, so it is copied instead. Only the import closure reachable from `view.js` is
|
||||||
|
vendored, and **`pdf.js` in that directory is our stub, not upstream's** — the real one imports a
|
||||||
|
bare `@pdfjs/pdf.min.mjs` that Rollup resolves at build time even though the path never runs.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
| Path | What |
|
||||||
|
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||||
|
| `lib/vendor/foliate-js/` | The engine. Do not edit — `vendor-foliate.sh` overwrites it. |
|
||||||
|
| `lib/reader/foliate.ts` | Lazy loader for the custom elements. The only thing that imports `$foliate`. |
|
||||||
|
| `lib/reader/settings.ts` · `stylesheet.ts` | Defaults/bounds, and the CSS injected into the book. |
|
||||||
|
| `lib/reader/progress.ts` | Debounced progress writer with a `sendBeacon` flush. |
|
||||||
|
| `lib/state/reader-settings.svelte.ts` | Settings state, persisted to `localStorage`. |
|
||||||
|
| `components/reader/foliate-view.svelte` | Wraps `<foliate-view>`; owns the imperative lifecycle. |
|
||||||
|
| `components/reader/epub-reader.svelte` | The shell: chrome, TOC, errors, progress. |
|
||||||
|
|
||||||
|
Things that will bite:
|
||||||
|
|
||||||
|
- **`$foliate` is a Vite-only alias.** It is deliberately absent from `kit.alias` and tsconfig
|
||||||
|
`paths` so TypeScript cannot resolve it and falls back to the ambient declaration in
|
||||||
|
`lib/reader/foliate-js.d.ts`; `src/lib/vendor` is also in tsconfig `exclude`. Without both,
|
||||||
|
`checkJs` walks ~11k lines of untyped JS. The declaration file must **not** be named `foliate.d.ts`
|
||||||
|
— beside `foliate.ts`, TypeScript takes it for that file's emitted declaration and drops it.
|
||||||
|
- **Never import the vendored code at module scope.** `view.js` calls `customElements.define` and
|
||||||
|
subclasses `HTMLElement` on import, so it must stay behind `loadFoliate()` inside `onMount`. SSR is
|
||||||
|
otherwise on for the reader route.
|
||||||
|
- **Sections render in iframes, which swallow key events.** Keyboard handlers are bound per section
|
||||||
|
document on the `load` event, and modifier combinations are replayed onto the host window so app
|
||||||
|
shortcuts (the sidebar's ctrl+B) still work while reading.
|
||||||
|
- **Renderer settings split two ways.** Flow, gap, margins, column count and line width are
|
||||||
|
_attributes_ set with `setAttribute` (there is no JS property API, no `margin` shorthand and no
|
||||||
|
`spread` — a spread is `max-column-count: 2`). Typography is CSS passed to `renderer.setStyles`,
|
||||||
|
which takes a `[before, after]` pair: the first is prepended to the section head so the book
|
||||||
|
overrides it, the second appended so it wins. User settings belong in the second, with
|
||||||
|
`!important`, or the book's own CSS beats them.
|
||||||
|
- **Progress needs no locations pre-pass.** `relocate` carries both a CFI and an overall `fraction`,
|
||||||
|
which map straight onto `epub_cfi` and `percentage`.
|
||||||
|
|
||||||
## Routing
|
## Routing
|
||||||
|
|
||||||
Route groups carry the layout structure:
|
Route groups carry the layout structure:
|
||||||
@@ -95,21 +148,31 @@ Route groups carry the layout structure:
|
|||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
Prettier (`.prettierrc`): tabs, single quotes, no trailing commas, 100 columns, with the Svelte and
|
Prettier (`.prettierrc`): tabs, single quotes, no trailing commas, 100 columns, with the Svelte and
|
||||||
Tailwind plugins. Run `pnpm check` (svelte-check) and `pnpm lint` before considering work done.
|
Tailwind plugins. Run `pnpm check` (svelte-check) and `pnpm lint` before considering work done —
|
||||||
|
but take a baseline first, because neither is clean (see below).
|
||||||
|
|
||||||
|
`src/lib/vendor/` is excluded from Prettier, ESLint and svelte-check. Don't reformat vendored code.
|
||||||
|
|
||||||
## Known rough edges
|
## Known rough edges
|
||||||
|
|
||||||
Observed in the current tree — don't mistake these for intentional patterns to copy:
|
Observed in the current tree — don't mistake these for intentional patterns to copy:
|
||||||
|
|
||||||
- `src/lib/schema/openapi/schema.d.ts` is **stale** — it predates the `BookProgress` rework and has
|
- `pnpm check` is not clean. **Baseline as of 2026-08-13: 30 errors, 1 warning, 8 files**, most of
|
||||||
no `BookProgressRead`, so `book.progress` types as `{}`. This is the source of most of the ~104
|
them in `src/routes/api/[...path]/+server.ts` (see below). Get your own baseline before assuming
|
||||||
errors `pnpm check` reports on a clean tree; regenerating it should clear them. Get a baseline
|
an error is yours. `src/lib/schema/openapi/schema.d.ts` was regenerated on that date and is
|
||||||
before assuming an error is yours.
|
current; regenerate it again after any backend API change, with
|
||||||
|
`pnpm exec openapi-typescript http://localhost:8000/schema/openapi.json -o src/lib/schema/openapi/schema.d.ts`
|
||||||
|
against a backend running **your** branch — a stale server silently writes a stale file.
|
||||||
|
- `pnpm lint` does not pass either — 131 pre-existing ESLint errors, 35 of them
|
||||||
|
`svelte/no-navigation-without-resolve` on plain `href`s, plus ~59 files Prettier would rewrite
|
||||||
|
(mostly vendored shadcn components). Check the files you touched, not the whole tree.
|
||||||
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
|
- `src/routes/api/[...path]/+server.ts` — all four handlers are annotated `RequestHandler` while the
|
||||||
import of that type is commented out at line 4.
|
import of that type is commented out at line 4. It also buffers whole **responses** with
|
||||||
- `src/app.d.ts` — `App.Locals["user"]` is typed from `lucide-svelte`'s `User` *icon* component
|
`arrayBuffer()` and forwards no `Range` header, so book downloads are not streamed. **Requests**
|
||||||
|
are streamed — POST and PATCH pass `request.body` through with `duplex: 'half'` (see `bodyOf`),
|
||||||
|
because a zipped Calibre library upload cannot be held in this process. The response side is
|
||||||
|
still buffered; see `TODO.md`.
|
||||||
|
- `src/app.d.ts` — `App.Locals["user"]` is typed from `lucide-svelte`'s `User` _icon_ component
|
||||||
rather than the `User` interface in `$lib/server/auth`.
|
rather than the `User` interface in `$lib/server/auth`.
|
||||||
- Uncommitted work in progress (as of 2026-08-10): library icons, spanning
|
- No CSP, which foliate's README asks for because EPUBs can carry scripts. See `TODO.md` for why it
|
||||||
`components/ui/icon-picker/`, the newly vendored `components/ui/popover/`,
|
is not enabled yet.
|
||||||
`forms/library-create-form.svelte`, `layout/library-switcher.svelte` and `schema/library.ts`.
|
|
||||||
Prefer not to refactor those files mid-flight.
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export default defineConfig(
|
|||||||
'no-undef': 'off'
|
'no-undef': 'off'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// Generated shadcn components take href as a prop and cannot resolve it —
|
||||||
|
// that is the caller's job. Editing them here would be lost on regeneration.
|
||||||
|
files: ['src/lib/components/ui/**'],
|
||||||
|
rules: { 'svelte/no-navigation-without-resolve': 'off' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
|
|||||||
@@ -48,7 +48,6 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"construct-style-sheets-polyfill": "^3.1.0",
|
"construct-style-sheets-polyfill": "^3.1.0",
|
||||||
"epubjs": "^0.3.93",
|
|
||||||
"mode-watcher": "^1.1.0",
|
"mode-watcher": "^1.1.0",
|
||||||
"svelte-sonner": "^1.0.8",
|
"svelte-sonner": "^1.0.8",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
|
|||||||
Generated
-218
@@ -11,9 +11,6 @@ importers:
|
|||||||
construct-style-sheets-polyfill:
|
construct-style-sheets-polyfill:
|
||||||
specifier: ^3.1.0
|
specifier: ^3.1.0
|
||||||
version: 3.1.0
|
version: 3.1.0
|
||||||
epubjs:
|
|
||||||
specifier: ^0.3.93
|
|
||||||
version: 0.3.93
|
|
||||||
mode-watcher:
|
mode-watcher:
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0(svelte@5.53.7)
|
version: 1.1.0(svelte@5.53.7)
|
||||||
@@ -899,10 +896,6 @@ packages:
|
|||||||
'@types/json-schema@7.0.15':
|
'@types/json-schema@7.0.15':
|
||||||
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
|
||||||
|
|
||||||
'@types/localforage@0.0.34':
|
|
||||||
resolution: {integrity: sha512-tJxahnjm9dEI1X+hQSC5f2BSd/coZaqbIl1m3TCl0q9SVuC52XcXfV0XmoCU1+PmjyucuVITwoTnN8OlTbEXXA==}
|
|
||||||
deprecated: This is a stub types definition for localforage (https://github.com/localForage/localForage). localforage provides its own type definitions, so you don't need @types/localforage installed!
|
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==}
|
||||||
|
|
||||||
@@ -1008,11 +1001,6 @@ packages:
|
|||||||
'@vue/shared@3.5.29':
|
'@vue/shared@3.5.29':
|
||||||
resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==}
|
resolution: {integrity: sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==}
|
||||||
|
|
||||||
'@xmldom/xmldom@0.7.13':
|
|
||||||
resolution: {integrity: sha512-lm2GW5PkosIzccsaZIz7tp8cPADSIlIHWDFTR1N0SzfinhhYgeIQjFMz4rYzanCScr3DqQLeomUDArp6MWKm+g==}
|
|
||||||
engines: {node: '>=10.0.0'}
|
|
||||||
deprecated: this version has critical issues, please update to the latest version
|
|
||||||
|
|
||||||
acorn-jsx@5.3.2:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1176,12 +1164,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
core-js@3.48.0:
|
|
||||||
resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==}
|
|
||||||
|
|
||||||
core-util-is@1.0.3:
|
|
||||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -1197,10 +1179,6 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
d@1.0.2:
|
|
||||||
resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==}
|
|
||||||
engines: {node: '>=0.12'}
|
|
||||||
|
|
||||||
debounce-fn@6.0.0:
|
debounce-fn@6.0.0:
|
||||||
resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==}
|
resolution: {integrity: sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1262,20 +1240,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||||
|
|
||||||
epubjs@0.3.93:
|
|
||||||
resolution: {integrity: sha512-c06pNSdBxcXv3dZSbXAVLE1/pmleRhOT6mXNZo6INKmvuKpYB65MwU/lO7830czCtjIiK9i+KR+3S+p0wtljrw==}
|
|
||||||
|
|
||||||
es5-ext@0.10.64:
|
|
||||||
resolution: {integrity: sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==}
|
|
||||||
engines: {node: '>=0.10'}
|
|
||||||
|
|
||||||
es6-iterator@2.0.3:
|
|
||||||
resolution: {integrity: sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==}
|
|
||||||
|
|
||||||
es6-symbol@3.1.4:
|
|
||||||
resolution: {integrity: sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==}
|
|
||||||
engines: {node: '>=0.12'}
|
|
||||||
|
|
||||||
esbuild@0.27.3:
|
esbuild@0.27.3:
|
||||||
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1334,10 +1298,6 @@ packages:
|
|||||||
esm-env@1.2.2:
|
esm-env@1.2.2:
|
||||||
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
|
resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==}
|
||||||
|
|
||||||
esniff@2.0.1:
|
|
||||||
resolution: {integrity: sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==}
|
|
||||||
engines: {node: '>=0.10'}
|
|
||||||
|
|
||||||
espree@10.4.0:
|
espree@10.4.0:
|
||||||
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
|
resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -1367,12 +1327,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
event-emitter@0.3.5:
|
|
||||||
resolution: {integrity: sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==}
|
|
||||||
|
|
||||||
ext@1.7.0:
|
|
||||||
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
|
|
||||||
|
|
||||||
fast-deep-equal@3.1.3:
|
fast-deep-equal@3.1.3:
|
||||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||||
|
|
||||||
@@ -1478,9 +1432,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
immediate@3.0.6:
|
|
||||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1493,9 +1444,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
|
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
inherits@2.0.4:
|
|
||||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
|
||||||
|
|
||||||
inline-style-parser@0.2.7:
|
inline-style-parser@0.2.7:
|
||||||
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
|
||||||
|
|
||||||
@@ -1532,9 +1480,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
isarray@1.0.0:
|
|
||||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
|
||||||
|
|
||||||
isexe@2.0.0:
|
isexe@2.0.0:
|
||||||
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
|
||||||
|
|
||||||
@@ -1575,9 +1520,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-LoCmV2n7rVry/gD4aMd9No7N3rB6xxxbbJedtdku8Ic7+JYbJRly6GWw+tO28/iuDxzAI0fpcgEoO0JyW+AUPg==}
|
resolution: {integrity: sha512-LoCmV2n7rVry/gD4aMd9No7N3rB6xxxbbJedtdku8Ic7+JYbJRly6GWw+tO28/iuDxzAI0fpcgEoO0JyW+AUPg==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
jszip@3.10.1:
|
|
||||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
@@ -1592,12 +1534,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
lie@3.1.1:
|
|
||||||
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
|
|
||||||
|
|
||||||
lie@3.3.0:
|
|
||||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
|
||||||
|
|
||||||
lightningcss-android-arm64@1.31.1:
|
lightningcss-android-arm64@1.31.1:
|
||||||
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
|
resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
@@ -1676,9 +1612,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
localforage@1.10.0:
|
|
||||||
resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==}
|
|
||||||
|
|
||||||
locate-character@3.0.0:
|
locate-character@3.0.0:
|
||||||
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
|
||||||
|
|
||||||
@@ -1689,9 +1622,6 @@ packages:
|
|||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
lodash@4.17.23:
|
|
||||||
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
|
|
||||||
|
|
||||||
lru-cache@11.2.6:
|
lru-cache@11.2.6:
|
||||||
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
|
||||||
engines: {node: 20 || >=22}
|
engines: {node: 20 || >=22}
|
||||||
@@ -1707,9 +1637,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==}
|
resolution: {integrity: sha512-vM2sG+wbVeVGYcCm16mM3d5fuem9oC28n436HjsGO3LcxoTI8LNVa4rwZDn3f76+cWyT4GGJDxjTYU1I2nr6zw==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
|
|
||||||
marks-pane@1.0.9:
|
|
||||||
resolution: {integrity: sha512-Ahs4oeG90tbdPWwAJkAAoHg2lRR8lAs9mZXETNPO9hYg3AkjUJBKi1NQ4aaIQZVGrig7c/3NUV1jANl8rFTeMg==}
|
|
||||||
|
|
||||||
mimic-function@5.0.1:
|
mimic-function@5.0.1:
|
||||||
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -1785,9 +1712,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
next-tick@1.1.0:
|
|
||||||
resolution: {integrity: sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==}
|
|
||||||
|
|
||||||
node-machine-id@1.1.12:
|
node-machine-id@1.1.12:
|
||||||
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
|
resolution: {integrity: sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==}
|
||||||
|
|
||||||
@@ -1838,9 +1762,6 @@ packages:
|
|||||||
package-manager-detector@1.6.0:
|
package-manager-detector@1.6.0:
|
||||||
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
|
||||||
|
|
||||||
pako@1.0.11:
|
|
||||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1867,9 +1788,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||||
engines: {node: 18 || 20 || >=22}
|
engines: {node: 18 || 20 || >=22}
|
||||||
|
|
||||||
path-webpack@0.0.3:
|
|
||||||
resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==}
|
|
||||||
|
|
||||||
pathe@2.0.3:
|
pathe@2.0.3:
|
||||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||||
|
|
||||||
@@ -1995,9 +1913,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
|
resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==}
|
||||||
engines: {node: ^20.17.0 || >=22.9.0}
|
engines: {node: ^20.17.0 || >=22.9.0}
|
||||||
|
|
||||||
process-nextick-args@2.0.1:
|
|
||||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
|
||||||
|
|
||||||
punycode@2.3.1:
|
punycode@2.3.1:
|
||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -2006,9 +1921,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
readable-stream@2.3.8:
|
|
||||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
|
||||||
|
|
||||||
readdirp@4.1.2:
|
readdirp@4.1.2:
|
||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
@@ -2066,9 +1978,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
safe-buffer@5.1.2:
|
|
||||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
|
||||||
|
|
||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
@@ -2080,9 +1989,6 @@ packages:
|
|||||||
set-cookie-parser@3.0.1:
|
set-cookie-parser@3.0.1:
|
||||||
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
|
resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==}
|
||||||
|
|
||||||
setimmediate@1.0.5:
|
|
||||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2129,9 +2035,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
string_decoder@1.1.1:
|
|
||||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
|
||||||
|
|
||||||
strip-ansi@6.0.1:
|
strip-ansi@6.0.1:
|
||||||
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2271,9 +2174,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
type@2.7.3:
|
|
||||||
resolution: {integrity: sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==}
|
|
||||||
|
|
||||||
typescript-eslint@8.56.1:
|
typescript-eslint@8.56.1:
|
||||||
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
|
resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@@ -3030,10 +2930,6 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json-schema@7.0.15': {}
|
'@types/json-schema@7.0.15': {}
|
||||||
|
|
||||||
'@types/localforage@0.0.34':
|
|
||||||
dependencies:
|
|
||||||
localforage: 1.10.0
|
|
||||||
|
|
||||||
'@types/node@22.19.15':
|
'@types/node@22.19.15':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -3193,8 +3089,6 @@ snapshots:
|
|||||||
|
|
||||||
'@vue/shared@3.5.29': {}
|
'@vue/shared@3.5.29': {}
|
||||||
|
|
||||||
'@xmldom/xmldom@0.7.13': {}
|
|
||||||
|
|
||||||
acorn-jsx@5.3.2(acorn@8.16.0):
|
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
@@ -3356,10 +3250,6 @@ snapshots:
|
|||||||
|
|
||||||
cookie@0.6.0: {}
|
cookie@0.6.0: {}
|
||||||
|
|
||||||
core-js@3.48.0: {}
|
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
path-key: 3.1.1
|
path-key: 3.1.1
|
||||||
@@ -3374,11 +3264,6 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
d@1.0.2:
|
|
||||||
dependencies:
|
|
||||||
es5-ext: 0.10.64
|
|
||||||
type: 2.7.3
|
|
||||||
|
|
||||||
debounce-fn@6.0.0:
|
debounce-fn@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-function: 5.0.1
|
mimic-function: 5.0.1
|
||||||
@@ -3420,36 +3305,6 @@ snapshots:
|
|||||||
|
|
||||||
env-paths@3.0.0: {}
|
env-paths@3.0.0: {}
|
||||||
|
|
||||||
epubjs@0.3.93:
|
|
||||||
dependencies:
|
|
||||||
'@types/localforage': 0.0.34
|
|
||||||
'@xmldom/xmldom': 0.7.13
|
|
||||||
core-js: 3.48.0
|
|
||||||
event-emitter: 0.3.5
|
|
||||||
jszip: 3.10.1
|
|
||||||
localforage: 1.10.0
|
|
||||||
lodash: 4.17.23
|
|
||||||
marks-pane: 1.0.9
|
|
||||||
path-webpack: 0.0.3
|
|
||||||
|
|
||||||
es5-ext@0.10.64:
|
|
||||||
dependencies:
|
|
||||||
es6-iterator: 2.0.3
|
|
||||||
es6-symbol: 3.1.4
|
|
||||||
esniff: 2.0.1
|
|
||||||
next-tick: 1.1.0
|
|
||||||
|
|
||||||
es6-iterator@2.0.3:
|
|
||||||
dependencies:
|
|
||||||
d: 1.0.2
|
|
||||||
es5-ext: 0.10.64
|
|
||||||
es6-symbol: 3.1.4
|
|
||||||
|
|
||||||
es6-symbol@3.1.4:
|
|
||||||
dependencies:
|
|
||||||
d: 1.0.2
|
|
||||||
ext: 1.7.0
|
|
||||||
|
|
||||||
esbuild@0.27.3:
|
esbuild@0.27.3:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@esbuild/aix-ppc64': 0.27.3
|
'@esbuild/aix-ppc64': 0.27.3
|
||||||
@@ -3559,13 +3414,6 @@ snapshots:
|
|||||||
|
|
||||||
esm-env@1.2.2: {}
|
esm-env@1.2.2: {}
|
||||||
|
|
||||||
esniff@2.0.1:
|
|
||||||
dependencies:
|
|
||||||
d: 1.0.2
|
|
||||||
es5-ext: 0.10.64
|
|
||||||
event-emitter: 0.3.5
|
|
||||||
type: 2.7.3
|
|
||||||
|
|
||||||
espree@10.4.0:
|
espree@10.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
@@ -3594,15 +3442,6 @@ snapshots:
|
|||||||
|
|
||||||
esutils@2.0.3: {}
|
esutils@2.0.3: {}
|
||||||
|
|
||||||
event-emitter@0.3.5:
|
|
||||||
dependencies:
|
|
||||||
d: 1.0.2
|
|
||||||
es5-ext: 0.10.64
|
|
||||||
|
|
||||||
ext@1.7.0:
|
|
||||||
dependencies:
|
|
||||||
type: 2.7.3
|
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
fast-deep-equal@3.1.3: {}
|
||||||
|
|
||||||
fast-json-stable-stringify@2.1.0: {}
|
fast-json-stable-stringify@2.1.0: {}
|
||||||
@@ -3693,8 +3532,6 @@ snapshots:
|
|||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
|
|
||||||
immediate@3.0.6: {}
|
|
||||||
|
|
||||||
import-fresh@3.3.1:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
@@ -3704,8 +3541,6 @@ snapshots:
|
|||||||
|
|
||||||
index-to-position@1.2.0: {}
|
index-to-position@1.2.0: {}
|
||||||
|
|
||||||
inherits@2.0.4: {}
|
|
||||||
|
|
||||||
inline-style-parser@0.2.7: {}
|
inline-style-parser@0.2.7: {}
|
||||||
|
|
||||||
ip-address@10.1.0: {}
|
ip-address@10.1.0: {}
|
||||||
@@ -3734,8 +3569,6 @@ snapshots:
|
|||||||
|
|
||||||
is-unicode-supported@2.1.0: {}
|
is-unicode-supported@2.1.0: {}
|
||||||
|
|
||||||
isarray@1.0.0: {}
|
|
||||||
|
|
||||||
isexe@2.0.0: {}
|
isexe@2.0.0: {}
|
||||||
|
|
||||||
jiti@2.6.1: {}
|
jiti@2.6.1: {}
|
||||||
@@ -3805,13 +3638,6 @@ snapshots:
|
|||||||
- ws
|
- ws
|
||||||
- zod
|
- zod
|
||||||
|
|
||||||
jszip@3.10.1:
|
|
||||||
dependencies:
|
|
||||||
lie: 3.3.0
|
|
||||||
pako: 1.0.11
|
|
||||||
readable-stream: 2.3.8
|
|
||||||
setimmediate: 1.0.5
|
|
||||||
|
|
||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
@@ -3825,14 +3651,6 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
lie@3.1.1:
|
|
||||||
dependencies:
|
|
||||||
immediate: 3.0.6
|
|
||||||
|
|
||||||
lie@3.3.0:
|
|
||||||
dependencies:
|
|
||||||
immediate: 3.0.6
|
|
||||||
|
|
||||||
lightningcss-android-arm64@1.31.1:
|
lightningcss-android-arm64@1.31.1:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3884,10 +3702,6 @@ snapshots:
|
|||||||
|
|
||||||
lilconfig@2.1.0: {}
|
lilconfig@2.1.0: {}
|
||||||
|
|
||||||
localforage@1.10.0:
|
|
||||||
dependencies:
|
|
||||||
lie: 3.1.1
|
|
||||||
|
|
||||||
locate-character@3.0.0: {}
|
locate-character@3.0.0: {}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
@@ -3896,8 +3710,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash@4.17.23: {}
|
|
||||||
|
|
||||||
lru-cache@11.2.6: {}
|
lru-cache@11.2.6: {}
|
||||||
|
|
||||||
lz-string@1.5.0: {}
|
lz-string@1.5.0: {}
|
||||||
@@ -3922,8 +3734,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
marks-pane@1.0.9: {}
|
|
||||||
|
|
||||||
mimic-function@5.0.1: {}
|
mimic-function@5.0.1: {}
|
||||||
|
|
||||||
minimatch@10.2.4:
|
minimatch@10.2.4:
|
||||||
@@ -3990,8 +3800,6 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
next-tick@1.1.0: {}
|
|
||||||
|
|
||||||
node-machine-id@1.1.12: {}
|
node-machine-id@1.1.12: {}
|
||||||
|
|
||||||
obug@2.1.1: {}
|
obug@2.1.1: {}
|
||||||
@@ -4054,8 +3862,6 @@ snapshots:
|
|||||||
|
|
||||||
package-manager-detector@1.6.0: {}
|
package-manager-detector@1.6.0: {}
|
||||||
|
|
||||||
pako@1.0.11: {}
|
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -4081,8 +3887,6 @@ snapshots:
|
|||||||
lru-cache: 11.2.6
|
lru-cache: 11.2.6
|
||||||
minipass: 7.1.3
|
minipass: 7.1.3
|
||||||
|
|
||||||
path-webpack@0.0.3: {}
|
|
||||||
|
|
||||||
pathe@2.0.3: {}
|
pathe@2.0.3: {}
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
@@ -4140,22 +3944,10 @@ snapshots:
|
|||||||
|
|
||||||
proc-log@6.1.0: {}
|
proc-log@6.1.0: {}
|
||||||
|
|
||||||
process-nextick-args@2.0.1: {}
|
|
||||||
|
|
||||||
punycode@2.3.1: {}
|
punycode@2.3.1: {}
|
||||||
|
|
||||||
react@19.2.0: {}
|
react@19.2.0: {}
|
||||||
|
|
||||||
readable-stream@2.3.8:
|
|
||||||
dependencies:
|
|
||||||
core-util-is: 1.0.3
|
|
||||||
inherits: 2.0.4
|
|
||||||
isarray: 1.0.0
|
|
||||||
process-nextick-args: 2.0.1
|
|
||||||
safe-buffer: 5.1.2
|
|
||||||
string_decoder: 1.1.1
|
|
||||||
util-deprecate: 1.0.2
|
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
require-from-string@2.0.2: {}
|
require-from-string@2.0.2: {}
|
||||||
@@ -4231,8 +4023,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mri: 1.2.0
|
mri: 1.2.0
|
||||||
|
|
||||||
safe-buffer@5.1.2: {}
|
|
||||||
|
|
||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4240,8 +4030,6 @@ snapshots:
|
|||||||
|
|
||||||
set-cookie-parser@3.0.1: {}
|
set-cookie-parser@3.0.1: {}
|
||||||
|
|
||||||
setimmediate@1.0.5: {}
|
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
@@ -4291,10 +4079,6 @@ snapshots:
|
|||||||
get-east-asian-width: 1.5.0
|
get-east-asian-width: 1.5.0
|
||||||
strip-ansi: 7.2.0
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
string_decoder@1.1.1:
|
|
||||||
dependencies:
|
|
||||||
safe-buffer: 5.1.2
|
|
||||||
|
|
||||||
strip-ansi@6.0.1:
|
strip-ansi@6.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-regex: 5.0.1
|
ansi-regex: 5.0.1
|
||||||
@@ -4449,8 +4233,6 @@ snapshots:
|
|||||||
|
|
||||||
type-fest@4.41.0: {}
|
type-fest@4.41.0: {}
|
||||||
|
|
||||||
type@2.7.3: {}
|
|
||||||
|
|
||||||
typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3):
|
typescript-eslint@8.56.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
|
'@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)
|
||||||
|
|||||||
@@ -8,12 +8,31 @@ import {
|
|||||||
deleteBooksSchema,
|
deleteBooksSchema,
|
||||||
editBookMetadataSchema,
|
editBookMetadataSchema,
|
||||||
updateBookProgressSchema,
|
updateBookProgressSchema,
|
||||||
|
duplicateDismissalSchema,
|
||||||
|
bookMergeSchema,
|
||||||
type Book,
|
type Book,
|
||||||
|
type BooksUploadResult,
|
||||||
|
type DuplicateBookGroup,
|
||||||
bookFilesUpload
|
bookFilesUpload
|
||||||
} from '$lib/schema/index';
|
} from '$lib/schema/index';
|
||||||
import { stringCoerce, type PaginatedResponse } from '$lib/schema/common';
|
import { stringCoerce, type PaginatedResponse } from '$lib/schema/common';
|
||||||
import { createQueryParams } from '$lib/utils';
|
import { createQueryParams } from '$lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend's own message for a failed response, rather than its JSON envelope.
|
||||||
|
*
|
||||||
|
* A refused duplicate answers 409 with a `detail` worth reading and the offending
|
||||||
|
* files in `extra`; passing the body through whole puts JSON in front of the reader.
|
||||||
|
*/
|
||||||
|
function detailOf(body: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
return typeof parsed?.detail === 'string' ? parsed.detail : body;
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const getBook = query(stringCoerce, async (id): Promise<Book> => {
|
export const getBook = query(stringCoerce, async (id): Promise<Book> => {
|
||||||
const { locals } = getRequestEvent();
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
@@ -68,7 +87,9 @@ export const updateBookCover = form(bookCoverUpload, async ({ book_id, file }) =
|
|||||||
return await response.json();
|
return await response.json();
|
||||||
});
|
});
|
||||||
|
|
||||||
export const uploadBooks = form(booksUpload, async ({ library_id, files }) => {
|
export const uploadBooks = form(
|
||||||
|
booksUpload,
|
||||||
|
async ({ library_id, files }): Promise<BooksUploadResult> => {
|
||||||
const { locals } = getRequestEvent();
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -87,7 +108,8 @@ export const uploadBooks = form(booksUpload, async ({ library_id, files }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export const uploadBookFiles = form(bookFilesUpload, async ({ book_id, files }) => {
|
export const uploadBookFiles = form(bookFilesUpload, async ({ book_id, files }) => {
|
||||||
const { locals } = getRequestEvent();
|
const { locals } = getRequestEvent();
|
||||||
@@ -100,8 +122,9 @@ export const uploadBookFiles = form(bookFilesUpload, async ({ book_id, files })
|
|||||||
const response = await locals.api.postMultipart(`/books/${book_id}/files`, formData);
|
const response = await locals.api.postMultipart(`/books/${book_id}/files`, formData);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const message = await response.text();
|
// 409 here means the file is already stored under a different book, which is
|
||||||
error(response.status, message);
|
// something the reader can act on — so the message has to survive the trip.
|
||||||
|
error(response.status, detailOf(await response.text()));
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
@@ -133,6 +156,65 @@ export const deleteBookFiles = command(deleteBookFilesSchema, async ({ book_id,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Books already in the library that look like copies of one another.
|
||||||
|
*
|
||||||
|
* Metadata only, so every group is a question rather than a verdict — which is why
|
||||||
|
* the screen it feeds offers a way to disagree.
|
||||||
|
*/
|
||||||
|
export const listDuplicateBooks = query(
|
||||||
|
stringCoerce,
|
||||||
|
async (libraryId): Promise<DuplicateBookGroup[]> => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.get(`/books/duplicate-books?library_id=${libraryId}`);
|
||||||
|
|
||||||
|
if (!response.ok) error(response.status, detailOf(await response.text()));
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold several books into one and delete the records folded in.
|
||||||
|
*
|
||||||
|
* Irreversible, so the caller is expected to have shown what is about to happen.
|
||||||
|
*/
|
||||||
|
export const mergeBooks = command(
|
||||||
|
bookMergeSchema,
|
||||||
|
async ({ library_id, ...data }): Promise<Book> => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.post(`/books/merge?library_id=${library_id}`, data);
|
||||||
|
|
||||||
|
if (!response.ok) error(response.status, detailOf(await response.text()));
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Record that two books are not the same book, so the pair stops being proposed. */
|
||||||
|
export const dismissDuplicateBooks = command(duplicateDismissalSchema, async (data) => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.post('/books/duplicate-books/dismissals', data);
|
||||||
|
|
||||||
|
if (!response.ok) error(response.status, detailOf(await response.text()));
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Undo a dismissal, so the pair is proposed again. */
|
||||||
|
export const restoreDuplicateBooks = command(duplicateDismissalSchema, async (data) => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const params = createQueryParams(data);
|
||||||
|
|
||||||
|
const response = await locals.api.delete(
|
||||||
|
`/books/duplicate-books/dismissals?${params.toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) error(response.status, detailOf(await response.text()));
|
||||||
|
});
|
||||||
|
|
||||||
export const updateBookProgress = command(
|
export const updateBookProgress = command(
|
||||||
updateBookProgressSchema,
|
updateBookProgressSchema,
|
||||||
async ({ book_ids, ...data }) => {
|
async ({ book_ids, ...data }) => {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { command, getRequestEvent, query } from '$app/server';
|
||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
import { stringCoerce } from '$lib/schema/common';
|
||||||
|
import type { CalibreImport } from '$lib/schema/library';
|
||||||
|
|
||||||
|
/** The backend's own message for a failed response, rather than its JSON envelope. */
|
||||||
|
function detailOf(body: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
return typeof parsed?.detail === 'string' ? parsed.detail : body;
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an import has got to.
|
||||||
|
*
|
||||||
|
* A `query` rather than a `command` so it can be refreshed, but it is polled on a timer
|
||||||
|
* rather than cached — the answer changes on its own.
|
||||||
|
*
|
||||||
|
* Starting an import is deliberately **not** here: the archive goes straight to the
|
||||||
|
* backend through the proxy, so it never passes through this process. See the import
|
||||||
|
* screen's `upload`.
|
||||||
|
*/
|
||||||
|
export const getCalibreImport = query(stringCoerce, async (jobId): Promise<CalibreImport> => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.get(`/libraries/imports/${jobId}`);
|
||||||
|
|
||||||
|
if (!response.ok) error(response.status, detailOf(await response.text()));
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask an import to stop after the book it is on.
|
||||||
|
*
|
||||||
|
* Not an abort: a book abandoned mid-copy would leave files on disk with no row
|
||||||
|
* describing them. Whatever it has imported stays imported.
|
||||||
|
*/
|
||||||
|
export const cancelCalibreImport = command(stringCoerce, async (jobId): Promise<CalibreImport> => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.delete(`/libraries/imports/${jobId}`);
|
||||||
|
|
||||||
|
if (!response.ok) error(response.status, detailOf(await response.text()));
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ export * from './auth.remote';
|
|||||||
export * from './author.remote';
|
export * from './author.remote';
|
||||||
export * from './book.remote';
|
export * from './book.remote';
|
||||||
export * from './bookshelf.remote';
|
export * from './bookshelf.remote';
|
||||||
|
export * from './calibre-import.remote';
|
||||||
export * from './library.remote';
|
export * from './library.remote';
|
||||||
export * from './publisher.remote';
|
export * from './publisher.remote';
|
||||||
export * from './tag.remote';
|
export * from './tag.remote';
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { FileUp, Folder, FileText } from '@lucide/svelte';
|
||||||
|
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import type { FileRejectedReason } from '$lib/components/ui/file-drop-zone';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
|
|
||||||
|
let {
|
||||||
|
onUpload,
|
||||||
|
onFileRejected,
|
||||||
|
accept,
|
||||||
|
maxFileSize,
|
||||||
|
disabled = false,
|
||||||
|
class: className
|
||||||
|
}: {
|
||||||
|
onUpload: (files: File[]) => Promise<void> | void;
|
||||||
|
onFileRejected?: (opts: { reason: FileRejectedReason; file: File }) => void;
|
||||||
|
/** Comma separated extensions and/or MIME types, as the `accept` attribute takes. */
|
||||||
|
accept?: string;
|
||||||
|
/** Bytes. */
|
||||||
|
maxFileSize?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two inputs rather than one.
|
||||||
|
*
|
||||||
|
* `webkitdirectory` is not a filter — it switches the picker into folder mode,
|
||||||
|
* so a single input can offer files or folders but never both. The drop target
|
||||||
|
* has no such constraint and stays one area.
|
||||||
|
*/
|
||||||
|
let fileInput = $state<HTMLInputElement>();
|
||||||
|
let folderInput = $state<HTMLInputElement>();
|
||||||
|
|
||||||
|
let dragging = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const active = $derived(!disabled && !busy);
|
||||||
|
|
||||||
|
function accepts(file: File): FileRejectedReason | undefined {
|
||||||
|
if (maxFileSize !== undefined && file.size > maxFileSize) return 'Maximum file size exceeded';
|
||||||
|
if (!accept) return undefined;
|
||||||
|
|
||||||
|
const name = file.name.toLowerCase();
|
||||||
|
const type = file.type.toLowerCase();
|
||||||
|
|
||||||
|
const ok = accept
|
||||||
|
.split(',')
|
||||||
|
.map((pattern) => pattern.trim().toLowerCase())
|
||||||
|
.some((pattern) => {
|
||||||
|
// Match on the pattern, not the file's type. Testing `type` here is
|
||||||
|
// what makes MOBI fail: browsers report no MIME type for it, so a
|
||||||
|
// ".mobi" rule never gets compared against the filename.
|
||||||
|
if (pattern.startsWith('.')) return name.endsWith(pattern);
|
||||||
|
if (pattern.endsWith('/*')) return type.startsWith(pattern.slice(0, -1));
|
||||||
|
return type === pattern;
|
||||||
|
});
|
||||||
|
|
||||||
|
return ok ? undefined : 'File type not allowed';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** readEntries hands back at most 100 at a time and signals the end with an empty batch. */
|
||||||
|
function readAll(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const entries: FileSystemEntry[] = [];
|
||||||
|
|
||||||
|
const next = () =>
|
||||||
|
reader.readEntries((batch) => {
|
||||||
|
if (batch.length === 0) return resolve(entries);
|
||||||
|
entries.push(...batch);
|
||||||
|
next();
|
||||||
|
}, reject);
|
||||||
|
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flattens a dropped entry into files, naming each one with its path inside the
|
||||||
|
* dropped folder so it matches what the folder picker puts in
|
||||||
|
* `webkitRelativePath` — which is what the upload form reads to keep structure.
|
||||||
|
*/
|
||||||
|
async function walk(entry: FileSystemEntry, prefix = ''): Promise<File[]> {
|
||||||
|
if (entry.isFile) {
|
||||||
|
const file = await new Promise<File>((resolve, reject) =>
|
||||||
|
(entry as FileSystemFileEntry).file(resolve, reject)
|
||||||
|
);
|
||||||
|
return [new File([file], `${prefix}${file.name}`, { type: file.type })];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
const entries = await readAll((entry as FileSystemDirectoryEntry).createReader());
|
||||||
|
const nested = await Promise.all(entries.map((e) => walk(e, `${prefix}${entry.name}/`)));
|
||||||
|
return nested.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
dragging = false;
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
// Read the entries synchronously: DataTransfer is emptied as soon as this
|
||||||
|
// handler yields, so awaiting first loses everything that was dropped.
|
||||||
|
const entries = Array.from(event.dataTransfer?.items ?? [])
|
||||||
|
.filter((item) => item.kind === 'file')
|
||||||
|
.map((item) => item.webkitGetAsEntry())
|
||||||
|
.filter((entry): entry is FileSystemEntry => entry !== null);
|
||||||
|
|
||||||
|
// Older engines expose no entries; fall back to the flat list, which cannot
|
||||||
|
// contain folders anyway.
|
||||||
|
if (entries.length === 0) {
|
||||||
|
await submit(Array.from(event.dataTransfer?.files ?? []));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
const nested = await Promise.all(entries.map((entry) => walk(entry)));
|
||||||
|
await submit(nested.flat());
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleChange(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
const chosen = Array.from(input.files ?? []);
|
||||||
|
// Reset first so picking the same file twice still fires a change event.
|
||||||
|
input.value = '';
|
||||||
|
await submit(chosen);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(candidates: File[]) {
|
||||||
|
const accepted: File[] = [];
|
||||||
|
|
||||||
|
for (const file of candidates) {
|
||||||
|
const reason = accepts(file);
|
||||||
|
if (reason) onFileRejected?.({ file, reason });
|
||||||
|
else accepted.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accepted.length > 0) await onUpload(accepted);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="Add books"
|
||||||
|
aria-disabled={!active}
|
||||||
|
ondragover={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (active) dragging = true;
|
||||||
|
}}
|
||||||
|
ondragleave={() => (dragging = false)}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
class={cn(
|
||||||
|
'flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-border bg-accent/20 p-6 text-center transition-colors',
|
||||||
|
dragging && 'border-primary bg-accent/50',
|
||||||
|
!active && 'opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex size-12 place-items-center justify-center rounded-full border border-dashed border-border text-muted-foreground"
|
||||||
|
>
|
||||||
|
<FileUp class="size-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="font-medium">
|
||||||
|
{busy ? 'Reading folder…' : 'Drop books here'}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-muted-foreground">A folder keeps its structure</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">or</span>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap justify-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!active}
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
>
|
||||||
|
<FileText class="size-4" />
|
||||||
|
Choose files
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!active}
|
||||||
|
onclick={() => folderInput?.click()}
|
||||||
|
>
|
||||||
|
<Folder class="size-4" />
|
||||||
|
Choose folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
{accept}
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleChange}
|
||||||
|
/>
|
||||||
|
<!-- webkitdirectory is why this needs to be a second input: it turns the
|
||||||
|
picker into a folder chooser rather than filtering what it accepts. -->
|
||||||
|
<input
|
||||||
|
bind:this={folderInput}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
webkitdirectory
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
@@ -1,165 +1,231 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { X } from '@lucide/svelte';
|
||||||
|
|
||||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
import * as NativeSelect from '$lib/components/ui/native-select/index.js';
|
||||||
import {
|
import { Button } from '$lib/components/ui/button';
|
||||||
displaySize,
|
|
||||||
FileDropZone,
|
|
||||||
type FileDropZoneProps
|
|
||||||
} from '$lib/components/ui/file-drop-zone';
|
|
||||||
import { X } from '@lucide/svelte';
|
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
|
|
||||||
import { Switch } from '$lib/components/ui/switch/index';
|
import { Switch } from '$lib/components/ui/switch/index';
|
||||||
|
import { displaySize, type FileRejectedReason } from '$lib/components/ui/file-drop-zone';
|
||||||
|
import BookDropZone from './book-drop-zone.svelte';
|
||||||
|
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
|
||||||
import { getLibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
import { uploadBooks } from '$lib/api';
|
import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
|
||||||
import type { Book, PaginatedResponse } from '$lib/schema';
|
|
||||||
import { goto } from '$app/navigation';
|
|
||||||
|
|
||||||
let { open = $bindable() }: { open?: boolean } = $props();
|
let { open = $bindable() }: { open?: boolean } = $props();
|
||||||
|
|
||||||
let libraryState = getLibraryState();
|
const libraryState = getLibraryState();
|
||||||
|
const queue = getUploadQueueState();
|
||||||
$effect(() => {
|
|
||||||
uploadBooks.fields.library_id.set(libraryState.activeLibrary!.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
let files = $derived(uploadBooks.fields.files.value() ?? []);
|
|
||||||
|
|
||||||
|
let libraryId = $state<number>(untrack(() => libraryState.activeLibrary!.id));
|
||||||
|
let files = $state<File[]>([]);
|
||||||
let autoUploadOnDrop = $state(true);
|
let autoUploadOnDrop = $state(true);
|
||||||
let navigateOnUpload = $state(true);
|
let navigateOnUpload = $state(true);
|
||||||
let formEl = $state<HTMLFormElement>();
|
|
||||||
|
|
||||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
const totalSize = $derived(files.reduce((sum, file) => sum + file.size, 0));
|
||||||
// Rename files to use webkitRelativePath so directory structure is preserved through form submission
|
|
||||||
const renamedFiles = uploadedFiles.map(f =>
|
/**
|
||||||
new File([f], f.webkitRelativePath || f.name, { type: f.type })
|
* Collected rather than raised one at a time. A folder of a few hundred books
|
||||||
|
* carries covers and notes alongside them, and a toast per rejected file
|
||||||
|
* buries the screen.
|
||||||
|
*/
|
||||||
|
let rejected = $state<{ name: string; reason: FileRejectedReason }[]>([]);
|
||||||
|
let showRejected = $state(false);
|
||||||
|
|
||||||
|
// Start each visit clean — a list left over from last time reads as though it
|
||||||
|
// applies to what was just chosen.
|
||||||
|
$effect(() => {
|
||||||
|
if (open) {
|
||||||
|
untrack(() => {
|
||||||
|
files = [];
|
||||||
|
rejected = [];
|
||||||
|
showRejected = false;
|
||||||
|
libraryId = libraryState.activeLibrary!.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const onUpload = async (uploadedFiles: File[]) => {
|
||||||
|
// Rename to the path relative to the chosen folder, which is what decides
|
||||||
|
// how the API groups files into books. Dropped folders already arrive named
|
||||||
|
// this way, since the drop zone builds the path while walking them.
|
||||||
|
const named = uploadedFiles.map(
|
||||||
|
(file) => new File([file], file.webkitRelativePath || file.name, { type: file.type })
|
||||||
);
|
);
|
||||||
uploadBooks.fields.files.set([...Array.from(files), ...renamedFiles]);
|
|
||||||
if (autoUploadOnDrop && files.length > 0) {
|
// Same relative path twice is the same file — dropping a folder a second
|
||||||
await tick();
|
// time should not queue everything again.
|
||||||
formEl?.requestSubmit();
|
const seen = new Set(files.map((file) => file.name));
|
||||||
|
files = [...files, ...named.filter((file) => !seen.has(file.name))];
|
||||||
|
|
||||||
|
if (autoUploadOnDrop) await startUpload();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFileRejected = ({ reason, file }: { reason: FileRejectedReason; file: File }) => {
|
||||||
|
rejected = [...rejected, { name: file.webkitRelativePath || file.name, reason }];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Files picked from a folder carry their whole relative path as the name, so
|
||||||
|
* truncating the end would cut off the filename — the only part worth reading.
|
||||||
|
* Split it and let the folder sit on its own, quieter line.
|
||||||
|
*/
|
||||||
|
function splitPath(path: string) {
|
||||||
|
const cut = path.lastIndexOf('/');
|
||||||
|
return cut === -1
|
||||||
|
? { dir: '', name: path }
|
||||||
|
: { dir: path.slice(0, cut), name: path.slice(cut + 1) };
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
/**
|
||||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
* Hands the books to the queue and closes.
|
||||||
};
|
*
|
||||||
|
* Nothing is awaited here: the queue lives in the root layout and reports
|
||||||
|
* through the tray, so the import carries on while the library stays usable.
|
||||||
|
*/
|
||||||
|
function startUpload() {
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
function navigateToBooks(books: PaginatedResponse<Book>) {
|
const queued = files;
|
||||||
|
const target = libraryId;
|
||||||
|
|
||||||
|
files = [];
|
||||||
|
rejected = [];
|
||||||
open = false;
|
open = false;
|
||||||
let libraryId = books.items[0].library_id;
|
|
||||||
libraryState.setActive(libraryId);
|
queue.enqueue(target, queued, ({ created, firstBook }) => {
|
||||||
if (books.items.length === 1) {
|
if (created === 0) return;
|
||||||
goto(`/book/${books.items[0].id}`);
|
|
||||||
} else {
|
const library = libraryState.libraries.find((lib) => lib.id === target);
|
||||||
goto(`/library/${libraryId}/view?orderBy=created_at&sortOrder=desc`);
|
if (library) library.total = (library.total ?? 0) + created;
|
||||||
|
|
||||||
|
// Only for a single book. Jumping somewhere after a bulk import would
|
||||||
|
// land minutes after the reader moved on.
|
||||||
|
if (navigateOnUpload && created === 1 && firstBook) {
|
||||||
|
libraryState.setActive(target);
|
||||||
|
void goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(firstBook.id) }));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root bind:open>
|
<Dialog.Root bind:open>
|
||||||
<Dialog.Content>
|
<!--
|
||||||
{#if uploadBooks.pending}
|
Wider than the default lg: a folder's worth of rows needs the room.
|
||||||
<div class="flex flex-col items-center gap-4">
|
|
||||||
<span class="text-lg font-semibold"
|
overflow-hidden and the min-w-0 on the body below are what keep a long
|
||||||
>Uploading {uploadBooks.fields.files.value().length} files...</span
|
filename inside the dialog. Dialog.Content is a grid, and grid and flex
|
||||||
>
|
items default to min-width:auto — they refuse to shrink below their
|
||||||
<Spinner class="scale-150" />
|
content's intrinsic width, so one long name widened the body and pushed it
|
||||||
</div>
|
straight through the dialog's edge regardless of any truncate further down.
|
||||||
{:else}
|
-->
|
||||||
|
<Dialog.Content class="overflow-hidden sm:max-w-2xl">
|
||||||
<Dialog.Header>
|
<Dialog.Header>
|
||||||
<Dialog.Title>Upload Books</Dialog.Title>
|
<Dialog.Title>Add books</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
Files or a folder. A folder becomes one book per directory.
|
||||||
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<form
|
<div class="flex w-full min-w-0 flex-col gap-3">
|
||||||
{...uploadBooks.enhance(async ({ submit, form }) => {
|
<div class="flex flex-col gap-1.5">
|
||||||
try {
|
<Field.Label for="library_id">Library</Field.Label>
|
||||||
await submit();
|
<NativeSelect.Root id="library_id" bind:value={libraryId} class="w-48">
|
||||||
|
{#each libraryState.libraries as library (library.id)}
|
||||||
// Check if there are any validation issues
|
<NativeSelect.Option value={library.id}>{library.name}</NativeSelect.Option>
|
||||||
const issues = uploadBooks.fields.allIssues();
|
|
||||||
if (issues && issues.length > 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update library book count
|
|
||||||
const count = uploadBooks.result.total
|
|
||||||
libraryState.libraries.find(lib => uploadBooks.fields.library_id.value() == lib.id.toString())!.total += count
|
|
||||||
|
|
||||||
// Reset the files field
|
|
||||||
uploadBooks.fields.files.set([]);
|
|
||||||
toast.success('Books successfully uploaded!');
|
|
||||||
|
|
||||||
if (navigateOnUpload) {
|
|
||||||
navigateToBooks(uploadBooks.result);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to upload book: ', error);
|
|
||||||
toast.error('Failed to upload books');
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
bind:this={formEl}
|
|
||||||
enctype="multipart/form-data"
|
|
||||||
class="flex w-full flex-col gap-2 p-4"
|
|
||||||
>
|
|
||||||
<!-- Library select field -->
|
|
||||||
<Field.Label for="library_id">Select Library</Field.Label>
|
|
||||||
<NativeSelect.Root {...uploadBooks.fields.library_id.as('select')} class="w-36">
|
|
||||||
{#each libraryState.libraries as library}
|
|
||||||
<NativeSelect.Option value={library.id}>
|
|
||||||
{library.name}
|
|
||||||
</NativeSelect.Option>
|
|
||||||
{/each}
|
{/each}
|
||||||
</NativeSelect.Root>
|
</NativeSelect.Root>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FileDropZone
|
<BookDropZone
|
||||||
{onUpload}
|
{onUpload}
|
||||||
{onFileRejected}
|
{onFileRejected}
|
||||||
directory={true}
|
|
||||||
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
||||||
sublabel="Only PDF, EPUB, and MOBI files supported"
|
|
||||||
/>
|
/>
|
||||||
<input class="hidden" {...uploadBooks.fields.files.as('file multiple')} />
|
|
||||||
<div class="flex max-h-[300px] flex-col gap-2 overflow-y-auto">
|
{#if files.length > 0}
|
||||||
{#each files as file, idx}
|
<div class="flex items-baseline justify-between border-b pb-1 text-sm">
|
||||||
<div class="flex place-items-center justify-between gap-2">
|
<span><strong class="tabular-nums">{files.length}</strong> ready to upload</span>
|
||||||
<div class="flex flex-col">
|
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
<span>{file.name}</span>
|
{displaySize(totalSize)}
|
||||||
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex max-h-[300px] min-w-0 flex-col gap-2 overflow-y-auto">
|
||||||
|
{#each files as file, idx (file.name)}
|
||||||
|
{@const location = splitPath(file.name)}
|
||||||
|
<div class="flex min-w-0 items-center justify-between gap-2">
|
||||||
|
<!-- flex-1 as well as min-w-0: without a constrained width there is
|
||||||
|
nothing for truncate to act against and the row pushes the dialog wide -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<span class="truncate text-sm" title={file.name}>{location.name}</span>
|
||||||
|
<span class="flex min-w-0 items-baseline gap-2 text-xs text-muted-foreground">
|
||||||
|
{#if location.dir}
|
||||||
|
<span class="truncate font-mono" title={location.dir}>{location.dir}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="shrink-0 tabular-nums">{displaySize(file.size)}</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
onclick={() => {
|
class="shrink-0"
|
||||||
uploadBooks.fields.files.set([
|
onclick={() => (files = files.filter((_, i) => i !== idx))}
|
||||||
...Array.from(files).slice(0, idx),
|
|
||||||
...Array.from(files).slice(idx + 1)
|
|
||||||
]);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<X />
|
<X />
|
||||||
|
<span class="sr-only">Remove {file.name}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
{#if rejected.length > 0}
|
||||||
<div class="flex items-center gap-2">
|
<div class="rounded-md border border-star/50 bg-star/10 p-2 text-sm">
|
||||||
<Switch bind:checked={autoUploadOnDrop} />
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
|
<span>
|
||||||
<Button type="submit" class="ml-auto w-fit">Upload</Button>
|
{rejected.length}
|
||||||
|
{rejected.length === 1 ? 'file was' : 'files were'} skipped
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" onclick={() => (showRejected = !showRejected)}>
|
||||||
|
{showRejected ? 'Hide' : 'Show'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
{#if showRejected}
|
||||||
<Switch bind:checked={navigateOnUpload} />
|
<ul class="mt-2 flex max-h-32 min-w-0 flex-col gap-1 overflow-y-auto">
|
||||||
<Field.Label for="navigate-to-book">Navigate to book on upload</Field.Label>
|
{#each rejected as entry (entry.name)}
|
||||||
</div>
|
<li class="flex min-w-0 justify-between gap-2 text-xs text-muted-foreground">
|
||||||
</div>
|
<span class="min-w-0 flex-1 truncate" title={entry.name}>
|
||||||
</form>
|
{splitPath(entry.name).name}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0">{entry.reason}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 border-t pt-3">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch id="auto-upload-on-drop" bind:checked={autoUploadOnDrop} />
|
||||||
|
<Field.Label for="auto-upload-on-drop" class="font-normal">
|
||||||
|
Start as soon as books are added
|
||||||
|
</Field.Label>
|
||||||
|
<Button class="ml-auto w-fit" disabled={files.length === 0} onclick={startUpload}>
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Switch id="navigate-to-book" bind:checked={navigateOnUpload} />
|
||||||
|
<Field.Label for="navigate-to-book" class="font-normal">
|
||||||
|
Open the book when a single one is added
|
||||||
|
</Field.Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
import { type Book } from '$lib/schema';
|
import { type Book } from '$lib/schema';
|
||||||
import EditCover from './edit-cover.svelte';
|
import EditCover from './edit-cover.svelte';
|
||||||
import EditFiles from './edit-files.svelte';
|
import EditFiles from './edit-files.svelte';
|
||||||
import EditMetadata from './edit-metadata.svelte';
|
import EditMetadata from './edit-metadata.svelte';
|
||||||
|
|
||||||
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props();
|
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The metadata form lives in a child, but Save belongs in the dialog footer —
|
||||||
|
* a submit button reaches it by id rather than the footer having to duplicate
|
||||||
|
* the submit logic.
|
||||||
|
*/
|
||||||
|
const METADATA_FORM_ID = 'edit-book-metadata';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book`
|
This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book`
|
||||||
changes underneath it as different books are edited. bookToEdit is never
|
changes underneath it as different books are edited. bookToEdit is never
|
||||||
cleared, so {#if book} stays true and the tab forms never unmount — they seed
|
cleared, so {#if book} stays true and the forms never unmount — they seed
|
||||||
local state from `book` on mount, so without this key you would open Edit on a
|
local state from `book` on mount, so without this key you would open Edit on a
|
||||||
second book and see the first book's authors, tags and cover, then save them
|
second book and see the first book's authors, tags and cover, then save them
|
||||||
onto the wrong record. Keying on the id remounts the forms per book.
|
onto the wrong record. Keying on the id remounts the forms per book.
|
||||||
@@ -20,29 +27,41 @@
|
|||||||
<Dialog.Root bind:open>
|
<Dialog.Root bind:open>
|
||||||
{#if book}
|
{#if book}
|
||||||
{#key book.id}
|
{#key book.id}
|
||||||
<Dialog.Content class="sm:max-w-xl">
|
<Dialog.Content
|
||||||
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]">
|
class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
|
||||||
<Tabs.List class="grid w-full grid-cols-3">
|
>
|
||||||
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger>
|
<Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
|
||||||
<Tabs.Trigger value="cover">Cover</Tabs.Trigger>
|
<Dialog.Title class="truncate font-serif text-base font-normal">{book.title}</Dialog.Title
|
||||||
<Tabs.Trigger value="files">Files</Tabs.Trigger>
|
>
|
||||||
</Tabs.List>
|
<Dialog.Description class="truncate text-xs">
|
||||||
|
{book.authors.map((author) => author.name).join(', ') || 'Unknown author'}
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
<!-- Metadata form -->
|
<!-- Rail and form scroll independently so the footer never moves -->
|
||||||
<Tabs.Content value="metadata" class="h-full overflow-y-auto pb-1">
|
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_1fr]">
|
||||||
<EditMetadata {book} {open} />
|
<aside
|
||||||
</Tabs.Content>
|
class="flex min-w-0 flex-col gap-5 overflow-y-auto border-b bg-sidebar p-4 md:border-r md:border-b-0"
|
||||||
|
>
|
||||||
<!-- Cover form -->
|
<EditCover {book} />
|
||||||
<Tabs.Content value="cover">
|
|
||||||
<EditCover {book} {open} />
|
|
||||||
</Tabs.Content>
|
|
||||||
|
|
||||||
<!-- Add files form -->
|
|
||||||
<Tabs.Content value="files">
|
|
||||||
<EditFiles {book} />
|
<EditFiles {book} />
|
||||||
</Tabs.Content>
|
</aside>
|
||||||
</Tabs.Root>
|
|
||||||
|
<div class="min-h-0 overflow-y-auto p-5">
|
||||||
|
<EditMetadata {book} bind:open formId={METADATA_FORM_ID} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer class="shrink-0 items-center gap-2 border-t px-5 py-3 sm:justify-between">
|
||||||
|
<!-- Cover and file changes hit the server as they happen, while the
|
||||||
|
fields wait for Save. Saying so is the cheapest way to stop
|
||||||
|
Cancel reading as "undo everything". -->
|
||||||
|
<p class="text-xs text-muted-foreground">Cover and file changes apply immediately</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
|
||||||
|
<Button type="submit" form={METADATA_FORM_ID}>Save changes</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Footer>
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
{/key}
|
{/key}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,34 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { tick, untrack } from 'svelte';
|
||||||
FileDropZone,
|
|
||||||
type FileDropZoneProps,
|
|
||||||
displaySize
|
|
||||||
} from '$lib/components/ui/file-drop-zone/index.js';
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
|
||||||
import { Switch } from '$lib/components/ui/switch/index';
|
|
||||||
import BookImage from '$lib/components/view/book-image.svelte';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
import { updateBookCover } from '$lib/api';
|
import { updateBookCover } from '$lib/api';
|
||||||
import type { Book } from '$lib/schema';
|
import type { Book } from '$lib/schema';
|
||||||
import { tick, untrack } from 'svelte';
|
import BookImage from '$lib/components/view/book-image.svelte';
|
||||||
import { X } from '@lucide/svelte';
|
import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone/index.js';
|
||||||
|
|
||||||
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
|
let { book }: { book: Book } = $props();
|
||||||
|
|
||||||
let formEl = $state<HTMLFormElement>();
|
let formEl = $state<HTMLFormElement>();
|
||||||
// Seeded once per mount — see the key in edit-book.svelte
|
// Seeded once per mount — see the key in edit-book.svelte
|
||||||
let coverImagePreview = $state(untrack(() => `/api/${book.cover_image}`));
|
let coverImagePreview = $state<string>(untrack(() => `/api/${book.cover_image}`));
|
||||||
let autoUploadOnDrop = $state(true);
|
|
||||||
|
|
||||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||||
updateBookCover.fields.file.set(uploadedFiles[0]);
|
updateBookCover.fields.file.set(uploadedFiles[0]);
|
||||||
updateCoverPreview();
|
updateCoverPreview();
|
||||||
if (autoUploadOnDrop && updateBookCover.fields.file.value()) {
|
|
||||||
await tick();
|
await tick();
|
||||||
formEl?.requestSubmit();
|
formEl?.requestSubmit();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateCoverPreview() {
|
function updateCoverPreview() {
|
||||||
@@ -36,14 +25,14 @@
|
|||||||
if (file && file.type.startsWith('image/')) {
|
if (file && file.type.startsWith('image/')) {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => {
|
reader.onloadend = () => {
|
||||||
coverImagePreview = reader.result;
|
coverImagePreview = reader.result as string;
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
toast.error(`${file.name} was not used`, { description: reason });
|
||||||
};
|
};
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -54,65 +43,40 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form
|
<section class="flex min-w-0 flex-col gap-2">
|
||||||
|
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Cover</h3>
|
||||||
|
|
||||||
|
<BookImage src={coverImagePreview} class="w-full rounded-md border" />
|
||||||
|
|
||||||
|
<form
|
||||||
bind:this={formEl}
|
bind:this={formEl}
|
||||||
{...updateBookCover.enhance(async ({ submit, form }) => {
|
{...updateBookCover.enhance(async ({ submit, form }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
form.reset();
|
form.reset();
|
||||||
open = false;
|
// Deliberately does not close the dialog. The cover is one panel of a
|
||||||
toast.success('Updated book cover!');
|
// larger form now, and closing here would throw away metadata edits
|
||||||
|
// the reader has not saved yet.
|
||||||
|
toast.success('Cover updated');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update book cover: ', error);
|
console.error('Failed to update book cover: ', error);
|
||||||
toast.error('Failed to update cover.');
|
toast.error('Failed to update the cover');
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
class="grid grid-cols-[1fr_2fr] gap-4 p-6"
|
class="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
|
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
|
||||||
|
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<BookImage src={coverImagePreview} class="w-64 rounded" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<FileDropZone
|
<FileDropZone
|
||||||
{onUpload}
|
{onUpload}
|
||||||
{onFileRejected}
|
{onFileRejected}
|
||||||
accept=".jpeg,.jpg,.png,.webp,image/*"
|
accept=".jpeg,.jpg,.png,.webp,image/*"
|
||||||
label="Only JPEG, PNG, and WEBP images supported"
|
label="Replace cover"
|
||||||
|
sublabel="JPEG, PNG or WEBP"
|
||||||
maxFiles={1}
|
maxFiles={1}
|
||||||
fileCount={updateBookCover.fields.file.value() ? 1 : 0}
|
fileCount={updateBookCover.fields.file.value() ? 1 : 0}
|
||||||
/>
|
/>
|
||||||
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
|
</form>
|
||||||
<div class="flex flex-col gap-2">
|
</section>
|
||||||
{#if updateBookCover.fields.file.value()}
|
|
||||||
<div class="flex place-items-center justify-between gap-2">
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<span>{updateBookCover.fields.file.value().name}</span>
|
|
||||||
<span class="text-xs text-muted-foreground"
|
|
||||||
>{displaySize(updateBookCover.fields.file.value().size)}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onclick={() => {
|
|
||||||
updateBookCover.fields.file.set(undefined);
|
|
||||||
coverImagePreview = `/api/${book.cover_image}`;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-row items-center space-x-2">
|
|
||||||
<Switch bind:checked={autoUploadOnDrop} />
|
|
||||||
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
|
|
||||||
<Button type="submit" class="ml-auto w-fit" disabled={!updateBookCover.fields.file.value()}>Upload</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|||||||
@@ -1,98 +1,213 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { uploadBookFiles } from '$lib/api';
|
import { tick, untrack } from 'svelte';
|
||||||
import {
|
|
||||||
displaySize,
|
|
||||||
FileDropZone,
|
|
||||||
type FileDropZoneProps
|
|
||||||
} from '$lib/components/ui/file-drop-zone';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
|
||||||
|
|
||||||
import { Switch } from '$lib/components/ui/switch/index';
|
|
||||||
import { X } from '@lucide/svelte';
|
|
||||||
|
|
||||||
import type { Book } from '$lib/schema';
|
|
||||||
import { tick } from 'svelte';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Download, Trash2 } from '@lucide/svelte';
|
||||||
|
|
||||||
|
import { uploadBookFiles } from '$lib/api';
|
||||||
|
import type { Book, BookFile } from '$lib/schema';
|
||||||
|
import { formatFileSize, getFileType } from '$lib/utils';
|
||||||
|
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||||
|
|
||||||
|
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||||
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
|
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||||
|
import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone';
|
||||||
|
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||||
|
|
||||||
let { book }: { book: Book } = $props();
|
let { book }: { book: Book } = $props();
|
||||||
|
|
||||||
let files = $derived(uploadBookFiles.fields.files.value() ?? []);
|
const bookOps = getBookOperationsState();
|
||||||
|
|
||||||
let autoUploadOnDrop = $state(true);
|
/**
|
||||||
|
* The book's files, owned locally.
|
||||||
|
*
|
||||||
|
* `book` is a snapshot handed down from bookOperations rather than a live
|
||||||
|
* query, so invalidating after a delete does not reach it. Keeping the list
|
||||||
|
* here lets the rail reflect an add or a remove straight away. Seeded once per
|
||||||
|
* mount — edit-book.svelte keys the dialog on book.id.
|
||||||
|
*/
|
||||||
|
let files = $state<BookFile[]>(untrack(() => [...book.files]));
|
||||||
|
|
||||||
|
// The field's value is a sparse-ish list until the form settles, so narrow it
|
||||||
|
// before rendering rather than asserting at each use.
|
||||||
|
let pending = $derived(
|
||||||
|
(uploadBookFiles.fields.files.value() ?? []).filter((file): file is File => Boolean(file))
|
||||||
|
);
|
||||||
|
|
||||||
|
let fileToDelete = $state<BookFile>();
|
||||||
|
let deleteFromDisk = $state(true);
|
||||||
|
let confirmOpen = $state(false);
|
||||||
let formEl = $state<HTMLFormElement>();
|
let formEl = $state<HTMLFormElement>();
|
||||||
|
|
||||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
const onUpload: FileDropZoneProps['onUpload'] = async (uploaded) => {
|
||||||
uploadBookFiles.fields.files.set([...Array.from(files), ...uploadedFiles]);
|
uploadBookFiles.fields.files.set([...Array.from(pending), ...uploaded]);
|
||||||
if (autoUploadOnDrop && files.length > 0) {
|
|
||||||
await tick();
|
await tick();
|
||||||
formEl?.requestSubmit();
|
formEl?.requestSubmit();
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
||||||
toast.error(`${file.name} failed to upload!`, { description: reason });
|
toast.error(`${file.name} was not added`, { description: reason });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The API's own words, when it has any.
|
||||||
|
*
|
||||||
|
* Adding a file the library already holds under another book is refused with a
|
||||||
|
* 409 naming it — far more use than "failed to add files". SvelteKit hands an
|
||||||
|
* `error()` back as an HttpError on the client, so the message sits on `body`.
|
||||||
|
*/
|
||||||
|
function apiMessage(error: unknown): string | undefined {
|
||||||
|
if (typeof error !== 'object' || error === null) return undefined;
|
||||||
|
|
||||||
|
const body = (error as { body?: { message?: string } }).body;
|
||||||
|
if (typeof body?.message === 'string') return body.message;
|
||||||
|
|
||||||
|
return error instanceof Error ? error.message : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(file: BookFile) {
|
||||||
|
fileToDelete = file;
|
||||||
|
confirmOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeFile() {
|
||||||
|
if (!fileToDelete) return;
|
||||||
|
|
||||||
|
const target = fileToDelete;
|
||||||
|
confirmOpen = false;
|
||||||
|
|
||||||
|
// Drop it from the list first: the request invalidates the books query, but
|
||||||
|
// this dialog holds its own copy of the book and would not see that.
|
||||||
|
files = files.filter((file) => file.id !== target.id);
|
||||||
|
|
||||||
|
await bookOps.deleteBookFiles(book.id, [target.id], deleteFromDisk);
|
||||||
|
fileToDelete = undefined;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form
|
<section class="flex min-w-0 flex-col gap-2">
|
||||||
{...uploadBookFiles.enhance(async ({ submit, form }) => {
|
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Files</h3>
|
||||||
|
|
||||||
|
{#if files.length === 0}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
No files yet. Add one below so this book can be read or downloaded.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="flex flex-col gap-1.5">
|
||||||
|
{#each files as file (file.id)}
|
||||||
|
<li class="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||||
|
<span
|
||||||
|
class="rounded-sm bg-accent px-1.5 py-0.5 font-mono text-[9px] font-semibold text-accent-foreground"
|
||||||
|
>
|
||||||
|
{getFileType(file.filename)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-xs" title={file.filename}>{file.filename}</span>
|
||||||
|
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-7 shrink-0"
|
||||||
|
title="Download {file.filename}"
|
||||||
|
onclick={() => bookOps.downloadBookFile(book.id, file.id, file.filename)}
|
||||||
|
>
|
||||||
|
<Download class="size-3.5" />
|
||||||
|
<span class="sr-only">Download {file.filename}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-7 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
title="Remove {file.filename}"
|
||||||
|
onclick={() => confirmDelete(file)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
<span class="sr-only">Remove {file.filename}</span>
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each pending as file (file.name)}
|
||||||
|
<li
|
||||||
|
class="flex items-center gap-2 rounded-md border border-dashed bg-background p-2 text-muted-foreground"
|
||||||
|
>
|
||||||
|
<Spinner class="size-3.5 shrink-0" />
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-xs">{file.name}</span>
|
||||||
|
<span class="block font-mono text-[10px] tabular-nums">Uploading…</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form
|
||||||
|
bind:this={formEl}
|
||||||
|
{...uploadBookFiles.enhance(async ({ submit }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
|
|
||||||
// Check if there are any validation issues
|
|
||||||
const issues = uploadBookFiles.fields.allIssues();
|
const issues = uploadBookFiles.fields.allIssues();
|
||||||
if (issues && issues.length > 0) {
|
if (issues && issues.length > 0) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset the files field
|
// The endpoint answers with the updated book, so the new files come
|
||||||
|
// back with their ids rather than having to be guessed at.
|
||||||
|
files = uploadBookFiles.result?.files ?? files;
|
||||||
uploadBookFiles.fields.files.set([]);
|
uploadBookFiles.fields.files.set([]);
|
||||||
toast.success('Files successfully added!');
|
toast.success('Files added');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to upload files: ', error);
|
console.error('Failed to add files: ', error);
|
||||||
toast.error('Failed to upload files');
|
toast.error(apiMessage(error) ?? 'Failed to add files');
|
||||||
|
uploadBookFiles.fields.files.set([]);
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
bind:this={formEl}
|
|
||||||
enctype="multipart/form-data"
|
enctype="multipart/form-data"
|
||||||
class="flex w-full flex-col gap-2 p-4"
|
class="flex flex-col gap-2"
|
||||||
>
|
>
|
||||||
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
|
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
|
||||||
|
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
|
||||||
|
|
||||||
<FileDropZone
|
<FileDropZone
|
||||||
{onUpload}
|
{onUpload}
|
||||||
{onFileRejected}
|
{onFileRejected}
|
||||||
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
|
||||||
sublabel="Only PDF, EPUB, and MOBI files supported"
|
label="Add a file"
|
||||||
|
sublabel="EPUB, PDF or MOBI"
|
||||||
/>
|
/>
|
||||||
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
|
</form>
|
||||||
<div class="flex flex-col gap-2">
|
</section>
|
||||||
{#each files as file, idx}
|
|
||||||
<div class="flex place-items-center justify-between gap-2">
|
<AlertDialog.Root bind:open={confirmOpen}>
|
||||||
<div class="flex flex-col">
|
<AlertDialog.Content>
|
||||||
<span>{file.name}</span>
|
<AlertDialog.Header>
|
||||||
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
|
<AlertDialog.Title>Remove {fileToDelete?.filename}?</AlertDialog.Title>
|
||||||
</div>
|
<AlertDialog.Description>
|
||||||
<Button
|
This cannot be undone. The other files on this book are not affected.
|
||||||
variant="outline"
|
</AlertDialog.Description>
|
||||||
size="icon"
|
</AlertDialog.Header>
|
||||||
onclick={() => {
|
|
||||||
uploadBookFiles.fields.files.set([
|
<!-- The API takes these as separate outcomes: drop the record, or drop the
|
||||||
...Array.from(files).slice(0, idx),
|
record and the file on disk. Leaving it implicit would mean deleting
|
||||||
...Array.from(files).slice(idx + 1)
|
someone's only copy without saying so. -->
|
||||||
]);
|
<div class="flex items-center gap-2">
|
||||||
}}
|
<Checkbox id="delete-from-disk" bind:checked={deleteFromDisk} />
|
||||||
>
|
<Field.Label for="delete-from-disk" class="font-normal">
|
||||||
<X />
|
Also delete the file from the filesystem
|
||||||
</Button>
|
</Field.Label>
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-row items-center space-x-2">
|
<AlertDialog.Footer>
|
||||||
<Switch bind:checked={autoUploadOnDrop} />
|
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
|
||||||
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
|
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={removeFile}>
|
||||||
<Button type="submit" class="ml-auto w-fit">Upload</Button>
|
Remove
|
||||||
</div>
|
</AlertDialog.Action>
|
||||||
</form>
|
</AlertDialog.Footer>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
|||||||
@@ -1,18 +1,23 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import { untrack } from 'svelte';
|
||||||
import * as Field from '$lib/components/ui/field/index.js';
|
|
||||||
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
|
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
|
||||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Minus, Plus } from '@lucide/svelte';
|
||||||
|
|
||||||
import { updateBookMetadata } from '$lib/api';
|
import { updateBookMetadata } from '$lib/api';
|
||||||
import type { Book } from '$lib/schema';
|
import type { Book } from '$lib/schema';
|
||||||
import { untrack } from 'svelte';
|
|
||||||
import { Minus, Plus } from '@lucide/svelte';
|
|
||||||
|
|
||||||
let { book, open = $bindable() }: { book: Book; open: boolean } = $props();
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
|
|
||||||
|
let {
|
||||||
|
book,
|
||||||
|
open = $bindable(),
|
||||||
|
/** Lets the dialog footer own the submit button via the `form` attribute. */
|
||||||
|
formId
|
||||||
|
}: { book: Book; open: boolean; formId: string } = $props();
|
||||||
|
|
||||||
// Seeded once per mount; edit-book.svelte keys this form on book.id so a
|
// Seeded once per mount; edit-book.svelte keys this form on book.id so a
|
||||||
// different book gets a fresh form rather than the previous book's values.
|
// different book gets a fresh form rather than the previous book's values.
|
||||||
@@ -22,7 +27,6 @@
|
|||||||
let identifierValues = $state(untrack(() => Object.values(book.identifiers)));
|
let identifierValues = $state(untrack(() => Object.values(book.identifiers)));
|
||||||
|
|
||||||
function handleAddIdentifier() {
|
function handleAddIdentifier() {
|
||||||
// Add empty strings to both arrays
|
|
||||||
identifierKeys = [...identifierKeys, ''];
|
identifierKeys = [...identifierKeys, ''];
|
||||||
identifierValues = [...identifierValues, ''];
|
identifierValues = [...identifierValues, ''];
|
||||||
}
|
}
|
||||||
@@ -88,9 +92,17 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card.Root class="w-full ">
|
{#snippet groupHeading(label: string)}
|
||||||
<form
|
<h3
|
||||||
{...updateBookMetadata.enhance(async ({ submit, form }) => {
|
class="col-span-full mt-2 border-b pb-1 font-mono text-[10px] tracking-widest text-primary uppercase first:mt-0"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</h3>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<form
|
||||||
|
id={formId}
|
||||||
|
{...updateBookMetadata.enhance(async ({ submit }) => {
|
||||||
try {
|
try {
|
||||||
await submit();
|
await submit();
|
||||||
|
|
||||||
@@ -101,28 +113,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
open = false;
|
open = false;
|
||||||
book = book;
|
|
||||||
toast.success('Updated book metadata!');
|
toast.success('Updated book metadata!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error occurred updating book metadata: ', error);
|
console.error('Error occurred updating book metadata: ', error);
|
||||||
toast.error('Failed to update book metadata.');
|
toast.error('Failed to update book metadata.');
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
>
|
class="grid grid-cols-1 items-start gap-x-4 gap-y-3 sm:grid-cols-2"
|
||||||
<Card.Content>
|
>
|
||||||
<Field.Set>
|
<input class="hidden" {...updateBookMetadata.fields.book_id.as('text')} />
|
||||||
<Field.Group class="flex flex-col">
|
|
||||||
<!-- Book ID field -->
|
|
||||||
<Field.Field class="hidden">
|
|
||||||
<Field.Label for="book_id">Book ID</Field.Label>
|
|
||||||
<Input {...updateBookMetadata.fields.book_id.as('text')} />
|
|
||||||
{#each updateBookMetadata.fields.book_id.issues() ?? [] as issue}
|
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
|
||||||
{/each}
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
<!-- Title field -->
|
{@render groupHeading('Identity')}
|
||||||
<Field.Field>
|
|
||||||
|
<Field.Field class="col-span-full">
|
||||||
<Field.Label for="title">Title</Field.Label>
|
<Field.Label for="title">Title</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.title.as('text')} />
|
<Input {...updateBookMetadata.fields.title.as('text')} />
|
||||||
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
|
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
|
||||||
@@ -130,7 +133,6 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Subtitle field -->
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="subtitle">Subtitle</Field.Label>
|
<Field.Label for="subtitle">Subtitle</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
|
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
|
||||||
@@ -139,8 +141,14 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<div class="grid grid-cols-[3fr_1fr] gap-2">
|
<Field.Field>
|
||||||
<!-- Series field -->
|
<Field.Label for="edition">Edition</Field.Label>
|
||||||
|
<Input {...updateBookMetadata.fields.edition.as('number')} />
|
||||||
|
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
|
||||||
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
|
{/each}
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="series">Series</Field.Label>
|
<Field.Label for="series">Series</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.series.as('text')} />
|
<Input {...updateBookMetadata.fields.series.as('text')} />
|
||||||
@@ -149,18 +157,27 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Series position field -->
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="series_position">Series position</Field.Label>
|
<Field.Label for="series_position">No.</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.series_position.as('text')} />
|
<Input {...updateBookMetadata.fields.series_position.as('text')} />
|
||||||
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
|
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="language">Language</Field.Label>
|
||||||
|
<Input {...updateBookMetadata.fields.language.as('text')} />
|
||||||
|
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
|
||||||
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
|
{/each}
|
||||||
|
</Field.Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Authors field -->
|
{@render groupHeading('People and subjects')}
|
||||||
<Field.Field>
|
|
||||||
|
<Field.Field class="col-span-full">
|
||||||
<Field.Label for="authors">Authors</Field.Label>
|
<Field.Label for="authors">Authors</Field.Label>
|
||||||
<TagsInput
|
<TagsInput
|
||||||
bind:value={authors}
|
bind:value={authors}
|
||||||
@@ -176,8 +193,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Tags field -->
|
<Field.Field class="col-span-full">
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="tags">Tags</Field.Label>
|
<Field.Label for="tags">Tags</Field.Label>
|
||||||
<TagsInput
|
<TagsInput
|
||||||
bind:value={tags}
|
bind:value={tags}
|
||||||
@@ -193,39 +209,8 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Description field -->
|
{@render groupHeading('Publication')}
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="description">Description</Field.Label>
|
|
||||||
<Textarea {...updateBookMetadata.fields.description.as('text')} />
|
|
||||||
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
|
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
|
||||||
{/each}
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
<!-- Identifier fields -->
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="identifiers">Identifiers</Field.Label>
|
|
||||||
<div class="grid grid-cols-[5fr_10fr_0.5fr] gap-2">
|
|
||||||
{#each identifierKeys as _, idx}
|
|
||||||
<Input bind:value={identifierKeys[idx]} placeholder="Identifier..." />
|
|
||||||
<Input bind:value={identifierValues[idx]} placeholder="Value..." />
|
|
||||||
|
|
||||||
<Button variant="outline" size="icon" onclick={() => handleRemoveIdentifier(idx)}>
|
|
||||||
<Minus />
|
|
||||||
</Button>
|
|
||||||
{/each}
|
|
||||||
|
|
||||||
<Button variant="outline" onclick={() => handleAddIdentifier()}>
|
|
||||||
<Plus />
|
|
||||||
Add Identifier
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
|
|
||||||
</div>
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
<!-- Publisher field -->
|
|
||||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="publisher">Publisher</Field.Label>
|
<Field.Label for="publisher">Publisher</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.publisher.as('text')} />
|
<Input {...updateBookMetadata.fields.publisher.as('text')} />
|
||||||
@@ -234,18 +219,15 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Published date field -->
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="published_date">Date published</Field.Label>
|
<Field.Label for="published_date">Published</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.published_date.as('date')} />
|
<Input {...updateBookMetadata.fields.published_date.as('date')} />
|
||||||
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
|
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-[2fr_1fr_1fr] gap-2">
|
|
||||||
<!-- Pages field -->
|
|
||||||
<Field.Field>
|
<Field.Field>
|
||||||
<Field.Label for="pages">Pages</Field.Label>
|
<Field.Label for="pages">Pages</Field.Label>
|
||||||
<Input {...updateBookMetadata.fields.pages.as('number')} />
|
<Input {...updateBookMetadata.fields.pages.as('number')} />
|
||||||
@@ -253,32 +235,44 @@
|
|||||||
<Field.Error>{issue.message}</Field.Error>
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
{/each}
|
{/each}
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
|
|
||||||
<!-- Language field -->
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="language">Language</Field.Label>
|
|
||||||
<Input {...updateBookMetadata.fields.language.as('text')} />
|
|
||||||
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
|
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
|
||||||
{/each}
|
|
||||||
</Field.Field>
|
|
||||||
|
|
||||||
<!-- Edition field -->
|
|
||||||
<Field.Field>
|
|
||||||
<Field.Label for="edition">Edition</Field.Label>
|
|
||||||
<Input {...updateBookMetadata.fields.edition.as('number')} />
|
|
||||||
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
|
|
||||||
<Field.Error>{issue.message}</Field.Error>
|
|
||||||
{/each}
|
|
||||||
</Field.Field>
|
|
||||||
</div>
|
</div>
|
||||||
</Field.Group>
|
|
||||||
</Field.Set>
|
|
||||||
</Card.Content>
|
|
||||||
|
|
||||||
<!-- Submit button -->
|
<Field.Field class="col-span-full">
|
||||||
<Card.Footer class="flex-col gap-2 pt-6">
|
<Field.Label for="identifiers">Identifiers</Field.Label>
|
||||||
<Button type="submit" class="w-full">Save</Button>
|
<div class="flex flex-col gap-2">
|
||||||
</Card.Footer>
|
{#each identifierKeys as _, idx}
|
||||||
</form>
|
<div class="grid grid-cols-[1fr_1.6fr_auto] gap-2">
|
||||||
</Card.Root>
|
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
|
||||||
|
<Input bind:value={identifierValues[idx]} placeholder="Value" />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
title="Remove identifier"
|
||||||
|
onclick={() => handleRemoveIdentifier(idx)}
|
||||||
|
>
|
||||||
|
<Minus />
|
||||||
|
<span class="sr-only">Remove identifier</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" class="w-fit" onclick={handleAddIdentifier}>
|
||||||
|
<Plus />
|
||||||
|
Add identifier
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
|
||||||
|
</div>
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
|
{@render groupHeading('Description')}
|
||||||
|
|
||||||
|
<Field.Field class="col-span-full">
|
||||||
|
<Field.Label for="description" class="sr-only">Description</Field.Label>
|
||||||
|
<Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} />
|
||||||
|
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
|
||||||
|
<Field.Error>{issue.message}</Field.Error>
|
||||||
|
{/each}
|
||||||
|
</Field.Field>
|
||||||
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { Book } from '$lib/schema';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What kind of thing a metadata field holds, which is the only thing that decides
|
||||||
|
* what you can do with it when two records disagree.
|
||||||
|
*/
|
||||||
|
export type FieldKind = 'text' | 'number' | 'date' | 'list' | 'keyed' | 'longtext';
|
||||||
|
|
||||||
|
/** An action offered on a field, beyond replacing it outright. */
|
||||||
|
export type FieldAction = 'replace' | 'merge' | 'append';
|
||||||
|
|
||||||
|
export interface FieldSpec {
|
||||||
|
/** The key sent in `BookMetadataUpdate`. */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
kind: FieldKind;
|
||||||
|
group: string;
|
||||||
|
/** Extra actions past `replace`, which every field has. */
|
||||||
|
extra: FieldAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fields a merge can resolve, in the order and grouping the edit form uses.
|
||||||
|
*
|
||||||
|
* Deliberately a spec rather than markup: the merge workbench and, later, the
|
||||||
|
* provider review screen both render from this, so a field cannot exist in one and
|
||||||
|
* not the other. `cover` and `files` are absent because they are not choices —
|
||||||
|
* files always come across and the cover has its own endpoint.
|
||||||
|
*/
|
||||||
|
export const MERGE_FIELDS: FieldSpec[] = [
|
||||||
|
{ key: 'title', label: 'Title', kind: 'text', group: 'Identity', extra: [] },
|
||||||
|
{ key: 'subtitle', label: 'Subtitle', kind: 'text', group: 'Identity', extra: [] },
|
||||||
|
{ key: 'edition', label: 'Edition', kind: 'number', group: 'Identity', extra: [] },
|
||||||
|
{ key: 'series', label: 'Series', kind: 'text', group: 'Identity', extra: [] },
|
||||||
|
{ key: 'series_position', label: 'No.', kind: 'text', group: 'Identity', extra: [] },
|
||||||
|
{ key: 'language', label: 'Language', kind: 'text', group: 'Identity', extra: [] },
|
||||||
|
|
||||||
|
// Order is meaningful for authors, so the second list is appended rather than
|
||||||
|
// interleaved; tags are a set, so they merge.
|
||||||
|
{
|
||||||
|
key: 'authors',
|
||||||
|
label: 'Authors',
|
||||||
|
kind: 'list',
|
||||||
|
group: 'People and subjects',
|
||||||
|
extra: ['append']
|
||||||
|
},
|
||||||
|
{ key: 'tags', label: 'Tags', kind: 'list', group: 'People and subjects', extra: ['merge'] },
|
||||||
|
|
||||||
|
{ key: 'publisher', label: 'Publisher', kind: 'text', group: 'Publication', extra: [] },
|
||||||
|
{ key: 'published_date', label: 'Published', kind: 'date', group: 'Publication', extra: [] },
|
||||||
|
{ key: 'pages', label: 'Pages', kind: 'number', group: 'Publication', extra: [] },
|
||||||
|
{
|
||||||
|
key: 'identifiers',
|
||||||
|
label: 'Identifiers',
|
||||||
|
kind: 'keyed',
|
||||||
|
group: 'Publication',
|
||||||
|
extra: ['merge']
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
label: 'Description',
|
||||||
|
kind: 'longtext',
|
||||||
|
group: 'Description',
|
||||||
|
extra: ['append']
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/** A field's value, in the shape `BookMetadataUpdate` expects to receive it. */
|
||||||
|
export type FieldValue = string | number | string[] | Record<string, string> | null;
|
||||||
|
|
||||||
|
/** Read one field off a book, flattening the relations the API returns as objects. */
|
||||||
|
export function readField(book: Book, key: string): FieldValue {
|
||||||
|
switch (key) {
|
||||||
|
case 'authors':
|
||||||
|
return book.authors.map((author) => author.name);
|
||||||
|
case 'tags':
|
||||||
|
return book.tags.map((tag) => tag.name);
|
||||||
|
case 'publisher':
|
||||||
|
return book.publisher?.name ?? null;
|
||||||
|
case 'series':
|
||||||
|
return book.series?.title ?? null;
|
||||||
|
case 'identifiers':
|
||||||
|
return book.identifiers ?? {};
|
||||||
|
default:
|
||||||
|
return (book as unknown as Record<string, FieldValue>)[key] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a field holds nothing, and so has no decision attached to it. */
|
||||||
|
export function isEmpty(value: FieldValue): boolean {
|
||||||
|
if (value === null || value === undefined || value === '') return true;
|
||||||
|
if (Array.isArray(value)) return value.length === 0;
|
||||||
|
if (typeof value === 'object') return Object.keys(value).length === 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether two field values say the same thing, order included for lists. */
|
||||||
|
export function isSame(left: FieldValue, right: FieldValue): boolean {
|
||||||
|
if (isEmpty(left) && isEmpty(right)) return true;
|
||||||
|
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an action to a pair of values and return what the target becomes.
|
||||||
|
*
|
||||||
|
* `merge` on a keyed collection is per name and the target wins a clash, because
|
||||||
|
* `Identifier` is unique on `(name, book_id)` — a book cannot hold both its print
|
||||||
|
* and its ebook ISBN, so the second one has nowhere to go.
|
||||||
|
*/
|
||||||
|
export function applyAction(
|
||||||
|
action: FieldAction,
|
||||||
|
kind: FieldKind,
|
||||||
|
target: FieldValue,
|
||||||
|
incoming: FieldValue
|
||||||
|
): FieldValue {
|
||||||
|
if (action === 'replace') return incoming;
|
||||||
|
|
||||||
|
if (kind === 'list') {
|
||||||
|
const current = Array.isArray(target) ? target : [];
|
||||||
|
const extra = Array.isArray(incoming) ? incoming : [];
|
||||||
|
// Order preserved, duplicates dropped — works for both append and merge.
|
||||||
|
return [...new Set([...current, ...extra])];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'keyed') {
|
||||||
|
return { ...(incoming as Record<string, string>), ...(target as Record<string, string>) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'longtext') {
|
||||||
|
const current = typeof target === 'string' ? target.trim() : '';
|
||||||
|
const extra = typeof incoming === 'string' ? incoming.trim() : '';
|
||||||
|
return [current, extra].filter(Boolean).join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return incoming;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How a value reads in the inert reference column. */
|
||||||
|
export function displayValue(value: FieldValue): string {
|
||||||
|
if (isEmpty(value)) return '';
|
||||||
|
if (Array.isArray(value)) return value.join(', ');
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return Object.entries(value as Record<string, string>)
|
||||||
|
.map(([name, id]) => `${name}: ${id}`)
|
||||||
|
.join(' · ');
|
||||||
|
}
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import { ArrowRight, GitMerge, Plus, Undo2 } from '@lucide/svelte';
|
||||||
|
|
||||||
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import BookImage from '$lib/components/view/book-image.svelte';
|
||||||
|
import GeneratedCover from '$lib/components/view/generated-cover.svelte';
|
||||||
|
import type { Book } from '$lib/schema';
|
||||||
|
|
||||||
|
import { mergeInto } from './merge';
|
||||||
|
|
||||||
|
import {
|
||||||
|
MERGE_FIELDS,
|
||||||
|
applyAction,
|
||||||
|
displayValue,
|
||||||
|
isEmpty,
|
||||||
|
isSame,
|
||||||
|
readField,
|
||||||
|
type FieldAction,
|
||||||
|
type FieldSpec,
|
||||||
|
type FieldValue
|
||||||
|
} from './field-spec';
|
||||||
|
|
||||||
|
let {
|
||||||
|
books,
|
||||||
|
libraryId,
|
||||||
|
open = $bindable(),
|
||||||
|
onmerged
|
||||||
|
}: {
|
||||||
|
books: Book[];
|
||||||
|
libraryId: number | string;
|
||||||
|
open: boolean;
|
||||||
|
/** Given the record that survived and the ones folded into it and deleted. */
|
||||||
|
onmerged?: (survivor: Book, folded: Book[]) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
// Seeded once per mount. The dialog is keyed on the group upstream, so a
|
||||||
|
// different group gets a fresh workbench rather than the previous one's draft.
|
||||||
|
let survivorId = $state(untrack(() => books[0]?.id));
|
||||||
|
let candidateId = $state(untrack(() => books[1]?.id));
|
||||||
|
let draft = $state<Record<string, FieldValue>>({});
|
||||||
|
let busy = $state(false);
|
||||||
|
|
||||||
|
const survivor = $derived(books.find((book) => book.id === survivorId) ?? books[0]);
|
||||||
|
const candidates = $derived(books.filter((book) => book.id !== survivorId));
|
||||||
|
/** The records that will be deleted — the same set, named for what happens to them. */
|
||||||
|
const folded = $derived(candidates);
|
||||||
|
const candidate = $derived(candidates.find((book) => book.id === candidateId) ?? candidates[0]);
|
||||||
|
|
||||||
|
/** The survivor's stored value for a field, or the draft if it has been touched. */
|
||||||
|
function current(field: FieldSpec): FieldValue {
|
||||||
|
return field.key in draft ? draft[field.key] : readField(survivor, field.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function take(field: FieldSpec, action: FieldAction) {
|
||||||
|
draft[field.key] = applyAction(
|
||||||
|
action,
|
||||||
|
field.kind,
|
||||||
|
current(field),
|
||||||
|
readField(candidate, field.key)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function undo(field: FieldSpec) {
|
||||||
|
delete draft[field.key];
|
||||||
|
draft = { ...draft };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fill only the fields the survivor has nothing in.
|
||||||
|
*
|
||||||
|
* The safe bulk action, and the one worth reaching for: it cannot overwrite a
|
||||||
|
* value, so it needs no per-field protection to be pressed without reading.
|
||||||
|
*/
|
||||||
|
function fillEmpty() {
|
||||||
|
for (const field of MERGE_FIELDS) {
|
||||||
|
const incoming = readField(candidate, field.key);
|
||||||
|
if (isEmpty(current(field)) && !isEmpty(incoming)) draft[field.key] = incoming;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = $derived(Object.keys(draft));
|
||||||
|
|
||||||
|
const differing = $derived(
|
||||||
|
candidate
|
||||||
|
? MERGE_FIELDS.filter((field) => !isSame(current(field), readField(candidate, field.key)))
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Fields shown as a row: anything the two disagree on, plus anything edited. */
|
||||||
|
const shown = $derived(
|
||||||
|
MERGE_FIELDS.filter((field) => differing.includes(field) || field.key in draft)
|
||||||
|
);
|
||||||
|
|
||||||
|
const agreed = $derived(MERGE_FIELDS.filter((field) => !shown.includes(field)));
|
||||||
|
|
||||||
|
const groups = $derived([...new Set(shown.map((field) => field.group))]);
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
|
||||||
|
const merged = await mergeInto(libraryId, survivor, folded, changed.length ? draft : undefined);
|
||||||
|
|
||||||
|
busy = false;
|
||||||
|
|
||||||
|
if (merged) {
|
||||||
|
open = false;
|
||||||
|
onmerged?.(survivor, folded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet cover(book: Book, size: string)}
|
||||||
|
<span class="{size} shrink-0 overflow-hidden rounded-sm border bg-muted">
|
||||||
|
{#if book.cover_image}
|
||||||
|
<BookImage src="/api/{book.cover_image}" class="h-full w-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<!-- Drawn rather than left blank, so the rail tells two coverless books
|
||||||
|
apart the same way the shelves do. -->
|
||||||
|
<GeneratedCover {book} />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<!-- The inert reference column: the same shape as the control opposite it, with
|
||||||
|
nothing that invites a click. -->
|
||||||
|
{#snippet reference(field: FieldSpec, book: Book)}
|
||||||
|
{@const value = readField(book, field.key)}
|
||||||
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
|
<span class="text-[11px] text-muted-foreground">{field.label}</span>
|
||||||
|
{#if isEmpty(value)}
|
||||||
|
<span class="min-h-8 py-1 text-sm text-muted-foreground italic">empty</span>
|
||||||
|
{:else if field.kind === 'list'}
|
||||||
|
<span class="flex min-h-8 flex-wrap items-center gap-1 py-0.5">
|
||||||
|
{#each value as string[] as item (item)}
|
||||||
|
<Badge variant="secondary" class="font-normal">{item}</Badge>
|
||||||
|
{/each}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="min-h-8 py-1 text-sm break-words">{displayValue(value)}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<Dialog.Root bind:open>
|
||||||
|
<Dialog.Content
|
||||||
|
class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
|
||||||
|
>
|
||||||
|
<Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
|
||||||
|
<Dialog.Title class="font-serif text-base font-normal">
|
||||||
|
Merge {books.length} books
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description class="text-xs">
|
||||||
|
{candidates.length}
|
||||||
|
{candidates.length === 1 ? 'record is' : 'records are'} deleted. Their files move onto the book
|
||||||
|
you keep — nothing is removed from disk.
|
||||||
|
</Dialog.Description>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
|
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[15rem_1fr]">
|
||||||
|
<!-- Rail: the books being folded in, one open at a time -->
|
||||||
|
<aside
|
||||||
|
class="flex min-w-0 flex-col overflow-y-auto border-b bg-sidebar md:border-r md:border-b-0"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="px-3 pt-3 pb-1 font-mono text-[10px] tracking-widest text-muted-foreground uppercase"
|
||||||
|
>
|
||||||
|
Taking from · {candidates.length}
|
||||||
|
</p>
|
||||||
|
{#each candidates as book (book.id)}
|
||||||
|
{@const count = MERGE_FIELDS.filter(
|
||||||
|
(field) => !isSame(readField(survivor, field.key), readField(book, field.key))
|
||||||
|
).length}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (candidateId = book.id)}
|
||||||
|
class="flex items-start gap-2 border-l-2 px-3 py-2 text-left transition-colors hover:bg-muted/50 {book.id ===
|
||||||
|
candidate?.id
|
||||||
|
? 'border-l-primary bg-background'
|
||||||
|
: 'border-l-transparent'}"
|
||||||
|
>
|
||||||
|
{@render cover(book, 'h-10 w-7')}
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="line-clamp-2 font-serif text-[13px]">{book.title}</span>
|
||||||
|
<span class="block text-[10px] text-muted-foreground">
|
||||||
|
#{book.id} · {book.files.length}
|
||||||
|
{book.files.length === 1 ? 'file' : 'files'}
|
||||||
|
</span>
|
||||||
|
{#if count === 0}
|
||||||
|
<Badge variant="secondary" class="mt-1 text-[10px] font-normal">
|
||||||
|
nothing to take
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-col">
|
||||||
|
<!-- Which record survives -->
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b px-5 py-2.5">
|
||||||
|
<span class="text-[11px] text-muted-foreground">Keeping</span>
|
||||||
|
{#each books as book (book.id)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (survivorId = book.id)}
|
||||||
|
class="flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors {book.id ===
|
||||||
|
survivorId
|
||||||
|
? 'border-primary bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-muted'}"
|
||||||
|
>
|
||||||
|
{@render cover(book, 'h-6 w-4')}
|
||||||
|
<span class="max-w-32 truncate">#{book.id}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<div class="ml-auto flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onclick={fillEmpty} disabled={!candidate}>
|
||||||
|
Fill empty fields
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
{#if !candidate}
|
||||||
|
<p class="text-sm text-muted-foreground">Nothing left to fold in.</p>
|
||||||
|
{:else if shown.length === 0}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
These records agree on every field. Merging keeps
|
||||||
|
<span class="font-medium text-foreground">#{survivor.id}</span> and moves the others' files
|
||||||
|
onto it.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
{#each groups as group (group)}
|
||||||
|
<h3
|
||||||
|
class="mt-5 border-b pb-1 font-mono text-[10px] tracking-widest text-primary uppercase first:mt-0"
|
||||||
|
>
|
||||||
|
{group}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{#each shown.filter((field) => field.group === group) as field (field.key)}
|
||||||
|
{@const value = current(field)}
|
||||||
|
{@const incoming = readField(candidate, field.key)}
|
||||||
|
{@const edited = field.key in draft}
|
||||||
|
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 border-b py-2">
|
||||||
|
{@render reference(field, candidate)}
|
||||||
|
|
||||||
|
<!-- Actions, on the row rather than stacked beside it -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
{#if edited}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-7"
|
||||||
|
title="Undo"
|
||||||
|
onclick={() => undo(field)}
|
||||||
|
>
|
||||||
|
<Undo2 class="size-3.5" />
|
||||||
|
<span class="sr-only">Undo {field.label}</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !isSame(value, incoming)}
|
||||||
|
{#if isEmpty(value)}
|
||||||
|
<!-- Nothing to weigh, so it is an offer rather than a choice -->
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="h-7 gap-1 px-2 text-[11px]"
|
||||||
|
onclick={() => take(field, 'replace')}
|
||||||
|
>
|
||||||
|
<ArrowRight class="size-3.5" />
|
||||||
|
Take
|
||||||
|
</Button>
|
||||||
|
{:else}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-7"
|
||||||
|
title="Replace"
|
||||||
|
onclick={() => take(field, 'replace')}
|
||||||
|
>
|
||||||
|
<ArrowRight class="size-3.5" />
|
||||||
|
<span class="sr-only">Replace {field.label}</span>
|
||||||
|
</Button>
|
||||||
|
{#each field.extra as action (action)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
class="size-7"
|
||||||
|
title={action === 'merge' ? 'Merge' : 'Append'}
|
||||||
|
onclick={() => take(field, action)}
|
||||||
|
>
|
||||||
|
{#if action === 'merge'}
|
||||||
|
<GitMerge class="size-3.5" />
|
||||||
|
{:else}
|
||||||
|
<Plus class="size-3.5" />
|
||||||
|
{/if}
|
||||||
|
<span class="sr-only">{action} {field.label}</span>
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- The survivor's side: the edit form, live -->
|
||||||
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
|
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
|
{#if edited}
|
||||||
|
<Badge class="h-4 px-1.5 text-[9px] font-semibold">taken</Badge>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if field.kind === 'longtext'}
|
||||||
|
<Textarea
|
||||||
|
rows={4}
|
||||||
|
class="text-sm"
|
||||||
|
value={(value as string) ?? ''}
|
||||||
|
oninput={(event) => (draft[field.key] = event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{:else if field.kind === 'list' || field.kind === 'keyed'}
|
||||||
|
<!-- Edited through the take actions; typing here would need the
|
||||||
|
tags and key/value editors, which belong to the edit form. -->
|
||||||
|
<div
|
||||||
|
class="flex min-h-8 flex-wrap items-center gap-1 rounded-md border bg-background px-2 py-1"
|
||||||
|
>
|
||||||
|
{#if isEmpty(value)}
|
||||||
|
<span class="text-sm text-muted-foreground">—</span>
|
||||||
|
{:else if field.kind === 'list'}
|
||||||
|
{#each value as string[] as item (item)}
|
||||||
|
<Badge variant="secondary" class="font-normal">{item}</Badge>
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each Object.entries(value as Record<string, string>) as [name, id] (name)}
|
||||||
|
<Badge variant="secondary" class="font-mono text-[10px] font-normal">
|
||||||
|
{name}: {id}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Input
|
||||||
|
type={field.kind === 'number'
|
||||||
|
? 'number'
|
||||||
|
: field.kind === 'date'
|
||||||
|
? 'date'
|
||||||
|
: 'text'}
|
||||||
|
class="h-8 text-sm"
|
||||||
|
value={(value as string | number) ?? ''}
|
||||||
|
oninput={(event) =>
|
||||||
|
(draft[field.key] =
|
||||||
|
field.kind === 'number'
|
||||||
|
? Number(event.currentTarget.value) || null
|
||||||
|
: event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if agreed.length > 0}
|
||||||
|
<p class="pt-3 text-center text-[11px] text-muted-foreground italic">
|
||||||
|
{agreed.map((field) => field.label).join(', ')} — identical in both
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog.Footer class="shrink-0 items-center gap-2 border-t px-5 py-3 sm:justify-between">
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{#if changed.length}
|
||||||
|
{changed.length}
|
||||||
|
{changed.length === 1 ? 'change' : 'changes'} pending · this cannot be undone
|
||||||
|
{:else}
|
||||||
|
Metadata is left as #{survivor.id} has it · this cannot be undone
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
|
||||||
|
<Button onclick={submit} disabled={busy || candidates.length === 0}>
|
||||||
|
Merge into “{survivor.title}”
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Footer>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { invalidate } from '$app/navigation';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
import { mergeBooks } from '$lib/api/book.remote';
|
||||||
|
import type { Book } from '$lib/schema';
|
||||||
|
import type { FieldValue } from './field-spec';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold `folded` into `survivor`, and tell the reader how it went.
|
||||||
|
*
|
||||||
|
* Shared because a merge is reachable two ways — the workbench, where the reader
|
||||||
|
* resolved the metadata field by field, and the quick action beside it, which
|
||||||
|
* takes the survivor's metadata as it stands. Only the `metadata` argument
|
||||||
|
* differs, and the invalidation and the wording should not.
|
||||||
|
*
|
||||||
|
* @returns whether the merge went through; the caller decides what to close or
|
||||||
|
* clear, and has no toast of its own to write either way.
|
||||||
|
*/
|
||||||
|
export async function mergeInto(
|
||||||
|
libraryId: number | string,
|
||||||
|
survivor: Book,
|
||||||
|
folded: Book[],
|
||||||
|
metadata?: Record<string, FieldValue>
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await mergeBooks({
|
||||||
|
library_id: libraryId,
|
||||||
|
survivor_id: survivor.id,
|
||||||
|
merged_ids: folded.map((book) => book.id),
|
||||||
|
metadata
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both, because a merge is started from the duplicates review and from the
|
||||||
|
// library's selection toolbar, and each page depends on a different one.
|
||||||
|
await Promise.all([invalidate('app:books'), invalidate('app:duplicate-books')]);
|
||||||
|
|
||||||
|
toast.success(`Merged into “${survivor.title}”`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to merge books', error);
|
||||||
|
toast.error(error instanceof Error ? error.message : 'Could not merge these books');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
|
||||||
import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
|
import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
// directly in the markup keeps it static.
|
// directly in the markup keeps it static.
|
||||||
const header = $derived({
|
const header = $derived({
|
||||||
title: 'chitai',
|
title: 'chitai',
|
||||||
url: `/library/${libraryState.activeLibrary!.id}`
|
url: resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(libraryState.activeLibrary!.id) })
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@
|
|||||||
-->
|
-->
|
||||||
<Sidebar.MenuButton class="mt-1 -ml-1.5">
|
<Sidebar.MenuButton class="mt-1 -ml-1.5">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
|
<!-- header.url is built with resolve(); the rule cannot trace the variable. -->
|
||||||
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
<a href={header.url} {...props}>
|
<a href={header.url} {...props}>
|
||||||
<ChitaiMark class="mr-3 size-7!" />
|
<ChitaiMark class="mr-3 size-7!" />
|
||||||
<span class="font-serif text-xl tracking-tight">{header.title}</span>
|
<span class="font-serif text-xl tracking-tight">{header.title}</span>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
import { Badge } from "$lib/components/ui/badge/index.js";
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
|
||||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||||
import { getLibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
@@ -22,10 +23,27 @@
|
|||||||
// rebuilt on every navigation the icons would remount, flashing and shifting
|
// rebuilt on every navigation the icons would remount, flashing and shifting
|
||||||
// layout. Only the url and active state need to be reactive, so they are
|
// layout. Only the url and active state need to be reactive, so they are
|
||||||
// computed per-item in the markup instead.
|
// computed per-item in the markup instead.
|
||||||
|
//
|
||||||
|
// Active state is matched on route id rather than pathname. resolve() returns
|
||||||
|
// an absolute path on the client but a relative one during SSR, so comparing
|
||||||
|
// it to page.url.pathname would be false on the server and true after
|
||||||
|
// hydration — the highlight would flash in.
|
||||||
const items = [
|
const items = [
|
||||||
{ title: 'Home', icon: House, path: (id?: number) => `/library/${id}` },
|
{
|
||||||
{ title: 'Library', icon: LibraryBig, path: (id?: number) => `/library/${id}/view` },
|
title: 'Home',
|
||||||
{ title: 'Shelves', icon: Rows3, path: () => '#', shelves: [] }
|
icon: House,
|
||||||
|
routeId: '/(root)/(library)/library/[libraryId]',
|
||||||
|
path: (id?: number) =>
|
||||||
|
resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(id ?? '') })
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Library',
|
||||||
|
icon: LibraryBig,
|
||||||
|
routeId: '/(root)/(library)/library/[libraryId]/view',
|
||||||
|
path: (id?: number) =>
|
||||||
|
resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(id ?? '') })
|
||||||
|
},
|
||||||
|
{ title: 'Shelves', icon: Rows3, routeId: null, path: () => '#', shelves: [] }
|
||||||
];
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,7 +59,7 @@
|
|||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
{#each items as item (item.title)}
|
{#each items as item (item.title)}
|
||||||
{@const url = item.path(libraryState.activeLibrary?.id)}
|
{@const url = item.path(libraryState.activeLibrary?.id)}
|
||||||
{@const isActive = page.url.pathname === url}
|
{@const isActive = item.routeId !== null && page.route.id === item.routeId}
|
||||||
{#if 'shelves' in item}
|
{#if 'shelves' in item}
|
||||||
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
|
<Collapsible.Root bind:open={shelvesOpen} class="group/collapsible">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
@@ -78,17 +96,18 @@
|
|||||||
<Sidebar.MenuSubButton>
|
<Sidebar.MenuSubButton>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<a
|
<a
|
||||||
href={`/library/${libraryState.activeLibrary!.id}/view?shelves=${shelf.id}`}
|
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||||
|
libraryId: String(libraryState.activeLibrary!.id)
|
||||||
|
})}?shelves={shelf.id}"
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="scale-90 font-semibold bg-sidebar-primary text-sidebar-primary-foreground mr-1">
|
class="mr-1 scale-90 bg-sidebar-primary font-semibold text-sidebar-primary-foreground"
|
||||||
|
>
|
||||||
{shelf.total}
|
{shelf.total}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span>{shelf.title}</span>
|
<span>{shelf.title}</span>
|
||||||
|
|
||||||
|
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuSubButton>
|
</Sidebar.MenuSubButton>
|
||||||
@@ -101,8 +120,10 @@
|
|||||||
</Collapsible.Root>
|
</Collapsible.Root>
|
||||||
{:else}
|
{:else}
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={isActive} tooltipContent={item.title} class="h-10">
|
<Sidebar.MenuButton {isActive} tooltipContent={item.title} class="h-10">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
|
<!-- item.path() calls resolve(); the rule cannot trace the variable. -->
|
||||||
|
<!-- eslint-disable-next-line svelte/no-navigation-without-resolve -->
|
||||||
<a href={url} {...props}>
|
<a href={url} {...props}>
|
||||||
{#if item.icon}
|
{#if item.icon}
|
||||||
<item.icon class="scale-125" />
|
<item.icon class="scale-125" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import * as Avatar from '$lib/components/ui/avatar/index.js';
|
import * as Avatar from '$lib/components/ui/avatar/index.js';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
@@ -31,13 +32,16 @@
|
|||||||
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar.Root class="size-8 rounded-lg">
|
<Avatar.Root class="size-8 rounded-lg">
|
||||||
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
<Avatar.Fallback
|
||||||
|
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||||
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</Avatar.Fallback>
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{handle}</span>
|
<span class="truncate font-semibold">{handle}</span>
|
||||||
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
|
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<ChevronsUpDownIcon class="ml-auto size-4" />
|
<ChevronsUpDownIcon class="ml-auto size-4" />
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
@@ -53,13 +57,16 @@
|
|||||||
<DropdownMenu.Label class="p-0 font-normal">
|
<DropdownMenu.Label class="p-0 font-normal">
|
||||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar.Root class="size-8 rounded-lg">
|
<Avatar.Root class="size-8 rounded-lg">
|
||||||
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
<Avatar.Fallback
|
||||||
|
class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"
|
||||||
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</Avatar.Fallback>
|
</Avatar.Fallback>
|
||||||
</Avatar.Root>
|
</Avatar.Root>
|
||||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span class="truncate font-semibold">{handle}</span>
|
<span class="truncate font-semibold">{handle}</span>
|
||||||
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
|
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenu.Label>
|
</DropdownMenu.Label>
|
||||||
@@ -67,11 +74,11 @@
|
|||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
|
|
||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.Item onSelect={() => goto('/settings/account')}>
|
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/account'))}>
|
||||||
<SettingsIcon class="size-4" />
|
<SettingsIcon class="size-4" />
|
||||||
Settings
|
Settings
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
<DropdownMenu.Item onSelect={() => goto('/settings/appearance')}>
|
<DropdownMenu.Item onSelect={() => goto(resolve('/settings/appearance'))}>
|
||||||
<PaletteIcon class="size-4" />
|
<PaletteIcon class="size-4" />
|
||||||
Appearance
|
Appearance
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -82,7 +89,7 @@
|
|||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onSelect={async () => {
|
onSelect={async () => {
|
||||||
await logout();
|
await logout();
|
||||||
await goto('/login');
|
await goto(resolve('/login'));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<LogOutIcon class="size-4" />
|
<LogOutIcon class="size-4" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import * as Command from '$lib/components/ui/command/index';
|
import * as Command from '$lib/components/ui/command/index';
|
||||||
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
import * as Kbd from '$lib/components/ui/kbd/index.js';
|
||||||
import * as InputGroup from '$lib/components/ui/input-group/index.js';
|
import * as InputGroup from '$lib/components/ui/input-group/index.js';
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
import { getLibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
import type { PaginatedResponse, Book } from '$lib/schema';
|
import type { PaginatedResponse, Book } from '$lib/schema';
|
||||||
import BookImage from '../view/book-image.svelte';
|
import BookImage from '../view/book-image.svelte';
|
||||||
|
import GeneratedCover from '../view/generated-cover.svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
const libraryState = getLibraryState();
|
const libraryState = getLibraryState();
|
||||||
@@ -97,16 +99,22 @@
|
|||||||
<Command.Item
|
<Command.Item
|
||||||
value={String(book.id)}
|
value={String(book.id)}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
goto(`/book/${book.id}`);
|
goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) }));
|
||||||
open = false;
|
open = false;
|
||||||
}}
|
}}
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
>
|
>
|
||||||
<div class="hover:bg-base-200 flex gap-4 p-3">
|
<div class="hover:bg-base-200 flex gap-4 p-3">
|
||||||
|
{#if book.cover_image}
|
||||||
<BookImage
|
<BookImage
|
||||||
src="/api/{book.cover_image}"
|
src="/api/{book.cover_image}"
|
||||||
class="w-24 rounded object-cover shadow-lg"
|
class="w-24 rounded object-cover shadow-lg"
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
<div class="aspect-9/12 w-24 shrink-0 overflow-hidden rounded shadow-lg">
|
||||||
|
<GeneratedCover {book} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="flex-top flex flex-col">
|
<div class="flex-top flex flex-col">
|
||||||
<span class="font-serif text-lg">{book.title}</span>
|
<span class="font-serif text-lg">{book.title}</span>
|
||||||
<span class=" text-md">{book.subtitle}</span>
|
<span class=" text-md">{book.subtitle}</span>
|
||||||
@@ -115,7 +123,9 @@
|
|||||||
by
|
by
|
||||||
{#each book.authors as author}
|
{#each book.authors as author}
|
||||||
<a
|
<a
|
||||||
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
|
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||||
|
libraryId: String(libraryState.activeLibrary!.id)
|
||||||
|
})}?authors={author.id}"
|
||||||
class="cs-list hover:underline">{author.name}</a
|
class="cs-list hover:underline">{author.name}</a
|
||||||
>  
|
>  
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fly } from 'svelte/transition';
|
||||||
|
import { prefersReducedMotion } from 'svelte/motion';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { ChevronDown, CircleAlert, Copy, RotateCcw, X } from '@lucide/svelte';
|
||||||
|
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||||
|
import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
|
||||||
|
import type { DuplicateBook, DuplicateFile } from '$lib/schema';
|
||||||
|
import { formatFileSize } from '$lib/utils';
|
||||||
|
|
||||||
|
const queue = getUploadQueueState();
|
||||||
|
|
||||||
|
// A clean run clears itself after a few seconds, so it needs to leave rather
|
||||||
|
// than blink out. Nothing to animate for anyone who asked not to see it.
|
||||||
|
const motion = $derived(prefersReducedMotion.current ? 0 : 200);
|
||||||
|
|
||||||
|
const percent = $derived(queue.total === 0 ? 0 : Math.round((queue.settled / queue.total) * 100));
|
||||||
|
|
||||||
|
const heading = $derived.by(() => {
|
||||||
|
const noun = queue.total === 1 ? 'book' : 'books';
|
||||||
|
if (queue.active) return `Adding ${queue.settled} of ${queue.total} ${noun}`;
|
||||||
|
if (queue.failed === 0 && queue.skipped === 0) return `Added ${queue.done} ${noun}`;
|
||||||
|
if (queue.done === 0 && queue.skipped === 0) return `Couldn't add ${queue.failed} ${noun}`;
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (queue.done > 0) parts.push(`Added ${queue.done}`);
|
||||||
|
if (queue.skipped > 0) parts.push(`${queue.skipped} already here`);
|
||||||
|
if (queue.failed > 0) parts.push(`${queue.failed} failed`);
|
||||||
|
return parts.join(', ');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the reader needs to know about files that were not stored.
|
||||||
|
*
|
||||||
|
* One duplicate can name where its bytes already live; several would be a list,
|
||||||
|
* and the row has no room for one, so they collapse to a count.
|
||||||
|
*/
|
||||||
|
function duplicateNote(duplicates: DuplicateFile[]) {
|
||||||
|
if (duplicates.length > 1) return `${duplicates.length} files are already in your library`;
|
||||||
|
|
||||||
|
const [only] = duplicates;
|
||||||
|
// No book to name: it matched another file in this same upload.
|
||||||
|
return only.book_title ? `Already in ${only.book_title}` : 'Already added by this upload';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The book a note can link to, when a single duplicate points at exactly one. */
|
||||||
|
function noteTarget(duplicates: DuplicateFile[]) {
|
||||||
|
return duplicates.length === 1 ? duplicates[0].book_id : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the reader needs to know about a book that went in and may already be here.
|
||||||
|
*
|
||||||
|
* Worded weaker than the file-level "Already in …" on purpose. That one means the
|
||||||
|
* library holds these exact bytes; this one means the metadata agrees, which a
|
||||||
|
* second edition, a translation and a re-scan all do. Nothing was refused.
|
||||||
|
*/
|
||||||
|
function possibleNote(candidates: DuplicateBook[]) {
|
||||||
|
if (candidates.length > 1)
|
||||||
|
return `Might already be in your library, ${candidates.length} times`;
|
||||||
|
|
||||||
|
return `Might already be in your library — ${candidates[0].title}`;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if queue.total > 0}
|
||||||
|
<!--
|
||||||
|
Docked rather than a toast. This runs for minutes and carries a row per
|
||||||
|
book, which a notification surface is not built to hold still for.
|
||||||
|
-->
|
||||||
|
<aside
|
||||||
|
aria-label="Uploads"
|
||||||
|
transition:fly={{ y: 12, duration: motion }}
|
||||||
|
onmouseenter={() => queue.hold()}
|
||||||
|
onmouseleave={() => queue.release()}
|
||||||
|
onfocusin={() => queue.hold()}
|
||||||
|
onfocusout={() => queue.release()}
|
||||||
|
class="fixed right-4 bottom-4 z-50 w-80 max-w-[calc(100vw-2rem)] overflow-hidden rounded-lg border bg-card shadow-lg"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 border-b px-3 py-2">
|
||||||
|
{#if queue.active}
|
||||||
|
<Spinner class="size-4 shrink-0" />
|
||||||
|
{:else if queue.failed > 0}
|
||||||
|
<CircleAlert class="size-4 shrink-0 text-destructive" />
|
||||||
|
{:else if queue.skipped > 0}
|
||||||
|
<!-- Muted, not alarming: nothing went wrong, the books were already here. -->
|
||||||
|
<Copy class="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm font-medium">{heading}</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
class="size-7 shrink-0"
|
||||||
|
onclick={() => (queue.collapsed = !queue.collapsed)}
|
||||||
|
>
|
||||||
|
<ChevronDown class="size-4 transition-transform {queue.collapsed ? '' : 'rotate-180'}" />
|
||||||
|
<span class="sr-only">{queue.collapsed ? 'Show' : 'Hide'} the list</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<!-- Only once nothing is in flight: dismissing mid-run would suggest it
|
||||||
|
stopped the upload, which it does not. -->
|
||||||
|
{#if !queue.active}
|
||||||
|
<Button variant="ghost" size="icon" class="size-7 shrink-0" onclick={() => queue.dismiss()}>
|
||||||
|
<X class="size-4" />
|
||||||
|
<span class="sr-only">Dismiss</span>
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="h-1 bg-muted">
|
||||||
|
<div
|
||||||
|
class="h-full bg-primary transition-[width] duration-300"
|
||||||
|
style="width: {percent}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !queue.collapsed}
|
||||||
|
<ul class="flex max-h-64 flex-col divide-y overflow-y-auto">
|
||||||
|
{#each queue.jobs as job (job.id)}
|
||||||
|
{@const duplicates = job.duplicates ?? []}
|
||||||
|
{@const target = noteTarget(duplicates)}
|
||||||
|
{@const possible = job.possibleDuplicates ?? []}
|
||||||
|
<li class="flex min-w-0 items-center gap-2 px-3 py-2">
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-sm" title={job.label}>{job.label}</span>
|
||||||
|
|
||||||
|
{#if job.error}
|
||||||
|
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
{job.error}
|
||||||
|
</span>
|
||||||
|
{:else if duplicates.length > 0}
|
||||||
|
<!--
|
||||||
|
Reported, with no offer to override. Storing the same bytes twice
|
||||||
|
splits reading progress and shelves across two records that can
|
||||||
|
never converge, and there is no version of that the reader wants.
|
||||||
|
`allow_duplicates` stays on the API for when the match is wrong.
|
||||||
|
-->
|
||||||
|
<span class="block min-w-0 text-[10px] text-muted-foreground">
|
||||||
|
{#if target}
|
||||||
|
<a
|
||||||
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(target) })}
|
||||||
|
class="truncate underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{duplicateNote(duplicates)}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="truncate">{duplicateNote(duplicates)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{:else if possible.length > 0}
|
||||||
|
<!--
|
||||||
|
A guess, not a fact, so it says so and stops there: the book was
|
||||||
|
stored, and the library's duplicates screen is where a reader
|
||||||
|
decides what to do about it.
|
||||||
|
-->
|
||||||
|
<span class="block min-w-0 text-[10px] text-muted-foreground">
|
||||||
|
{#if possible.length === 1}
|
||||||
|
<a
|
||||||
|
href={resolve('/(root)/(library)/book/[bookId]', {
|
||||||
|
bookId: String(possible[0].book_id)
|
||||||
|
})}
|
||||||
|
class="truncate underline underline-offset-2"
|
||||||
|
>
|
||||||
|
{possibleNote(possible)}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="truncate">{possibleNote(possible)}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||||
|
{formatFileSize(job.size)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span
|
||||||
|
class="shrink-0 font-mono text-[10px] tracking-wider uppercase
|
||||||
|
{job.status === 'done' ? 'text-primary' : ''}
|
||||||
|
{job.status === 'failed' ? 'text-destructive' : ''}
|
||||||
|
{job.status !== 'done' && job.status !== 'failed' ? 'text-muted-foreground' : ''}"
|
||||||
|
>
|
||||||
|
{#if job.status === 'uploading'}
|
||||||
|
Adding
|
||||||
|
{:else if job.status === 'done'}
|
||||||
|
Done
|
||||||
|
{:else if job.status === 'skipped'}
|
||||||
|
Skipped
|
||||||
|
{:else if job.status === 'failed'}
|
||||||
|
Failed
|
||||||
|
{:else}
|
||||||
|
Queued
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if !queue.active && queue.failed > 0}
|
||||||
|
<div class="border-t p-2">
|
||||||
|
<Button variant="outline" size="sm" class="w-full" onclick={() => queue.retryFailed()}>
|
||||||
|
<RotateCcw class="size-4" />
|
||||||
|
Retry {queue.failed} failed
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</aside>
|
||||||
|
{/if}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { onMount, untrack } from 'svelte';
|
import { onMount, untrack } from 'svelte';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -25,14 +26,14 @@
|
|||||||
import ReaderToc from './reader-toc.svelte';
|
import ReaderToc from './reader-toc.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
bookUrl,
|
fileUrl,
|
||||||
bookId,
|
bookId,
|
||||||
filename,
|
filename,
|
||||||
title = '',
|
title = '',
|
||||||
initialProgress = 0,
|
initialProgress = 0,
|
||||||
initialEpubLoc = null
|
initialEpubLoc = null
|
||||||
}: {
|
}: {
|
||||||
bookUrl: string;
|
fileUrl: string;
|
||||||
bookId: string | number;
|
bookId: string | number;
|
||||||
filename: string;
|
filename: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -78,7 +79,7 @@
|
|||||||
file = undefined;
|
file = undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(bookUrl);
|
const response = await fetch(fileUrl);
|
||||||
if (!response.ok) throw new Error(`The server returned ${response.status} for this file.`);
|
if (!response.ok) throw new Error(`The server returned ${response.status} for this file.`);
|
||||||
|
|
||||||
file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
|
file = new File([await response.blob()], filename, { type: 'application/epub+zip' });
|
||||||
@@ -135,7 +136,7 @@
|
|||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="/book/{bookId}"
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
|
||||||
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
|
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
|
||||||
{title}
|
{title}
|
||||||
>
|
>
|
||||||
@@ -178,7 +179,10 @@
|
|||||||
<RotateCcw class="size-4" />
|
<RotateCcw class="size-4" />
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
<a href="/book/{bookId}" class={buttonVariants({ variant: 'outline' })}>
|
<a
|
||||||
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
|
||||||
|
class={buttonVariants({ variant: 'outline' })}
|
||||||
|
>
|
||||||
Back to book
|
Back to book
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -104,7 +104,46 @@
|
|||||||
doc.addEventListener('keydown', onKeydown);
|
doc.addEventListener('keydown', onKeydown);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replays a shortcut pressed inside the book on the host window.
|
||||||
|
*
|
||||||
|
* Key events do not cross document boundaries, so a shortcut pressed while
|
||||||
|
* the book has focus never reaches the app's handlers — which are bound on
|
||||||
|
* the window, as the sidebar's ctrl+B is — and the browser's own default runs
|
||||||
|
* instead. That is why ctrl+B opened bookmarks in the book but toggled the
|
||||||
|
* chapter sidebar everywhere else.
|
||||||
|
*
|
||||||
|
* The original is only cancelled if an app handler actually claimed the
|
||||||
|
* replay, so combinations the app does not use — ctrl+C above all — keep
|
||||||
|
* their normal browser behaviour.
|
||||||
|
*/
|
||||||
|
function forwardShortcut(event: KeyboardEvent) {
|
||||||
|
const source = (event.target as Node | null)?.ownerDocument;
|
||||||
|
// isTrusted rules out the replay itself, which would otherwise recurse.
|
||||||
|
if (!event.isTrusted || !source || source === document) return;
|
||||||
|
|
||||||
|
const claimed = !window.dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', {
|
||||||
|
key: event.key,
|
||||||
|
code: event.code,
|
||||||
|
ctrlKey: event.ctrlKey,
|
||||||
|
metaKey: event.metaKey,
|
||||||
|
shiftKey: event.shiftKey,
|
||||||
|
altKey: event.altKey,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (claimed) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.ctrlKey || event.metaKey || event.altKey) {
|
||||||
|
forwardShortcut(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.key) {
|
switch (event.key) {
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
case 'PageUp':
|
case 'PageUp':
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||||
import { BookOpenCheck, Download, Trash2, SquareCheckBig, X, Album, PlusIcon } from '@lucide/svelte';
|
import {
|
||||||
|
BookOpenCheck,
|
||||||
|
Download,
|
||||||
|
GitMerge,
|
||||||
|
Trash2,
|
||||||
|
SquareCheckBig,
|
||||||
|
SlidersHorizontal,
|
||||||
|
X,
|
||||||
|
Album,
|
||||||
|
PlusIcon
|
||||||
|
} from '@lucide/svelte';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||||
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||||
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
|
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
|
||||||
import { getLibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||||
import { invalidate } from '$app/navigation';
|
|
||||||
import ShelfCreateDialog from '../forms/shelf-create-dialog.svelte';
|
import ShelfCreateDialog from '../forms/shelf-create-dialog.svelte';
|
||||||
|
import MergeBooks from '../forms/merge-books/merge-books.svelte';
|
||||||
|
import { mergeInto } from '../forms/merge-books/merge';
|
||||||
|
import type { Book } from '$lib/schema';
|
||||||
|
|
||||||
const libraryState = getLibraryState();
|
const libraryState = getLibraryState();
|
||||||
const bookshelfState = getBookshelfState();
|
const bookshelfState = getBookshelfState();
|
||||||
@@ -17,10 +29,50 @@
|
|||||||
const bookOps = getBookOperationsState();
|
const bookOps = getBookOperationsState();
|
||||||
const collectionState = getBookCollectionState();
|
const collectionState = getBookCollectionState();
|
||||||
|
|
||||||
let selectedBooks = $derived(selectionState.getSelectedBooks())
|
let selectedBooks = $derived(selectionState.getSelectedBooks());
|
||||||
|
|
||||||
let createShelfDialogOpen = $state(false)
|
let createShelfDialogOpen = $state(false);
|
||||||
|
|
||||||
|
// The books the merge dialog opened on. Snapshotted rather than read live from
|
||||||
|
// the selection, so clearing the selection on success cannot empty the dialog
|
||||||
|
// underneath itself.
|
||||||
|
let mergingBooks = $state<Book[] | null>(null);
|
||||||
|
|
||||||
|
// A quick merge has no dialog to disable, so the flag is what stops a slow
|
||||||
|
// round trip being started twice.
|
||||||
|
let quickMerging = $state(false);
|
||||||
|
|
||||||
|
/** Put the library back in step with a merge that went through. */
|
||||||
|
function afterMerge(folded: Book[]) {
|
||||||
|
// Only the folded records are gone; the survivor is still in the library.
|
||||||
|
libraryState.activeLibrary!.total! -= folded.length;
|
||||||
|
bookshelfState.deletedBooks(folded);
|
||||||
|
selectionState.deselectAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge without opening the workbench, keeping the book selected first.
|
||||||
|
*
|
||||||
|
* Selection is held in insertion order, so the first record is the one the
|
||||||
|
* reader started from — the one they were looking at when they decided the
|
||||||
|
* rest were copies of it.
|
||||||
|
*/
|
||||||
|
async function quickMerge() {
|
||||||
|
if (quickMerging) return;
|
||||||
|
|
||||||
|
// Read once: the merge clears the selection, and the bookkeeping afterwards
|
||||||
|
// still needs the records that went away.
|
||||||
|
const [survivor, ...folded] = selectedBooks;
|
||||||
|
if (!survivor || folded.length === 0) return;
|
||||||
|
|
||||||
|
quickMerging = true;
|
||||||
|
|
||||||
|
const merged = await mergeInto(libraryState.activeLibrary!.id, survivor, folded);
|
||||||
|
|
||||||
|
quickMerging = false;
|
||||||
|
|
||||||
|
if (merged) afterMerge(folded);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Mark selected as finished button -->
|
<!-- Mark selected as finished button -->
|
||||||
@@ -96,8 +148,9 @@
|
|||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onclick={() => createShelfDialogOpen = true}
|
onclick={() => (createShelfDialogOpen = true)}
|
||||||
class="text-muted-foreground ">
|
class="text-muted-foreground "
|
||||||
|
>
|
||||||
<PlusIcon class="size-4" />
|
<PlusIcon class="size-4" />
|
||||||
New Shelf
|
New Shelf
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -128,6 +181,44 @@
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
|
|
||||||
|
<!-- Merge selected button. Two books is the smallest thing a merge can mean, so
|
||||||
|
it appears only once there are two. -->
|
||||||
|
{#if selectedBooks.length > 1}
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||||
|
<Tooltip.Trigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<!-- A choice rather than a jump straight into the workbench: two
|
||||||
|
copies of one book have nothing to resolve, and the dialog is a
|
||||||
|
step in the way of saying so. -->
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
{...props}
|
||||||
|
disabled={quickMerging}
|
||||||
|
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
|
||||||
|
>
|
||||||
|
<GitMerge />
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
<DropdownMenu.Item onclick={quickMerge}>
|
||||||
|
<GitMerge class="size-4 shrink-0" />
|
||||||
|
Merge into first selected
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onclick={() => (mergingBooks = selectedBooks)}>
|
||||||
|
<SlidersHorizontal class="size-4 shrink-0" />
|
||||||
|
Manual merge…
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
{/snippet}
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content>
|
||||||
|
<p>Merge {selectedBooks.length} books</p>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Delete selected button -->
|
<!-- Delete selected button -->
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||||
@@ -136,8 +227,8 @@
|
|||||||
bookOps.deleteDialogTitle = `Delete ${selectionState.getSelectedIds().length} books?`;
|
bookOps.deleteDialogTitle = `Delete ${selectionState.getSelectedIds().length} books?`;
|
||||||
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
||||||
await bookOps.deleteBooks(selectionState.getSelectedIds(), deleteFiles);
|
await bookOps.deleteBooks(selectionState.getSelectedIds(), deleteFiles);
|
||||||
libraryState.activeLibrary!.total! -= selectedBooks.length
|
libraryState.activeLibrary!.total! -= selectedBooks.length;
|
||||||
bookshelfState.deletedBooks(selectedBooks)
|
bookshelfState.deletedBooks(selectedBooks);
|
||||||
selectionState.deselectAll();
|
selectionState.deselectAll();
|
||||||
};
|
};
|
||||||
bookOps.deleteDialogOpen = true;
|
bookOps.deleteDialogOpen = true;
|
||||||
@@ -186,14 +277,43 @@
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
|
|
||||||
|
|
||||||
<ShelfCreateDialog
|
<ShelfCreateDialog
|
||||||
bind:open={createShelfDialogOpen}
|
bind:open={createShelfDialogOpen}
|
||||||
onSubmit={async (name: string) => {
|
onSubmit={async (name: string) => {
|
||||||
|
const bookshelf = await bookshelfState.addBookshelf(
|
||||||
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, selectionState.getSelectedIds())
|
name,
|
||||||
selectedBooks.forEach(book => book.lists.push(bookshelf))
|
libraryState.activeLibrary!.id,
|
||||||
selectionState.deselectAll()
|
selectionState.getSelectedIds()
|
||||||
createShelfDialogOpen = false
|
);
|
||||||
|
selectedBooks.forEach((book) => book.lists.push(bookshelf));
|
||||||
|
selectionState.deselectAll();
|
||||||
|
createShelfDialogOpen = false;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Keyed on the selection so opening merge on a different pair starts from those
|
||||||
|
records rather than the previous dialog's draft.
|
||||||
|
-->
|
||||||
|
{#if mergingBooks}
|
||||||
|
{#key mergingBooks.map((book) => book.id).join()}
|
||||||
|
<MergeBooks
|
||||||
|
books={mergingBooks}
|
||||||
|
libraryId={libraryState.activeLibrary!.id}
|
||||||
|
bind:open={
|
||||||
|
() => mergingBooks !== null,
|
||||||
|
(value) => {
|
||||||
|
// Bound, not passed as a bare `true`: the dialog closes itself on an
|
||||||
|
// outside click or Escape, and if that never reaches `mergingBooks`
|
||||||
|
// the snapshot stays set — the menu item then re-assigns the same
|
||||||
|
// selection, nothing changes, and the dialog never reopens.
|
||||||
|
if (!value) mergingBooks = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onmerged={(_survivor, folded) => {
|
||||||
|
afterMerge(folded);
|
||||||
|
mergingBooks = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -29,19 +29,31 @@
|
|||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!sentinel || !bookCollection.moreBooks) return;
|
if (!sentinel || !bookCollection.moreBooks) return;
|
||||||
|
const target = sentinel;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
async (entries) => {
|
async (entries) => {
|
||||||
if (entries[0].isIntersecting && !bookCollection.loading) {
|
if (!entries[0].isIntersecting) return;
|
||||||
|
|
||||||
await bookCollection.loadMoreBooks();
|
await bookCollection.loadMoreBooks();
|
||||||
|
|
||||||
|
// A page that does not push the sentinel back out of the prefetch zone
|
||||||
|
// produces no further intersection change, so the observer would sit
|
||||||
|
// idle until the next scroll. Re-observing asks for a fresh reading.
|
||||||
|
if (bookCollection.moreBooks) {
|
||||||
|
observer.unobserve(target);
|
||||||
|
observer.observe(target);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
root: scrollContainer,
|
root: scrollContainer,
|
||||||
rootMargin: '0px 0px 200px 0px'
|
// One viewport of lead time: the next page is requested a full screen
|
||||||
|
// before the reader reaches the end of the current one.
|
||||||
|
rootMargin: '0px 0px 100% 0px'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
observer.observe(sentinel);
|
observer.observe(target);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
@@ -110,7 +122,7 @@
|
|||||||
|
|
||||||
{#if bookCollection.moreBooks}
|
{#if bookCollection.moreBooks}
|
||||||
<div bind:this={sentinel}>
|
<div bind:this={sentinel}>
|
||||||
{#if bookCollection.loading}
|
{#if bookCollection.loadingMore}
|
||||||
<Spinner />
|
<Spinner />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Book } from '$lib/schema';
|
import type { Book } from '$lib/schema';
|
||||||
|
import GeneratedCover from './generated-cover.svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
book,
|
book,
|
||||||
@@ -10,12 +11,6 @@
|
|||||||
let failed = $state(false);
|
let failed = $state(false);
|
||||||
|
|
||||||
const src = $derived(book.cover_image ? `/api/${book.cover_image}` : null);
|
const src = $derived(book.cover_image ? `/api/${book.cover_image}` : null);
|
||||||
|
|
||||||
// A stable hue per title, so a book with no artwork still gets its own
|
|
||||||
// colour rather than every placeholder looking identical.
|
|
||||||
const hue = $derived([...book.title].reduce((acc, ch) => (acc * 31 + ch.charCodeAt(0)) % 360, 7));
|
|
||||||
|
|
||||||
const authors = $derived(book.authors.map((a) => a.name).join(', '));
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -65,20 +60,10 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- No cover on record, or the file is missing. Draw one. -->
|
<!-- No cover on record, or the file is missing. Draw one. -->
|
||||||
<span
|
<span
|
||||||
class="flex h-full flex-col justify-between overflow-hidden rounded-sm p-3 text-left shadow-lg"
|
class="h-full overflow-hidden rounded-sm shadow-lg"
|
||||||
style="width: {Math.round(height * 0.66)}px; background: linear-gradient(152deg, hsl({hue}
|
style="width: {Math.round(height * 0.66)}px;"
|
||||||
30% 34%), hsl({hue} 38% 18%));"
|
|
||||||
>
|
>
|
||||||
<span
|
<GeneratedCover {book} />
|
||||||
class="line-clamp-4 font-serif text-xs leading-tight"
|
|
||||||
style="color: hsl({hue} 38% 95%);">{book.title}</span
|
|
||||||
>
|
|
||||||
{#if authors}
|
|
||||||
<span
|
|
||||||
class="line-clamp-2 font-mono text-[8px] tracking-wider uppercase"
|
|
||||||
style="color: hsl({hue} 24% 78%);">{authors}</span
|
|
||||||
>
|
|
||||||
{/if}
|
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import BookCover from './book-cover.svelte';
|
import BookCover from './book-cover.svelte';
|
||||||
import BookActionsMenu from './book-actions-menu.svelte';
|
import BookActionsMenu from './book-actions-menu.svelte';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||||
@@ -120,7 +121,7 @@
|
|||||||
? 'cursor-pointer'
|
? 'cursor-pointer'
|
||||||
: ''}"
|
: ''}"
|
||||||
>
|
>
|
||||||
<a href="/book/{book.id}" class="shrink-0">
|
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })} class="shrink-0">
|
||||||
<BookCover {book} height={110} />
|
<BookCover {book} height={110} />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -128,7 +129,7 @@
|
|||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<a
|
<a
|
||||||
href="/book/{book.id}"
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||||
class="line-clamp-2 font-serif text-sm leading-snug hover:underline"
|
class="line-clamp-2 font-serif text-sm leading-snug hover:underline"
|
||||||
>
|
>
|
||||||
{book.title}
|
{book.title}
|
||||||
@@ -166,7 +167,9 @@
|
|||||||
{#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)}
|
{#each book.tags.slice(0, TAG_LIMIT) as tag (tag.id)}
|
||||||
<a
|
<a
|
||||||
data-row-control
|
data-row-control
|
||||||
href="/library/{libraryState.activeLibrary?.id}/view?tags={tag.id}"
|
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||||
|
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
||||||
|
})}?tags={tag.id}"
|
||||||
class={badgeVariants({ variant: 'secondary' })}>{tag.name}</a
|
class={badgeVariants({ variant: 'secondary' })}>{tag.name}</a
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import * as Table from '$lib/components/ui/table/index';
|
import * as Table from '$lib/components/ui/table/index';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||||
@@ -52,7 +53,9 @@
|
|||||||
|
|
||||||
const visible = $derived(columns.filter((c) => c.on));
|
const visible = $derived(columns.filter((c) => c.on));
|
||||||
|
|
||||||
const allSelected = $derived(books.length > 0 && books.every((b) => selectionState.isSelected(b.id)));
|
const allSelected = $derived(
|
||||||
|
books.length > 0 && books.every((b) => selectionState.isSelected(b.id))
|
||||||
|
);
|
||||||
|
|
||||||
/** What a record is lacking — the reason this view exists. */
|
/** What a record is lacking — the reason this view exists. */
|
||||||
function missing(book: Book) {
|
function missing(book: Book) {
|
||||||
@@ -208,19 +211,26 @@
|
|||||||
/>
|
/>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<a href="/book/{book.id}"><BookCover {book} height={36} /></a>
|
<a href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||||
|
><BookCover {book} height={36} /></a
|
||||||
|
>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{#each visible as column (column.key)}
|
{#each visible as column (column.key)}
|
||||||
{#if column.key === 'title'}
|
{#if column.key === 'title'}
|
||||||
<Table.Cell class="max-w-[280px] truncate font-serif">
|
<Table.Cell class="max-w-[280px] truncate font-serif">
|
||||||
<a href="/book/{book.id}" class="hover:underline">{book.title}</a>
|
<a
|
||||||
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||||
|
class="hover:underline">{book.title}</a
|
||||||
|
>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{:else if column.key === 'authors'}
|
{:else if column.key === 'authors'}
|
||||||
<Table.Cell class="max-w-[180px] truncate">
|
<Table.Cell class="max-w-[180px] truncate">
|
||||||
{#each book.authors as author (author.id)}
|
{#each book.authors as author (author.id)}
|
||||||
<a
|
<a
|
||||||
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
|
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||||
|
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
||||||
|
})}?authors={author.id}"
|
||||||
class="cs-list hover:underline">{author.name}</a
|
class="cs-list hover:underline">{author.name}</a
|
||||||
>  
|
>  
|
||||||
{/each}
|
{/each}
|
||||||
@@ -248,7 +258,7 @@
|
|||||||
{formatFileSize(totalSize(book))}
|
{formatFileSize(totalSize(book))}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{:else if column.key === 'added'}
|
{:else if column.key === 'added'}
|
||||||
<Table.Cell class="font-mono text-xs tabular-nums text-muted-foreground">
|
<Table.Cell class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
{addedOn(book)}
|
{addedOn(book)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
{:else if column.key === 'progress'}
|
{:else if column.key === 'progress'}
|
||||||
@@ -263,7 +273,7 @@
|
|||||||
style="width: {Math.round(book.progress.percentage * 100)}%;"
|
style="width: {Math.round(book.progress.percentage * 100)}%;"
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
<span class="font-mono text-xs tabular-nums text-muted-foreground">
|
<span class="font-mono text-xs text-muted-foreground tabular-nums">
|
||||||
{Math.round(book.progress.percentage * 100)}%
|
{Math.round(book.progress.percentage * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import BookImage from './book-image.svelte';
|
import BookImage from './book-image.svelte';
|
||||||
|
import GeneratedCover from './generated-cover.svelte';
|
||||||
import { Progress } from '$lib/components/ui/progress/index';
|
import { Progress } from '$lib/components/ui/progress/index';
|
||||||
import { getLibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||||
import type { Book } from '$lib/schema';
|
import type { Book } from '$lib/schema';
|
||||||
|
|
||||||
let { book, class: className = '', ...rest }: { book: Book, class?: string} = $props();
|
let { book, class: className = '', ...rest }: { book: Book; class?: string } = $props();
|
||||||
|
|
||||||
const selectionState = getBookSelectionState();
|
const selectionState = getBookSelectionState();
|
||||||
const libraryState = getLibraryState();
|
const libraryState = getLibraryState();
|
||||||
@@ -24,12 +26,19 @@
|
|||||||
<div class="flex w-full flex-shrink-0 flex-col gap-1 {className}">
|
<div class="flex w-full flex-shrink-0 flex-col gap-1 {className}">
|
||||||
<!-- Book Cover -->
|
<!-- Book Cover -->
|
||||||
<a
|
<a
|
||||||
href="/book/{book.id}"
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||||
class="group relative aspect-9/12 w-full overflow-hidden rounded-sm shadow-lg drop-shadow-lg transition-all duration-200 {selected
|
class="group relative aspect-9/12 w-full overflow-hidden rounded-sm shadow-lg drop-shadow-lg transition-all duration-200 {selected
|
||||||
? 'ring-2 ring-star'
|
? 'ring-2 ring-star'
|
||||||
: ''}"
|
: ''}"
|
||||||
onclick={handleClick}
|
onclick={handleClick}
|
||||||
>
|
>
|
||||||
|
<!--
|
||||||
|
Checked rather than left to the image's onerror: with no cover_image the
|
||||||
|
src became "/api/undefined", 404'd, and fell back to the generic
|
||||||
|
default_cover.jpg — which is what made every coverless book look alike in
|
||||||
|
the grid.
|
||||||
|
-->
|
||||||
|
{#if book.cover_image}
|
||||||
<BookImage
|
<BookImage
|
||||||
src="/api/{book.cover_image}"
|
src="/api/{book.cover_image}"
|
||||||
class="h-full w-full rounded-sm object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
|
class="h-full w-full rounded-sm object-cover transition-all duration-200 group-hover:brightness-50 {selected ||
|
||||||
@@ -37,6 +46,16 @@
|
|||||||
? 'brightness-50'
|
? 'brightness-50'
|
||||||
: ''}"
|
: ''}"
|
||||||
/>
|
/>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="h-full w-full transition-all duration-200 group-hover:brightness-50 {selected ||
|
||||||
|
darkened
|
||||||
|
? 'brightness-50'
|
||||||
|
: ''}"
|
||||||
|
>
|
||||||
|
<GeneratedCover {book} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Inside the anchor, which already clips to rounded-sm, so the bar
|
<!-- Inside the anchor, which already clips to rounded-sm, so the bar
|
||||||
follows the cover's corners instead of floating below it -->
|
follows the cover's corners instead of floating below it -->
|
||||||
@@ -51,7 +70,9 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Book Title -->
|
<!-- Book Title -->
|
||||||
<a href="/book/{book.id}" class="text-base-content mt-1 line-clamp-2 w-full font-serif text-sm hover:underline"
|
<a
|
||||||
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(book.id) })}
|
||||||
|
class="text-base-content mt-1 line-clamp-2 w-full font-serif text-sm hover:underline"
|
||||||
>{book.title}</a
|
>{book.title}</a
|
||||||
>
|
>
|
||||||
|
|
||||||
@@ -59,7 +80,9 @@
|
|||||||
<p class="line-clamp-1 w-full text-xs text-muted-foreground">
|
<p class="line-clamp-1 w-full text-xs text-muted-foreground">
|
||||||
{#each book.authors as author}
|
{#each book.authors as author}
|
||||||
<a
|
<a
|
||||||
href="/library/{libraryState.activeLibrary?.id}/view?authors={author.id}"
|
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||||
|
libraryId: String(libraryState.activeLibrary?.id ?? '')
|
||||||
|
})}?authors={author.id}"
|
||||||
class="cs-list hover:underline">{author.name}</a
|
class="cs-list hover:underline">{author.name}</a
|
||||||
>  
|
>  
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Typed by what is drawn rather than by `Book`: the duplicates review holds
|
||||||
|
// candidates that carry a title and author names without a whole record, and a
|
||||||
|
// `Book` satisfies this shape anyway.
|
||||||
|
let {
|
||||||
|
book,
|
||||||
|
class: className = ''
|
||||||
|
}: {
|
||||||
|
book: { title: string; authors?: { name: string }[] | null };
|
||||||
|
class?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bookcloth tones rather than a point on the hue wheel.
|
||||||
|
*
|
||||||
|
* The old placeholder derived a hue from the title with `hash % 360`, which
|
||||||
|
* eventually lands on acid green. Hashing into a fixed list keeps every
|
||||||
|
* generated cover looking like it came from the same library.
|
||||||
|
*/
|
||||||
|
const CLOTH = [
|
||||||
|
'#1f5f5b', // teal, the app's own accent
|
||||||
|
'#7a2e2a', // oxblood
|
||||||
|
'#2f4858', // slate
|
||||||
|
'#3c5148', // forest
|
||||||
|
'#6b4a6e', // plum
|
||||||
|
'#8a6a1f', // brass
|
||||||
|
'#2b3038', // graphite
|
||||||
|
'#5a4632' // tobacco
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Weaves, as CSS gradients — an SVG <pattern> would need a unique id per cover. */
|
||||||
|
const WEAVE = [
|
||||||
|
// twill
|
||||||
|
`repeating-linear-gradient(45deg, transparent 0 4px, currentColor 4px 5px)`,
|
||||||
|
// horizontal ribbing
|
||||||
|
`repeating-linear-gradient(0deg, transparent 0 5px, currentColor 5px 6px)`,
|
||||||
|
// crosshatch
|
||||||
|
`repeating-linear-gradient(45deg, transparent 0 6px, currentColor 6px 7px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent 0 6px, currentColor 6px 7px)`,
|
||||||
|
// vertical laid lines
|
||||||
|
`repeating-linear-gradient(90deg, transparent 0 5px, currentColor 5px 6px)`
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Stable per title, so a book always looks the same. */
|
||||||
|
function hash(value: string) {
|
||||||
|
let out = 7;
|
||||||
|
for (const char of value) out = (out * 31 + char.charCodeAt(0)) % 100_000;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seed = $derived(hash(book.title || 'Untitled'));
|
||||||
|
const cloth = $derived(CLOTH[seed % CLOTH.length]);
|
||||||
|
const weave = $derived(WEAVE[(seed >> 3) % WEAVE.length]);
|
||||||
|
|
||||||
|
const authors = $derived(book.authors?.map((author) => author.name).join(', ') ?? '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stepped by length rather than scaled continuously: a long title shrunk to
|
||||||
|
* fit would end up smaller than the author line, which reads as a mistake.
|
||||||
|
* Units are cqw so the whole thing works at any size the caller asks for.
|
||||||
|
*/
|
||||||
|
const titleSize = $derived.by(() => {
|
||||||
|
const length = (book.title ?? '').length;
|
||||||
|
if (length <= 14) return 17;
|
||||||
|
if (length <= 28) return 13;
|
||||||
|
if (length <= 46) return 10;
|
||||||
|
return 8.5;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Drawn, not stored. It costs nothing, follows the title when it is edited, and
|
||||||
|
is replaced the moment a real cover is uploaded. Colours are fixed rather than
|
||||||
|
themed: this is an object on a shelf beside real artwork, which does not
|
||||||
|
change with the theme either.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="generated-cover {className}"
|
||||||
|
style="--cloth: {cloth};"
|
||||||
|
role="img"
|
||||||
|
aria-label="Generated cover for {book.title}"
|
||||||
|
>
|
||||||
|
<div class="weave" style="background-image: {weave};"></div>
|
||||||
|
|
||||||
|
<div class="type">
|
||||||
|
<span class="title" style="font-size: {titleSize}cqw;">{book.title}</span>
|
||||||
|
{#if authors}
|
||||||
|
<span class="author">{authors}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.generated-cover {
|
||||||
|
container-type: inline-size;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background: var(--cloth);
|
||||||
|
color: #f4f1ec;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* currentColor drives the gradient, so one rule covers every weave. */
|
||||||
|
.weave {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
opacity: 0.14;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A little depth so it does not read as a flat swatch next to a photograph. */
|
||||||
|
.generated-cover::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(150deg, rgb(255 255 255 / 0.1), rgb(0 0 0 / 0.28));
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 6cqw;
|
||||||
|
padding: 10cqw 9cqw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: var(--app-font-serif, Georgia, serif);
|
||||||
|
line-height: 1.04;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-wrap: balance;
|
||||||
|
/* Clip rather than spill: an overflowing title looks broken, a clipped one
|
||||||
|
just looks like a long title. */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 6;
|
||||||
|
line-clamp: 6;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-family: var(--app-font-mono, ui-monospace, monospace);
|
||||||
|
font-size: 4.4cqw;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.75;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Below about a centimetre the author is unreadable, and the padding is
|
||||||
|
eating the title. Show the title alone. */
|
||||||
|
@container (max-width: 72px) {
|
||||||
|
.author {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type {
|
||||||
|
padding: 8cqw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -6,6 +6,49 @@ export type Book = components['schemas']['BookRead'];
|
|||||||
export type BookFile = components['schemas']['FileMetadataRead'];
|
export type BookFile = components['schemas']['FileMetadataRead'];
|
||||||
export type BookProgress = components['schemas']['BookProgressRead'];
|
export type BookProgress = components['schemas']['BookProgressRead'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file the library already held, so the upload did not store it again.
|
||||||
|
*
|
||||||
|
* `book_id` is null when the match was another file in the same upload — there is
|
||||||
|
* no book to point at yet.
|
||||||
|
*/
|
||||||
|
export type DuplicateFile = components['schemas']['DuplicateFileRead'];
|
||||||
|
export type BooksUploadResult = components['schemas']['BooksUploadResult'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A stored book that may be the same book as another one.
|
||||||
|
*
|
||||||
|
* Weaker than `DuplicateFile`, and deliberately so: that one means the library holds
|
||||||
|
* these exact bytes, this one means the metadata agrees. A second edition and a
|
||||||
|
* translation both look like this, so nothing is ever refused on the strength of it.
|
||||||
|
*/
|
||||||
|
export type DuplicateBook = components['schemas']['DuplicateBookRead'];
|
||||||
|
|
||||||
|
/** A book that was imported, together with what it might be a second copy of. */
|
||||||
|
export type PossibleDuplicate = components['schemas']['PossibleDuplicateRead'];
|
||||||
|
|
||||||
|
/** Books the library holds that all look like copies of one book. */
|
||||||
|
export type DuplicateBookGroup = components['schemas']['DuplicateBookGroupRead'];
|
||||||
|
|
||||||
|
/** The metadata a reader resolved while merging, or while reviewing a provider. */
|
||||||
|
export type BookMetadataUpdate = components['schemas']['BookMetadataUpdate'];
|
||||||
|
|
||||||
|
/** Mirrors BookMerge in backend/src/chitai/schemas/book.py */
|
||||||
|
export const bookMergeSchema = z.object({
|
||||||
|
library_id: z.coerce.number(),
|
||||||
|
survivor_id: z.coerce.number(),
|
||||||
|
merged_ids: z.array(z.coerce.number()).min(1, 'Pick at least one book to fold in'),
|
||||||
|
// Passed through untouched — the backend validates it as BookMetadataUpdate, and
|
||||||
|
// duplicating that shape here would be two places to keep in step for no gain.
|
||||||
|
metadata: z.record(z.string(), z.unknown()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Mirrors DuplicateDismissal in backend/src/chitai/schemas/book.py */
|
||||||
|
export const duplicateDismissalSchema = z.object({
|
||||||
|
book_a_id: z.coerce.number(),
|
||||||
|
book_b_id: z.coerce.number()
|
||||||
|
});
|
||||||
|
|
||||||
export const bookQuerySchema = commonQuerySchema.extend({
|
export const bookQuerySchema = commonQuerySchema.extend({
|
||||||
libraries: stringArrayCoerce,
|
libraries: stringArrayCoerce,
|
||||||
authors: stringArrayCoerce,
|
authors: stringArrayCoerce,
|
||||||
@@ -97,4 +140,6 @@ export type BookQuery = z.infer<typeof bookQuerySchema>;
|
|||||||
export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
|
export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
|
||||||
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
|
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
|
||||||
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
|
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
|
||||||
|
export type DuplicateDismissal = z.infer<typeof duplicateDismissalSchema>;
|
||||||
|
export type BookMerge = z.infer<typeof bookMergeSchema>;
|
||||||
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
|
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
|
||||||
|
|||||||
@@ -19,3 +19,6 @@ export const libraryCreateSchema = z.object({
|
|||||||
|
|
||||||
export type LibraryQuerySchema = typeof libraryQuerySchema;
|
export type LibraryQuerySchema = typeof libraryQuerySchema;
|
||||||
export type LibraryCreateSchema = typeof libraryCreateSchema;
|
export type LibraryCreateSchema = typeof libraryCreateSchema;
|
||||||
|
|
||||||
|
export type CalibreImport = components['schemas']['CalibreImportRead'];
|
||||||
|
export type ImportFailure = components['schemas']['ImportFailureRead'];
|
||||||
|
|||||||
+500
-12
@@ -22,6 +22,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/books/duplicate-files": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** CheckDuplicateFiles */
|
||||||
|
post: operations["BooksDuplicateFilesCheckDuplicateFiles"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/books": {
|
"/books": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -58,6 +75,24 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/books/duplicate-books/dismissals": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** DismissDuplicateBooks */
|
||||||
|
post: operations["BooksDuplicateBooksDismissalsDismissDuplicateBooks"];
|
||||||
|
/** RestoreDuplicateBooks */
|
||||||
|
delete: operations["BooksDuplicateBooksDismissalsRestoreDuplicateBooks"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/books/{book_id}": {
|
"/books/{book_id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -110,6 +145,40 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/books/duplicate-books": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** ListDuplicateBooks */
|
||||||
|
get: operations["BooksDuplicateBooksListDuplicateBooks"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/books/merge": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** MergeBooks */
|
||||||
|
post: operations["BooksMergeMergeBooks"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/books/progress": {
|
"/books/progress": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -161,6 +230,24 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/libraries/imports/{job_id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** GetImport */
|
||||||
|
get: operations["LibrariesImportsJobIdGetImport"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** CancelImport */
|
||||||
|
delete: operations["LibrariesImportsJobIdCancelImport"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/libraries": {
|
"/libraries": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -179,6 +266,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/libraries/{library_id}/imports/calibre/upload": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** UploadCalibreImport */
|
||||||
|
post: operations["LibrariesLibraryIdImportsCalibreUploadUploadCalibreImport"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/access/me": {
|
"/access/me": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -637,6 +741,12 @@ export interface components {
|
|||||||
cover_image?: string | null;
|
cover_image?: string | null;
|
||||||
files?: string[];
|
files?: string[];
|
||||||
};
|
};
|
||||||
|
/** BookMerge */
|
||||||
|
BookMerge: {
|
||||||
|
survivor_id: number;
|
||||||
|
merged_ids: number[];
|
||||||
|
metadata?: components["schemas"]["BookMetadataUpdate"] | null;
|
||||||
|
};
|
||||||
/** BookMetadataUpdate */
|
/** BookMetadataUpdate */
|
||||||
BookMetadataUpdate: {
|
BookMetadataUpdate: {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -710,15 +820,85 @@ export interface components {
|
|||||||
BooksCreateFromFiles: {
|
BooksCreateFromFiles: {
|
||||||
files?: string[];
|
files?: string[];
|
||||||
};
|
};
|
||||||
|
/** BooksUploadResult */
|
||||||
|
BooksUploadResult: {
|
||||||
|
created: components["schemas"]["BookRead"][];
|
||||||
|
skipped: components["schemas"]["DuplicateFileRead"][];
|
||||||
|
possible_duplicates?: components["schemas"]["PossibleDuplicateRead"][];
|
||||||
|
};
|
||||||
|
/** CalibreArchiveUpload */
|
||||||
|
CalibreArchiveUpload: {
|
||||||
|
/** Format: binary */
|
||||||
|
archive: string;
|
||||||
|
/** @default false */
|
||||||
|
allow_duplicates: boolean;
|
||||||
|
};
|
||||||
|
/** CalibreImportRead */
|
||||||
|
CalibreImportRead: {
|
||||||
|
id: string;
|
||||||
|
library_id: number;
|
||||||
|
source: string;
|
||||||
|
state: string;
|
||||||
|
total: number;
|
||||||
|
processed: number;
|
||||||
|
created: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
current_title?: string | null;
|
||||||
|
failures?: components["schemas"]["ImportFailureRead"][];
|
||||||
|
/** @default 0 */
|
||||||
|
possible_duplicates: number;
|
||||||
|
error?: string | null;
|
||||||
|
};
|
||||||
|
/** DuplicateBookGroupRead */
|
||||||
|
DuplicateBookGroupRead: {
|
||||||
|
books: components["schemas"]["DuplicateBookRead"][];
|
||||||
|
};
|
||||||
|
/** DuplicateBookRead */
|
||||||
|
DuplicateBookRead: {
|
||||||
|
book_id: number;
|
||||||
|
title: string;
|
||||||
|
authors: string[];
|
||||||
|
library_id: number;
|
||||||
|
cover_image?: string | null;
|
||||||
|
matched_on: string[];
|
||||||
|
};
|
||||||
|
/** DuplicateDismissal */
|
||||||
|
DuplicateDismissal: {
|
||||||
|
book_a_id: number;
|
||||||
|
book_b_id: number;
|
||||||
|
};
|
||||||
|
/** DuplicateFileRead */
|
||||||
|
DuplicateFileRead: {
|
||||||
|
filename: string;
|
||||||
|
hash: string;
|
||||||
|
size: number;
|
||||||
|
library_id: number;
|
||||||
|
book_id?: number | null;
|
||||||
|
book_title?: string | null;
|
||||||
|
};
|
||||||
|
/** FileFingerprint */
|
||||||
|
FileFingerprint: {
|
||||||
|
hash: string;
|
||||||
|
size: number;
|
||||||
|
/** @default */
|
||||||
|
filename: string;
|
||||||
|
};
|
||||||
/** FileMetadataRead */
|
/** FileMetadataRead */
|
||||||
FileMetadataRead: {
|
FileMetadataRead: {
|
||||||
id: number;
|
id: number;
|
||||||
path: string;
|
path: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
size: number;
|
size: number;
|
||||||
content_type: string;
|
content_type?: string | null;
|
||||||
readonly filename: string;
|
readonly filename: string;
|
||||||
};
|
};
|
||||||
|
/** ImportFailureRead */
|
||||||
|
ImportFailureRead: {
|
||||||
|
calibre_id: number;
|
||||||
|
title: string;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
/** KosyncDeviceCreate */
|
/** KosyncDeviceCreate */
|
||||||
KosyncDeviceCreate: {
|
KosyncDeviceCreate: {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -777,6 +957,12 @@ export interface components {
|
|||||||
refresh_token?: string | null;
|
refresh_token?: string | null;
|
||||||
expires_in?: number | null;
|
expires_in?: number | null;
|
||||||
};
|
};
|
||||||
|
/** PossibleDuplicateRead */
|
||||||
|
PossibleDuplicateRead: {
|
||||||
|
book_id: number;
|
||||||
|
title: string;
|
||||||
|
candidates: components["schemas"]["DuplicateBookRead"][];
|
||||||
|
};
|
||||||
/** PublisherRead */
|
/** PublisherRead */
|
||||||
PublisherRead: {
|
PublisherRead: {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -828,6 +1014,7 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
library_id?: number | null;
|
library_id?: number | null;
|
||||||
|
allow_duplicates?: boolean;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
@@ -908,6 +1095,47 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
BooksDuplicateFilesCheckDuplicateFiles: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
library_id?: number | null;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["FileFingerprint"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Request fulfilled, document follows */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DuplicateFileRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad request syntax or unsupported method */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
status_code: number;
|
||||||
|
detail: string;
|
||||||
|
extra?: null | {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
BooksListBooks: {
|
BooksListBooks: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -917,7 +1145,7 @@ export interface operations {
|
|||||||
tags?: number[] | null;
|
tags?: number[] | null;
|
||||||
shelves?: number[] | null;
|
shelves?: number[] | null;
|
||||||
progress?: string[] | null;
|
progress?: string[] | null;
|
||||||
ids?: string[] | null;
|
ids?: number[] | null;
|
||||||
searchString?: string | null;
|
searchString?: string | null;
|
||||||
searchIgnoreCase?: boolean | null;
|
searchIgnoreCase?: boolean | null;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
@@ -969,6 +1197,7 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
library_id?: number | null;
|
library_id?: number | null;
|
||||||
|
allow_duplicates?: boolean;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -1047,6 +1276,7 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
library_id?: number | null;
|
library_id?: number | null;
|
||||||
|
allow_duplicates?: boolean;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
@@ -1060,21 +1290,86 @@ export interface operations {
|
|||||||
responses: {
|
responses: {
|
||||||
/** @description Document created, URL follows */
|
/** @description Document created, URL follows */
|
||||||
201: {
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BooksUploadResult"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad request syntax or unsupported method */
|
||||||
|
400: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
items?: components["schemas"]["BookRead"][];
|
status_code: number;
|
||||||
/** @description Maximal number of items to send. */
|
detail: string;
|
||||||
limit?: number;
|
extra?: null | {
|
||||||
/** @description Offset from the beginning of the query. */
|
[key: string]: unknown;
|
||||||
offset?: number;
|
} | unknown[];
|
||||||
/** @description Total number of items. */
|
|
||||||
total?: number;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
BooksDuplicateBooksDismissalsDismissDuplicateBooks: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DuplicateDismissal"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Request fulfilled, nothing follows */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Bad request syntax or unsupported method */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
status_code: number;
|
||||||
|
detail: string;
|
||||||
|
extra?: null | {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
BooksDuplicateBooksDismissalsRestoreDuplicateBooks: {
|
||||||
|
parameters: {
|
||||||
|
query: {
|
||||||
|
book_a_id: number;
|
||||||
|
book_b_id: number;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Request fulfilled, nothing follows */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Bad request syntax or unsupported method */
|
/** @description Bad request syntax or unsupported method */
|
||||||
400: {
|
400: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1254,6 +1549,84 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
BooksDuplicateBooksListDuplicateBooks: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
library_id?: number | null;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Request fulfilled, document follows */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["DuplicateBookGroupRead"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad request syntax or unsupported method */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
status_code: number;
|
||||||
|
detail: string;
|
||||||
|
extra?: null | {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
BooksMergeMergeBooks: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
library_id?: number | null;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BookMerge"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Document created, URL follows */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BookRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad request syntax or unsupported method */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
status_code: number;
|
||||||
|
detail: string;
|
||||||
|
extra?: null | {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
BooksProgressSetBookProgressBatch: {
|
BooksProgressSetBookProgressBatch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
@@ -1378,6 +1751,80 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
LibrariesImportsJobIdGetImport: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
job_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Request fulfilled, document follows */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CalibreImportRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad request syntax or unsupported method */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
status_code: number;
|
||||||
|
detail: string;
|
||||||
|
extra?: null | {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
LibrariesImportsJobIdCancelImport: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
job_id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Request fulfilled, document follows */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CalibreImportRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad request syntax or unsupported method */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
status_code: number;
|
||||||
|
detail: string;
|
||||||
|
extra?: null | {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
LibrariesListLibraries: {
|
LibrariesListLibraries: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
@@ -1464,6 +1911,47 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
LibrariesLibraryIdImportsCalibreUploadUploadCalibreImport: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
library_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"multipart/form-data": components["schemas"]["CalibreArchiveUpload"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Request accepted, processing continues off-line */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CalibreImportRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad request syntax or unsupported method */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
status_code: number;
|
||||||
|
detail: string;
|
||||||
|
extra?: null | {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
AccessMeGetUserInfo: {
|
AccessMeGetUserInfo: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1931,7 +2419,7 @@ export interface operations {
|
|||||||
tags?: number[] | null;
|
tags?: number[] | null;
|
||||||
shelves?: number[] | null;
|
shelves?: number[] | null;
|
||||||
progress?: string[] | null;
|
progress?: string[] | null;
|
||||||
ids?: string[] | null;
|
ids?: number[] | null;
|
||||||
searchString?: string | null;
|
searchString?: string | null;
|
||||||
searchIgnoreCase?: boolean | null;
|
searchIgnoreCase?: boolean | null;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
@@ -2018,7 +2506,7 @@ export interface operations {
|
|||||||
OpdsLibraryLibraryIdCollectionTypeGetLibraryCollectionFeed: {
|
OpdsLibraryLibraryIdCollectionTypeGetLibraryCollectionFeed: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
ids?: string[] | null;
|
ids?: number[] | null;
|
||||||
searchString?: string | null;
|
searchString?: string | null;
|
||||||
searchIgnoreCase?: boolean | null;
|
searchIgnoreCase?: boolean | null;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
@@ -2147,7 +2635,7 @@ export interface operations {
|
|||||||
tags?: number[] | null;
|
tags?: number[] | null;
|
||||||
shelves?: number[] | null;
|
shelves?: number[] | null;
|
||||||
progress?: string[] | null;
|
progress?: string[] | null;
|
||||||
ids?: string[] | null;
|
ids?: number[] | null;
|
||||||
searchString?: string | null;
|
searchString?: string | null;
|
||||||
searchIgnoreCase?: boolean | null;
|
searchIgnoreCase?: boolean | null;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ export class BookCollectionState {
|
|||||||
public moreBooks = $state(false);
|
public moreBooks = $state(false);
|
||||||
private currentBookPage = $state(1);
|
private currentBookPage = $state(1);
|
||||||
public loading = $state(false);
|
public loading = $state(false);
|
||||||
|
/** A page append, as opposed to `loading`, which replaces the whole list. */
|
||||||
|
public loadingMore = $state(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the reader has loaded past the first page. Anything that refetches
|
||||||
|
* gets page one back, which is a jump to the top from here.
|
||||||
|
*/
|
||||||
|
readonly pastFirstPage = $derived(this.currentBookPage > 1);
|
||||||
|
|
||||||
public filterOptions: FilterOption[] = $state([]);
|
public filterOptions: FilterOption[] = $state([]);
|
||||||
|
|
||||||
@@ -99,6 +107,9 @@ export class BookCollectionState {
|
|||||||
|
|
||||||
const url = new URL(page.url);
|
const url = new URL(page.url);
|
||||||
url.searchParams.set('view', next);
|
url.searchParams.set('view', next);
|
||||||
|
// Not a route to resolve — this is the current URL with one query param
|
||||||
|
// changed, so it is already fully qualified.
|
||||||
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
replaceState(url, page.state);
|
replaceState(url, page.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +148,8 @@ export class BookCollectionState {
|
|||||||
url.searchParams.set('orderBy', this.orderBy);
|
url.searchParams.set('orderBy', this.orderBy);
|
||||||
url.searchParams.set('sortOrder', this.sortOrder);
|
url.searchParams.set('sortOrder', this.sortOrder);
|
||||||
|
|
||||||
// pushState(url.toString(), {})
|
// Same as setView: the current URL with sort params rewritten, not a route.
|
||||||
|
// eslint-disable-next-line svelte/no-navigation-without-resolve
|
||||||
goto(url.toString());
|
goto(url.toString());
|
||||||
|
|
||||||
this.loadNewBooks();
|
this.loadNewBooks();
|
||||||
@@ -155,11 +167,21 @@ export class BookCollectionState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.books = [...result.items];
|
this.books = [...result.items];
|
||||||
|
this.moreBooks = result.total > result.items.length;
|
||||||
this.currentBookPage = 1;
|
this.currentBookPage = 1;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prefetching means the trigger can fire again while a page is still in
|
||||||
|
* flight, so the guard is here rather than in the observer — every caller
|
||||||
|
* gets it, and a second call is dropped instead of duplicating a page.
|
||||||
|
*/
|
||||||
async loadMoreBooks() {
|
async loadMoreBooks() {
|
||||||
|
if (this.loadingMore || !this.moreBooks) return;
|
||||||
|
|
||||||
|
this.loadingMore = true;
|
||||||
|
try {
|
||||||
const result = await this.ops.listBooks({
|
const result = await this.ops.listBooks({
|
||||||
currentPage: this.currentBookPage + 1,
|
currentPage: this.currentBookPage + 1,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
@@ -169,8 +191,13 @@ export class BookCollectionState {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.books = [...this.books, ...result.items];
|
this.books = [...this.books, ...result.items];
|
||||||
this.moreBooks = result.total > this.books.length;
|
// An empty page means the count we were given was stale; stop asking
|
||||||
|
// rather than loop on a page that never grows the list.
|
||||||
|
this.moreBooks = result.items.length > 0 && result.total > this.books.length;
|
||||||
this.currentBookPage++;
|
this.currentBookPage++;
|
||||||
|
} finally {
|
||||||
|
this.loadingMore = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBooks(books: PaginatedResponse<Book>) {
|
updateBooks(books: PaginatedResponse<Book>) {
|
||||||
@@ -183,6 +210,11 @@ export class BookCollectionState {
|
|||||||
this.moreBooks = books.total > books.items.length;
|
this.moreBooks = books.total > books.items.length;
|
||||||
this.filterOptions = this.buildFilterOptions(filterData);
|
this.filterOptions = this.buildFilterOptions(filterData);
|
||||||
|
|
||||||
|
// The loader hands back the first page, so the counter has to say so —
|
||||||
|
// otherwise loadMoreBooks resumes from wherever the reader had scrolled to
|
||||||
|
// and skips every page in between.
|
||||||
|
this.currentBookPage = 1;
|
||||||
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
// Re-read sort and filter state from URL
|
// Re-read sort and filter state from URL
|
||||||
@@ -285,9 +317,7 @@ export class BookCollectionState {
|
|||||||
|
|
||||||
return wanted.every(([key, values]) => {
|
return wanted.every(([key, values]) => {
|
||||||
const current = this.filters[key] ?? [];
|
const current = this.filters[key] ?? [];
|
||||||
return (
|
return current.length === values.length && values.every((value) => current.includes(value));
|
||||||
current.length === values.length && values.every((value) => current.includes(value))
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { resolve } from '$app/paths';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { goto, invalidate } from '$app/navigation';
|
import { goto, invalidate } from '$app/navigation';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
@@ -31,7 +32,9 @@ export class LibraryState {
|
|||||||
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
|
this.activeLibrary = this.libraries.find((lib) => lib.id === libraryId) || this.libraries[0];
|
||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
|
localStorage.setItem('previousLibraryId', this.activeLibrary.id.toString());
|
||||||
await goto(`/library/${libraryId}/view`, { invalidate: ['app:libraries'] });
|
await goto(resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }), {
|
||||||
|
invalidate: ['app:libraries']
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
import { getContext, setContext } from 'svelte';
|
||||||
|
import { invalidate } from '$app/navigation';
|
||||||
|
|
||||||
|
import type { Book, BooksUploadResult, DuplicateBook, DuplicateFile } from '$lib/schema';
|
||||||
|
|
||||||
|
export type UploadStatus = 'queued' | 'uploading' | 'done' | 'skipped' | 'failed';
|
||||||
|
|
||||||
|
export interface UploadJob {
|
||||||
|
id: string;
|
||||||
|
/** The book's folder, or the filename for a book that is a single file. */
|
||||||
|
label: string;
|
||||||
|
libraryId: number | string;
|
||||||
|
files: File[];
|
||||||
|
size: number;
|
||||||
|
status: UploadStatus;
|
||||||
|
error?: string;
|
||||||
|
book?: Book;
|
||||||
|
/**
|
||||||
|
* Files the library already held, which were not stored again. A job with
|
||||||
|
* nothing left over lands as `skipped`; one that had something new is `done`
|
||||||
|
* and still carries these, since the reader asked for those files too.
|
||||||
|
*/
|
||||||
|
duplicates?: DuplicateFile[];
|
||||||
|
/**
|
||||||
|
* Books already in the library that the one this job created might be a second
|
||||||
|
* copy of. Much weaker than `duplicates`: the bytes are new and only the metadata
|
||||||
|
* agrees, which a second edition and a translation both do. The book was stored.
|
||||||
|
*/
|
||||||
|
possibleDuplicates?: DuplicateBook[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadSummary {
|
||||||
|
created: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
firstBook?: Book;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches the grouping in BookService.create_books_from_files: files are grouped
|
||||||
|
* by their parent directory, except those at the root, which each become their
|
||||||
|
* own book. Diverging from it would split one book across two records.
|
||||||
|
*/
|
||||||
|
function groupByBook(files: File[]): [key: string, files: File[]][] {
|
||||||
|
// A plain record rather than a Map: this is a throwaway local, and Svelte's
|
||||||
|
// lint rule steers any Map towards the reactive SvelteMap.
|
||||||
|
const groups: Record<string, File[]> = {};
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const cut = file.name.lastIndexOf('/');
|
||||||
|
const key = cut === -1 ? file.name : file.name.slice(0, cut);
|
||||||
|
|
||||||
|
(groups[key] ??= []).push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.entries(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
function label(key: string) {
|
||||||
|
const cut = key.lastIndexOf('/');
|
||||||
|
return cut === -1 ? key : key.slice(cut + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How long a clean run stays on screen before clearing itself. */
|
||||||
|
const DISMISS_AFTER_MS = 6000;
|
||||||
|
|
||||||
|
/** How often a run that is still going refreshes the list behind it. */
|
||||||
|
const REFRESH_EVERY_MS = 2000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads books one request at a time and keeps the result on screen.
|
||||||
|
*
|
||||||
|
* Lives above the dialog so a run outlives it — the dialog only adds to the
|
||||||
|
* queue, and the tray in the root layout is what reports on it. This is why
|
||||||
|
* uploads do not go through a remote function: one request per book is what
|
||||||
|
* makes progress real, bounds how much any single request buffers, and stops
|
||||||
|
* one bad file taking the whole import with it.
|
||||||
|
*/
|
||||||
|
export class UploadQueueState {
|
||||||
|
jobs = $state<UploadJob[]>([]);
|
||||||
|
collapsed = $state(false);
|
||||||
|
|
||||||
|
readonly total = $derived(this.jobs.length);
|
||||||
|
readonly done = $derived(this.jobs.filter((job) => job.status === 'done').length);
|
||||||
|
readonly skipped = $derived(this.jobs.filter((job) => job.status === 'skipped').length);
|
||||||
|
readonly failed = $derived(this.jobs.filter((job) => job.status === 'failed').length);
|
||||||
|
readonly active = $derived(
|
||||||
|
this.jobs.some((job) => job.status === 'queued' || job.status === 'uploading')
|
||||||
|
);
|
||||||
|
readonly current = $derived(this.jobs.find((job) => job.status === 'uploading'));
|
||||||
|
readonly settled = $derived(this.done + this.skipped + this.failed);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the run left something the reader still has to see.
|
||||||
|
*
|
||||||
|
* A skipped book is not a failure, but it is the only place that says the file
|
||||||
|
* was already here — and the only place to override it from. A book that may
|
||||||
|
* already be in the library counts too: it is a note the reader has to actually
|
||||||
|
* read, and a tray that clears itself after six seconds is one they never will.
|
||||||
|
*/
|
||||||
|
readonly needsAttention = $derived(
|
||||||
|
this.jobs.some(
|
||||||
|
(job) =>
|
||||||
|
job.status === 'failed' ||
|
||||||
|
(job.duplicates?.length ?? 0) > 0 ||
|
||||||
|
(job.possibleDuplicates?.length ?? 0) > 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set by a list that has loaded past its first page, to keep a mid-run refresh
|
||||||
|
* from throwing the reader back to the top. See #scheduleRefresh.
|
||||||
|
*/
|
||||||
|
holdRefresh = false;
|
||||||
|
|
||||||
|
#running = false;
|
||||||
|
#dismissTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
#refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
#held = false;
|
||||||
|
|
||||||
|
/** Adds one job per book and starts the runner if it is not already going. */
|
||||||
|
enqueue(
|
||||||
|
libraryId: number | string,
|
||||||
|
files: File[],
|
||||||
|
onFinished?: (summary: UploadSummary) => void
|
||||||
|
) {
|
||||||
|
const added = groupByBook(files).map(([key, group]) => ({
|
||||||
|
id: `${libraryId}:${key}`,
|
||||||
|
label: label(key),
|
||||||
|
libraryId,
|
||||||
|
files: group,
|
||||||
|
size: group.reduce((sum, file) => sum + file.size, 0),
|
||||||
|
status: 'queued' as UploadStatus
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (added.length === 0) return;
|
||||||
|
|
||||||
|
clearTimeout(this.#dismissTimer);
|
||||||
|
|
||||||
|
// A finished run stays on screen until dismissed; adding to it starts fresh
|
||||||
|
// rather than appending to a report the reader has already read.
|
||||||
|
if (!this.active) this.jobs = [];
|
||||||
|
|
||||||
|
this.jobs = [...this.jobs, ...added];
|
||||||
|
this.collapsed = false;
|
||||||
|
|
||||||
|
void this.#run(onFinished);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
if (this.active) return;
|
||||||
|
clearTimeout(this.#dismissTimer);
|
||||||
|
this.jobs = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Holds a finished run on screen while the pointer is over it.
|
||||||
|
*
|
||||||
|
* Without this the tray can vanish from under a reader who is part-way
|
||||||
|
* through reading which books were added.
|
||||||
|
*/
|
||||||
|
hold() {
|
||||||
|
this.#held = true;
|
||||||
|
clearTimeout(this.#dismissTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
release() {
|
||||||
|
this.#held = false;
|
||||||
|
if (!this.active && !this.needsAttention && this.total > 0) this.#scheduleDismiss();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears itself only when everything went in cleanly. A run with failures or
|
||||||
|
* skipped files stays until dismissed — it is the only record of what did not
|
||||||
|
* make it in, and the only place to retry or override from.
|
||||||
|
*/
|
||||||
|
#scheduleDismiss() {
|
||||||
|
clearTimeout(this.#dismissTimer);
|
||||||
|
if (this.#held) return;
|
||||||
|
|
||||||
|
this.#dismissTimer = setTimeout(() => {
|
||||||
|
if (!this.active && !this.needsAttention) this.jobs = [];
|
||||||
|
}, DISMISS_AFTER_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the books added so far, without waiting for the rest of the run.
|
||||||
|
*
|
||||||
|
* A refetch rather than inserting the created book into the list by hand: the
|
||||||
|
* server owns the sort, the filters and the paging, so asking it again is what
|
||||||
|
* makes a part-finished import look the same as a finished one.
|
||||||
|
*
|
||||||
|
* Trailing, and at most one refresh per REFRESH_EVERY_MS — a folder of small
|
||||||
|
* books lands faster than the list can usefully redraw.
|
||||||
|
*/
|
||||||
|
#scheduleRefresh() {
|
||||||
|
if (this.#refreshTimer) return;
|
||||||
|
|
||||||
|
this.#refreshTimer = setTimeout(() => {
|
||||||
|
this.#refreshTimer = undefined;
|
||||||
|
|
||||||
|
// The loaders only ever return the first page, so refreshing under a
|
||||||
|
// reader who has scrolled past it would drop them back to the top. Their
|
||||||
|
// list catches up when the run ends.
|
||||||
|
if (this.holdRefresh) return;
|
||||||
|
|
||||||
|
void invalidate('app:books');
|
||||||
|
}, REFRESH_EVERY_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-queues everything that failed, so one bad book is not a reason to start over. */
|
||||||
|
retryFailed() {
|
||||||
|
this.jobs = this.jobs.map((job) =>
|
||||||
|
job.status === 'failed' ? { ...job, status: 'queued' as UploadStatus, error: undefined } : job
|
||||||
|
);
|
||||||
|
void this.#run();
|
||||||
|
}
|
||||||
|
|
||||||
|
async #run(onFinished?: (summary: UploadSummary) => void) {
|
||||||
|
if (this.#running) return;
|
||||||
|
this.#running = true;
|
||||||
|
|
||||||
|
// Nothing here is resumable, so leaving mid-run loses whatever is left.
|
||||||
|
window.addEventListener('beforeunload', warnBeforeUnload);
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let firstBook: Book | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (;;) {
|
||||||
|
const index = this.jobs.findIndex((job) => job.status === 'queued');
|
||||||
|
if (index === -1) break;
|
||||||
|
|
||||||
|
const job = this.jobs[index];
|
||||||
|
this.#patch(index, { status: 'uploading' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
for (const file of job.files) body.append('files', file);
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/books/fromFiles?library_id=${encodeURIComponent(String(job.libraryId))}`,
|
||||||
|
{ method: 'POST', body }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`The server returned ${response.status}`);
|
||||||
|
|
||||||
|
const result: BooksUploadResult = await response.json();
|
||||||
|
const book = result.created[0];
|
||||||
|
|
||||||
|
created += result.created.length;
|
||||||
|
firstBook ??= book;
|
||||||
|
|
||||||
|
// Nothing created means every file in this folder was already here.
|
||||||
|
// That is not a failure, and it is not something to hide either.
|
||||||
|
this.#patch(index, {
|
||||||
|
status: result.created.length === 0 ? 'skipped' : 'done',
|
||||||
|
book,
|
||||||
|
duplicates: result.skipped,
|
||||||
|
// One job is one book, so it has at most one set of candidates.
|
||||||
|
possibleDuplicates: result.possible_duplicates?.[0]?.candidates
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.created.length > 0) this.#scheduleRefresh();
|
||||||
|
} catch (error) {
|
||||||
|
// One bad book must not take the rest of the queue with it.
|
||||||
|
console.error(`Failed to upload ${this.jobs[index].label}`, error);
|
||||||
|
this.#patch(index, {
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : 'Upload failed'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.#running = false;
|
||||||
|
window.removeEventListener('beforeunload', warnBeforeUnload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The run is over, so the refresh below is the one that counts — a pending
|
||||||
|
// mid-run one would only repeat it a moment later, and it is the one that
|
||||||
|
// honours holdRefresh.
|
||||||
|
clearTimeout(this.#refreshTimer);
|
||||||
|
this.#refreshTimer = undefined;
|
||||||
|
|
||||||
|
if (created > 0) await invalidate('app:books');
|
||||||
|
|
||||||
|
if (!this.needsAttention) this.#scheduleDismiss();
|
||||||
|
|
||||||
|
onFinished?.({ created, skipped: this.skipped, failed: this.failed, firstBook });
|
||||||
|
}
|
||||||
|
|
||||||
|
#patch(index: number, changes: Partial<UploadJob>) {
|
||||||
|
this.jobs[index] = { ...this.jobs[index], ...changes };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPLOAD_QUEUE_KEY = Symbol('UPLOAD_QUEUE');
|
||||||
|
|
||||||
|
export function setUploadQueueState() {
|
||||||
|
return setContext(UPLOAD_QUEUE_KEY, new UploadQueueState());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUploadQueueState() {
|
||||||
|
return getContext<ReturnType<typeof setUploadQueueState>>(UPLOAD_QUEUE_KEY);
|
||||||
|
}
|
||||||
@@ -81,7 +81,10 @@ function isbnDigits(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function identifierKey(name: string) {
|
function identifierKey(name: string) {
|
||||||
return name.trim().toLowerCase().replace(/[\s_]+/g, '-');
|
return name
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s_]+/g, '-');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function describeIdentifier(name: string, value: string) {
|
export function describeIdentifier(name: string, value: string) {
|
||||||
@@ -111,4 +114,17 @@ export function getFileType(filename: string) {
|
|||||||
return extension.toUpperCase();
|
return extension.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The formats there is a reader for. Everything else is download-only.
|
||||||
|
*
|
||||||
|
* A library imported from elsewhere carries MOBI, AZW3 and CBZ files, which are
|
||||||
|
* legitimate to store and to download but have nowhere to open — the reader routes
|
||||||
|
* are `read/epub` and `read/pdf` and there is no third one.
|
||||||
|
*/
|
||||||
|
const READABLE_FILE_TYPES = ['EPUB', 'PDF'];
|
||||||
|
|
||||||
|
export function isReadable(filename: string) {
|
||||||
|
return READABLE_FILE_TYPES.includes(getFileType(filename));
|
||||||
|
}
|
||||||
|
|
||||||
export const pluck = (array: [], key: string) => array.map((obj) => obj?.[key]);
|
export const pluck = (array: [], key: string) => array.map((obj) => obj?.[key]);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { Badge, badgeVariants } from '$lib/components/ui/badge/index.js';
|
import { Badge, badgeVariants } from '$lib/components/ui/badge/index.js';
|
||||||
import { CollapsibleText } from '$lib/components/ui/collapsible-text/index.js';
|
import { CollapsibleText } from '$lib/components/ui/collapsible-text/index.js';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
describeIdentifier,
|
describeIdentifier,
|
||||||
formatFileSize,
|
formatFileSize,
|
||||||
getFileType,
|
getFileType,
|
||||||
|
isReadable,
|
||||||
sortIdentifiers
|
sortIdentifiers
|
||||||
} from '$lib/utils.js';
|
} from '$lib/utils.js';
|
||||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
|
import { getBookOperationsState } from '$lib/state/bookOperations.svelte.js';
|
||||||
@@ -56,6 +58,11 @@
|
|||||||
book.progress?.percentage ? Math.round(book.progress.percentage * 100) : 0
|
book.progress?.percentage ? Math.round(book.progress.percentage * 100) : 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only the files there is a reader for. A book can be stored in a format Chitai
|
||||||
|
// cannot open — a Calibre library is full of MOBI and AZW3 — and offering to read
|
||||||
|
// one opened a window that did nothing at all.
|
||||||
|
const readableFiles = $derived(book.files.filter((file) => isReadable(file.filename)));
|
||||||
|
|
||||||
// The primary action states what it will actually do.
|
// The primary action states what it will actually do.
|
||||||
const readLabel = $derived(
|
const readLabel = $derived(
|
||||||
book.progress?.completed
|
book.progress?.completed
|
||||||
@@ -65,12 +72,24 @@
|
|||||||
: 'Read'
|
: 'Read'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// window.open is outside the lint rule's reach, but the paths need resolving
|
||||||
|
// just the same — they would break under a non-empty base path.
|
||||||
function openBookInReader(file: BookFile) {
|
function openBookInReader(file: BookFile) {
|
||||||
|
const params = { bookId: String(book.id), fileId: String(file.id) };
|
||||||
|
|
||||||
if (getFileType(file.filename) === 'EPUB')
|
if (getFileType(file.filename) === 'EPUB')
|
||||||
window.open(`/book/${book.id}/read/epub/${file.id}`, '_blank', 'noopener,noreferrer');
|
window.open(
|
||||||
|
resolve('/(root)/(library)/book/[bookId]/read/epub/[fileId]', params),
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer'
|
||||||
|
);
|
||||||
|
|
||||||
if (getFileType(file.filename) === 'PDF')
|
if (getFileType(file.filename) === 'PDF')
|
||||||
window.open(`/book/${book.id}/read/pdf/${file.id}`, '_blank', 'noopener,noreferrer');
|
window.open(
|
||||||
|
resolve('/(root)/(library)/book/[bookId]/read/pdf/[fileId]', params),
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On the band, the accent is the ground — invert the buttons against it.
|
// On the band, the accent is the ground — invert the buttons against it.
|
||||||
@@ -78,6 +97,10 @@
|
|||||||
'inline-flex items-center gap-2 rounded-lg bg-primary-foreground px-4 py-2 text-sm font-semibold text-primary tabular-nums transition-colors hover:bg-primary-foreground/90';
|
'inline-flex items-center gap-2 rounded-lg bg-primary-foreground px-4 py-2 text-sm font-semibold text-primary tabular-nums transition-colors hover:bg-primary-foreground/90';
|
||||||
const bandGhost =
|
const bandGhost =
|
||||||
'inline-flex items-center gap-2 rounded-lg border border-primary-foreground/35 px-4 py-2 text-sm font-medium transition-colors hover:bg-primary-foreground/10';
|
'inline-flex items-center gap-2 rounded-lg border border-primary-foreground/35 px-4 py-2 text-sm font-medium transition-colors hover:bg-primary-foreground/10';
|
||||||
|
|
||||||
|
// With nothing to read, downloading is the only thing left to do — so it takes the
|
||||||
|
// primary slot rather than leaving the band with no filled button on it.
|
||||||
|
const bandDownload = $derived(readableFiles.length ? bandGhost : bandPrimary);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-y-auto">
|
<div class="flex h-full flex-col overflow-y-auto">
|
||||||
@@ -101,7 +124,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{#if book.subtitle}
|
{#if book.subtitle}
|
||||||
<p class="mt-1 font-serif text-lg italic text-primary-foreground/75">{book.subtitle}</p>
|
<p class="mt-1 font-serif text-lg text-primary-foreground/75 italic">{book.subtitle}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if book.series}
|
{#if book.series}
|
||||||
@@ -115,16 +138,22 @@
|
|||||||
By
|
By
|
||||||
{#each book.authors as author (author.id)}
|
{#each book.authors as author (author.id)}
|
||||||
<a
|
<a
|
||||||
href="/library/{libraryState.activeLibrary!.id}/view?authors={author.id}"
|
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||||
|
libraryId: String(libraryState.activeLibrary!.id)
|
||||||
|
})}?authors={author.id}"
|
||||||
class="cs-list hover:underline">{author.name}</a
|
class="cs-list hover:underline">{author.name}</a
|
||||||
>  
|
>  
|
||||||
{/each}
|
{/each}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Primary actions live on the band, not in a side rail -->
|
<!--
|
||||||
|
Primary actions live on the band, not in a side rail. Read and Download
|
||||||
|
are counted separately: everything can be downloaded, only EPUB and PDF
|
||||||
|
can be opened, so a book can have two files and one way to read it.
|
||||||
|
-->
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-2">
|
<div class="mt-5 flex flex-wrap items-center gap-2">
|
||||||
{#if book.files.length > 1}
|
{#if readableFiles.length > 1}
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger class={bandPrimary}>
|
<DropdownMenu.Trigger class={bandPrimary}>
|
||||||
<BookOpenText class="size-4" />
|
<BookOpenText class="size-4" />
|
||||||
@@ -134,7 +163,7 @@
|
|||||||
<DropdownMenu.Group>
|
<DropdownMenu.Group>
|
||||||
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
|
<DropdownMenu.GroupHeading>File formats:</DropdownMenu.GroupHeading>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
{#each book.files as file (file.id)}
|
{#each readableFiles as file (file.id)}
|
||||||
<DropdownMenu.Item onclick={() => openBookInReader(file)}>
|
<DropdownMenu.Item onclick={() => openBookInReader(file)}>
|
||||||
{getFileType(file.filename)}
|
{getFileType(file.filename)}
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -142,9 +171,20 @@
|
|||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
|
{:else if readableFiles.length === 1}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={bandPrimary}
|
||||||
|
onclick={() => openBookInReader(readableFiles[0])}
|
||||||
|
>
|
||||||
|
<BookOpenText class="size-4" />
|
||||||
|
{readLabel}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if book.files.length > 1}
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger class={bandGhost}>
|
<DropdownMenu.Trigger class={bandDownload}>
|
||||||
<Download class="size-4" />
|
<Download class="size-4" />
|
||||||
Download
|
Download
|
||||||
</DropdownMenu.Trigger>
|
</DropdownMenu.Trigger>
|
||||||
@@ -169,14 +209,9 @@
|
|||||||
</DropdownMenu.Content>
|
</DropdownMenu.Content>
|
||||||
</DropdownMenu.Root>
|
</DropdownMenu.Root>
|
||||||
{:else if book.files.length === 1}
|
{:else if book.files.length === 1}
|
||||||
<button type="button" class={bandPrimary} onclick={() => openBookInReader(book.files[0])}>
|
|
||||||
<BookOpenText class="size-4" />
|
|
||||||
{readLabel}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={bandGhost}
|
class={bandDownload}
|
||||||
onclick={() =>
|
onclick={() =>
|
||||||
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
bookOps.downloadBookFile(book.id, book.files[0].id, book.files[0].filename)}
|
||||||
>
|
>
|
||||||
@@ -184,7 +219,6 @@
|
|||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,10 +301,11 @@
|
|||||||
</dt>
|
</dt>
|
||||||
<dd class="m-0 font-mono break-all tabular-nums">
|
<dd class="m-0 font-mono break-all tabular-nums">
|
||||||
{#if id.href}
|
{#if id.href}
|
||||||
|
<!-- Always an absolute URL off-site: openlibrary, doi.org or amazon. -->
|
||||||
<a
|
<a
|
||||||
href={id.href}
|
href={id.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="external noopener noreferrer"
|
||||||
class="hover:text-primary hover:underline">{value}</a
|
class="hover:text-primary hover:underline">{value}</a
|
||||||
>
|
>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -288,7 +323,14 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{#each book.tags as tag (tag.id)}
|
{#each book.tags as tag (tag.id)}
|
||||||
<a href="/tag/{tag.id}" class={badgeVariants({ variant: 'default' })}>{tag.name}</a>
|
<!-- Was /tag/{id}, a route that has never existed — these badges 404'd.
|
||||||
|
Filter the library view, as the tag links in the list views do. -->
|
||||||
|
<a
|
||||||
|
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||||
|
libraryId: String(libraryState.activeLibrary!.id)
|
||||||
|
})}?tags={tag.id}"
|
||||||
|
class={badgeVariants({ variant: 'default' })}>{tag.name}</a
|
||||||
|
>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -302,7 +344,9 @@
|
|||||||
<div class="flex flex-wrap gap-1.5">
|
<div class="flex flex-wrap gap-1.5">
|
||||||
{#each book.lists as shelf (shelf.id)}
|
{#each book.lists as shelf (shelf.id)}
|
||||||
<a
|
<a
|
||||||
href="/library/{libraryState.activeLibrary!.id}/view?shelves={shelf.id}"
|
href="{resolve('/(root)/(library)/library/[libraryId]/view', {
|
||||||
|
libraryId: String(libraryState.activeLibrary!.id)
|
||||||
|
})}?shelves={shelf.id}"
|
||||||
class={badgeVariants({ variant: 'outline' })}>{shelf.title}</a
|
class={badgeVariants({ variant: 'outline' })}>{shelf.title}</a
|
||||||
>
|
>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -462,10 +506,13 @@
|
|||||||
<Table.Cell class="font-mono text-xs tabular-nums">
|
<Table.Cell class="font-mono text-xs tabular-nums">
|
||||||
{formatFileSize(file.size)}
|
{formatFileSize(file.size)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell><span class="font-mono text-xs">{getFileType(file.filename)}</span></Table.Cell>
|
<Table.Cell
|
||||||
|
><span class="font-mono text-xs">{getFileType(file.filename)}</span
|
||||||
|
></Table.Cell
|
||||||
|
>
|
||||||
<Table.Cell class="text-right">
|
<Table.Cell class="text-right">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
{#if getFileType(file.filename) === 'EPUB' || getFileType(file.filename) === 'PDF'}
|
{#if isReadable(file.filename)}
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||||
<Tooltip.Trigger
|
<Tooltip.Trigger
|
||||||
|
|||||||
@@ -8,11 +8,11 @@
|
|||||||
const bookId = page.params.bookId!;
|
const bookId = page.params.bookId!;
|
||||||
|
|
||||||
// Fetched by the browser through the proxy, which attaches the auth header
|
// Fetched by the browser through the proxy, which attaches the auth header
|
||||||
const bookUrl = `/api/books/download/${bookId}/${fileId}`;
|
const fileUrl = `/api/books/download/${bookId}/${fileId}`;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<EpubReader
|
<EpubReader
|
||||||
{bookUrl}
|
{fileUrl}
|
||||||
{bookId}
|
{bookId}
|
||||||
title={data.title}
|
title={data.title}
|
||||||
filename={data.filename}
|
filename={data.filename}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
import { Spinner } from '$lib/components/ui/spinner/index';
|
import { Spinner } from '$lib/components/ui/spinner/index';
|
||||||
@@ -55,7 +56,7 @@
|
|||||||
<!-- Same chrome as the EPUB reader, so leaving works the same way in both -->
|
<!-- Same chrome as the EPUB reader, so leaving works the same way in both -->
|
||||||
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
<header class="flex h-12 shrink-0 items-center gap-2 border-b px-3">
|
||||||
<a
|
<a
|
||||||
href="/book/{bookId}"
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
|
||||||
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
|
class="min-w-0 flex-1 truncate font-serif text-sm hover:underline"
|
||||||
title={data?.title}
|
title={data?.title}
|
||||||
>
|
>
|
||||||
@@ -76,7 +77,10 @@
|
|||||||
<RotateCcw class="size-4" />
|
<RotateCcw class="size-4" />
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
<a href="/book/{bookId}" class={buttonVariants({ variant: 'outline' })}>
|
<a
|
||||||
|
href={resolve('/(root)/(library)/book/[bookId]', { bookId: String(bookId) })}
|
||||||
|
class={buttonVariants({ variant: 'outline' })}
|
||||||
|
>
|
||||||
Back to book
|
Back to book
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
import type { PaginatedResponse } from '$lib/schema/common';
|
import type { PaginatedResponse } from '$lib/schema/common';
|
||||||
import { setBookCollectionState } from '$lib/state/bookCollection.svelte';
|
import { setBookCollectionState } from '$lib/state/bookCollection.svelte';
|
||||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||||
|
import { getUploadQueueState } from '$lib/state/upload-queue.svelte';
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -23,6 +24,16 @@
|
|||||||
|
|
||||||
const bookOps = getBookOperationsState();
|
const bookOps = getBookOperationsState();
|
||||||
const bookCollection = setBookCollectionState(bookOps, books, filterData);
|
const bookCollection = setBookCollectionState(bookOps, books, filterData);
|
||||||
|
const queue = getUploadQueueState();
|
||||||
|
|
||||||
|
// An upload refreshes this list every couple of seconds so books show up as
|
||||||
|
// they land. That refresh returns the first page, so it waits while the reader
|
||||||
|
// is reading further down. Cleared on the way out: this list is gone, and a
|
||||||
|
// stale hold would stop the next one refreshing at all.
|
||||||
|
$effect(() => {
|
||||||
|
queue.holdRefresh = bookCollection.pastFirstPage;
|
||||||
|
return () => (queue.holdRefresh = false);
|
||||||
|
});
|
||||||
|
|
||||||
let skip = $state(true);
|
let skip = $state(true);
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
import { listBookshelves } from '$lib/api';
|
import { listBookshelves } from '$lib/api';
|
||||||
import AppSidebar from '$lib/components/layout/app-sidebar.svelte';
|
import AppSidebar from '$lib/components/layout/app-sidebar.svelte';
|
||||||
import SiteHeader from '$lib/components/layout/site-header.svelte';
|
import SiteHeader from '$lib/components/layout/site-header.svelte';
|
||||||
|
import UploadTray from '$lib/components/layout/upload-tray.svelte';
|
||||||
import { Loading } from '$lib/components/ui/command';
|
import { Loading } from '$lib/components/ui/command';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
import type { Library, PaginatedResponse } from '$lib/schema';
|
import type { Library, PaginatedResponse } from '$lib/schema';
|
||||||
import { setBookOperationsState } from '$lib/state/bookOperations.svelte';
|
import { setBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||||
import { setBookshelfState } from '$lib/state/bookshelf.svelte';
|
import { setBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||||
import { setLibraryState } from '$lib/state/library.svelte.js';
|
import { setLibraryState } from '$lib/state/library.svelte.js';
|
||||||
|
import { setUploadQueueState } from '$lib/state/upload-queue.svelte';
|
||||||
import { setThemeState } from '$lib/theme/theme.svelte';
|
import { setThemeState } from '$lib/theme/theme.svelte';
|
||||||
import type { ThemeConfig } from '$lib/theme/presets';
|
import type { ThemeConfig } from '$lib/theme/presets';
|
||||||
|
|
||||||
@@ -30,6 +32,10 @@
|
|||||||
const bookOps = setBookOperationsState(libraryState.activeLibrary!.id);
|
const bookOps = setBookOperationsState(libraryState.activeLibrary!.id);
|
||||||
const theme = setThemeState(untrack(() => data.theme));
|
const theme = setThemeState(untrack(() => data.theme));
|
||||||
|
|
||||||
|
// Set here rather than beside the upload dialog so a running import survives
|
||||||
|
// the dialog closing and any navigation within the app shell.
|
||||||
|
setUploadQueueState();
|
||||||
|
|
||||||
// Inline custom properties live on :root and so are mode-blind. When the
|
// Inline custom properties live on :root and so are mode-blind. When the
|
||||||
// light/dark switch flips, rewrite them for the mode now showing.
|
// light/dark switch flips, rewrite them for the mode now showing.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -52,3 +58,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</Sidebar.Provider>
|
</Sidebar.Provider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Outside the sidebar shell: an import keeps running while you browse, so the
|
||||||
|
tray must not sit anywhere a page swap can take away. -->
|
||||||
|
<UploadTray />
|
||||||
|
|||||||
@@ -1,40 +1,76 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
// Set in (root)/+layout.svelte, above this group, so listing the libraries in
|
||||||
|
// the nav needs no load function.
|
||||||
|
const libraryState = getLibraryState();
|
||||||
|
|
||||||
|
// Route ids rather than paths: resolve() is called in the markup so it stays a
|
||||||
|
// direct call the lint rule can see, and the active check compares route ids.
|
||||||
|
// A pathname comparison would miss during SSR, where resolve() returns a
|
||||||
|
// relative path, and only settle after hydration.
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{ title: 'Account', routeId: '/(root)/settings/account' },
|
||||||
title: 'Account',
|
{ title: 'Appearance', routeId: '/(root)/settings/appearance' },
|
||||||
url: '/settings/account'
|
{ title: 'Libraries', routeId: '/(root)/settings/libraries' },
|
||||||
},
|
{ title: 'Devices', routeId: '/(root)/settings/devices' }
|
||||||
{
|
] as const;
|
||||||
title: 'Appearance',
|
|
||||||
url: '/settings/appearance'
|
/**
|
||||||
},
|
* Whether a nested library item is the one being looked at.
|
||||||
{
|
*
|
||||||
title: 'Libraries',
|
* Both halves are needed: the route id alone is shared by every library, so
|
||||||
url: '/settings/libraries'
|
* matching on it would light all of them up at once.
|
||||||
},
|
*/
|
||||||
{
|
function isActiveLibrary(id: number) {
|
||||||
title: 'Devices',
|
return (
|
||||||
url: '/settings/devices'
|
page.route.id?.startsWith('/(root)/settings/libraries/[libraryId]') === true &&
|
||||||
|
page.params.libraryId === String(id)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
];
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-[calc(100vh-var(--header-height)-2rem)] flex-col">
|
<div class="flex h-[calc(100vh-var(--header-height)-2rem)] flex-col">
|
||||||
<h1 class="font-serif text-xl font-medium tracking-tight">Settings</h1>
|
<h1 class="font-serif text-xl font-medium tracking-tight">Settings</h1>
|
||||||
<div class="mt-6 flex flex-1 gap-8 overflow-hidden">
|
<div class="mt-6 flex flex-1 gap-8 overflow-hidden">
|
||||||
<nav class="flex w-48 shrink-0 flex-col gap-1">
|
<!-- Scrolls on its own now that it holds a list that grows with the number
|
||||||
|
of libraries; the row above it is overflow-hidden, so without this a
|
||||||
|
long list is clipped rather than reachable. -->
|
||||||
|
<nav class="flex w-48 shrink-0 flex-col gap-1 overflow-y-auto">
|
||||||
{#each items as item}
|
{#each items as item}
|
||||||
<a
|
<a
|
||||||
href={item.url}
|
href={resolve(item.routeId)}
|
||||||
class="rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted {page.url.pathname.endsWith(item.url)
|
class="rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted {page
|
||||||
|
.route.id === item.routeId
|
||||||
? 'bg-muted'
|
? 'bg-muted'
|
||||||
: 'text-muted-foreground'}"
|
: 'text-muted-foreground'}"
|
||||||
>
|
>
|
||||||
{item.title}
|
{item.title}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<!-- Libraries is the one item with children: a library's own settings
|
||||||
|
hang off it, so every library and every section it has stay one
|
||||||
|
click from here. -->
|
||||||
|
{#if item.routeId === '/(root)/settings/libraries'}
|
||||||
|
{#each libraryState.libraries as library (library.id)}
|
||||||
|
<a
|
||||||
|
href={resolve('/(root)/settings/libraries/[libraryId]/duplicates', {
|
||||||
|
libraryId: String(library.id)
|
||||||
|
})}
|
||||||
|
class="ml-3 truncate rounded-md border-l px-3 py-1.5 text-sm transition-colors hover:bg-muted {isActiveLibrary(
|
||||||
|
library.id
|
||||||
|
)
|
||||||
|
? 'bg-muted font-medium'
|
||||||
|
: 'text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{library.name}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="flex-1 overflow-auto">{@render children()}</div>
|
<div class="flex-1 overflow-auto">{@render children()}</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { logout } from '$lib/api';
|
import { logout } from '$lib/api';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
<Button
|
<Button
|
||||||
onclick={async () => {
|
onclick={async () => {
|
||||||
await logout();
|
await logout();
|
||||||
goto('/login');
|
goto(resolve('/login'));
|
||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
class="w-32"
|
class="w-32"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
import * as Card from '$lib/components/ui/card/index.js';
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
import { getLibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
@@ -36,7 +37,10 @@
|
|||||||
>{library.name[0]}</Table.Cell
|
>{library.name[0]}</Table.Cell
|
||||||
>
|
>
|
||||||
<Table.Cell class="font-medium">
|
<Table.Cell class="font-medium">
|
||||||
<a href={`/library/${library.id}`} class="hover:underline">{library.name}</a>
|
<a
|
||||||
|
href={resolve('/(root)/(library)/library/[libraryId]', { libraryId: String(library.id) })}
|
||||||
|
class="hover:underline">{library.name}</a
|
||||||
|
>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
<Table.Cell class="w-16 text-center">
|
<Table.Cell class="w-16 text-center">
|
||||||
<EllipsisVertical class="scale-75" />
|
<EllipsisVertical class="scale-75" />
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
|
||||||
|
// Set in (root)/+layout.svelte, above the settings group, so the libraries are
|
||||||
|
// already here — this pane needs no load function of its own.
|
||||||
|
const libraryState = getLibraryState();
|
||||||
|
|
||||||
|
const library = $derived(
|
||||||
|
libraryState.libraries.find((lib) => String(lib.id) === page.params.libraryId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// The strip exists so General and a danger zone have an obvious place to land;
|
||||||
|
// neither is built yet, and an empty tab is worse than no tab.
|
||||||
|
//
|
||||||
|
// Active state is matched on route id, not pathname, for the reason spelled
|
||||||
|
// out in settings/+layout.svelte.
|
||||||
|
const sections = [
|
||||||
|
{ title: 'Duplicates', routeId: '/(root)/settings/libraries/[libraryId]/duplicates' },
|
||||||
|
{ title: 'Import', routeId: '/(root)/settings/libraries/[libraryId]/import' }
|
||||||
|
] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">{library?.name ?? 'Library'}</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">Settings for this library</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex gap-1 border-b">
|
||||||
|
{#each sections as section (section.routeId)}
|
||||||
|
<a
|
||||||
|
href={resolve(section.routeId, { libraryId: page.params.libraryId! })}
|
||||||
|
class="-mb-px border-b-2 px-3 py-2 text-sm font-medium transition-colors hover:text-foreground {page
|
||||||
|
.route.id === section.routeId
|
||||||
|
? 'border-foreground'
|
||||||
|
: 'border-transparent text-muted-foreground'}"
|
||||||
|
>
|
||||||
|
{section.title}
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{@render children()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function load({ params }) {
|
||||||
|
// The library pane is its sections; land on the first one.
|
||||||
|
redirect(
|
||||||
|
303,
|
||||||
|
resolve('/(root)/settings/libraries/[libraryId]/duplicates', { libraryId: params.libraryId })
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { listBooks, listDuplicateBooks } from '$lib/api/book.remote.js';
|
||||||
|
|
||||||
|
export async function load({ params, depends }) {
|
||||||
|
// Dismissing or merging a group re-runs this, so the card leaves the screen.
|
||||||
|
depends('app:duplicate-books');
|
||||||
|
|
||||||
|
const groups = await listDuplicateBooks(params.libraryId);
|
||||||
|
|
||||||
|
// The groups carry only enough to render a card. Merging needs the whole record —
|
||||||
|
// identifiers, description, publisher — so fetch them in one go rather than per
|
||||||
|
// card, and let the dialog pick out the books for its own group.
|
||||||
|
const ids = [...new Set(groups.flatMap((group) => group.books.map((book) => book.book_id)))];
|
||||||
|
const books = ids.length
|
||||||
|
? await listBooks({ ids, pageSize: ids.length })
|
||||||
|
: { items: [] };
|
||||||
|
|
||||||
|
return { groups, books: books.items };
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { invalidate } from '$app/navigation';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { CopyCheck, Fingerprint, GitMerge, SlidersHorizontal, Type } from '@lucide/svelte';
|
||||||
|
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import * as Empty from '$lib/components/ui/empty/index.js';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||||
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
|
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
|
||||||
|
import BookImage from '$lib/components/view/book-image.svelte';
|
||||||
|
import GeneratedCover from '$lib/components/view/generated-cover.svelte';
|
||||||
|
import { dismissDuplicateBooks } from '$lib/api/book.remote';
|
||||||
|
import MergeBooks from '$lib/components/forms/merge-books/merge-books.svelte';
|
||||||
|
import { mergeInto } from '$lib/components/forms/merge-books/merge';
|
||||||
|
import type { Book, DuplicateBookGroup } from '$lib/schema';
|
||||||
|
|
||||||
|
let { data }: { data: { groups: DuplicateBookGroup[]; books: Book[] } } = $props();
|
||||||
|
|
||||||
|
// The group open in the workbench, or null when it is closed.
|
||||||
|
let merging = $state<DuplicateBookGroup | null>(null);
|
||||||
|
|
||||||
|
/** The full records behind a group, in the order the group lists them. */
|
||||||
|
function recordsFor(group: DuplicateBookGroup): Book[] {
|
||||||
|
return group.books
|
||||||
|
.map(({ book_id }) => data.books.find((book) => book.id === book_id))
|
||||||
|
.filter((book): book is Book => book !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which groups have a request in flight — a dismissal or a quick merge — so a
|
||||||
|
// slow round trip cannot be started twice.
|
||||||
|
let dismissing = $state<number[]>([]);
|
||||||
|
let quickMerging = $state<number[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge without opening the workbench, keeping the first book as it stands.
|
||||||
|
*
|
||||||
|
* The common case by far: the group is the same book twice, one record is as
|
||||||
|
* good as the other, and the point is to end up with one. Resolving metadata
|
||||||
|
* field by field is the other button.
|
||||||
|
*/
|
||||||
|
async function quickMerge(group: DuplicateBookGroup) {
|
||||||
|
const key = keyOf(group);
|
||||||
|
if (quickMerging.includes(key)) return;
|
||||||
|
|
||||||
|
const [survivor, ...folded] = recordsFor(group);
|
||||||
|
if (!survivor || folded.length === 0) return;
|
||||||
|
|
||||||
|
quickMerging = [...quickMerging, key];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mergeInto(page.params.libraryId!, survivor, folded);
|
||||||
|
} finally {
|
||||||
|
quickMerging = quickMerging.filter((id) => id !== key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A group is named by its lowest book id, which the backend orders it by. */
|
||||||
|
function keyOf(group: DuplicateBookGroup) {
|
||||||
|
return group.books[0].book_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reasonLabel(reason: string) {
|
||||||
|
if (reason === 'identifier') return 'Same identifier';
|
||||||
|
if (reason === 'title-author') return 'Same title and author';
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismiss every pairing in a group at once.
|
||||||
|
*
|
||||||
|
* A group is held together pair by pair, so saying "these are not duplicates"
|
||||||
|
* about three books means saying it about all three pairs — dismissing only the
|
||||||
|
* first would leave the rest of the group standing and the screen unchanged.
|
||||||
|
*/
|
||||||
|
async function notDuplicates(group: DuplicateBookGroup) {
|
||||||
|
const key = keyOf(group);
|
||||||
|
if (dismissing.includes(key)) return;
|
||||||
|
|
||||||
|
dismissing = [...dismissing, key];
|
||||||
|
|
||||||
|
const ids = group.books.map((book) => book.book_id);
|
||||||
|
const pairs = ids.flatMap((a, index) =>
|
||||||
|
ids.slice(index + 1).map((b) => ({ book_a_id: a, book_b_id: b }))
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Promise.all(pairs.map((pair) => dismissDuplicateBooks(pair)));
|
||||||
|
await invalidate('app:duplicate-books');
|
||||||
|
toast.success('Marked as different books');
|
||||||
|
} catch {
|
||||||
|
toast.error('Could not mark these as different books');
|
||||||
|
} finally {
|
||||||
|
dismissing = dismissing.filter((id) => id !== key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- No max width and no padding of its own: the settings pane sets the width and
|
||||||
|
is the thing that scrolls, so a wrapper that centres inside it only makes the
|
||||||
|
cards narrower than they need to be. -->
|
||||||
|
<div class="flex flex-col gap-6 pb-4">
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Books whose metadata matches. A second edition, a translation and a different scan of one book
|
||||||
|
all look like this, so nothing here has been changed or removed — this is a list to read, not a
|
||||||
|
problem to fix.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if data.groups.length === 0}
|
||||||
|
<Empty.Root>
|
||||||
|
<Empty.Header>
|
||||||
|
<Empty.Media variant="icon">
|
||||||
|
<CopyCheck />
|
||||||
|
</Empty.Media>
|
||||||
|
<Empty.Title>Nothing looks duplicated</Empty.Title>
|
||||||
|
<Empty.Description>
|
||||||
|
No two books in this library share an identifier, or a title and an author.
|
||||||
|
</Empty.Description>
|
||||||
|
</Empty.Header>
|
||||||
|
</Empty.Root>
|
||||||
|
{:else}
|
||||||
|
{#each data.groups as group (keyOf(group))}
|
||||||
|
{@const busy = dismissing.includes(keyOf(group)) || quickMerging.includes(keyOf(group))}
|
||||||
|
{@const records = recordsFor(group)}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>{group.books.length} books look like the same book</Card.Title>
|
||||||
|
<Card.Action class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onclick={() => notDuplicates(group)}
|
||||||
|
>
|
||||||
|
Not duplicates
|
||||||
|
</Button>
|
||||||
|
<!-- A choice rather than a jump straight into the workbench:
|
||||||
|
most groups are one book twice, where there is nothing to
|
||||||
|
resolve and the dialog is a step in the way.
|
||||||
|
|
||||||
|
Disabled until every record in the group came back, so
|
||||||
|
neither action can run on a partial group. -->
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
class={buttonVariants({ size: 'sm' })}
|
||||||
|
disabled={busy || records.length !== group.books.length}
|
||||||
|
>
|
||||||
|
Merge…
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item onclick={() => quickMerge(group)}>
|
||||||
|
<GitMerge class="size-4 shrink-0" />
|
||||||
|
Merge into first selected
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
<DropdownMenu.Item onclick={() => (merging = group)}>
|
||||||
|
<SlidersHorizontal class="size-4 shrink-0" />
|
||||||
|
Manual merge…
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Card.Action>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content>
|
||||||
|
<ul class="flex flex-wrap gap-4">
|
||||||
|
{#each group.books as book (book.book_id)}
|
||||||
|
<li class="w-36">
|
||||||
|
<a
|
||||||
|
href={resolve('/(root)/(library)/book/[bookId]', {
|
||||||
|
bookId: String(book.book_id)
|
||||||
|
})}
|
||||||
|
class="group flex flex-col gap-2"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block aspect-9/12 w-full overflow-hidden rounded-sm bg-muted shadow-md transition-all group-hover:brightness-75"
|
||||||
|
>
|
||||||
|
{#if book.cover_image}
|
||||||
|
<BookImage src="/api/{book.cover_image}" class="h-full w-full object-cover" />
|
||||||
|
{:else}
|
||||||
|
<!-- A blank swatch here is worse than anywhere else: this screen
|
||||||
|
is a side-by-side comparison, and two of them are impossible
|
||||||
|
to tell apart. The candidate carries author names rather than
|
||||||
|
records, which is all the cover draws. -->
|
||||||
|
<GeneratedCover
|
||||||
|
book={{
|
||||||
|
title: book.title,
|
||||||
|
authors: book.authors.map((name) => ({ name }))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="line-clamp-2 font-serif text-sm group-hover:underline">
|
||||||
|
{book.title}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<p class="line-clamp-2 text-xs text-muted-foreground">
|
||||||
|
{book.authors.join(', ')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Why this book is in the group, so the reader can judge the
|
||||||
|
evidence rather than take the grouping on trust. -->
|
||||||
|
<p class="mt-1 flex flex-wrap gap-1">
|
||||||
|
{#each book.matched_on as reason (reason)}
|
||||||
|
<Badge variant="secondary" class="gap-1 text-[10px] font-normal">
|
||||||
|
{#if reason === 'identifier'}
|
||||||
|
<Fingerprint class="size-3" />
|
||||||
|
{:else}
|
||||||
|
<Type class="size-3" />
|
||||||
|
{/if}
|
||||||
|
{reasonLabel(reason)}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Keyed on the group so a second merge starts from that group's records rather
|
||||||
|
than the previous one's draft, the same way the edit dialog keys on the book.
|
||||||
|
-->
|
||||||
|
{#if merging}
|
||||||
|
{#key merging.books[0].book_id}
|
||||||
|
<MergeBooks
|
||||||
|
books={recordsFor(merging)}
|
||||||
|
libraryId={page.params.libraryId!}
|
||||||
|
bind:open={
|
||||||
|
() => merging !== null,
|
||||||
|
(value) => {
|
||||||
|
// Bound, not passed as a bare `true`: the dialog closes itself on an
|
||||||
|
// outside click or Escape, and if that never reaches `merging` the
|
||||||
|
// group stays set — the menu item then re-assigns the same group,
|
||||||
|
// nothing changes, and the workbench never reopens.
|
||||||
|
if (!value) merging = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onmerged={() => (merging = null)}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { BookOpen, Loader2, TriangleAlert, Upload } from '@lucide/svelte';
|
||||||
|
|
||||||
|
import * as Card from '$lib/components/ui/card/index.js';
|
||||||
|
import * as Field from '$lib/components/ui/field/index.js';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import { Button } from '$lib/components/ui/button/index.js';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||||
|
import { Progress } from '$lib/components/ui/progress/index.js';
|
||||||
|
import { cancelCalibreImport, getCalibreImport } from '$lib/api/calibre-import.remote';
|
||||||
|
import { formatFileSize } from '$lib/utils';
|
||||||
|
import type { CalibreImport } from '$lib/schema/library';
|
||||||
|
|
||||||
|
const libraryId = $derived(page.params.libraryId!);
|
||||||
|
|
||||||
|
let archive = $state<File | null>(null);
|
||||||
|
let allowDuplicates = $state(false);
|
||||||
|
|
||||||
|
let job = $state<CalibreImport | null>(null);
|
||||||
|
|
||||||
|
let busy = $state(false);
|
||||||
|
let problem = $state<string | null>(null);
|
||||||
|
|
||||||
|
/** How much of the archive has reached the server, 0–1, while it is going up. */
|
||||||
|
let uploaded = $state<number | null>(null);
|
||||||
|
|
||||||
|
/** How often a running import is asked where it has got to. */
|
||||||
|
const POLL_MS = 1000;
|
||||||
|
let poll: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
const running = $derived(job?.state === 'running');
|
||||||
|
|
||||||
|
const percent = $derived(
|
||||||
|
job && job.total > 0 ? Math.round((job.processed / job.total) * 100) : 0
|
||||||
|
);
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
clearInterval(poll);
|
||||||
|
poll = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(stopPolling);
|
||||||
|
|
||||||
|
function pick(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement;
|
||||||
|
archive = input.files?.[0] ?? null;
|
||||||
|
problem = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backend's own message for a failed proxied response.
|
||||||
|
*
|
||||||
|
* The proxy wraps the upstream body in SvelteKit's error envelope, so the useful
|
||||||
|
* `detail` is one or two layers down.
|
||||||
|
*/
|
||||||
|
function detailOf(body: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
|
||||||
|
if (typeof parsed?.detail === 'string') return parsed.detail;
|
||||||
|
if (typeof parsed?.message === 'string') return detailOf(parsed.message);
|
||||||
|
} catch {
|
||||||
|
// Not JSON — the raw text is the best there is.
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow a running import until it lands.
|
||||||
|
*
|
||||||
|
* Polled rather than pushed: the job lives on the server, so this survives a reload
|
||||||
|
* and does not depend on the tab that started it staying open.
|
||||||
|
*/
|
||||||
|
function follow(jobId: string) {
|
||||||
|
stopPolling();
|
||||||
|
|
||||||
|
poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
job = await getCalibreImport(jobId);
|
||||||
|
} catch (error) {
|
||||||
|
problem = error instanceof Error ? error.message : 'Lost track of the import';
|
||||||
|
stopPolling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (job.state !== 'running') {
|
||||||
|
stopPolling();
|
||||||
|
announce(job);
|
||||||
|
}
|
||||||
|
}, POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function announce(finished: CalibreImport) {
|
||||||
|
const created = `${finished.created} book${finished.created === 1 ? '' : 's'} imported`;
|
||||||
|
|
||||||
|
if (finished.state === 'failed') toast.error(finished.error ?? 'The import failed');
|
||||||
|
else if (finished.state === 'cancelled') toast.info(`Import stopped — ${created}`);
|
||||||
|
else if (finished.failed > 0) toast.warning(`${created}, ${finished.failed} failed`);
|
||||||
|
else toast.success(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the archive and start the import it becomes.
|
||||||
|
*
|
||||||
|
* `XMLHttpRequest` rather than `fetch` for the one thing fetch cannot do: report how
|
||||||
|
* much of the body has gone up. On a large archive that is the only progress there is
|
||||||
|
* for minutes at a time.
|
||||||
|
*
|
||||||
|
* It goes through the proxy so the browser streams straight to the backend — a remote
|
||||||
|
* function would put the whole archive through the SvelteKit process first.
|
||||||
|
*/
|
||||||
|
function send(file: File): Promise<CalibreImport> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = new XMLHttpRequest();
|
||||||
|
|
||||||
|
request.open('POST', `/api/libraries/${libraryId}/imports/calibre/upload`);
|
||||||
|
|
||||||
|
request.upload.addEventListener('progress', (event) => {
|
||||||
|
if (event.lengthComputable) uploaded = event.loaded / event.total;
|
||||||
|
});
|
||||||
|
|
||||||
|
request.addEventListener('load', () => {
|
||||||
|
if (request.status >= 200 && request.status < 300) {
|
||||||
|
resolve(JSON.parse(request.responseText));
|
||||||
|
} else {
|
||||||
|
reject(new Error(detailOf(request.responseText)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
request.addEventListener('error', () => reject(new Error('The upload failed')));
|
||||||
|
request.addEventListener('abort', () => reject(new Error('The upload was stopped')));
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.append('archive', file);
|
||||||
|
body.append('allow_duplicates', String(allowDuplicates));
|
||||||
|
|
||||||
|
request.send(body);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (!archive) return;
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
problem = null;
|
||||||
|
uploaded = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
job = await send(archive);
|
||||||
|
follow(job.id);
|
||||||
|
} catch (error) {
|
||||||
|
problem = error instanceof Error ? error.message : 'Could not upload the archive';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
uploaded = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (!job) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
job = await cancelCalibreImport(job.id);
|
||||||
|
} catch (error) {
|
||||||
|
problem = error instanceof Error ? error.message : 'Could not stop the import';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
stopPolling();
|
||||||
|
job = null;
|
||||||
|
problem = null;
|
||||||
|
archive = null;
|
||||||
|
uploaded = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Import from Calibre</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Zip your Calibre library folder and upload it here. Nothing is taken from the original — the
|
||||||
|
books are copied in, and uploading the same library again only picks up what is new.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content class="flex flex-col gap-4">
|
||||||
|
<Field.Field>
|
||||||
|
<Field.Label for="archive">Zipped Calibre library</Field.Label>
|
||||||
|
<!--
|
||||||
|
The native file button inherits the input's own text styling, which leaves
|
||||||
|
"Browse…" looking like the first half of the sentence "Browse… No file
|
||||||
|
selected." The `file:` variants target ::file-selector-button, so it can be
|
||||||
|
made to read as a button without replacing the input with a custom one.
|
||||||
|
Styled here rather than in `ui/input`, which is generated.
|
||||||
|
-->
|
||||||
|
<Input
|
||||||
|
id="archive"
|
||||||
|
type="file"
|
||||||
|
accept=".zip,application/zip"
|
||||||
|
onchange={pick}
|
||||||
|
disabled={running || busy}
|
||||||
|
class="py-1.5 file:mr-3 file:cursor-pointer file:rounded-sm file:border file:border-input file:bg-secondary file:px-2 file:py-0.5 file:text-secondary-foreground file:hover:bg-secondary/80"
|
||||||
|
/>
|
||||||
|
<Field.Description>
|
||||||
|
The zip has to contain <code class="font-mono text-xs">metadata.db</code> — zip the whole Calibre
|
||||||
|
folder rather than just the books.
|
||||||
|
</Field.Description>
|
||||||
|
</Field.Field>
|
||||||
|
|
||||||
|
{#if archive}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{archive.name} · {formatFileSize(archive.size)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox id="allow-duplicates" bind:checked={allowDuplicates} disabled={running || busy} />
|
||||||
|
<label for="allow-duplicates" class="text-sm">
|
||||||
|
Import books this library already holds
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if uploaded !== null}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<Progress value={Math.round(uploaded * 100)} />
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Uploading · {Math.round(uploaded * 100)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if problem}
|
||||||
|
<p class="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
<TriangleAlert class="size-4 shrink-0" />
|
||||||
|
{problem}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
|
||||||
|
<Card.Footer class="gap-2">
|
||||||
|
{#if running}
|
||||||
|
<Button variant="outline" onclick={stop}>Stop after this book</Button>
|
||||||
|
{:else if job}
|
||||||
|
<Button onclick={reset}>Import another</Button>
|
||||||
|
{:else}
|
||||||
|
<!--
|
||||||
|
No preview step: the catalogue cannot be read until the archive is on the
|
||||||
|
server, and by then it has been carried across anyway. Unpacking it is what
|
||||||
|
refuses an archive that is not a Calibre library.
|
||||||
|
-->
|
||||||
|
<Button onclick={start} disabled={busy || !archive}>
|
||||||
|
{#if busy}
|
||||||
|
<Loader2 class="size-4 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<Upload class="size-4" />
|
||||||
|
{/if}
|
||||||
|
Upload and import
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
{#if job}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
{#if running}<Loader2 class="size-4 animate-spin" />{/if}
|
||||||
|
{running ? 'Importing' : job.state === 'failed' ? 'Import failed' : 'Import finished'}
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
{#if running && job.current_title}
|
||||||
|
{job.processed} of {job.total} · {job.current_title}
|
||||||
|
{:else if job.state === 'cancelled'}
|
||||||
|
Stopped after {job.processed} of {job.total}. Everything imported is complete.
|
||||||
|
{:else if job.state === 'failed'}
|
||||||
|
{job.error ?? 'The import stopped before it finished.'}
|
||||||
|
{:else}
|
||||||
|
{job.processed} of {job.total} books considered.
|
||||||
|
{/if}
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content class="flex flex-col gap-4">
|
||||||
|
<Progress value={percent} />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-lg tabular-nums">{job.created}</p>
|
||||||
|
<p class="text-muted-foreground">imported</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-lg tabular-nums">{job.skipped}</p>
|
||||||
|
<p class="text-muted-foreground">already here</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-mono text-lg tabular-nums">{job.failed}</p>
|
||||||
|
<p class="text-muted-foreground">failed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if job.created > 0}
|
||||||
|
<a
|
||||||
|
href={resolve('/(root)/(library)/library/[libraryId]/view', { libraryId })}
|
||||||
|
class="inline-flex w-fit items-center gap-2 text-sm hover:underline"
|
||||||
|
>
|
||||||
|
<BookOpen class="size-4" />
|
||||||
|
See the books
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!--
|
||||||
|
An import into a library that already holds books is the likeliest way to
|
||||||
|
end up with two records for one book, and that screen already handles them.
|
||||||
|
-->
|
||||||
|
{#if job.possible_duplicates > 0}
|
||||||
|
<div class="rounded-lg border p-3 text-sm">
|
||||||
|
<p>
|
||||||
|
{job.possible_duplicates} imported book{job.possible_duplicates === 1 ? '' : 's'}
|
||||||
|
look{job.possible_duplicates === 1 ? 's' : ''} like something this library already had.
|
||||||
|
They were imported all the same — a metadata match is a guess.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={resolve('/(root)/settings/libraries/[libraryId]/duplicates', { libraryId })}
|
||||||
|
class="mt-2 inline-block font-medium hover:underline"
|
||||||
|
>
|
||||||
|
Review duplicates
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if job.failures?.length}
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="text-sm font-medium">Books that could not be imported</p>
|
||||||
|
<div class="overflow-x-auto rounded-lg border">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head class="w-[80px]">Calibre</Table.Head>
|
||||||
|
<Table.Head>Title</Table.Head>
|
||||||
|
<Table.Head>Reason</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each job.failures as failure (failure.calibre_id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-mono text-xs">#{failure.calibre_id}</Table.Cell>
|
||||||
|
<Table.Cell>{failure.title}</Table.Cell>
|
||||||
|
<Table.Cell class="font-mono text-xs">{failure.reason}</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -30,6 +30,20 @@ async function handleResponse(response: Response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The body to forward, as fetch init options.
|
||||||
|
*
|
||||||
|
* Passed through as a stream rather than read into memory first: this process should
|
||||||
|
* never hold a whole upload, which for a zipped Calibre library could be many
|
||||||
|
* gigabytes. A request with no body contributes nothing, since `duplex` without a body
|
||||||
|
* is rejected.
|
||||||
|
*/
|
||||||
|
function bodyOf(request: Request) {
|
||||||
|
if (!request.body) return {};
|
||||||
|
|
||||||
|
return { body: request.body, duplex: 'half' } as RequestInit;
|
||||||
|
}
|
||||||
|
|
||||||
// Shared function to prepare the request with authentication
|
// Shared function to prepare the request with authentication
|
||||||
function prepareRequest(locals: App.Locals, request: Request) {
|
function prepareRequest(locals: App.Locals, request: Request) {
|
||||||
const token = locals.authToken || 'server-default-token';
|
const token = locals.authToken || 'server-default-token';
|
||||||
@@ -76,13 +90,14 @@ export const POST: RequestHandler = async ({ params, locals, fetch, request, url
|
|||||||
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
||||||
const headers = prepareRequest(locals, request);
|
const headers = prepareRequest(locals, request);
|
||||||
|
|
||||||
// Get the request body
|
|
||||||
const body = await request.arrayBuffer();
|
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
const response = await fetch(backendUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers,
|
headers,
|
||||||
body
|
// Streamed, not buffered. `arrayBuffer()` held the whole upload in this
|
||||||
|
// process before forwarding a byte of it, which is survivable for one book
|
||||||
|
// and not for a zipped Calibre library. `duplex: 'half'` is required by the
|
||||||
|
// fetch spec whenever the body is a stream.
|
||||||
|
...bodyOf(request)
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
@@ -100,13 +115,10 @@ export const PATCH: RequestHandler = async ({ params, locals, fetch, request, ur
|
|||||||
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
const backendUrl = `${BACKEND_API_URL}/${path}${queryString}`;
|
||||||
const headers = prepareRequest(locals, request);
|
const headers = prepareRequest(locals, request);
|
||||||
|
|
||||||
// Get the request body
|
|
||||||
const body = await request.arrayBuffer();
|
|
||||||
|
|
||||||
const response = await fetch(backendUrl, {
|
const response = await fetch(backendUrl, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers,
|
headers,
|
||||||
body
|
...bodyOf(request)
|
||||||
});
|
});
|
||||||
|
|
||||||
return handleResponse(response);
|
return handleResponse(response);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import '../../app.css';
|
import '../../app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
|
import ThemeToggle from '$lib/components/layout/theme-toggle.svelte';
|
||||||
@@ -10,12 +11,11 @@
|
|||||||
<link rel="icon" href={favicon} />
|
<link rel="icon" href={favicon} />
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
|
|
||||||
<div class="flex h-screen min-h-screen flex-col">
|
<div class="flex h-screen min-h-screen flex-col">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-4 pt-4 pl-6">
|
<div class="flex items-center gap-4 pt-4 pl-6">
|
||||||
<p class="text-3xl">📚</p>
|
<p class="text-3xl">📚</p>
|
||||||
<a href="/" class="text-2xl font-semibold">chitai</a>
|
<a href={resolve('/')} class="text-2xl font-semibold">chitai</a>
|
||||||
|
|
||||||
<ThemeToggle class="mr-4 ml-auto" />
|
<ThemeToggle class="mr-4 ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user