mirror of
https://github.com/chmouel/liseur-sync.git
synced 2026-10-19 11:23:25 +00:00
The per-user budget counted only the translated text, so rows of tiny text spent almost none of it and the row count grew without bound. Each entry now spends its hash and its text in both the total and the eviction measure. The per-user stamp table also gains the cascading user reference its sibling tables carry, and the OpenAPI push schema marks the translations array as non-empty, as the route already refuses it.
3913 lines
171 KiB
YAML
3913 lines
171 KiB
YAML
openapi: 3.1.0
|
|
info:
|
|
title: liseur-sync native API
|
|
version: "1.0"
|
|
description: |
|
|
Reading-position sync and reading-statistics API.
|
|
|
|
Conventions that apply everywhere:
|
|
|
|
- **Auth**: native `/v1` endpoints except `/v1/login` and
|
|
`/v1/register` require `Authorization: Bearer <token>`;
|
|
`/healthz` is unauthenticated. OPDS uses HTTP Basic with a device
|
|
token secret. Login returns a short-lived *auth token* usable only
|
|
for token management; device tokens carry one or more scopes
|
|
(`sync`, `read-insights`, `library-read`, `library-manage`,
|
|
`library-upload`, `library-delete`, `admin`).
|
|
- **Folder access**: catalog scopes and folder grants are independent.
|
|
Catalog listings contain only books from folders explicitly granted to
|
|
the token's account. Direct access to an inaccessible folder, book or
|
|
entity returns `404`; administrator status does not bypass this rule.
|
|
- **Identity**: books are *works*. Clients never need a work to
|
|
exist before pushing: call `/v1/works/resolve` with every
|
|
identifier you have and use the returned `work_id`.
|
|
- **Idempotency**: `POST /v1/ops` and `POST /v1/sessions` are safe
|
|
to retry. Replaying the same `op_id`/`session_id` with the same
|
|
payload reports `duplicate`; the same id with a different payload
|
|
is a conflict (never an overwrite).
|
|
- **Delta sync**: `GET /v1/changes?since=<seq>` streams every op
|
|
after `seq` with a high-water mark. If your cursor falls below
|
|
the compaction horizon the server answers 410 `resync_required`;
|
|
fetch `/v1/heads` and resume from its `snapshot_seq`.
|
|
- **Errors**: JSON `{"error": "..."}`. Malformed input is always a
|
|
4xx with a reason, never a 5xx.
|
|
license:
|
|
name: MIT
|
|
url: https://opensource.org/license/mit
|
|
|
|
servers:
|
|
- url: https://your-instance.example.com
|
|
|
|
tags:
|
|
- name: auth
|
|
- name: identity
|
|
- name: sync
|
|
- name: sessions
|
|
- name: insights
|
|
- name: library
|
|
- name: opds
|
|
|
|
paths:
|
|
/v1/events:
|
|
get:
|
|
tags: [sync]
|
|
security: [{ deviceToken: [] }]
|
|
summary: Subscribe to live reading-state invalidations
|
|
description: |
|
|
Opens an authenticated Server-Sent Events stream for this account.
|
|
The `sync` scope permits `positions` and `annotations`;
|
|
`read-insights` permits `insights`. A credential with no eligible
|
|
topic is refused. Reader tokens do not need insight authority.
|
|
|
|
Registration precedes an opening invalidation of all permitted
|
|
topics. Later invalidations coalesce topics by union. There is no
|
|
event ID, sequence number, `since` parameter or replay history.
|
|
Unknown topics should be ignored. Read the affected topic through
|
|
its existing feed or snapshot endpoint; keep durable cursors there.
|
|
|
|
Comment heartbeats arrive every 20 seconds; clients should reconnect
|
|
after 60 seconds without bytes, using bounded backoff and jitter.
|
|
The stream ends at token expiry or when revalidation finds revoked
|
|
authority. After headers have been sent, these conditions close
|
|
the connection rather than returning a new HTTP status.
|
|
The current limit is eight concurrent streams per account.
|
|
responses:
|
|
"200":
|
|
description: |
|
|
SSE frames containing topic invalidations, comment heartbeats
|
|
and retry advice in milliseconds. Each event ends with a blank
|
|
line. The stream carries no book, device or reading position.
|
|
headers:
|
|
Cache-Control:
|
|
schema: { type: string, const: no-store }
|
|
X-Accel-Buffering:
|
|
schema: { type: string, const: "no" }
|
|
content:
|
|
text/event-stream:
|
|
schema: { type: string }
|
|
example: "retry: 5000\n\nevent: invalidate\ndata: {\"topics\":[\"positions\",\"annotations\"]}\n\n: ping\n\n"
|
|
"401": { $ref: "#/components/responses/Error", description: "Missing, invalid, revoked or expired token" }
|
|
"403": { $ref: "#/components/responses/Error", description: "No eligible topic, or HTTPS required" }
|
|
"429":
|
|
description: Too many concurrent streams for this account
|
|
headers:
|
|
Retry-After:
|
|
description: Reconnect delay in seconds
|
|
schema: { type: integer, example: 5 }
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
error: { type: string }
|
|
"503": { $ref: "#/components/responses/Error", description: "Live events are not enabled" }
|
|
|
|
/healthz:
|
|
get:
|
|
summary: Liveness probe (unauthenticated)
|
|
description: |
|
|
Also reports which build is answering, so an operator can tell a
|
|
stale container from a current one without signing in. The image
|
|
carries no OCI labels; this is the stamp from the binary itself.
|
|
security: []
|
|
responses:
|
|
"200":
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status: { type: string, example: ok }
|
|
version:
|
|
type: string
|
|
description: Release tag, an abbreviated revision, or `dev`; suffixed `(modified)` when built from a dirty tree.
|
|
example: v1.2.3
|
|
revision:
|
|
type: string
|
|
description: Abbreviated VCS revision. Omitted when unknown.
|
|
example: 46ac8da1b2c3
|
|
|
|
/v1/login:
|
|
post:
|
|
tags: [auth]
|
|
security: []
|
|
summary: Exchange credentials for a short-lived auth token
|
|
description: |
|
|
The auth token (1 h) authorises token management only; it cannot
|
|
sync or read insights. Requires HTTPS unless the instance sets
|
|
`insecure_http`.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [username, password]
|
|
properties:
|
|
username: { type: string }
|
|
password: { type: string }
|
|
responses:
|
|
"200":
|
|
description: Authenticated
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
auth_token: { type: string, description: "Short-lived (1 h) token-management credential" }
|
|
expires_in: { type: integer, description: seconds }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"403": { $ref: "#/components/responses/Error", description: "HTTPS required" }
|
|
"429": { $ref: "#/components/responses/RateLimited" }
|
|
|
|
/v1/register:
|
|
post:
|
|
tags: [auth]
|
|
security: []
|
|
summary: Create an account with an invite code
|
|
description: Registration is invite-only. Invite codes are single-use and expire.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [invite, username, password]
|
|
properties:
|
|
invite: { type: string }
|
|
username: { type: string }
|
|
password: { type: string, minLength: 8 }
|
|
timezone:
|
|
type: string
|
|
description: IANA name. Omitted or invalid values use UTC.
|
|
responses:
|
|
"201":
|
|
description: Account created
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
user_id: { type: string }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"403": { $ref: "#/components/responses/Error", description: "Invalid or expired invite" }
|
|
"409": { $ref: "#/components/responses/Error", description: "Username taken" }
|
|
"429": { $ref: "#/components/responses/RateLimited", description: "Per-IP limit or shared password capacity reached. The invite has not been consumed." }
|
|
|
|
/v1/token:
|
|
get:
|
|
tags: [auth]
|
|
summary: Describe the calling token
|
|
description: |
|
|
Returns the device token that made the request: its id, its
|
|
device id, its name and its scopes. Call it once at startup and
|
|
draw the interface from the answer, rather than probing routes
|
|
and reading `403`s.
|
|
|
|
Authenticated like every other route — an absent, revoked or
|
|
expired token gets `401` — but it requires no particular scope.
|
|
The subject of the response is the credential presenting it, so
|
|
the narrowest token can ask about itself.
|
|
|
|
It never returns a secret, a secret hash, or any token other
|
|
than the caller's. To enumerate the account's tokens, use
|
|
`GET /v1/tokens`, which needs the login credential.
|
|
|
|
`HEAD` is served as the status line of the `GET`, which is what
|
|
makes it a cheap liveness check for a stored credential. Every
|
|
other method is `405`.
|
|
|
|
Like every authenticated route, a call updates the token's
|
|
last-used timestamp. Nothing about the credential's authority —
|
|
its scopes, device binding, expiry or secret — changes.
|
|
security: [{ deviceToken: [] }]
|
|
responses:
|
|
"200":
|
|
description: The calling token
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/TokenSelf" }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
head:
|
|
tags: [auth]
|
|
summary: Check the calling token is still live
|
|
description: |
|
|
The status line of the `GET`, with no body. The cheapest way for
|
|
a client holding a stored secret to find out whether it is still
|
|
good before using it: `200` if the token authenticates, `401` if
|
|
it is absent, revoked or expired.
|
|
security: [{ deviceToken: [] }]
|
|
responses:
|
|
"200": { description: The token is live }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/tokens:
|
|
post:
|
|
tags: [auth]
|
|
summary: Create a device token
|
|
description: |
|
|
Requires the login auth token (not a device token). The token
|
|
secret is returned once; store it. Each token is bound to a
|
|
server-generated `device_id` that stamps every op and session it
|
|
pushes.
|
|
|
|
`admin` scope may only be requested by a caller who already
|
|
holds an active admin token; otherwise the request is refused
|
|
with `403`. The first admin token on an instance is minted with
|
|
`liseur-sync admin mint-token -scope admin`.
|
|
|
|
Legacy clients may send `scope`. New clients send `scopes`.
|
|
Supplying both is accepted only when they describe the same
|
|
singleton set. Responses always return `scopes` and return the
|
|
deprecated `scope` field only for singleton sets.
|
|
security: [{ loginAuth: [] }]
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [name]
|
|
anyOf:
|
|
- required: [scope]
|
|
- required: [scopes]
|
|
properties:
|
|
name: { type: string, example: "Boox Palma" }
|
|
scope:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/Scope" }
|
|
deprecated: true
|
|
description: "Legacy singleton form."
|
|
scopes: { $ref: "#/components/schemas/ScopeSet" }
|
|
expires_in_seconds: { type: integer }
|
|
device_id:
|
|
type: string
|
|
maxLength: 64
|
|
description: |
|
|
Ask the new token to carry an existing device identity
|
|
instead of a fresh one. Accepted only if a token of
|
|
the same account — live, expired or revoked — already
|
|
carries it; a client that stored the id from a lapsed
|
|
credential stays one device in the op log and its
|
|
replays stay `duplicate`. An id this account never
|
|
carried (a deleted predecessor, or another account's)
|
|
is refused with `400` and `code: unknown_device`.
|
|
A device id is a label, not a credential: two live
|
|
tokens may share one and are then one device. Older
|
|
servers ignore the field; compare the `device_id` in
|
|
the response with the one you asked for.
|
|
responses:
|
|
"201":
|
|
description: Token created; secret shown once
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [token_id, device_id, name, scopes, secret, expires_at]
|
|
properties:
|
|
token_id: { type: string }
|
|
device_id: { type: string }
|
|
name: { type: string }
|
|
scope:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/Scope" }
|
|
deprecated: true
|
|
description: "Present only for singleton scope sets."
|
|
scopes: { $ref: "#/components/schemas/ScopeSet" }
|
|
secret: { type: string, description: "Bearer secret, returned exactly once" }
|
|
expires_at: { type: [string, "null"], format: date-time }
|
|
"400":
|
|
description: |
|
|
Malformed request, or `device_id` names no device of this
|
|
account (body carries `code: unknown_device` and echoes
|
|
`device_id`).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [error]
|
|
properties:
|
|
error: { type: string }
|
|
code: { type: string, enum: [unknown_device] }
|
|
device_id: { type: string }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"403":
|
|
description: |
|
|
`admin` scope requested by a caller who is not already an
|
|
admin.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties: { error: { type: string } }
|
|
get:
|
|
tags: [auth]
|
|
summary: List your tokens
|
|
security: [{ loginAuth: [] }]
|
|
responses:
|
|
"200":
|
|
description: Token list (secrets never included)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
tokens:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/TokenInfo" }
|
|
|
|
/v1/tokens/{id}:
|
|
patch:
|
|
tags: [auth]
|
|
summary: Update a device token's scopes in place
|
|
description: |
|
|
Preserves the token id, device id, and secret. Requires the
|
|
short-lived login credential. Adding `admin` requires the user
|
|
to already hold an active admin token.
|
|
security: [{ loginAuth: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
anyOf:
|
|
- required: [scope]
|
|
- required: [scopes]
|
|
properties:
|
|
scope:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/Scope" }
|
|
deprecated: true
|
|
scopes: { $ref: "#/components/schemas/ScopeSet" }
|
|
responses:
|
|
"200":
|
|
description: Token scopes updated in place
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [status, scopes]
|
|
properties:
|
|
status: { type: string, const: updated }
|
|
scope:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/Scope" }
|
|
deprecated: true
|
|
description: "Present only for singleton scope sets."
|
|
scopes: { $ref: "#/components/schemas/ScopeSet" }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"403": { $ref: "#/components/responses/Error" }
|
|
"404": { $ref: "#/components/responses/Error" }
|
|
delete:
|
|
tags: [auth]
|
|
summary: Revoke a token
|
|
security: [{ loginAuth: [] }]
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
"200": { $ref: "#/components/responses/StatusOK" }
|
|
"404": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/me:
|
|
get:
|
|
tags: [auth]
|
|
summary: The account behind this credential
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
description: |
|
|
Reads the token's own account — never a path parameter, so there
|
|
is no id here to get wrong. It exists for a client that holds a
|
|
token and nothing else: the browser reader served from the
|
|
separate reader origin has no session and no user, so it asks
|
|
once the token is in hand.
|
|
|
|
`reader_prompt_template` is the text the account keeps for the
|
|
reader's "copy a prompt about this passage" button, with
|
|
`{title} {author} {series} {chapter} {page} {pages} {percent}
|
|
{text}` substituted at the moment of the copy. The server never
|
|
interprets it. It is empty unless the account wrote one, and an
|
|
empty one means the reader shows no button at all.
|
|
responses:
|
|
"200":
|
|
description: The account
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [id, name, timezone, reader_prompt_template]
|
|
properties:
|
|
id: { type: string }
|
|
name: { type: string }
|
|
timezone:
|
|
type: string
|
|
description: IANA name; the account-local day statistics are attributed to.
|
|
reader_prompt_template:
|
|
type: string
|
|
description: Empty when the account has not written one.
|
|
|
|
/v1/me/settings:
|
|
get:
|
|
tags: [sync]
|
|
summary: Read this device's stored settings
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
description: |
|
|
Returns every key/value pair stored for the calling device, the
|
|
one the token belongs to. Another device of the same account
|
|
never sees them (ADR-0050). The server treats keys and values as
|
|
opaque strings; which keys exist and what values mean is the
|
|
client's business.
|
|
|
|
`scope` is always `device`. A server that answers without it is
|
|
older and its map is account-wide; a client keeping settings per
|
|
device should adopt nothing from it.
|
|
responses:
|
|
"200":
|
|
description: The settings map (empty object when none are stored)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [settings, scope]
|
|
properties:
|
|
settings:
|
|
type: object
|
|
additionalProperties:
|
|
$ref: "#/components/schemas/SettingEntry"
|
|
scope:
|
|
type: string
|
|
enum: [device]
|
|
description: Whose settings these are; always the calling device's.
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"403": { $ref: "#/components/responses/Error" }
|
|
"500": { $ref: "#/components/responses/Error" }
|
|
put:
|
|
tags: [sync]
|
|
summary: Upsert this device's settings (newer wins)
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
description: |
|
|
Each key in the request is upserted only when the supplied
|
|
`updated_at` is strictly newer than what the server holds for
|
|
that key; a stale write is silently dropped. Keys not mentioned
|
|
are untouched — this is a partial update, not a replace.
|
|
|
|
Rows belong to the calling device; nothing written here is
|
|
visible to another device of the account.
|
|
|
|
The response is the merged result: every key the device holds
|
|
after the upsert, not just the ones sent. A dropped write is not
|
|
an error, so a client must compare the response against what it
|
|
sent rather than read `200` as "my value was stored".
|
|
|
|
`updated_at` is supplied by the client, so a retry arriving late
|
|
cannot undo a newer write from the same device. It may not sit
|
|
more than 24 hours ahead of the server's clock: without that bound a single write stamped far in
|
|
the future would pin the key forever, and there is no delete.
|
|
|
|
Refused with `400`: an empty `settings` map, an absent or
|
|
unparseable `updated_at`, one more than 24 hours ahead
|
|
(`code: time_in_future`), an empty key, a key or value over
|
|
`ops.settings_max_key_bytes` / `ops.settings_max_value_bytes`,
|
|
and a NUL byte in either. Refused with `409` when the device
|
|
already holds `ops.settings_max_per_account` keys (a per-device
|
|
cap, despite its name) and the request would add another;
|
|
nothing is stored in that case.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [settings]
|
|
properties:
|
|
settings:
|
|
type: object
|
|
additionalProperties:
|
|
$ref: "#/components/schemas/SettingEntry"
|
|
responses:
|
|
"200":
|
|
description: The merged settings map after the upsert
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [settings, scope]
|
|
properties:
|
|
settings:
|
|
type: object
|
|
additionalProperties:
|
|
$ref: "#/components/schemas/SettingEntry"
|
|
scope:
|
|
type: string
|
|
enum: [device]
|
|
description: Whose settings these are; always the calling device's.
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"403": { $ref: "#/components/responses/Error" }
|
|
"409": { $ref: "#/components/responses/Error" }
|
|
"500": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/works/resolve:
|
|
post:
|
|
tags: [identity]
|
|
summary: Resolve a file's identifiers to a work
|
|
description: |
|
|
Send every identifier you have for the file. Resolution priority is
|
|
`sha256` → `partial-md5` → `source` → `dc` → `ta`, but every supplied
|
|
identifier that already matches must agree on one work. On a
|
|
high-confidence result, all supplied identifiers are registered as
|
|
aliases and every supplied SHA-256 is registered as an edition of the
|
|
resolved work (this is how identity converges across re-encodes).
|
|
Fuzzy `ta` matches return `confidence: low` — confirm with the
|
|
user before treating it as authoritative. On a low match nothing
|
|
is registered, so a guess cannot be laundered into a certainty;
|
|
send `confirmed: true` once the user has said it is the same
|
|
book, and the match resolves high and registers the identifiers.
|
|
|
|
If the identifiers resolve to more than one distinct work, the
|
|
server answers 409 with the conflicting work ids and changes
|
|
nothing; call `/v1/works/merge` to confirm the merge.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [identifiers]
|
|
properties:
|
|
identifiers:
|
|
type: array
|
|
minItems: 1
|
|
items: { $ref: "#/components/schemas/Identifier" }
|
|
title: { type: string }
|
|
author: { type: string }
|
|
confirmed:
|
|
type: boolean
|
|
description: Confirm that a low-confidence title/author match is the same work.
|
|
responses:
|
|
"200":
|
|
description: Resolved to an existing work
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/ResolveResult" }
|
|
"201":
|
|
description: New work created
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/ResolveResult" }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"409":
|
|
description: Identifiers map to multiple works; nothing changed
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
error: { type: string }
|
|
works: { type: array, items: { type: string } }
|
|
|
|
/v1/works/{id}/split:
|
|
post:
|
|
tags: [identity]
|
|
summary: Detach an edition into a new work
|
|
description: |
|
|
Repairs a wrong merge. Moves the edition and its ops/sessions to
|
|
a new work. Aliases belong to works, not editions, so pass the
|
|
aliases to move explicitly (the edition's own `sha256` alias is
|
|
implied). A different edition's `sha256` alias is rejected.
|
|
Works with compacted session rollups cannot be split because the
|
|
aggregate no longer retains enough edition provenance. The same
|
|
restriction applies to legacy inferred sessions whose edition or
|
|
origin alias cannot be recovered.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [edition_sha]
|
|
properties:
|
|
edition_sha: { type: string }
|
|
aliases:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Identifier" }
|
|
responses:
|
|
"200":
|
|
description: Split complete
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties: { work_id: { type: string } }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"404": { $ref: "#/components/responses/Error" }
|
|
"409": { $ref: "#/components/responses/Error", description: "Edition or history cannot be split from this work" }
|
|
|
|
/v1/works/merge:
|
|
post:
|
|
tags: [identity]
|
|
summary: Merge one work into another (explicit confirmation)
|
|
description: |
|
|
Moves aliases, editions, operations, and raw sessions into the
|
|
target work. A source work with compacted session rollups cannot
|
|
be merged because those aggregates no longer retain session
|
|
provenance.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [from_work_id, into_work_id]
|
|
properties:
|
|
from_work_id: { type: string }
|
|
into_work_id: { type: string }
|
|
responses:
|
|
"200":
|
|
description: Merged
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties: { work_id: { type: string } }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"404": { $ref: "#/components/responses/Error" }
|
|
"409": { $ref: "#/components/responses/Error", description: "Source work has compacted history" }
|
|
|
|
/v1/ops:
|
|
post:
|
|
tags: [sync]
|
|
summary: Push a batch of position ops
|
|
description: |
|
|
Ops are appended to your per-user log with a monotonic `seq`.
|
|
Retries are safe: replaying an `op_id` with the same payload
|
|
returns `duplicate` with the original seq; a reused `op_id` with
|
|
a different payload returns `conflict` and is not stored.
|
|
`device_id` is taken from the token; a client-supplied device id
|
|
is ignored.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ops]
|
|
properties:
|
|
ops:
|
|
type: array
|
|
maxItems: 500
|
|
items: { $ref: "#/components/schemas/OpInput" }
|
|
responses:
|
|
"200":
|
|
description: Per-item results (valid items applied even if others conflict)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
results:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/OpResult" }
|
|
"400":
|
|
description: |
|
|
The batch was refused as a whole; nothing was stored. A
|
|
batch-level refusal (`ops required`, `code: batch_too_large`
|
|
with `limit`) has no item to name. An item-level refusal
|
|
names the first bad item: `code`, its zero-based `item_index`,
|
|
and its `op_id` when it had one. `unknown_work` is the one a
|
|
client recovers from by re-resolving the book and retrying
|
|
under the fresh `work_id`; `locator_too_large` carries the
|
|
byte `limit` and the op may be retried under the same
|
|
`op_id` without its locator, since the original batch was
|
|
never stored.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/BatchItemError" }
|
|
"413":
|
|
description: Request body over `ops.max_body_bytes`
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
/v1/changes:
|
|
get:
|
|
tags: [sync]
|
|
summary: Delta-sync every op after a seq
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
parameters:
|
|
- { name: since, in: query, schema: { type: integer, default: 0, minimum: 0 } }
|
|
- { name: limit, in: query, schema: { type: integer, default: 500, maximum: 500 } }
|
|
responses:
|
|
"200":
|
|
description: |
|
|
Page of ops with the new high-water mark. The page, the mark
|
|
and the compaction check come from one snapshot: a page you
|
|
were not told to resync from has no holes in it.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
ops:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Op" }
|
|
high_water: { type: integer }
|
|
has_more: { type: boolean }
|
|
"400":
|
|
description: "`since` is not a non-negative integer"
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
"410":
|
|
description: |
|
|
Cursor below the compaction horizon. Fetch `heads_endpoint`
|
|
and resume from its `snapshot_seq`.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
error: { type: string, enum: [resync_required] }
|
|
high_water: { type: integer }
|
|
heads_endpoint: { type: string }
|
|
message: { type: string }
|
|
|
|
/v1/heads:
|
|
get:
|
|
tags: [sync]
|
|
summary: Resync snapshot — newest op per (work, device)
|
|
description: |
|
|
The recovery protocol after `resync_required` (or first sync of
|
|
a fresh client): returns the newest op for every work+device
|
|
pair plus an atomic `snapshot_seq`. Rebuild your baseline from
|
|
the heads, then resume `/v1/changes?since=<snapshot_seq>`.
|
|
The separate `furthest` array contains retained maximum-progression
|
|
operations per work/edition/origin-alias ownership group, from the
|
|
same snapshot. Store those as historical destinations, never as new
|
|
movement for latest-position reconciliation.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
responses:
|
|
"200":
|
|
description: Heads snapshot
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ops, furthest, snapshot_seq]
|
|
properties:
|
|
ops:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Op" }
|
|
furthest:
|
|
type: array
|
|
description: |
|
|
Full original maximum-progression operations, retained
|
|
through compaction. Equal fractions keep the earliest
|
|
sequence. Sorted by work_id, descending progression,
|
|
then sequence. Empty when there are no positions.
|
|
items: { $ref: "#/components/schemas/Op" }
|
|
snapshot_seq: { type: integer }
|
|
|
|
/v1/works/{id}/positions:
|
|
get:
|
|
tags: [sync]
|
|
summary: Recent position history for a work (newest first)
|
|
description: |
|
|
Recent `ops` and retained `furthest` candidates come from one snapshot.
|
|
`limit` bounds only `ops`. `furthest` holds the greatest progression
|
|
per edition/origin-alias ownership group, with the earliest sequence
|
|
breaking ties, sorted by descending progression then sequence.
|
|
These are full original operations, not synthetic page numbers.
|
|
Clients offer them independently of current position and use
|
|
edition-aware locator restoration. Earlier history already compacted
|
|
before furthest retention was introduced cannot be recovered.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
- { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
|
|
responses:
|
|
"200":
|
|
description: Recent ops
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [ops, furthest]
|
|
properties:
|
|
ops:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Op" }
|
|
furthest:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Op" }
|
|
"404": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/sessions:
|
|
post:
|
|
tags: [sessions]
|
|
summary: Push a batch of reading sessions
|
|
description: |
|
|
Sessions are append-only facts. Report progression fractions
|
|
measured at the time of reading (`start_progression`,
|
|
`end_progression`); never derive page numbers yourself — the
|
|
server derives pages from edition metadata when known. Same
|
|
`session_id` with a different payload is a 409.
|
|
Optional `active_ms` is authoritative measured duration, including
|
|
zero, independent of wall-clock span and idle. Negotiate it through
|
|
insights capabilities or `GET /v1/token` (`session_active_ms: true`)
|
|
before sending it. Preserve its presence and value on retry.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [sessions]
|
|
properties:
|
|
sessions:
|
|
type: array
|
|
maxItems: 1000
|
|
items: { $ref: "#/components/schemas/SessionInput" }
|
|
responses:
|
|
"200":
|
|
description: Accepted
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties: { accepted: { type: integer } }
|
|
"400":
|
|
description: |
|
|
The batch was refused as a whole; nothing was stored. Same
|
|
shape as `POST /v1/ops`: a batch-level refusal (`sessions
|
|
required`, `code: batch_too_large` with `limit`) names no
|
|
item; an item-level one names the first bad item by `code`,
|
|
`item_index` and `session_id` when it had one. `unknown_work`
|
|
is recoverable by re-resolving the book and retrying.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/BatchItemError" }
|
|
"409":
|
|
description: |
|
|
A `session_id` in the batch was already accepted with a
|
|
different payload (live, or archived as a rollup tombstone).
|
|
`code: id_reused`, with the `session_id` and its `item_index`;
|
|
the batch was refused as a whole.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/BatchItemError" }
|
|
"413":
|
|
description: Request body over `ops.max_body_bytes`
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
|
|
/v1/annotations:
|
|
post:
|
|
tags: [sync]
|
|
summary: Push a batch of annotations (highlights, notes, bookmarks)
|
|
description: |
|
|
Annotations are the mutable kind of reading state (ADR-0028):
|
|
each record carries a `rev`, and every write is a compare-and-set
|
|
against it. A create sends `base_rev: 0`; an edit sends the rev
|
|
it read. A byte-identical retry of the last write is `duplicate`
|
|
and acknowledged with the stored rev and seq; any other stale
|
|
`base_rev` is a per-item `conflict` carrying the server's copy —
|
|
the server orders, it never merges. The batch is never atomic:
|
|
one bad item fails alone, whether its problem is shape (bad
|
|
kind, oversized excerpt or body, a color off the palette, a
|
|
locator on a note) or reference (unknown work, per-work cap) —
|
|
both come back as per-item `invalid` results with a reason.
|
|
Only a request nothing in it can excuse is refused whole:
|
|
malformed JSON, an empty batch or one over 100 items (400), or
|
|
a body larger than any legal batch could need (413).
|
|
`device_id` is taken from the token.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [annotations]
|
|
properties:
|
|
annotations:
|
|
type: array
|
|
maxItems: 100
|
|
items: { $ref: "#/components/schemas/AnnotationInput" }
|
|
responses:
|
|
"200":
|
|
description: Per-item results (valid items applied even if others conflict)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
results:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/AnnotationResult" }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"413": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/annotations/changes:
|
|
get:
|
|
tags: [sync]
|
|
summary: Delta-sync annotations after a seq
|
|
description: |
|
|
The annotation feed is *state*, not history: it has its own
|
|
per-user seq counter, separate from the op log, and an edit or
|
|
delete moves the record to the head of the feed with its current
|
|
content. Tombstones are included, carrying identity, `rev`,
|
|
`seq` and `deleted_at` and nothing else; after the retention
|
|
window they are swept, so a device offline longer than that
|
|
reconciles against the live set instead. `/v1/changes` remains
|
|
positions-only.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
parameters:
|
|
- { name: since, in: query, schema: { type: integer, default: 0 } }
|
|
- { name: limit, in: query, schema: { type: integer, default: 500, maximum: 500 } }
|
|
responses:
|
|
"200":
|
|
description: Page of annotations with the new high-water mark
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
annotations:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Annotation" }
|
|
high_water: { type: integer }
|
|
has_more: { type: boolean }
|
|
|
|
/v1/annotations/{id}:
|
|
delete:
|
|
tags: [sync]
|
|
summary: Delete an annotation (tombstone, rev-checked)
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
- name: rev
|
|
in: query
|
|
required: true
|
|
description: The rev the client read; the delete applies iff it is current.
|
|
schema: { type: integer, minimum: 1 }
|
|
responses:
|
|
"200":
|
|
description: Tombstone written (`applied`), or the record was already deleted (`duplicate`)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
status: { type: string, enum: [applied, duplicate] }
|
|
rev: { type: integer }
|
|
seq: { type: integer }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"404": { $ref: "#/components/responses/Error" }
|
|
"409":
|
|
description: Stale rev; the body carries the server's current copy under `server`.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
error: { type: string }
|
|
server: { $ref: "#/components/schemas/Annotation" }
|
|
|
|
/v1/works/{id}/annotations:
|
|
get:
|
|
tags: [sync]
|
|
summary: Live annotations for a work
|
|
description: |
|
|
The live set only — no tombstones — ordered by progression, then
|
|
`client_ts` (records without a progression sort last).
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
responses:
|
|
"200":
|
|
description: Live annotations
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
annotations:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/Annotation" }
|
|
"404": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/works/{id}/translations:
|
|
post:
|
|
tags: [sync]
|
|
summary: Push a batch of cached sentence translations
|
|
description: |
|
|
The account's sentence translation cache (ADR-0052): a batched,
|
|
atomic, idempotent upsert. Last write wins — a row is inserted
|
|
if absent and updated only when the text differs — and an
|
|
identical push writes nothing at all, so `updated_at` does not
|
|
move and pullers never re-download unchanged rows. There is no
|
|
conflict and no rev. The key is the client's opaque content hash
|
|
(64 hex characters); the sentence itself never reaches the
|
|
server. `device_id` and `updated_at` are stamped by the server
|
|
from the token. Validation is atomic like an ops batch: the
|
|
first bad item refuses the whole request with `code`
|
|
(`bad_key`, `too_large`, `bad_value`, `missing_field`),
|
|
`item_index` and the offending `key`; `bad_value` is a
|
|
translation holding a NUL, which no store can keep. A work the user does not have is
|
|
`400 unknown_work` with the `work_id`, the same refusal and
|
|
recovery as ops. The batch shares `ops.max_batch`; values are
|
|
bounded by `ops.translation_max_value_bytes` and the whole
|
|
cache by `ops.translation_budget_bytes`, evicting the
|
|
oldest-updated rows first.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [translations]
|
|
properties:
|
|
translations:
|
|
type: array
|
|
minItems: 1
|
|
maxItems: 500
|
|
items: { $ref: "#/components/schemas/WorkTranslationInput" }
|
|
responses:
|
|
"200":
|
|
description: The batch was applied (an identical batch is a no-op, not a different answer)
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
status: { type: string, enum: [applied] }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"413": { $ref: "#/components/responses/Error" }
|
|
get:
|
|
tags: [sync]
|
|
summary: Pull a work's cached translations (delta feed)
|
|
description: |
|
|
One page of the work's sentence translation cache, ordered by
|
|
`(updated_at, key)` — the same order the cursor cuts at, so rows
|
|
sharing a timestamp cannot be skipped or repeated across pages.
|
|
The first pull (no `after`) is the whole work; later pulls pass
|
|
the `next_cursor` the last page returned and receive only the
|
|
delta. A cursor that does not parse is `400`; the recovery is to
|
|
drop the cursor and pull from the beginning — re-reading, never
|
|
skipping. A work the user does not have is `404`.
|
|
security: [{ deviceToken: [] }] # requires sync scope
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
- name: limit
|
|
in: query
|
|
description: Page size, capped by `ops.translation_max_pull` (500 by default).
|
|
schema: { type: integer, default: 500, maximum: 500 }
|
|
- name: after
|
|
in: query
|
|
description: The `next_cursor` of the last page; absent for the whole work.
|
|
schema: { type: string }
|
|
responses:
|
|
"200":
|
|
description: One page of cached translations
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
translations:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/WorkTranslation" }
|
|
next_cursor: { type: string, description: The cursor of the last row returned; pass it as `after` to continue. }
|
|
has_more: { type: boolean }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
"404": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/insights/capabilities:
|
|
get:
|
|
tags: [insights]
|
|
summary: Negotiate statistics snapshot support
|
|
description: |
|
|
Requires `read-insights`. Returns the account id and timezone,
|
|
protocol and attribution versions, all-history and period-comparison
|
|
support, and request limits.
|
|
A sync-only client can negotiate session duration through
|
|
`GET /v1/token` instead; browser tokens do not need wider scopes.
|
|
security: [{ deviceToken: [] }] # requires read-insights scope
|
|
responses:
|
|
"200":
|
|
description: Supported statistics protocol
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/InsightsCapabilities" }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"403": { $ref: "#/components/responses/Error" }
|
|
"500": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/insights/snapshot:
|
|
post:
|
|
tags: [insights]
|
|
summary: Read coherent statistics and actual local-session overlap
|
|
description: |
|
|
Requires `read-insights`, not `sync`; candidates are evidence,
|
|
never uploaded. Reads effective sessions, rollups, archived proof,
|
|
positions, metadata, timezone and revision in one transaction.
|
|
Server history has no 10000-session cap.
|
|
The byte limit covers the entire body, including trailing whitespace.
|
|
Send one JSON value only; trailing JSON or garbage is invalid.
|
|
|
|
Candidates must contain the original native payload and original
|
|
device id, not the current token's device id. Only matching
|
|
contributions present in these aggregates count as overlap.
|
|
Upload acknowledgements, receipts and op cursors are not proof.
|
|
On a complete response, additive totals can be combined as
|
|
server + captured local - actual overlap. Keep the local capture
|
|
fixed and check account, snapshot id, timezone, versions and bounds.
|
|
Use local-only statistics if negotiation or complete proof is
|
|
unavailable, including on legacy servers.
|
|
|
|
An optional `comparison` asks for current and previous date spans
|
|
stopped at the same account-local wall-clock time. Its current dates
|
|
must equal the bounded headline dates and end today; comparison is
|
|
unavailable for `range: all`. Both server totals and overlap totals
|
|
come from this same snapshot. If raw timestamps or candidate evidence
|
|
cannot prove all four values, the response omits both comparison
|
|
objects and sets `comparison_incomplete_reason`; headline `complete`
|
|
and its totals are unaffected by comparison-only evidence.
|
|
|
|
Explicit calendar bounds affect only `days` and `overlap.days`.
|
|
Across chunks require the same decimal-string `stats_revision`
|
|
and response identity; do not sum repeated summary/work totals.
|
|
security: [{ deviceToken: [] }] # requires read-insights scope
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/InsightsSnapshotInput" }
|
|
responses:
|
|
"200":
|
|
description: |
|
|
Coherent aggregates and overlap. An incomplete headline still
|
|
returns totals, but cannot certify an exact local/server union.
|
|
A refused optional comparison is reported separately and never
|
|
invalidates an otherwise complete headline.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/InsightsSnapshot" }
|
|
"400":
|
|
description: Invalid payload, duplicate candidate id, excessive evidence or invalid bounds.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"403": { $ref: "#/components/responses/Error" }
|
|
"409":
|
|
description: Requested timezone differs from the account's current timezone.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
"413":
|
|
description: Request body exceeds `ops.max_body_bytes`.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
"500": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/insights/summary:
|
|
get:
|
|
tags: [insights]
|
|
summary: Reading totals, streak, speed over a range
|
|
security: [{ deviceToken: [] }] # requires read-insights scope
|
|
parameters:
|
|
- name: from
|
|
in: query
|
|
description: |
|
|
First day of the span, inclusive, in the user's timezone.
|
|
Must be paired with `to`, and takes precedence over `range`.
|
|
Prefer this over `range`: a count of days is resolved against
|
|
the server's clock, a pair of dates says exactly what was
|
|
meant, and both are echoed back so a client can tell an
|
|
honoured span from one a server too old to understand it
|
|
ignored.
|
|
schema: { type: string, format: date }
|
|
- name: to
|
|
in: query
|
|
description: Last day of the span, inclusive. Must be paired with `from` and not fall before it.
|
|
schema: { type: string, format: date }
|
|
- name: range
|
|
in: query
|
|
description: |
|
|
The older spelling of a span: a whole number of *calendar*
|
|
days from 1 through 3660 ending today, such as `30d`, or
|
|
`all` for everything on record, which is genuinely unbounded.
|
|
Omitted or invalid values use `30d`. Ignored when a valid
|
|
`from`/`to` pair is given. `streak_days` is not narrowed by
|
|
any of this: it counts every consecutive day on record, so a
|
|
short span cannot truncate a long run.
|
|
schema: { type: string, default: 30d }
|
|
responses:
|
|
"200":
|
|
description: |
|
|
Aggregates (rereads count time but zero pages). `from` and
|
|
`to` are echoed back whenever the span was bounded, and must
|
|
be checked: a server that did not understand the request
|
|
answers about a different span, and nothing in the totals
|
|
themselves would say so.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/InsightsSummary" }
|
|
- { $ref: "#/components/schemas/InsightsWindow" }
|
|
- { $ref: "#/components/schemas/InsightsRevision" }
|
|
- type: object
|
|
required: [first_activity_day]
|
|
properties:
|
|
first_activity_day: { $ref: "#/components/schemas/InsightsFirstActivityDay" }
|
|
|
|
/v1/insights/works/{id}:
|
|
get:
|
|
tags: [insights]
|
|
summary: Per-work statistics and ETA
|
|
security: [{ deviceToken: [] }] # requires read-insights scope
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string } }
|
|
- name: from
|
|
in: query
|
|
description: |
|
|
First day of the span, inclusive, in the user's timezone.
|
|
Must be paired with `to`, and takes precedence over `range`.
|
|
Prefer this over `range`: a count of days is resolved against
|
|
the server's clock, a pair of dates says exactly what was
|
|
meant, and both are echoed back so a client can tell an
|
|
honoured span from one a server too old to understand it
|
|
ignored.
|
|
schema: { type: string, format: date }
|
|
- name: to
|
|
in: query
|
|
description: Last day of the span, inclusive. Must be paired with `from` and not fall before it.
|
|
schema: { type: string, format: date }
|
|
- name: range
|
|
in: query
|
|
description: |
|
|
The older spelling of a span: a whole number of calendar days
|
|
from 1 through 3660, or `all`. Omitted, the aggregate covers
|
|
the work's whole history, which is what this endpoint
|
|
answered before spans existed. `current_progression` is
|
|
always current; ETA uses that position and measured pace
|
|
within the selected window.
|
|
schema: { type: string }
|
|
responses:
|
|
"200":
|
|
description: |
|
|
One work's aggregate; `eta_seconds` is null when no speed
|
|
history exists. The span is named back as `range_days`
|
|
(always) and `from`/`to` (when bounded).
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/InsightsWork" }
|
|
- { $ref: "#/components/schemas/InsightsWindow" }
|
|
- type: object
|
|
required: [timezone]
|
|
properties:
|
|
timezone: { type: string }
|
|
"404": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/insights/works:
|
|
get:
|
|
tags: [insights]
|
|
summary: Per-work statistics for every work with reading history
|
|
security: [{ deviceToken: [] }] # requires read-insights scope
|
|
parameters:
|
|
- name: from
|
|
in: query
|
|
description: |
|
|
First day of the span, inclusive, in the user's timezone.
|
|
Must be paired with `to`, and takes precedence over `range`.
|
|
Prefer this over `range`: a count of days is resolved against
|
|
the server's clock, a pair of dates says exactly what was
|
|
meant, and both are echoed back so a client can tell an
|
|
honoured span from one a server too old to understand it
|
|
ignored.
|
|
schema: { type: string, format: date }
|
|
- name: to
|
|
in: query
|
|
description: Last day of the span, inclusive. Must be paired with `from` and not fall before it.
|
|
schema: { type: string, format: date }
|
|
- name: range
|
|
in: query
|
|
description: |
|
|
The older spelling of a span: a whole number of calendar days
|
|
from 1 through 3660, or `all`. Omitted, every aggregate
|
|
covers its work's whole history. Give this the same span as
|
|
`/v1/insights/summary` when the two are shown on one screen,
|
|
or the headline and the rows below it will describe
|
|
different spans.
|
|
schema: { type: string }
|
|
responses:
|
|
"200":
|
|
description: |
|
|
Per-work aggregates, ordered by active time descending.
|
|
`range_days` is always present, and `from`/`to` whenever the
|
|
span was bounded; both must be checked before the rows are
|
|
labelled with a span. `title` and `author` name each work so
|
|
that a client can list one it holds no local file for.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/InsightsWindow" }
|
|
- { $ref: "#/components/schemas/InsightsRevision" }
|
|
- type: object
|
|
required: [works]
|
|
properties:
|
|
works:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/InsightsWork" }
|
|
|
|
/v1/insights/calendar:
|
|
get:
|
|
tags: [insights]
|
|
summary: Daily reading minutes over a year or an arbitrary span (user's timezone)
|
|
security: [{ deviceToken: [] }] # requires read-insights scope
|
|
parameters:
|
|
- name: year
|
|
in: query
|
|
description: A year from 1971 through 2999. Omitted or invalid values use the server's current year. Ignored when a valid `from`/`to` pair is given.
|
|
schema: { type: integer }
|
|
- name: from
|
|
in: query
|
|
description: |
|
|
First day to include. Must be paired with `to`. A rolling
|
|
window costs one request in this form rather than one per
|
|
calendar year it straddles. A span longer than 4000 days is
|
|
refused rather than served.
|
|
schema: { type: string, format: date }
|
|
- name: to
|
|
in: query
|
|
description: Last day to include, inclusive. Must be paired with `from` and not fall before it.
|
|
schema: { type: string, format: date }
|
|
responses:
|
|
"200":
|
|
description: |
|
|
Days with recorded contributions. Raw sessions and v2 rollups
|
|
count wholly on their end day; legacy split-day buckets retain
|
|
their original attribution.
|
|
`from`, `to` and `range_days` are echoed back only when
|
|
`from`/`to` were honoured, so a client can tell this server
|
|
from one that predates them, ignored them, and answered with
|
|
the current year.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/InsightsRevision" }
|
|
- type: object
|
|
required: [year, days]
|
|
properties:
|
|
year: { type: integer }
|
|
from: { type: string, format: date }
|
|
to: { type: string, format: date }
|
|
range_days:
|
|
type: integer
|
|
description: Calendar days covered; present only when `from`/`to` were honoured.
|
|
days:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/InsightsDay" }
|
|
"400": { $ref: "#/components/responses/Error" }
|
|
|
|
/v1/folders:
|
|
get:
|
|
tags: [library]
|
|
summary: The folders this server watches
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
description: |
|
|
Where a catalog client starts: every other route needs a folder
|
|
id. The list contains only folders explicitly granted to the token's
|
|
account; administrator status does not imply a grant. `root_path` is deliberately not
|
|
here — it is a fact about the server's filesystem, and naming it
|
|
would make this route an oracle for one.
|
|
parameters:
|
|
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
|
|
- { name: after, in: query, schema: { type: string }, description: "Opaque cursor from a previous page's `next_after`" }
|
|
responses:
|
|
"200":
|
|
description: The folders
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [folders]
|
|
properties:
|
|
next_after:
|
|
type: string
|
|
description: Present only when a further page exists.
|
|
folders:
|
|
type: array
|
|
items:
|
|
type: object
|
|
required: [folder_id, name, kind, created_at]
|
|
properties:
|
|
folder_id: { type: string, format: uuid }
|
|
name: { type: string }
|
|
kind:
|
|
type: string
|
|
enum: [plain, calibre]
|
|
description: |
|
|
`calibre` when the directory holds a
|
|
`metadata.db`, which the server reads
|
|
read-only for series, tags, descriptions and
|
|
covers. `plain` otherwise, where the
|
|
directory tree is the organisation: a
|
|
subdirectory of EPUBs is a series.
|
|
accepts_uploads:
|
|
type: boolean
|
|
description: |
|
|
Whether an administrator turned uploads on
|
|
for this folder. A client offers the action
|
|
only where this is true; the server refuses
|
|
it everywhere else regardless.
|
|
created_at: { type: string, format: date-time }
|
|
|
|
/v1/folders/{folder}/books:
|
|
get:
|
|
tags: [library]
|
|
summary: Books in a folder
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
description: |
|
|
Cursor-paginated, oldest first unless `order=recent`. Books
|
|
whose file is missing are excluded. `next_cursor` appears only when a further
|
|
page exists, so a client pages until it is absent. Treat the
|
|
cursor as opaque — it means "where the last page ended", which
|
|
is a different place in each order, so cursors do not carry
|
|
across a change of `order`.
|
|
parameters:
|
|
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: order
|
|
in: query
|
|
description: |
|
|
`recent` is newest first, which is the "what arrived since
|
|
last time" listing. An unrecognized value is refused rather
|
|
than defaulted.
|
|
schema: { type: string, enum: [oldest, recent], default: oldest }
|
|
- name: limit
|
|
in: query
|
|
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
|
- name: cursor
|
|
in: query
|
|
schema: { type: string }
|
|
description: A `next_cursor` from a previous page.
|
|
responses:
|
|
"200":
|
|
description: One page of the catalog
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [books]
|
|
properties:
|
|
books:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/CatalogBook" }
|
|
next_cursor:
|
|
type: string
|
|
description: Absent on the last page.
|
|
"400": { $ref: "#/components/responses/Error", description: "Bad limit or malformed cursor" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such folder, or the folder is not granted to this account" }
|
|
|
|
post:
|
|
tags: [library]
|
|
summary: Upload a book into a folder
|
|
security: [{ deviceToken: [] }] # requires library-upload scope
|
|
parameters:
|
|
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
|
|
description: |
|
|
An upload is a file written into a folder, and nothing more
|
|
(ADR-0023). This route does not write the catalog: it puts the
|
|
publication where the folder can see it and then runs the same
|
|
pass a file copied in by hand would get.
|
|
|
|
Two conditions, both deliberate. The folder must have
|
|
`accepts_uploads` — an administrator turns that on per folder —
|
|
and the token must carry `library-upload`, which no other route
|
|
uses. A folder nobody marked is refused even with the scope.
|
|
|
|
In a Calibre folder the file alone would not be a book, because
|
|
discovery there comes from `metadata.db`. So a Calibre folder
|
|
gets a Calibre book: rows, Calibre's directory layout, a
|
|
`cover.jpg` and a `metadata.opf`.
|
|
|
|
The SHA-256 of the bytes is the idempotency key. There is no
|
|
client-supplied key and no pending state: a retry after a lost
|
|
connection is answered with the book the first attempt made.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
multipart/form-data:
|
|
schema:
|
|
type: object
|
|
required: [file]
|
|
properties:
|
|
file:
|
|
type: string
|
|
format: binary
|
|
description: The EPUB. Other parts are ignored.
|
|
responses:
|
|
"201":
|
|
description: The book was written and the pass catalogued it.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [book_id, folder_id, relative_path, content_sha256, duplicate]
|
|
properties:
|
|
book_id: { type: string, format: uuid }
|
|
folder_id: { type: string, format: uuid }
|
|
relative_path: { type: string }
|
|
content_sha256: { type: string }
|
|
duplicate: { type: boolean, enum: [false] }
|
|
"200":
|
|
description: |
|
|
The caller's visible catalog already held these bytes, here or in
|
|
another granted folder. Nothing was written and this is the book.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [book_id, folder_id, content_sha256, duplicate]
|
|
properties:
|
|
book_id: { type: string, format: uuid }
|
|
folder_id: { type: string, format: uuid }
|
|
content_sha256: { type: string }
|
|
duplicate: { type: boolean, enum: [true] }
|
|
"202":
|
|
description: |
|
|
The bytes are on the disk and durable, but the pass
|
|
concluded nothing yet — rules 1 and 2 of ADR-0017 allow
|
|
that. The watcher will come back to it; resolve the book
|
|
later by its digest.
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [folder_id, relative_path, content_sha256]
|
|
properties:
|
|
folder_id: { type: string, format: uuid }
|
|
relative_path: { type: string }
|
|
content_sha256: { type: string }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed multipart body or an empty upload" }
|
|
"403":
|
|
description: The folder does not accept uploads, or the token lacks `library-upload`.
|
|
"404": { description: "No such folder, or the folder is not granted to this account" }
|
|
"409":
|
|
description: |
|
|
A Calibre library that is busy. Close Calibre and retry.
|
|
"413": { description: Larger than `content.max_upload_bytes` }
|
|
"422": { description: Not a readable EPUB }
|
|
"503": { description: This server is running without a folder watcher }
|
|
|
|
/v1/folders/{folder}/search:
|
|
get:
|
|
tags: [library]
|
|
summary: Find a book in a folder
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
description: |
|
|
Matches `q` against everything a book says about itself — title,
|
|
subtitle, description, publisher, and the names of the series,
|
|
contributors and tags it claims — and returns the best
|
|
matches first. A book *called* Dune outranks one that merely
|
|
mentions it.
|
|
|
|
`q` is words, never index syntax. Punctuation and operators are
|
|
split away rather than interpreted, so no query can change how it
|
|
is read; a query made only of punctuation matches nothing.
|
|
Diacritics and case are folded, so `emile` finds `Émile`.
|
|
|
|
The answer is unpaged. A relevance order has no stable cursor, and
|
|
this route answers "where is that book" rather than "show me
|
|
everything"; `truncated` says the answer was cut at `limit` so a
|
|
client can ask the person to narrow it rather than implying it
|
|
found all there was. Browsing a whole folder is what
|
|
`/v1/folders/{folder}/books` is for.
|
|
|
|
`facets` describe the books actually returned, counted over that
|
|
set rather than over the folder, and each `id` can be passed back
|
|
as `entity` to narrow the query. A filter needs no `kind`: an id
|
|
already knows what it is. Repeating `entity` ANDs the filters.
|
|
|
|
There is no reading-state filter and there will not be one. A
|
|
catalog-only credential must not be able to observe reading state,
|
|
so this route has no vocabulary for it; reading state is reported
|
|
by the `sync` and `read-insights` surfaces, which ask for their
|
|
own scopes.
|
|
parameters:
|
|
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: q
|
|
in: query
|
|
description: The words to match. Absent or empty matches every book, which is useful with `entity`.
|
|
schema: { type: string, maxLength: 512 }
|
|
- name: entity
|
|
in: query
|
|
description: Narrow to books claiming this entity, whatever its kind. Repeatable; filters are ANDed.
|
|
schema: { type: array, items: { type: string, format: uuid }, maxItems: 8 }
|
|
- name: limit
|
|
in: query
|
|
description: Most books returned.
|
|
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
|
|
responses:
|
|
"200":
|
|
description: The matching books and what they have in common
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/SearchResult" }
|
|
"400": { $ref: "#/components/responses/Error", description: "Bad limit, over-long query, or malformed entity filter" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such folder, or the folder is not granted to this account" }
|
|
|
|
/v1/books/{id}:
|
|
get:
|
|
tags: [library]
|
|
summary: One book, in the same shape every listing returns
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
responses:
|
|
"200":
|
|
description: The book
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/CatalogBook" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
|
|
|
|
delete:
|
|
tags: [library]
|
|
summary: Delete a book from the server
|
|
security: [{ deviceToken: [] }] # requires library-delete scope
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: forget_reading
|
|
in: query
|
|
required: false
|
|
schema: { type: boolean, default: false }
|
|
description: |
|
|
Also forget the caller's own reading of this book, and only
|
|
theirs. Another reader's history is never touched.
|
|
description: |
|
|
The counterpart of an upload, bounded the same way (ADR-0025).
|
|
This deletes the file itself and then the catalog row: the bytes
|
|
are gone from this server's disk, for every device, and there is
|
|
no trash to take them back out of.
|
|
|
|
Two conditions, and they mirror the upload's. The book's folder
|
|
must have `accepts_uploads` — that flag marks the one place this
|
|
server may write under a folder root, and deleting is a write —
|
|
and the token must carry `library-delete`, which no other route
|
|
uses. `library-upload` is not enough: sending your own book and
|
|
destroying everyone's are different questions.
|
|
|
|
Reading survives by default. A work with history outlives its
|
|
book and becomes an entry only its own reader can remove
|
|
(ADR-0024), which is what lets a reader on another device keep
|
|
their position. `forget_reading=true` removes the caller's, and
|
|
is declined silently when another copy of the same book still
|
|
maps that work — the reader asked to forget a book they still
|
|
have.
|
|
|
|
In a Calibre folder the row is the book, so the delete goes
|
|
through `metadata.db`: the row goes, Calibre's own triggers take
|
|
the links with it, and the book's directory is removed. The
|
|
directory comes from `books.path`, not from anything cached,
|
|
because Calibre renames directories when metadata changes.
|
|
responses:
|
|
"204": { description: The book is gone. }
|
|
"403":
|
|
description: |
|
|
The book's folder does not accept uploads, or the token
|
|
lacks `library-delete`.
|
|
"404": { description: "No such book, or its folder is not granted to this account" }
|
|
"409":
|
|
description: |
|
|
The file changed since it was last scanned and was left
|
|
alone, or a Calibre library is busy — close Calibre and
|
|
retry.
|
|
"503": { description: This server is running without a folder watcher }
|
|
|
|
/v1/books/{id}/series:
|
|
get:
|
|
tags: [library]
|
|
summary: Read a book's series layers
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
description: |
|
|
Returns the effective series memberships for one book and the
|
|
three layers they resolve from. This is the read an editor uses
|
|
to show what the folder said underneath a claim, and whether
|
|
clearing a claim would change anything.
|
|
|
|
`shared` and `personal` are `null` when that layer has no claim.
|
|
They are `[]` when the layer explicitly claims the book is in no
|
|
series. That difference is meaningful: `null` means there is
|
|
nothing to reset at that layer, while `[]` is a resettable claim.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
responses:
|
|
"200":
|
|
description: The effective series and the layers beneath it
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/BookSeriesLayers" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
|
|
put:
|
|
tags: [library]
|
|
summary: Replace one layer's series claim for a book
|
|
security: [{ deviceToken: [] }] # requires library-manage scope
|
|
description: |
|
|
States the whole of one writable layer's opinion about the
|
|
book's series memberships. It is a replacement, not a diff:
|
|
omitted memberships are removed from that layer's claim.
|
|
|
|
The default scope is `personal`, which affects only the caller.
|
|
`shared` affects every reader without a personal claim and also
|
|
requires the `admin` scope; a non-admin token with
|
|
`library-manage` receives `403`.
|
|
|
|
An absent or empty `series` array is a real claim that the book
|
|
is in no series. It is not the same as no claim; use `DELETE` to
|
|
clear a layer and fall back to the one beneath it.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/BookSeriesClaim" }
|
|
responses:
|
|
"200":
|
|
description: The effective series and all layers after the claim
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/BookSeriesLayers" }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed input, unwritable scope, invalid membership, unknown series id, non-finite position, or too many series" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
|
|
"409": { $ref: "#/components/responses/Error", description: "client_ts was reused for different claim state" }
|
|
delete:
|
|
tags: [library]
|
|
summary: Clear one layer's series claim for a book
|
|
security: [{ deviceToken: [] }] # requires library-manage scope
|
|
description: |
|
|
Drops one writable layer's claim so the book falls back to the
|
|
layer beneath. Clearing a claim that is not present is not an
|
|
error. The default scope is `personal`; `shared` also requires
|
|
the `admin` scope.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: scope
|
|
in: query
|
|
required: false
|
|
description: The claim layer to clear. Defaults to `personal`.
|
|
schema: { $ref: "#/components/schemas/SeriesClaimScope" }
|
|
- name: client_ts
|
|
in: query
|
|
required: false
|
|
description: Client-generated stable idempotency key. Reuse it exactly when retrying the same deletion.
|
|
schema: { type: string }
|
|
- name: if_updated_at
|
|
in: query
|
|
required: false
|
|
description: Last server claim revision observed by the client. A different current revision returns `stale` without mutation. Revisions are millisecond-precise and compared at that precision.
|
|
schema: { type: string, format: date-time }
|
|
responses:
|
|
"200":
|
|
description: The effective series and all layers after clearing
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/BookSeriesLayers" }
|
|
"400": { $ref: "#/components/responses/Error", description: "Unwritable scope" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
|
|
"409": { $ref: "#/components/responses/Error", description: "client_ts was reused for different claim state" }
|
|
|
|
/v1/entities/{kind}:
|
|
get:
|
|
tags: [library]
|
|
summary: The series, contributors and tags in the library
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
description: |
|
|
Entities are library-wide and shared by every book that claims
|
|
them, whichever folder that book was found in: one series named
|
|
the same way in two folders is one entity here (ADR-0019).
|
|
`book_count` counts only books whose file is present, so an
|
|
entity whose books have all gone missing reads as empty rather
|
|
than leading to a blank page.
|
|
|
|
Paging resumes after a name rather than at an offset, because an
|
|
offset would skip or repeat entities as books are added
|
|
underneath it.
|
|
parameters:
|
|
- { $ref: "#/components/parameters/EntityKind" }
|
|
- name: after
|
|
in: query
|
|
description: The `next_after` of the previous page.
|
|
schema: { type: string, maxLength: 512 }
|
|
- name: limit
|
|
in: query
|
|
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
|
responses:
|
|
"200":
|
|
description: One page of entities, ordered by name
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [entities]
|
|
properties:
|
|
entities:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/CatalogEntity" }
|
|
next_after:
|
|
type: string
|
|
description: Absent on the last page.
|
|
"400": { $ref: "#/components/responses/Error", description: "Bad limit or cursor" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such kind" }
|
|
|
|
/v1/entities/{kind}/{entity}/books:
|
|
get:
|
|
tags: [library]
|
|
summary: The books claiming one entity
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
description: |
|
|
Active books only, from every folder. A series returns its books
|
|
in reading order, with books that have no position last, because
|
|
an unplaced book is an unanswered question rather than book zero.
|
|
Every other kind returns them oldest first.
|
|
parameters:
|
|
- { $ref: "#/components/parameters/EntityKind" }
|
|
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- { name: cursor, in: query, schema: { type: string } }
|
|
- name: limit
|
|
in: query
|
|
schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
|
|
responses:
|
|
"200":
|
|
description: The entity and one page of its books
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [entity, books]
|
|
properties:
|
|
entity: { $ref: "#/components/schemas/CatalogEntity" }
|
|
books:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/CatalogBook" }
|
|
next_cursor:
|
|
type: string
|
|
description: Absent on the last page.
|
|
"400": { $ref: "#/components/responses/Error", description: "Bad limit or malformed cursor" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such kind or entity" }
|
|
|
|
/v1/entities/{kind}/{entity}/order:
|
|
put:
|
|
tags: [library]
|
|
summary: Reorder the books in a series
|
|
security: [{ deviceToken: [] }] # requires library-manage scope
|
|
description: |
|
|
Bulk-renumbers books within one series in a single transaction.
|
|
Only `kind=series` is accepted; other entity kinds are `404`.
|
|
|
|
The operation restates positions in one writable layer and
|
|
preserves each book's other series memberships. Replaying the
|
|
same order is idempotent. The default scope is `personal`;
|
|
`shared` also requires `admin`.
|
|
|
|
The series is library-wide, so an order covers every folder's
|
|
copy of it. Send the ids of the books actually on the shelf you
|
|
are renumbering.
|
|
parameters:
|
|
- { $ref: "#/components/parameters/EntityKind" }
|
|
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/SeriesReorder" }
|
|
responses:
|
|
"204": { description: The order was applied }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed input, empty order, duplicate book, non-finite position, or too many books" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such series, book, or reorderable kind" }
|
|
|
|
/v1/entities/{kind}/{entity}/name:
|
|
put:
|
|
tags: [library]
|
|
summary: Rename a series
|
|
security: [{ deviceToken: [] }] # requires library-manage scope
|
|
description: |
|
|
Renames one series in one layer (ADR-0020). Only `kind=series`
|
|
is accepted; other entity kinds are `404`, because a name a scan
|
|
gave a tag or a contributor has no layer over it.
|
|
|
|
The rename is a display layer. The name the scan observed is
|
|
left alone and stays what a folder pass matches against, so a
|
|
series renamed here still absorbs a folder that goes on calling
|
|
it by its old name — which is the point, and is why a rename
|
|
survives a rescan.
|
|
|
|
The default scope is `personal`; `shared` also requires `admin`.
|
|
|
|
A name that already belongs to another series in the caller's
|
|
view is a `409`. That is a request to merge two shelves, which
|
|
this API does not do.
|
|
parameters:
|
|
- { $ref: "#/components/parameters/EntityKind" }
|
|
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/SeriesRename" }
|
|
responses:
|
|
"200":
|
|
description: The series as it now reads
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/CatalogEntity" }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed body, empty name, or a name over 512 bytes" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such series, or a kind that cannot be renamed" }
|
|
"409": { $ref: "#/components/responses/Error", description: "That name already belongs to another series" }
|
|
delete:
|
|
tags: [library]
|
|
summary: Undo a rename
|
|
security: [{ deviceToken: [] }] # requires library-manage scope
|
|
description: |
|
|
Drops one layer's rename so the series falls back to the layer
|
|
beneath it, and ultimately to the name the scan gave it.
|
|
Clearing a name that was never set is not an error.
|
|
parameters:
|
|
- { $ref: "#/components/parameters/EntityKind" }
|
|
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: scope
|
|
in: query
|
|
schema: { type: string, enum: [personal, shared], default: personal }
|
|
responses:
|
|
"200":
|
|
description: The series as it now reads
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/CatalogEntity" }
|
|
"400": { $ref: "#/components/responses/Error", description: "Unknown scope" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the shared layer requires admin" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such series, or a kind that cannot be renamed" }
|
|
|
|
/v1/entities/{kind}/{entity}/merge:
|
|
post:
|
|
tags: [library]
|
|
summary: Merge this series into another
|
|
security: [{ deviceToken: [] }] # requires library-manage AND admin
|
|
description: |
|
|
Folds the addressed series into the one named by `into`
|
|
(ADR-0021). The addressed series is the one absorbed; the answer
|
|
describes the survivor, which is where its books now are.
|
|
|
|
A merge is a statement about the library's shape, so it is
|
|
shared and takes `admin` on top of `library-manage`. There is no
|
|
personal form: a reader who wants their own arrangement has the
|
|
personal claim layer.
|
|
|
|
Memberships and claims naming the absorbed series are repointed
|
|
at the survivor, and a book already on both shelves keeps the
|
|
survivor's position. **Nothing is renumbered**: two shelves that
|
|
each had a volume 1 still do.
|
|
|
|
The absorbed row is deleted, but its name is not forgotten: a
|
|
binding records that the name now means the survivor, and a
|
|
folder pass that observes it folds it in again. That is what
|
|
makes a merge survive a rescan.
|
|
|
|
Reading state is untouched. Series do not appear in the op log,
|
|
in sessions or in a position.
|
|
parameters:
|
|
- { $ref: "#/components/parameters/EntityKind" }
|
|
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/SeriesMerge" }
|
|
responses:
|
|
"200":
|
|
description: The surviving series
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/CatalogEntity" }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed body, missing `into`, or a series merged into itself" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the caller is not an admin" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such series, either addressed or named by `into`" }
|
|
|
|
/v1/entities/{kind}/{entity}/split:
|
|
post:
|
|
tags: [library]
|
|
summary: Split one folder's books onto a new series
|
|
security: [{ deviceToken: [] }] # requires library-manage AND admin
|
|
description: |
|
|
Moves the books one folder contributed to this shelf onto a new
|
|
series called `name` (ADR-0021), undoing the automatic fold that
|
|
put two folders' identically named series together.
|
|
|
|
Every name that folds into the old shelf — its own and any it
|
|
absorbed in an earlier merge — is bound to the new series *in
|
|
that folder only*, so the next pass over it agrees.
|
|
|
|
Splitting a shelf whose books all came from one folder is a
|
|
rename, and is refused as one. Splitting a *single* folder's
|
|
shelf into two — an omnibus directory holding two series — is a
|
|
per-book decision: use the shared claim layer
|
|
(`PUT /v1/books/{id}/series`).
|
|
parameters:
|
|
- { $ref: "#/components/parameters/EntityKind" }
|
|
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/SeriesSplit" }
|
|
responses:
|
|
"200":
|
|
description: The new series
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/CatalogEntity" }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed body, missing `folder_id`, empty or over-long name, or every book on the shelf came from that folder" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the caller is not an admin" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such series, or that folder has no books on this shelf" }
|
|
"409": { $ref: "#/components/responses/Error", description: "That name already belongs to another series" }
|
|
|
|
/v1/entities/{kind}/{entity}/bindings:
|
|
get:
|
|
tags: [library]
|
|
summary: List the names that fold into this series
|
|
security: [{ deviceToken: [] }] # requires library-manage AND admin
|
|
description: |
|
|
Returns the observed names a merge or a split made resolve to
|
|
this series (ADR-0021), which is what a client shows to offer an
|
|
undo. A binding with no `folder_id` applies to every folder; one
|
|
with a `folder_id` applies only there.
|
|
|
|
This is not what a folder pass observed today. A binding is a
|
|
resolver rule and outlives the folder's own spelling on purpose:
|
|
it is what keeps a merge from coming undone when somebody edits
|
|
the name back in their library.
|
|
parameters:
|
|
- { $ref: "#/components/parameters/EntityKind" }
|
|
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
|
|
responses:
|
|
"200":
|
|
description: The bindings pointing at this series
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/SeriesBindingList" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the caller is not an admin" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such series, or a kind that has no bindings" }
|
|
|
|
/v1/entities/{kind}/{entity}/bindings/{binding}:
|
|
delete:
|
|
tags: [library]
|
|
summary: Undo a merge or a split
|
|
security: [{ deviceToken: [] }] # requires library-manage AND admin
|
|
description: |
|
|
Deletes one binding. No book moves: the next pass over a folder
|
|
that observes the freed name resolves it to nothing, mints the
|
|
series again and refills it from what the folder says.
|
|
|
|
So an unmerge restores what the disk holds, not what readers
|
|
claimed. A claim that named the absorbed series was repointed by
|
|
the merge and stays repointed, and a series that only ever
|
|
existed as a claim has no folder to come back from.
|
|
parameters:
|
|
- { $ref: "#/components/parameters/EntityKind" }
|
|
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- { name: binding, in: path, required: true, schema: { type: string, format: uuid } }
|
|
responses:
|
|
"204": { description: The binding is gone }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-manage, or the caller is not an admin" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such series or binding" }
|
|
|
|
/v1/books/{id}/resolve:
|
|
post:
|
|
tags: [library, sync]
|
|
summary: Join a catalog book to your own work
|
|
security: [{ deviceToken: [] }] # requires BOTH library-read and sync
|
|
description: |
|
|
Returns the `work_id` to report positions and sessions against for a
|
|
book that came from the catalog, creating it on first call.
|
|
|
|
Send no identifiers: the server collects them from the catalog. A
|
|
client that has only browsed has not seen the bytes, and two devices
|
|
should resolve the same book from the same evidence.
|
|
|
|
The mapping is per user. Two readers of one shared book get two
|
|
different works, which is what keeps reading history private.
|
|
|
|
A title/author-only match is a guess: it answers `confidence: low`
|
|
and stores nothing. Repeat with `confirmed: true` to accept it.
|
|
|
|
This is the one route that needs two scopes. It reads the catalog
|
|
and writes the caller's work graph, so a catalog-only credential
|
|
cannot perform it and neither can a sync-only one.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
requestBody:
|
|
required: false
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties:
|
|
confirmed:
|
|
type: boolean
|
|
description: Accept a low-confidence title/author match.
|
|
responses:
|
|
"200":
|
|
description: The existing work, or a low-confidence proposal
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/CatalogResolution" }
|
|
"201":
|
|
description: A work was created for this book
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/CatalogResolution" }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed body" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-read or sync" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
|
|
"409":
|
|
description: The book's identifiers resolve to more than one work, and nothing was changed
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [error, works]
|
|
properties:
|
|
error: { type: string }
|
|
works: { type: array, items: { type: string } }
|
|
|
|
/v1/books/resolve:
|
|
post:
|
|
tags: [library, sync]
|
|
summary: Join many catalog books to your own works
|
|
security: [{ deviceToken: [] }] # requires BOTH library-read and sync
|
|
description: |
|
|
The same join as `POST /v1/books/{id}/resolve`, for a shelf at a
|
|
time. It exists for the moment a device signs in to an account and
|
|
has a name here for none of its books: a book with no work can
|
|
neither send a position nor receive one, so asking one request per
|
|
book is what made a fresh library arrive in batches.
|
|
|
|
Each book is resolved in its own transaction and takes its own
|
|
answer, so one book the server cannot place settles nothing about
|
|
the others. The response is therefore `200` with per-item results
|
|
rather than a status code: no single status could describe five
|
|
hundred answers, and refusing the batch would throw away the ones
|
|
that worked.
|
|
|
|
Results come back in the order the ids were sent. A refused one
|
|
carries `error`: `not_found`, `ambiguous` with the works, or
|
|
`conflict` when the work graph moved under the write — the only
|
|
one of the three worth sending again.
|
|
|
|
`confirmed` applies to the whole batch and should be sent only by a
|
|
caller with no reader to ask. Accepting a doubtful title/author
|
|
match is a question about one book, and the single-book route is
|
|
where the answer belongs.
|
|
requestBody:
|
|
required: true
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [book_ids]
|
|
properties:
|
|
book_ids:
|
|
type: array
|
|
minItems: 1
|
|
maxItems: 500
|
|
items: { type: string, format: uuid }
|
|
confirmed:
|
|
type: boolean
|
|
description: Accept low-confidence title/author matches.
|
|
responses:
|
|
"200":
|
|
description: One result per requested book, in the order asked for
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
required: [results]
|
|
properties:
|
|
results:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/CatalogResolutionItem" }
|
|
"400":
|
|
description: |
|
|
Malformed or empty body, or more than 500 ids — in which case
|
|
the answer carries `code: batch_too_large` and the `limit` the
|
|
server will take.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/BatchItemError" }
|
|
"403": { $ref: "#/components/responses/Error", description: "The token lacks library-read or sync" }
|
|
"413": { $ref: "#/components/responses/Error", description: "Request body too large" }
|
|
|
|
/v1/books/{id}/download:
|
|
get:
|
|
tags: [library]
|
|
summary: Download a book's file
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
description: |
|
|
Serves the newest available file. Supports `HEAD`, byte ranges
|
|
and conditional requests; `Content-Disposition` carries a
|
|
sanitized filename that is a label, never a path.
|
|
|
|
For a book whose bytes this server keeps a copy of, the `ETag` is
|
|
the content digest and never changes, and the answer may be
|
|
cached indefinitely. For a book read out of a watched
|
|
directory, the bytes belong to whoever owns that directory: the
|
|
`ETag` is weak and the answer is `private, no-cache`. If such a
|
|
file no longer matches what was catalogued — a different size or
|
|
modification time — it is refused with `409` and the book is
|
|
flagged for an administrator rather than served under a title
|
|
that may no longer describe it.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- { name: Range, in: header, required: false, schema: { type: string } }
|
|
- { name: If-None-Match, in: header, required: false, schema: { type: string } }
|
|
responses:
|
|
"200":
|
|
description: The file
|
|
content:
|
|
application/epub+zip:
|
|
schema: { type: string, format: binary }
|
|
"206": { description: The requested byte range }
|
|
"304": { description: The client's copy is current }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
|
|
"409": { $ref: "#/components/responses/Error", description: "The file behind this book changed on disk since it was catalogued" }
|
|
"410": { $ref: "#/components/responses/Error", description: "The book exists but its content is no longer stored" }
|
|
"416": { $ref: "#/components/responses/Error", description: "The requested byte range is not satisfiable" }
|
|
"503": { $ref: "#/components/responses/Error", description: "Content storage is not configured on this instance" }
|
|
|
|
/v1/books/{id}/publication/manifest.json:
|
|
get:
|
|
tags: [library]
|
|
summary: Fetch a book's Readium publication manifest
|
|
security: [{ deviceToken: [] }]
|
|
description: |
|
|
Returns a Readium Web Publication manifest for the catalogued EPUB.
|
|
Its reading-order and resource links are versioned by the book's
|
|
content digest. Fetch the position-list and package links with the
|
|
same bearer token; a digest change means the client must reopen the
|
|
publication. `HEAD` is also supported and returns the same headers
|
|
without a body.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
responses:
|
|
"200":
|
|
description: Readium Web Publication manifest
|
|
headers:
|
|
Cache-Control: { schema: { type: string, const: "private, no-store" } }
|
|
content:
|
|
application/webpub+json:
|
|
schema: { type: object, additionalProperties: true }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
|
|
"410": { $ref: "#/components/responses/Error", description: "The book has no readable file" }
|
|
"422": { $ref: "#/components/responses/Error", description: "The EPUB is invalid or unsupported" }
|
|
|
|
/v1/books/{id}/publication/{digest}/positions.json:
|
|
get:
|
|
tags: [library]
|
|
summary: Fetch a Readium position list
|
|
security: [{ deviceToken: [] }]
|
|
description: Returns the positions generated for the versioned EPUB publication. `HEAD` is also supported.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- { name: digest, in: path, required: true, description: Content SHA-256 from the manifest links, schema: { type: string, pattern: "^[0-9a-f]{64}$" } }
|
|
responses:
|
|
"200":
|
|
description: Readium position list
|
|
content:
|
|
application/vnd.readium.position-list+json:
|
|
schema:
|
|
type: object
|
|
required: [total, positions]
|
|
properties:
|
|
total: { type: integer, minimum: 0 }
|
|
positions: { type: array, items: { type: object, additionalProperties: true } }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
|
|
"409": { $ref: "#/components/responses/Error", description: "The digest is stale" }
|
|
"410": { $ref: "#/components/responses/Error", description: "The book has no readable file" }
|
|
|
|
/v1/books/{id}/publication/{digest}/resources/{resource}:
|
|
get:
|
|
tags: [library]
|
|
summary: Fetch one Readium publication resource
|
|
security: [{ deviceToken: [] }]
|
|
description: |
|
|
Returns one declared EPUB resource, addressed relative to the
|
|
package document. Clients should use the resource URLs in the
|
|
manifest rather than constructing paths.
|
|
|
|
`{resource}` is a greedy match on the server: it is registered as
|
|
Go's `{resource...}` wildcard, so it consumes every remaining
|
|
path segment after `/resources/`, slashes included (for example
|
|
`OEBPS/images/cover.jpg` arrives as that one value, not three
|
|
separate segments). OpenAPI has no path-parameter syntax for
|
|
that, so tooling that treats `{resource}` as a single segment
|
|
will not by itself construct a nested path correctly; use the
|
|
resource URLs already embedded in the manifest instead of
|
|
building this path yourself. `HEAD` is also supported.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- { name: digest, in: path, required: true, schema: { type: string, pattern: "^[0-9a-f]{64}$" } }
|
|
- { name: resource, in: path, required: true, description: "Relative archive resource path; may itself contain slashes (see above)", schema: { type: string } }
|
|
responses:
|
|
"200":
|
|
description: The declared resource bytes
|
|
content:
|
|
application/octet-stream:
|
|
schema: { type: string, format: binary }
|
|
"401": { $ref: "#/components/responses/Error" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, no access, or undeclared resource" }
|
|
"409": { $ref: "#/components/responses/Error", description: "The digest is stale" }
|
|
"410": { $ref: "#/components/responses/Error", description: "The book has no readable file" }
|
|
|
|
/v1/books/{id}/cover:
|
|
get:
|
|
tags: [library]
|
|
summary: Fetch a book's cover image
|
|
security: [{ deviceToken: [] }] # requires library-read scope
|
|
description: |
|
|
Renders the cover declared by the publication and serves it as a
|
|
JPEG. The type is always `image/jpeg` regardless of what the book
|
|
contains, and `X-Content-Type-Options: nosniff` is set: covers are
|
|
publisher-supplied bytes, and this route decides what they are.
|
|
|
|
Rendered covers are cached, so the first request for a book pays
|
|
for the decode and later ones do not. The `ETag` combines the
|
|
content digest with the size, both of which are fixed for a given
|
|
book, so a client that has one may reuse it indefinitely — except
|
|
for a book read out of a watched folder, whose
|
|
bytes are not this server's to promise: there the validator is
|
|
weak and the answer is `private, no-cache`.
|
|
|
|
A book whose publication declares no cover, or one this server
|
|
cannot decode, answers `404`. That answer is cached too, so it
|
|
stays cheap for a catalog full of coverless books.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: size
|
|
in: query
|
|
required: false
|
|
description: |
|
|
`thumbnail` is a catalog grid image and the default; `full` is
|
|
sized for a book page; `icon` is a small square crop for a
|
|
browser tab. Any other value is rejected rather than
|
|
treated as the default. `icon` alone answers a coverless
|
|
book with a drawn placeholder rather than a `404`: the asker
|
|
there needs an image, not an answer.
|
|
schema: { type: string, enum: [thumbnail, full, icon], default: thumbnail }
|
|
- { name: If-None-Match, in: header, required: false, schema: { type: string } }
|
|
responses:
|
|
"200":
|
|
description: The rendered cover
|
|
content:
|
|
image/jpeg:
|
|
schema: { type: string, format: binary }
|
|
"304": { description: The client's copy is current }
|
|
"400": { $ref: "#/components/responses/Error", description: "Unknown cover size" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, no access, or no cover this server can serve" }
|
|
"409": { $ref: "#/components/responses/Error", description: "The file behind this book changed on disk since it was catalogued" }
|
|
"410": { $ref: "#/components/responses/Error", description: "The book exists but its content is no longer stored" }
|
|
"503": { $ref: "#/components/responses/Error", description: "Content storage is not configured on this instance" }
|
|
|
|
/opds/v1.2:
|
|
get:
|
|
tags: [opds]
|
|
summary: OPDS 1.2 navigation feed
|
|
security: [{ opdsBasic: [] }] # requires library-read scope
|
|
description: |
|
|
The catalog root for e-reader clients, and the only entry point
|
|
they need: it lists the server's folders, each linking to its
|
|
acquisition feed. `/opds/v1.2/` is accepted too, because readers
|
|
differ on the trailing slash.
|
|
|
|
OPDS is catalog-only by design. It exposes no reading positions,
|
|
sessions or statistics even when the token also carries `sync`.
|
|
responses:
|
|
"200":
|
|
description: An Atom navigation feed
|
|
content:
|
|
application/atom+xml;profile=opds-catalog;kind=navigation:
|
|
schema: { type: string }
|
|
"401": { description: "Missing or invalid credentials; carries WWW-Authenticate" }
|
|
"403": { $ref: "#/components/responses/Error", description: "Valid credential without library-read" }
|
|
|
|
/opds/v1.2/folders/{folder}:
|
|
get:
|
|
tags: [opds]
|
|
summary: OPDS 1.2 acquisition feed for one folder
|
|
security: [{ opdsBasic: [] }] # requires library-read scope
|
|
description: |
|
|
One page of books, each with an EPUB acquisition link. A `next`
|
|
link appears while more pages remain; readers follow it rather
|
|
than constructing cursors.
|
|
parameters:
|
|
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: cursor
|
|
in: query
|
|
schema: { type: string }
|
|
description: Opaque; only ever taken from a `next` link.
|
|
responses:
|
|
"200":
|
|
description: An Atom acquisition feed
|
|
content:
|
|
application/atom+xml;profile=opds-catalog;kind=acquisition:
|
|
schema: { type: string }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed cursor" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such folder" }
|
|
|
|
/opds/v1.2/folders/{folder}/search.xml:
|
|
get:
|
|
tags: [opds]
|
|
summary: OpenSearch description for one folder
|
|
security: [{ opdsBasic: [] }] # requires library-read scope
|
|
description: |
|
|
Readers find this through the `search` link on the folder's
|
|
acquisition feed rather than being configured with it, and read
|
|
the query template out of it. `searchTerms` is the only term
|
|
offered: a reader that could substitute anything else would be
|
|
asking a question this surface deliberately cannot answer.
|
|
parameters:
|
|
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
|
|
responses:
|
|
"200":
|
|
description: An OpenSearch description document
|
|
content:
|
|
application/opensearchdescription+xml:
|
|
schema: { type: string }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such folder" }
|
|
|
|
/opds/v1.2/folders/{folder}/search:
|
|
get:
|
|
tags: [opds]
|
|
summary: OPDS search results for one folder
|
|
security: [{ opdsBasic: [] }] # requires library-read scope
|
|
description: |
|
|
The same search as `GET /v1/folders/{folder}/search`, rendered
|
|
as an acquisition feed so every result can be downloaded from
|
|
where it was found. It carries no `next` link: the feed is
|
|
unpaged, and the answer to wanting more is a better query.
|
|
parameters:
|
|
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- { name: q, in: query, schema: { type: string, maxLength: 512 } }
|
|
responses:
|
|
"200":
|
|
description: An Atom acquisition feed of the matches
|
|
content:
|
|
application/atom+xml;profile=opds-catalog;kind=acquisition:
|
|
schema: { type: string }
|
|
"400": { $ref: "#/components/responses/Error", description: "Over-long query" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such folder" }
|
|
|
|
/opds/v1.2/folders/{folder}/recent:
|
|
get:
|
|
tags: [opds]
|
|
summary: OPDS acquisition feed of a folder's newest books
|
|
security: [{ opdsBasic: [] }] # requires library-read scope
|
|
description: |
|
|
The feed a reader opens to see what has arrived since last time,
|
|
reached from the `http://opds-spec.org/sort/new` link on the
|
|
folder's own feed. It is `order=recent` on the native listing,
|
|
rendered as a feed.
|
|
parameters:
|
|
- { name: folder, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: cursor
|
|
in: query
|
|
schema: { type: string }
|
|
description: Opaque; only ever taken from a `next` link.
|
|
responses:
|
|
"200":
|
|
description: An Atom acquisition feed, newest first
|
|
content:
|
|
application/atom+xml;profile=opds-catalog;kind=acquisition:
|
|
schema: { type: string }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed cursor" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such folder" }
|
|
|
|
/opds/v1.2/entities/{kind}:
|
|
get:
|
|
tags: [opds]
|
|
summary: OPDS navigation feed of the library's series, contributors or tags
|
|
security: [{ opdsBasic: [] }] # requires library-read scope
|
|
description: |
|
|
Reached from the `http://opds-spec.org/facet` links on a folder's
|
|
acquisition feed. Each entry leads to the books claiming it,
|
|
across every folder.
|
|
parameters:
|
|
- $ref: "#/components/parameters/EntityKind"
|
|
- name: after
|
|
in: query
|
|
schema: { type: string }
|
|
description: Only ever taken from a `next` link.
|
|
responses:
|
|
"200":
|
|
description: An Atom navigation feed
|
|
content:
|
|
application/atom+xml;profile=opds-catalog;kind=navigation:
|
|
schema: { type: string }
|
|
"400": { $ref: "#/components/responses/Error", description: "Over-long cursor" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such kind" }
|
|
|
|
/opds/v1.2/entities/{kind}/{entity}:
|
|
get:
|
|
tags: [opds]
|
|
summary: OPDS acquisition feed of the books claiming one entity
|
|
security: [{ opdsBasic: [] }] # requires library-read scope
|
|
description: |
|
|
A series comes back in reading order, with unplaced books last,
|
|
which is the whole reason a reader would rather browse a series
|
|
than search for it.
|
|
parameters:
|
|
- $ref: "#/components/parameters/EntityKind"
|
|
- { name: entity, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: cursor
|
|
in: query
|
|
schema: { type: string }
|
|
description: Opaque; only ever taken from a `next` link.
|
|
responses:
|
|
"200":
|
|
description: An Atom acquisition feed
|
|
content:
|
|
application/atom+xml;profile=opds-catalog;kind=acquisition:
|
|
schema: { type: string }
|
|
"400": { $ref: "#/components/responses/Error", description: "Malformed cursor" }
|
|
"404": { $ref: "#/components/responses/Error", description: "No such kind or entity" }
|
|
|
|
/opds/v1.2/books/{id}/download:
|
|
get:
|
|
tags: [opds]
|
|
summary: Acquire a book over OPDS
|
|
security: [{ opdsBasic: [] }] # requires library-read scope
|
|
description: |
|
|
The same download as `/v1/books/{id}/download`, reachable with
|
|
the Basic credential a reader already holds. Ranges, `HEAD` and
|
|
conditional requests behave identically.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- { name: Range, in: header, required: false, schema: { type: string } }
|
|
- { name: If-None-Match, in: header, required: false, schema: { type: string } }
|
|
responses:
|
|
"200":
|
|
description: The file
|
|
content:
|
|
application/epub+zip:
|
|
schema: { type: string, format: binary }
|
|
"206": { description: The requested byte range }
|
|
"304": { description: The client's copy is current }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, or no access" }
|
|
"409": { $ref: "#/components/responses/Error", description: "The file behind this book changed on disk since it was catalogued" }
|
|
"410": { $ref: "#/components/responses/Error", description: "The book exists but its content is no longer stored" }
|
|
"416": { $ref: "#/components/responses/Error", description: "The requested byte range is not satisfiable" }
|
|
"503": { $ref: "#/components/responses/Error", description: "Content storage is not configured on this instance" }
|
|
|
|
/opds/v1.2/books/{id}/cover:
|
|
get:
|
|
tags: [opds]
|
|
summary: Fetch a book cover over OPDS
|
|
security: [{ opdsBasic: [] }] # requires library-read scope
|
|
description: |
|
|
The same rendered JPEG cover as `/v1/books/{id}/cover`, reachable
|
|
with the Basic credential used for the OPDS feed. `HEAD` and
|
|
conditional requests behave identically.
|
|
parameters:
|
|
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
|
|
- name: size
|
|
in: query
|
|
required: false
|
|
schema: { type: string, enum: [thumbnail, full, icon], default: thumbnail }
|
|
- { name: If-None-Match, in: header, required: false, schema: { type: string } }
|
|
responses:
|
|
"200":
|
|
description: The rendered cover
|
|
content:
|
|
image/jpeg:
|
|
schema: { type: string, format: binary }
|
|
"304": { description: The client's copy is current }
|
|
"400": { $ref: "#/components/responses/Error", description: "Unknown cover size" }
|
|
"404": { $ref: "#/components/responses/Error", description: "Unknown book, no access, or no cover this server can serve" }
|
|
"409": { $ref: "#/components/responses/Error", description: "The file behind this book changed on disk since it was catalogued" }
|
|
"410": { $ref: "#/components/responses/Error", description: "The book exists but its content is no longer stored" }
|
|
"503": { $ref: "#/components/responses/Error", description: "Content storage is not configured on this instance" }
|
|
|
|
components:
|
|
securitySchemes:
|
|
deviceToken:
|
|
type: http
|
|
scheme: bearer
|
|
description: Scoped device token (`sync`, `read-insights`, `library-read`, `library-manage`, `library-upload`, `library-delete`, or `admin`)
|
|
loginAuth:
|
|
type: http
|
|
scheme: bearer
|
|
description: Short-lived auth token from /v1/login (token management only)
|
|
opdsBasic:
|
|
type: http
|
|
scheme: basic
|
|
description: |
|
|
OPDS only. Username is the token's name or the literal `token`;
|
|
the password is a device token secret carrying `library-read`.
|
|
Account passwords are rejected — a reader stores its credential
|
|
in plain text on the device.
|
|
|
|
parameters:
|
|
EntityKind:
|
|
name: kind
|
|
in: path
|
|
required: true
|
|
description: |
|
|
Which kind of library-wide entity. The set is closed, and a kind
|
|
outside it is a 404 rather than a 400: it names no resource.
|
|
schema:
|
|
type: string
|
|
enum: [series, contributors, tags]
|
|
|
|
responses:
|
|
Error:
|
|
description: |
|
|
Error with a human-readable reason. Most failures carry only
|
|
`error`; a refusal a client can act on also carries a
|
|
machine-readable `code` and the identifiers that explain it
|
|
(see `BatchItemError` for the batch routes). Clients must key
|
|
any recovery off `code`, never off the message text.
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
RateLimited:
|
|
description: Per-IP limit or shared password capacity reached
|
|
headers:
|
|
Retry-After:
|
|
description: Seconds to wait before retrying
|
|
schema: { type: integer, example: 1 }
|
|
content:
|
|
application/json:
|
|
schema: { $ref: "#/components/schemas/Error" }
|
|
StatusOK:
|
|
description: OK
|
|
content:
|
|
application/json:
|
|
schema:
|
|
type: object
|
|
properties: { status: { type: string } }
|
|
|
|
schemas:
|
|
Error:
|
|
type: object
|
|
required: [error]
|
|
properties:
|
|
error: { type: string }
|
|
code: { type: string }
|
|
BatchItemError:
|
|
type: object
|
|
description: |
|
|
A refusal of a batch (`POST /v1/ops`, `POST /v1/sessions`). The
|
|
batch is appended atomically, so a refusal means nothing was
|
|
stored, and it names the first item that caused it well enough
|
|
for a client to set that item aside and send the rest again:
|
|
`item_index` is always present for an item-level refusal;
|
|
`op_id`/`session_id` when the item had one (a `missing_field`
|
|
about the id itself cannot name it). Batch-level refusals
|
|
(`batch_too_large`) have no `item_index`.
|
|
required: [error]
|
|
properties:
|
|
error: { type: string }
|
|
code:
|
|
type: string
|
|
enum:
|
|
- unknown_work
|
|
- missing_field
|
|
- bad_time
|
|
- time_in_future
|
|
- progression_out_of_range
|
|
- locator_too_large
|
|
- idle_out_of_range
|
|
- active_out_of_range
|
|
- id_reused
|
|
- batch_too_large
|
|
description: |
|
|
`unknown_work`: the item named a work the server no longer
|
|
holds; re-resolve the book and retry under the new id.
|
|
`locator_too_large`: retry that op under the same `op_id`
|
|
without its locator. `id_reused` (409): the id was already
|
|
accepted with a different payload; that item will never be
|
|
taken as is. The validation codes are permanent for that
|
|
payload. Unrecognised codes should be treated as permanent
|
|
for the item but reported, not silently dropped.
|
|
item_index:
|
|
type: integer
|
|
description: Zero-based position of the item in the batch.
|
|
op_id: { type: string }
|
|
session_id: { type: string }
|
|
work_id:
|
|
type: string
|
|
description: With `unknown_work`, the work the server lacks.
|
|
limit:
|
|
type: integer
|
|
description: |
|
|
With `locator_too_large`, the byte bound; with
|
|
`batch_too_large`, the item bound.
|
|
CatalogResolution:
|
|
type: object
|
|
description: |
|
|
The join between a catalog book and the caller's own sync work,
|
|
plus the identifiers the server used to make it, so a client can
|
|
show why two books were treated as one.
|
|
required: [book_id, work_id, confidence, created, identifiers]
|
|
properties:
|
|
book_id: { type: string }
|
|
work_id: { type: string }
|
|
confidence:
|
|
type: string
|
|
enum: [high, low]
|
|
description: |
|
|
`low` means the match rests on title and author alone. Nothing
|
|
was stored; resend with `confirmed: true` to accept it.
|
|
created:
|
|
type: boolean
|
|
description: Whether this call created the work.
|
|
identifiers:
|
|
type: array
|
|
description: The catalog's evidence, strongest first.
|
|
items:
|
|
type: object
|
|
required: [kind, value]
|
|
properties:
|
|
kind: { type: string, enum: [sha256, partial-md5, source, dc, ta] }
|
|
value: { type: string }
|
|
|
|
CatalogResolutionItem:
|
|
type: object
|
|
description: |
|
|
One book's answer within a batch resolve. Either it resolved, and
|
|
carries the same fields `CatalogResolution` does, or it did not,
|
|
and carries the reason instead.
|
|
|
|
A doubtful match is not a refusal: the work comes back with
|
|
`confidence: low` and nothing is stored, exactly as on the
|
|
single-book route.
|
|
required: [book_id]
|
|
properties:
|
|
book_id: { type: string }
|
|
work_id: { type: string }
|
|
confidence: { type: string, enum: [high, low] }
|
|
created: { type: boolean }
|
|
identifiers:
|
|
type: array
|
|
description: The catalog's evidence, strongest first.
|
|
items:
|
|
type: object
|
|
required: [kind, value]
|
|
properties:
|
|
kind: { type: string, enum: [sha256, partial-md5, source, dc, ta] }
|
|
value: { type: string }
|
|
error:
|
|
type: string
|
|
enum: [not_found, ambiguous, conflict]
|
|
description: |
|
|
Why this book alone was refused. `not_found` is the batch
|
|
spelling of the single route's `404`, `ambiguous` of its
|
|
`409`. `conflict` means the work graph moved under the write
|
|
and the book is worth asking about again.
|
|
works:
|
|
type: array
|
|
description: With `ambiguous`, the works the identifiers landed between.
|
|
items: { type: string }
|
|
|
|
CatalogBook:
|
|
type: object
|
|
description: |
|
|
One book, and the only shape a book is ever returned in: the
|
|
folder listing, search, entity listings and `GET /v1/books/{id}`
|
|
all return exactly this. Detail is not a richer shape.
|
|
|
|
A book is one file in one watched folder, so its file fields are
|
|
flat rather than a list. Optional metadata fields are omitted
|
|
when unknown rather than sent empty, so a client can tell "no
|
|
publisher recorded" from "publisher is blank". The relationship
|
|
fields are the opposite: `contributors` and `series` are always
|
|
present, and empty when the book has none.
|
|
required:
|
|
- book_id
|
|
- folder_id
|
|
- title
|
|
- status
|
|
- sha256
|
|
- size_bytes
|
|
- media_type
|
|
- filename
|
|
- created_at
|
|
- updated_at
|
|
- cover_url
|
|
- contributors
|
|
- series
|
|
- series_source
|
|
properties:
|
|
book_id: { type: string, format: uuid }
|
|
folder_id: { type: string, format: uuid }
|
|
title: { type: string }
|
|
subtitle: { type: string }
|
|
description: { type: string }
|
|
publisher: { type: string }
|
|
published_date: { type: string }
|
|
status:
|
|
type: string
|
|
enum: [active, missing]
|
|
description: |
|
|
`missing` means the last complete pass over the folder did
|
|
not see the file. The book stays in the catalog, and its
|
|
reading history with it, because a disconnected disk is not
|
|
a deleted book. A pass that failed part way, or that
|
|
observed nothing at all, never marks anything missing.
|
|
|
|
A missing book is left out of the listing and search
|
|
responses, because downloading it or fetching its cover
|
|
answers `410`. It is still readable by id, so a client
|
|
holding one can tell a book that went away from a book that
|
|
never existed. It comes back to the listings when a complete
|
|
pass sees the file again.
|
|
sha256:
|
|
type: string
|
|
description: |
|
|
The digest of the file's **content** — what the bytes are.
|
|
It is what a client matches its own copies against, and it
|
|
is never the address of anything inside the server.
|
|
size_bytes:
|
|
type: integer
|
|
format: int64
|
|
description: |
|
|
The length those bytes had when the server last read them.
|
|
That is a fact about the last look, not a promise about now;
|
|
the download is the truth.
|
|
media_type: { type: string, example: application/epub+zip }
|
|
filename:
|
|
type: string
|
|
description: The file's own name on disk, without its directory.
|
|
created_at: { type: string, format: date-time }
|
|
updated_at: { type: string, format: date-time }
|
|
cover_url:
|
|
type: string
|
|
description: |
|
|
Offered for every book, whether or not one is known: a client
|
|
that asks and gets 404 has learned the same thing at the same
|
|
cost as a flag would have told it.
|
|
contributors:
|
|
type: array
|
|
description: |
|
|
Every contributor in every role, in the order the book stores
|
|
them — not just the authors. Roles are normalized, so select
|
|
authors with `role == "author"` rather than by guessing.
|
|
items:
|
|
type: object
|
|
required: [id, name, role]
|
|
properties:
|
|
id: { type: string, format: uuid }
|
|
name: { type: string }
|
|
role: { type: string, example: author }
|
|
series:
|
|
type: array
|
|
description: |
|
|
Every series the book belongs to. A book may be in more than
|
|
one, and reporting a single series would silently pick one.
|
|
For a plain folder a series is the subdirectory the file sits
|
|
in; for a Calibre folder it is what `metadata.db` says. The
|
|
`source` field names the layer now in force for this
|
|
membership.
|
|
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
|
|
series_source:
|
|
allOf: [{ $ref: "#/components/schemas/SeriesSource" }]
|
|
description: Effective layer for the whole book, including when `series` is empty. `personal` is only the caller's own claim.
|
|
series_claim_updated_at:
|
|
type: string
|
|
format: date-time
|
|
description: Revision of the effective shared or personal claim. Omitted for the folder layer.
|
|
|
|
CatalogSeriesMembership:
|
|
type: object
|
|
description: |
|
|
One membership of a book in a series. `source` is the layer that
|
|
supplied this membership after override resolution: `folder` is
|
|
what the last pass observed, `shared` is an administrator's claim
|
|
for everyone without a personal claim, and `personal` is the
|
|
caller's own claim.
|
|
required: [id, name, source]
|
|
properties:
|
|
id: { type: string, format: uuid }
|
|
name: { type: string }
|
|
source: { $ref: "#/components/schemas/SeriesSource" }
|
|
position:
|
|
type: number
|
|
description: |
|
|
Omitted when the book's place in the series is unknown. It
|
|
is never invented: 0 would mean "first".
|
|
|
|
SeriesSource:
|
|
type: string
|
|
enum: [folder, shared, personal]
|
|
description: The layer a resolved series membership came from.
|
|
|
|
SeriesClaimScope:
|
|
type: string
|
|
enum: [personal, shared]
|
|
default: personal
|
|
description: |
|
|
Writable series-claim layer. `personal` affects only the caller;
|
|
`shared` affects every reader without a personal claim and also
|
|
requires `admin`.
|
|
|
|
BookSeriesLayers:
|
|
type: object
|
|
description: |
|
|
All series layers for one book. `series` is the effective list
|
|
returned by every other catalog read. `folder` is what the last
|
|
reconcile pass observed. `shared` and `personal` are `null` when
|
|
that layer has no claim, and an array — possibly empty — when it
|
|
does. `shared_updated_at` and `personal_updated_at` are the last
|
|
layer revisions, including a deletion represented by a null layer.
|
|
Revisions are millisecond-precise, so a client may hold one as
|
|
milliseconds since the epoch and quote it back as a precondition.
|
|
required: [book_id, source, series, folder, shared, personal]
|
|
properties:
|
|
book_id: { type: string, format: uuid }
|
|
source: { $ref: "#/components/schemas/SeriesSource" }
|
|
series:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
|
|
folder:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
|
|
shared:
|
|
type: [array, "null"]
|
|
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
|
|
description: |
|
|
`null` means no shared claim exists; `[]` means the shared
|
|
layer claims the book is in no series.
|
|
personal:
|
|
type: [array, "null"]
|
|
items: { $ref: "#/components/schemas/CatalogSeriesMembership" }
|
|
description: |
|
|
`null` means no personal claim exists; `[]` means the
|
|
personal layer claims the book is in no series.
|
|
shared_updated_at: { type: string, format: date-time }
|
|
personal_updated_at: { type: string, format: date-time }
|
|
outcome: { $ref: "#/components/schemas/SeriesClaimOutcome" }
|
|
|
|
BookSeriesClaim:
|
|
type: object
|
|
description: |
|
|
Replacement claim for one book at one writable layer. The
|
|
`series` array is the whole claim, not a diff. Omitted or empty
|
|
means "this book is in no series"; use `DELETE` to remove the
|
|
claim and fall back.
|
|
properties:
|
|
scope: { $ref: "#/components/schemas/SeriesClaimScope" }
|
|
client_ts:
|
|
type: string
|
|
description: Client-generated stable idempotency key. Reuse exactly on retry; reuse with different state returns 409.
|
|
if_updated_at:
|
|
type: [string, "null"]
|
|
format: date-time
|
|
description: Last server revision observed for this layer. A non-matching revision returns `stale` without mutation. Revisions are millisecond-precise and compared at that precision.
|
|
series:
|
|
type: array
|
|
maxItems: 64
|
|
default: []
|
|
items: { $ref: "#/components/schemas/BookSeriesClaimItem" }
|
|
|
|
BookSeriesClaimItem:
|
|
type: object
|
|
description: |
|
|
One series membership in a claim. Exactly one of `series_id` and
|
|
`name` is accepted. A `series_id` must already exist. A `name`
|
|
creates or reuses the series by the same normalized-name folding
|
|
a reconcile pass uses; blank names are rejected. `position`,
|
|
when present, must be finite.
|
|
oneOf:
|
|
- required: [series_id]
|
|
- required: [name]
|
|
properties:
|
|
series_id: { type: string, format: uuid, minLength: 1 }
|
|
name: { type: string, minLength: 1, maxLength: 512 }
|
|
position:
|
|
type: number
|
|
description: Omitted when the claimant does not place the book in the series; NaN and infinities are rejected.
|
|
|
|
SeriesClaimOutcome:
|
|
type: string
|
|
enum: [applied, stale, duplicate]
|
|
description: "`applied` changed the layer; `stale` was older than its revision; `duplicate` retried the current deletion."
|
|
|
|
SeriesReorder:
|
|
type: object
|
|
description: |
|
|
Bulk renumbering for one series. The order must be non-empty and
|
|
must not name the same book twice. It restates only positions in
|
|
the named series and preserves each book's other series
|
|
memberships.
|
|
required: [order]
|
|
properties:
|
|
scope: { $ref: "#/components/schemas/SeriesClaimScope" }
|
|
order:
|
|
type: array
|
|
minItems: 1
|
|
maxItems: 1000
|
|
items:
|
|
type: object
|
|
required: [book_id]
|
|
properties:
|
|
book_id: { type: string, format: uuid }
|
|
position:
|
|
type: number
|
|
description: Omitted when the book is unplaced; NaN and infinities are rejected.
|
|
|
|
SeriesRename:
|
|
type: object
|
|
description: |
|
|
A new display name for one series, in one layer. The name is
|
|
trimmed; a name that is empty once trimmed is rejected, because
|
|
a shelf nobody can name is a shelf nobody can find.
|
|
required: [name]
|
|
properties:
|
|
scope: { $ref: "#/components/schemas/SeriesClaimScope" }
|
|
name: { type: string, minLength: 1, maxLength: 512 }
|
|
|
|
SeriesMerge:
|
|
type: object
|
|
description: |
|
|
The series that survives a merge. The absorbed one is the one in
|
|
the path.
|
|
required: [into]
|
|
properties:
|
|
into:
|
|
type: string
|
|
format: uuid
|
|
description: |
|
|
The surviving series. It must still exist: merging into a
|
|
series that was itself absorbed is a `404` rather than a
|
|
chain the resolver would have to follow.
|
|
|
|
SeriesSplit:
|
|
type: object
|
|
description: |
|
|
Which folder's books leave the shelf, and what to call them.
|
|
required: [folder_id, name]
|
|
properties:
|
|
folder_id:
|
|
type: string
|
|
format: uuid
|
|
description: The folder whose contribution moves to a new series.
|
|
name:
|
|
type: string
|
|
minLength: 1
|
|
maxLength: 512
|
|
description: |
|
|
The new series' name. It must be free: a name another series
|
|
already holds is a `409`, not a merge.
|
|
|
|
SeriesBindingList:
|
|
type: object
|
|
required: [bindings]
|
|
properties:
|
|
bindings:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/SeriesBinding" }
|
|
|
|
SeriesBinding:
|
|
type: object
|
|
description: |
|
|
One observed name that resolves to this series because somebody
|
|
merged or split it there (ADR-0021).
|
|
required: [binding_id, name, created_at]
|
|
properties:
|
|
binding_id: { type: string, format: uuid }
|
|
name:
|
|
type: string
|
|
description: The absorbed name, in the spelling it was written in.
|
|
folder_id:
|
|
type: [string, "null"]
|
|
format: uuid
|
|
description: |
|
|
The folder this binding applies to, or `null` when it
|
|
applies to every folder. A merge binds everywhere; a split
|
|
binds in the folder that left.
|
|
folder_name: { type: [string, "null"] }
|
|
created_at: { type: string, format: date-time }
|
|
|
|
CatalogEntity:
|
|
type: object
|
|
description: |
|
|
One library-wide series, contributor or tag. Matching folds
|
|
case and spacing while the display spelling is preserved, so
|
|
"Frank Herbert" and "frank herbert" are one entity spelled one
|
|
way — including when the two spellings were found in different
|
|
folders (ADR-0019).
|
|
required: [id, name, book_count]
|
|
properties:
|
|
id: { type: string, format: uuid }
|
|
name:
|
|
type: string
|
|
description: |
|
|
The display spelling as this reader sees it. For a series
|
|
that can be a rename (ADR-0020).
|
|
scanned_name:
|
|
type: string
|
|
description: |
|
|
What the last scan called it. Equal to `name` unless a
|
|
series has been renamed; it is what a revert would restore.
|
|
name_source:
|
|
type: string
|
|
enum: [folder, shared, personal]
|
|
description: |
|
|
Which layer `name` came from. Always `folder` for a
|
|
contributor or a tag, which have no rename layer.
|
|
book_count:
|
|
type: integer
|
|
description: Books claiming it whose file is present.
|
|
|
|
SearchResult:
|
|
type: object
|
|
description: |
|
|
One folder's answer to a search: the matching books, best first,
|
|
and what those books have in common.
|
|
required: [books, facets, truncated]
|
|
properties:
|
|
books:
|
|
type: array
|
|
items: { $ref: "#/components/schemas/CatalogBook" }
|
|
facets:
|
|
type: array
|
|
description: |
|
|
What the returned books share, at most 20 of each kind, most
|
|
claimed first. Counted over the answer rather than over the
|
|
folder, so a facet can never describe a different set than
|
|
the one returned.
|
|
items: { $ref: "#/components/schemas/SearchFacet" }
|
|
truncated:
|
|
type: boolean
|
|
description: |
|
|
The answer was cut at `limit`. Ask the person to narrow it
|
|
rather than paging: there is no cursor.
|
|
|
|
SearchFacet:
|
|
type: object
|
|
description: |
|
|
One entity the matching books share. `id` is what to send back as
|
|
the `entity` filter.
|
|
required: [kind, id, name, book_count]
|
|
properties:
|
|
kind: { type: string, enum: [series, contributor, tag] }
|
|
id: { type: string, format: uuid }
|
|
name: { type: string, description: The display spelling. }
|
|
book_count:
|
|
type: integer
|
|
description: Returned books claiming it, not the entity's whole shelf.
|
|
|
|
Scope:
|
|
type: string
|
|
enum: [sync, read-insights, library-read, library-manage, library-upload, library-delete, admin]
|
|
description: |
|
|
`sync` writes and reads positions, `read-insights` reads
|
|
statistics, `library-read` reads the catalog, and
|
|
`library-manage` states series claims. `library-upload` sends a
|
|
book into a folder that accepts uploads (ADR-0023), and
|
|
`library-delete` takes one back out of one (ADR-0025); the two
|
|
are separate because sending your own book and destroying
|
|
everyone's are different questions. `library-manage` does
|
|
not grant catalog reads; request `library-read` too when a
|
|
client needs both. Scopes never imply folder grants, including
|
|
`admin`. `admin` implies all scopes. No other scope implies
|
|
another. `admin` cannot be self-granted: creating or
|
|
updating a token to include it requires an existing admin token
|
|
or the admin CLI.
|
|
ScopeSet:
|
|
type: array
|
|
minItems: 1
|
|
items: { $ref: "#/components/schemas/Scope" }
|
|
description: |
|
|
A set of token capabilities. Duplicate values are accepted and
|
|
canonicalized; responses use deterministic scope order.
|
|
Identifier:
|
|
type: object
|
|
required: [kind, value]
|
|
properties:
|
|
kind:
|
|
type: string
|
|
enum: [sha256, partial-md5, source, dc, ta]
|
|
description: |
|
|
`sha256`: exact file bytes. `partial-md5`: KOReader
|
|
fingerprint (`util.partialMD5`; note LuaJIT's first offset
|
|
overflows to 0, so the first sample is the head of the
|
|
file). `source`: the catalog server's own id for the book
|
|
(e.g. `komga:<id>`), shared by devices browsing the same
|
|
catalog before any of them has the file. `dc`: the EPUB's
|
|
dc:identifier (ISBN, UUID).
|
|
`ta`: normalised title+author (fuzzy).
|
|
|
|
`ta` values are compared as exact strings and are never
|
|
normalised by the server, so every client must normalise
|
|
identically or they will silently fail to match each other:
|
|
`fold(title) + "|" + fold(author)`, where fold is NFKD,
|
|
`\p{Mn}` stripped, lowercased, runs of non-alphanumerics
|
|
replaced with a single space, trimmed. Send it only when
|
|
both title and author are known.
|
|
value: { type: string, maxLength: 512 }
|
|
ResolveResult:
|
|
type: object
|
|
properties:
|
|
work_id: { type: string }
|
|
confidence:
|
|
type: string
|
|
enum: [high, low]
|
|
description: |
|
|
`low` only for fuzzy ta: matches. Confirm with the user
|
|
before exchanging any reading state, and remember a refusal
|
|
— a client that forgets will resolve the same book again on
|
|
its next run and ask forever.
|
|
created: { type: boolean }
|
|
TokenInfo:
|
|
type: object
|
|
required: [id, device_id, name, scopes, created_at, revoked]
|
|
properties:
|
|
id: { type: string }
|
|
device_id: { type: string }
|
|
name: { type: string }
|
|
scope:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/Scope" }
|
|
deprecated: true
|
|
description: "Present only for singleton scope sets."
|
|
scopes: { $ref: "#/components/schemas/ScopeSet" }
|
|
created_at: { type: string, format: date-time }
|
|
revoked: { type: boolean }
|
|
TokenSelf:
|
|
type: object
|
|
description: |
|
|
A token's description of itself. The same field names TokenInfo
|
|
uses for the same things; `created_at` and `revoked` are absent
|
|
because a caller that reaches this route is neither revoked nor
|
|
expired.
|
|
required: [id, account_id, device_id, name, scopes, session_active_ms]
|
|
properties:
|
|
id: { type: string }
|
|
account_id:
|
|
type: string
|
|
description: |
|
|
Opaque, stable name for the account the token belongs to.
|
|
Every token minted on one account carries the same value;
|
|
compare it, not `device_id` or `id`, to decide whether a
|
|
replaced credential is the same account (keep mirrors and
|
|
cursors) or a different one (clear them).
|
|
session_active_ms:
|
|
type: boolean
|
|
const: true
|
|
description: |
|
|
Supports optional authoritative `active_ms` on native sessions.
|
|
Available without `read-insights`, including to browser tokens
|
|
with only `sync` and `library-read`. Older servers omit this;
|
|
omission or false means send the legacy payload without it.
|
|
device_id: { type: string }
|
|
name: { type: string }
|
|
scope:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/Scope" }
|
|
deprecated: true
|
|
description: "Present only for singleton scope sets."
|
|
scopes: { $ref: "#/components/schemas/ScopeSet" }
|
|
OpInput:
|
|
type: object
|
|
required: [op_id, work_id, client_ts, progression]
|
|
properties:
|
|
op_id:
|
|
type: string
|
|
maxLength: 64
|
|
description: |
|
|
Client-generated idempotency key. UUIDv7 is the suggestion,
|
|
but the server treats it as opaque and does not check the
|
|
format. Deriving it from (device, work, revision) rather
|
|
than drawing it at random makes an interrupted push free to
|
|
repeat: the same id with the same payload is `duplicate`,
|
|
the same id with a different payload is `conflict`, never an
|
|
overwrite. That only holds if every field of the payload
|
|
comes from stored state — in particular `client_ts` must be
|
|
the stored update time, not the clock at send time.
|
|
work_id: { type: string, description: "From /v1/works/resolve" }
|
|
edition_sha: { type: string, description: "sha256 of the exact file, if known" }
|
|
client_ts: { type: string, format: date-time }
|
|
progression: { type: number, minimum: 0, maximum: 1 }
|
|
locator:
|
|
description: |
|
|
Any JSON reader-native position (for example, a Readium
|
|
locator), opaque to the server and replayed verbatim. Refused with 400 above
|
|
`ops.max_locator_bytes` (16 KiB by default), and the refusal
|
|
fails the whole batch. A client with an oversized locator
|
|
should drop it and send the progression alone rather than
|
|
lose the position entirely.
|
|
foreign_pos:
|
|
type: string
|
|
description: Engine-native position string from another protocol (CRe xpointer), stored and replayed verbatim
|
|
Op:
|
|
allOf:
|
|
- $ref: "#/components/schemas/OpInput"
|
|
- type: object
|
|
properties:
|
|
seq: { type: integer, description: Server-assigned per-user monotonic sequence }
|
|
device_id: { type: string }
|
|
origin: { type: string, enum: [native, kosync] }
|
|
origin_alias:
|
|
type: string
|
|
description: "Source identifier for edition-less records, preserving ownership when a work is split."
|
|
received_at: { type: string, format: date-time }
|
|
OpResult:
|
|
type: object
|
|
properties:
|
|
op_id: { type: string }
|
|
status:
|
|
type: string
|
|
enum: [applied, duplicate, conflict]
|
|
description: |
|
|
`applied`: new, with its `seq`. `duplicate`: this `op_id`
|
|
was already accepted with this exact payload; `seq` is the
|
|
original. `conflict`: this `op_id` was already accepted with
|
|
a different payload (or from a different device); the item
|
|
was not stored and never will be under this id.
|
|
seq: { type: integer }
|
|
reason: { type: string }
|
|
InsightsCapabilities:
|
|
type: object
|
|
required: [version, account_id, all_time, active_ms, comparison, attribution_version, timezone, max_candidates, max_local_active_days, max_calendar_days, max_body_bytes]
|
|
properties:
|
|
version: { type: integer, const: 1 }
|
|
account_id:
|
|
type: string
|
|
description: Stable account id of the authenticated caller; must match the client's captured account.
|
|
all_time:
|
|
type: boolean
|
|
const: true
|
|
description: Supports range all without a total-history row cap.
|
|
active_ms: { type: boolean, const: true }
|
|
comparison:
|
|
type: boolean
|
|
const: true
|
|
description: Supports one-snapshot current/previous period comparison.
|
|
attribution_version: { type: integer, const: 2 }
|
|
timezone: { type: string, description: "Account IANA timezone", example: Europe/Paris }
|
|
max_candidates: { type: integer, const: 10000 }
|
|
max_local_active_days:
|
|
type: integer
|
|
const: 10000
|
|
description: Maximum entries in snapshot local_active_days evidence.
|
|
max_calendar_days: { type: integer, const: 4000 }
|
|
max_body_bytes:
|
|
type: integer
|
|
format: int64
|
|
description: |
|
|
Configured `ops.max_body_bytes` request limit, default 1 MiB
|
|
(1048576 bytes). Clients check the complete serialized UTF-8
|
|
snapshot body against this bound before requesting a proof.
|
|
example: 1048576
|
|
InsightsWindow:
|
|
type: object
|
|
required: [range_days]
|
|
properties:
|
|
range_days:
|
|
type: integer
|
|
minimum: 0
|
|
description: Inclusive aggregate calendar days; 0 for unbounded history through today.
|
|
from: { type: string, format: date, description: "First aggregate day; absent when unbounded" }
|
|
to: { type: string, format: date, description: "Last aggregate day; absent when unbounded" }
|
|
InsightsRevision:
|
|
type: object
|
|
required: [timezone, stats_revision, attribution_version]
|
|
properties:
|
|
timezone: { type: string, description: Account IANA timezone }
|
|
stats_revision:
|
|
type: string
|
|
pattern: '^[0-9]+$'
|
|
example: "42"
|
|
description: |
|
|
Per-account monotonic statistics revision encoded as a decimal
|
|
string, never a JSON number or op cursor. It covers aggregate
|
|
inputs and archive proof. Require equality across calendar
|
|
chunks before combining them.
|
|
attribution_version:
|
|
type: integer
|
|
const: 2
|
|
description: |
|
|
Raw sessions and new rollups use whole end-day attribution.
|
|
This does not certify legacy history; check snapshot `complete`.
|
|
InsightsFirstActivityDay:
|
|
type: [string, "null"]
|
|
format: date
|
|
description: |
|
|
Earliest server day with positive active duration, across all
|
|
history through today, or null. Local evidence does not alter it.
|
|
InsightsSummary:
|
|
type: object
|
|
required: [total_active_minutes, total_pages, sessions, streak_days, speed_prog_per_hour]
|
|
properties:
|
|
total_active_minutes: { type: number }
|
|
total_pages:
|
|
type: number
|
|
description: Edition-derived or source-reported pages; not universal pagination.
|
|
sessions: { type: integer }
|
|
streak_days:
|
|
type: integer
|
|
description: |
|
|
Consecutive server days with positive active duration ending
|
|
today or yesterday, over all history, independent of bounds.
|
|
speed_prog_per_hour:
|
|
type: number
|
|
description: |
|
|
Measured positive progression divided by measured active hours
|
|
in the aggregate window. Inferred sessions never supply pace.
|
|
Zero without measured duration or with unknown legacy pace.
|
|
InsightsWork:
|
|
type: object
|
|
required: [work_id, sessions, total_active_minutes, total_pages, current_progression, eta_seconds, last_read_at]
|
|
properties:
|
|
work_id: { type: string }
|
|
title: { type: string, description: Absent when unknown }
|
|
author: { type: string, description: Absent when unknown }
|
|
sessions: { type: integer }
|
|
total_active_minutes: { type: number }
|
|
total_pages: { type: number }
|
|
current_progression:
|
|
type: number
|
|
description: Latest position regardless of window; 0 when none is known.
|
|
eta_seconds:
|
|
type: [number, "null"]
|
|
description: |
|
|
Current remaining progression divided by measured pace in the
|
|
selected window. Null without usable pace or position, with
|
|
unknown legacy pace, or when progression is already 1.
|
|
last_read_at:
|
|
type: [string, "null"]
|
|
format: date-time
|
|
description: |
|
|
Latest contributing session end in the window, or local
|
|
midnight for a rollup whose raw timestamp is no longer kept.
|
|
InsightsDay:
|
|
type: object
|
|
required: [date, minutes, pages, sessions]
|
|
properties:
|
|
date: { type: string, format: date }
|
|
minutes: { type: number }
|
|
pages: { type: number }
|
|
sessions: { type: integer }
|
|
InsightsCandidate:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/SessionInput" }
|
|
- type: object
|
|
required: [device_id]
|
|
properties:
|
|
device_id:
|
|
type: string
|
|
minLength: 1
|
|
maxLength: 64
|
|
description: Original upload device id, not the current token's; at most 64 UTF-8 bytes.
|
|
description: |
|
|
Full original native session payload, including original optional
|
|
duration and edition fields, plus its original device id.
|
|
Evidence only; the snapshot endpoint never stores this session.
|
|
InsightsSnapshotInput:
|
|
type: object
|
|
required: [snapshot_id, timezone]
|
|
oneOf:
|
|
- required: [range]
|
|
- required: [from, to]
|
|
description: |
|
|
Choose `range` or a complete `from`/`to` pair, not both.
|
|
Capture local totals and evidence together; do not truncate the
|
|
evidence to fit the limits and then treat the union as complete.
|
|
The body is also bounded by `ops.max_body_bytes`.
|
|
properties:
|
|
snapshot_id:
|
|
type: string
|
|
minLength: 1
|
|
maxLength: 128
|
|
description: Client-local capture identifier, echoed unchanged; at most 128 UTF-8 bytes.
|
|
timezone:
|
|
type: string
|
|
description: Account IANA timezone from capabilities; a mismatch returns 409.
|
|
range:
|
|
type: string
|
|
pattern: '^(all|[1-9][0-9]*d)$'
|
|
description: All history through today, or 1 through 3660 calendar days ending today.
|
|
example: all
|
|
from: { type: string, format: date, description: "First aggregate day, inclusive; requires to" }
|
|
to: { type: string, format: date, description: "Last aggregate day, inclusive; not before from" }
|
|
candidates:
|
|
type: array
|
|
maxItems: 10000
|
|
description: Original native sessions; session ids must be unique within this request.
|
|
items: { $ref: "#/components/schemas/InsightsCandidate" }
|
|
local_active_days:
|
|
type: array
|
|
maxItems: 10000
|
|
description: |
|
|
All-history account-local days with positive captured local
|
|
activity, including outside the aggregate window. Future dates
|
|
after server today are refused. Used only for combined streak.
|
|
items: { type: string, format: date }
|
|
calendar_from:
|
|
type: string
|
|
format: date
|
|
description: |
|
|
First daily-output day; requires calendar_to. At most 4000
|
|
inclusive days, within the aggregate window when bounded.
|
|
Without explicit calendar bounds, bounded requests use the
|
|
aggregate window. For all history, start at earliest positive
|
|
server activity, clipped to the latest 4000 days (today alone
|
|
when no positive activity exists).
|
|
calendar_to:
|
|
type: string
|
|
format: date
|
|
description: |
|
|
Last daily-output day, inclusive; requires calendar_from and
|
|
must not precede it. Defaults to aggregate end, or today for all.
|
|
Calendar bounds do not narrow summary, works or streaks.
|
|
comparison: { $ref: "#/components/schemas/InsightsComparisonInput" }
|
|
InsightsComparisonInput:
|
|
type: object
|
|
required: [current_from, current_to, previous_from, previous_to, through]
|
|
description: |
|
|
Optional current/previous comparison inside the same statistics
|
|
snapshot. Both spans are inclusive, ordered and at most 4000 days.
|
|
The previous span must end before the current span starts. The current
|
|
dates must equal the bounded headline dates and current_to must be
|
|
account-local today; range all cannot carry a comparison.
|
|
|
|
`through` is a local wall-clock cutoff with optional seconds and up
|
|
to millisecond precision. The response normalizes it to HH:mm:ss.SSS.
|
|
A repeated DST time uses its earlier occurrence; a skipped time moves
|
|
forward by the size of the gap.
|
|
properties:
|
|
current_from: { type: string, format: date }
|
|
current_to: { type: string, format: date }
|
|
previous_from: { type: string, format: date }
|
|
previous_to: { type: string, format: date }
|
|
through:
|
|
type: string
|
|
pattern: '^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9](?:\.[0-9]{1,3})?)?$'
|
|
example: "08:48:42.375"
|
|
InsightsComparison:
|
|
type: object
|
|
required: [current_active_minutes, previous_active_minutes, current_from, current_to, previous_from, previous_to, through]
|
|
properties:
|
|
current_active_minutes: { type: number, minimum: 0 }
|
|
previous_active_minutes: { type: number, minimum: 0 }
|
|
current_from: { type: string, format: date }
|
|
current_to: { type: string, format: date }
|
|
previous_from: { type: string, format: date }
|
|
previous_to: { type: string, format: date }
|
|
through:
|
|
type: string
|
|
pattern: '^(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9]\.[0-9]{3}$'
|
|
description: Millisecond-normalized account-local cutoff echoed from the request.
|
|
InsightsComparisonTotals:
|
|
type: object
|
|
required: [current_active_minutes, previous_active_minutes]
|
|
properties:
|
|
current_active_minutes: { type: number, minimum: 0 }
|
|
previous_active_minutes: { type: number, minimum: 0 }
|
|
InsightsOverlap:
|
|
type: object
|
|
required: [total_active_minutes, sessions, works, days]
|
|
description: |
|
|
Contributions of matching original candidates actually present in
|
|
this snapshot, including exact v2 archive proof. Absent candidates
|
|
contribute zero. There is no top-level total_pages; pages are in
|
|
works/days. Merge only additive metrics, not position or ETA fields.
|
|
properties:
|
|
total_active_minutes: { type: number }
|
|
sessions: { type: integer }
|
|
works:
|
|
type: array
|
|
description: |
|
|
Same row shape as server works, but current_progression is 0
|
|
and eta_seconds is null because overlap has no positions.
|
|
items: { $ref: "#/components/schemas/InsightsWork" }
|
|
days:
|
|
type: array
|
|
description: Daily overlap within the echoed calendar bounds, ordered by date.
|
|
items: { $ref: "#/components/schemas/InsightsDay" }
|
|
comparison:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/InsightsComparisonTotals" }
|
|
description: |
|
|
Actual candidate overlap in the comparison's current and previous
|
|
spans. The bounds are the echoed top-level comparison bounds.
|
|
Present exactly when the top-level comparison is present.
|
|
InsightsSnapshot:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/InsightsWindow" }
|
|
- { $ref: "#/components/schemas/InsightsRevision" }
|
|
- type: object
|
|
required: [version, account_id, snapshot_id, complete, first_activity_day, today, calendar_from, calendar_to, summary, works, days, combined_streak_days, overlap]
|
|
properties:
|
|
version: { type: integer, const: 1 }
|
|
account_id: { type: string, description: Stable account id of the authenticated caller }
|
|
snapshot_id: { type: string, description: Echoed client-local capture identifier }
|
|
complete:
|
|
type: boolean
|
|
description: |
|
|
False when legacy or timezone-incompatible rollups, a
|
|
candidate payload mismatch, or unknown archived proof
|
|
prevent an exact union. Check this before using overlap.
|
|
incomplete_reason:
|
|
type: string
|
|
description: Present only when complete is false; unknown reasons also require fallback.
|
|
enum:
|
|
- legacy_or_different_timezone_rollups
|
|
- candidate_payload_mismatch
|
|
- unknown_archived_contribution
|
|
- archived_timezone_mismatch
|
|
- archived_work_missing
|
|
comparison_incomplete_reason:
|
|
type: string
|
|
description: |
|
|
Present only when a comparison was requested but omitted.
|
|
It does not change `complete`, which describes the headline
|
|
union only. Unknown reasons also require local comparison
|
|
fallback.
|
|
enum:
|
|
- comparison_history_incomplete
|
|
- candidate_payload_mismatch
|
|
- unknown_archived_contribution
|
|
- archived_timezone_mismatch
|
|
- archived_work_missing
|
|
first_activity_day: { $ref: "#/components/schemas/InsightsFirstActivityDay" }
|
|
today: { type: string, format: date, description: Today in the account timezone }
|
|
calendar_from: { type: string, format: date }
|
|
calendar_to: { type: string, format: date }
|
|
summary: { $ref: "#/components/schemas/InsightsSummary" }
|
|
works:
|
|
type: array
|
|
description: |
|
|
Server works with contributions in the aggregate window,
|
|
including works the client does not hold. Sorted by active
|
|
minutes descending, then work id.
|
|
items: { $ref: "#/components/schemas/InsightsWork" }
|
|
days:
|
|
type: array
|
|
description: |
|
|
Server daily contributions within the echoed calendar
|
|
bounds, ordered by date. Missing dates contribute zero.
|
|
items: { $ref: "#/components/schemas/InsightsDay" }
|
|
combined_streak_days:
|
|
type: integer
|
|
description: |
|
|
All-history streak over server positive-activity days
|
|
unioned with local_active_days, ending today or yesterday.
|
|
Independent of aggregate and calendar bounds.
|
|
comparison:
|
|
allOf:
|
|
- { $ref: "#/components/schemas/InsightsComparison" }
|
|
description: |
|
|
Server totals for the requested current and previous spans.
|
|
Omitted with `comparison_incomplete_reason` when exact
|
|
proration cannot be proven. Rollups created before exact
|
|
millisecond comparison evidence was introduced are refused.
|
|
overlap: { $ref: "#/components/schemas/InsightsOverlap" }
|
|
SessionInput:
|
|
type: object
|
|
required: [session_id, work_id, started_at, ended_at, start_progression, end_progression]
|
|
properties:
|
|
session_id:
|
|
type: string
|
|
maxLength: 64
|
|
description: |
|
|
Client-generated idempotency key, opaque to the server, same
|
|
bargain as `op_id`. It matters more here: a position sent
|
|
twice is the same position, but an hour sent twice is an
|
|
hour that never happened. Send only sessions that have
|
|
ended, so the payload behind the id can never change.
|
|
work_id: { type: string }
|
|
edition_sha: { type: [string, "null"] }
|
|
started_at: { type: string, format: date-time }
|
|
ended_at: { type: string, format: date-time, description: Must not precede started_at }
|
|
start_progression: { type: number, minimum: 0, maximum: 1 }
|
|
end_progression: { type: number, minimum: 0, maximum: 1 }
|
|
idle_ms:
|
|
type: integer
|
|
format: int64
|
|
minimum: 0
|
|
default: 0
|
|
description: |
|
|
Idle milliseconds; must not exceed the wall-clock span even
|
|
when active_ms is supplied. Subtracted only when active_ms
|
|
is absent or null.
|
|
active_ms:
|
|
type: [integer, "null"]
|
|
format: int64
|
|
minimum: 0
|
|
maximum: 9007199254740991
|
|
description: |
|
|
Optional monotonic measured duration. Explicit 0 is zero
|
|
active time; absent or null uses wall-clock span minus idle.
|
|
Authoritative independently of the span and idle_ms, never
|
|
capped to the span or reduced by idle again. Negotiate support
|
|
through insights capabilities (active_ms) or token introspection
|
|
(session_active_ms). Presence/value participates in immutable
|
|
payload identity; do not change it on retries.
|
|
AnnotationInput:
|
|
type: object
|
|
required: [id, base_rev, work_id, kind, client_ts]
|
|
properties:
|
|
id:
|
|
type: string
|
|
maxLength: 64
|
|
description: |
|
|
Client-generated identity, opaque to the server, stable for
|
|
the record's whole life across edits and the delete. Unlike
|
|
an `op_id` it names a mutable record, not one write.
|
|
base_rev:
|
|
type: integer
|
|
minimum: 0
|
|
description: |
|
|
The rev this write was based on: 0 to create, the rev the
|
|
client read to edit. A mismatch is a per-item `conflict`.
|
|
work_id: { type: string, maxLength: 128, description: "From /v1/works/resolve" }
|
|
edition_sha: { type: string, maxLength: 128, description: "sha256 of the exact file the locator was made against, if known" }
|
|
kind:
|
|
type: string
|
|
enum: [highlight, note, bookmark]
|
|
description: |
|
|
A highlight anchors to the text and may carry a body; a
|
|
bookmark is an anchor with no body; a note is a body with no
|
|
anchor.
|
|
locator:
|
|
description: |
|
|
Any JSON reader-native anchor (for example, a Readium
|
|
locator), opaque to the server and replayed verbatim.
|
|
Required for a highlight or bookmark, refused on a note,
|
|
bounded by `ops.max_locator_bytes`. A JSON `null` counts
|
|
as absent.
|
|
progression: { type: number, minimum: 0, maximum: 1, description: "Position in the book, used only to sort" }
|
|
excerpt:
|
|
type: string
|
|
maxLength: 1024
|
|
description: The selected text a highlight shows in a list, bounded (1 KiB by default).
|
|
color:
|
|
type: string
|
|
enum: [yellow, green, blue, pink, purple, orange]
|
|
description: A palette token, never CSS; highlights only.
|
|
body:
|
|
type: string
|
|
description: The user's own words, bounded (16 KiB by default).
|
|
client_ts: { type: string, format: date-time, maxLength: 64 }
|
|
Annotation:
|
|
type: object
|
|
description: |
|
|
One annotation as the server holds it. A tombstone carries only
|
|
`id`, `rev`, `seq`, `updated_at`, `deleted` and `deleted_at`.
|
|
required: [id, rev]
|
|
properties:
|
|
id: { type: string }
|
|
rev: { type: integer }
|
|
seq: { type: integer, description: "Server-assigned per-user monotonic sequence, separate from the op log" }
|
|
work_id: { type: string }
|
|
edition_sha: { type: string }
|
|
kind: { type: string, enum: [highlight, note, bookmark] }
|
|
locator: { description: "The anchor, verbatim as pushed" }
|
|
progression: { type: number, minimum: 0, maximum: 1 }
|
|
excerpt: { type: string }
|
|
color: { type: string, enum: [yellow, green, blue, pink, purple, orange] }
|
|
body: { type: string }
|
|
device_id: { type: string }
|
|
client_ts: { type: string, format: date-time }
|
|
updated_at: { type: string, format: date-time }
|
|
deleted: { type: boolean }
|
|
deleted_at: { type: string, format: date-time }
|
|
SettingEntry:
|
|
type: object
|
|
required: [updated_at]
|
|
properties:
|
|
value:
|
|
type: string
|
|
default: ""
|
|
description: |
|
|
Opaque value; the server never interprets it. Absent on the
|
|
way in is the same as an empty string, but an explicit JSON
|
|
null is refused, since a client that meant to send nothing
|
|
and a client whose serialiser dropped a value should not
|
|
silently clear a preference. There is no delete, so a client
|
|
that needs to represent "unset" does it with a value of its
|
|
own choosing.
|
|
updated_at:
|
|
type: string
|
|
format: date-time
|
|
description: |
|
|
When this key was last written, used for last-writer-wins.
|
|
Sent as RFC 3339 and stored to microsecond precision, so a
|
|
client can echo back exactly what it was given. Anything
|
|
finer is rounded down on the way in.
|
|
AnnotationResult:
|
|
type: object
|
|
properties:
|
|
id: { type: string }
|
|
status: { type: string, enum: [applied, duplicate, conflict, invalid] }
|
|
rev: { type: integer, description: When applied or duplicate }
|
|
seq: { type: integer, description: When applied or duplicate }
|
|
reason: { type: string, description: When invalid }
|
|
server:
|
|
allOf: [{ $ref: "#/components/schemas/Annotation" }]
|
|
description: The server's current copy, carried on a conflict.
|
|
WorkTranslationInput:
|
|
type: object
|
|
required: [key, translation]
|
|
properties:
|
|
key:
|
|
type: string
|
|
minLength: 64
|
|
maxLength: 64
|
|
description: |
|
|
The client's opaque content hash: SHA-256 over its
|
|
translation identity and the sentence. The server does not
|
|
interpret it; the sentence itself never travels.
|
|
translation:
|
|
type: string
|
|
description: The translated text, bounded by `ops.translation_max_value_bytes` (8 KiB by default).
|
|
WorkTranslation:
|
|
type: object
|
|
description: One cached sentence translation as the server holds it.
|
|
required: [key, translation]
|
|
properties:
|
|
key: { type: string, description: The client's opaque content hash. }
|
|
translation: { type: string }
|
|
device_id: { type: string, description: "The token's device, stamped by the server." }
|
|
updated_at: { type: string, format: date-time, description: Server-stamped; moves only when the text changes. }
|