Compare commits
318
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1eda696f1 | ||
|
|
27b3dcb69f | ||
|
|
5c5593644d | ||
|
|
9e516b96cc | ||
|
|
44735554f7 | ||
|
|
311049379d | ||
|
|
f65db5ab4f | ||
|
|
7ddcdd0756 | ||
|
|
b927ed9988 | ||
|
|
e2953c4a01 | ||
|
|
d429534b12 | ||
|
|
f70579b4fc | ||
|
|
1f5c5a28bd | ||
|
|
d4c52e9a6a | ||
|
|
acbb6c7981 | ||
|
|
91c8be8562 | ||
|
|
6859f81144 | ||
|
|
078c4b1f3f | ||
|
|
c49a9605ff | ||
|
|
df90938c5c | ||
|
|
ffcdab36a0 | ||
|
|
44a0f8c7a4 | ||
|
|
8ca95db08a | ||
|
|
9a60196f1d | ||
|
|
7274b5196c | ||
|
|
868003331c | ||
|
|
e310fa6a9d | ||
|
|
e35d394736 | ||
|
|
2a7ac881fd | ||
|
|
26f1f98736 | ||
|
|
54d0550dec | ||
|
|
995ccb50bb | ||
|
|
4ab947f7db | ||
|
|
f07c93e582 | ||
|
|
178fb2eb37 | ||
|
|
dafcadd211 | ||
|
|
50ec2bebf2 | ||
|
|
6e9b3528d8 | ||
|
|
97e546b2a4 | ||
|
|
1585aa1073 | ||
|
|
f6e257e497 | ||
|
|
0670d904a0 | ||
|
|
922336c064 | ||
|
|
243d369d21 | ||
|
|
e500039d1b | ||
|
|
94be6edceb | ||
|
|
1a07635605 | ||
|
|
206db93587 | ||
|
|
fd4c357d39 | ||
|
|
dc68d03360 | ||
|
|
6cd0fb226a | ||
|
|
f283903e2b | ||
|
|
34a27a5951 | ||
|
|
1905feceea | ||
|
|
6fc4107e3c | ||
|
|
5e73b0a4f6 | ||
|
|
eb09a5d939 | ||
|
|
a05b0167ad | ||
|
|
40d70513da | ||
|
|
bd7d71a284 | ||
|
|
24ea9d8a38 | ||
|
|
a962342ee0 | ||
|
|
14d1a158a0 | ||
|
|
612f888683 | ||
|
|
ba95cc3e8b | ||
|
|
03cb4c7869 | ||
|
|
8004cb81a5 | ||
|
|
77990d0dc0 | ||
|
|
0c39e04e4a | ||
|
|
8599e5c250 | ||
|
|
830741cd65 | ||
|
|
48af5d3e14 | ||
|
|
5584bdefb5 | ||
|
|
60a94df8e1 | ||
|
|
9b171a0060 | ||
|
|
b7a9b470a7 | ||
|
|
f5d9578375 | ||
|
|
04e2a069d6 | ||
|
|
05c7431d86 | ||
|
|
9a62c10803 | ||
|
|
8d65e03555 | ||
|
|
ab465d8e0e | ||
|
|
1461273162 | ||
|
|
598d70f735 | ||
|
|
537330e7e0 | ||
|
|
885f6d8187 | ||
|
|
936a48405b | ||
|
|
757398bf15 | ||
|
|
d12911d3c8 | ||
|
|
457a38306d | ||
|
|
bc47450653 | ||
|
|
89ea310414 | ||
|
|
4b152bbe4e | ||
|
|
64ad10b3f2 | ||
|
|
a42232e67a | ||
|
|
006cc0c2c9 | ||
|
|
706be09dec | ||
|
|
a87c8afc22 | ||
|
|
2976dad4e5 | ||
|
|
acc9d08c60 | ||
|
|
e09c55cecc | ||
|
|
6f1c13998c | ||
|
|
0a0caa9533 | ||
|
|
4716790564 | ||
|
|
8e2c1a4b3a | ||
|
|
4a870a7f18 | ||
|
|
40dabfd788 | ||
|
|
ef3c05714a | ||
|
|
816ee0ec80 | ||
|
|
73a2852ee3 | ||
|
|
f67232a20b | ||
|
|
7b465ac97e | ||
|
|
033a012069 | ||
|
|
9165c4eb3f | ||
|
|
9ef6c5b6ed | ||
|
|
987ece38f0 | ||
|
|
c5c2270007 | ||
|
|
b23f6b0bab | ||
|
|
8ea70ea7f8 | ||
|
|
07578e0206 | ||
|
|
f402a3ee03 | ||
|
|
f3f908eb7e | ||
|
|
94a4facc6c | ||
|
|
11617c1860 | ||
|
|
d0040fe428 | ||
|
|
59d5de3607 | ||
|
|
fffe0b17e6 | ||
|
|
451aa48aec | ||
|
|
5ac407057e | ||
|
|
bf83492bf7 | ||
|
|
33c69e7c71 | ||
|
|
c2f72ca785 | ||
|
|
ca8c592496 | ||
|
|
1f5b0d0164 | ||
|
|
13cc689bff | ||
|
|
9920fd47b9 | ||
|
|
26f695f480 | ||
|
|
05370d236a | ||
|
|
114a4574b0 | ||
|
|
3c9e4d8126 | ||
|
|
e7c4c931ee | ||
|
|
bac84e24ec | ||
|
|
de8f71b2be | ||
|
|
1129fcae6f | ||
|
|
76c6826920 | ||
|
|
75b33fdae6 | ||
|
|
635a9439cb | ||
|
|
3b15766149 | ||
|
|
2a15effc3e | ||
|
|
78176c57a5 | ||
|
|
980aaee0d9 | ||
|
|
acfb298b74 | ||
|
|
ce3ab8bcbc | ||
|
|
6d44ae884c | ||
|
|
f6d98dd7cc | ||
|
|
e584200369 | ||
|
|
9bd23fc1d7 | ||
|
|
762181c123 | ||
|
|
d11a623f9f | ||
|
|
caaf27427e | ||
|
|
423a1c0bf7 | ||
|
|
b1d2ccc87c | ||
|
|
3170aa6b77 | ||
|
|
f75d68bf66 | ||
|
|
f37de6c07c | ||
|
|
cea8e64da2 | ||
|
|
307a43f6b0 | ||
|
|
d5018936a0 | ||
|
|
3a54dda5c5 | ||
|
|
3c2a504747 | ||
|
|
b0dc0c591e | ||
|
|
0f225c39b9 | ||
|
|
bc59159cde | ||
|
|
f57d64563b | ||
|
|
1f8c3c21ae | ||
|
|
5d22021e8e | ||
|
|
8e4412544d | ||
|
|
d764f820b2 | ||
|
|
39a2829cc8 | ||
|
|
795f10d2af | ||
|
|
44f38803dc | ||
|
|
b2b1804aa3 | ||
|
|
c82f20c3f2 | ||
|
|
5a76fed099 | ||
|
|
342c2f88b2 | ||
|
|
854a888306 | ||
|
|
a4b91393a6 | ||
|
|
0af2ee4951 | ||
|
|
d78a182f0d | ||
|
|
13260d8c1f | ||
|
|
6fcd7f4400 | ||
|
|
6e8986bea5 | ||
|
|
87da5c3cf5 | ||
|
|
fb9427a864 | ||
|
|
0af940c7b3 | ||
|
|
f8745da6b2 | ||
|
|
604a2458e9 | ||
|
|
666b72c4fd | ||
|
|
7221906d53 | ||
|
|
0a9cde0fc8 | ||
|
|
d3a510d2e0 | ||
|
|
a6f5d8d693 | ||
|
|
6a352a6afb | ||
|
|
e7a4f0f758 | ||
|
|
64d3e8d272 | ||
|
|
8e48aa4334 | ||
|
|
2fa3764df5 | ||
|
|
faef4d9fff | ||
|
|
a2fc2613b4 | ||
|
|
f23ee6e66a | ||
|
|
73fc609d7b | ||
|
|
5dca78789d | ||
|
|
37e092c2a8 | ||
|
|
4f7794767d | ||
|
|
d61abb1be1 | ||
|
|
1afc202ba9 | ||
|
|
5caebcfe45 | ||
|
|
b1e85dca79 | ||
|
|
b8dc4e87d5 | ||
|
|
0855dd7b3b | ||
|
|
d0460885ff | ||
|
|
ced90cd1f4 | ||
|
|
4b43bd04ba | ||
|
|
553a1dc19b | ||
|
|
f3cacd1b16 | ||
|
|
b829b7fe22 | ||
|
|
d42d09b8db | ||
|
|
71832bee24 | ||
|
|
504f145f64 | ||
|
|
46e0744802 | ||
|
|
a93be75408 | ||
|
|
dc07a2f19f | ||
|
|
fa38ca0d1b | ||
|
|
ae2e03d499 | ||
|
|
65c08fb523 | ||
|
|
0680e051c2 | ||
|
|
03156e7866 | ||
|
|
4f2e1e36bf | ||
|
|
36b2afeed7 | ||
|
|
b088ec97f6 | ||
|
|
a92739d210 | ||
|
|
f199918775 | ||
|
|
a3504f1ae5 | ||
|
|
355ae5f7a9 | ||
|
|
ddc14e3314 | ||
|
|
a57694b738 | ||
|
|
3bcae0abf5 | ||
|
|
cce7ad4907 | ||
|
|
c5cda015b3 | ||
|
|
b83e319a34 | ||
|
|
004b761381 | ||
|
|
9405afa2c5 | ||
|
|
d48404e800 | ||
|
|
ed68f92f4a | ||
|
|
607ce8ce3a | ||
|
|
ff0517d038 | ||
|
|
e14bbeabdd | ||
|
|
a9f8e23ddd | ||
|
|
540fb147d2 | ||
|
|
ffaa561cb7 | ||
|
|
4305c77df4 | ||
|
|
1da7765466 | ||
|
|
5da91b9c7c | ||
|
|
f87fc45377 | ||
|
|
5be6fec408 | ||
|
|
55a9ec00e1 | ||
|
|
c592c745c3 | ||
|
|
e5726e12be | ||
|
|
f589bedad5 | ||
|
|
27e9a654bf | ||
|
|
caf50ade31 | ||
|
|
dd1ddff08d | ||
|
|
1863fb3380 | ||
|
|
bc58103c62 | ||
|
|
37f84dd3ea | ||
|
|
2736409a79 | ||
|
|
94102af4d7 | ||
|
|
cb214b16b2 | ||
|
|
4fe36ba0a5 | ||
|
|
283b2f2ed7 | ||
|
|
f699899408 | ||
|
|
e87f481988 | ||
|
|
d68f72f21b | ||
|
|
d400377474 | ||
|
|
7cd88b6107 | ||
|
|
d38804e910 | ||
|
|
192c978a38 | ||
|
|
44ec2f496a | ||
|
|
d3ddecb840 | ||
|
|
da1732285f | ||
|
|
0269403a5d | ||
|
|
a4962a87b2 | ||
|
|
35c8ffe33e | ||
|
|
70d8ffd528 | ||
|
|
3a3fa8763e | ||
|
|
4119a5e38e | ||
|
|
86f44230c2 | ||
|
|
3c3c4e8bf5 | ||
|
|
133ca1fdaa | ||
|
|
c7f0eb406a | ||
|
|
2fdf894216 | ||
|
|
17f2dc3120 | ||
|
|
a06c85e72a | ||
|
|
e389df92c3 | ||
|
|
ad27902790 | ||
|
|
855ef161d8 | ||
|
|
a6700f73e0 | ||
|
|
8baecad379 | ||
|
|
9ccff320a1 | ||
|
|
d8d6334052 | ||
|
|
5b2d105609 | ||
|
|
18811cea1b | ||
|
|
3e73a582ba | ||
|
|
8aeae33167 | ||
|
|
3cecb04e8d | ||
|
|
6820208a36 | ||
|
|
77cbbf600b | ||
|
|
4e326dfc86 |
@@ -10,6 +10,21 @@ JWT_SECRET=your-secure-jwt-secret-key-here
|
|||||||
# Generate with: openssl rand -hex 16
|
# Generate with: openssl rand -hex 16
|
||||||
DBPASS=your-secure-database-password-here
|
DBPASS=your-secure-database-password-here
|
||||||
|
|
||||||
|
# Networking: change a port if it conflicts on your host
|
||||||
|
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
||||||
|
# DB_PORT=15432
|
||||||
|
# App web port, host + container
|
||||||
|
# SERVER_PORT=8765
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
# External URL for device sync (must include protocol; defaults to http://localhost:8765)
|
||||||
|
# Examples: https://bookhoard.example.com | http://192.168.1.10:8765
|
||||||
|
# BASE_URL=https://bookhoard.example.com
|
||||||
|
# Mark session cookies Secure — set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||||
|
# COOKIE_SECURE=true
|
||||||
|
# Pin or rollback a specific published image version (defaults to "latest")
|
||||||
|
# IMAGE_TAG=1.0.0
|
||||||
|
|
||||||
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
||||||
# Test Mode: WARNING - Only set to true for integration testing
|
# Test Mode: WARNING - Only set to true for integration testing
|
||||||
# TEST_MODE=true
|
# TEST_MODE=true
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Overrides the default run name (the tagged commit's message) so the Actions
|
||||||
|
# runs list shows "Release v0.3.0" instead.
|
||||||
|
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
|
||||||
|
|
||||||
|
# Publishes the Bookhoard container image to the Gitea container registry AND
|
||||||
|
# creates a Gitea Release whose body is the annotated tag's message (generated
|
||||||
|
# locally by `make release VERSION=...` via git-cliff). Triggered by a version
|
||||||
|
# tag push, or manually via workflow_dispatch with a tag. Pushing to main does
|
||||||
|
# nothing, so work-in-progress commits never ship. Each release publishes two
|
||||||
|
# image tags: the version (e.g. v0.3.0) and "latest".
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Tag to release (e.g. v0.3.0)'
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
env:
|
||||||
|
# Resolve the target tag for both triggers: explicit input on manual
|
||||||
|
# dispatch, otherwise the pushed tag ref.
|
||||||
|
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
# Full history ensures the tag annotation (the release notes) is present.
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: git.linuxhg.com
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
# PAT stored as a repo Actions secret (auto GITHUB_TOKEN lacks package scope in Gitea)
|
||||||
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
|
||||||
|
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
|
||||||
|
# pin or roll back by setting IMAGE_TAG in .env.
|
||||||
|
tags: |
|
||||||
|
git.linuxhg.com/bookhoard/bookhoard:${{ env.TAG }}
|
||||||
|
git.linuxhg.com/bookhoard/bookhoard:latest
|
||||||
|
|
||||||
|
- name: Create Gitea Release
|
||||||
|
env:
|
||||||
|
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
|
||||||
|
# GITHUB_TOKEN cannot create releases on this instance. The PAT must
|
||||||
|
# carry write:repository scope. Idempotent: re-runs update an existing
|
||||||
|
# release for this tag instead of failing with 409. On any HTTP error
|
||||||
|
# the API response body is printed so a 403 names the missing scope.
|
||||||
|
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
REPO: ${{ gitea.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
: "${TAG:?TAG is required}"
|
||||||
|
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||||
|
AUTH="Authorization: token ${TOKEN}"
|
||||||
|
# Release body = the annotated tag's message (the git-cliff notes).
|
||||||
|
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
|
||||||
|
|
||||||
|
# Tags containing a '-' (e.g. v0.3.0-rc1) are published as pre-releases.
|
||||||
|
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
|
||||||
|
|
||||||
|
PAYLOAD=$(jq -n \
|
||||||
|
--arg t "${TAG}" --arg n "${TAG}" --arg b "${BODY}" --argjson p "${PRE}" \
|
||||||
|
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
|
||||||
|
|
||||||
|
# POST/PATCH the release, surfacing Gitea's error message on failure
|
||||||
|
# (e.g. "token does not have write scope") instead of failing silently.
|
||||||
|
api_call() {
|
||||||
|
local method="$1" url="$2" resp code rbody
|
||||||
|
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
|
||||||
|
-H "${AUTH}" -H "Content-Type: application/json" \
|
||||||
|
-d "${PAYLOAD}" "${url}")"
|
||||||
|
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||||
|
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||||
|
if [ "${code}" -ge 400 ]; then
|
||||||
|
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
|
||||||
|
if [ -n "${EXISTING_ID}" ]; then
|
||||||
|
api_call PATCH "${API}/${EXISTING_ID}"
|
||||||
|
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
|
||||||
|
else
|
||||||
|
api_call POST "${API}"
|
||||||
|
echo "Created new release for ${TAG}"
|
||||||
|
fi
|
||||||
+5
-4
@@ -8,15 +8,15 @@ RUN apk add --no-cache nodejs npm curl git
|
|||||||
|
|
||||||
# Install Go tools (cached well)
|
# Install Go tools (cached well)
|
||||||
RUN wget -O /tmp/sqlc.tar.gz https://github.com/sqlc-dev/sqlc/releases/download/v1.31.0/sqlc_1.31.0_linux_amd64.tar.gz && \
|
RUN wget -O /tmp/sqlc.tar.gz https://github.com/sqlc-dev/sqlc/releases/download/v1.31.0/sqlc_1.31.0_linux_amd64.tar.gz && \
|
||||||
tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \
|
tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \
|
||||||
rm /tmp/sqlc.tar.gz
|
rm /tmp/sqlc.tar.gz
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
go install github.com/a-h/templ/cmd/templ@v0.3.1020
|
go install github.com/a-h/templ/cmd/templ@v0.3.1020
|
||||||
|
|
||||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
npm ci
|
npm install
|
||||||
|
|
||||||
# Copy Go mod files (cached unless go.mod changes)
|
# Copy Go mod files (cached unless go.mod changes)
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
@@ -36,7 +36,8 @@ RUN npm run build:ts
|
|||||||
|
|
||||||
# Build Go binary (cached unless Go files or generated code changes)
|
# Build Go binary (cached unless Go files or generated code changes)
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
|
||||||
|
|
||||||
# Test runner stage - includes Go runtime and test dependencies
|
# Test runner stage - includes Go runtime and test dependencies
|
||||||
# This stage is ONLY used for running tests, never deployed to production
|
# This stage is ONLY used for running tests, never deployed to production
|
||||||
|
|||||||
@@ -1,232 +1,661 @@
|
|||||||
GNU GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 29 June 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
Copyright © 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
Preamble
|
Preamble
|
||||||
|
|
||||||
The GNU General Public License is a free, copyleft license for software and other kinds of works.
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
|
software for all its users.
|
||||||
|
|
||||||
Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
The precise terms and conditions for copying, distribution and modification follow.
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
TERMS AND CONDITIONS
|
and/or modify the software.
|
||||||
|
|
||||||
0. Definitions.
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
“This License” refers to version 3 of the GNU General Public License.
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
|
source code to the public.
|
||||||
|
|
||||||
A “covered work” means either the unmodified Program or a work based on the Program.
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
|
code of the modified version.
|
||||||
|
|
||||||
1. Source Code.
|
An older license, called the Affero General Public License and
|
||||||
The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
|
modification follow.
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that same work.
|
0. Definitions.
|
||||||
|
|
||||||
2. Basic Permissions.
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
"recipients" may be individuals or organizations.
|
||||||
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
4. Conveying Verbatim Copies.
|
earlier work or a work "based on" the earlier work.
|
||||||
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
|
on the Program.
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
To "propagate" a work means to do anything with it that, without
|
||||||
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
6. Conveying Non-Source Forms.
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
|
1. Source Code.
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
|
form of a work.
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
7. Additional Terms.
|
(kernel, window system, and so on) of the specific operating system
|
||||||
“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
|
Source.
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
|
|
||||||
|
2. Basic Permissions.
|
||||||
8. Termination.
|
|
||||||
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
You may make, run and propagate covered works that you do not
|
||||||
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
of having them make modifications exclusively for you, or provide you
|
||||||
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
11. Patents.
|
|
||||||
A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
|
makes it unnecessary.
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
|
measures.
|
||||||
|
|
||||||
A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
12. No Surrender of Others' Freedom.
|
modification of the work as a means of enforcing, against the work's
|
||||||
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
13. Use with the GNU Affero General Public License.
|
|
||||||
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
You may convey verbatim copies of the Program's source code as you
|
||||||
The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
15. Disclaimer of Warranty.
|
and you may offer support or warranty protection for a fee.
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
16. Limitation of Liability.
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
17. Interpretation of Sections 15 and 16.
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
END OF TERMS AND CONDITIONS
|
it, and giving a relevant date.
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
bookhoard
|
License to anyone who comes into possession of a copy. This
|
||||||
Copyright (C) 2026 john-okeefe
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
bookhoard Copyright (C) 2026 john-okeefe
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read <https://www.gnu.org/philosophy/why-not-lgpl.html>.
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick
|
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release
|
||||||
|
|
||||||
# Include .env file for environment variables (single source of truth)
|
# Include .env file for environment variables (single source of truth)
|
||||||
# Ignore if .env doesn't exist yet
|
# Ignore if .env doesn't exist yet
|
||||||
@@ -7,6 +7,14 @@ ifneq (,$(wildcard ./.env))
|
|||||||
export
|
export
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
# Auto-detect container runtime: prefer docker, fall back to podman
|
||||||
|
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
||||||
|
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
||||||
|
|
||||||
|
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
||||||
|
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
||||||
|
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@@ -36,6 +44,9 @@ help:
|
|||||||
@echo "Verification:"
|
@echo "Verification:"
|
||||||
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
||||||
@echo " make verify-quick - Run quick guidelines check"
|
@echo " make verify-quick - Run quick guidelines check"
|
||||||
|
@echo ""
|
||||||
|
@echo "Release:"
|
||||||
|
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
|
||||||
|
|
||||||
# Run unit tests locally (fast, no containers)
|
# Run unit tests locally (fast, no containers)
|
||||||
test:
|
test:
|
||||||
@@ -44,23 +55,23 @@ test:
|
|||||||
# Run integration tests in containers (matches production environment)
|
# Run integration tests in containers (matches production environment)
|
||||||
test-integration:
|
test-integration:
|
||||||
@echo "Building test containers..."
|
@echo "Building test containers..."
|
||||||
podman compose --profile tests build
|
$(COMPOSE) --profile tests build
|
||||||
@echo "Starting application containers..."
|
@echo "Starting application containers..."
|
||||||
podman compose up -d db app
|
$(COMPOSE) up -d db app
|
||||||
@echo "Waiting for services to be healthy..."
|
@echo "Waiting for services to be healthy..."
|
||||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||||
echo " Database not ready yet..."; \
|
echo " Database not ready yet..."; \
|
||||||
sleep 2; \
|
sleep 2; \
|
||||||
done; \
|
done; \
|
||||||
echo " ✓ Database is ready"
|
echo " ✓ Database is ready"
|
||||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
||||||
echo " Application not ready yet..."; \
|
echo " Application not ready yet..."; \
|
||||||
sleep 2; \
|
sleep 2; \
|
||||||
done; \
|
done; \
|
||||||
echo " ✓ Application is ready"
|
echo " ✓ Application is ready"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Running integration tests in container..."
|
@echo "Running integration tests in container..."
|
||||||
podman compose --profile tests run --rm tests
|
$(COMPOSE) --profile tests run --rm tests
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "✅ Integration tests completed!"
|
@echo "✅ Integration tests completed!"
|
||||||
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
||||||
@@ -71,76 +82,76 @@ test-all: test test-integration
|
|||||||
# Rebuild app container only (preserve DB, with cache)
|
# Rebuild app container only (preserve DB, with cache)
|
||||||
rebuild-app:
|
rebuild-app:
|
||||||
@echo "Rebuilding app container (database stays running)..."
|
@echo "Rebuilding app container (database stays running)..."
|
||||||
podman compose up --build --force-recreate -d app
|
$(COMPOSE) up --build --force-recreate -d app
|
||||||
@echo "✓ App container rebuilt and restarted"
|
@echo "✓ App container rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild app container only (preserve DB, no cache)
|
# Rebuild app container only (preserve DB, no cache)
|
||||||
rebuild-app-force:
|
rebuild-app-force:
|
||||||
@echo "Force rebuilding app container (database stays running, no cache)..."
|
@echo "Force rebuilding app container (database stays running, no cache)..."
|
||||||
podman compose build --no-cache app
|
$(COMPOSE) build --no-cache app
|
||||||
podman compose up --force-recreate -d app
|
$(COMPOSE) up --force-recreate -d app
|
||||||
@echo "✓ App container rebuilt and restarted"
|
@echo "✓ App container rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (preserve DB, with cache)
|
# Rebuild all containers (preserve DB, with cache)
|
||||||
rebuild:
|
rebuild:
|
||||||
@echo "Rebuilding all containers (database preserved)..."
|
@echo "Rebuilding all containers (database preserved)..."
|
||||||
podman compose up --build --force-recreate -d
|
$(COMPOSE) up --build --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (preserve DB, no cache)
|
# Rebuild all containers (preserve DB, no cache)
|
||||||
rebuild-force:
|
rebuild-force:
|
||||||
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
||||||
podman compose build --no-cache
|
$(COMPOSE) build --no-cache
|
||||||
podman compose up --force-recreate -d
|
$(COMPOSE) up --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (remove DB, no cache)
|
# Rebuild all containers (remove DB, no cache)
|
||||||
rebuild-force-db:
|
rebuild-force-db:
|
||||||
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
||||||
podman compose down -v
|
$(COMPOSE) down -v
|
||||||
podman compose build --no-cache
|
$(COMPOSE) build --no-cache
|
||||||
podman compose up --force-recreate -d
|
$(COMPOSE) up --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Stop and remove containers
|
# Stop and remove containers
|
||||||
clean:
|
clean:
|
||||||
podman compose down -v
|
$(COMPOSE) down -v
|
||||||
|
|
||||||
# Quick start (if already built)
|
# Quick start (if already built)
|
||||||
up:
|
up:
|
||||||
podman compose up -d
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
# Stop all containers (alias for clean)
|
# Stop all containers (alias for clean)
|
||||||
down:
|
down:
|
||||||
podman compose down
|
$(COMPOSE) down
|
||||||
|
|
||||||
# Restart app container (preserves database)
|
# Restart app container (preserves database)
|
||||||
restart:
|
restart:
|
||||||
@echo "Restarting app container (database stays running)..."
|
@echo "Restarting app container (database stays running)..."
|
||||||
podman compose restart app
|
$(COMPOSE) restart app
|
||||||
@echo "✓ App container restarted"
|
@echo "✓ App container restarted"
|
||||||
|
|
||||||
# Show container status
|
# Show container status
|
||||||
ps:
|
ps:
|
||||||
podman compose ps
|
$(COMPOSE) ps
|
||||||
|
|
||||||
# Show container logs
|
# Show container logs
|
||||||
logs:
|
logs:
|
||||||
podman compose logs -f
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
# Start containers with test mode enabled for manual testing
|
# Start containers with test mode enabled for manual testing
|
||||||
test-env-up:
|
test-env-up:
|
||||||
@echo "Starting containers with test mode enabled..."
|
@echo "Starting containers with test mode enabled..."
|
||||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 podman compose up --build --force-recreate -d
|
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d
|
||||||
@echo "Waiting for services to be ready..."
|
@echo "Waiting for services to be ready..."
|
||||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
||||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
||||||
@echo "✓ Test environment is ready!"
|
@echo "✓ Test environment is ready!"
|
||||||
@echo "Application available at http://localhost:8765"
|
@echo "Application available at http://localhost:8765"
|
||||||
|
|
||||||
# Stop test environment
|
# Stop test environment
|
||||||
test-env-down:
|
test-env-down:
|
||||||
podman compose down -v
|
$(COMPOSE) down -v
|
||||||
|
|
||||||
# Verify project guidelines compliance
|
# Verify project guidelines compliance
|
||||||
verify-guidelines:
|
verify-guidelines:
|
||||||
@@ -150,3 +161,27 @@ verify-guidelines:
|
|||||||
verify-quick:
|
verify-quick:
|
||||||
@echo "Running quick project guidelines verification..."
|
@echo "Running quick project guidelines verification..."
|
||||||
@./scripts/verify-quick.sh
|
@./scripts/verify-quick.sh
|
||||||
|
|
||||||
|
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
|
||||||
|
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
|
||||||
|
# image and publishes a Gitea Release whose body is this tag's message. Notes come
|
||||||
|
# entirely from Conventional Commits — no hand-written message required.
|
||||||
|
#
|
||||||
|
# git-cliff's --latest needs the tag to exist to scope the notes, so we create a
|
||||||
|
# throwaway lightweight tag, generate the notes, replace it with an annotated tag,
|
||||||
|
# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's
|
||||||
|
# default cleanup would strip lines starting with "#").
|
||||||
|
#
|
||||||
|
# Requires git-cliff: https://git-cliff.org/install
|
||||||
|
# Usage: make release VERSION=v0.3.0
|
||||||
|
release:
|
||||||
|
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; }
|
||||||
|
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
||||||
|
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
||||||
|
@echo "Generating release notes for $(VERSION)..."
|
||||||
|
@git tag "$(VERSION)" HEAD && \
|
||||||
|
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
||||||
|
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
|
||||||
|
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
|
||||||
|
@git push origin "$(VERSION)"
|
||||||
|
@echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release."
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# 📚 Bookhoard
|
# <img src="web/static/favicon.svg" width="32" alt="Bookhoard logo"> Bookhoard
|
||||||
|
|
||||||
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring **universal cross-device sync**, beautiful dark themes, and comprehensive media management.
|
A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and Tailwind CSS featuring **universal cross-device sync**, beautiful dark themes, and comprehensive media management.
|
||||||
|
|
||||||
## ✨ Why Bookhoard?
|
## ✨ Why Bookhoard?
|
||||||
|
|
||||||
**🔄 Universal Sync**: Your reading progress, highlights, and notes sync automatically across all your devices - KOReader, Kobo, web, and mobile.
|
**🔄 Universal Sync**: Your reading position, bookmarks, highlights, and notes sync automatically between KOReader and the web - with native Kobo sync and mobile apps coming later.
|
||||||
|
|
||||||
**📱 Multi-Library**: Organize your ebooks, comics, and manga with per-library folders and smart collections.
|
**📱 Multi-Library**: Organize your ebooks, comics, and manga with per-library folders and smart collections.
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone https://github.com/yourusername/bookhoard.git
|
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
||||||
cd bookhoard
|
cd bookhoard
|
||||||
|
|
||||||
# 2. Set up environment
|
# 2. Set up environment
|
||||||
@@ -35,8 +35,10 @@ cp .env.example .env
|
|||||||
# DBPASS: openssl rand -hex 16
|
# DBPASS: openssl rand -hex 16
|
||||||
# Edit .env with your generated values
|
# Edit .env with your generated values
|
||||||
|
|
||||||
# 3. Start the server
|
# 3. Pull images and start the server
|
||||||
podman-compose up --build -d # or: docker-compose up --build -d
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
||||||
|
|
||||||
# 4. Open your browser
|
# 4. Open your browser
|
||||||
open http://localhost:8765
|
open http://localhost:8765
|
||||||
@@ -50,13 +52,13 @@ The first user to register automatically becomes an admin.
|
|||||||
|
|
||||||
### Universal Cross-Platform Sync
|
### Universal Cross-Platform Sync
|
||||||
|
|
||||||
- **Real-Time Progress**: Turn a page on your Kindle, see it on your phone
|
- **Real-Time Progress**: Turn a page on your e-reader, see it in your browser
|
||||||
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
|
- **Format-Aware**: EPUB CFI, page numbers, percentages - all handled correctly
|
||||||
- **Offline Queue**: Changes sync when you reconnect, priority-processed
|
- **Offline Queue**: Changes sync when you reconnect, priority-processed
|
||||||
- **Conflict Resolution**: Smart handling when same book read on multiple devices
|
- **Conflict Resolution**: Smart handling when same book read on multiple devices
|
||||||
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
|
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
|
||||||
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
|
- **OPDS Catalog**: Wireless book delivery to e-readers over Wi-Fi
|
||||||
- **Format Conversion**: On-the-fly EPUB→KEPUB for Kobo devices
|
- **Format Conversion**: On-the-fly EPUB→KEPUB conversion (for upcoming native Kobo support)
|
||||||
|
|
||||||
### Media Management
|
### Media Management
|
||||||
|
|
||||||
@@ -72,7 +74,7 @@ The first user to register automatically becomes an admin.
|
|||||||
### Smart Collections
|
### Smart Collections
|
||||||
|
|
||||||
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
|
- **Auto-Assign Rules**: Automatically add books based on genre, author, series, tags, language, publisher, year
|
||||||
- **Device Shelf Mappings**: Sync collections to Kobo shelves and KOReader categories
|
- **Device Shelf Mappings**: Map collections to device shelves (used by native Kobo sync, coming soon)
|
||||||
- **Test Before Creating**: Preview which books match your rules
|
- **Test Before Creating**: Preview which books match your rules
|
||||||
|
|
||||||
### Library Organization
|
### Library Organization
|
||||||
@@ -100,8 +102,8 @@ The first user to register automatically becomes an admin.
|
|||||||
|
|
||||||
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
|
- **[docs/user/calibre-integration.md](docs/user/calibre-integration.md)** - Calibre library integration
|
||||||
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
|
- **[docs/user/sync-guide.md](docs/user/sync-guide.md)** - Understanding and using universal sync
|
||||||
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration
|
|
||||||
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
|
- **[docs/user/devices/koreader-setup.md](docs/user/devices/koreader-setup.md)** - KOReader configuration
|
||||||
|
- **[docs/user/devices/kobo-setup.md](docs/user/devices/kobo-setup.md)** - Kobo e-reader configuration (coming soon)
|
||||||
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
|
- **[docs/user/user-guide.md](docs/user/user-guide.md)** - General user guide
|
||||||
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
|
- **[docs/user/admin-guide.md](docs/user/admin-guide.md)** - Admin features and configuration
|
||||||
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
|
- **[docs/user/settings-guide.md](docs/user/settings-guide.md)** - Settings and preferences
|
||||||
@@ -109,18 +111,19 @@ The first user to register automatically becomes an admin.
|
|||||||
### For Developers
|
### For Developers
|
||||||
|
|
||||||
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
|
- **[docs/developer/api/api-reference.md](docs/developer/api/api-reference.md)** - Complete API documentation
|
||||||
- **[docs/contributing/DEVELOPMENT.md](docs/contributing/DEVELOPMENT.md)** - Development workflow
|
- **[docs/developer/android-app.md](docs/developer/android-app.md)** - Android app design & roadmap
|
||||||
|
- **[docs/contributing/development.md](docs/contributing/development.md)** - Development workflow
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 Supported Devices
|
## 🎯 Supported Devices
|
||||||
|
|
||||||
| Platform | Sync | OPDS | Status |
|
| Platform | Sync | OPDS | Status |
|
||||||
| ---------------- | ---- | ---- | ------------------------ |
|
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
|
||||||
| **Web Browser** | ✅ | ✅ | Full support |
|
| **Web Browser** | ✅ | ✅ | Full support |
|
||||||
| **KOReader** | ✅ | ✅ | Kindle, Kobo, PocketBook |
|
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
|
||||||
| **Kobo Devices** | ✅ | ✅ | Clara, Libra, Sage, etc. |
|
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
|
||||||
| **Mobile Apps** | 🚧 | 🚧 | Coming Q2 2026 |
|
| **Mobile Apps** | 🚧 | 🚧 | Native Android app in design ([docs](docs/developer/android-app.md)); iOS later |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -153,20 +156,20 @@ bruno run
|
|||||||
## 📊 Project Status
|
## 📊 Project Status
|
||||||
|
|
||||||
**Version**: 1.0
|
**Version**: 1.0
|
||||||
**License**: GPL-3.0
|
**License**: AGPL-3.0
|
||||||
**Status**: Production-ready ✅
|
**Status**: Production-ready ✅
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🤝 Contributing
|
## 🤝 Contributing
|
||||||
|
|
||||||
We welcome contributions! Please see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for guidelines.
|
We welcome contributions! Please see [docs/developer/development.md](docs/developer/development.md) for guidelines.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
GPL-3.0 - See [LICENSE](LICENSE) file for details.
|
AGPL-3.0 - See [LICENSE](LICENSE) file for details.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,379 +0,0 @@
|
|||||||
# Timezone Implementation Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Add per-user timezone support with system-wide fallback (set via docker-compose), defaulting to UTC. The database already stores all timestamps as UTC via `TIMESTAMPTZ` columns, so this is primarily a display-layer feature.
|
|
||||||
|
|
||||||
**Display format:** MM-DD-YYYY HH:MM (US convention, no timezone abbreviation shown)
|
|
||||||
|
|
||||||
**Timezone selection:** Manual dropdown only (no browser auto-detect)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Database Schema
|
|
||||||
|
|
||||||
**File:** `database/schema/schema.sql`
|
|
||||||
|
|
||||||
1. Add `timezone` column directly to the `users` table definition (line ~36):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
||||||
email VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
username VARCHAR(255) UNIQUE NOT NULL,
|
|
||||||
password_hash VARCHAR(255) NOT NULL,
|
|
||||||
first_name VARCHAR(255),
|
|
||||||
last_name VARCHAR(255),
|
|
||||||
role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
|
||||||
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
|
||||||
max_devices INTEGER DEFAULT 10,
|
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
|
||||||
timezone VARCHAR(50) DEFAULT 'UTC'
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note: The `timezone` column is already present at line 36 in the current schema. No change needed for this step.
|
|
||||||
|
|
||||||
1. Add `default_timezone` to the `system_settings` INSERT block (line ~49-52):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
|
||||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
|
||||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
|
|
||||||
('default_timezone', 'UTC', 'System default timezone')
|
|
||||||
ON CONFLICT (setting_key) DO NOTHING;
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Add index in the indexes section (after line ~460, with other user indexes):
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
|
|
||||||
```
|
|
||||||
|
|
||||||
1. Regenerate sqlc code:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd internal/database && sqlc generate
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Database Queries
|
|
||||||
|
|
||||||
**File:** `internal/database/queries/queries.sql`
|
|
||||||
|
|
||||||
Add new queries:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- name: UpdateUserTimezone :exec
|
|
||||||
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
|
||||||
|
|
||||||
-- name: GetSystemTimezone :one
|
|
||||||
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note: `UpdateSystemTimezone` is omitted because the existing `UpdateSystemSetting` query handles it by passing `'default_timezone'` as the key parameter.
|
|
||||||
|
|
||||||
Regenerate after adding queries:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd internal/database && sqlc generate
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Template Utilities
|
|
||||||
|
|
||||||
**File:** `templates/utils.go`
|
|
||||||
|
|
||||||
Add timezone-aware time formatting helpers:
|
|
||||||
|
|
||||||
```go
|
|
||||||
package templates
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FormatInTimezone formats a time.Time in the specified timezone as MM-DD-YYYY HH:MM
|
|
||||||
func FormatInTimezone(t time.Time, timezone string) string {
|
|
||||||
if t.IsZero() {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
loc, err := time.LoadLocation(timezone)
|
|
||||||
if err != nil {
|
|
||||||
loc = time.UTC
|
|
||||||
}
|
|
||||||
|
|
||||||
return t.In(loc).Format("01-02-2006 03:04 PM")
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatTimestamptzInTimezone formats a pgtype.Timestamptz in the specified timezone
|
|
||||||
func FormatTimestamptzInTimezone(t pgtype.Timestamptz, timezone string) string {
|
|
||||||
if !t.Valid {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return FormatInTimezone(t.Time, timezone)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: User Context Update
|
|
||||||
|
|
||||||
**File:** `templates/types.go`
|
|
||||||
|
|
||||||
Add `Timezone` field to the `User` struct:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type User struct {
|
|
||||||
ID string
|
|
||||||
Email string
|
|
||||||
Username string
|
|
||||||
Role string
|
|
||||||
Theme string
|
|
||||||
FirstName string
|
|
||||||
LastName string
|
|
||||||
CreatedAt time.Time
|
|
||||||
Token string
|
|
||||||
Timezone string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**File:** `internal/router/helpers.go`
|
|
||||||
|
|
||||||
Update `getTemplateUserWithTheme()` to include timezone:
|
|
||||||
|
|
||||||
```go
|
|
||||||
userTimezone := "UTC"
|
|
||||||
if userDB.Timezone.Valid {
|
|
||||||
userTimezone = userDB.Timezone.String
|
|
||||||
}
|
|
||||||
|
|
||||||
return templates.User{
|
|
||||||
// ... existing fields ...
|
|
||||||
Timezone: userTimezone,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Handlers
|
|
||||||
|
|
||||||
**File:** `internal/handlers/auth.go`
|
|
||||||
|
|
||||||
Update `UpdateProfileRequest` struct:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type UpdateProfileRequest struct {
|
|
||||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
|
||||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
|
||||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
|
||||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
|
||||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
|
||||||
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add timezone update logic in `UpdateProfile()`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
if req.Timezone != "" {
|
|
||||||
if _, err := time.LoadLocation(req.Timezone); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
||||||
"error": "Invalid timezone",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
err := h.db.UpdateUserTimezone(ctx, database.UpdateUserTimezoneParams{
|
|
||||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
||||||
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**File:** `internal/handlers/system_settings.go`
|
|
||||||
|
|
||||||
Add timezone settings handler:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type UpdateTimezoneSettingsRequest struct {
|
|
||||||
DefaultTimezone string `json:"default_timezone" validate:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
|
||||||
var req UpdateTimezoneSettingsRequest
|
|
||||||
if err := c.Bind(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
||||||
}
|
|
||||||
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
|
|
||||||
}
|
|
||||||
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
|
|
||||||
SettingKey: "default_timezone",
|
|
||||||
SettingValue: req.DefaultTimezone,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: User Profile UI
|
|
||||||
|
|
||||||
**File:** `templates/profile_form.templ`
|
|
||||||
|
|
||||||
Add timezone dropdown after the theme field:
|
|
||||||
|
|
||||||
```templ
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="timezone">Timezone</label>
|
|
||||||
<select name="timezone" id="timezone" class="form-select">
|
|
||||||
<option value="UTC" selected?={ user.Timezone == "UTC" }>UTC (Coordinated Universal Time)</option>
|
|
||||||
<option value="America/New_York" selected?={ user.Timezone == "America/New_York" }>Eastern Time</option>
|
|
||||||
<option value="America/Chicago" selected?={ user.Timezone == "America/Chicago" }>Central Time</option>
|
|
||||||
<option value="America/Denver" selected?={ user.Timezone == "America/Denver" }>Mountain Time</option>
|
|
||||||
<option value="America/Los_Angeles" selected?={ user.Timezone == "America/Los_Angeles" }>Pacific Time</option>
|
|
||||||
<option value="America/Phoenix" selected?={ user.Timezone == "America/Phoenix" }>Mountain Time (no DST)</option>
|
|
||||||
<option value="America/Anchorage" selected?={ user.Timezone == "America/Anchorage" }>Alaska Time</option>
|
|
||||||
<option value="Pacific/Honolulu" selected?={ user.Timezone == "Pacific/Honolulu" }>Hawaii Time</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Include timezone in the HTMX form submission payload.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Admin Settings UI
|
|
||||||
|
|
||||||
**File:** `templates/admin_settings.templ`
|
|
||||||
|
|
||||||
Add system default timezone setting:
|
|
||||||
|
|
||||||
```templ
|
|
||||||
<div class="setting-group">
|
|
||||||
<h3>System Defaults</h3>
|
|
||||||
<label for="default_timezone">Default Timezone</label>
|
|
||||||
<select name="default_timezone" id="default_timezone">
|
|
||||||
<option value="UTC">UTC (Coordinated Universal Time)</option>
|
|
||||||
<option value="America/New_York">Eastern Time</option>
|
|
||||||
<option value="America/Chicago">Central Time</option>
|
|
||||||
<option value="America/Denver">Mountain Time</option>
|
|
||||||
<option value="America/Los_Angeles">Pacific Time</option>
|
|
||||||
<option value="America/Phoenix">Mountain Time (no DST)</option>
|
|
||||||
<option value="America/Anchorage">Alaska Time</option>
|
|
||||||
<option value="Pacific/Honolulu">Hawaii Time</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8: Template Time Display Updates
|
|
||||||
|
|
||||||
### Files to update
|
|
||||||
|
|
||||||
| Template | Line(s) | Field(s) |
|
|
||||||
| ------------------------------------ | -------------- | ----------------------------------- |
|
|
||||||
| `templates/book_detail.templ` | ~253, ~282 | `LastReadAt`, `DatePublished` |
|
|
||||||
| `templates/book_detail_modals.templ` | ~68, ~113 | `Timestamp`, `LastReadAt` |
|
|
||||||
| `templates/devices.templ` | ~83, ~91, ~174 | `LastSync`, `LastSeen`, `ExpiresAt` |
|
|
||||||
| `templates/conflicts.templ` | ~114 | `CreatedAt` |
|
|
||||||
| `templates/admin_users.templ` | ~89 | `CreatedAt` |
|
|
||||||
| `templates/queue.templ` | ~138 | `CreatedAt` |
|
|
||||||
|
|
||||||
### Change pattern
|
|
||||||
|
|
||||||
```templ
|
|
||||||
<!-- Before -->
|
|
||||||
{ book.ReadingProgress.LastReadAt.Time.Format("01-02-2006 03:04 PM") }
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
{ templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
|
|
||||||
```
|
|
||||||
|
|
||||||
For `time.Time` fields:
|
|
||||||
|
|
||||||
```templ
|
|
||||||
<!-- Before -->
|
|
||||||
{ device.LastSync.Format("01-02-2006 03:04 PM") }
|
|
||||||
|
|
||||||
<!-- After -->
|
|
||||||
{ templates.FormatInTimezone(device.LastSync, user.Timezone) }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9: Docker Configuration
|
|
||||||
|
|
||||||
**File:** `docker-compose.yml`
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
services:
|
|
||||||
server:
|
|
||||||
environment:
|
|
||||||
- TZ=UTC
|
|
||||||
```
|
|
||||||
|
|
||||||
**File:** `.env.example`
|
|
||||||
|
|
||||||
```
|
|
||||||
# System default timezone (fallback if not set in DB)
|
|
||||||
TZ=UTC
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Files Modified Summary
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
| --------------------------------------- | ------------------------------------------------------------------------------- |
|
|
||||||
| `database/schema/schema.sql` | Add timezone column to users, system_setting row |
|
|
||||||
| `internal/database/queries/queries.sql` | Add UpdateUserTimezone, GetSystemTimezone (reuses existing UpdateSystemSetting) |
|
|
||||||
| `templates/utils.go` | Add FormatInTimezone, FormatTimestamptzInTimezone |
|
|
||||||
| `templates/types.go` | Add Timezone field to User struct |
|
|
||||||
| `internal/router/helpers.go` | Pass timezone to template User |
|
|
||||||
| `internal/handlers/auth.go` | Handle timezone updates in UpdateProfile |
|
|
||||||
| `internal/handlers/system_settings.go` | Add timezone settings handler |
|
|
||||||
| `templates/profile_form.templ` | Add timezone dropdown |
|
|
||||||
| `templates/admin_settings.templ` | Add default timezone setting |
|
|
||||||
| `templates/book_detail.templ` | Update time displays |
|
|
||||||
| `templates/book_detail_modals.templ` | Update time displays |
|
|
||||||
| `templates/devices.templ` | Update time displays |
|
|
||||||
| `templates/conflicts.templ` | Update time displays |
|
|
||||||
| `templates/admin_users.templ` | Update time displays |
|
|
||||||
| `templates/queue.templ` | Update time displays |
|
|
||||||
| `docker-compose.yml` | Add TZ env var |
|
|
||||||
| `.env.example` | Add TZ example |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Create user, set timezone to Eastern, verify times display in MM-DD-YYYY HH:MM format
|
|
||||||
- [ ] Create user, set timezone to Pacific, verify different offset
|
|
||||||
- [ ] Test system default timezone fallback for users with no timezone set
|
|
||||||
- [ ] Verify invalid timezone values are rejected by the API
|
|
||||||
- [ ] Verify existing users (no timezone set) fall back to system default
|
|
||||||
- [ ] Verify all templates show consistent MM-DD-YYYY HH:MM format
|
|
||||||
- [ ] Run `make test-integration` to verify no regressions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment Steps
|
|
||||||
|
|
||||||
1. Update `database/schema/schema.sql` with new column and settings
|
|
||||||
2. Regenerate sqlc: `cd internal/database && sqlc generate`
|
|
||||||
3. Apply schema changes (restart database container with `make rebuild-force-db`)
|
|
||||||
4. Deploy backend code changes
|
|
||||||
5. Verify with existing data
|
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
info:
|
||||||
|
name: SetBaseUrl
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/system/config'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: |-
|
||||||
|
{
|
||||||
|
"base_url": "http://localhost:8765"
|
||||||
|
}
|
||||||
|
headers:
|
||||||
|
- key: Authorization
|
||||||
|
value: Bearer {{token}}
|
||||||
|
- key: Content-Type
|
||||||
|
value: application/json
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Set Base URL
|
||||||
|
|
||||||
|
Configures the server's base_url during initial dev database setup.
|
||||||
|
|
||||||
|
Must be run after RegisterUser (which provides the auth token) and before
|
||||||
|
any library/device creation (which require setup to be complete).
|
||||||
|
|
||||||
|
**Method:** PUT
|
||||||
|
**Endpoint:** /api/system/config
|
||||||
|
**Auth:** Bearer token (from RegisterUser)
|
||||||
@@ -47,7 +47,7 @@ docs: |-
|
|||||||
- `id` (string, required): Media item UUID
|
- `id` (string, required): Media item UUID
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
- `rating` (number, required): Rating value (typically 1-5)
|
- `rating` (number, required): Rating value (1-10 integer scale; displayed as 1-5 stars with half-star precision)
|
||||||
- `review` (string, optional): Review text
|
- `review` (string, optional): Review text
|
||||||
|
|
||||||
**Response:** Updated rating object
|
**Response:** Updated rating object
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ docs:
|
|||||||
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
||||||
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
||||||
Ratings (All Users)
|
Ratings (All Users)
|
||||||
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated)
|
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
|
||||||
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
|
- **Create/Update Rating**: POST /api/media-items/:id/rating - Rate media item (1-10 scale, displayed as 1-5 stars with half-star precision). POST upserts; PUT also available.
|
||||||
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
|
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
|
||||||
|
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
|
||||||
Collections (All Users)
|
Collections (All Users)
|
||||||
- **List Collections**: GET /api/collections - Get user's collections
|
- **List Collections**: GET /api/collections - Get user's collections
|
||||||
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# git-cliff configuration — generates the body of each Gitea Release from
|
||||||
|
# Conventional Commits accumulated since the previous tag. Invoked in CI by
|
||||||
|
# orhun/git-cliff-action with --latest so only the current tag's section is
|
||||||
|
# emitted (no full history, no header — the Gitea Release title is the tag).
|
||||||
|
# Docs: https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
header = ""
|
||||||
|
body = """
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}\
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}\
|
||||||
|
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
|
||||||
|
{% endfor %}\
|
||||||
|
{% endfor %}\
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
footer = ""
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = false
|
||||||
|
require_conventional = false
|
||||||
|
split_commits = false
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Refactor" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = "^test", group = "Tests" },
|
||||||
|
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
|
||||||
|
{ message = ".*", group = "Other" },
|
||||||
|
]
|
||||||
|
filter_commits = false
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
|
sort_commits = "oldest"
|
||||||
+66
-4
@@ -14,6 +14,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
@@ -48,24 +50,74 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("✅ Database schema initialized and verified, starting server...")
|
log.Println("✅ Database schema initialized and verified, starting server...")
|
||||||
|
|
||||||
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
|
// Load tunable settings from the DB into the registry. All values fall back
|
||||||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
// to compiled defaults if a row is missing, so this never blocks startup.
|
||||||
|
registry := database.NewSettingsRegistry(queries)
|
||||||
|
if err := registry.Load(ctx); err != nil {
|
||||||
|
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||||
|
}
|
||||||
|
// Wire the registry into the package-level password validator so live
|
||||||
|
// rule changes apply to the echo struct-tag validator and ValidatePassword.
|
||||||
|
middleware.SetDefaultPasswordSettings(registry)
|
||||||
|
|
||||||
|
// Seed base_url from env var if not already configured. Uses conditional
|
||||||
|
// UPDATE so admin-set values are never overwritten on restart.
|
||||||
|
if cfg.BaseURL != "" {
|
||||||
|
_, err = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ('base_url', $1)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Could not seed base_url: %v", err)
|
||||||
|
} else {
|
||||||
|
// Also seed derived URLs
|
||||||
|
for key, suffix := range map[string]string{
|
||||||
|
"opds_base_url": "/opds",
|
||||||
|
"api_base_url": "/api",
|
||||||
|
} {
|
||||||
|
_, _ = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, key, cfg.BaseURL+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create login attempt tracker from configured (or default) lockout policy.
|
||||||
|
loginMaxAttempts, loginLockout := registry.LoginLockout()
|
||||||
|
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(loginMaxAttempts, loginLockout, 5*time.Minute)
|
||||||
|
|
||||||
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
||||||
|
authHandler.SetSettings(registry)
|
||||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||||
|
systemSettingsHandler.SetSettings(registry)
|
||||||
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
||||||
|
sidecarHandler.SetSettings(registry)
|
||||||
libraryHandler := handlers.NewLibraryHandler(queries)
|
libraryHandler := handlers.NewLibraryHandler(queries)
|
||||||
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
||||||
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||||||
|
deviceAuthMiddleware.SetSettings(registry)
|
||||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||||
|
hashConflictsHandler := handlers.NewHashConflictsHandler(queries)
|
||||||
|
|
||||||
// Create WebSocket connection manager
|
// Create WebSocket connection manager
|
||||||
connManager := sync.NewConnectionManager()
|
connManager := sync.NewConnectionManager()
|
||||||
|
|
||||||
progressService := sync.NewProgressService(queries, connManager)
|
progressService := sync.NewProgressService(queries, connManager)
|
||||||
|
annotationService := sync.NewAnnotationService(queries, connManager)
|
||||||
|
annotationService.SetSettings(registry)
|
||||||
|
maintenanceCancel := annotationService.StartDailyMaintenance()
|
||||||
|
defer maintenanceCancel()
|
||||||
|
|
||||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize)
|
||||||
queueProcessor.SetProgressService(progressService)
|
queueProcessor.SetProgressService(progressService)
|
||||||
|
queueProcessor.SetAnnotationService(annotationService)
|
||||||
|
|
||||||
// Create library service
|
// Create library service
|
||||||
libraryService := services.NewLibraryService(queries)
|
libraryService := services.NewLibraryService(queries)
|
||||||
@@ -74,18 +126,23 @@ func main() {
|
|||||||
libraryService.SyncAllowedExtensions(context.Background())
|
libraryService.SyncAllowedExtensions(context.Background())
|
||||||
|
|
||||||
// Create worker for background tasks
|
// Create worker for background tasks
|
||||||
worker := services.NewWorker(3, connManager)
|
workerCfg := registry.WorkerPoolConfig()
|
||||||
|
worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
|
||||||
services.WorkerInstance = worker
|
services.WorkerInstance = worker
|
||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
koreaderHandler.SetProgressService(progressService)
|
koreaderHandler.SetProgressService(progressService)
|
||||||
|
koreaderHandler.SetAnnotationService(annotationService)
|
||||||
|
koreaderHandler.SetLibraryService(libraryService)
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||||
|
|
||||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||||
|
conversionService.SetSettings(registry)
|
||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
|
opdsHandler.SetSettings(registry)
|
||||||
|
|
||||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries)
|
||||||
@@ -94,6 +151,7 @@ func main() {
|
|||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
mediaHandler.SetProgressService(progressService)
|
mediaHandler.SetProgressService(progressService)
|
||||||
|
mediaHandler.SetAnnotationService(annotationService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
@@ -137,6 +195,7 @@ func main() {
|
|||||||
Echo: e,
|
Echo: e,
|
||||||
Queries: queries,
|
Queries: queries,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
Settings: registry,
|
||||||
DBPool: dbPool,
|
DBPool: dbPool,
|
||||||
AuthHandler: authHandler,
|
AuthHandler: authHandler,
|
||||||
LibraryHandler: libraryHandler,
|
LibraryHandler: libraryHandler,
|
||||||
@@ -144,6 +203,7 @@ func main() {
|
|||||||
MediaHandler: mediaHandler,
|
MediaHandler: mediaHandler,
|
||||||
MatchingHandler: matchingHandler,
|
MatchingHandler: matchingHandler,
|
||||||
ProcessingIssuesHandler: processingIssuesHandler,
|
ProcessingIssuesHandler: processingIssuesHandler,
|
||||||
|
HashConflictsHandler: hashConflictsHandler,
|
||||||
KOReaderHandler: koreaderHandler,
|
KOReaderHandler: koreaderHandler,
|
||||||
WSHandler: wsHandler,
|
WSHandler: wsHandler,
|
||||||
ConflictHandler: conflictHandler,
|
ConflictHandler: conflictHandler,
|
||||||
@@ -161,9 +221,11 @@ func main() {
|
|||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
ProgressService: progressService,
|
ProgressService: progressService,
|
||||||
|
AnnotationService: annotationService,
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
JobsHandler: jobsHandler,
|
JobsHandler: jobsHandler,
|
||||||
LoginTracker: loginAttemptTracker,
|
LoginTracker: loginAttemptTracker,
|
||||||
|
LibraryService: libraryService,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register all routes and get ebook handler
|
// Register all routes and get ebook handler
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir})
|
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
@@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir})
|
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ type TestServerSetup struct {
|
|||||||
ConnManager *wsync.ConnectionManager
|
ConnManager *wsync.ConnectionManager
|
||||||
QueueProcessor *wsync.SyncQueueProcessor
|
QueueProcessor *wsync.SyncQueueProcessor
|
||||||
ProgressService *wsync.ProgressService
|
ProgressService *wsync.ProgressService
|
||||||
|
AnnotationService *wsync.AnnotationService
|
||||||
CleanupCancel context.CancelFunc
|
CleanupCancel context.CancelFunc
|
||||||
QueueCtx context.Context
|
QueueCtx context.Context
|
||||||
QueueCancel context.CancelFunc
|
QueueCancel context.CancelFunc
|
||||||
@@ -455,6 +456,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
cleanupCancel := connManager.StartCleanupTask()
|
cleanupCancel := connManager.StartCleanupTask()
|
||||||
|
|
||||||
progressService := wsync.NewProgressService(queries, connManager)
|
progressService := wsync.NewProgressService(queries, connManager)
|
||||||
|
annotationService := wsync.NewAnnotationService(queries, connManager)
|
||||||
|
|
||||||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||||||
queueProcessor.SetProgressService(progressService)
|
queueProcessor.SetProgressService(progressService)
|
||||||
@@ -463,6 +465,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
koreaderHandler.SetProgressService(progressService)
|
koreaderHandler.SetProgressService(progressService)
|
||||||
|
koreaderHandler.SetAnnotationService(annotationService)
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
@@ -482,6 +485,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
mediaHandler.SetProgressService(progressService)
|
mediaHandler.SetProgressService(progressService)
|
||||||
|
mediaHandler.SetAnnotationService(annotationService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
|
|
||||||
// Create conversion service for OPDS
|
// Create conversion service for OPDS
|
||||||
@@ -537,6 +541,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
ProgressService: progressService,
|
ProgressService: progressService,
|
||||||
|
AnnotationService: annotationService,
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
LoginTracker: loginAttemptTracker,
|
LoginTracker: loginAttemptTracker,
|
||||||
}
|
}
|
||||||
|
|||||||
+274
-13
@@ -45,11 +45,47 @@ CREATE TABLE IF NOT EXISTS system_settings (
|
|||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Insert default system settings
|
-- Extend system_settings with typed metadata so it can back the admin UI's
|
||||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
-- configurable tunables. All columns are nullable for backward compatibility
|
||||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
-- with the original three rows and any pre-existing data.
|
||||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
|
||||||
('default_timezone', 'UTC', 'System default timezone')
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
|
||||||
|
|
||||||
|
-- Insert default system settings (original scan/timezone rows + tunables).
|
||||||
|
-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade.
|
||||||
|
-- ON CONFLICT DO NOTHING preserves any admin-modified values.
|
||||||
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
|
||||||
|
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
|
||||||
|
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
|
||||||
|
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
|
||||||
|
-- security / auth (live)
|
||||||
|
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
|
||||||
|
('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'),
|
||||||
|
('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
-- security / auth (restart required)
|
||||||
|
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
|
||||||
|
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
|
||||||
|
('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'),
|
||||||
|
-- api (live)
|
||||||
|
('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'),
|
||||||
|
('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'),
|
||||||
|
('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
-- sync / performance (live)
|
||||||
|
('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'),
|
||||||
|
('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'),
|
||||||
|
-- sync / performance (restart required)
|
||||||
|
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
|
||||||
|
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
|
||||||
|
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
||||||
|
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
|
||||||
ON CONFLICT (setting_key) DO NOTHING;
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
|
|
||||||
-- Create refresh_tokens table
|
-- Create refresh_tokens table
|
||||||
@@ -245,6 +281,7 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
|||||||
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
||||||
character_offset BIGINT,
|
character_offset BIGINT,
|
||||||
epubcfi TEXT,
|
epubcfi TEXT,
|
||||||
|
context_text TEXT,
|
||||||
chapter INTEGER,
|
chapter INTEGER,
|
||||||
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
||||||
viewport_x FLOAT DEFAULT 0,
|
viewport_x FLOAT DEFAULT 0,
|
||||||
@@ -298,8 +335,8 @@ CREATE TABLE IF NOT EXISTS media_highlights (
|
|||||||
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
selection_text TEXT NOT NULL,
|
selection_text TEXT NOT NULL,
|
||||||
start_position VARCHAR(100), -- position (page:offset or CFI) where highlight starts
|
start_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight starts
|
||||||
end_position VARCHAR(100), -- position (page:offset or CFI) where highlight ends
|
end_position TEXT, -- position (page:offset, CFI, or locator JSON) where highlight ends
|
||||||
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
|
color VARCHAR(7) DEFAULT '#ffff00', -- hex color code for highlight
|
||||||
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
|
note_id UUID REFERENCES media_notes(id) ON DELETE SET NULL, -- optional associated note
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
@@ -937,6 +974,7 @@ BEGIN
|
|||||||
percentage = (book_record->>'percentage')::FLOAT,
|
percentage = (book_record->>'percentage')::FLOAT,
|
||||||
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
||||||
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
||||||
|
context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
|
||||||
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
||||||
chapter_progress = (book_record->>'percentage')::FLOAT,
|
chapter_progress = (book_record->>'percentage')::FLOAT,
|
||||||
last_sync_device = 'koreader',
|
last_sync_device = 'koreader',
|
||||||
@@ -955,6 +993,7 @@ BEGIN
|
|||||||
percentage,
|
percentage,
|
||||||
character_offset,
|
character_offset,
|
||||||
epubcfi,
|
epubcfi,
|
||||||
|
context_text,
|
||||||
chapter,
|
chapter,
|
||||||
chapter_progress,
|
chapter_progress,
|
||||||
last_sync_device,
|
last_sync_device,
|
||||||
@@ -971,6 +1010,7 @@ BEGIN
|
|||||||
(book_record->>'percentage')::FLOAT,
|
(book_record->>'percentage')::FLOAT,
|
||||||
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
||||||
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
||||||
|
CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
|
||||||
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
||||||
(book_record->>'percentage')::FLOAT,
|
(book_record->>'percentage')::FLOAT,
|
||||||
'koreader',
|
'koreader',
|
||||||
@@ -1145,12 +1185,14 @@ CREATE TABLE IF NOT EXISTS system_config (
|
|||||||
updated_by UUID REFERENCES users(id)
|
updated_by UUID REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Pre-seeded values
|
-- One-time cleanup: clear the old placeholder seed so the startup logic
|
||||||
INSERT INTO system_config (key, value) VALUES
|
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
|
||||||
('base_url', 'https://bookhoard.example.com'),
|
UPDATE system_config SET value = ''
|
||||||
('opds_base_url', 'https://bookhoard.example.com/opds'),
|
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
|
||||||
('api_base_url', 'https://bookhoard.example.com/api')
|
UPDATE system_config SET value = ''
|
||||||
ON CONFLICT (key) DO NOTHING;
|
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
|
||||||
|
UPDATE system_config SET value = ''
|
||||||
|
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';
|
||||||
|
|
||||||
-- Create opds_tokens table (device-specific OPDS access tokens)
|
-- Create opds_tokens table (device-specific OPDS access tokens)
|
||||||
CREATE TABLE IF NOT EXISTS opds_tokens (
|
CREATE TABLE IF NOT EXISTS opds_tokens (
|
||||||
@@ -1308,3 +1350,222 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ANNOTATION SYNC MIGRATIONS
|
||||||
|
-- Adds dedup_key, LWW timestamps, soft-delete,
|
||||||
|
-- and device_sync_data to annotation tables.
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Widen position columns for existing databases: the API handlers
|
||||||
|
-- validate up to 1000 characters (full Readium locators, KOReader CRE
|
||||||
|
-- xpointers) but VARCHAR(100) rejected anything longer at the database
|
||||||
|
-- layer. VARCHAR -> TEXT is a metadata-only change, safe to re-run.
|
||||||
|
ALTER TABLE media_highlights ALTER COLUMN start_position TYPE TEXT;
|
||||||
|
ALTER TABLE media_highlights ALTER COLUMN end_position TYPE TEXT;
|
||||||
|
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
||||||
|
ON media_highlights (user_id, media_item_id, dedup_key)
|
||||||
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
|
||||||
|
ON media_notes (user_id, media_item_id, dedup_key)
|
||||||
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
|
||||||
|
ON media_bookmarks (user_id, media_item_id, dedup_key)
|
||||||
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
--: MEDIA ITEM DEDUPLICATION + PATH UNIQUENESS
|
||||||
|
-- ============================================
|
||||||
|
-- A read-then-write race in the scanner historically allowed the same
|
||||||
|
-- (library_id, file_path) to be inserted twice. This block is self-healing:
|
||||||
|
-- it collapses any existing path-duplicates (re-parenting child rows onto a
|
||||||
|
-- survivor so no reading history is lost), then enforces uniqueness going
|
||||||
|
-- forward. Idempotent — safe to re-run on every startup.
|
||||||
|
|
||||||
|
-- Move every child row that points at p_source so it points at p_target,
|
||||||
|
-- deleting source rows that would violate a UNIQUE constraint on the target.
|
||||||
|
CREATE OR REPLACE FUNCTION reparent_media_item_children(p_target UUID, p_source UUID)
|
||||||
|
RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
IF p_target IS NULL OR p_source IS NULL OR p_target = p_source THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
DELETE FROM reading_progress
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM reading_progress WHERE media_item_id = p_target);
|
||||||
|
UPDATE reading_progress SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM reading_speed
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM reading_speed WHERE media_item_id = p_target);
|
||||||
|
UPDATE reading_speed SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM media_ratings
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND user_id IN (SELECT user_id FROM media_ratings WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_ratings SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM media_bookmarks
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND (user_id, title) IN (SELECT user_id, title FROM media_bookmarks WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_bookmarks SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM media_item_formats
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND format_type IN (SELECT format_type FROM media_item_formats WHERE media_item_id = p_target);
|
||||||
|
UPDATE media_item_formats SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM collection_items
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND collection_id IN (SELECT collection_id FROM collection_items WHERE media_item_id = p_target);
|
||||||
|
UPDATE collection_items SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM kobo_shelves
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND device_id IN (SELECT device_id FROM kobo_shelves WHERE media_item_id = p_target);
|
||||||
|
UPDATE kobo_shelves SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM panel_data
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND page_number IN (SELECT page_number FROM panel_data WHERE media_item_id = p_target);
|
||||||
|
UPDATE panel_data SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM processing_issues
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND issue_type IN (SELECT issue_type FROM processing_issues WHERE media_item_id = p_target);
|
||||||
|
UPDATE processing_issues SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
DELETE FROM device_file_aliases
|
||||||
|
WHERE media_item_id = p_source
|
||||||
|
AND (device_id, file_path) IN (SELECT device_id, file_path FROM device_file_aliases WHERE media_item_id = p_target);
|
||||||
|
UPDATE device_file_aliases SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
|
||||||
|
-- Tables whose UNIQUE keys do not include media_item_id.
|
||||||
|
UPDATE device_catalogs SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE kobo_entitlements SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE media_highlights SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE media_notes SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE reading_history SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE sync_conflicts SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
UPDATE sync_queue SET media_item_id = p_target WHERE media_item_id = p_source;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Collapse every (library_id, file_path) group into a single row.
|
||||||
|
-- Survivor = the row with the most user data; ties broken by lowest id.
|
||||||
|
CREATE OR REPLACE FUNCTION dedup_media_items_by_path() RETURNS void
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
g RECORD;
|
||||||
|
v_surv UUID;
|
||||||
|
v_loser UUID;
|
||||||
|
BEGIN
|
||||||
|
FOR g IN
|
||||||
|
SELECT library_id, file_path
|
||||||
|
FROM media_items
|
||||||
|
GROUP BY library_id, file_path
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
LOOP
|
||||||
|
SELECT mi.id INTO v_surv
|
||||||
|
FROM media_items mi
|
||||||
|
WHERE mi.library_id = g.library_id AND mi.file_path = g.file_path
|
||||||
|
ORDER BY
|
||||||
|
((SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM reading_history rh WHERE rh.media_item_id = mi.id)
|
||||||
|
+ (SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = mi.id)) DESC,
|
||||||
|
mi.id ASC
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
FOR v_loser IN
|
||||||
|
SELECT id FROM media_items
|
||||||
|
WHERE library_id = g.library_id AND file_path = g.file_path AND id <> v_surv
|
||||||
|
ORDER BY id
|
||||||
|
LOOP
|
||||||
|
PERFORM reparent_media_item_children(v_surv, v_loser);
|
||||||
|
DELETE FROM media_items WHERE id = v_loser;
|
||||||
|
END LOOP;
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- Collapse any existing path-duplicates so the constraint below can be created.
|
||||||
|
SELECT dedup_media_items_by_path();
|
||||||
|
|
||||||
|
-- Enforce path uniqueness going forward (guarded so re-runs don't error).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conname = 'media_items_library_id_file_path_key'
|
||||||
|
AND conrelid = 'media_items'::regclass
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE media_items
|
||||||
|
ADD CONSTRAINT media_items_library_id_file_path_key UNIQUE (library_id, file_path);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
--: HASH CONFLICTS
|
||||||
|
-- ============================================
|
||||||
|
-- Records content-duplicate groups discovered during hash backfill or rescan:
|
||||||
|
-- two or more media_items in the same library share a file_sha256 but live at
|
||||||
|
-- different file paths (e.g. the same book imported twice under two names on
|
||||||
|
-- a preexisting database). Unlike path duplicates these cannot be auto-collapsed
|
||||||
|
-- (keeping both copies may be intentional), so each group is surfaced on the
|
||||||
|
-- admin Hash Conflicts page for the user to resolve:
|
||||||
|
-- keep_all - both copies are intentional; just stop flagging
|
||||||
|
-- kept:<uuid> - merge every other copy's child rows into the kept item
|
||||||
|
-- (via reparent_media_item_children) and delete the losers
|
||||||
|
CREATE TABLE IF NOT EXISTS hash_conflicts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||||
|
file_sha256 CHAR(64) NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','resolved')),
|
||||||
|
resolution VARCHAR(50), -- 'keep_all' or 'kept:<media_item_uuid>' (41 chars)
|
||||||
|
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
resolved_at TIMESTAMPTZ,
|
||||||
|
UNIQUE(library_id, file_sha256)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
|
||||||
|
|
||||||
|
-- Widen for databases created before the resolution format settled (no-op otherwise)
|
||||||
|
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Development override — merged on top of docker-compose.yml (the base/prod file).
|
||||||
|
# Activated by all `make` targets via:
|
||||||
|
# COMPOSE = <runtime> compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
#
|
||||||
|
# What this adds over prod:
|
||||||
|
# - Local image BUILDING (prod pulls a prebuilt image from the registry)
|
||||||
|
# - The integration-tests service (dev only, gated behind the "tests" profile)
|
||||||
|
# Everything else (env vars, volumes, ports, healthchecks) is inherited from the base file.
|
||||||
|
services:
|
||||||
|
# Build the app image locally instead of pulling from the registry
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
|
||||||
|
# Integration Tests - runs against containerized app and db (dev only)
|
||||||
|
tests:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./Dockerfile
|
||||||
|
target: test-runner
|
||||||
|
container_name: bookhoard_tests
|
||||||
|
environment:
|
||||||
|
# Database Configuration
|
||||||
|
DATABASE_HOST: db
|
||||||
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
|
DATABASE_USER: postgres
|
||||||
|
DATABASE_PASSWORD: ${DBPASS}
|
||||||
|
DATABASE_NAME: bookhoard
|
||||||
|
COOKIE_SECURE: false
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||||
|
|
||||||
|
# Test Configuration
|
||||||
|
TEST_MODE: "true"
|
||||||
|
RATE_LIMIT_ENABLED: "false"
|
||||||
|
REQUESTS_PER_MINUTE: 1000
|
||||||
|
|
||||||
|
# Conversion Service Configuration
|
||||||
|
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
||||||
|
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
||||||
|
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
||||||
|
|
||||||
|
# Test upload path (inside container)
|
||||||
|
TEST_UPLOAD_PATH: /app/uploads
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
|
- bookhoard_conversion_cache:/app/cache/kepub
|
||||||
|
profiles:
|
||||||
|
- tests
|
||||||
+14
-55
@@ -1,5 +1,3 @@
|
|||||||
version: "3.8"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# PostgreSQL Database
|
# PostgreSQL Database
|
||||||
db:
|
db:
|
||||||
@@ -9,14 +7,15 @@ services:
|
|||||||
POSTGRES_DB: bookhoard
|
POSTGRES_DB: bookhoard
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ${DBPASS}
|
POSTGRES_PASSWORD: ${DBPASS}
|
||||||
COOKIE_SECURE: false # make true in production with HTTPS
|
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
|
||||||
|
PGPORT: ${DB_PORT:-5432}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./database/schema:/docker-entrypoint-initdb.d
|
- ./database/schema:/docker-entrypoint-initdb.d
|
||||||
# Make other volumes as needed
|
# Make other volumes as needed
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -27,27 +26,30 @@ services:
|
|||||||
- .env
|
- .env
|
||||||
|
|
||||||
# Bookhoard Application
|
# Bookhoard Application
|
||||||
|
# In production this image is pulled from the Gitea container registry.
|
||||||
|
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||||
app:
|
app:
|
||||||
build:
|
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||||
context: .
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
container_name: bookhoard
|
container_name: bookhoard
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_HOST: db
|
DATABASE_HOST: db
|
||||||
DATABASE_PORT: 5432
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
DATABASE_USER: postgres
|
DATABASE_USER: postgres
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
DATABASE_PASSWORD: ${DBPASS}
|
||||||
DATABASE_NAME: bookhoard
|
DATABASE_NAME: bookhoard
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
SERVER_PORT: 8765
|
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||||
# IMPORTANT: Device sync requires full URL with protocol
|
# IMPORTANT: Device sync requires full URL with protocol
|
||||||
# Local: http://localhost:8765
|
# Local: http://localhost:8765
|
||||||
# Local network: http://192.168.1.X:8765
|
# Local network: http://192.168.1.X:8765
|
||||||
# Domain: https://bookhoard.example.com
|
# Domain: https://bookhoard.example.com
|
||||||
BASE_URL: http://localhost:${SERVER_PORT}
|
BASE_URL: ${BASE_URL:-http://localhost:8765}
|
||||||
|
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
|
|
||||||
# Rate Limiting Configuration
|
# Rate Limiting Configuration
|
||||||
TEST_MODE: ${TEST_MODE:-false}
|
TEST_MODE: ${TEST_MODE:-false}
|
||||||
@@ -62,7 +64,7 @@ services:
|
|||||||
# System timezone (fallback for server-side time operations)
|
# System timezone (fallback for server-side time operations)
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
ports:
|
ports:
|
||||||
- "8765:8765"
|
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -70,55 +72,12 @@ services:
|
|||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- bookhoard_conversion_cache:/app/cache/kepub
|
- bookhoard_conversion_cache:/app/cache/kepub
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# Integration Tests - runs against containerized app and db
|
|
||||||
tests:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
target: test-runner
|
|
||||||
container_name: bookhoard_tests
|
|
||||||
environment:
|
|
||||||
# Database Configuration
|
|
||||||
DATABASE_HOST: db
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_USER: postgres
|
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
|
||||||
DATABASE_NAME: bookhoard
|
|
||||||
COOKIE_SECURE: false
|
|
||||||
|
|
||||||
# Application Configuration
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
SERVER_PORT: 8765
|
|
||||||
|
|
||||||
# Test Configuration
|
|
||||||
TEST_MODE: "true"
|
|
||||||
RATE_LIMIT_ENABLED: "false"
|
|
||||||
REQUESTS_PER_MINUTE: 1000
|
|
||||||
|
|
||||||
# Conversion Service Configuration
|
|
||||||
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
|
||||||
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
|
||||||
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
|
||||||
|
|
||||||
# Test upload path (inside container)
|
|
||||||
TEST_UPLOAD_PATH: /app/uploads
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
app:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
- bookhoard_conversion_cache:/app/cache/kepub
|
|
||||||
profiles:
|
|
||||||
- tests
|
|
||||||
|
|
||||||
# Named Volumes
|
# Named Volumes
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
# Android App — Design & Roadmap
|
||||||
|
|
||||||
|
This document describes the planned native Android client for Bookhoard: a thin, offline-first reading app that treats the Bookhoard server as its backend. The relationship is the same as the audiobookshelf app to an audiobookshelf server, or the Kindle app to Kindle cloud — the server owns the library, sync, and conflict resolution; the app is a dedicated, mobile-first reading frontend with its own UI, designed independently of the web interface.
|
||||||
|
|
||||||
|
**Status**: Planning / pre-development
|
||||||
|
**Companion repo**: `bookhoard-app` (separate repository, AGPL-3.0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Product Vision
|
||||||
|
|
||||||
|
- An **amazing ereader** first and foremost — rendering polish, latency, and reading UX are the product
|
||||||
|
- **Mobile-first UI** designed from scratch for phones; not a wrapper around the web app
|
||||||
|
- **Thin client**: the server remains authoritative for all sync, book matching, and conflict resolution
|
||||||
|
- **v1 formats**: EPUB (ebooks) and CBZ (comics/manga); PDF comes nearly free via the reader toolkit
|
||||||
|
- **Android first**. iOS is a real roadmap item but unscheduled — likely contributor-driven
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Tech Stack
|
||||||
|
|
||||||
|
| Concern | Choice |
|
||||||
|
| -------------- | ------------------------------------------------- |
|
||||||
|
| Language | Kotlin |
|
||||||
|
| UI | Jetpack Compose + Material 3 |
|
||||||
|
| Reader engine | [Readium Kotlin toolkit](https://github.com/readium/kotlin-toolkit) |
|
||||||
|
| Local database | Room |
|
||||||
|
| Networking | OkHttp / Retrofit + WebSocket |
|
||||||
|
| Background | WorkManager |
|
||||||
|
| Images | Coil |
|
||||||
|
| Settings | DataStore |
|
||||||
|
|
||||||
|
### Why native Android
|
||||||
|
|
||||||
|
- The quality bar is the Kindle app. Page-turn latency, text layout fidelity, PDF rendering (`PdfRenderer`), and comic/manga image pipelines are platform-level strengths — and they are the *hard* parts in a WebView, not the easy parts.
|
||||||
|
- Android-first removes the "share one codebase across two platforms simultaneously" constraint that motivates hybrid stacks.
|
||||||
|
- Solo, AI-assisted development compresses the cost of native (code volume), while native's failure modes (well-documented platform APIs) are far easier to debug — alone or with AI — than cross-framework bridge/plugin bugs.
|
||||||
|
- The target audience is the self-hosted community, best reached via GitHub Releases and F-Droid rather than app-store optimization.
|
||||||
|
|
||||||
|
### Alternatives considered
|
||||||
|
|
||||||
|
- **Capacitor / WebView shell** (the audiobookshelf-app model): excellent when a self-contained SPA already exists; a poor fit here. Bookhoard's web UI is server-rendered HTMX and cannot be packaged, comics rendering in a WebView caps the polish target, and deep offline support fights the shell.
|
||||||
|
- **Flutter**: strong middle ground, but no Readium port and a weaker EPUB/PDF plugin ecosystem than the native toolkits.
|
||||||
|
- **Kotlin Multiplatform**: only pays off with a committed near-term iOS effort. Revisit if iOS becomes active; until then it would constrain v1 for a hypothetical.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗 Architecture
|
||||||
|
|
||||||
|
Thin, offline-first client. The server API is the contract (see [API Reference](api/api-reference.md) and [WebSocket API](websocket-api.md)).
|
||||||
|
|
||||||
|
### Module layout
|
||||||
|
|
||||||
|
```
|
||||||
|
:app Compose UI, navigation, dependency injection
|
||||||
|
:core:domain Pure Kotlin — models, sync logic, use cases (no Android deps)
|
||||||
|
:core:data Room, Retrofit/OkHttp, downloads and file storage
|
||||||
|
:feature:reader Readium navigator integration and reading UI
|
||||||
|
```
|
||||||
|
|
||||||
|
Keeping `:core:domain` free of Android dependencies preserves optionality: a future iOS client, a KMP extraction, or a desktop client can reuse or port the domain logic without touching the UI.
|
||||||
|
|
||||||
|
### Offline-first sync flow
|
||||||
|
|
||||||
|
1. UI writes go to the local Room mirror **first** (never blocked on network)
|
||||||
|
2. A WorkManager queue replays changes to the existing REST endpoints (`/api/progress`, `/api/media-items/:id/notes`, `/highlights`, etc.)
|
||||||
|
3. Conflicts are resolved by the server's existing mechanisms — the client never invents its own merge logic
|
||||||
|
4. While online, a WebSocket connection receives realtime updates pushed by other devices (web reader, KOReader)
|
||||||
|
5. Books are downloaded to app storage for fully offline reading, with storage management UI
|
||||||
|
|
||||||
|
### Authentication & device identity
|
||||||
|
|
||||||
|
- **Primary auth: username/password login** via the existing endpoints (`POST /api/auth/login` + refresh). The app is a full user client — browse, collections, ratings, and annotation management all live behind the user JWT, which device tokens cannot reach
|
||||||
|
- After login, the app registers itself as a **device** (`device_type: mobile`) and **self-approves** its registration using its own JWT — approval only requires a logged-in user. The phone then appears on the Devices page with sync attribution, per-device settings, and individually revocable access, with no QR ceremony
|
||||||
|
- Netflix-style QR pairing as a zero-typing sign-in option: post-v1 (see below)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📖 Reader Engine
|
||||||
|
|
||||||
|
[Readium](https://github.com/readium/kotlin-toolkit) provides EPUB, PDF, and CBZ through one publication model and navigator — production-hardened by real reading apps. This avoids building and maintaining three renderers.
|
||||||
|
|
||||||
|
Planned reading features:
|
||||||
|
|
||||||
|
- Custom fonts (including user-loaded), adjustable margins and line height
|
||||||
|
- Themes including OLED true-black for battery
|
||||||
|
- Paginated and scroll modes; gesture and volume-key page turns
|
||||||
|
- Keep-screen-awake while reading
|
||||||
|
- Highlights, notes, and bookmarks synced via existing APIs (including deleted-annotation restore)
|
||||||
|
- Resume to exact position using EPUB CFI, consistent with universal sync
|
||||||
|
|
||||||
|
### Comics & manga UX
|
||||||
|
|
||||||
|
- RTL reading direction and double-page spreads with correct cover/single-page handling
|
||||||
|
- Per-book reading-mode overrides (a manga library can default to RTL)
|
||||||
|
- Zoom and pan; aggressive preloading of adjacent pages
|
||||||
|
- Webtoon / continuous vertical mode: post-v1
|
||||||
|
|
||||||
|
### Reader settings parity with the web reader
|
||||||
|
|
||||||
|
The web reader (`web/src/reader/`) is the reference implementation for reading ergonomics — its font selection, reading themes, and highlight system are considered well-designed; only its desktop-oriented presentation is being replaced on mobile. The Android reader should reuse the same settings model (stored in the `reader_settings` table and synced via the settings endpoint) rather than inventing a parallel one:
|
||||||
|
|
||||||
|
- **Fonts**: the same roster of variable fonts, self-hosted under `/static/fonts/` — Literata (default), Crimson Pro, Source Serif 4, EB Garamond, Libertinus Serif, Noto Serif, Charis SIL, IBM Plex Serif (`FONT_MAP` in `web/src/reader/reader.ts`)
|
||||||
|
- **Typography**: `font_size` (default 18), `line_height` (1.6), `margin_width`, `double_page_spread`
|
||||||
|
- **Themes**: `chrome_theme` (default `tokyo-night`) for app chrome vs `reading_theme`/`reading_mode` for the page surface, plus the fx stack (`fx_brightness`, `fx_contrast`, `fx_invert`)
|
||||||
|
- **Navigation**: `tap_zones_enabled` + `tap_zone_size`, `reading_direction`, `progress_mode`
|
||||||
|
- **Highlights**: per-annotation color (default `#ffd54f`), matching the web palette
|
||||||
|
|
||||||
|
Settings chosen on one device should follow the user everywhere — mobile changes write back through the same sync.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🍎 iOS Posture
|
||||||
|
|
||||||
|
iOS is a real roadmap item but not near-term. The strategy is **not** to pre-pay for it with KMP or a cross-platform framework. Instead:
|
||||||
|
|
||||||
|
- The documented REST/WebSocket API is the sharing mechanism — a future iOS client is a *new client over the same contract*, never a rewrite of shared logic
|
||||||
|
- A contributor-driven Swift/SwiftUI client is welcome; the server needs no changes to support it
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Distribution & Licensing
|
||||||
|
|
||||||
|
- **License**: AGPL-3.0, matching the Bookhoard server
|
||||||
|
- **Channels**: GitHub Releases and F-Droid; Play Store optional later
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 Milestones
|
||||||
|
|
||||||
|
1. **Scaffold** — app shell, auth + QR device pairing, library browsing, book downloads
|
||||||
|
2. **EPUB reading** — Readium integration, CFI progress sync, offline-first reading
|
||||||
|
3. **Annotations** — highlights/notes/bookmarks sync with offline queue
|
||||||
|
4. **Comics** — CBZ navigator with manga modes (RTL, spreads, zoom)
|
||||||
|
5. **Polish** — OLED themes, gestures, background sync, storage management
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔭 Post-v1 Ideas
|
||||||
|
|
||||||
|
### QR pairing sign-in (Netflix-style)
|
||||||
|
|
||||||
|
"Add device" on the web (while logged in) displays a QR code; a fresh app install scans it and is **fully signed in** — no server URL, no password, nothing typed on the phone.
|
||||||
|
|
||||||
|
- **QR is a full login**: the claim endpoint returns JWT + refresh token (plus the device token for sync identity)
|
||||||
|
- **Typed-code fallback** (GitHub/Netflix device-flow style: app displays a short code, user enters it on the web) for phones with broken cameras or no camera
|
||||||
|
- **KOReader keeps its existing flow unchanged** — no typed-code pairing there; it is already as convenient as it can be
|
||||||
|
- **Use the configured `BASE_URL`, never a detected LAN IP** — if the server is published at `https://public.domain`, pairing must work identically from outside the LAN
|
||||||
|
- Requires small server additions: `pair`/`claim` endpoints backed by single-use pairing sessions with a short TTL (in-memory like `pendingRegistrations`)
|
||||||
|
|
||||||
|
### Other ideas
|
||||||
|
|
||||||
|
- Webtoon / continuous vertical reading mode
|
||||||
|
- Home-screen widgets and app shortcuts ("continue reading")
|
||||||
|
- Text-to-speech
|
||||||
|
- OPDS feed consumption from other servers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- **[API Reference](api/api-reference.md)** - Complete REST API
|
||||||
|
- **[WebSocket API](websocket-api.md)** - Real-time sync events
|
||||||
|
- **[Sync Guide](../user/sync-guide.md)** - How universal sync works
|
||||||
|
- **[Devices API](api/devices/)** - Device registration and approval
|
||||||
+173
-24
@@ -26,14 +26,16 @@ Complete API documentation for Bookhoard v1.0 with Universal Cross-Platform Sync
|
|||||||
8. [Device Management](#device-management)
|
8. [Device Management](#device-management)
|
||||||
9. [Analytics](#analytics)
|
9. [Analytics](#analytics)
|
||||||
10. [Book Matching & Linking](#book-matching--linking)
|
10. [Book Matching & Linking](#book-matching--linking)
|
||||||
11. [Collections](#collections) → See [COLLECTIONS_API.md](COLLECTIONS_API.md)
|
11. [Collections](#collections) → See [Collections API](collections-api.md)
|
||||||
12. [OPDS](#opds-open-publication-distribution-system)
|
12. [OPDS](#opds-open-publication-distribution-system)
|
||||||
13. [Sync Protocol - KOReader](#sync-protocol---koreader)
|
13. [Sync Protocol - KOReader](#sync-protocol---koreader)
|
||||||
14. [Sync Protocol - Kobo](#sync-protocol---kobo)
|
14. [Sync Protocol - Kobo](#sync-protocol---kobo)
|
||||||
15. [Universal Progress](#universal-progress)
|
15. [Universal Progress](#universal-progress)
|
||||||
16. [Conflicts](#conflicts)
|
16. [Conflicts](#conflicts)
|
||||||
17. [Sync Queue](#sync-queue)
|
17. [Sync Queue](#sync-queue)
|
||||||
18. [WebSocket](#websocket)
|
18. [System Settings & Configuration](#system-settings--configuration)
|
||||||
|
19. [Hash Conflicts](#hash-conflicts)
|
||||||
|
20. [WebSocket](#websocket)
|
||||||
|
|
||||||
## Base URL
|
## Base URL
|
||||||
|
|
||||||
@@ -201,7 +203,9 @@ Content-Type: application/json
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Update Scan Settings
|
### Update Scan Settings (Legacy)
|
||||||
|
|
||||||
|
> Superseded by `PUT /api/system/settings` (see [System Settings & Configuration](#system-settings--configuration)); kept for backward compatibility.
|
||||||
|
|
||||||
```http
|
```http
|
||||||
PUT /api/libraries/scan-settings
|
PUT /api/libraries/scan-settings
|
||||||
@@ -665,18 +669,23 @@ Content-Type: application/json
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_id": "uuid",
|
|
||||||
"registration_id": "registration-uuid",
|
"registration_id": "registration-uuid",
|
||||||
"auth_url": "https://bookhoard.com/devices/auth/confirm/abc123",
|
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
||||||
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
||||||
"expires_in": 300
|
"expires_in": 300,
|
||||||
|
"poll_interval": 3,
|
||||||
|
"setup_instructions": {
|
||||||
|
"koreader": "Calibre URL: https://bookhoard.com/api/sync/koreader"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes.
|
||||||
|
|
||||||
### Check Registration Status
|
### Check Registration Status
|
||||||
|
|
||||||
```http
|
```http
|
||||||
POST /api/devices/auth/status
|
POST /api/devices/register/status
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
@@ -688,13 +697,13 @@ Content-Type: application/json
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "pending|approved|expired",
|
"status": "pending|approved",
|
||||||
"auth_token": "device-bearer-token...",
|
"auth_token": "device-bearer-token...",
|
||||||
"device_id": "uuid",
|
"device_id": "uuid",
|
||||||
"sync_endpoints": {
|
"sync_endpoints": {
|
||||||
"progress": "https://bookhoard.com/api/sync/progress",
|
"progress": "https://bookhoard.com/api/sync/koreader/progress",
|
||||||
"metadata": "https://bookhoard.com/api/sync/metadata",
|
"metadata": "https://bookhoard.com/api/sync/koreader/metadata",
|
||||||
"annotations": "https://bookhoard.com/api/sync/annotations"
|
"bookmarks": "https://bookhoard.com/api/sync/koreader/bookmarks"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -747,6 +756,22 @@ DELETE /api/devices/{device_id}
|
|||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Get Device Sidecar Config
|
||||||
|
|
||||||
|
Returns the `.bookhoard.json` sidecar config for a device (server endpoints, books keyed by per-format SHA-256, collections) used by the KOReader plugin to self-configure.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/devices/{device_id}/sidecar
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Also available as a file download:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/devices/{device_id}/sidecar/download
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
```
|
||||||
|
|
||||||
## Analytics
|
## Analytics
|
||||||
|
|
||||||
### Get Reading Statistics
|
### Get Reading Statistics
|
||||||
@@ -953,7 +978,7 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
## Collections
|
## Collections
|
||||||
|
|
||||||
For complete collection management documentation, see **[COLLECTIONS_API.md](COLLECTIONS_API.md)**.
|
For complete collection management documentation, see **[Collections API](collections-api.md)**.
|
||||||
|
|
||||||
**Quick Reference**:
|
**Quick Reference**:
|
||||||
|
|
||||||
@@ -986,25 +1011,39 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
|
|||||||
- `page` (optional): Page number (default: 1)
|
- `page` (optional): Page number (default: 1)
|
||||||
- `per_page` (optional): Items per page (default: 50, max: 200)
|
- `per_page` (optional): Items per page (default: 50, max: 200)
|
||||||
|
|
||||||
|
The feed is paginated via standard OPDS link relations. Clients (e.g. KOReader)
|
||||||
|
walk pages by following the `rel="next"` link until it is absent. OpenSearch
|
||||||
|
paging metadata (`totalResults`, `itemsPerPage`, `startIndex`) is also included.
|
||||||
|
|
||||||
**Response** (200 - OPDS 1.2 XML):
|
**Response** (200 - OPDS 1.2 XML):
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||||
xmlns:opds="http://opds-spec.org/2010/"
|
xmlns:opds="http://opds-spec.org/2010/"
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
<id>urn:uuid:device-id</id>
|
<id>urn:uuid:device-id</id>
|
||||||
<title>Bookhoard Library</title>
|
<title>Bookhoard Library</title>
|
||||||
<updated>2026-02-01T12:00:00Z</updated>
|
<updated>2026-02-01T12:00:00Z</updated>
|
||||||
|
|
||||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
|
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
|
||||||
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
|
<link rel="first" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
|
<link rel="previous" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
|
<link rel="next" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=3&per_page=50"/>
|
||||||
|
<link rel="last" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=37&per_page=50"/>
|
||||||
|
<link rel="search" type="application/opensearchdescription+xml"
|
||||||
|
href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
||||||
|
|
||||||
|
<opensearch:totalResults>1814</opensearch:totalResults>
|
||||||
|
<opensearch:itemsPerPage>50</opensearch:itemsPerPage>
|
||||||
|
<opensearch:startIndex>51</opensearch:startIndex>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<id>urn:uuid:bookhoard-uuid-123</id>
|
<id>urn:uuid:bookhoard-uuid-123</id>
|
||||||
<dc:title>The Hobbit</dc:title>
|
<title>The Hobbit</title>
|
||||||
<dc:creator>J.R.R. Tolkien</dc:creator>
|
<author><name>J.R.R. Tolkien</name></author>
|
||||||
<updated>2026-02-01T10:00:00Z</updated>
|
<updated>2026-02-01T10:00:00Z</updated>
|
||||||
|
|
||||||
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
||||||
@@ -1043,10 +1082,28 @@ GET /opds/devices/{deviceId}/download/{bookId}?format={format}
|
|||||||
### Search OPDS Catalog
|
### Search OPDS Catalog
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /opds/devices/{deviceId}/search?q={query}
|
GET /opds/devices/{deviceId}/search # OpenSearch description
|
||||||
|
GET /opds/devices/{deviceId}/search?q={query} # search results feed
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response** (200 - OPDS 1.2 XML with search results)
|
When called **without** a `q` parameter, returns an OpenSearch description
|
||||||
|
document (`application/opensearchdescription+xml`). OPDS clients fetch this to
|
||||||
|
learn the search URL template, then substitute `{searchTerms}`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
|
<ShortName>Bookhoard</ShortName>
|
||||||
|
<Description>Search the Bookhoard library</Description>
|
||||||
|
<InputEncoding>UTF-8</InputEncoding>
|
||||||
|
<OutputEncoding>UTF-8</OutputEncoding>
|
||||||
|
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||||
|
template="http://localhost:8765/opds/devices/kobo-id/search?q={searchTerms}"/>
|
||||||
|
</OpenSearchDescription>
|
||||||
|
```
|
||||||
|
|
||||||
|
When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with
|
||||||
|
search results, including `opensearch:totalResults`).
|
||||||
|
|
||||||
### List Available Formats
|
### List Available Formats
|
||||||
|
|
||||||
@@ -1171,6 +1228,8 @@ Authorization: Bearer <device_token>
|
|||||||
|
|
||||||
## Sync Protocol - Kobo
|
## Sync Protocol - Kobo
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change.
|
||||||
|
|
||||||
### Kobo Markup Sync
|
### Kobo Markup Sync
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -1506,6 +1565,96 @@ Authorization: Bearer <token>
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## System Settings & Configuration
|
||||||
|
|
||||||
|
### List All Settings
|
||||||
|
|
||||||
|
Returns every tunable setting with current value and metadata (type, range, category, group, description, `requires_restart`, `is_default`).
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/system/settings
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (200):
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"key": "scan_poll_interval_seconds",
|
||||||
|
"value": "60",
|
||||||
|
"type": "int",
|
||||||
|
"min": "1",
|
||||||
|
"max": "3600",
|
||||||
|
"requires_restart": false,
|
||||||
|
"category": "scanner",
|
||||||
|
"group": "Scanning",
|
||||||
|
"description": "How often to scan all libraries (seconds)",
|
||||||
|
"is_default": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update a Setting
|
||||||
|
|
||||||
|
Type-aware validation (int range, bool parse, IANA timezone for `default_timezone`), persists the value, reloads the registry, and reports whether a restart is needed.
|
||||||
|
|
||||||
|
```http
|
||||||
|
PUT /api/system/settings
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"key": "scan_poll_interval_seconds",
|
||||||
|
"value": "30"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (200): the updated entry plus `reload_required`.
|
||||||
|
|
||||||
|
Setting categories: scanner (`scan_poll_interval_seconds`, `auto_scan_enabled`), general (`default_timezone`), security (session duration, password rules, auth rate limit, login lockout), api (OPDS page sizes, device rate limits), sync (annotation tombstone TTL, sync queue interval/batch), performance (conversion cache TTL, worker pool size/capacity). See [System Settings API](api/system/settings.md) for the full catalog.
|
||||||
|
|
||||||
|
### Get / Update Raw System Config
|
||||||
|
|
||||||
|
Flat key/value configuration (e.g. `base_url`), including keys without registry metadata.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/system/config
|
||||||
|
PUT /api/system/config
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hash Conflicts
|
||||||
|
|
||||||
|
Duplicate content discovered during hashing (import, rescan, or the startup backfill) is grouped into hash conflicts for an explicit keep/merge decision. Files on disk are never deleted.
|
||||||
|
|
||||||
|
### List Hash Conflicts
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/admin/hash-conflicts
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response** (200): `{ "conflicts": [ { id, library_id, library_name, sha256, created_at, items: [ { id, title, author, file_path, file_size, created_at, progress_count, highlight_count, bookmark_count, note_count, collection_count } ] } ], "total": n }`
|
||||||
|
|
||||||
|
### Resolve Hash Conflict
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/admin/hash-conflicts/:id/resolve
|
||||||
|
Authorization: Bearer <admin_token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"action": "keep",
|
||||||
|
"keep_uuid": "media-item-uuid-to-keep"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `action=keep` — merge every other copy's child rows (progress, highlights, bookmarks, notes, collections) into the kept item, then delete the losers
|
||||||
|
- `action=keep_all` — copies are intentional; dismiss the conflict
|
||||||
|
|
||||||
|
**Errors**: `400` (bad ID / missing `keep_uuid`), `404` (not found), `409` (already resolved).
|
||||||
|
|
||||||
## WebSocket
|
## WebSocket
|
||||||
|
|
||||||
### Connect to WebSocket
|
### Connect to WebSocket
|
||||||
@@ -1661,10 +1810,10 @@ bruno run bruno/devices/
|
|||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- [README.md](README.md) - Getting started guide
|
- [README.md](../../README.md) - Getting started guide
|
||||||
- [UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md](UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md) - Sync architecture
|
- [Sync Guide](../user/sync-guide.md) - Sync concepts and conflict resolution
|
||||||
- [KOBOREADER_SETUP.md](KOBOREADER_SETUP.md) - KOReader device setup
|
- [KOReader Setup](../user/devices/koreader-setup.md) - KOReader device setup
|
||||||
- [KOBO_SETUP.md](KOBO_SETUP.md) - Kobo device setup
|
- [Kobo Setup](../user/devices/kobo-setup.md) - Kobo device setup (native sync coming soon)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Hash Conflicts API
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
When Bookhoard hashes your library (on import, rescan, or the startup backfill), two media items in the same library with the same `file_sha256` indicate duplicate content. Each duplicate group is recorded as a **hash conflict** and exposed here for an explicit keep/merge decision. Conflicts are also surfaced in the admin UI's Hash Conflicts page.
|
||||||
|
|
||||||
|
**Authentication**: Admin JWT token required
|
||||||
|
**Content-Type**: `application/json` (resolve also accepts form-encoded bodies for htmx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### List Hash Conflicts
|
||||||
|
|
||||||
|
List all pending conflict groups, each with its member items and per-item usage counts (reading progress, highlights, bookmarks, notes, collections) to help decide which copy to keep.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/admin/hash-conflicts`
|
||||||
|
|
||||||
|
**Response**: **200 OK**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conflicts": [
|
||||||
|
{
|
||||||
|
"id": "conflict-uuid",
|
||||||
|
"library_id": "library-uuid",
|
||||||
|
"library_name": "Ebooks",
|
||||||
|
"sha256": "abc123...",
|
||||||
|
"created_at": "2026-08-14T12:00:00Z",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "media-item-uuid",
|
||||||
|
"title": "The Hobbit",
|
||||||
|
"author": "J. R. R. Tolkien",
|
||||||
|
"file_path": "/books/hobbit.epub",
|
||||||
|
"file_size": 1048576,
|
||||||
|
"created_at": "2026-01-01T00:00:00Z",
|
||||||
|
"progress_count": 2,
|
||||||
|
"highlight_count": 12,
|
||||||
|
"bookmark_count": 3,
|
||||||
|
"note_count": 1,
|
||||||
|
"collection_count": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET https://bookhoard.example.com/api/admin/hash-conflicts \
|
||||||
|
-H "Authorization: Bearer <admin_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Resolve Hash Conflict
|
||||||
|
|
||||||
|
Resolve one conflict group.
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/admin/hash-conflicts/{id}/resolve`
|
||||||
|
|
||||||
|
**Request Body** (JSON or form-encoded):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "keep",
|
||||||
|
"keep_uuid": "media-item-uuid-to-keep"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
| ----------- | ------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `action` | string | Yes | `keep_all` — both copies are intentional; dismiss the conflict. `keep` — keep `keep_uuid` and delete the other copies. |
|
||||||
|
| `keep_uuid` | string | for `action=keep` | The media item UUID to keep. Must belong to this conflict group. With `keep`, every other copy's child rows (progress, highlights, bookmarks, notes, collections, …) are merged into the kept item before the losers are deleted. |
|
||||||
|
|
||||||
|
**Responses**:
|
||||||
|
|
||||||
|
- `200 OK` — resolved (body is an HTML confirmation snippet for the admin UI page)
|
||||||
|
- `400 Bad Request` — invalid conflict ID, missing `keep_uuid`, or `keep_uuid` not in the group
|
||||||
|
- `404 Not Found` — conflict doesn't exist
|
||||||
|
- `409 Conflict` — conflict already resolved
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://bookhoard.example.com/api/admin/hash-conflicts/<id>/resolve \
|
||||||
|
-H "Authorization: Bearer <admin_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"action": "keep", "keep_uuid": "media-item-uuid"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When Conflicts Are Created
|
||||||
|
|
||||||
|
- **Startup backfill**: items imported before hashing existed are hashed automatically ~30s after startup; duplicates discovered land here.
|
||||||
|
- **Rescan**: hashes are recomputed and content duplicates are flagged.
|
||||||
|
|
||||||
|
Files on disk are never deleted — resolution only affects database rows.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Endpoints
|
||||||
|
|
||||||
|
- [System Settings API](../system/settings.md) — scanning configuration
|
||||||
|
- [Scanner API](../scanner/) — triggering scans and watch mode
|
||||||
@@ -21,9 +21,10 @@ Complete reference for Bookhoard REST API endpoints.
|
|||||||
- [Conflicts](conflicts/) - Sync conflict resolution
|
- [Conflicts](conflicts/) - Sync conflict resolution
|
||||||
- [Queue](queue/) - Sync queue management
|
- [Queue](queue/) - Sync queue management
|
||||||
- [Scanner](scanner/) - Library scanning and watch mode (admin)
|
- [Scanner](scanner/) - Library scanning and watch mode (admin)
|
||||||
|
- [System](system/) - Tunable system settings and configuration (admin)
|
||||||
- [OPDS](opds/) - Open Publication Distribution
|
- [OPDS](opds/) - Open Publication Distribution
|
||||||
- [KOReader](koreader/) - KOReader sync protocol
|
- [KOReader](koreader/) - KOReader sync protocol
|
||||||
- [Kobo](kobo/) - Kobo sync protocol
|
- [Kobo](kobo/) - Kobo sync protocol (coming soon)
|
||||||
- [WebSocket](websocket/) - Real-time sync events
|
- [WebSocket](websocket/) - Real-time sync events
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -51,6 +52,8 @@ See [Admin Operations](admin/)
|
|||||||
|
|
||||||
- GET /api/auth/users - List all users (admin)
|
- GET /api/auth/users - List all users (admin)
|
||||||
- PUT /api/auth/users/:id/max-devices - Update user device limit (admin)
|
- PUT /api/auth/users/:id/max-devices - Update user device limit (admin)
|
||||||
|
- GET /api/admin/hash-conflicts - List pending hash conflict groups (admin) — see [Hash Conflicts](admin/hash-conflicts.md)
|
||||||
|
- POST /api/admin/hash-conflicts/:id/resolve - Resolve a conflict (keep / keep_all) (admin)
|
||||||
|
|
||||||
## Users & Profiles
|
## Users & Profiles
|
||||||
|
|
||||||
@@ -71,7 +74,10 @@ See [Library Management](libraries/)
|
|||||||
- DELETE /api/libraries/:id/folders - Delete library folder (admin)
|
- DELETE /api/libraries/:id/folders - Delete library folder (admin)
|
||||||
- GET /api/libraries/:id/stats - Get library statistics (admin)
|
- GET /api/libraries/:id/stats - Get library statistics (admin)
|
||||||
- GET /api/libraries/:id/media-items - Get library media items (admin)
|
- GET /api/libraries/:id/media-items - Get library media items (admin)
|
||||||
|
- GET /api/libraries/browse - Browse server directories (admin)
|
||||||
- POST /api/libraries/:id/scan - Scan library (admin)
|
- POST /api/libraries/:id/scan - Scan library (admin)
|
||||||
|
- GET /api/libraries/scan-settings - Legacy scan settings (admin; superseded by /api/system/settings)
|
||||||
|
- PUT /api/libraries/scan-settings - Legacy scan settings update (admin; superseded by /api/system/settings)
|
||||||
- GET /api/libraries/visibility - Get visible libraries
|
- GET /api/libraries/visibility - Get visible libraries
|
||||||
- POST /api/libraries/visibility - Set library visibility
|
- POST /api/libraries/visibility - Set library visibility
|
||||||
|
|
||||||
@@ -83,7 +89,7 @@ See [Media Item Operations](media-items/)
|
|||||||
- GET /api/media-items/:id - Get media item details
|
- GET /api/media-items/:id - Get media item details
|
||||||
- POST /api/media-items/bulk-delete - Bulk delete media items
|
- POST /api/media-items/bulk-delete - Bulk delete media items
|
||||||
- POST /api/media-items/bulk-update - Bulk update media items (tags/contributors with normalization)
|
- POST /api/media-items/bulk-update - Bulk update media items (tags/contributors with normalization)
|
||||||
- GET /api/media-items/:uuid/download - Download media item file
|
- GET /uploads/library-{library_id}/{file_path} - Download book file / cover (JWT; see [Download Media Item](media-items/download_media_item.md))
|
||||||
- POST /api/media-items/:id/rating - Create rating
|
- POST /api/media-items/:id/rating - Create rating
|
||||||
- GET /api/media-items/:id/rating - Get rating
|
- GET /api/media-items/:id/rating - Get rating
|
||||||
- PUT /api/media-items/:id/rating - Update rating
|
- PUT /api/media-items/:id/rating - Update rating
|
||||||
@@ -101,6 +107,10 @@ See [Media Item Operations](media-items/)
|
|||||||
- GET /api/media-items/:id/highlights/:highlightId - Get highlight
|
- GET /api/media-items/:id/highlights/:highlightId - Get highlight
|
||||||
- PUT /api/media-items/:id/highlights/:highlightId - Update highlight
|
- PUT /api/media-items/:id/highlights/:highlightId - Update highlight
|
||||||
- DELETE /api/media-items/:id/highlights/:highlightId - Delete highlight
|
- DELETE /api/media-items/:id/highlights/:highlightId - Delete highlight
|
||||||
|
- GET /api/media-items/:id/bookmarks - Get bookmarks
|
||||||
|
- GET /api/media-items/:id/annotations/deleted - List deleted annotations (history)
|
||||||
|
- POST /api/media-items/:id/annotations/:annotationId/restore - Restore a deleted annotation
|
||||||
|
- DELETE /api/media-items/:id/annotations/:annotationId?annotation_type=highlight|note|bookmark - Permanently delete a deleted annotation
|
||||||
- POST /api/media-items - Create media item (admin)
|
- POST /api/media-items - Create media item (admin)
|
||||||
- PUT /api/media-items/:id - Update media item (admin)
|
- PUT /api/media-items/:id - Update media item (admin)
|
||||||
- DELETE /api/media-items/:id - Delete media item (admin)
|
- DELETE /api/media-items/:id - Delete media item (admin)
|
||||||
@@ -134,10 +144,21 @@ See [Device Registration & Sync](devices/)
|
|||||||
- GET /api/devices/pending - List pending registrations (admin)
|
- GET /api/devices/pending - List pending registrations (admin)
|
||||||
- GET /api/devices/approve/:registration_id - Approve registration (admin)
|
- GET /api/devices/approve/:registration_id - Approve registration (admin)
|
||||||
- POST /api/devices/reject/:registration_id - Reject registration (admin)
|
- POST /api/devices/reject/:registration_id - Reject registration (admin)
|
||||||
- POST /api/devices/:id/shelves - Add to shelf (Kobo)
|
- POST /api/devices/:id/shelves - Add to shelf (Kobo; used by native Kobo sync, coming soon)
|
||||||
- GET /api/devices/:id/shelves - Get shelf contents
|
- GET /api/devices/:id/shelves - Get shelf contents
|
||||||
- DELETE /api/devices/:id/shelves - Remove from shelf
|
- DELETE /api/devices/:id/shelves - Remove from shelf
|
||||||
- DELETE /api/devices/:id/shelves/clear - Clear shelf
|
- DELETE /api/devices/:id/shelves/clear - Clear shelf
|
||||||
|
- GET /api/devices/:id/sidecar - Get device sidecar config (.bookhoard.json) — see [Sidecar Config](devices/get_sidecar_config.md)
|
||||||
|
- GET /api/devices/:id/sidecar/download - Download sidecar config as a file
|
||||||
|
|
||||||
|
## System Settings & Configuration
|
||||||
|
|
||||||
|
See [System API](system/)
|
||||||
|
|
||||||
|
- GET /api/system/settings - List all tunable settings with metadata (admin)
|
||||||
|
- PUT /api/system/settings - Validate, persist, and reload a single setting (admin)
|
||||||
|
- GET /api/system/config - Raw key/value system configuration (admin)
|
||||||
|
- PUT /api/system/config - Update raw config values (admin)
|
||||||
|
|
||||||
## Analytics
|
## Analytics
|
||||||
|
|
||||||
@@ -220,12 +241,15 @@ See [OPDS Feeds](opds/)
|
|||||||
See [KOReader Sync](koreader/) and [Sync Protocol](sync/koreader-protocol.md)
|
See [KOReader Sync](koreader/) and [Sync Protocol](sync/koreader-protocol.md)
|
||||||
|
|
||||||
- POST /api/sync/koreader/progress - Sync reading progress
|
- POST /api/sync/koreader/progress - Sync reading progress
|
||||||
|
- GET /api/sync/koreader/resolve?sha256={hash} - Resolve a book UUID by file SHA-256
|
||||||
- GET /api/sync/koreader/metadata/:uuid - Get book metadata
|
- GET /api/sync/koreader/metadata/:uuid - Get book metadata
|
||||||
- GET /api/sync/koreader/library - Get device library
|
- GET /api/sync/koreader/library - Get device library
|
||||||
- POST /api/sync/koreader/bookmarks - Sync bookmarks
|
- POST /api/sync/koreader/bookmarks - Sync bookmarks
|
||||||
|
|
||||||
## Kobo Sync Protocol
|
## Kobo Sync Protocol
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change.
|
||||||
|
|
||||||
See [Kobo Sync](kobo/) and [Sync Protocol](sync/kobo-protocol.md)
|
See [Kobo Sync](kobo/) and [Sync Protocol](sync/kobo-protocol.md)
|
||||||
|
|
||||||
- POST /api/sync/kobo/markup - Sync markup highlights
|
- POST /api/sync/kobo/markup - Sync markup highlights
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ Authenticate with email and password.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
"refresh_token": "d4f5g6h7...",
|
"refresh_token": "d4f5g6h7...",
|
||||||
"token_type": "Bearer",
|
"token_type": "Bearer",
|
||||||
"expires_in": 604800,
|
"expires_in": 604800,
|
||||||
@@ -41,6 +41,8 @@ Authenticate with email and password.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Note: the access token field is `access_token` (not `token`). Nullable profile fields (`first_name`, `last_name`) may be empty strings.
|
||||||
|
|
||||||
**Set-Cookie Header**:
|
**Set-Cookie Header**:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ The frontend toast.js interceptor:
|
|||||||
- **Backend**: Automatically manages HTTP-only cookie
|
- **Backend**: Automatically manages HTTP-only cookie
|
||||||
- **Frontend**: Store tokens in localStorage for API calls
|
- **Frontend**: Store tokens in localStorage for API calls
|
||||||
|
|
||||||
### Mobile Applications
|
### Mobile Applications (coming later)
|
||||||
|
|
||||||
- Store access token in secure storage (Keychain/Keystore)
|
- Store access token in secure storage (Keychain/Keystore)
|
||||||
- Store refresh token in secure storage
|
- Store refresh token in secure storage
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Check device registration status or get device details.
|
Check device registration status or get device details.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/devices/auth/status` or `GET /api/devices/{device_id}`
|
**Endpoint**: `POST /api/devices/register/status` or `GET /api/devices/{device_id}`
|
||||||
**Auth**: Not required for status check, Required for device details
|
**Auth**: Not required for status check, Required for device details
|
||||||
**Content-Type**: `application/json` (for status check)
|
**Content-Type**: `application/json` (for status check)
|
||||||
|
|
||||||
@@ -24,7 +24,9 @@ Check device registration status or get device details.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "pending|approved|expired",
|
"status": "pending|approved",
|
||||||
|
"message": "awaiting user approval",
|
||||||
|
"expires_in": 123,
|
||||||
"auth_token": "device-bearer-token...",
|
"auth_token": "device-bearer-token...",
|
||||||
"device_id": "uuid",
|
"device_id": "uuid",
|
||||||
"sync_endpoints": {
|
"sync_endpoints": {
|
||||||
@@ -35,24 +37,16 @@ Check device registration status or get device details.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK) - Device Details
|
`status` is `pending` or `approved`. While pending, the response includes `message` and `expires_in` (seconds remaining). Once approved, the response includes `auth_token`, `device_id`, and `sync_endpoints`; `auth_token` fields are empty when pending.
|
||||||
|
|
||||||
```json
|
**The approved response is single-use**: the registration is deleted from the pending map once returned, so store the `auth_token` immediately. A repeat status check for the same `registration_id` returns 404.
|
||||||
{
|
|
||||||
"id": "uuid",
|
|
||||||
"device_name": "My Kobo Clara",
|
|
||||||
"device_type": "kobo",
|
|
||||||
"last_sync": "2026-01-31T10:00:00Z",
|
|
||||||
"last_seen": "2026-01-31T10:05:00Z",
|
|
||||||
"sync_enabled": true,
|
|
||||||
"auto_sync": true,
|
|
||||||
"sync_frequency_minutes": 5
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | --------------------------------------------- |
|
| ---- | -------------------------------------------------- |
|
||||||
| 401 | Invalid or expired token (for device details) |
|
| 400 | Invalid or missing `registration_id` |
|
||||||
| 404 | Device or registration not found |
|
| 404 | Registration not found (unknown or already issued) |
|
||||||
|
| 410 | Registration expired (`{"error": "registration expired"}`) |
|
||||||
|
|
||||||
|
Note: expiration is signaled by HTTP 410 Gone, not a `"status": "expired"` value. Pending registrations are held in server memory, so a server restart also invalidates them (subsequent checks return 404).
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Get Device Sidecar Config
|
||||||
|
|
||||||
|
Returns the KOReader/Kobo sidecar configuration (`.bookhoard.json`) for a device: server endpoints, the user's books (keyed by SHA-256 with UUID fallback), and collections. Used by the Bookhoard KOReader plugin to self-configure after approval.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/devices/{id}/sidecar`
|
||||||
|
**Auth**: User JWT (device owner or admin)
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "1",
|
||||||
|
"bookhoard": {
|
||||||
|
"opds_catalog": "https://bookhoard.example.com/opds/devices/<device-id>/catalog",
|
||||||
|
"sync_api": "https://bookhoard.example.com/api/sync/kobo",
|
||||||
|
"opds_base_url": "https://bookhoard.example.com/opds",
|
||||||
|
"api_base_url": "https://bookhoard.example.com",
|
||||||
|
"device_id": "<device-id>",
|
||||||
|
"device_token": "dev_..."
|
||||||
|
},
|
||||||
|
"books": {
|
||||||
|
"abc123sha256...": {
|
||||||
|
"bookhoard_uuid": "media-item-uuid",
|
||||||
|
"title": "The Hobbit",
|
||||||
|
"author": "J. R. R. Tolkien",
|
||||||
|
"available_formats": ["epub", "kepub"],
|
||||||
|
"sha256": "abc123sha256...",
|
||||||
|
"file_path": "/books/hobbit.epub"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"collections": [
|
||||||
|
{ "name": "Favorites", "shelf_mapping": "Favorites" }
|
||||||
|
],
|
||||||
|
"opds_enabled": true,
|
||||||
|
"sidecar_enabled": true,
|
||||||
|
"last_updated": "2026-08-20T12:00:00Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes**:
|
||||||
|
|
||||||
|
- The `books` map is keyed by per-format SHA-256 (falling back to the item UUID), so a book downloaded in a different format (e.g. KEPUB) still matches its primary entry. Each entry lists `available_formats` for the item.
|
||||||
|
- `available_formats` includes `kepub` when the source is an EPUB (conversion available).
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://bookhoard.example.com/api/devices/<device-id>/sidecar \
|
||||||
|
-H "Authorization: Bearer <token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Download Device Sidecar Config
|
||||||
|
|
||||||
|
Generates the same configuration as a downloadable `.bookhoard.json` file for manual device setup.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/devices/{id}/sidecar/download`
|
||||||
|
**Auth**: User JWT (device owner or admin)
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
**Headers**:
|
||||||
|
|
||||||
|
- `Content-Type`: `application/json`
|
||||||
|
- `Content-Disposition`: attachment; filename="<device-name>.bookhoard.json"
|
||||||
|
|
||||||
|
**Body**: the sidecar JSON (same shape as above).
|
||||||
@@ -28,14 +28,19 @@ Register a new device for sync.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_id": "uuid",
|
|
||||||
"registration_id": "registration-uuid",
|
"registration_id": "registration-uuid",
|
||||||
"auth_url": "https://bookhoard.com/devices/auth/confirm/abc123",
|
"auth_url": "https://bookhoard.com/devices/approve/abc123",
|
||||||
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
"qr_code": "data:image/png;base64,iVBORw0KG...",
|
||||||
"expires_in": 300
|
"expires_in": 300,
|
||||||
|
"poll_interval": 3,
|
||||||
|
"setup_instructions": {
|
||||||
|
"koreader": "Calibre URL: https://bookhoard.com/api/sync/koreader"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Open `auth_url` (or scan the QR code) while logged in to approve; the registration expires after 5 minutes. Poll `POST /api/devices/register/status` at `poll_interval` seconds until `status` is `approved`, at which point the response includes the device's `auth_token`, `device_id`, and `sync_endpoints`.
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Analytics GetTests
|
# Analytics GetTests
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Kobo analytics endpoint (device compatibility).
|
Kobo analytics endpoint (device compatibility).
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/kobo/v1/analytics/gettests`
|
**Endpoint**: `POST /api/sync/kobo/v1/analytics/gettests`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Bookmark Sync
|
# Bookmark Sync
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Sync bookmarks from Kobo device.
|
Sync bookmarks from Kobo device.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/kobo/bookmark`
|
**Endpoint**: `POST /api/sync/kobo/bookmark`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Kobo Initialization
|
# Kobo Initialization
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Initialize Kobo device sync.
|
Initialize Kobo device sync.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/sync/kobo/v1/initialization`
|
**Endpoint**: `GET /api/sync/kobo/v1/initialization`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Markup Sync
|
# Markup Sync
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Sync markup highlights and annotations from Kobo device.
|
Sync markup highlights and annotations from Kobo device.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/kobo/markup`
|
**Endpoint**: `POST /api/sync/kobo/markup`
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Sync From Server
|
# Sync From Server
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is not yet supported on real devices; this endpoint is under active development and may change.
|
||||||
|
|
||||||
Push content and metadata to Kobo device.
|
Push content and metadata to Kobo device.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/kobo/sync-from-server`
|
**Endpoint**: `POST /api/sync/kobo/sync-from-server`
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Resolve Book
|
||||||
|
|
||||||
|
Map a book's file SHA-256 to its Bookhoard UUID without touching progress
|
||||||
|
state. Used by devices to link a freshly downloaded book before their first
|
||||||
|
pull, so the device's first-page position is never pushed (which would
|
||||||
|
conflict with server-side progress for books already mid-read).
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sync/koreader/resolve`
|
||||||
|
**Auth**: Required (Device authentication)
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
| --------- | ------ | -------- | ------------------------------------ |
|
||||||
|
| sha256 | string | Yes | File content hash (64 hex characters) |
|
||||||
|
|
||||||
|
Resolution is format-aware: the hash is checked against both
|
||||||
|
`media_items.file_sha256` and `media_item_formats.file_sha256`, so a
|
||||||
|
converted file (KEPUB/PDF) matches its media item too.
|
||||||
|
|
||||||
|
## Device Authentication
|
||||||
|
|
||||||
|
This endpoint requires device authentication (not user JWT). Devices
|
||||||
|
authenticate using their device credentials.
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/sync/koreader/resolve?sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||||
|
Authorization: Bearer {device_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"book_uuid": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"title": "Book Title",
|
||||||
|
"author": "Author Name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | -------------------------------------------- |
|
||||||
|
| 400 | Missing or malformed `sha256` parameter |
|
||||||
|
| 401 | Device authentication failed |
|
||||||
|
| 404 | No book in the library matches the given hash |
|
||||||
@@ -1,49 +1,81 @@
|
|||||||
# Sync Bookmarks
|
# Sync Bookmarks
|
||||||
|
|
||||||
Sync bookmarks from KOReader device.
|
Sync bookmarks, notes, and highlights from a KOReader device (bidirectional — the response also returns the server's current state for the book so the device can reconcile).
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/koreader/bookmarks`
|
**Endpoint**: `POST /api/sync/koreader/bookmarks`
|
||||||
**Auth**: Required (Device authentication)
|
**Auth**: Device token (Bearer)
|
||||||
|
|
||||||
## Device Authentication
|
|
||||||
|
|
||||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
|
||||||
|
|
||||||
## Request Body
|
## Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| --------- | ------------- | -------- | ------------------------- |
|
| ------------ | ------ | --------------------- | ----------------------------------------------------------------- |
|
||||||
| device_id | string (UUID) | Yes | Device UUID |
|
| book_uuid | string | one of uuid/sha | Book UUID (highest-confidence match) |
|
||||||
| bookmarks | array | Yes | Array of bookmark objects |
|
| book_sha256 | string | one of uuid/sha | Full-file SHA-256 (64 hex chars); format-aware (also matches `media_item_formats`, so a KEPUB/PDF download matches) |
|
||||||
|
| bookmarks | array | No | Bookmark objects |
|
||||||
|
| notes | array | No | Note objects |
|
||||||
|
| highlights | array | No | Highlight objects |
|
||||||
|
|
||||||
### Bookmark Object
|
At least one of `book_uuid` or `book_sha256` is required; `book_sha256` resolves through the shared BookResolver.
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
### Bookmark / Note / Highlight Object
|
||||||
| ---------------- | ------- | -------- | -------------------------- |
|
|
||||||
| book | string | Yes | Book identifier |
|
All three types share the same KOReader annotation shape:
|
||||||
| chapter | string | No | Chapter title |
|
|
||||||
| page | integer | No | Page number |
|
| Field | Type | Required | Description |
|
||||||
| position | float | Yes | Position in document (0-1) |
|
| ------------ | ------- | -------- | ---------------------------------------------------- |
|
||||||
| notes | string | No | Bookmark notes |
|
| chapter | int | No | Chapter index |
|
||||||
| highlighted_text | string | No | Highlighted text |
|
| datetime | string | No | ISO 8601 creation/edit timestamp |
|
||||||
| time | string | Yes | ISO 8601 timestamp |
|
| pos0 / pos1 | string | No | Start/end xpointer (or `page:N` / bare page) |
|
||||||
| created_at | string | Yes | ISO 8601 timestamp |
|
| page | int | No | Page number (fallback location when `pos0` is empty) |
|
||||||
|
| text | string | No | Highlighted text |
|
||||||
|
| notes | string | No | Note text attached to the annotation |
|
||||||
|
| type | string | No | Annotation type (`highlight`, `note`, `bookmark`) |
|
||||||
|
| color | string | No | Highlight color (highlights only) — KOReader palette name, see below |
|
||||||
|
| percentage | float | No | Position within the book (0-1) |
|
||||||
|
| book_sha256 | string | No | Per-annotation SHA-256; overrides the request-level book match |
|
||||||
|
| dedup_key | string | No | Stable echo key; an entry whose content is unchanged from what the server previously served is recognized as an echo rather than a new edit |
|
||||||
|
|
||||||
|
### Color Semantics
|
||||||
|
|
||||||
|
KOReader paints highlights from a fixed palette of color names; the web reader uses hex swatches. Colors are mapped at the boundary (unmappable values fall back to yellow on both sides):
|
||||||
|
|
||||||
|
| KOReader name | Web hex |
|
||||||
|
| ------------- | --------- |
|
||||||
|
| yellow, orange | `#ffd54f` |
|
||||||
|
| green, olive | `#a5d6a7` |
|
||||||
|
| cyan, blue | `#90caf9` |
|
||||||
|
| purple | `#ce93d8` |
|
||||||
|
| red | `#f48fb1` |
|
||||||
|
|
||||||
|
- An echo (device re-reporting an annotation it received from the server) carries **no color**, so the stored web color is never clobbered.
|
||||||
|
- A non-empty color means the user edited the highlight on the device; it is mapped to the nearest web swatch.
|
||||||
|
|
||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_id": "550e8400-e29b-41d4-a716-446655440000",
|
"book_sha256": "64-hex-char-sha256",
|
||||||
"bookmarks": [
|
"bookmarks": [
|
||||||
{
|
{
|
||||||
"book": "book.epub",
|
"chapter": 3,
|
||||||
"chapter": "Chapter 1",
|
"datetime": "2026-08-20T10:00:00Z",
|
||||||
|
"pos0": "/body/Doc[4]/Sec[2]",
|
||||||
"page": 25,
|
"page": 25,
|
||||||
"position": 0.125,
|
"text": "",
|
||||||
"notes": "Important section",
|
"type": "bookmark",
|
||||||
"highlighted_text": "Text to remember",
|
"percentage": 0.125
|
||||||
"time": "2026-02-08T10:00:00Z",
|
}
|
||||||
"created_at": "2026-02-08T10:00:00Z"
|
],
|
||||||
|
"highlights": [
|
||||||
|
{
|
||||||
|
"datetime": "2026-08-20T10:05:00Z",
|
||||||
|
"pos0": "/body/Doc[4]/Sec[2]/text()[3]:0",
|
||||||
|
"pos1": "/body/Doc[4]/Sec[2]/text()[3]:42",
|
||||||
|
"text": "Text to remember",
|
||||||
|
"notes": "Why this matters",
|
||||||
|
"type": "highlight",
|
||||||
|
"color": "blue",
|
||||||
|
"dedup_key": "echo-key-from-server"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -53,15 +85,17 @@ This endpoint requires device authentication (not user JWT). Devices authenticat
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message": "Bookmarks synced successfully",
|
"sync_status": "ok",
|
||||||
"synced_count": 1
|
"bookmarks_synced": 1,
|
||||||
|
"notes_synced": 0,
|
||||||
|
"highlights_synced": 1
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | ---------------------------- |
|
| ---- | -------------------------------------------------- |
|
||||||
| 401 | Device authentication failed |
|
| 400 | Invalid request, or neither uuid nor SHA provided |
|
||||||
| 400 | Invalid request data |
|
| 401 | Missing/invalid device token |
|
||||||
| 404 | Device not found |
|
| 404 | Book not found by SHA-256 |
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Retrieve all libraries visible to the current user.
|
Retrieve all libraries visible to the current user.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/libraries/visible`
|
**Endpoint**: `GET /api/libraries/visibility`
|
||||||
**Auth**: Required
|
**Auth**: Required
|
||||||
|
|
||||||
## Request Headers
|
## Request Headers
|
||||||
@@ -14,26 +14,33 @@ Retrieve all libraries visible to the current user.
|
|||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/libraries/visible
|
GET /api/libraries/visibility
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK)
|
## Response (200 OK)
|
||||||
|
|
||||||
|
A top-level JSON **array** of library rows:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
[
|
||||||
"libraries": [
|
{
|
||||||
{
|
"id": "uuid",
|
||||||
"id": "uuid",
|
"name": "My Ebooks",
|
||||||
"name": "My Ebooks",
|
"description": "Ebook collection",
|
||||||
"description": "Ebook collection",
|
"library_type_id": "uuid",
|
||||||
"type_name": "ebooks",
|
"created_by_admin_id": "uuid",
|
||||||
"is_visible": true
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
}
|
"updated_at": "2026-01-31T10:00:00Z",
|
||||||
]
|
"type_name": "ebooks",
|
||||||
}
|
"type_description": "Ebook libraries",
|
||||||
|
"is_visible": true
|
||||||
|
}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Nullable columns (`description`, `type_description`) serialize as `null` when unset. Timestamps are RFC 3339.
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Deleted Annotations History
|
||||||
|
|
||||||
|
List, restore, or permanently delete tombstoned annotations (highlights,
|
||||||
|
notes, bookmarks) for a book. Deletions — from the web or propagated from a
|
||||||
|
synced device — are soft-deleted and retained for the sync retention window
|
||||||
|
(default 30 days), powering the book page's "Recently deleted" list. A
|
||||||
|
restore returns the row to the active set on every synced device; a purge
|
||||||
|
removes it immediately and irreversibly.
|
||||||
|
|
||||||
|
All endpoints require user JWT authentication and operate only on the
|
||||||
|
caller's own annotations.
|
||||||
|
|
||||||
|
## List Deleted Annotations
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/media-items/:id/annotations/deleted`
|
||||||
|
|
||||||
|
Returns tombstoned annotations for the book, newest deletion first.
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"deleted_annotations": [
|
||||||
|
{
|
||||||
|
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||||
|
"annotation_type": "highlight",
|
||||||
|
"display_text": "the chosen text",
|
||||||
|
"secondary_text": "user note",
|
||||||
|
"color": "#ffd54f",
|
||||||
|
"deleted_at": "2026-08-22T15:04:05Z",
|
||||||
|
"created_at": "2026-08-01T10:00:00Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
| --------------- | ------------------------------------------------------ |
|
||||||
|
| annotation_type | `highlight`, `note`, or `bookmark` |
|
||||||
|
| display_text | Highlighted text / note content / bookmark title |
|
||||||
|
| secondary_text | Note text (highlights) or notes field (bookmarks) |
|
||||||
|
|
||||||
|
## Restore Deleted Annotation
|
||||||
|
|
||||||
|
**Endpoint**: `POST /api/media-items/:id/annotations/:annotationId/restore`
|
||||||
|
|
||||||
|
Body (or query param) `annotation_type` must be `highlight`, `note`, or
|
||||||
|
`bookmark`. Clears the tombstone; the annotation reappears in the active
|
||||||
|
set and re-syncs to devices on their next pull.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "annotation_type": "highlight" }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "restored": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
404 when no matching *deleted* annotation exists for this user and book.
|
||||||
|
|
||||||
|
## Permanently Delete Annotation
|
||||||
|
|
||||||
|
**Endpoint**: `DELETE /api/media-items/:id/annotations/:annotationId?annotation_type=highlight|note|bookmark`
|
||||||
|
|
||||||
|
Removes the tombstoned row from the history immediately. Irreversible —
|
||||||
|
unlike the tombstone itself, which is restorable until the retention window
|
||||||
|
lapses and the daily maintenance sweep purges it.
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "purged": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
| ---- | -------------------------------------------------- |
|
||||||
|
| 400 | Invalid IDs or missing/unknown `annotation_type` |
|
||||||
|
| 401 | Not authenticated |
|
||||||
|
| 404 | No matching deleted annotation |
|
||||||
@@ -2,15 +2,19 @@
|
|||||||
|
|
||||||
Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/media-items/:uuid/download`
|
Book files are served by the authenticated file route, the same one the web reader uses. Build the URL from the media item's `library_id` and relative `file_path` (both returned by the media item list/get endpoints):
|
||||||
**Auth**: None (public endpoint for Kobo devices)
|
|
||||||
**Content-Type**: Binary file download
|
**Endpoint**: `GET /uploads/library-{library_id}/{file_path}`
|
||||||
|
**Auth**: Required (JWT - Bearer header or session cookie)
|
||||||
|
|
||||||
|
The `file_path` segments are URL-escaped individually; slashes are preserved. `cover_image_path` uses the same route.
|
||||||
|
|
||||||
## Path Parameters
|
## Path Parameters
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
| --------- | ------ | -------- | --------------- |
|
| ----------- | ------ | -------- | ------------------------------------ |
|
||||||
| uuid | string | Yes | Media item UUID |
|
| library_id | string | Yes | Library UUID (the item's library) |
|
||||||
|
| file_path | string | Yes | The item's relative `file_path` |
|
||||||
|
|
||||||
## Response
|
## Response
|
||||||
|
|
||||||
@@ -18,25 +22,27 @@ Download a media item file (EPUB, PDF, etc.) from the Bookhoard server.
|
|||||||
|
|
||||||
**Response Headers**:
|
**Response Headers**:
|
||||||
|
|
||||||
- `Content-Type`: `application/epub+zip`, `application/pdf`, or appropriate MIME type
|
- `Content-Type`: MIME type by file extension (`application/epub+zip`, `application/pdf`, …; `application/octet-stream` fallback)
|
||||||
- `Content-Disposition`: `attachment; filename="filename.epub"`
|
- `Cache-Control`: `public, max-age=86400`
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | --------------------------------- |
|
| ---- | --------------------------- |
|
||||||
| 404 | Media item not found |
|
| 400 | Invalid library ID or path |
|
||||||
| 500 | Server error during file download |
|
| 401 | Missing/invalid token |
|
||||||
|
| 404 | File not found on disk |
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -O http://localhost:8765/api/media-items/550e8400-e29b-41d4-a716-446655440000/download
|
curl -O -H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:8765/uploads/library/550e8400-.../books/1984.epub"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
(URL shape: `/uploads/library-{uuid}/{escaped-relative-path}`.)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- **Public endpoint**: No authentication required for Kobo device downloads
|
- **Do not rely on `GET /api/media-items/:id/download`** — it appears in older docs but is **not registered**; `MediaHandler.DownloadBook` exists as dead code. Use the file route above.
|
||||||
- **File format**: Returns the original file format (EPUB, PDF, etc.)
|
- OPDS-capable devices may alternatively use the device-authenticated `GET /opds/devices/{deviceId}/download/{bookId}`, which supports on-the-fly format conversion (epub, kepub, pdf, cbz).
|
||||||
- **Kobo integration**: Designed for direct downloads from Kobo e-readers
|
|
||||||
- **Cover images**: Use `/api/media-items/:uuid/cover` for cover images
|
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
# List Media Items
|
# List Media Items
|
||||||
|
|
||||||
Retrieve a paginated list of media items from a library.
|
Retrieve a paginated list of media items, scoped to a library or across all libraries.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/media-items`
|
**Endpoint**: `GET /api/media-items`
|
||||||
**Auth**: Required
|
**Auth**: Required
|
||||||
|
|
||||||
## Query Parameters
|
## Query Parameters
|
||||||
|
|
||||||
| Parameter | Type | Required | Description |
|
| Parameter | Type | Required | Description |
|
||||||
| ---------- | ------- | -------- | ----------------------------------------------- |
|
| ---------- | ------ | -------- | ------------------------------------------------------ |
|
||||||
| library_id | string | Yes | Library UUID |
|
| library_id | string | No | Library UUID. If omitted, items from all libraries are returned |
|
||||||
| limit | integer | No | Number of items to return (max 100, default 20) |
|
| limit | int | No | Items to return (default 50, max 1000) |
|
||||||
| offset | integer | No | Number of items to skip |
|
| offset | int | No | Items to skip (must be >= 0) |
|
||||||
|
| sort | string | No | Sort expression, default `created_at DESC` |
|
||||||
|
|
||||||
|
### Allowed sort expressions
|
||||||
|
|
||||||
|
`created_at`, `title`, `author`, `series`, `date_published`, `copyright_year`, `page_count`, `genre` — each with ` ASC` or ` DESC` (e.g. `title ASC`). Any other value silently falls back to `created_at DESC`.
|
||||||
|
|
||||||
## Request Headers
|
## Request Headers
|
||||||
|
|
||||||
@@ -22,46 +27,121 @@ Retrieve a paginated list of media items from a library.
|
|||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/media-items?library_id=uuid&limit=20&offset=0
|
GET /api/media-items?library_id=uuid&limit=20&offset=0&sort=title%20ASC
|
||||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK)
|
## Response (200 OK)
|
||||||
|
|
||||||
|
The response body is `{"data": [...]}` in both modes. The item shape differs by mode.
|
||||||
|
|
||||||
|
**No total is returned** — page until fewer items than `limit` come back.
|
||||||
|
|
||||||
|
### With `library_id` — full database rows
|
||||||
|
|
||||||
|
Nullable columns serialize as `null`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"media_items": [
|
"data": [
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"library_id": "uuid",
|
"library_id": "uuid",
|
||||||
"title": "Book Title",
|
"title": "Book Title",
|
||||||
"author": "Author Name",
|
"author": "Author Name",
|
||||||
|
"isbn": "978-...",
|
||||||
"description": "Book description",
|
"description": "Book description",
|
||||||
"file_path": "/path/to/book.epub",
|
"file_path": "relative/path/book.epub",
|
||||||
"file_size": 1024000,
|
"file_size": 1024000,
|
||||||
"mime_type": "application/epub+zip",
|
"mime_type": "application/epub+zip",
|
||||||
"cover_image_path": "/path/to/cover.jpg",
|
"cover_image_path": "relative/path/cover.jpg",
|
||||||
"series": "Series Name",
|
"series": "Series Name",
|
||||||
"series_number": 1,
|
"series_number": 1,
|
||||||
"tags": ["sci-fi", "space opera"],
|
"tags": ["sci-fi"],
|
||||||
"tags_search": ["sci fi", "space opera"],
|
"asin": null,
|
||||||
"contributors": ["Author Name", "ACME CORP."],
|
"date_published": "2023-06-01",
|
||||||
"contributors_search": ["author name", "acme corp"],
|
"publisher": null,
|
||||||
|
"contributors": ["Author Name"],
|
||||||
"language": "en",
|
"language": "en",
|
||||||
|
"edition": null,
|
||||||
"page_count": 350,
|
"page_count": 350,
|
||||||
"genre": "Science Fiction",
|
"genre": "Science Fiction",
|
||||||
"copyright_year": 2023,
|
"copyright_year": 2023,
|
||||||
"created_at": "2026-01-31T10:00:00Z"
|
"goodreads_id": null,
|
||||||
|
"openlibrary_id": null,
|
||||||
|
"google_books_id": null,
|
||||||
|
"added_by_admin_id": "uuid",
|
||||||
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
|
"imported_at": "2026-01-31T10:00:00Z",
|
||||||
|
"updated_at": "2026-01-31T10:00:00Z",
|
||||||
|
"format_group": "epub",
|
||||||
|
"format_mimetype": "application/epub+zip",
|
||||||
|
"is_reflowable": true,
|
||||||
|
"has_fixed_layout": false,
|
||||||
|
"total_characters": 480000,
|
||||||
|
"chapter_count": 24
|
||||||
}
|
}
|
||||||
],
|
]
|
||||||
"total": 100
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: in this mode `file_path` and `cover_image_path` are the raw relative storage paths, not URLs.
|
||||||
|
|
||||||
|
### Without `library_id` — curated items with resolved URLs
|
||||||
|
|
||||||
|
Across all libraries; file and cover paths are resolved to fetchable URL paths (`/uploads/...` or library-scoped paths):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"library_id": "uuid",
|
||||||
|
"title": "Book Title",
|
||||||
|
"author": "Author Name",
|
||||||
|
"isbn": "978-...",
|
||||||
|
"description": "Book description",
|
||||||
|
"file_path": "/api/libraries/<uuid>/files/...",
|
||||||
|
"file_size": 1024000,
|
||||||
|
"mime_type": "application/epub+zip",
|
||||||
|
"cover_image_path": "/api/libraries/<uuid>/files/.../cover.jpg",
|
||||||
|
"series": "Series Name",
|
||||||
|
"series_number": 1,
|
||||||
|
"tags": ["sci-fi"],
|
||||||
|
"asin": null,
|
||||||
|
"date_published": "2023-06-01",
|
||||||
|
"publisher": null,
|
||||||
|
"contributors": ["Author Name"],
|
||||||
|
"language": "en",
|
||||||
|
"edition": null,
|
||||||
|
"page_count": 350,
|
||||||
|
"genre": "Science Fiction",
|
||||||
|
"created_at": "2026-01-31T10:00:00Z",
|
||||||
|
"updated_at": "2026-01-31T10:00:00Z",
|
||||||
|
"format_group": "epub",
|
||||||
|
"manga_type": null,
|
||||||
|
"reading_direction": null,
|
||||||
|
"series_count": null,
|
||||||
|
"volume": null,
|
||||||
|
"imprint": null,
|
||||||
|
"age_rating": null,
|
||||||
|
"web_url": null,
|
||||||
|
"metadata_notes": null,
|
||||||
|
"community_rating": null,
|
||||||
|
"story_arc": null,
|
||||||
|
"is_black_and_white": false,
|
||||||
|
"alternate_info": null,
|
||||||
|
"scan_information": null,
|
||||||
|
"summary": null
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | ----------------------------------------- |
|
| ---- | ------------------------------------------ |
|
||||||
| 400 | Invalid query parameters |
|
| 400 | Invalid `library_id`, `offset` < 0 |
|
||||||
| 401 | Invalid or expired token |
|
| 401 | Invalid or expired token |
|
||||||
| 403 | User does not have access to this library |
|
| 500 | Query failure (returned as `{"error": …}`) |
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# Kobo Sync Protocol
|
# Kobo Sync Protocol
|
||||||
|
|
||||||
|
> **Status: Coming Soon** — Native Kobo sync is implemented server-side but not yet supported on real devices. These endpoints are under active development and may change. Until then, KOReader (which runs on Kobo hardware) is fully supported.
|
||||||
|
|
||||||
Kobo uses a proprietary sync protocol with JSON payloads.
|
Kobo uses a proprietary sync protocol with JSON payloads.
|
||||||
|
|
||||||
## Kobo Markup Sync
|
## Kobo Markup Sync
|
||||||
|
|||||||
@@ -17,20 +17,31 @@ KOReader uses a custom JSON-based sync protocol.
|
|||||||
|
|
||||||
### Request Body
|
### Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ------------------ | ------- | -------- | ----------------------------- |
|
| ------------------ | ------- | -------- | ---------------------------------------------------- |
|
||||||
| library_id | string | No | Library UUID |
|
| library_id | string | No | Library UUID |
|
||||||
| books | array | Yes | Array of book sync data |
|
| books | array | Yes | Array of book sync data |
|
||||||
| books[].uuid | string | Yes | Book UUID |
|
| books[].uuid | string | No\* | Book UUID (highest-confidence match; omitted on first sync of a newly downloaded book) |
|
||||||
| books[].title | string | Yes | Book title |
|
| books[].sha256 | string | No\* | Full-file SHA-256 (64 hex chars); used to resolve the book when `uuid` is absent |
|
||||||
| books[].authors | array | Yes | Array of author names |
|
| books[].file_path | string | No | Device-local file path; used to create/look up a device file alias |
|
||||||
| books[].progress | float | Yes | Progress percentage (0-1) |
|
| books[].title | string | Yes | Book title |
|
||||||
| books[].percentage | float | Yes | Progress percentage (0-1) |
|
| books[].authors | array | Yes | Array of author names |
|
||||||
| books[].last_read | string | Yes | ISO 8601 timestamp |
|
| books[].progress | float | Yes | Progress percentage (0-1) |
|
||||||
| books[].chapter | integer | No | Current chapter |
|
| books[].percentage | float | Yes | Progress percentage (0-1) |
|
||||||
| books[].epubcfi | string | No | EPUB CFI location |
|
| books[].last_read | string | Yes | ISO 8601 timestamp |
|
||||||
| books[].character | integer | No | Character offset |
|
| books[].chapter | integer | No | Current chapter |
|
||||||
| books[].bookmarks | array | No | Array of bookmarks/highlights |
|
| books[].epubcfi | string | No | EPUB CFI location |
|
||||||
|
| books[].character | integer | No | Character offset |
|
||||||
|
| books[].bookmarks | array | No | Array of bookmarks/highlights (shape, color mapping, and echo/dedup rules: see [Sync Bookmarks](../koreader/sync_bookmarks.md)) |
|
||||||
|
| books[].deleted_highlights | array | No | Highlights deleted on the device: `[{ "dedup_key": "..." }]` — keys previously served to this device (see [Deletion propagation](#deletion-propagation)) |
|
||||||
|
| books[].deleted_bookmarks | array | No | Bookmarks deleted on the device: `[{ "dedup_key": "..." }]` |
|
||||||
|
|
||||||
|
\* At least one of `uuid` or `sha256` should be present. The server resolves the
|
||||||
|
book through the shared `BookResolver` with this priority: `uuid` → `sha256` →
|
||||||
|
`file_path` alias → `title`/`author`. SHA-256 matching is **format-aware**: it
|
||||||
|
checks `media_items.file_sha256` first, then `media_item_formats.file_sha256`, so
|
||||||
|
a converted file (e.g. KEPUB or PDF) downloaded via OPDS matches even though its
|
||||||
|
hash differs from the primary format's hash.
|
||||||
|
|
||||||
### Example Request
|
### Example Request
|
||||||
|
|
||||||
@@ -85,6 +96,41 @@ KOReader uses a custom JSON-based sync protocol.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Book Resolution (UUID lookup)
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/sync/koreader/resolve?sha256={hash}`
|
||||||
|
**Auth**: Device token required
|
||||||
|
|
||||||
|
Read-only lookup mapping a file SHA-256 to the book's UUID (format-aware,
|
||||||
|
same `BookResolver` path as the progress push). Devices call this on the
|
||||||
|
first open of a newly downloaded book to learn the UUID **before** their
|
||||||
|
first pull. Full details: [Resolve Book](../koreader/resolve_book.md).
|
||||||
|
|
||||||
|
This matters for conflict avoidance: a device that pushes to bootstrap its
|
||||||
|
identity transmits its current (first-page) position, which the server
|
||||||
|
treats as a real progress update — overwriting/conflicting with genuine
|
||||||
|
mid-read progress from other sources. Resolve, then pull, then push.
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/sync/koreader/resolve?sha256=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||||
|
Authorization: Bearer device-token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response (200 OK)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"book_uuid": "book-uuid",
|
||||||
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
|
"title": "Book Title",
|
||||||
|
"author": "Author Name"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
404 when no book in the library matches the hash.
|
||||||
|
|
||||||
## KOReader Metadata Fetch
|
## KOReader Metadata Fetch
|
||||||
|
|
||||||
**Endpoint**: `GET /api/sync/koreader/metadata/{book_uuid}`
|
**Endpoint**: `GET /api/sync/koreader/metadata/{book_uuid}`
|
||||||
@@ -102,6 +148,7 @@ Authorization: Bearer device-token
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"uuid": "book-uuid",
|
"uuid": "book-uuid",
|
||||||
|
"sha256": "ff3e4501bf9d72dea2ae28731a6cb5b83d7a7532c05b5d2dd083d0dbc9193ebf",
|
||||||
"title": "Book Title",
|
"title": "Book Title",
|
||||||
"authors": ["Author Name"],
|
"authors": ["Author Name"],
|
||||||
"progress": {
|
"progress": {
|
||||||
@@ -119,3 +166,44 @@ Authorization: Bearer device-token
|
|||||||
"last_sync": "2026-01-30T20:00:00Z"
|
"last_sync": "2026-01-30T20:00:00Z"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`sha256` is the canonical primary-format hash of the book on the server. It is
|
||||||
|
returned so clients can cache it regardless of how the book was originally
|
||||||
|
obtained. The library list endpoint (`GET /api/sync/koreader/library`) includes
|
||||||
|
the same `sha256` field on each book.
|
||||||
|
|
||||||
|
## Deletion propagation
|
||||||
|
|
||||||
|
The progress push is upsert-only: absence of an annotation from
|
||||||
|
`highlights`/`notes`/`bookmarks` is **never** interpreted as a delete (a
|
||||||
|
client with a category disabled must not wipe the server). Deletions are
|
||||||
|
reported explicitly:
|
||||||
|
|
||||||
|
- Devices remember the `dedup_key` of every annotation the server served
|
||||||
|
them (persisted locally, e.g. KOReader's sidecar `bookhoard_known_keys`).
|
||||||
|
- When one of those annotations no longer exists locally, the next push
|
||||||
|
lists its key in `deleted_highlights` / `deleted_bookmarks`.
|
||||||
|
- The server tombstones the matching rows (`deleted = TRUE`, kept for the
|
||||||
|
retention window). Tombstones are served back to *other* devices via the
|
||||||
|
metadata fetch's `deleted_highlights` / `deleted_bookmarks` arrays so the
|
||||||
|
deletion converges everywhere.
|
||||||
|
- A stale replay pushing the annotation's content cannot resurrect the
|
||||||
|
tombstone: device pushes carry no modification timestamp, so the save is
|
||||||
|
treated as older than the delete.
|
||||||
|
- Restoring is possible from the web book page's deleted-annotation
|
||||||
|
history (`GET /api/media-items/:id/annotations/deleted`, restore/purge
|
||||||
|
endpoints) until the retention window lapses.
|
||||||
|
|
||||||
|
Because keys are only learned from server pulls, a device-native annotation
|
||||||
|
deleted locally is simply never pushed again — it can never be mis-flagged
|
||||||
|
as a server annotation deletion.
|
||||||
|
|
||||||
|
## Book identification
|
||||||
|
|
||||||
|
Every client/sync interface (KOReader, Kobo, OPDS, the device-link UI, and any
|
||||||
|
future mobile app) resolves books through a single shared service:
|
||||||
|
[`internal/services/book_resolver.go`](../../../internal/services/book_resolver.go).
|
||||||
|
The import-time SHA-256 (stored on `media_items.file_sha256`, plus a per-format
|
||||||
|
hash on `media_item_formats.file_sha256` for KEPUB/PDF) is the canonical shared
|
||||||
|
identifier. New clients should resolve by SHA-256 via `BookResolver` rather than
|
||||||
|
re-implementing their own matcher.
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# System Config API
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Raw key/value system configuration storage (backed by the `system_config` table). Unlike the typed [System Settings API](settings.md), this endpoint reads and writes arbitrary config keys as plain strings — including keys without registry metadata, such as `base_url`.
|
||||||
|
|
||||||
|
**Base URL**: `/api/system`
|
||||||
|
**Authentication**: Admin JWT token required
|
||||||
|
**Content-Type**: `application/json`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Get System Configuration
|
||||||
|
|
||||||
|
Retrieve all system configuration entries as a flat key/value map.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/system/config`
|
||||||
|
|
||||||
|
**Authentication**: Admin role required
|
||||||
|
|
||||||
|
**Response**: **200 OK**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base_url": "http://192.168.1.100:8765",
|
||||||
|
"default_timezone": "America/New_York"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET https://bookhoard.example.com/api/system/config \
|
||||||
|
-H "Authorization: Bearer <admin_token>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Update System Configuration
|
||||||
|
|
||||||
|
Update one or more config values.
|
||||||
|
|
||||||
|
**Endpoint**: `PUT /api/system/config`
|
||||||
|
|
||||||
|
**Authentication**: Admin role required
|
||||||
|
|
||||||
|
**Request Body**: a flat map of keys to string values. Only the supplied keys are updated.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"base_url": "https://bookhoard.example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation**: values for known keys are validated where applicable — for example, `default_timezone` must be a valid IANA timezone (`time.LoadLocation`); invalid values return `400` without persisting.
|
||||||
|
|
||||||
|
**Response**: **200 OK** on success; `400` (invalid value/format), `401`, `403`, `500` on failure.
|
||||||
|
|
||||||
|
**Example**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT https://bookhoard.example.com/api/system/config \
|
||||||
|
-H "Authorization: Bearer <admin_token>" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"base_url": "https://bookhoard.example.com"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** settings that appear in the typed settings registry (e.g. `default_timezone`) are better managed through [`PUT /api/system/settings`](settings.md), which also returns metadata and reload hints. Writes through either endpoint refresh the shared registry cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Endpoints
|
||||||
|
|
||||||
|
- [System Settings API](settings.md) — typed, validated tunable settings with metadata
|
||||||
|
- `GET /api/devices/:id/sidecar` — device setup config derived from system config (see [Devices API](../devices/))
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
# System Scan Settings API
|
# System Settings API
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The System Scan Settings API allows administrators to configure system-wide scan settings that apply to all libraries. These settings control the automatic scanning behavior for the entire Bookhoard system.
|
The System Settings API is the canonical way to read and write Bookhoard's tunable system settings (scanning, security, rate limits, sync, performance, and defaults). Every setting carries full metadata — type, range, category, description, and whether a restart is required — so the admin UI (and API clients) can render and validate settings generically.
|
||||||
|
|
||||||
**Base URL**: `/api/libraries`
|
**Base URL**: `/api/system`
|
||||||
**Authentication**: Admin JWT token required
|
**Authentication**: Admin JWT token required
|
||||||
**Content-Type**: `application/json`
|
**Content-Type**: `application/json`
|
||||||
|
|
||||||
@@ -12,49 +12,61 @@ The System Scan Settings API allows administrators to configure system-wide scan
|
|||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
### Get System Scan Settings
|
### List All Settings
|
||||||
|
|
||||||
Retrieve the current system-wide scan settings.
|
Retrieve every known tunable setting with its current value and metadata.
|
||||||
|
|
||||||
**Endpoint**: `GET /api/libraries/scan-settings`
|
**Endpoint**: `GET /api/system/settings`
|
||||||
|
|
||||||
**Authentication**: Admin role required
|
**Authentication**: Admin role required
|
||||||
|
|
||||||
**Response**:
|
**Response**: **200 OK**
|
||||||
|
|
||||||
- **200 OK**: Returns current scan settings
|
|
||||||
- **401 Unauthorized**: Invalid or missing authentication
|
|
||||||
- **403 Forbidden**: User does not have admin role
|
|
||||||
- **500 Internal Server Error**: Server error
|
|
||||||
|
|
||||||
**Response Body**:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
[
|
||||||
"scan_poll_interval_seconds": 60,
|
{
|
||||||
"auto_scan_enabled": true
|
"key": "scan_poll_interval_seconds",
|
||||||
}
|
"value": "60",
|
||||||
|
"type": "int",
|
||||||
|
"min": "1",
|
||||||
|
"max": "3600",
|
||||||
|
"requires_restart": false,
|
||||||
|
"category": "scanner",
|
||||||
|
"group": "Scanning",
|
||||||
|
"description": "How often to scan all libraries (seconds)",
|
||||||
|
"is_default": true
|
||||||
|
}
|
||||||
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fields**:
|
**Entry fields**:
|
||||||
|
|
||||||
- `scan_poll_interval_seconds` (integer): How often to poll for file changes in seconds (1-3600)
|
| Field | Type | Description |
|
||||||
- `auto_scan_enabled` (boolean): Whether auto-scanning is enabled system-wide
|
| ------------------ | ------- | -------------------------------------------------------- |
|
||||||
|
| `key` | string | Setting identifier (stable API name) |
|
||||||
|
| `value` | string | Current value (validated/clamped by the registry) |
|
||||||
|
| `type` | string | `int`, `bool`, or `string` |
|
||||||
|
| `min` / `max` | string | Range bounds for `int` settings (omitted otherwise) |
|
||||||
|
| `requires_restart` | boolean | Change takes effect only after a server restart |
|
||||||
|
| `category` | string | Coarse area: `scanner`, `security`, `api`, `sync`, `performance`, `general` |
|
||||||
|
| `group` | string | Sub-section shown in the admin UI |
|
||||||
|
| `description` | string | Human-readable description |
|
||||||
|
| `is_default` | boolean | True when the current value equals the compiled default |
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X GET https://bookhoard.example.com/api/libraries/scan-settings \
|
curl -X GET https://bookhoard.example.com/api/system/settings \
|
||||||
-H "Authorization: Bearer <admin_token>"
|
-H "Authorization: Bearer <admin_token>"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Update System Scan Settings
|
### Update a Setting
|
||||||
|
|
||||||
Update the system-wide scan settings.
|
Validate, persist, and reload a single setting.
|
||||||
|
|
||||||
**Endpoint**: `PUT /api/libraries/scan-settings`
|
**Endpoint**: `PUT /api/system/settings`
|
||||||
|
|
||||||
**Authentication**: Admin role required
|
**Authentication**: Admin role required
|
||||||
|
|
||||||
@@ -62,128 +74,123 @@ Update the system-wide scan settings.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"scan_poll_interval_seconds": 30,
|
"key": "scan_poll_interval_seconds",
|
||||||
"auto_scan_enabled": true
|
"value": "30"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fields**:
|
| Field | Type | Required | Description |
|
||||||
|
| ------- | ------ | -------- | ------------------------------- |
|
||||||
|
| `key` | string | Yes | Setting key (from the list) |
|
||||||
|
| `value` | string | Yes | New value, as a string |
|
||||||
|
|
||||||
- `scan_poll_interval_seconds` (integer, required): How often to poll for file changes in seconds
|
**Response**: **200 OK**
|
||||||
- Minimum: 1 (1 second)
|
|
||||||
- Maximum: 3600 (1 hour)
|
|
||||||
- Default: 60
|
|
||||||
- `auto_scan_enabled` (boolean, required): Whether auto-scanning is enabled system-wide
|
|
||||||
- Default: true
|
|
||||||
|
|
||||||
**Response**:
|
|
||||||
|
|
||||||
- **200 OK**: Settings updated successfully
|
|
||||||
- **400 Bad Request**: Invalid request parameters
|
|
||||||
- **401 Unauthorized**: Invalid or missing authentication
|
|
||||||
- **403 Forbidden**: User does not have admin role
|
|
||||||
- **500 Internal Server Error**: Server error
|
|
||||||
|
|
||||||
**Success Response Body**:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"scan_poll_interval_seconds": 30,
|
"key": "scan_poll_interval_seconds",
|
||||||
"auto_scan_enabled": true,
|
"value": "30",
|
||||||
"message": "scan settings updated successfully"
|
"type": "int",
|
||||||
|
"min": "1",
|
||||||
|
"max": "3600",
|
||||||
|
"requires_restart": false,
|
||||||
|
"category": "scanner",
|
||||||
|
"group": "Scanning",
|
||||||
|
"description": "How often to scan all libraries (seconds)",
|
||||||
|
"is_default": false,
|
||||||
|
"reload_required": false,
|
||||||
|
"message": ""
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Error Response Body**:
|
- `reload_required: true` means the change takes effect only after a restart (e.g. rate limits, worker pool, lockout settings).
|
||||||
|
- Validation is type-aware: `int` values are checked against `min`/`max`, `bool` values must parse, `default_timezone` must be a valid IANA timezone via `time.LoadLocation`, and strings must be non-empty.
|
||||||
|
|
||||||
```json
|
**Errors**: `400` (unknown key, invalid value, out of range), `401`, `403`, `503` (settings registry not initialized).
|
||||||
{
|
|
||||||
"error": "error message"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Validation Rules**:
|
|
||||||
|
|
||||||
- `scan_poll_interval_seconds` must be between 1 and 3600 seconds (1 second to 1 hour)
|
|
||||||
- Both fields are required
|
|
||||||
|
|
||||||
**Example**:
|
**Example**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X PUT https://bookhoard.example.com/api/libraries/scan-settings \
|
curl -X PUT https://bookhoard.example.com/api/system/settings \
|
||||||
-H "Authorization: Bearer <admin_token>" \
|
-H "Authorization: Bearer <admin_token>" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{"key": "scan_poll_interval_seconds", "value": "30"}'
|
||||||
"scan_poll_interval_seconds": 30,
|
|
||||||
"auto_scan_enabled": true
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Behavior
|
## Setting Catalog
|
||||||
|
|
||||||
### Poll Interval
|
Current tunable settings by category:
|
||||||
|
|
||||||
The `scan_poll_interval_seconds` setting determines how often the system will poll library folders for file changes as a fallback to real-time file watching.
|
**Scanner** (`scanner`)
|
||||||
|
|
||||||
**Constraints**:
|
| Key | Default | Range | Restart | Description |
|
||||||
|
| ------------------------------ | ------- | -------- | ------- | ----------------------------------------- |
|
||||||
|
| `scan_poll_interval_seconds` | `60` | 1-3600 | No | How often to scan all libraries (seconds) |
|
||||||
|
| `auto_scan_enabled` | `true` | - | No | Whether auto-scanning is enabled |
|
||||||
|
|
||||||
- Minimum: 1 second
|
**General** (`general`)
|
||||||
- Maximum: 3600 seconds (1 hour)
|
|
||||||
- Default: 60 seconds
|
|
||||||
|
|
||||||
### Auto-Scan Toggle
|
| Key | Default | Restart | Description |
|
||||||
|
| ----------------- | ------- | ------- | ------------------------- |
|
||||||
|
| `default_timezone`| `UTC` | No | System default timezone |
|
||||||
|
|
||||||
The `auto_scan_enabled` setting acts as a master switch for automatic scanning:
|
**Security** (`security`)
|
||||||
|
|
||||||
- When `true`: File watching and polling fallback are active for all libraries
|
| Key | Default | Range | Restart | Description |
|
||||||
- When `false`: No automatic file monitoring occurs (manual scans still available)
|
| ---------------------------- | --------- | ------------ | ------- | ---------------------------------------------- |
|
||||||
|
| `session_duration_seconds` | `604800` | 300-31536000 | No | How long a login session stays valid |
|
||||||
|
| `password_min_length` | `8` | 1-128 | No | Minimum password length |
|
||||||
|
| `password_require_upper` | `true` | - | No | Require at least one uppercase letter |
|
||||||
|
| `password_require_lower` | `true` | - | No | Require at least one lowercase letter |
|
||||||
|
| `password_require_number` | `true` | - | No | Require at least one number |
|
||||||
|
| `password_require_special` | `true` | - | No | Require at least one special character |
|
||||||
|
| `auth_rate_limit_per_min` | `10` | 1-10000 | **Yes** | Global auth API rate limit (req/min) |
|
||||||
|
| `login_max_attempts` | `5` | 1-100 | **Yes** | Failed login attempts before lockout |
|
||||||
|
| `login_lockout_minutes` | `15` | 1-10080 | **Yes** | Lockout duration after failed logins |
|
||||||
|
|
||||||
### File Watching System
|
**API** (`api`)
|
||||||
|
|
||||||
The scan settings control the file watching system which consists of:
|
| Key | Default | Range | Restart | Description |
|
||||||
|
| ------------------------------- | ------- | --------- | ------- | ------------------------------------ |
|
||||||
|
| `opds_default_page_size` | `50` | 1-500 | No | Default OPDS page size |
|
||||||
|
| `opds_max_page_size` | `200` | 1-1000 | No | Maximum OPDS page size |
|
||||||
|
| `device_rate_sync_per_min` | `60` | 1-10000 | No | Device sync requests per minute |
|
||||||
|
| `device_rate_progress_per_min` | `120` | 1-10000 | No | Device progress requests per minute |
|
||||||
|
| `device_rate_metadata_per_min` | `30` | 1-10000 | No | Device metadata requests per minute |
|
||||||
|
|
||||||
1. **Real-time file watching**: Uses fsnotify to detect file changes immediately
|
**Sync** (`sync`)
|
||||||
2. **Polling fallback**: If file watching fails or is unavailable, polls folders at the configured interval
|
|
||||||
|
|
||||||
The system applies these settings to all configured libraries automatically on startup.
|
| Key | Default | Range | Restart | Description |
|
||||||
|
| ------------------------------- | ------- | -------- | ------- | -------------------------------------------------- |
|
||||||
|
| `annotation_tombstone_ttl_days` | `30` | 1-3650 | No | How long deleted annotations are kept before purge |
|
||||||
|
| `sync_queue_interval_seconds` | `5` | 1-3600 | **Yes** | How often the sync queue flushes |
|
||||||
|
| `sync_queue_batch_size` | `50` | 1-10000 | **Yes** | Max items processed per sync queue flush |
|
||||||
|
|
||||||
|
**Performance** (`performance`)
|
||||||
|
|
||||||
|
| Key | Default | Range | Restart | Description |
|
||||||
|
| ------------------------ | ------- | --------- | ------- | ------------------------------------------- |
|
||||||
|
| `conversion_cache_ttl_hours` | `24` | 1-720 | No | How long converted (KEPUB) files are cached |
|
||||||
|
| `worker_pool_size` | `3` | 1-100 | **Yes** | Number of background worker goroutines |
|
||||||
|
| `worker_queue_cap` | `100` | 1-10000 | **Yes** | Background worker job queue capacity |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Error Codes
|
## Legacy Scan Settings Routes
|
||||||
|
|
||||||
| Status Code | Error Description |
|
The older JSON routes still work for backward compatibility and now refresh the settings registry cache on write, but they are **superseded** by `GET/PUT /api/system/settings`:
|
||||||
| ----------- | ---------------------------------------------------------- |
|
|
||||||
| 400 | Invalid request parameters (e.g., frequency outside range) |
|
- `GET /api/libraries/scan-settings` — returns only `scan_poll_interval_seconds` and `auto_scan_enabled`
|
||||||
| 401 | Missing or invalid JWT token |
|
- `PUT /api/libraries/scan-settings` — accepts `{ "scan_poll_interval_seconds": int, "auto_scan_enabled": bool }`
|
||||||
| 403 | User lacks admin role |
|
|
||||||
| 500 | Internal server error (e.g., database connection issue) |
|
Both fields are backed by the same registry entries documented above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Related Endpoints
|
## Related Endpoints
|
||||||
|
|
||||||
- `POST /api/libraries/{id}/scan` - Manually trigger a scan for a specific library (admin only)
|
- `GET/PUT /api/system/config` — raw key/value system configuration (see [System Config API](config.md))
|
||||||
- `GET /api/libraries` - List all libraries
|
- `POST /api/scanner/scan` — trigger a manual scan (see [Scanner API](../scanner/))
|
||||||
- `GET /api/libraries/{id}` - Get details for a specific library
|
- `GET /api/admin/hash-conflicts` — duplicates found during hashing (see [Hash Conflicts API](../admin/hash-conflicts.md))
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
This API has been updated to use a new polling-based scanning system. The following changes were made:
|
|
||||||
|
|
||||||
- **Changed**: `scan_frequency_minutes` renamed to `scan_poll_interval_seconds`
|
|
||||||
- **Changed**: Unit changed from minutes to seconds (15-1440 minutes → 1-3600 seconds)
|
|
||||||
- **Removed**: Old scheduler-based scanning system
|
|
||||||
- **Added**: Real-time file watching with polling fallback
|
|
||||||
- **Preserved**: API endpoint paths remain the same
|
|
||||||
|
|
||||||
The new system ensures that:
|
|
||||||
|
|
||||||
1. File changes are detected in real-time when possible (via fsnotify)
|
|
||||||
2. Polling fallback catches missed events at the configured interval
|
|
||||||
3. Settings apply to all libraries system-wide
|
|
||||||
4. Only administrators can modify scan settings
|
|
||||||
5. The `auto_scan_enabled` setting controls both file watching and polling
|
|
||||||
|
|||||||
+14
-14
@@ -11,8 +11,8 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
**[User Documentation Portal](user/user-guide.md)** - Guides for using Bookhoard features
|
**[User Documentation Portal](user/user-guide.md)** - Guides for using Bookhoard features
|
||||||
|
|
||||||
- **Device Setup**
|
- **Device Setup**
|
||||||
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Complete Kobo e-reader configuration
|
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
|
||||||
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook
|
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
|
||||||
|
|
||||||
- **Sync Configuration**
|
- **Sync Configuration**
|
||||||
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
- [Universal Sync Guide](user/sync-guide.md) - Understanding sync, book matching, conflicts
|
||||||
@@ -38,12 +38,12 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
- [Queue API](developer/api/queue/) - Sync queue management endpoints
|
- [Queue API](developer/api/queue/) - Sync queue management endpoints
|
||||||
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
|
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
|
||||||
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints
|
- [KOReader API](developer/api/koreader/) - KOReader sync protocol endpoints
|
||||||
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints
|
- [Kobo API](developer/api/kobo/) - Kobo sync protocol endpoints (feature coming soon)
|
||||||
- [WebSocket API](developer/api/websocket/) - Real-time sync events
|
- [WebSocket API](developer/api/websocket/) - Real-time sync events
|
||||||
|
|
||||||
- **Protocol Specifications**
|
- **Protocol Specifications**
|
||||||
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync
|
|
||||||
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync
|
- [KOReader Sync Protocol](developer/api/sync/koreader-protocol.md) - KOReader sync
|
||||||
|
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync (coming soon)
|
||||||
- [WebSocket API](developer/websocket-api.md) - Real-time events
|
- [WebSocket API](developer/websocket-api.md) - Real-time events
|
||||||
|
|
||||||
### 🔧 For Operations
|
### 🔧 For Operations
|
||||||
@@ -62,8 +62,8 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
|
|
||||||
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
||||||
|
|
||||||
- [Development Guide](contributing/Development.md) - Architecture, setup, testing
|
- [Development Guide](contributing/development.md) - Architecture, setup, testing
|
||||||
- [PROJECT_GUIDELINES.md](PROJECT_GUIDELINES.md) - Development rules and standards
|
- [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) - Development rules and standards
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
|
| **Set up a device** | [User Portal → Device Setup](user/user-guide.md) |
|
||||||
| **Use the API** | [Developer Portal → API Docs](developer/development.md) |
|
| **Use the API** | [Developer Portal → API Docs](developer/development.md) |
|
||||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
||||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/Development.md) |
|
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
||||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -87,13 +87,13 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
| Question | Answer |
|
| Question | Answer |
|
||||||
| --------------------------- | ------------------------------------------------------ |
|
| --------------------------- | ------------------------------------------------------ |
|
||||||
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
|
| ...install Bookhoard? | [README.md](../README.md) - Quick Start |
|
||||||
| ...set up my Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) |
|
|
||||||
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
||||||
|
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
|
||||||
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
||||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||||
| ...use the API? | [API Reference](developer/api-reference.md) |
|
| ...use the API? | [API Reference](developer/api-reference.md) |
|
||||||
| ...contribute code? | [Development Guide](contributing/Development.md) |
|
| ...contribute code? | [Development Guide](contributing/development.md) |
|
||||||
|
|
||||||
### "Where is..."
|
### "Where is..."
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
|
|
||||||
### Set up a new device
|
### Set up a new device
|
||||||
|
|
||||||
1. Choose your device: [Kobo](user/devices/kobo-setup.md) or [KOReader](user/devices/koreader-setup.md)
|
1. Choose your device: [KOReader](user/devices/koreader-setup.md) (works on Kindle, Kobo, and PocketBook hardware)
|
||||||
2. Understand sync: [Sync Guide](user/sync-guide.md)
|
2. Understand sync: [Sync Guide](user/sync-guide.md)
|
||||||
3. Troubleshoot: Device-specific guides
|
3. Troubleshoot: Device-specific guides
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
1. Follow [README.md](../README.md) quick start
|
1. Follow [README.md](../README.md) quick start
|
||||||
2. Configure environment: [.env.example](../.env.example)
|
2. Configure environment: [.env.example](../.env.example)
|
||||||
3. Review [Troubleshooting Guide](operations/troubleshooting.md)
|
3. Review [Troubleshooting Guide](operations/troubleshooting.md)
|
||||||
4. Check [Development Guide](contributing/Development.md) for performance tuning
|
4. Check [Development Guide](contributing/development.md) for performance tuning
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -138,12 +138,12 @@ When adding new features:
|
|||||||
|
|
||||||
1. **User-facing features** → Update relevant User docs
|
1. **User-facing features** → Update relevant User docs
|
||||||
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs
|
2. **API endpoints** → Update [API Reference](developer/api-reference.md) & split docs
|
||||||
3. **Backend changes** → Update [Development Guide](contributing/Development.md)
|
3. **Backend changes** → Update [Development Guide](contributing/development.md)
|
||||||
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
|
4. **Deployment changes** → Update [Operations Portal](operations/operations.md)
|
||||||
|
|
||||||
Keep [PROJECT_GUIDELINES.md](PROJECT_GUIDELINES.md) in mind for documentation standards.
|
Keep [../PROJECT_GUIDELINES.md](../PROJECT_GUIDELINES.md) in mind for documentation standards.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2026-02-08
|
**Last Updated**: August 2026
|
||||||
**Bookhoard Version**: 1.0
|
**Bookhoard Version**: 1.0
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ When creating or managing a library, you can add folders containing your media f
|
|||||||
|
|
||||||
The admin library page includes a folder browser to help you select folders on the server:
|
The admin library page includes a folder browser to help you select folders on the server:
|
||||||
|
|
||||||
1. Navigate to **Admin → Library Management**
|
1. Open the **Administration** panel in the sidebar (admins only) and go to **Libraries**
|
||||||
2. Find the library you want to manage
|
2. Click a library in the list to expand its panel
|
||||||
3. Click the **Folders** button
|
3. Find the **Folders** section
|
||||||
4. Click **Browse** next to "Add folder path"
|
4. Click **Browse** next to the folder path input — this opens the **Browse Folders** dialog
|
||||||
5. Navigate through the server's filesystem
|
5. Navigate through the server's filesystem
|
||||||
6. Select a folder by clicking **Select This Folder**
|
6. Select a folder; it fills the path input, then click **Add**
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
|
|
||||||
|
|||||||
@@ -58,20 +58,22 @@ Calibre Library/
|
|||||||
|
|
||||||
### Step 2: Add Library in Bookhoard
|
### Step 2: Add Library in Bookhoard
|
||||||
|
|
||||||
1. Navigate to **Admin** → **Libraries**
|
1. Open the **Administration** panel in the sidebar and go to **Libraries**
|
||||||
2. Click **Add Library**
|
2. Click **Create Library**
|
||||||
3. Configure:
|
3. Configure:
|
||||||
- **Name**: "My Calibre Library"
|
- **Library Name**: "My Calibre Library"
|
||||||
- **Type**: Ebook (or Audiobook/Comic)
|
- **Description**: Optional
|
||||||
- **Folder**: Path to your Calibre library
|
- **Library Type**: Ebook (or Audiobook/Comic)
|
||||||
- **Scan on save**: ✅ Checked
|
4. Click the library in the list to expand its panel
|
||||||
4. Click **Save**
|
5. Add your Calibre library folder in the **Folders** section:
|
||||||
|
- Enter the path (or click **Browse** to find it on the server) and click **Add**
|
||||||
|
6. Trigger a scan (see below), or rely on watch mode if enabled
|
||||||
|
|
||||||
Bookhoard will automatically scan the library and import all books with their Calibre metadata.
|
Bookhoard scans the library and imports all books with their Calibre metadata. Scan progress shows in the sidebar next to the logo.
|
||||||
|
|
||||||
### Step 3: Verify Import
|
### Step 3: Verify Import
|
||||||
|
|
||||||
1. Navigate to **Library** view
|
1. Open the **Dashboard** or **All Books** page (sidebar navigation)
|
||||||
2. Browse your imported books
|
2. Browse your imported books
|
||||||
3. Check that:
|
3. Check that:
|
||||||
- Titles and authors are correct
|
- Titles and authors are correct
|
||||||
@@ -136,8 +138,8 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
**Scenario**: You have a Calibre library with 500 ebooks, all organized with series, tags, and custom covers.
|
**Scenario**: You have a Calibre library with 500 ebooks, all organized with series, tags, and custom covers.
|
||||||
|
|
||||||
**Steps**:
|
**Steps**:
|
||||||
1. Add the Calibre library folder in Bookhoard
|
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
|
||||||
2. Enable "Scan on save"
|
2. Trigger a scan via the **Scanner API**, or let watch mode pick up the changed files (the File Watcher status is shown on the admin dashboard)
|
||||||
3. Bookhoard imports all 500 books with:
|
3. Bookhoard imports all 500 books with:
|
||||||
- Correct titles and authors
|
- Correct titles and authors
|
||||||
- Series information (e.g., "Harry Potter #2")
|
- Series information (e.g., "Harry Potter #2")
|
||||||
@@ -166,9 +168,8 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
**Steps**:
|
**Steps**:
|
||||||
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
||||||
2. In Bookhoard, trigger a rescan:
|
2. In Bookhoard, trigger a rescan:
|
||||||
- Navigate to **Admin** → **Libraries**
|
- Via the **Scanner API** (`POST /api/scanner/scan`), or
|
||||||
- Click **Rescan** on your library
|
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
|
||||||
- Or use the **Scanner API** to force rescan
|
|
||||||
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
|
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
|
||||||
|
|
||||||
**Result**: Bookhoard reflects your Calibre changes automatically.
|
**Result**: Bookhoard reflects your Calibre changes automatically.
|
||||||
@@ -182,7 +183,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
|
1. **Check file structure**: Ensure `metadata.opf` is in the same folder as the book file
|
||||||
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
|
2. **Verify library type**: Ensure library type matches content (ebook vs. audiobook)
|
||||||
3. **Force rescan**: Use the "Force Rescan" option to re-import all metadata
|
3. **Force rescan**: Trigger a scan via the Scanner API to re-import all metadata (watch mode also picks up changed files automatically)
|
||||||
4. **Check logs**: Review Bookhoard logs for parsing errors
|
4. **Check logs**: Review Bookhoard logs for parsing errors
|
||||||
|
|
||||||
### Incorrect Metadata
|
### Incorrect Metadata
|
||||||
@@ -219,7 +220,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
|||||||
|
|
||||||
**Do**:
|
**Do**:
|
||||||
- ✅ Edit metadata in Calibre
|
- ✅ Edit metadata in Calibre
|
||||||
- ✅ Rescan in Bookhoard to sync changes
|
- ✅ Let Bookhoard's next scan (or watch mode) pick up the changes
|
||||||
- ✅ Use Calibre for library management
|
- ✅ Use Calibre for library management
|
||||||
|
|
||||||
**Don't**:
|
**Don't**:
|
||||||
@@ -259,12 +260,11 @@ Stay tuned for updates!
|
|||||||
|
|
||||||
### OPDS Integration
|
### OPDS Integration
|
||||||
|
|
||||||
You can access your Bookhoard library (including Calibre-imported books) via OPDS from Calibre-aware devices:
|
You can access your Bookhoard library (including Calibre-imported books) via OPDS from OPDS-capable clients:
|
||||||
- Kobo e-readers
|
- KOReader (Kindle, Kobo, PocketBook hardware)
|
||||||
- KOReader
|
|
||||||
- Phone/tablet apps (KYBook, Chunky, etc.)
|
- Phone/tablet apps (KYBook, Chunky, etc.)
|
||||||
|
|
||||||
See the [Kobo Setup Guide](devices/kobo-setup.md) or [KOReader Setup Guide](devices/koreader-setup.md) for details.
|
See the [KOReader Setup Guide](devices/koreader-setup.md) for details.
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Collections allow you to organize books across multiple libraries.
|
|||||||
|
|
||||||
### From Collections Page
|
### From Collections Page
|
||||||
|
|
||||||
Navigate to `/collections` to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
|
Navigate to **Collections** (sidebar navigation) to see all your collections. Clicking on a collection shows ALL books in that collection across all libraries.
|
||||||
|
|
||||||
### From Dashboard
|
### From Dashboard
|
||||||
|
|
||||||
@@ -19,8 +19,24 @@ When viewing a specific library's dashboard, collections only show books from th
|
|||||||
|
|
||||||
## Creating Collections
|
## Creating Collections
|
||||||
|
|
||||||
[Instructions for creating collections]
|
1. Go to **Collections** (sidebar navigation)
|
||||||
|
2. Click **New Collection** (top-right) — or **Create Your First Collection** if the list is empty
|
||||||
|
3. Fill in the details:
|
||||||
|
- **Name** - Collection name
|
||||||
|
- **Description** - Optional description
|
||||||
|
- **Icon** - Pick from the icon grid
|
||||||
|
- **Color** - Pick a color swatch
|
||||||
|
4. Click **Create Collection**
|
||||||
|
|
||||||
## Managing Collections
|
## Managing Collections
|
||||||
|
|
||||||
[Instructions for editing/deleting collections]
|
Each collection in the list has icon buttons on its card:
|
||||||
|
|
||||||
|
- **Edit** - Opens the edit form to change name, description, icon, or color. Click **Update Collection** to save.
|
||||||
|
- **Delete** - Removes the collection after a confirmation prompt.
|
||||||
|
|
||||||
|
Deleted a system collection by mistake? The **Restore System** button on the Collections page brings back system collections.
|
||||||
|
|
||||||
|
### Dashboard Sections
|
||||||
|
|
||||||
|
Collections appear as sections on your dashboard. Show, hide, and reorder them from the dashboard's **Customize Dashboard** settings (see [Dashboard](dashboard.md)).
|
||||||
|
|||||||
+14
-13
@@ -16,28 +16,27 @@ Smart sections are automatically generated based on your reading activity:
|
|||||||
|
|
||||||
### User Collections
|
### User Collections
|
||||||
|
|
||||||
Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.
|
Your collections appear as sections on the dashboard. To show or hide a collection's section:
|
||||||
|
|
||||||
To enable a collection:
|
1. Select the library in the **Library** bar
|
||||||
|
2. Click the **Customize Dashboard** icon button
|
||||||
1. Go to Collections
|
3. Toggle the collection on or off
|
||||||
2. Edit a collection
|
4. Click "Save Changes"
|
||||||
3. Toggle "Show on Dashboard"
|
|
||||||
4. Save
|
|
||||||
|
|
||||||
### Customizing Your Dashboard
|
### Customizing Your Dashboard
|
||||||
|
|
||||||
1. Click the ⚙️ (gear icon) in the top-right
|
1. In the **Library** bar below the top bar, select the library you want to customize (a specific library, not "All Libraries")
|
||||||
2. **Drag sections** to reorder them
|
2. Click the **Customize Dashboard** icon button at the right end of the Library bar (next to Refresh)
|
||||||
3. **Toggle visibility** with the switches
|
3. **Drag sections** to reorder them
|
||||||
4. **Adjust items per section** (10-50 items)
|
4. **Toggle visibility** with the switches
|
||||||
5. Click "Save Changes"
|
5. **Adjust items per section** (10-50 items)
|
||||||
|
6. Click "Save Changes"
|
||||||
|
|
||||||
Settings are saved per library.
|
Settings are saved per library.
|
||||||
|
|
||||||
### Library Switching
|
### Library Switching
|
||||||
|
|
||||||
Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.
|
Use the **Library** dropdown in the bar below the top bar to switch between libraries (including "All Libraries"). Each library has its own dashboard settings.
|
||||||
|
|
||||||
### Keyboard Navigation
|
### Keyboard Navigation
|
||||||
|
|
||||||
@@ -45,6 +44,8 @@ Use the dropdown in the sticky header to switch between libraries. Each library
|
|||||||
- **Arrow Keys**: Scroll carousels horizontally
|
- **Arrow Keys**: Scroll carousels horizontally
|
||||||
- **Enter**: Open selected book
|
- **Enter**: Open selected book
|
||||||
|
|
||||||
|
Hovering a carousel shows chevron buttons on either side for scrolling.
|
||||||
|
|
||||||
### Touch Gestures (Mobile)
|
### Touch Gestures (Mobile)
|
||||||
|
|
||||||
- **Swipe**: Drag carousel left/right to scroll
|
- **Swipe**: Drag carousel left/right to scroll
|
||||||
|
|||||||
+24
-632
@@ -1,650 +1,42 @@
|
|||||||
# Kobo Device Setup Guide
|
# Kobo Device Setup Guide
|
||||||
|
|
||||||
This guide will help you set up your Kobo e-reader to sync with Bookhoard for seamless cross-device reading progress synchronization.
|
> ## 🚧 Coming Soon
|
||||||
|
>
|
||||||
|
> Native Kobo sync is not available yet. It is actively being developed and this guide will be filled in as the feature lands.
|
||||||
|
|
||||||
## What is Kobo Sync?
|
## Using a Kobo With Bookhoard Today
|
||||||
|
|
||||||
Bookhoard implements a Kobo-compatible sync protocol that allows your Kobo device to:
|
You don't have to wait: **KOReader runs on Kobo hardware** and syncs fully with Bookhoard today — reading position, bookmarks, highlights, and notes, plus OPDS wireless book delivery.
|
||||||
|
|
||||||
- Sync reading progress across all your devices
|
See the **[KOReader Setup Guide](koreader-setup.md)** for complete instructions.
|
||||||
- Sync highlights and bookmarks
|
|
||||||
- Sync reading statistics
|
|
||||||
- Maintain device-specific metadata
|
|
||||||
|
|
||||||
## Prerequisites
|
## What's Planned for Native Kobo Sync
|
||||||
|
|
||||||
Before you begin, make sure you have:
|
When released, native Kobo sync will let stock Kobo firmware talk directly to Bookhoard:
|
||||||
|
|
||||||
- ✅ A Kobo e-reader device (Clara, Aura, Nia, Libra, Sage, Elipsa, etc.)
|
- **Reading position sync** — percentages, pages, and reading statistics
|
||||||
- ✅ A Bookhoard instance running and accessible on your network
|
- **Bookmarks, highlights, and notes** — synced with the web and other devices
|
||||||
- ✅ Your Bookhoard credentials (username and password)
|
- **OPDS wireless delivery** — browse and download books directly on the Kobo
|
||||||
- ✅ USB cable to connect your Kobo to your computer
|
- **Automatic EPUB → KEPUB conversion** — for better Kobo rendering
|
||||||
- ✅ Your Kobo connected to the same Wi-Fi network as your Bookhoard instance
|
- **Shelf mappings** — Bookhoard collections appearing as Kobo shelves
|
||||||
|
|
||||||
## Supported Kobo Devices
|
The server-side protocol endpoints are already implemented and under test; the feature will be announced when it's ready for real devices.
|
||||||
|
|
||||||
Bookhoard supports all Kobo devices that use the standard Kobo sync protocol:
|
|
||||||
|
|
||||||
- **Kobo Clara**: Clara 2E, Clara HD
|
|
||||||
- **Kobo Aura**: Aura, Aura H2O, Aura ONE, Aura Edition 2
|
|
||||||
- **Kobo Libra**: Libra 2, Libra H2O
|
|
||||||
- **Kobo Forma**: All versions
|
|
||||||
- **Kobo Sage**: All versions
|
|
||||||
- **Kobo Elipsa**: All versions
|
|
||||||
- **Kobo Nia**: All versions
|
|
||||||
- **Kobo Touch**: Touch 2.0
|
|
||||||
- **Kobo Glo**: Glo, Glo HD
|
|
||||||
|
|
||||||
## Device Registration
|
|
||||||
|
|
||||||
### Step 1: Find Your Kobo Serial Number
|
|
||||||
|
|
||||||
1. Turn on your Kobo device
|
|
||||||
2. Go to **Settings** (gear icon)
|
|
||||||
3. Select **Device Information**
|
|
||||||
4. Note your **Device Serial Number** (e.g., N1234567890123)
|
|
||||||
- This is your device identifier for registration
|
|
||||||
|
|
||||||
### Step 2: Register Your Device in Bookhoard
|
|
||||||
|
|
||||||
1. Log in to your Bookhoard web interface
|
|
||||||
2. Navigate to **Device Management** → **Add New Device**
|
|
||||||
3. Fill in the device details:
|
|
||||||
- **Device Name**: A friendly name (e.g., "My Kobo Clara")
|
|
||||||
- **Device Type**: Select "Kobo"
|
|
||||||
- **Device Identifier**: Enter your Kobo serial number
|
|
||||||
4. Click **Register Device**
|
|
||||||
|
|
||||||
You'll receive:
|
|
||||||
|
|
||||||
- An **Auth URL** to approve the device
|
|
||||||
- Instructions for manual configuration
|
|
||||||
|
|
||||||
### Step 3: Approve Your Device
|
|
||||||
|
|
||||||
1. **Method A: QR Code**
|
|
||||||
- If displayed, scan the QR code with your phone's camera
|
|
||||||
- This will open the approval page in your browser
|
|
||||||
- Log in and click **Approve**
|
|
||||||
|
|
||||||
2. **Method B: Manual URL**
|
|
||||||
- Copy the Auth URL from the registration confirmation
|
|
||||||
- Open it in your web browser
|
|
||||||
- Log in to your Bookhoard account
|
|
||||||
- Click **Approve Device**
|
|
||||||
|
|
||||||
Your device is now registered and ready for configuration!
|
|
||||||
|
|
||||||
## Configure Kobo Sync
|
|
||||||
|
|
||||||
### Step 1: Connect Kobo to Your Computer
|
|
||||||
|
|
||||||
1. Use your USB cable to connect Kobo to your computer
|
|
||||||
2. Your computer should recognize Kobo as a storage device
|
|
||||||
3. Kobo will show "Connected" and "Eject before disconnecting"
|
|
||||||
|
|
||||||
### Step 2: Edit Kobo Configuration File
|
|
||||||
|
|
||||||
#### Windows Users
|
|
||||||
|
|
||||||
1. Open **File Explorer** and navigate to your Kobo device
|
|
||||||
2. Open the `.kobo` folder (hidden folder)
|
|
||||||
3. Open `Kobo/Kobo eReader.conf` in a text editor (Notepad++, VS Code, etc.)
|
|
||||||
|
|
||||||
#### Mac Users
|
|
||||||
|
|
||||||
1. Kobo device appears on your Desktop
|
|
||||||
2. Right-click the Kobo volume and select **Show Package Contents**
|
|
||||||
3. Navigate to `.kobo/Kobo/Kobo eReader.conf`
|
|
||||||
4. Open in a text editor (TextEdit, VS Code, etc.)
|
|
||||||
|
|
||||||
#### Linux Users
|
|
||||||
|
|
||||||
1. Kobo mounts at `/media/USERNAME/Kobo` or similar
|
|
||||||
2. Navigate to `.kobo/Kobo/Kobo eReader.conf`
|
|
||||||
3. Open in a text editor
|
|
||||||
|
|
||||||
### Step 3: Add Bookhoard Sync Configuration
|
|
||||||
|
|
||||||
After device registration is complete, you'll receive an API key and sync URL from Bookhoard.
|
|
||||||
|
|
||||||
Add the following section to the end of your `Kobo eReader.conf` file:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[FeatureSettings]
|
|
||||||
# Enable Kobo store replacement
|
|
||||||
KoboStoreSyncDisabled=true
|
|
||||||
|
|
||||||
[Sync]
|
|
||||||
# Bookhoard Sync Configuration (from Device Management page)
|
|
||||||
ServerURL=http://YOUR_COMPUTER_IP:8765/api/sync/kobo/YOUR_API_KEY
|
|
||||||
AutoSyncEnabled=true
|
|
||||||
SyncFrequency=5
|
|
||||||
```
|
|
||||||
|
|
||||||
**Where to find these values**:
|
|
||||||
|
|
||||||
- `YOUR_COMPUTER_IP`: Your Bookhoard server's IP address (e.g., 192.168.1.100)
|
|
||||||
- `YOUR_API_KEY`: Copy from Bookhoard Device Management → Your Kobo Device → "Copy Sync URL"
|
|
||||||
|
|
||||||
**Example configuration**:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Sync]
|
|
||||||
ServerURL=http://192.168.1.100:8765/api/sync/kobo/dev_abc123def456
|
|
||||||
AutoSyncEnabled=true
|
|
||||||
SyncFrequency=5
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important Notes**:
|
|
||||||
|
|
||||||
- The API key is generated during device registration
|
|
||||||
- You can regenerate the API key anytime from Device Management if needed
|
|
||||||
- Keep your API key confidential like a password
|
|
||||||
- Bookhoard uses revocable API keys for security (not username/password)
|
|
||||||
|
|
||||||
**Replace the following with your actual values**:
|
|
||||||
|
|
||||||
- `YOUR_COMPUTER_IP`: Your computer's local IP address (e.g., 192.168.1.100)
|
|
||||||
- `YOUR_BOOKHOARD_USERNAME`: Your Bookhoard email or username
|
|
||||||
- `YOUR_BOOKHOARD_PASSWORD`: Your Bookhoard password
|
|
||||||
|
|
||||||
**Example configuration:**
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Sync]
|
|
||||||
ServerURL=http://192.168.1.100:8765/api/sync/kobo
|
|
||||||
AutoSyncEnabled=true
|
|
||||||
SyncFrequency=5
|
|
||||||
Username=john@example.com
|
|
||||||
Password=securePassword123
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Save and Eject
|
|
||||||
|
|
||||||
1. Save the `Kobo eReader.conf` file
|
|
||||||
2. Safely eject your Kobo device from your computer
|
|
||||||
3. Kobo will restart automatically
|
|
||||||
|
|
||||||
### Step 5: Verify Sync on Kobo
|
|
||||||
|
|
||||||
1. After Kobo restarts, go to **Settings** → **Sync & Backup**
|
|
||||||
2. You should see "Bookhoard" listed as a sync provider
|
|
||||||
3. Tap **Sync Now** to test the connection
|
|
||||||
4. If successful, you'll see a "Sync Complete" message
|
|
||||||
|
|
||||||
## Sync Features
|
|
||||||
|
|
||||||
### Reading Progress Sync
|
|
||||||
|
|
||||||
Kobo syncs:
|
|
||||||
|
|
||||||
- **Percentage Read**: Overall book completion percentage
|
|
||||||
- **Page Number**: Current page in fixed-layout books
|
|
||||||
- **Time Spent**: Reading time statistics
|
|
||||||
- **Last Read**: Timestamp of last reading session
|
|
||||||
|
|
||||||
### Annotations Sync
|
|
||||||
|
|
||||||
Kobo syncs:
|
|
||||||
|
|
||||||
- **Bookmarks**: Page positions saved for quick access
|
|
||||||
- **Highlights**: Highlighted text passages
|
|
||||||
- **Notes**: Notes attached to highlights
|
|
||||||
- **Reading Statistics**: Pages read, time spent
|
|
||||||
|
|
||||||
### Shelf Management
|
|
||||||
|
|
||||||
Kobo syncs:
|
|
||||||
|
|
||||||
- **Book Collections**: Your organized shelves
|
|
||||||
- **Shelf Contents**: Books in each collection
|
|
||||||
- **Sync Metadata**: When shelves were last updated
|
|
||||||
|
|
||||||
## OPDS Wireless Book Delivery
|
|
||||||
|
|
||||||
### What is OPDS?
|
|
||||||
|
|
||||||
OPDS (Open Publication Distribution System) allows your Kobo to **wirelessly download books** from Bookhoard - no USB cable needed!
|
|
||||||
|
|
||||||
### OPDS Benefits
|
|
||||||
|
|
||||||
- **No USB Required**: Download books directly to your Kobo over Wi-Fi
|
|
||||||
- **On-Demand Delivery**: Browse your Bookhoard library from your Kobo
|
|
||||||
- **Collection Support**: Download books from specific collections
|
|
||||||
- **Progress Tracking**: Books downloaded via OPDS sync progress automatically
|
|
||||||
- **Format Conversion**: Automatic EPUB to KEPUB conversion for better Kobo support
|
|
||||||
|
|
||||||
### Enable OPDS on Your Kobo
|
|
||||||
|
|
||||||
#### Option 1: Automatic Configuration (Recommended)
|
|
||||||
|
|
||||||
1. After registering your Kobo device, a **Download Configuration** button appears
|
|
||||||
2. Click **Download Configuration** to get a `.kobo` configuration file
|
|
||||||
3. Copy this file to your Kobo's `.kobo/` directory via USB
|
|
||||||
4. Eject and restart your Kobo
|
|
||||||
5. OPDS catalog will automatically appear in your Kobo's store
|
|
||||||
|
|
||||||
#### Option 2: Manual Configuration
|
|
||||||
|
|
||||||
1. Connect your Kobo to your computer via USB
|
|
||||||
2. Navigate to `.kobo/Kobo/Kobo eReader.conf`
|
|
||||||
3. Add the following configuration:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[FeatureSettings]
|
|
||||||
# Enable OPDS catalog
|
|
||||||
OPDSCatalogEnabled=true
|
|
||||||
OPDSCatalogURL=http://YOUR_COMPUTER_IP:8765/opds/devices/YOUR_DEVICE_ID/catalog?token=YOUR_API_KEY
|
|
||||||
|
|
||||||
# Example:
|
|
||||||
# OPDSCatalogURL=http://192.168.1.100:8765/opds/devices/kobo-clara-123/catalog?token=dev_abc123def456
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Replace:
|
|
||||||
- `YOUR_COMPUTER_IP`: Your Bookhoard server IP
|
|
||||||
- `YOUR_DEVICE_ID`: Your Kobo's device ID from Bookhoard Device Management
|
|
||||||
- `YOUR_API_KEY`: Your Kobo device's API key (same as in sync URL)
|
|
||||||
|
|
||||||
5. Save the file and safely eject your Kobo
|
|
||||||
|
|
||||||
### Access OPDS Catalog on Kobo
|
|
||||||
|
|
||||||
1. Wake your Kobo and connect to Wi-Fi
|
|
||||||
2. Go to **Home** → **Store** (or **Shop**)
|
|
||||||
3. You'll see **Bookhoard** listed as a store
|
|
||||||
4. Tap to enter the Bookhoard catalog
|
|
||||||
|
|
||||||
### Browse and Download Books
|
|
||||||
|
|
||||||
#### Browse All Books
|
|
||||||
|
|
||||||
1. In the Bookhoard catalog, you'll see all books from your library
|
|
||||||
2. Browse by:
|
|
||||||
- **Recently Added**: Latest books in your library
|
|
||||||
- **Collections**: Books organized by collections
|
|
||||||
- **Authors**: Books grouped by author
|
|
||||||
- **Series**: Books in reading order
|
|
||||||
|
|
||||||
#### Download a Book
|
|
||||||
|
|
||||||
1. Tap on any book cover to see details
|
|
||||||
2. Tap **Download** or **Add to Library**
|
|
||||||
3. The book downloads wirelessly to your Kobo
|
|
||||||
4. Progress bar shows download status
|
|
||||||
5. Once downloaded, the book appears in your **Home** library
|
|
||||||
|
|
||||||
#### Download from Collections
|
|
||||||
|
|
||||||
1. In the Bookhoard catalog, tap **Collections**
|
|
||||||
2. Select a collection (e.g., "Science Fiction")
|
|
||||||
3. Browse books in that collection
|
|
||||||
4. Tap to download individual books
|
|
||||||
5. Or tap **Download All** to get entire collection
|
|
||||||
|
|
||||||
### OPDS Features
|
|
||||||
|
|
||||||
#### Format Support
|
|
||||||
|
|
||||||
Kobo OPDS supports:
|
|
||||||
|
|
||||||
- **EPUB**: Standard ebook format (recommended)
|
|
||||||
- **KEPUB**: Kobo-optimized EPUB (better page turns, fonts)
|
|
||||||
- **PDF**: Fixed-layout documents
|
|
||||||
|
|
||||||
**Automatic Conversion**: Bookhoard automatically converts EPUB to KEPUB on-the-fly for better Kobo experience.
|
|
||||||
|
|
||||||
#### Progress Sync
|
|
||||||
|
|
||||||
Books downloaded via OPDS automatically sync progress:
|
|
||||||
|
|
||||||
1. Download a book via OPDS
|
|
||||||
2. Start reading on your Kobo
|
|
||||||
3. Progress syncs to Bookhoard automatically
|
|
||||||
4. Continue reading on any other device!
|
|
||||||
|
|
||||||
#### Collection to Shelf Mapping
|
|
||||||
|
|
||||||
Bookhoard maps your collections to Kobo shelves:
|
|
||||||
|
|
||||||
- Collection **"Science Fiction"** → Kobo shelf **"Sci-Fi"**
|
|
||||||
- Collection **"To Read"** → Kobo shelf **"To Read"**
|
|
||||||
- Customizable in Bookhoard Device Management
|
|
||||||
|
|
||||||
### OPDS Troubleshooting
|
|
||||||
|
|
||||||
#### Catalog Not Appearing
|
|
||||||
|
|
||||||
**Problem**: Bookhoard catalog doesn't show in Kobo store
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify OPDS URL is correct in config file
|
|
||||||
2. Check Kobo is connected to Wi-Fi
|
|
||||||
3. Try accessing OPDS URL in your browser
|
|
||||||
4. Ensure device ID matches Bookhoard device ID
|
|
||||||
5. Restart Kobo after editing config file
|
|
||||||
|
|
||||||
#### Download Fails
|
|
||||||
|
|
||||||
**Problem**: Book download starts but fails partway through
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Check Wi-Fi signal strength
|
|
||||||
2. Ensure Bookhoard server is running
|
|
||||||
3. Verify book file exists in Bookhoard library
|
|
||||||
4. Try downloading a smaller book first
|
|
||||||
5. Check Bookhoard logs for errors
|
|
||||||
|
|
||||||
#### Book Downloads But Won't Open
|
|
||||||
|
|
||||||
**Problem**: Downloaded book shows error when opening
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify book format is supported (EPUB/KEPUB/PDF)
|
|
||||||
2. Check file isn't corrupted in Bookhoard
|
|
||||||
3. Try downloading via USB and opening
|
|
||||||
4. Check Kobo has sufficient free storage
|
|
||||||
5. Restart your Kobo device
|
|
||||||
|
|
||||||
#### Slow Download Speed
|
|
||||||
|
|
||||||
**Problem**: Books take too long to download
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Ensure strong Wi-Fi signal (stay near router)
|
|
||||||
2. Use 5GHz Wi-Fi if your Kobo supports it
|
|
||||||
3. Close other apps using bandwidth
|
|
||||||
4. Download smaller books first
|
|
||||||
5. Consider using USB for large books
|
|
||||||
|
|
||||||
### OPDS vs USB Transfer
|
|
||||||
|
|
||||||
| Feature | OPDS (Wireless) | USB Transfer |
|
|
||||||
| -------------------- | ----------------------------- | ------------------------- |
|
|
||||||
| **Convenience** | ⭐⭐⭐⭐⭐ No cable needed | ⭐⭐ Requires cable |
|
|
||||||
| **Speed** | ⭐⭐⭐ Fast (Wi-Fi dependent) | ⭐⭐⭐⭐⭐ Very fast |
|
|
||||||
| **Bulk Transfer** | ⭐⭐⭐ One at a time | ⭐⭐⭐⭐⭐ Many at once |
|
|
||||||
| **Progress Sync** | ⭐⭐⭐⭐⭐ Automatic | ⭐⭐⭐⭐ After first sync |
|
|
||||||
| **Setup Complexity** | ⭐⭐⭐ Moderate | ⭐⭐⭐⭐⭐ Simple |
|
|
||||||
| **Reliability** | ⭐⭐⭐⭐ Good | ⭐⭐⭐⭐⭐ Excellent |
|
|
||||||
|
|
||||||
**Recommendation**: Use OPDS for convenience (1-5 books), use USB for bulk transfers (10+ books).
|
|
||||||
|
|
||||||
### Advanced OPDS Configuration
|
|
||||||
|
|
||||||
#### Custom Catalog Name
|
|
||||||
|
|
||||||
Change the name of the Bookhoard catalog on your Kobo:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[OPDS]
|
|
||||||
CatalogName=My Library
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Auto-Download
|
|
||||||
|
|
||||||
Automatically download new books added to collections:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[OPDS]
|
|
||||||
AutoDownloadEnabled=true
|
|
||||||
AutoDownloadCollections=To Read,Recent
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Download Quality
|
|
||||||
|
|
||||||
Choose between original EPUB or converted KEPUB:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[OPDS]
|
|
||||||
PreferredFormat=kepub # Options: epub, kepub, auto
|
|
||||||
```
|
|
||||||
|
|
||||||
## Sync Frequency Options
|
|
||||||
|
|
||||||
Configure how often Kobo syncs with Bookhoard:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Sync]
|
|
||||||
# Sync frequency in minutes
|
|
||||||
SyncFrequency=5 # Sync every 5 minutes (recommended)
|
|
||||||
SyncFrequency=15 # Sync every 15 minutes
|
|
||||||
SyncFrequency=60 # Sync every hour
|
|
||||||
SyncFrequency=0 # Manual sync only
|
|
||||||
```
|
|
||||||
|
|
||||||
**Recommended**: `SyncFrequency=5` for near real-time sync
|
|
||||||
**Battery Saving**: `SyncFrequency=15` or `30` to reduce Wi-Fi usage
|
|
||||||
**Manual Only**: `SyncFrequency=0` sync only when you press "Sync Now"
|
|
||||||
|
|
||||||
## Manual Sync
|
|
||||||
|
|
||||||
To manually trigger a sync on your Kobo:
|
|
||||||
|
|
||||||
1. Connect Kobo to Wi-Fi
|
|
||||||
2. Go to **Settings** → **Sync & Backup**
|
|
||||||
3. Tap **Sync Now**
|
|
||||||
4. Wait for "Sync Complete" message
|
|
||||||
|
|
||||||
## Advanced Configuration
|
|
||||||
|
|
||||||
### Disable Kobo Store
|
|
||||||
|
|
||||||
To prevent Kobo from trying to connect to the official Kobo store:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[FeatureSettings]
|
|
||||||
KoboStoreSyncDisabled=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Custom Sync URL
|
|
||||||
|
|
||||||
If you're running Bookhoard with a custom domain or port:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Sync]
|
|
||||||
# Custom domain
|
|
||||||
ServerURL=https://bookhoard.example.com/api/sync/kobo
|
|
||||||
|
|
||||||
# Custom port
|
|
||||||
ServerURL=http://192.168.1.100:9000/api/sync/kobo
|
|
||||||
|
|
||||||
# Localhost (for testing)
|
|
||||||
ServerURL=http://localhost:8765/api/sync/kobo
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTTPS Configuration
|
|
||||||
|
|
||||||
If you have SSL/TLS configured on Bookhoard:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Sync]
|
|
||||||
ServerURL=https://bookhoard.yourdomain.com/api/sync/kobo/YOUR_API_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace `YOUR_API_KEY` with your device's API key from Bookhoard Device Management.
|
|
||||||
|
|
||||||
Kobo will automatically trust the certificate if properly configured.
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Sync Not Working
|
|
||||||
|
|
||||||
**Problem**: Sync doesn't happen automatically
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Check Kobo is connected to Wi-Fi
|
|
||||||
2. Verify `AutoSyncEnabled=true` in config
|
|
||||||
3. Check `SyncFrequency` is not set to 0
|
|
||||||
4. Test with manual sync first
|
|
||||||
5. Check Bookhoard logs for connection attempts
|
|
||||||
|
|
||||||
### Connection Refused
|
|
||||||
|
|
||||||
**Problem**: "Connection refused" or "Server not reachable"
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify Bookhoard is running on your computer
|
|
||||||
2. Check the server URL and IP address are correct
|
|
||||||
3. Ensure Kobo is on same Wi-Fi network as computer
|
|
||||||
4. Temporarily disable firewall to test
|
|
||||||
5. Try accessing Bookhoard URL in your browser first
|
|
||||||
|
|
||||||
### Authentication Failed
|
|
||||||
|
|
||||||
**Problem**: "Authentication failed" or "Invalid API key"
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify the API key in your sync URL matches the one in Bookhoard Device Management
|
|
||||||
2. Check that device is approved in Bookhoard (not pending)
|
|
||||||
3. Try regenerating the API key from Device Management page
|
|
||||||
4. Ensure the sync URL is complete (includes the API key)
|
|
||||||
5. Copy the sync URL directly from Device Management → "Copy Sync URL" button
|
|
||||||
|
|
||||||
### Configuration File Not Saving
|
|
||||||
|
|
||||||
**Problem**: Changes to `Kobo eReader.conf` are lost
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Make sure Kobo is ejected safely after editing
|
|
||||||
2. Check file permissions (should be writable)
|
|
||||||
3. Try a different text editor (Notepad++, VS Code, Sublime Text)
|
|
||||||
4. Backup the file before editing
|
|
||||||
5. On Mac, ensure you're not editing the package directly
|
|
||||||
|
|
||||||
### Sync Only Works Manually
|
|
||||||
|
|
||||||
**Problem**: Manual sync works, but auto-sync doesn't
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify `AutoSyncEnabled=true` in config
|
|
||||||
2. Check `SyncFrequency` is not 0
|
|
||||||
3. Kobo only syncs when connected to Wi-Fi
|
|
||||||
4. Some Kobo models require Wi-Fi to be manually connected
|
|
||||||
5. Check Bookhoard device management page for connection errors
|
|
||||||
|
|
||||||
### Books Not Appearing in Kobo
|
|
||||||
|
|
||||||
**Problem**: Books added to Bookhoard don't show on Kobo
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Kobo needs books to be sideloaded (manually transferred via USB)
|
|
||||||
2. Bookhoard syncs PROGRESS, not book files
|
|
||||||
3. Transfer book files to Kobo's `Documents` folder via USB
|
|
||||||
4. Kobo will then sync progress for those books with Bookhoard
|
|
||||||
5. Check that book formats are supported by Kobo
|
|
||||||
|
|
||||||
### Conflicts Not Showing
|
|
||||||
|
|
||||||
**Problem**: Conflicts between devices aren't being detected
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Check Bookhoard Conflicts page
|
|
||||||
2. Ensure both devices have synced recently
|
|
||||||
3. Conflicts only detected when progress differs within 5 minutes
|
|
||||||
4. Manually sync both devices to trigger conflict detection
|
|
||||||
5. Review conflict resolution settings
|
|
||||||
|
|
||||||
## Security Best Practices
|
|
||||||
|
|
||||||
1. **Use HTTPS**: If deploying Bookhoard publicly, configure SSL/TLS
|
|
||||||
2. **Strong Password**: Use a secure password for your Bookhoard account
|
|
||||||
3. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
|
||||||
4. **Regular Updates**: Keep Kobo firmware updated
|
|
||||||
5. **Device Authorization**: Only approve devices you recognize
|
|
||||||
|
|
||||||
## Network Configuration
|
|
||||||
|
|
||||||
### Local Network (Recommended)
|
|
||||||
|
|
||||||
For home use, keep Kobo and Bookhoard on the same local network:
|
|
||||||
|
|
||||||
```
|
|
||||||
Kobo Wi-Fi: 192.168.1.x
|
|
||||||
Bookhoard: 192.168.1.x
|
|
||||||
```
|
|
||||||
|
|
||||||
### Remote Access
|
|
||||||
|
|
||||||
For access outside your home network:
|
|
||||||
|
|
||||||
1. Set up port forwarding on your router (port 8765)
|
|
||||||
2. Configure SSL/TLS on Bookhoard
|
|
||||||
3. Use a dynamic DNS service for constant hostname
|
|
||||||
4. Update Kobo config with public URL including API key:
|
|
||||||
```ini
|
|
||||||
[Sync]
|
|
||||||
ServerURL=https://yourdomain.com/api/sync/kobo/YOUR_API_KEY
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance Optimization
|
|
||||||
|
|
||||||
### Battery Life
|
|
||||||
|
|
||||||
To extend Kobo battery life:
|
|
||||||
|
|
||||||
1. Use longer sync intervals (15-30 minutes)
|
|
||||||
2. Sync only on Wi-Fi (not cellular if your Kobo has it)
|
|
||||||
3. Disable unnecessary Kobo features
|
|
||||||
4. Keep Kobo in sleep mode when not reading
|
|
||||||
|
|
||||||
### Sync Speed
|
|
||||||
|
|
||||||
To improve sync speed:
|
|
||||||
|
|
||||||
1. Ensure strong Wi-Fi signal
|
|
||||||
2. Use local network (not remote access)
|
|
||||||
3. Keep Bookhoard and Kobo on same network
|
|
||||||
4. Close other apps using Wi-Fi bandwidth
|
|
||||||
5. Reduce number of books syncing at once
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
- [Kobo Developer Documentation](https://help.kobo.com/hc/en-us)
|
|
||||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
|
||||||
- [KOReader Setup Guide](koreader-setup.md)
|
|
||||||
- [Bookhoard API Reference](../../developer/api-reference.md)
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
**Q: Can I sync books (files) between devices?**
|
**Q: Should I buy a Kobo to use with Bookhoard today?**
|
||||||
A: No, Bookhoard only syncs reading progress and annotations. You must sideload book files to each device manually.
|
A: Kobo devices work great with Bookhoard via KOReader. Native (stock firmware) sync is coming soon.
|
||||||
|
|
||||||
**Q: Will Kobo update automatically when I add books in Bookhoard?**
|
**Q: What happens to my KOReader setup when native sync arrives?**
|
||||||
A: No, Kobo doesn't fetch book files from Bookhoard. You must transfer books via USB.
|
A: Nothing — KOReader will keep working. Native sync simply adds another option for people who prefer stock Kobo firmware.
|
||||||
|
|
||||||
**Q: Can I use both Kobo Sync and Calibre?**
|
## Additional Resources
|
||||||
A: Yes, but they may conflict. It's recommended to choose one sync method.
|
|
||||||
|
|
||||||
**Q: What happens if I read the same book on Kobo and KOReader?**
|
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
|
||||||
A: Bookhoard will detect conflicts and you can resolve them in the Conflicts UI.
|
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||||
|
- [Bookhoard API Reference](../../developer/api-reference.md)
|
||||||
**Q: Does Kobo sync when in sleep mode?**
|
|
||||||
A: Only if Wi-Fi is enabled and configured to stay active during sleep.
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
|
|
||||||
1. Check the troubleshooting section above
|
|
||||||
2. Review Kobo sync logs in device settings
|
|
||||||
3. Check Bookhoard sync queue and device management pages
|
|
||||||
4. Verify your configuration file is saved correctly
|
|
||||||
5. Open an issue on the Bookhoard GitHub repository
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2026-01-31
|
**Last Updated**: August 2026
|
||||||
**Bookhoard Version**: 1.0
|
**Bookhoard Version**: 1.0
|
||||||
**Kobo Firmware**: 4.30.0+
|
|
||||||
|
|||||||
@@ -9,18 +9,19 @@ KOReader is an open-source e-reader application that supports a wide range of e-
|
|||||||
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
|
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
|
||||||
- Kobo devices (Clara, Aura, Nia, etc.)
|
- Kobo devices (Clara, Aura, Nia, etc.)
|
||||||
- PocketBook devices
|
- PocketBook devices
|
||||||
- Android tablets and phones
|
|
||||||
|
It also runs on Android tablets and phones, although Bookhoard's dedicated mobile apps (coming later) will be the better option there.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Before you begin, make sure you have:
|
Before you begin, make sure you have:
|
||||||
|
|
||||||
- ✅ A Bookhoard instance running and accessible on your network
|
- ✅ A Bookhoard instance running and accessible on your network
|
||||||
- ✅ Your Bookhoard credentials (username and password)
|
- ✅ A web browser logged in to your Bookhoard account (for device approval)
|
||||||
- ✅ A KOReader-compatible e-reader device
|
- ✅ A KOReader-compatible e-reader device
|
||||||
- ✅ Your device connected to the same Wi-Fi network as your Bookhoard instance
|
- ✅ Your device connected to the same Wi-Fi network as your Bookhoard instance
|
||||||
|
|
||||||
## Installation
|
## Installing KOReader
|
||||||
|
|
||||||
### Kindle Devices
|
### Kindle Devices
|
||||||
|
|
||||||
@@ -64,469 +65,132 @@ Before you begin, make sure you have:
|
|||||||
- Open KOReader from your apps menu
|
- Open KOReader from your apps menu
|
||||||
- Enable Wi-Fi in the network settings
|
- Enable Wi-Fi in the network settings
|
||||||
|
|
||||||
## Device Registration
|
## Connecting KOReader to Bookhoard
|
||||||
|
|
||||||
### Step 1: Get Your Bookhoard Instance URL
|
Setup is done **on the server**: you approve the device from the Bookhoard web interface — no usernames, passwords, or tokens to type on the device.
|
||||||
|
|
||||||
Find your Bookhoard instance URL. This will typically be one of:
|
### Step 1: Install the Bookhoard Plugin
|
||||||
|
|
||||||
- **Local Network**: `http://YOUR_COMPUTER_IP:8765`
|
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||||
- **Localhost (if testing)**: `http://localhost:8765`
|
2. Copy it to your KOReader `plugins/` directory
|
||||||
- **Domain (if configured)**: `https://bookhoard.yourdomain.com`
|
3. Restart KOReader
|
||||||
|
|
||||||
### Step 2: Register Your Device in Bookhoard
|
### Step 2: Point the Plugin at Your Server
|
||||||
|
|
||||||
1. Log in to your Bookhoard web interface
|
1. Open KOReader, tap the **wrench icon** at the top
|
||||||
2. Navigate to **Device Management** → **Add New Device**
|
2. Find and tap **Bookhoard sync**
|
||||||
3. Fill in the device details:
|
3. Tap **Server URL**, enter your server address, then tap **OK**:
|
||||||
- **Device Name**: A friendly name (e.g., "My Kindle Paperwhite")
|
|
||||||
- **Device Type**: Select "KOReader"
|
|
||||||
- **Device Identifier**: Enter your device's hardware ID or serial number
|
|
||||||
- On Kindle: Settings → Device Options → Device Info → Serial Number
|
|
||||||
- On Kobo: Settings → Device Information → Serial Number
|
|
||||||
4. Click **Register Device**
|
|
||||||
|
|
||||||
You'll receive:
|
|
||||||
|
|
||||||
- An **Auth URL** to approve the device
|
|
||||||
- A **Device Token** (automatically generated after approval)
|
|
||||||
|
|
||||||
### Step 3: Approve Your Device
|
|
||||||
|
|
||||||
1. **Method A: QR Code**
|
|
||||||
- If displayed, scan the QR code with your phone's camera
|
|
||||||
- This will open the approval page in your browser
|
|
||||||
- Log in and click **Approve**
|
|
||||||
|
|
||||||
2. **Method B: Manual URL**
|
|
||||||
- Copy the Auth URL from the registration confirmation
|
|
||||||
- Open it in your web browser
|
|
||||||
- Log in to your Bookhoard account
|
|
||||||
- Click **Approve Device**
|
|
||||||
|
|
||||||
Your device is now registered and ready to sync!
|
|
||||||
|
|
||||||
## Configure KOReader Sync
|
|
||||||
|
|
||||||
### Step 1: Access KOReader Settings
|
|
||||||
|
|
||||||
1. Open KOReader on your device
|
|
||||||
2. Tap the menu icon (≡) in the top-left corner
|
|
||||||
3. Select **Tools** → **Calibre**
|
|
||||||
|
|
||||||
### Step 2: Configure Wireless Connection
|
|
||||||
|
|
||||||
1. **Enable Calibre Wireless Connection**: Toggle ON
|
|
||||||
2. **Server Address**: Enter your Bookhoard instance URL
|
|
||||||
|
|
||||||
```
|
```
|
||||||
http://YOUR_COMPUTER_IP:8765/api/sync/koreader
|
http://YOUR_COMPUTER_IP:8765
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `YOUR_COMPUTER_IP` with your actual IP address
|
Use your server's LAN IP (or domain if you have one configured).
|
||||||
|
|
||||||
3. **Set Custom Port** (if needed): Keep default or enter `8765`
|
### Step 3: Approve the Device in Bookhoard
|
||||||
|
|
||||||
### Step 3: Configure Authentication
|
1. On your computer or phone, open Bookhoard and go to the **Devices** page (sidebar navigation)
|
||||||
|
2. Refresh the page — your device appears under **Pending Device Registrations**
|
||||||
|
3. Click **Approve** to connect the device
|
||||||
|
|
||||||
1. **Authentication Method**: Select "Basic Auth"
|
Once approved, the plugin picks up its credentials automatically — reading progress sync and OPDS catalog access are set up automatically. No further configuration is needed.
|
||||||
2. **Username**: Your Bookhoard email or username
|
|
||||||
3. **Password**: Your Bookhoard password
|
|
||||||
|
|
||||||
### Step 4: Configure Sync Settings
|
> **Note:** Pending registrations expire after 5 minutes. If yours expires, just re-run the sync from the plugin menu and approve again.
|
||||||
|
|
||||||
1. **Auto Sync**: Enable for automatic sync
|
### Auth Token (Advanced)
|
||||||
2. **Sync Frequency**: Choose from:
|
|
||||||
- Every page turn (recommended for real-time sync)
|
|
||||||
- Every bookmark save
|
|
||||||
- Every highlight
|
|
||||||
- Manual only (sync when you press the sync button)
|
|
||||||
|
|
||||||
3. **What to Sync**: Enable:
|
The Devices page shows each KOReader device's **Auth Token**. You normally never need it (the plugin receives it automatically during approval), but it can be re-entered manually in the plugin settings if you're moving a setup between devices or debugging.
|
||||||
- ✅ Reading progress
|
|
||||||
- ✅ Bookmarks
|
|
||||||
- ✅ Highlights
|
|
||||||
- ✅ Notes
|
|
||||||
|
|
||||||
### Step 5: Test Connection
|
## What Syncs
|
||||||
|
|
||||||
1. Tap **Test Connection** in the Calibre settings
|
Once connected, the following sync automatically in both directions between KOReader and Bookhoard (web and other devices):
|
||||||
2. You should see a success message if configured correctly
|
|
||||||
3. If it fails:
|
|
||||||
- Verify your device is connected to Wi-Fi
|
|
||||||
- Check the server URL is correct
|
|
||||||
- Ensure your Bookhoard instance is running
|
|
||||||
- Verify username and password are correct
|
|
||||||
|
|
||||||
## Using Sync Features
|
- **Reading position** — percentage, chapter, and EPUB CFI where available
|
||||||
|
- **Bookmarks**
|
||||||
|
- **Highlights** — including highlight colors, mapped between the web and KOReader palettes
|
||||||
|
- **Notes** — standalone and attached to highlights
|
||||||
|
|
||||||
### Initial Sync
|
Books are matched automatically using UUIDs, file hashes (SHA-256, format-aware so converted files still match), file aliases, and title/author fallback. If a book can't be matched, it shows up under the device's **Unlinked Books** in Bookhoard, where you can link it manually.
|
||||||
|
|
||||||
When you first enable sync, KOReader will:
|
|
||||||
|
|
||||||
1. Connect to Bookhoard
|
|
||||||
2. Upload your current reading progress
|
|
||||||
3. Download any annotations from the server
|
|
||||||
4. Set up bidirectional sync for future changes
|
|
||||||
|
|
||||||
### Reading Progress Sync
|
|
||||||
|
|
||||||
As you read:
|
|
||||||
|
|
||||||
- Progress updates automatically sync based on your sync frequency
|
|
||||||
- Page turns, chapter changes, and bookmark saves all trigger sync
|
|
||||||
- Sync occurs in the background without interrupting reading
|
|
||||||
|
|
||||||
### Annotations Sync
|
|
||||||
|
|
||||||
- **Bookmarks**: Sync when created or deleted
|
|
||||||
- **Highlights**: Sync when created, edited, or deleted
|
|
||||||
- **Notes**: Sync when created, edited, or deleted
|
|
||||||
- **Linked Notes**: Notes attached to highlights sync together
|
|
||||||
|
|
||||||
### Manual Sync
|
|
||||||
|
|
||||||
To manually trigger a sync:
|
|
||||||
|
|
||||||
1. Open the KOReader menu (≡)
|
|
||||||
2. Select **Tools** → **Calibre**
|
|
||||||
3. Tap **Sync Now**
|
|
||||||
|
|
||||||
The sync status will display:
|
|
||||||
|
|
||||||
- 🟢 **Synced** - All changes uploaded
|
|
||||||
- 🟡 **Syncing...** - In progress
|
|
||||||
- 🔴 **Failed** - Check your network connection
|
|
||||||
|
|
||||||
## Advanced Configuration
|
|
||||||
|
|
||||||
### Offline Mode
|
|
||||||
|
|
||||||
KOReader automatically handles offline scenarios:
|
|
||||||
|
|
||||||
1. Changes are queued locally when offline
|
|
||||||
2. Auto-sync resumes when connected
|
|
||||||
3. Queue processes all pending changes in priority order
|
|
||||||
|
|
||||||
### Checkpoint Sync
|
|
||||||
|
|
||||||
For better battery life, use checkpoint mode:
|
|
||||||
|
|
||||||
1. In KOReader Calibre settings
|
|
||||||
2. Set **Sync Mode** to "Checkpoint"
|
|
||||||
3. Set **Checkpoint Interval** (e.g., every 5 minutes)
|
|
||||||
4. Syncs occur in batches instead of every action
|
|
||||||
|
|
||||||
### Debug Mode
|
|
||||||
|
|
||||||
Enable debug logging if sync isn't working:
|
|
||||||
|
|
||||||
1. KOReader menu → Tools → Calibre
|
|
||||||
2. Enable **Debug Logging**
|
|
||||||
3. Sync and check logs at `/mnt/us/koreader/calibre.log`
|
|
||||||
|
|
||||||
## OPDS Wireless Book Delivery
|
## OPDS Wireless Book Delivery
|
||||||
|
|
||||||
### What is OPDS?
|
Once your device is approved, the plugin also registers Bookhoard's OPDS catalog, so you can browse and download books wirelessly — no USB cable needed.
|
||||||
|
|
||||||
OPDS (Open Publication Distribution System) allows your KOReader device to **wirelessly download books** from Bookhoard - no USB cable needed!
|
|
||||||
|
|
||||||
### OPDS Benefits
|
|
||||||
|
|
||||||
- **Wireless Downloads**: Browse and download books over Wi-Fi
|
|
||||||
- **On-Demand Access**: Your entire library at your fingertips
|
|
||||||
- **Collection Support**: Browse and download from specific collections
|
|
||||||
- **Automatic Progress Sync**: Downloaded books sync progress instantly
|
|
||||||
- **Format Support**: EPUB, KEPUB, PDF, and more
|
|
||||||
|
|
||||||
### Enable OPDS in KOReader
|
|
||||||
|
|
||||||
#### Step 1: Get Your OPDS URL
|
|
||||||
|
|
||||||
1. Log in to Bookhoard web interface
|
|
||||||
2. Go to **Device Management**
|
|
||||||
3. Find your registered KOReader device
|
|
||||||
4. Click **Show OPDS URL**
|
|
||||||
5. Copy the URL (format: `http://YOUR_IP:8765/opds/devices/YOUR_DEVICE_ID/catalog`)
|
|
||||||
|
|
||||||
#### Step 2: Add OPDS Catalog in KOReader
|
|
||||||
|
|
||||||
1. Open KOReader on your device
|
|
||||||
2. Tap the **+** (plus) button on the home screen
|
|
||||||
3. Select **OPDS Catalog**
|
|
||||||
4. Enter catalog details:
|
|
||||||
- **Name**: Bookhoard (or any name you prefer)
|
|
||||||
- **URL**: Paste your OPDS URL from Step 1
|
|
||||||
5. Tap **Save**
|
|
||||||
|
|
||||||
Your Bookhoard library now appears in KOReader's home screen!
|
|
||||||
|
|
||||||
### Browse and Download Books
|
### Browse and Download Books
|
||||||
|
|
||||||
#### Browse Your Library
|
1. In KOReader, open the OPDS catalog list and tap **Bookhoard**
|
||||||
|
2. Browse your library: all books, collections, and recent additions
|
||||||
|
3. Tap a book to see details and **Download** it
|
||||||
|
|
||||||
1. Tap **Bookhoard** on KOReader home screen
|
### Supported Formats
|
||||||
2. You'll see:
|
|
||||||
- **All Books**: Complete library view
|
|
||||||
- **Collections**: Books organized by collections
|
|
||||||
- **Recent**: Latest additions
|
|
||||||
3. Tap any category to browse
|
|
||||||
|
|
||||||
#### Download a Book
|
|
||||||
|
|
||||||
1. Browse to find a book
|
|
||||||
2. Tap the book to see details
|
|
||||||
3. Tap **Download**
|
|
||||||
4. Progress bar shows download status
|
|
||||||
5. Book opens automatically when complete
|
|
||||||
|
|
||||||
#### Download Entire Collections
|
|
||||||
|
|
||||||
1. In Bookhoard catalog, tap **Collections**
|
|
||||||
2. Select a collection
|
|
||||||
3. Tap **Download All** to get all books
|
|
||||||
4. Downloads queue and process in background
|
|
||||||
|
|
||||||
### OPDS Features
|
|
||||||
|
|
||||||
#### Supported Formats
|
|
||||||
|
|
||||||
KOReader OPDS supports:
|
|
||||||
|
|
||||||
- **EPUB**: Standard ebook format
|
- **EPUB**: Standard ebook format
|
||||||
- **KEPUB**: Kobo-optimized format (KOReader handles this well)
|
- **KEPUB**: Kobo-optimized format
|
||||||
- **PDF**: Fixed-layout documents
|
- **PDF**: Fixed-layout documents
|
||||||
- **CBZ**: Comic book archives
|
- **CBZ**: Comic book archives
|
||||||
- **TXT**: Plain text files
|
|
||||||
- **RTF**: Rich text format
|
|
||||||
|
|
||||||
#### Automatic Book Matching
|
Books downloaded via OPDS are automatically matched to your library, so their progress syncs from the first page.
|
||||||
|
|
||||||
Books downloaded via OPDS are automatically matched:
|
|
||||||
|
|
||||||
- Uses SHA-256 hashes for precise matching
|
|
||||||
- Falls back to title/author matching
|
|
||||||
- Links to your existing Bookhoard library
|
|
||||||
- Progress syncs automatically
|
|
||||||
|
|
||||||
#### Collection Integration
|
|
||||||
|
|
||||||
Your Bookhoard collections appear in KOReader:
|
|
||||||
|
|
||||||
- Collection **"To Read"** → KOReader category
|
|
||||||
- Collection **"Science Fiction"** → Browseable section
|
|
||||||
- Custom collections → Preserved organization
|
|
||||||
|
|
||||||
### KOReader OPDS Settings
|
|
||||||
|
|
||||||
#### Update Interval
|
|
||||||
|
|
||||||
Configure how often KOReader checks for new books:
|
|
||||||
|
|
||||||
1. KOReader menu → Tools → OPDS
|
|
||||||
2. Set **Update Interval**: 5min, 15min, 1hr, manual
|
|
||||||
3. **Recommended**: 15min for balance
|
|
||||||
|
|
||||||
#### Download Location
|
|
||||||
|
|
||||||
Choose where to store downloaded books:
|
|
||||||
|
|
||||||
1. KOReader menu → File Browser
|
|
||||||
2. Set **Default Download Folder**
|
|
||||||
3. **Recommended**: `/mnt/us/Documents/` (Kindle) or `/mnt/onboard/Documents/` (Kobo)
|
|
||||||
|
|
||||||
#### Auto-Download
|
|
||||||
|
|
||||||
Automatically download new books from collections:
|
|
||||||
|
|
||||||
1. KOReader menu → Tools → OPDS
|
|
||||||
2. Enable **Auto-Download New Books**
|
|
||||||
3. Select collections to monitor
|
|
||||||
4. New books download automatically when connected to Wi-Fi
|
|
||||||
|
|
||||||
### OPDS Troubleshooting
|
|
||||||
|
|
||||||
#### Catalog Not Loading
|
|
||||||
|
|
||||||
**Problem**: Bookhoard catalog shows error or won't load
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify device is connected to Wi-Fi
|
|
||||||
2. Check OPDS URL is correct in settings
|
|
||||||
3. Try accessing OPDS URL in your browser
|
|
||||||
4. Ensure Bookhoard server is running
|
|
||||||
5. Check Bookhoard device is approved
|
|
||||||
|
|
||||||
#### Download Fails
|
|
||||||
|
|
||||||
**Problem**: Book download starts but fails
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Check Wi-Fi signal strength
|
|
||||||
2. Ensure sufficient storage on device
|
|
||||||
3. Try downloading a smaller book
|
|
||||||
4. Check Bookhoard has the book file
|
|
||||||
5. Review Bookhoard logs for errors
|
|
||||||
|
|
||||||
#### Book Opens But Progress Doesn't Sync
|
|
||||||
|
|
||||||
**Problem**: Downloaded book doesn't sync progress
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Verify book is matched to Bookhoard library
|
|
||||||
2. Check device sync settings are enabled
|
|
||||||
3. Try manual sync from device
|
|
||||||
4. Ensure book exists in Bookhoard with same hash
|
|
||||||
5. Check Bookhoard Progress page
|
|
||||||
|
|
||||||
#### Slow Downloads
|
|
||||||
|
|
||||||
**Problem**: Books take too long to download
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
1. Stay close to Wi-Fi router
|
|
||||||
2. Use 5GHz Wi-Fi if available
|
|
||||||
3. Close other apps using bandwidth
|
|
||||||
4. Download smaller books first
|
|
||||||
5. Consider USB for large books (100MB+)
|
|
||||||
|
|
||||||
### Advanced OPDS Configuration
|
|
||||||
|
|
||||||
#### Custom User-Agent
|
|
||||||
|
|
||||||
Some OPDS catalogs require specific user agent:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
-- In KOReader settings
|
|
||||||
OPDSUserAgent = "KOReader/2024.01"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Authentication Token
|
|
||||||
|
|
||||||
If Bookhoard requires token authentication:
|
|
||||||
|
|
||||||
1. Get token from Bookhoard device settings
|
|
||||||
2. Add to OPDS URL: `?token=YOUR_TOKEN`
|
|
||||||
3. KOReader includes token in all requests
|
|
||||||
|
|
||||||
#### Compression
|
|
||||||
|
|
||||||
Enable compression for faster downloads:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
-- In KOReader settings
|
|
||||||
OPDSCompressionEnabled = true
|
|
||||||
```
|
|
||||||
|
|
||||||
### OPDS vs USB Transfer
|
|
||||||
|
|
||||||
| Feature | OPDS (Wireless) | USB Transfer |
|
|
||||||
| ----------------- | ----------------------------- | ----------------------- |
|
|
||||||
| **Convenience** | ⭐⭐⭐⭐⭐ No cable needed | ⭐⭐ Requires cable |
|
|
||||||
| **Speed** | ⭐⭐⭐ Fast (Wi-Fi dependent) | ⭐⭐⭐⭐⭐ Very fast |
|
|
||||||
| **Bulk Transfer** | ⭐⭐⭐ One at a time | ⭐⭐⭐⭐⭐ Many at once |
|
|
||||||
| **Progress Sync** | ⭐⭐⭐⭐⭐ Instant | ⭐⭐⭐⭐ After transfer |
|
|
||||||
| **Accessibility** | ⭐⭐⭐⭐⭐ Anywhere | ⭐⭐ At computer only |
|
|
||||||
| **Reliability** | ⭐⭐⭐⭐ Very good | ⭐⭐⭐⭐⭐ Excellent |
|
|
||||||
|
|
||||||
**Recommendation**: Use OPDS for daily reading (convenience), USB for bulk library transfers.
|
|
||||||
|
|
||||||
### OPDS Tips and Tricks
|
|
||||||
|
|
||||||
1. **Favorite Collections**: Pin frequently-used collections to home screen
|
|
||||||
2. **Batch Downloads**: Start multiple downloads before leaving Wi-Fi
|
|
||||||
3. **Download Queue**: Downloads continue in background while reading
|
|
||||||
4. **Storage Management**: Check free space before downloading large collections
|
|
||||||
5. **Network Speed**: Use 5GHz Wi-Fi for faster downloads if available
|
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Pending Registration Never Appears
|
||||||
|
|
||||||
|
**Problem**: You entered the Server URL, but no pending registration shows in Bookhoard
|
||||||
|
|
||||||
|
**Solutions**:
|
||||||
|
|
||||||
|
1. Verify the Server URL is correct (no trailing path — just the base address)
|
||||||
|
2. Make sure KOReader is connected to Wi-Fi
|
||||||
|
3. Check the Bookhoard server is reachable from the device's network
|
||||||
|
4. Registrations expire after 5 minutes — re-run the sync and approve quickly
|
||||||
|
|
||||||
### Connection Refused
|
### Connection Refused
|
||||||
|
|
||||||
**Problem**: "Connection refused" error
|
**Problem**: "Connection refused" error on the device
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
||||||
- Verify Bookhoard is running on your computer
|
- Verify Bookhoard is running
|
||||||
- Check the server URL and port (8765)
|
- Check the server address and port (default `8765`)
|
||||||
- Ensure device is on same Wi-Fi network
|
- Ensure the device is on the same Wi-Fi network as the server
|
||||||
- Try using your computer's IP address instead of "localhost"
|
- Use the server's LAN IP instead of `localhost`
|
||||||
|
|
||||||
### Authentication Failed
|
### Sync Not Working After Approval
|
||||||
|
|
||||||
**Problem**: "Authentication failed" error
|
**Problem**: Device shows as approved but changes don't appear in Bookhoard
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
||||||
- Verify username and password
|
- Trigger a manual sync from the plugin menu
|
||||||
- Check your account is active and not locked
|
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
|
||||||
- Try logging in to Bookhoard web interface first
|
- Verify the book appears as an unlinked book for the device and link it if needed
|
||||||
- Reset password if needed
|
- Check Bookhoard server logs for errors
|
||||||
|
|
||||||
### Sync Not Working
|
|
||||||
|
|
||||||
**Problem**: Changes not appearing in Bookhoard
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
- Enable debug logging in KOReader
|
|
||||||
- Check Bookhoard Device Management page for errors
|
|
||||||
- Verify sync is enabled in KOReader settings
|
|
||||||
- Try manual sync to trigger immediate update
|
|
||||||
- Check Bookhoard logs for sync errors
|
|
||||||
|
|
||||||
### Conflicts Detected
|
### Conflicts Detected
|
||||||
|
|
||||||
**Problem**: Sync conflicts when reading on multiple devices
|
**Problem**: Sync conflicts when reading the same book on multiple devices
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
||||||
1. Go to Bookhoard **Conflicts** page
|
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
|
||||||
2. Review conflicting progress from each device
|
2. Review the progress reported by each device
|
||||||
3. Choose which device's progress to keep
|
3. Choose which device's progress to keep
|
||||||
4. Set auto-resolution preference for future conflicts
|
|
||||||
|
|
||||||
### Large Files Not Syncing
|
|
||||||
|
|
||||||
**Problem**: Large annotations or highlights fail to sync
|
|
||||||
|
|
||||||
**Solutions**:
|
|
||||||
|
|
||||||
- Check Bookhoard sync queue for stuck items
|
|
||||||
- Increase sync timeout in KOReader settings
|
|
||||||
- Break up large highlights into smaller segments
|
|
||||||
- Verify network bandwidth is sufficient
|
|
||||||
|
|
||||||
## Security Best Practices
|
## Security Best Practices
|
||||||
|
|
||||||
1. **Use HTTPS**: If deploying Bookhoard publicly, configure SSL/TLS
|
1. **Use HTTPS**: If exposing Bookhoard beyond your LAN, configure SSL/TLS
|
||||||
2. **Strong Password**: Use a secure password for your Bookhoard account
|
2. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
||||||
3. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
3. **Device Authorization**: Only approve pending registrations you initiated
|
||||||
4. **Device Authorization**: Only approve devices you recognize
|
4. **Revoke lost devices**: Remove devices you no longer use from the Devices page
|
||||||
5. **Regular Updates**: Keep KOReader updated to the latest version
|
|
||||||
|
|
||||||
## Additional Resources
|
## Additional Resources
|
||||||
|
|
||||||
- [KOReader Documentation](https://github.com/koreader/koreader)
|
- [KOReader Documentation](https://github.com/koreader/koreader)
|
||||||
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
|
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
|
||||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||||
- [Kobo Setup Guide](kobo-setup.md)
|
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
|
|
||||||
1. Check the troubleshooting section above
|
|
||||||
2. Enable debug logging and review KOReader logs
|
|
||||||
3. Check Bookhoard sync queue and device management pages
|
|
||||||
4. Open an issue on the Bookhoard GitHub repository
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: 2026-01-31
|
**Last Updated**: August 2026
|
||||||
**Bookhoard Version**: 1.0
|
**Bookhoard Version**: 1.0
|
||||||
**KOReader Version**: 2024.01+
|
|
||||||
|
|||||||
@@ -1,60 +1,58 @@
|
|||||||
## Saving Custom Filters
|
# Saving Custom Filters
|
||||||
|
|
||||||
The bookshelf page allows you to save custom filter presets for quick access.
|
The bookshelf (**All Books**) page lets you save custom filter presets for quick access.
|
||||||
|
|
||||||
### How to Save a Filter
|
## Bookshelf Toolbar
|
||||||
|
|
||||||
|
The All Books page has a toolbar with:
|
||||||
|
|
||||||
|
- A **search input** for quick text searches
|
||||||
|
- A **sort** dropdown (title, author, date added, page count)
|
||||||
|
- A **Filters** button that opens the filter drawer (author, tags, series, and more)
|
||||||
|
- **Save**, **Load**, and **Clear** buttons for filter presets
|
||||||
|
|
||||||
|
## How to Save a Filter
|
||||||
|
|
||||||
1. Navigate to the **All Books** page
|
1. Navigate to the **All Books** page
|
||||||
2. Set your desired filters (genre, author, series, etc.)
|
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters**
|
||||||
3. Click the **💾 Save Filter** button
|
3. Click the **Save** button in the toolbar
|
||||||
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
|
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
|
||||||
5. Click **Save**
|
5. Click **Save**
|
||||||
|
|
||||||
### Loading Saved Filters
|
## Loading Saved Filters
|
||||||
|
|
||||||
After saving filters, you can quickly load them from the saved filters dropdown:
|
1. Click the **Load** button to open the **Saved Filters** dropdown
|
||||||
|
2. Click a filter's name to apply it
|
||||||
|
3. The filter values are applied instantly, without a page reload
|
||||||
|
|
||||||
1. Click the **📋 Saved Filters** button (next to the Save Filter button)
|
## Managing Saved Filters
|
||||||
2. Select a filter from the dropdown list
|
|
||||||
3. The filter values are automatically applied to the form
|
|
||||||
4. Your books are instantly filtered to show matching results
|
|
||||||
|
|
||||||
**Tips:**
|
**Delete a filter:**
|
||||||
- Saved filters appear in the dropdown with their names
|
|
||||||
- Hover over a filter to see a delete button (🗑️)
|
|
||||||
- Click a filter name to apply it instantly
|
|
||||||
- Filters are applied without page reload (instant feedback)
|
|
||||||
|
|
||||||
### Managing Saved Filters
|
1. Click the **Load** button to open the **Saved Filters** dropdown
|
||||||
|
2. Click the trash icon next to the filter you want to remove
|
||||||
**View Saved Filters:**
|
3. Confirm deletion
|
||||||
- Saved filters are displayed in the dropdown
|
|
||||||
- Each filter shows its name (e.g., "My Sci-Fi Books")
|
|
||||||
|
|
||||||
**Delete a Filter:**
|
|
||||||
1. Click the **📋 Saved Filters** button
|
|
||||||
2. Hover over the filter you want to delete
|
|
||||||
3. Click the **🗑️** delete button
|
|
||||||
4. Confirm deletion
|
|
||||||
5. The filter is removed from your list
|
|
||||||
|
|
||||||
**Filter Privacy:**
|
**Filter Privacy:**
|
||||||
|
|
||||||
Saved filters are **private to your account**. Other users cannot see or modify your filters.
|
Saved filters are **private to your account**. Other users cannot see or modify your filters.
|
||||||
|
|
||||||
### Common Use Cases
|
## Common Use Cases
|
||||||
|
|
||||||
**Reading by Genre:**
|
**Reading by Genre:**
|
||||||
1. Filter by genre: "Science Fiction"
|
|
||||||
|
1. Filter by tag: "Science Fiction"
|
||||||
2. Save as "Sci-Fi Books"
|
2. Save as "Sci-Fi Books"
|
||||||
3. Quickly access all your sci-fi collection anytime
|
3. Quickly access all your sci-fi collection anytime
|
||||||
|
|
||||||
**Author Collections:**
|
**Author Collections:**
|
||||||
|
|
||||||
1. Filter by author: "Isaac Asimov"
|
1. Filter by author: "Isaac Asimov"
|
||||||
2. Save as "Asimov Books"
|
2. Save as "Asimov Books"
|
||||||
3. Switch between different author collections instantly
|
3. Switch between different author collections instantly
|
||||||
|
|
||||||
**Series Tracking:**
|
**Series Tracking:**
|
||||||
|
|
||||||
1. Filter by series: "Foundation"
|
1. Filter by series: "Foundation"
|
||||||
2. Save as "Foundation Series"
|
2. Save as "Foundation Series"
|
||||||
3. Track your progress through a series
|
3. Track your progress through a series
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ Your profile contains your account information and preferences.
|
|||||||
|
|
||||||
### How to Update
|
### How to Update
|
||||||
|
|
||||||
1. Click on your **username** (top-right)
|
1. Click on your **username** at the bottom of the sidebar to expand the account menu
|
||||||
2. Select **Profile** from the dropdown
|
2. Select **Profile**
|
||||||
3. Edit any fields in the "Account Information" section
|
3. Edit any fields in the "Account Information" section
|
||||||
4. Click **Update Profile**
|
4. Click **Save Changes**
|
||||||
5. Changes take effect immediately
|
5. Changes take effect immediately
|
||||||
|
|
||||||
### Fields You Can Update
|
### Fields You Can Update
|
||||||
@@ -65,7 +65,7 @@ When you delete your account:
|
|||||||
1. Go to **Profile** page
|
1. Go to **Profile** page
|
||||||
2. Scroll to "Danger Zone" (bottom of page)
|
2. Scroll to "Danger Zone" (bottom of page)
|
||||||
3. Click **Remove My Account**
|
3. Click **Remove My Account**
|
||||||
4. Confirm by clicking "OK" in the popup
|
4. Confirm the deletion prompt
|
||||||
|
|
||||||
**Note:** If you're the last admin, you cannot delete your account for security reasons.
|
**Note:** If you're the last admin, you cannot delete your account for security reasons.
|
||||||
|
|
||||||
@@ -75,10 +75,12 @@ Personalize your reading experience with different color themes.
|
|||||||
|
|
||||||
### Quick Theme Switch
|
### Quick Theme Switch
|
||||||
|
|
||||||
1. Click the **paintbrush icon** (top-right, next to your username)
|
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||||
2. Select a theme from the dropdown
|
2. Select a theme from the list; your active theme is marked with a checkmark
|
||||||
3. Changes apply instantly
|
3. Changes apply instantly
|
||||||
|
|
||||||
|
For the full theme list and bookshelf background (wood) options, see [Themes and Wood Paneling](themes.md).
|
||||||
|
|
||||||
### Available Themes
|
### Available Themes
|
||||||
|
|
||||||
- **Tokyo Night** (default) - Blue/purple accents
|
- **Tokyo Night** (default) - Blue/purple accents
|
||||||
@@ -88,9 +90,7 @@ Personalize your reading experience with different color themes.
|
|||||||
- **Monokai** - Classic vibrant colors
|
- **Monokai** - Classic vibrant colors
|
||||||
- **One Dark Pro** - Atom editor inspired
|
- **One Dark Pro** - Atom editor inspired
|
||||||
- **Material Dark** - Google Material Design
|
- **Material Dark** - Google Material Design
|
||||||
- **Wood Light** - Light wood texture
|
- **Catppuccin Mocha / Macchiato / Frappé / Latte** - Soothing pastel palettes (Latte is light)
|
||||||
- **Wood Dark** - Dark wood texture
|
|
||||||
- **Wood Mahogany** - Reddish-brown wood
|
|
||||||
|
|
||||||
## For Admin Users
|
## For Admin Users
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ Tags are keywords or categories assigned to books, such as:
|
|||||||
|
|
||||||
### Filtering by Tags
|
### Filtering by Tags
|
||||||
|
|
||||||
1. Navigate to the **Bookshelf** page
|
1. Navigate to the **All Books** page
|
||||||
2. Use the **Tags** filter input
|
2. Click **Filters** in the toolbar to open the filter drawer
|
||||||
3. Start typing to see autocomplete suggestions
|
3. Use the **Tags** filter input
|
||||||
4. Select a tag or press Enter to filter
|
4. Start typing to see autocomplete suggestions
|
||||||
|
5. Select a tag or press Enter, then click **Apply Filters**
|
||||||
|
|
||||||
**Example:** Typing "Sci" will suggest "Science Fiction"
|
**Example:** Typing "Sci" will suggest "Science Fiction"
|
||||||
|
|
||||||
|
|||||||
+43
-40
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
🔄 **Automatic Sync** - Your reading progress syncs automatically when you turn pages
|
🔄 **Automatic Sync** - Your reading progress syncs automatically when you turn pages
|
||||||
|
|
||||||
📱 **Multi-Platform** - Works with web browsers, KOReader, Kobo devices, and mobile apps
|
📱 **Multi-Platform** - Works with web browsers and KOReader, with native Kobo sync and mobile apps on the roadmap
|
||||||
|
|
||||||
📍 **Precise Location Tracking** - Supports EPUB CFI, page numbers, percentages, and character offsets
|
📍 **Precise Location Tracking** - Supports EPUB CFI, page numbers, percentages, and character offsets
|
||||||
|
|
||||||
@@ -37,19 +37,24 @@
|
|||||||
|
|
||||||
### Currently Supported ✅
|
### Currently Supported ✅
|
||||||
|
|
||||||
| Platform | Status | Sync Method | Notes |
|
| Platform | Status | Sync Method | Notes |
|
||||||
| ---------------- | ------------------ | --------------------------- | ------------------------------ |
|
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
|
||||||
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
||||||
| **KOReader** | ✅ Fully Supported | Wi-Fi (Calibre-compatible) | Kindle, Kobo, PocketBook, etc. |
|
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
|
||||||
| **Kobo Devices** | ✅ Fully Supported | Wi-Fi (Kobo API-compatible) | Clara, Libra, Sage, etc. |
|
|
||||||
|
|
||||||
### Coming Soon 🚧
|
### Coming Soon 🚧
|
||||||
|
|
||||||
| Platform | Expected Release |
|
| Platform | Status |
|
||||||
| --------------------- | ---------------- |
|
| --------------------- | ------------------------------------------------------------- |
|
||||||
| **Mobile Apps** | Q2 2026 |
|
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
|
||||||
| **Kindle Devices** | Q3 2026 |
|
| **Mobile Apps** | Android/iOS apps coming later |
|
||||||
| **Remarkable Tablet** | Q4 2026 |
|
|
||||||
|
### On the Roadmap 🔭
|
||||||
|
|
||||||
|
| Platform | Status |
|
||||||
|
| --------------------- | ---------------------------------------- |
|
||||||
|
| **Kindle Devices** | Under consideration (no date yet) |
|
||||||
|
| **Remarkable Tablet** | Under consideration (no date yet) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -74,22 +79,21 @@
|
|||||||
|
|
||||||
For detailed device configuration instructions, see the appropriate setup guide:
|
For detailed device configuration instructions, see the appropriate setup guide:
|
||||||
|
|
||||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Kobo e-reader configuration
|
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration (Kindle, Kobo, and PocketBook hardware)
|
||||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration
|
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon; use KOReader today)
|
||||||
|
|
||||||
### Quick Overview
|
### Quick Overview
|
||||||
|
|
||||||
**Registration Process**:
|
**Registration Process** (KOReader):
|
||||||
|
|
||||||
1. Register device in Bookhoard web interface (Settings → Devices)
|
1. Install the Bookhoard plugin and enter your server URL in KOReader
|
||||||
2. Approve device via QR code or approval URL
|
2. Approve the pending registration on the Bookhoard **Devices** page (sidebar navigation)
|
||||||
3. Configure sync settings on your device
|
3. That's it — sync starts automatically once approved
|
||||||
4. Start reading - progress syncs automatically!
|
|
||||||
|
|
||||||
**Device Management**:
|
**Device Management**:
|
||||||
|
|
||||||
```
|
```
|
||||||
Settings → Devices
|
Devices page (sidebar navigation)
|
||||||
```
|
```
|
||||||
|
|
||||||
You can:
|
You can:
|
||||||
@@ -124,7 +128,7 @@ Sometimes a book on your device can't be automatically matched to your library.
|
|||||||
### Viewing Unlinked Books
|
### Viewing Unlinked Books
|
||||||
|
|
||||||
```
|
```
|
||||||
Settings → Devices → Select Device → View Unlinked Books
|
Devices page → select device → unlinked books
|
||||||
```
|
```
|
||||||
|
|
||||||
### Resolving Unlinked Books
|
### Resolving Unlinked Books
|
||||||
@@ -284,7 +288,7 @@ This ensures your highlights work across all devices, even with different page c
|
|||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
||||||
1. Check device is online: `Settings → Devices`
|
1. Check device is online: Devices page (sidebar navigation)
|
||||||
2. Verify sync is enabled for the device
|
2. Verify sync is enabled for the device
|
||||||
3. Check sync URL is correct
|
3. Check sync URL is correct
|
||||||
4. Ensure device has network connection
|
4. Ensure device has network connection
|
||||||
@@ -318,7 +322,7 @@ This ensures your highlights work across all devices, even with different page c
|
|||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
|
|
||||||
1. Go to `Settings → Conflicts`
|
1. Open the book's detail page and click **Sync Progress**, or go to the Conflicts page (`/conflicts`)
|
||||||
2. Review both device progress
|
2. Review both device progress
|
||||||
3. Choose which device's progress to keep
|
3. Choose which device's progress to keep
|
||||||
4. Or choose "Merge" (keeps furthest progress)
|
4. Or choose "Merge" (keeps furthest progress)
|
||||||
@@ -331,7 +335,7 @@ This ensures your highlights work across all devices, even with different page c
|
|||||||
|
|
||||||
1. Switch to checkpoint mode
|
1. Switch to checkpoint mode
|
||||||
2. Increase sync interval
|
2. Increase sync interval
|
||||||
3. Use Wi-Fi instead of cellular (for mobile)
|
3. Sync less frequently
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -341,7 +345,7 @@ This ensures your highlights work across all devices, even with different page c
|
|||||||
|
|
||||||
✅ **DO**:
|
✅ **DO**:
|
||||||
|
|
||||||
- Use checkpoint mode when on cellular data
|
- Use checkpoint mode when on slow connections
|
||||||
- Keep device firmware updated
|
- Keep device firmware updated
|
||||||
- Use Wi-Fi when available
|
- Use Wi-Fi when available
|
||||||
- Approve only devices you own
|
- Approve only devices you own
|
||||||
@@ -369,7 +373,7 @@ This ensures your highlights work across all devices, even with different page c
|
|||||||
|
|
||||||
- **Primary Device**: KOReader on e-reader
|
- **Primary Device**: KOReader on e-reader
|
||||||
- **Secondary Device**: Web browser (work/home)
|
- **Secondary Device**: Web browser (work/home)
|
||||||
- **Mobile Device**: Phone app (commute)
|
- **On the go**: Web browser on a phone (dedicated mobile apps coming later)
|
||||||
|
|
||||||
**Sync Strategy**:
|
**Sync Strategy**:
|
||||||
|
|
||||||
@@ -393,7 +397,7 @@ This ensures your highlights work across all devices, even with different page c
|
|||||||
**Manual Resolution**:
|
**Manual Resolution**:
|
||||||
|
|
||||||
```
|
```
|
||||||
Settings → Conflicts → Select conflict → Choose winner
|
Book detail → Sync Progress → choose winner
|
||||||
```
|
```
|
||||||
|
|
||||||
**Options**:
|
**Options**:
|
||||||
@@ -408,7 +412,7 @@ Settings → Conflicts → Select conflict → Choose winner
|
|||||||
**View Queue Status**:
|
**View Queue Status**:
|
||||||
|
|
||||||
```
|
```
|
||||||
Settings → Devices → Select Device → View Queue
|
Devices page → sync queue section
|
||||||
```
|
```
|
||||||
|
|
||||||
**Queue Stats**:
|
**Queue Stats**:
|
||||||
@@ -436,7 +440,7 @@ Settings → Devices → Select Device → View Queue
|
|||||||
**View History**:
|
**View History**:
|
||||||
|
|
||||||
```
|
```
|
||||||
Book → Reading History
|
Progress page (sidebar navigation), or the book's detail page
|
||||||
```
|
```
|
||||||
|
|
||||||
**Privacy**:
|
**Privacy**:
|
||||||
@@ -503,9 +507,8 @@ Book → Reading History
|
|||||||
### For Better Battery Life
|
### For Better Battery Life
|
||||||
|
|
||||||
1. **Checkpoint mode** - Fewer sync requests
|
1. **Checkpoint mode** - Fewer sync requests
|
||||||
2. **Wi-Fi only** - Disable cellular
|
2. **Increase sync interval** - Fewer updates
|
||||||
3. **Increase sync interval** - Fewer updates
|
3. **Close when not reading** - Reduces background activity
|
||||||
4. **Close when not reading** - Reduces background activity
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -523,7 +526,7 @@ A: No, devices are tied to individual accounts for security.
|
|||||||
A: All sync data for that book is removed from the server.
|
A: All sync data for that book is removed from the server.
|
||||||
|
|
||||||
**Q: Can I export my reading data?**
|
**Q: Can I export my reading data?**
|
||||||
A: Yes! Settings → Export → Download sync data.
|
A: Reading data isn't exportable from the UI yet — it's accessible via the API.
|
||||||
|
|
||||||
**Q: Does sync work over the internet?**
|
**Q: Does sync work over the internet?**
|
||||||
A: Yes, if your server is publicly accessible with HTTPS.
|
A: Yes, if your server is publicly accessible with HTTPS.
|
||||||
@@ -537,7 +540,7 @@ A: Approximately 1KB per page turn, 50KB per annotation.
|
|||||||
A: Uses percentage and EPUB CFI for universal positioning.
|
A: Uses percentage and EPUB CFI for universal positioning.
|
||||||
|
|
||||||
**Q: Can I sync with Calibre anymore?**
|
**Q: Can I sync with Calibre anymore?**
|
||||||
A: Yes! KOReader sync is Calibre-compatible.
|
A: Bookhoard's KOReader sync uses a dedicated plugin (server-side approval, device tokens) — no Calibre involvement required.
|
||||||
|
|
||||||
**Q: What if I lose my device?**
|
**Q: What if I lose my device?**
|
||||||
A: Revoke it in settings and register a new one.
|
A: Revoke it in settings and register a new one.
|
||||||
@@ -571,18 +574,18 @@ A: Yes, HTTPS/TLS 1.3 for all sync traffic.
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
### Version 1.0.0 (January 2026)
|
### Version 1.0.x (2026)
|
||||||
|
|
||||||
- ✅ Initial release
|
|
||||||
- ✅ KOReader sync support
|
|
||||||
- ✅ Kobo device support
|
|
||||||
- ✅ Web sync support
|
- ✅ Web sync support
|
||||||
|
- ✅ KOReader sync (progress, bookmarks, highlights, notes)
|
||||||
- ✅ Conflict resolution
|
- ✅ Conflict resolution
|
||||||
- ✅ Offline queue
|
- ✅ Offline queue
|
||||||
- ✅ Real-time WebSocket sync
|
- ✅ Real-time WebSocket sync
|
||||||
|
- 🚧 Native Kobo sync (coming soon)
|
||||||
|
- 🚧 Mobile apps (coming later)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated**: January 31, 2026
|
**Last Updated**: August 2026
|
||||||
**Version**: 1.0.0
|
**Version**: 1.0
|
||||||
**License**: MIT
|
**License**: AGPL-3.0
|
||||||
|
|||||||
+8
-6
@@ -18,10 +18,12 @@ Bookhoard includes multiple color themes to suit your preferences:
|
|||||||
|
|
||||||
### Changing Your Theme
|
### Changing Your Theme
|
||||||
|
|
||||||
1. Click the theme icon (palette) in the header
|
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||||
2. Select your preferred color theme
|
2. Pick a theme from the list — each option shows its color swatch, and your active theme is marked with a checkmark
|
||||||
3. Your choice is saved automatically and synced across devices
|
3. Your choice is saved automatically and synced across devices
|
||||||
|
|
||||||
|
On small screens, open the sidebar with the menu button in the top bar first.
|
||||||
|
|
||||||
## Wood Paneling
|
## Wood Paneling
|
||||||
|
|
||||||
Wood paneling adds texture to your dashboard bookshelf background, giving it a classic bookshelf feel.
|
Wood paneling adds texture to your dashboard bookshelf background, giving it a classic bookshelf feel.
|
||||||
@@ -35,10 +37,10 @@ Wood paneling adds texture to your dashboard bookshelf background, giving it a c
|
|||||||
|
|
||||||
### Applying Wood Paneling
|
### Applying Wood Paneling
|
||||||
|
|
||||||
1. Click the theme icon (palette) in the header
|
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||||
2. Scroll to "Bookshelf Background" section
|
2. Scroll to the **Bookshelf** section below the theme list
|
||||||
3. Select your preferred wood texture
|
3. Select your preferred wood texture (each option shows a texture swatch; **None** is the default)
|
||||||
4. Texture is applied to dashboard bookshelf only
|
4. Texture is applied to the dashboard bookshelf background
|
||||||
|
|
||||||
**Note:** Wood paneling is a browser preference and is not synced across devices.
|
**Note:** Wood paneling is a browser preference and is not synced across devices.
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,16 @@ Welcome to the Bookhoard user documentation. This section contains guides for us
|
|||||||
|
|
||||||
Learn how to configure your e-reader devices to sync with Bookhoard:
|
Learn how to configure your e-reader devices to sync with Bookhoard:
|
||||||
|
|
||||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Complete guide for Kobo e-readers
|
|
||||||
- Device registration
|
|
||||||
- Sync configuration
|
|
||||||
- OPDS wireless book delivery
|
|
||||||
- Troubleshooting
|
|
||||||
|
|
||||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
|
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
|
||||||
- Installation on Kindle/Kobo/PocketBook
|
- Installation on Kindle/Kobo/PocketBook
|
||||||
- Sync setup
|
- Plugin setup with server-side device approval
|
||||||
|
- Progress, bookmark, highlight, and note sync
|
||||||
- OPDS catalog access
|
- OPDS catalog access
|
||||||
- Troubleshooting
|
- Troubleshooting
|
||||||
|
|
||||||
|
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon)
|
||||||
|
- In the meantime, KOReader works great on Kobo hardware
|
||||||
|
|
||||||
## 🔄 Sync Configuration
|
## 🔄 Sync Configuration
|
||||||
|
|
||||||
- **[Universal Sync Guide](sync-guide.md)** - Understanding and using sync features
|
- **[Universal Sync Guide](sync-guide.md)** - Understanding and using sync features
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ require (
|
|||||||
github.com/yuin/goldmark v1.8.2
|
github.com/yuin/goldmark v1.8.2
|
||||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
||||||
golang.org/x/crypto v0.50.0
|
golang.org/x/crypto v0.50.0
|
||||||
|
golang.org/x/net v0.53.0
|
||||||
golang.org/x/text v0.36.0
|
golang.org/x/text v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,7 +58,6 @@ require (
|
|||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/xyproto/randomstring v1.2.0 // indirect
|
github.com/xyproto/randomstring v1.2.0 // indirect
|
||||||
golang.org/x/image v0.39.0 // indirect
|
golang.org/x/image v0.39.0 // indirect
|
||||||
golang.org/x/net v0.53.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.43.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
|
|||||||
@@ -45,30 +45,19 @@ func (c *Config) DatabaseURL() string {
|
|||||||
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
|
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBaseURL returns the base URL from system configuration database with fallback to config/env var
|
// SystemConfigGetter returns the value for a system config key, or an error.
|
||||||
func GetBaseURL(ctx context.Context, db interface{}) string {
|
type SystemConfigGetter func(ctx context.Context, key string) (string, error)
|
||||||
// Try to get from database first
|
|
||||||
type SystemConfigQuerier interface {
|
|
||||||
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if querier, ok := db.(SystemConfigQuerier); ok {
|
// GetBaseURL returns the base URL from system configuration database, or empty
|
||||||
config, err := querier.GetSystemConfig(ctx, "base_url")
|
// string if not set. The getter abstraction avoids importing the database package.
|
||||||
if err == nil && config.Value != "" {
|
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
|
||||||
return config.Value
|
val, err := getter(ctx, "base_url")
|
||||||
}
|
if err == nil && val != "" {
|
||||||
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return empty string - caller should use their own fallback
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemConfigRow represents a system configuration row
|
|
||||||
type SystemConfigRow struct {
|
|
||||||
Key string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func getEnv(key, defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
+73
-36
@@ -92,6 +92,17 @@ type DictionaryCache struct {
|
|||||||
AccessedAt pgtype.Timestamptz `db:"accessed_at" json:"accessed_at"`
|
AccessedAt pgtype.Timestamptz `db:"accessed_at" json:"accessed_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HashConflicts struct {
|
||||||
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
|
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||||
|
FileSha256 string `db:"file_sha256" json:"file_sha256"`
|
||||||
|
Status string `db:"status" json:"status"`
|
||||||
|
Resolution pgtype.Text `db:"resolution" json:"resolution"`
|
||||||
|
ResolvedBy pgtype.UUID `db:"resolved_by" json:"resolved_by"`
|
||||||
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||||
|
}
|
||||||
|
|
||||||
type KoboEntitlements struct {
|
type KoboEntitlements struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||||
@@ -155,40 +166,55 @@ type LibraryVisibility struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MediaBookmarks struct {
|
type MediaBookmarks struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
||||||
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
||||||
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
||||||
Title string `db:"title" json:"title"`
|
Title string `db:"title" json:"title"`
|
||||||
Position pgtype.Text `db:"position" json:"position"`
|
Position pgtype.Text `db:"position" json:"position"`
|
||||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||||
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||||
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||||
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||||
|
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
|
||||||
|
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
|
||||||
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||||
|
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||||
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaHighlights struct {
|
type MediaHighlights struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
SelectionText string `db:"selection_text" json:"selection_text"`
|
SelectionText string `db:"selection_text" json:"selection_text"`
|
||||||
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||||
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||||
Color pgtype.Text `db:"color" json:"color"`
|
Color pgtype.Text `db:"color" json:"color"`
|
||||||
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
||||||
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
||||||
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
||||||
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
||||||
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||||
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||||
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
||||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||||
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||||
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||||
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||||
|
NoteText pgtype.Text `db:"note_text" json:"note_text"`
|
||||||
|
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||||
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaItemFormats struct {
|
type MediaItemFormats struct {
|
||||||
@@ -304,6 +330,11 @@ type MediaNotes struct {
|
|||||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||||
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
||||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||||
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||||
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||||
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||||
|
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||||
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaRatings struct {
|
type MediaRatings struct {
|
||||||
@@ -380,6 +411,7 @@ type ReadingProgress struct {
|
|||||||
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
||||||
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
||||||
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
||||||
|
ContextText pgtype.Text `db:"context_text" json:"context_text"`
|
||||||
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
||||||
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
||||||
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
||||||
@@ -463,11 +495,16 @@ type SystemConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemSettings struct {
|
type SystemSettings struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
SettingKey string `db:"setting_key" json:"setting_key"`
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
||||||
SettingValue string `db:"setting_value" json:"setting_value"`
|
SettingValue string `db:"setting_value" json:"setting_value"`
|
||||||
Description pgtype.Text `db:"description" json:"description"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
|
||||||
|
MinValue pgtype.Text `db:"min_value" json:"min_value"`
|
||||||
|
MaxValue pgtype.Text `db:"max_value" json:"max_value"`
|
||||||
|
RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
|
||||||
|
Category pgtype.Text `db:"category" json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnlinkedBooks struct {
|
type UnlinkedBooks struct {
|
||||||
|
|||||||
@@ -26,13 +26,15 @@ type Querier interface {
|
|||||||
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
||||||
// Cleanup expired OPDS tokens
|
// Cleanup expired OPDS tokens
|
||||||
CleanupExpiredOpdsTokens(ctx context.Context) error
|
CleanupExpiredOpdsTokens(ctx context.Context) error
|
||||||
CleanupExpiredRefreshTokens(ctx context.Context) error
|
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
||||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||||
|
CountAdmins(ctx context.Context) (int64, error)
|
||||||
// Count unlinked books for a device
|
// Count unlinked books for a device
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
|
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||||
// COLLECTIONS QUERIES
|
// COLLECTIONS QUERIES
|
||||||
// Create collection
|
// Create collection
|
||||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
||||||
@@ -51,11 +53,17 @@ type Querier interface {
|
|||||||
// Create device shelf mapping
|
// Create device shelf mapping
|
||||||
CreateDeviceShelfMapping(ctx context.Context, arg CreateDeviceShelfMappingParams) (DeviceShelfMappings, error)
|
CreateDeviceShelfMapping(ctx context.Context, arg CreateDeviceShelfMappingParams) (DeviceShelfMappings, error)
|
||||||
CreateDictionaryEntry(ctx context.Context, arg CreateDictionaryEntryParams) (DictionaryCache, error)
|
CreateDictionaryEntry(ctx context.Context, arg CreateDictionaryEntryParams) (DictionaryCache, error)
|
||||||
|
// HASH CONFLICTS QUERIES
|
||||||
|
// Record a pending hash conflict (no-op if the group is already tracked, so
|
||||||
|
// resolved groups stay resolved and are never re-flagged)
|
||||||
|
CreateHashConflict(ctx context.Context, arg CreateHashConflictParams) error
|
||||||
// Libraries queries
|
// Libraries queries
|
||||||
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
||||||
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
||||||
|
CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error)
|
||||||
// Media Highlights queries
|
// Media Highlights queries
|
||||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||||
|
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
||||||
// Media Items queries
|
// Media Items queries
|
||||||
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||||
// MEDIA ITEM FORMATS QUERIES
|
// MEDIA ITEM FORMATS QUERIES
|
||||||
@@ -63,6 +71,7 @@ type Querier interface {
|
|||||||
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
||||||
// Media Notes queries
|
// Media Notes queries
|
||||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||||
|
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
|
||||||
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
||||||
// OPDS TOKENS QUERIES
|
// OPDS TOKENS QUERIES
|
||||||
// Create OPDS token
|
// Create OPDS token
|
||||||
@@ -124,10 +133,17 @@ type Querier interface {
|
|||||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||||
|
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||||
|
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
||||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||||
|
// ============================================
|
||||||
|
// ANNOTATION SERVE QUERIES
|
||||||
|
// ============================================
|
||||||
|
GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error)
|
||||||
// Get all system config
|
// Get all system config
|
||||||
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
||||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||||
|
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||||
// Get collection
|
// Get collection
|
||||||
@@ -174,6 +190,7 @@ type Querier interface {
|
|||||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||||
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
|
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
|
||||||
|
GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error)
|
||||||
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
||||||
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
||||||
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
|
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
|
||||||
@@ -196,8 +213,17 @@ type Querier interface {
|
|||||||
// LIBRARY WITH TYPE INFO QUERIES
|
// LIBRARY WITH TYPE INFO QUERIES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
|
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
|
||||||
|
GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error)
|
||||||
|
// ============================================
|
||||||
|
// ANNOTATION SYNC QUERIES (bookmarks)
|
||||||
|
// ============================================
|
||||||
|
GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error)
|
||||||
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
|
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
|
||||||
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error)
|
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error)
|
||||||
|
// ============================================
|
||||||
|
// ANNOTATION SYNC QUERIES (highlights)
|
||||||
|
// ============================================
|
||||||
|
GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error)
|
||||||
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
|
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
|
||||||
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||||
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
|
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
|
||||||
@@ -213,13 +239,21 @@ type Querier interface {
|
|||||||
GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text) (MediaItems, error)
|
GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text) (MediaItems, error)
|
||||||
// Get media item by SHA-256 hash
|
// Get media item by SHA-256 hash
|
||||||
GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItems, error)
|
GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItems, error)
|
||||||
|
// Get media item by SHA-256 hash within a specific library (content dedup)
|
||||||
|
GetMediaItemBySHA256AndLibrary(ctx context.Context, arg GetMediaItemBySHA256AndLibraryParams) (MediaItems, error)
|
||||||
// Get media item format by SHA-256
|
// Get media item format by SHA-256
|
||||||
GetMediaItemFormatBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItemFormats, error)
|
GetMediaItemFormatBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItemFormats, error)
|
||||||
// Get media item format by type
|
// Get media item format by type
|
||||||
GetMediaItemFormatByType(ctx context.Context, arg GetMediaItemFormatByTypeParams) (MediaItemFormats, error)
|
GetMediaItemFormatByType(ctx context.Context, arg GetMediaItemFormatByTypeParams) (MediaItemFormats, error)
|
||||||
// Get media item formats
|
// Get media item formats
|
||||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||||
|
// Per-item user-data counts, used when choosing which duplicate copy to keep
|
||||||
|
GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error)
|
||||||
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
||||||
|
// ============================================
|
||||||
|
// ANNOTATION SYNC QUERIES (notes)
|
||||||
|
// ============================================
|
||||||
|
GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error)
|
||||||
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
||||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
||||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
||||||
@@ -256,7 +290,9 @@ type Querier interface {
|
|||||||
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
|
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
|
||||||
// System Settings queries
|
// System Settings queries
|
||||||
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
||||||
|
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
|
||||||
GetSystemTimezone(ctx context.Context) (string, error)
|
GetSystemTimezone(ctx context.Context) (string, error)
|
||||||
|
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||||
// Get universal progress for a book
|
// Get universal progress for a book
|
||||||
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
||||||
// Get unlinked book by ContentId
|
// Get unlinked book by ContentId
|
||||||
@@ -280,6 +316,8 @@ type Querier interface {
|
|||||||
// Get user reading history for analytics
|
// Get user reading history for analytics
|
||||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
|
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||||
|
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||||
// Check if book is in collection
|
// Check if book is in collection
|
||||||
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
||||||
@@ -289,12 +327,25 @@ type Querier interface {
|
|||||||
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
||||||
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
||||||
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
||||||
|
// ============================================
|
||||||
|
// ANNOTATION HISTORY (deleted-annotation archive)
|
||||||
|
// ============================================
|
||||||
|
// Lists every currently-tombstoned annotation for a book regardless of the
|
||||||
|
// tombstone TTL: this backs the book page's "recently deleted" history where
|
||||||
|
// users can restore or permanently remove entries. Rows whose tombstones have
|
||||||
|
// been purged by the daily maintenance sweep no longer exist at all.
|
||||||
|
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
||||||
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||||
|
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||||
|
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
|
||||||
|
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
|
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
|
||||||
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
|
||||||
|
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
|
||||||
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||||
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
||||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||||
@@ -302,6 +353,14 @@ type Querier interface {
|
|||||||
// List unresolved unlinked books with pagination
|
// List unresolved unlinked books with pagination
|
||||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
|
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
|
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
|
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
|
PurgeMediaBookmarkByID(ctx context.Context, arg PurgeMediaBookmarkByIDParams) (int64, error)
|
||||||
|
// Permanent removal from the history (distinct from the TTL-driven purge,
|
||||||
|
// which is maintenance). Scoped to the owning user and book.
|
||||||
|
PurgeMediaHighlightByID(ctx context.Context, arg PurgeMediaHighlightByIDParams) (int64, error)
|
||||||
|
PurgeMediaNoteByID(ctx context.Context, arg PurgeMediaNoteByIDParams) (int64, error)
|
||||||
// Query media items by multiple identifiers with confidence scoring
|
// Query media items by multiple identifiers with confidence scoring
|
||||||
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
||||||
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
||||||
@@ -309,11 +368,17 @@ type Querier interface {
|
|||||||
// Remove book from collection
|
// Remove book from collection
|
||||||
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
||||||
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
||||||
|
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||||
|
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
||||||
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
||||||
|
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
||||||
ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error)
|
ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error)
|
||||||
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
|
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
|
||||||
// Resolve unlinked book
|
// Resolve unlinked book
|
||||||
ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBookParams) (UnlinkedBooks, error)
|
ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBookParams) (UnlinkedBooks, error)
|
||||||
|
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
|
||||||
|
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error)
|
||||||
|
RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error)
|
||||||
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
|
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
|
||||||
RevokeDevice(ctx context.Context, id pgtype.UUID) error
|
RevokeDevice(ctx context.Context, id pgtype.UUID) error
|
||||||
// Revoke OPDS token
|
// Revoke OPDS token
|
||||||
@@ -332,6 +397,12 @@ type Querier interface {
|
|||||||
// Set system config
|
// Set system config
|
||||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||||
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
||||||
|
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
||||||
|
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
|
||||||
|
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
|
||||||
|
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
|
||||||
|
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||||
|
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
|
||||||
// Update collection
|
// Update collection
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
@@ -355,7 +426,9 @@ type Querier interface {
|
|||||||
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
||||||
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
||||||
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error)
|
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error)
|
||||||
|
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
|
||||||
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
||||||
|
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
||||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
||||||
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||||
// Update media item format
|
// Update media item format
|
||||||
@@ -373,6 +446,7 @@ type Querier interface {
|
|||||||
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
||||||
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
|
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
|
||||||
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
|
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
|
||||||
|
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
||||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||||
@@ -391,6 +465,7 @@ type Querier interface {
|
|||||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||||
|
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
|
|||||||
+1951
-22
File diff suppressed because it is too large
Load Diff
@@ -137,10 +137,19 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
ORDER BY l.created_at ASC;
|
ORDER BY l.created_at ASC;
|
||||||
|
|
||||||
|
-- name: GetVisibleLibraryMediaCounts :many
|
||||||
|
SELECT l.id, COUNT(mi.id) as media_count
|
||||||
|
FROM libraries l
|
||||||
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||||
|
LEFT JOIN media_items mi ON mi.library_id = l.id
|
||||||
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
GROUP BY l.id;
|
||||||
|
|
||||||
-- Media Items queries
|
-- Media Items queries
|
||||||
-- name: CreateMediaItem :one
|
-- name: CreateMediaItem :one
|
||||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
||||||
|
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetMediaItem :one
|
-- name: GetMediaItem :one
|
||||||
@@ -346,6 +355,9 @@ WHERE role = 'admin'
|
|||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CountAdmins :one
|
||||||
|
SELECT COUNT(*) FROM users WHERE role = 'admin';
|
||||||
|
|
||||||
-- name: ReassignLibraries :exec
|
-- name: ReassignLibraries :exec
|
||||||
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
|
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
|
||||||
|
|
||||||
@@ -362,9 +374,29 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1;
|
|||||||
-- name: UpdateSystemSetting :exec
|
-- name: UpdateSystemSetting :exec
|
||||||
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
|
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
|
||||||
|
|
||||||
|
-- name: UpsertSystemSetting :one
|
||||||
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (setting_key) DO UPDATE
|
||||||
|
SET setting_value = EXCLUDED.setting_value,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
setting_type = EXCLUDED.setting_type,
|
||||||
|
min_value = EXCLUDED.min_value,
|
||||||
|
max_value = EXCLUDED.max_value,
|
||||||
|
requires_restart = EXCLUDED.requires_restart,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetSystemSettingFull :one
|
||||||
|
SELECT * FROM system_settings WHERE setting_key = $1;
|
||||||
|
|
||||||
-- name: GetAllSystemSettings :many
|
-- name: GetAllSystemSettings :many
|
||||||
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
|
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
|
||||||
|
|
||||||
|
-- name: GetAllSystemSettingsFull :many
|
||||||
|
SELECT * FROM system_settings ORDER BY category, setting_key;
|
||||||
|
|
||||||
-- name: CreateMediaRating :one
|
-- name: CreateMediaRating :one
|
||||||
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -693,7 +725,7 @@ RETURNING *;
|
|||||||
SELECT * FROM media_notes WHERE id = $1;
|
SELECT * FROM media_notes WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetMediaNotes :many
|
-- name: GetMediaNotes :many
|
||||||
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: UpdateMediaNote :one
|
-- name: UpdateMediaNote :one
|
||||||
UPDATE media_notes SET
|
UPDATE media_notes SET
|
||||||
@@ -716,7 +748,7 @@ RETURNING *;
|
|||||||
SELECT * FROM media_highlights WHERE id = $1;
|
SELECT * FROM media_highlights WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetMediaHighlights :many
|
-- name: GetMediaHighlights :many
|
||||||
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: UpdateMediaHighlight :one
|
-- name: UpdateMediaHighlight :one
|
||||||
UPDATE media_highlights SET
|
UPDATE media_highlights SET
|
||||||
@@ -732,6 +764,350 @@ RETURNING *;
|
|||||||
-- name: DeleteMediaHighlight :exec
|
-- name: DeleteMediaHighlight :exec
|
||||||
DELETE FROM media_highlights WHERE id = $1;
|
DELETE FROM media_highlights WHERE id = $1;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ANNOTATION SYNC QUERIES (highlights)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- name: GetMediaHighlightByDedupKey :one
|
||||||
|
SELECT * FROM media_highlights
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||||
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CreateMediaHighlightFull :one
|
||||||
|
INSERT INTO media_highlights (
|
||||||
|
media_item_id, user_id, selection_text,
|
||||||
|
start_position, end_position, color, note_text,
|
||||||
|
percentage_start, percentage_end,
|
||||||
|
epubcfi_start, epubcfi_end,
|
||||||
|
chapter_reference,
|
||||||
|
dedup_key, last_modified_at, last_modified_source,
|
||||||
|
device_sync_data
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
||||||
|
) RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateMediaHighlightForSync :one
|
||||||
|
UPDATE media_highlights SET
|
||||||
|
selection_text = $2,
|
||||||
|
start_position = $3,
|
||||||
|
end_position = $4,
|
||||||
|
color = $5,
|
||||||
|
note_text = $6,
|
||||||
|
percentage_start = $7,
|
||||||
|
percentage_end = $8,
|
||||||
|
epubcfi_start = $9,
|
||||||
|
epubcfi_end = $10,
|
||||||
|
chapter_reference = $11,
|
||||||
|
last_modified_at = $12,
|
||||||
|
last_modified_source = $13,
|
||||||
|
device_sync_data = $14,
|
||||||
|
updated_at = NOW(),
|
||||||
|
deleted = FALSE,
|
||||||
|
deleted_at = NULL
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: TombstoneMediaHighlightByDedupKey :exec
|
||||||
|
UPDATE media_highlights SET
|
||||||
|
deleted = TRUE,
|
||||||
|
deleted_at = NOW(),
|
||||||
|
last_modified_at = NOW()
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
||||||
|
|
||||||
|
-- name: TombstoneMediaHighlightByID :exec
|
||||||
|
UPDATE media_highlights SET
|
||||||
|
deleted = TRUE,
|
||||||
|
deleted_at = NOW(),
|
||||||
|
last_modified_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: PurgeExpiredHighlightTombstones :exec
|
||||||
|
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ANNOTATION SYNC QUERIES (notes)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- name: GetMediaNoteByDedupKey :one
|
||||||
|
SELECT * FROM media_notes
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||||
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CreateMediaNoteFull :one
|
||||||
|
INSERT INTO media_notes (
|
||||||
|
media_item_id, user_id, content, position,
|
||||||
|
percentage_location, character_start, character_end,
|
||||||
|
epubcfi_location, chapter_reference, paragraph_reference,
|
||||||
|
dedup_key, last_modified_at, last_modified_source,
|
||||||
|
device_sync_data
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
||||||
|
) RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateMediaNoteForSync :one
|
||||||
|
UPDATE media_notes SET
|
||||||
|
content = $2,
|
||||||
|
position = $3,
|
||||||
|
percentage_location = $4,
|
||||||
|
character_start = $5,
|
||||||
|
character_end = $6,
|
||||||
|
epubcfi_location = $7,
|
||||||
|
chapter_reference = $8,
|
||||||
|
paragraph_reference = $9,
|
||||||
|
last_modified_at = $10,
|
||||||
|
last_modified_source = $11,
|
||||||
|
device_sync_data = $12,
|
||||||
|
updated_at = NOW(),
|
||||||
|
deleted = FALSE,
|
||||||
|
deleted_at = NULL
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: TombstoneMediaNoteByDedupKey :exec
|
||||||
|
UPDATE media_notes SET
|
||||||
|
deleted = TRUE,
|
||||||
|
deleted_at = NOW(),
|
||||||
|
last_modified_at = NOW()
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
||||||
|
|
||||||
|
-- name: TombstoneMediaNoteByID :exec
|
||||||
|
UPDATE media_notes SET
|
||||||
|
deleted = TRUE,
|
||||||
|
deleted_at = NOW(),
|
||||||
|
last_modified_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: PurgeExpiredNoteTombstones :exec
|
||||||
|
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ANNOTATION SYNC QUERIES (bookmarks)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- name: GetMediaBookmarkByDedupKey :one
|
||||||
|
SELECT * FROM media_bookmarks
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||||
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CreateMediaBookmarkFull :one
|
||||||
|
INSERT INTO media_bookmarks (
|
||||||
|
media_item_id, user_id, page_number, chapter_number,
|
||||||
|
cfi_position, title, position, notes,
|
||||||
|
percentage_location, epubcfi_location, chapter_reference,
|
||||||
|
dedup_key, last_modified_at, last_modified_source,
|
||||||
|
device_sync_data
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
||||||
|
) RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateMediaBookmarkForSync :one
|
||||||
|
UPDATE media_bookmarks SET
|
||||||
|
page_number = $2,
|
||||||
|
chapter_number = $3,
|
||||||
|
cfi_position = $4,
|
||||||
|
title = $5,
|
||||||
|
position = $6,
|
||||||
|
notes = $7,
|
||||||
|
percentage_location = $8,
|
||||||
|
epubcfi_location = $9,
|
||||||
|
chapter_reference = $10,
|
||||||
|
last_modified_at = $11,
|
||||||
|
last_modified_source = $12,
|
||||||
|
device_sync_data = $13,
|
||||||
|
created_at = created_at,
|
||||||
|
deleted = FALSE,
|
||||||
|
deleted_at = NULL
|
||||||
|
WHERE id = $1
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: TombstoneMediaBookmarkByDedupKey :exec
|
||||||
|
UPDATE media_bookmarks SET
|
||||||
|
deleted = TRUE,
|
||||||
|
deleted_at = NOW(),
|
||||||
|
last_modified_at = NOW()
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
||||||
|
|
||||||
|
-- name: TombstoneMediaBookmarkByID :exec
|
||||||
|
UPDATE media_bookmarks SET
|
||||||
|
deleted = TRUE,
|
||||||
|
deleted_at = NOW(),
|
||||||
|
last_modified_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: PurgeExpiredBookmarkTombstones :exec
|
||||||
|
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ANNOTATION SERVE QUERIES
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- name: GetActiveAnnotationsForBook :many
|
||||||
|
SELECT
|
||||||
|
mh.id,
|
||||||
|
mh.selection_text,
|
||||||
|
mh.start_position,
|
||||||
|
mh.end_position,
|
||||||
|
mh.color,
|
||||||
|
mh.created_at,
|
||||||
|
mh.updated_at,
|
||||||
|
'highlight' as annotation_type,
|
||||||
|
mh.percentage_start,
|
||||||
|
mh.percentage_end,
|
||||||
|
mh.epubcfi_start,
|
||||||
|
mh.epubcfi_end,
|
||||||
|
mh.note_text,
|
||||||
|
mh.dedup_key,
|
||||||
|
mh.last_modified_at,
|
||||||
|
mh.last_modified_source
|
||||||
|
FROM media_highlights mh
|
||||||
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
mn.id,
|
||||||
|
mn.content,
|
||||||
|
mn.position,
|
||||||
|
NULL as end_position,
|
||||||
|
NULL as color,
|
||||||
|
mn.created_at,
|
||||||
|
mn.updated_at,
|
||||||
|
'note' as annotation_type,
|
||||||
|
mn.percentage_location as percentage_start,
|
||||||
|
NULL as percentage_end,
|
||||||
|
mn.epubcfi_location as epubcfi_start,
|
||||||
|
NULL as epubcfi_end,
|
||||||
|
NULL as note_text,
|
||||||
|
mn.dedup_key,
|
||||||
|
mn.last_modified_at,
|
||||||
|
mn.last_modified_source
|
||||||
|
FROM media_notes mn
|
||||||
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE
|
||||||
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetTombstonedAnnotationsForBook :many
|
||||||
|
SELECT
|
||||||
|
mh.id,
|
||||||
|
mh.dedup_key,
|
||||||
|
'highlight' as annotation_type,
|
||||||
|
mh.device_sync_data,
|
||||||
|
mh.deleted_at,
|
||||||
|
mh.start_position,
|
||||||
|
mh.end_position,
|
||||||
|
mh.epubcfi_start,
|
||||||
|
mh.epubcfi_end
|
||||||
|
FROM media_highlights mh
|
||||||
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
mn.id,
|
||||||
|
mn.dedup_key,
|
||||||
|
'note' as annotation_type,
|
||||||
|
mn.device_sync_data,
|
||||||
|
mn.deleted_at,
|
||||||
|
mn.position as start_position,
|
||||||
|
NULL as end_position,
|
||||||
|
mn.epubcfi_location as epubcfi_start,
|
||||||
|
NULL as epubcfi_end
|
||||||
|
FROM media_notes mn
|
||||||
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
mb.id,
|
||||||
|
mb.dedup_key,
|
||||||
|
'bookmark' as annotation_type,
|
||||||
|
mb.device_sync_data,
|
||||||
|
mb.deleted_at,
|
||||||
|
mb.position as start_position,
|
||||||
|
NULL as end_position,
|
||||||
|
mb.cfi_position as epubcfi_start,
|
||||||
|
NULL as epubcfi_end
|
||||||
|
FROM media_bookmarks mb
|
||||||
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
||||||
|
ORDER BY deleted_at DESC;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ANNOTATION HISTORY (deleted-annotation archive)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- Lists every currently-tombstoned annotation for a book regardless of the
|
||||||
|
-- tombstone TTL: this backs the book page's "recently deleted" history where
|
||||||
|
-- users can restore or permanently remove entries. Rows whose tombstones have
|
||||||
|
-- been purged by the daily maintenance sweep no longer exist at all.
|
||||||
|
-- name: ListDeletedAnnotationsForBook :many
|
||||||
|
SELECT
|
||||||
|
mh.id,
|
||||||
|
mh.dedup_key,
|
||||||
|
'highlight' as annotation_type,
|
||||||
|
mh.selection_text as display_text,
|
||||||
|
mh.note_text as secondary_text,
|
||||||
|
mh.color,
|
||||||
|
mh.deleted_at,
|
||||||
|
mh.created_at
|
||||||
|
FROM media_highlights mh
|
||||||
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
mn.id,
|
||||||
|
mn.dedup_key,
|
||||||
|
'note' as annotation_type,
|
||||||
|
mn.content as display_text,
|
||||||
|
NULL::text as secondary_text,
|
||||||
|
NULL::text as color,
|
||||||
|
mn.deleted_at,
|
||||||
|
mn.created_at
|
||||||
|
FROM media_notes mn
|
||||||
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE
|
||||||
|
UNION ALL
|
||||||
|
SELECT
|
||||||
|
mb.id,
|
||||||
|
mb.dedup_key,
|
||||||
|
'bookmark' as annotation_type,
|
||||||
|
mb.title as display_text,
|
||||||
|
mb.notes as secondary_text,
|
||||||
|
NULL::text as color,
|
||||||
|
mb.deleted_at,
|
||||||
|
mb.created_at
|
||||||
|
FROM media_bookmarks mb
|
||||||
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE
|
||||||
|
ORDER BY deleted_at DESC;
|
||||||
|
|
||||||
|
-- name: RestoreMediaHighlightByID :execrows
|
||||||
|
UPDATE media_highlights SET
|
||||||
|
deleted = FALSE,
|
||||||
|
deleted_at = NULL,
|
||||||
|
last_modified_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||||
|
|
||||||
|
-- name: RestoreMediaNoteByID :execrows
|
||||||
|
UPDATE media_notes SET
|
||||||
|
deleted = FALSE,
|
||||||
|
deleted_at = NULL,
|
||||||
|
last_modified_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||||
|
|
||||||
|
-- name: RestoreMediaBookmarkByID :execrows
|
||||||
|
UPDATE media_bookmarks SET
|
||||||
|
deleted = FALSE,
|
||||||
|
deleted_at = NULL,
|
||||||
|
last_modified_at = NOW()
|
||||||
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||||
|
|
||||||
|
-- Permanent removal from the history (distinct from the TTL-driven purge,
|
||||||
|
-- which is maintenance). Scoped to the owning user and book.
|
||||||
|
-- name: PurgeMediaHighlightByID :execrows
|
||||||
|
DELETE FROM media_highlights
|
||||||
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||||
|
|
||||||
|
-- name: PurgeMediaNoteByID :execrows
|
||||||
|
DELETE FROM media_notes
|
||||||
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||||
|
|
||||||
|
-- name: PurgeMediaBookmarkByID :execrows
|
||||||
|
DELETE FROM media_bookmarks
|
||||||
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
||||||
|
|
||||||
-- Refresh Tokens queries
|
-- Refresh Tokens queries
|
||||||
-- name: CreateRefreshToken :one
|
-- name: CreateRefreshToken :one
|
||||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||||
@@ -751,7 +1127,7 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
|
|||||||
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
|
||||||
|
|
||||||
-- name: CleanupExpiredRefreshTokens :exec
|
-- name: CleanupExpiredRefreshTokens :exec
|
||||||
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
|
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision));
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- FORMAT DETECTION & PROGRESS
|
-- FORMAT DETECTION & PROGRESS
|
||||||
@@ -793,6 +1169,7 @@ SELECT
|
|||||||
rp.percentage,
|
rp.percentage,
|
||||||
rp.character_offset,
|
rp.character_offset,
|
||||||
rp.epubcfi,
|
rp.epubcfi,
|
||||||
|
rp.context_text,
|
||||||
rp.chapter,
|
rp.chapter,
|
||||||
rp.chapter_progress,
|
rp.chapter_progress,
|
||||||
rp.viewport_x,
|
rp.viewport_x,
|
||||||
@@ -825,6 +1202,7 @@ INSERT INTO reading_progress (
|
|||||||
percentage,
|
percentage,
|
||||||
character_offset,
|
character_offset,
|
||||||
epubcfi,
|
epubcfi,
|
||||||
|
context_text,
|
||||||
chapter,
|
chapter,
|
||||||
chapter_progress,
|
chapter_progress,
|
||||||
viewport_x,
|
viewport_x,
|
||||||
@@ -842,13 +1220,14 @@ INSERT INTO reading_progress (
|
|||||||
last_read_at
|
last_read_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW()
|
||||||
)
|
)
|
||||||
ON CONFLICT (media_item_id, user_id)
|
ON CONFLICT (media_item_id, user_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
percentage = EXCLUDED.percentage,
|
percentage = EXCLUDED.percentage,
|
||||||
character_offset = EXCLUDED.character_offset,
|
character_offset = EXCLUDED.character_offset,
|
||||||
epubcfi = EXCLUDED.epubcfi,
|
epubcfi = EXCLUDED.epubcfi,
|
||||||
|
context_text = EXCLUDED.context_text,
|
||||||
chapter = EXCLUDED.chapter,
|
chapter = EXCLUDED.chapter,
|
||||||
chapter_progress = EXCLUDED.chapter_progress,
|
chapter_progress = EXCLUDED.chapter_progress,
|
||||||
viewport_x = EXCLUDED.viewport_x,
|
viewport_x = EXCLUDED.viewport_x,
|
||||||
@@ -1120,6 +1499,11 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data
|
|||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateAutoResolvedSyncConflict :one
|
||||||
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetSyncConflict :one
|
-- name: GetSyncConflict :one
|
||||||
SELECT * FROM sync_conflicts WHERE id = $1;
|
SELECT * FROM sync_conflicts WHERE id = $1;
|
||||||
|
|
||||||
@@ -1162,6 +1546,15 @@ JOIN media_items mi ON sc.media_item_id = mi.id
|
|||||||
WHERE sc.user_id = $1
|
WHERE sc.user_id = $1
|
||||||
ORDER BY sc.created_at DESC;
|
ORDER BY sc.created_at DESC;
|
||||||
|
|
||||||
|
-- name: HasRecentConflictResolution :one
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM sync_conflicts
|
||||||
|
WHERE media_item_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
AND resolution_status != 'unresolved'
|
||||||
|
AND resolved_at > NOW() - INTERVAL '10 minutes'
|
||||||
|
);
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- KOREADER SYNC PROTOCOL
|
-- KOREADER SYNC PROTOCOL
|
||||||
-- ============================================
|
-- ============================================
|
||||||
@@ -1210,7 +1603,7 @@ SELECT
|
|||||||
mh.epubcfi_start,
|
mh.epubcfi_start,
|
||||||
mh.epubcfi_end
|
mh.epubcfi_end
|
||||||
FROM media_highlights mh
|
FROM media_highlights mh
|
||||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
mn.id,
|
mn.id,
|
||||||
@@ -1226,7 +1619,7 @@ SELECT
|
|||||||
mn.epubcfi_location as epubcfi_start,
|
mn.epubcfi_location as epubcfi_start,
|
||||||
NULL as epubcfi_end
|
NULL as epubcfi_end
|
||||||
FROM media_notes mn
|
FROM media_notes mn
|
||||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: UpdateDeviceSyncTimestamp :one
|
-- name: UpdateDeviceSyncTimestamp :one
|
||||||
@@ -1414,6 +1807,70 @@ RETURNING *;
|
|||||||
-- name: GetMediaItemBySHA256 :one
|
-- name: GetMediaItemBySHA256 :one
|
||||||
SELECT * FROM media_items WHERE file_sha256 = $1;
|
SELECT * FROM media_items WHERE file_sha256 = $1;
|
||||||
|
|
||||||
|
-- Get media item by SHA-256 hash within a specific library (content dedup)
|
||||||
|
-- name: GetMediaItemBySHA256AndLibrary :one
|
||||||
|
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
|
||||||
|
|
||||||
|
-- List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||||
|
-- name: ListMediaItemsBySHA256AndLibrary :many
|
||||||
|
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
|
||||||
|
|
||||||
|
-- List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
|
-- name: ListMediaItemsMissingHash :many
|
||||||
|
SELECT * FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at;
|
||||||
|
|
||||||
|
-- Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||||
|
-- name: FindHashConflictGroups :many
|
||||||
|
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
||||||
|
FROM media_items
|
||||||
|
WHERE file_sha256 IS NOT NULL
|
||||||
|
GROUP BY library_id, file_sha256
|
||||||
|
HAVING COUNT(*) > 1;
|
||||||
|
|
||||||
|
-- Per-item user-data counts, used when choosing which duplicate copy to keep
|
||||||
|
-- name: GetMediaItemUsageCounts :one
|
||||||
|
SELECT
|
||||||
|
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = $1) AS progress_count,
|
||||||
|
(SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = $1) AS highlights_count,
|
||||||
|
(SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = $1) AS bookmarks_count,
|
||||||
|
(SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = $1) AS notes_count,
|
||||||
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = $1) AS collections_count;
|
||||||
|
|
||||||
|
-- HASH CONFLICTS QUERIES
|
||||||
|
|
||||||
|
-- Record a pending hash conflict (no-op if the group is already tracked, so
|
||||||
|
-- resolved groups stay resolved and are never re-flagged)
|
||||||
|
-- name: CreateHashConflict :exec
|
||||||
|
INSERT INTO hash_conflicts (library_id, file_sha256)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (library_id, file_sha256) DO NOTHING;
|
||||||
|
|
||||||
|
-- name: ListPendingHashConflicts :many
|
||||||
|
SELECT hc.id, hc.library_id, hc.file_sha256, hc.created_at,
|
||||||
|
l.name AS library_name,
|
||||||
|
COUNT(mi.id) AS item_count
|
||||||
|
FROM hash_conflicts hc
|
||||||
|
JOIN libraries l ON l.id = hc.library_id
|
||||||
|
LEFT JOIN media_items mi ON mi.library_id = hc.library_id AND mi.file_sha256 = hc.file_sha256
|
||||||
|
WHERE hc.status = 'pending'
|
||||||
|
GROUP BY hc.id, hc.library_id, hc.file_sha256, hc.created_at, l.name
|
||||||
|
ORDER BY hc.created_at;
|
||||||
|
|
||||||
|
-- name: GetHashConflict :one
|
||||||
|
SELECT * FROM hash_conflicts WHERE id = $1;
|
||||||
|
|
||||||
|
-- name: ResolveHashConflict :exec
|
||||||
|
UPDATE hash_conflicts
|
||||||
|
SET status = 'resolved',
|
||||||
|
resolution = $2,
|
||||||
|
resolved_by = $3,
|
||||||
|
resolved_at = NOW()
|
||||||
|
WHERE id = $1;
|
||||||
|
|
||||||
|
-- Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||||
|
-- name: ReparentMediaItemChildren :exec
|
||||||
|
SELECT reparent_media_item_children($1::uuid, $2::uuid);
|
||||||
|
|
||||||
-- Get media item by OPF identifier
|
-- Get media item by OPF identifier
|
||||||
-- name: GetMediaItemByOPFIdentifier :one
|
-- name: GetMediaItemByOPFIdentifier :one
|
||||||
SELECT * FROM media_items WHERE opf_identifier = $1;
|
SELECT * FROM media_items WHERE opf_identifier = $1;
|
||||||
@@ -1461,6 +1918,11 @@ ORDER BY confidence_score DESC;
|
|||||||
-- name: CreateMediaItemFormat :one
|
-- name: CreateMediaItemFormat :one
|
||||||
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
|
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
ON CONFLICT (media_item_id, format_type) DO UPDATE SET
|
||||||
|
file_path = EXCLUDED.file_path,
|
||||||
|
file_sha256 = EXCLUDED.file_sha256,
|
||||||
|
file_size_bytes = EXCLUDED.file_size_bytes,
|
||||||
|
mime_type = EXCLUDED.mime_type
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- Get media item formats
|
-- Get media item formats
|
||||||
@@ -2097,9 +2559,12 @@ RETURNING *;
|
|||||||
|
|
||||||
-- name: GetMediaBookmarks :many
|
-- name: GetMediaBookmarks :many
|
||||||
SELECT * FROM media_bookmarks
|
SELECT * FROM media_bookmarks
|
||||||
WHERE media_item_id = $1 AND user_id = $2
|
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetMediaBookmark :one
|
||||||
|
SELECT * FROM media_bookmarks WHERE id = $1;
|
||||||
|
|
||||||
-- name: CreateMediaBookmark :one
|
-- name: CreateMediaBookmark :one
|
||||||
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
@@ -2115,7 +2580,7 @@ SET
|
|||||||
title = $2,
|
title = $2,
|
||||||
notes = $3,
|
notes = $3,
|
||||||
position = $4,
|
position = $4,
|
||||||
updated_at = NOW()
|
last_modified_at = NOW()
|
||||||
WHERE id = $1 AND user_id = $5
|
WHERE id = $1 AND user_id = $5
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
// SettingsRegistry provides a typed, cached view over the system_settings table.
|
||||||
|
// It is the single source of truth for tunable runtime values that used to be
|
||||||
|
// hardcoded as Go literals.
|
||||||
|
//
|
||||||
|
// Consumers call the domain-specific getters (SessionDuration, OpdsPageSize,
|
||||||
|
// etc.) which read from an in-memory cache. The cache is populated by Load at
|
||||||
|
// startup and refreshed by Reload whenever a setting is written. Getters always
|
||||||
|
// fall back to a compiled-in default if the DB value is missing or unparsable,
|
||||||
|
// so a corrupt or deleted row can never break the app.
|
||||||
|
//
|
||||||
|
// SettingsRegistry lives in the database package (rather than its own package)
|
||||||
|
// so that every consumer already imports database and does not need to take on
|
||||||
|
// a new package import.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingType enumerates the value types stored in system_settings.setting_type.
|
||||||
|
const (
|
||||||
|
SettingTypeInt = "int"
|
||||||
|
SettingTypeBool = "bool"
|
||||||
|
SettingTypeString = "string"
|
||||||
|
SettingTypeStringList = "string_list"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecondsPerDay / SecondsPerHour are conversion helpers used by defaults.
|
||||||
|
const (
|
||||||
|
SecondsPerMinute = 60
|
||||||
|
SecondsPerHour = 3600
|
||||||
|
SecondsPerDay = 86400
|
||||||
|
)
|
||||||
|
|
||||||
|
// SettingDefault holds the fallback value for a key. These mirror the literals that
|
||||||
|
// were previously hardcoded in the source so an empty/corrupt DB row preserves
|
||||||
|
// prior behavior exactly.
|
||||||
|
type SettingDefault struct {
|
||||||
|
Key string
|
||||||
|
Value string
|
||||||
|
Type string
|
||||||
|
Min string
|
||||||
|
Max string
|
||||||
|
RequiresRestart bool
|
||||||
|
Category string
|
||||||
|
Group string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingDefaults is the source of truth for fallback values and metadata. New keys
|
||||||
|
// must be added here AND seeded in database/schema/schema.sql. Entries are ordered
|
||||||
|
// by (RequiresRestart, Group) so the admin UI renders coherent sub-sections.
|
||||||
|
var SettingDefaults = []SettingDefault{
|
||||||
|
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
|
||||||
|
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
|
||||||
|
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
|
||||||
|
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
|
||||||
|
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
|
||||||
|
{Key: "password_require_upper", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one uppercase letter (A-Z)"},
|
||||||
|
{Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
|
||||||
|
{Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
|
||||||
|
{Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
|
||||||
|
{Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
|
||||||
|
{Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
|
||||||
|
{Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
|
||||||
|
{Key: "device_rate_progress_per_min", Value: "120", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device progress requests per minute"},
|
||||||
|
{Key: "device_rate_metadata_per_min", Value: "30", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device metadata requests per minute"},
|
||||||
|
{Key: "annotation_tombstone_ttl_days", Value: "30", Type: SettingTypeInt, Min: "1", Max: "3650", Category: "sync", Group: "Annotation Retention", Description: "How long deleted annotations are kept before purge"},
|
||||||
|
{Key: "conversion_cache_ttl_hours", Value: "24", Type: SettingTypeInt, Min: "1", Max: "720", Category: "performance", Group: "Conversion Cache", Description: "How long converted (kepub) files are cached"},
|
||||||
|
{Key: "auth_rate_limit_per_min", Value: "10", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "security", Group: "Auth Rate Limiting", Description: "Global auth API rate limit (requests per minute)"},
|
||||||
|
{Key: "login_max_attempts", Value: "5", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Failed login attempts before lockout"},
|
||||||
|
{Key: "login_lockout_minutes", Value: "15", Type: SettingTypeInt, Min: "1", Max: "10080", RequiresRestart: true, Category: "security", Group: "Login Lockout", Description: "Lockout duration after too many failed logins"},
|
||||||
|
{Key: "sync_queue_interval_seconds", Value: "5", Type: SettingTypeInt, Min: "1", Max: "3600", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "How often the sync queue flushes"},
|
||||||
|
{Key: "sync_queue_batch_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "sync", Group: "Sync Queue", Description: "Maximum items processed per sync queue flush"},
|
||||||
|
{Key: "worker_pool_size", Value: "3", Type: SettingTypeInt, Min: "1", Max: "100", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Number of background worker goroutines"},
|
||||||
|
{Key: "worker_queue_cap", Value: "100", Type: SettingTypeInt, Min: "1", Max: "10000", RequiresRestart: true, Category: "performance", Group: "Worker Pool", Description: "Background worker job queue capacity"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultBy indexes SettingDefaults by key for O(1) lookup.
|
||||||
|
var defaultBy = func() map[string]SettingDefault {
|
||||||
|
m := make(map[string]SettingDefault, len(SettingDefaults))
|
||||||
|
for _, d := range SettingDefaults {
|
||||||
|
m[d.Key] = d
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Registry caches system_settings values in memory. The zero value is not
|
||||||
|
// usable; construct with New.
|
||||||
|
type SettingsRegistry struct {
|
||||||
|
q *Queries
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
values map[string]string
|
||||||
|
loadedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a Registry backed by the given queries. The cache is empty
|
||||||
|
// until Load is called.
|
||||||
|
func NewSettingsRegistry(q *Queries) *SettingsRegistry {
|
||||||
|
return &SettingsRegistry{q: q, values: make(map[string]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load populates the cache from the database. Missing rows fall back to the
|
||||||
|
// compiled defaults. Safe to call multiple times.
|
||||||
|
func (r *SettingsRegistry) Load(ctx context.Context) error {
|
||||||
|
rows, err := r.q.GetAllSystemSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fresh := make(map[string]string, len(SettingDefaults))
|
||||||
|
for _, d := range SettingDefaults {
|
||||||
|
fresh[d.Key] = d.Value
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
if _, ok := fresh[row.SettingKey]; ok {
|
||||||
|
fresh[row.SettingKey] = row.SettingValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.values = fresh
|
||||||
|
r.loadedAt = time.Now()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload refreshes the cache from the database. Should be called after any
|
||||||
|
// setting write. On error the cache is left untouched and the error is logged.
|
||||||
|
func (r *SettingsRegistry) Reload(ctx context.Context) {
|
||||||
|
if err := r.Load(ctx); err != nil {
|
||||||
|
log.Printf("settings: reload failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw returns the cached string value for a key (or the default), clamped to
|
||||||
|
// [min, max] for int-typed keys.
|
||||||
|
func (r *SettingsRegistry) raw(key string) string {
|
||||||
|
r.mu.RLock()
|
||||||
|
v, ok := r.values[key]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
if !ok || v == "" {
|
||||||
|
v = defaultBy[key].Value
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SettingsRegistry) getInt(key string) int {
|
||||||
|
d := defaultBy[key]
|
||||||
|
v := r.raw(key)
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
n, _ = strconv.Atoi(d.Value)
|
||||||
|
}
|
||||||
|
if d.Min != "" {
|
||||||
|
if mn, err := strconv.Atoi(d.Min); err == nil && n < mn {
|
||||||
|
n = mn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Max != "" {
|
||||||
|
if mx, err := strconv.Atoi(d.Max); err == nil && n > mx {
|
||||||
|
n = mx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SettingsRegistry) getBool(key string) bool {
|
||||||
|
v := r.raw(key)
|
||||||
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
b, _ = strconv.ParseBool(defaultBy[key].Value)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Domain-specific getters (call sites use these) ----
|
||||||
|
|
||||||
|
// ScanPollInterval is how often the scanner polls, as a duration.
|
||||||
|
func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
||||||
|
return time.Duration(r.getInt("scan_poll_interval_seconds")) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoScanEnabled reports whether auto-scanning is on.
|
||||||
|
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||||
|
|
||||||
|
// DefaultTimezone returns the configured default timezone name.
|
||||||
|
func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
|
||||||
|
|
||||||
|
// SessionDuration is how long a login session / refresh token stays valid.
|
||||||
|
func (r *SettingsRegistry) SessionDuration() time.Duration {
|
||||||
|
return time.Duration(r.getInt("session_duration_seconds")) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordMinLength is the minimum password length.
|
||||||
|
func (r *SettingsRegistry) PasswordMinLength() int { return r.getInt("password_min_length") }
|
||||||
|
|
||||||
|
// PasswordRules bundles the active complexity requirements.
|
||||||
|
type PasswordRules struct {
|
||||||
|
MinLength int
|
||||||
|
Upper bool
|
||||||
|
Lower bool
|
||||||
|
Number bool
|
||||||
|
Special bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// PasswordRules returns the active password complexity configuration.
|
||||||
|
func (r *SettingsRegistry) PasswordRules() PasswordRules {
|
||||||
|
return PasswordRules{
|
||||||
|
MinLength: r.PasswordMinLength(),
|
||||||
|
Upper: r.getBool("password_require_upper"),
|
||||||
|
Lower: r.getBool("password_require_lower"),
|
||||||
|
Number: r.getBool("password_require_number"),
|
||||||
|
Special: r.getBool("password_require_special"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthRateLimit is the global auth endpoint rate limit (requests/minute). Read
|
||||||
|
// once at startup.
|
||||||
|
func (r *SettingsRegistry) AuthRateLimit() int { return r.getInt("auth_rate_limit_per_min") }
|
||||||
|
|
||||||
|
// LoginLockout returns (max attempts, lockout duration). Read once at startup.
|
||||||
|
func (r *SettingsRegistry) LoginLockout() (int, time.Duration) {
|
||||||
|
return r.getInt("login_max_attempts"), time.Duration(r.getInt("login_lockout_minutes")) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpdsDefaultPageSize is the default OPDS items-per-page.
|
||||||
|
func (r *SettingsRegistry) OpdsDefaultPageSize() int { return r.getInt("opds_default_page_size") }
|
||||||
|
|
||||||
|
// OpdsMaxPageSize is the maximum items-per-page a client may request.
|
||||||
|
func (r *SettingsRegistry) OpdsMaxPageSize() int { return r.getInt("opds_max_page_size") }
|
||||||
|
|
||||||
|
// DeviceRateLimits bundles the per-route device rate limits (requests/minute).
|
||||||
|
type DeviceRateLimits struct {
|
||||||
|
Sync int
|
||||||
|
Progress int
|
||||||
|
Metadata int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceRateLimits returns the active device rate limits.
|
||||||
|
func (r *SettingsRegistry) DeviceRateLimits() DeviceRateLimits {
|
||||||
|
return DeviceRateLimits{
|
||||||
|
Sync: r.getInt("device_rate_sync_per_min"),
|
||||||
|
Progress: r.getInt("device_rate_progress_per_min"),
|
||||||
|
Metadata: r.getInt("device_rate_metadata_per_min"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TombstoneTTL is how long deleted annotations are retained before purge.
|
||||||
|
func (r *SettingsRegistry) TombstoneTTL() time.Duration {
|
||||||
|
return time.Duration(r.getInt("annotation_tombstone_ttl_days")) * 24 * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConversionCacheTTL is how long converted (kepub) files are served from cache.
|
||||||
|
func (r *SettingsRegistry) ConversionCacheTTL() time.Duration {
|
||||||
|
return time.Duration(r.getInt("conversion_cache_ttl_hours")) * time.Hour
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncQueueConfig bundles the sync queue interval and batch size. Read at
|
||||||
|
// startup; changes require a restart.
|
||||||
|
type SyncQueueConfig struct {
|
||||||
|
Interval time.Duration
|
||||||
|
BatchSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncQueueConfig returns the active sync queue configuration.
|
||||||
|
func (r *SettingsRegistry) SyncQueueConfig() SyncQueueConfig {
|
||||||
|
return SyncQueueConfig{
|
||||||
|
Interval: time.Duration(r.getInt("sync_queue_interval_seconds")) * time.Second,
|
||||||
|
BatchSize: r.getInt("sync_queue_batch_size"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerPoolConfig bundles worker count and queue capacity. Read at startup;
|
||||||
|
// changes require a restart.
|
||||||
|
type WorkerPoolConfig struct {
|
||||||
|
Size int
|
||||||
|
QueueCap int
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkerPoolConfig returns the active worker pool configuration.
|
||||||
|
func (r *SettingsRegistry) WorkerPoolConfig() WorkerPoolConfig {
|
||||||
|
return WorkerPoolConfig{
|
||||||
|
Size: r.getInt("worker_pool_size"),
|
||||||
|
QueueCap: r.getInt("worker_queue_cap"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingEntry exposes one setting's metadata + current value, for the admin UI/API.
|
||||||
|
type SettingEntry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Min string `json:"min,omitempty"`
|
||||||
|
Max string `json:"max,omitempty"`
|
||||||
|
RequiresRestart bool `json:"requires_restart"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Group string `json:"group"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
IsDefault bool `json:"is_default"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// All returns metadata + current values for every known setting, grouped by
|
||||||
|
// the in-memory cache (which reflects the DB after Load/Reload).
|
||||||
|
func (r *SettingsRegistry) All() []SettingEntry {
|
||||||
|
r.mu.RLock()
|
||||||
|
vals := make(map[string]string, len(r.values))
|
||||||
|
for k, v := range r.values {
|
||||||
|
vals[k] = v
|
||||||
|
}
|
||||||
|
r.mu.RUnlock()
|
||||||
|
out := make([]SettingEntry, 0, len(SettingDefaults))
|
||||||
|
for _, d := range SettingDefaults {
|
||||||
|
v, ok := vals[d.Key]
|
||||||
|
if !ok {
|
||||||
|
v = d.Value
|
||||||
|
}
|
||||||
|
out = append(out, SettingEntry{
|
||||||
|
Key: d.Key,
|
||||||
|
Value: v,
|
||||||
|
Type: d.Type,
|
||||||
|
Min: d.Min,
|
||||||
|
Max: d.Max,
|
||||||
|
RequiresRestart: d.RequiresRestart,
|
||||||
|
Category: d.Category,
|
||||||
|
Group: d.Group,
|
||||||
|
Description: d.Description,
|
||||||
|
IsDefault: v == d.Value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupDefault returns the compiled-in SettingDefault for a key (ok=false if unknown).
|
||||||
|
func LookupDefault(key string) (SettingDefault, bool) {
|
||||||
|
d, ok := defaultBy[key]
|
||||||
|
return d, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestSettingDefaults ensures every seeded setting has a compiled default with
|
||||||
|
// a valid value for its declared type. This guards against typos that would
|
||||||
|
// silently fall back at runtime.
|
||||||
|
func TestSettingDefaults(t *testing.T) {
|
||||||
|
if len(SettingDefaults) == 0 {
|
||||||
|
t.Fatal("SettingDefaults is empty")
|
||||||
|
}
|
||||||
|
for _, d := range SettingDefaults {
|
||||||
|
if d.Key == "" {
|
||||||
|
t.Errorf("default has empty key: %+v", d)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch d.Type {
|
||||||
|
case SettingTypeInt:
|
||||||
|
if _, err := strconv.Atoi(d.Value); err != nil {
|
||||||
|
t.Errorf("int setting %s default %q is not an int: %v", d.Key, d.Value, err)
|
||||||
|
}
|
||||||
|
if d.Min != "" {
|
||||||
|
if _, err := strconv.Atoi(d.Min); err != nil {
|
||||||
|
t.Errorf("int setting %s min %q is not an int", d.Key, d.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if d.Max != "" {
|
||||||
|
if _, err := strconv.Atoi(d.Max); err != nil {
|
||||||
|
t.Errorf("int setting %s max %q is not an int", d.Key, d.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case SettingTypeBool:
|
||||||
|
if _, err := strconv.ParseBool(d.Value); err != nil {
|
||||||
|
t.Errorf("bool setting %s default %q is not a bool", d.Key, d.Value)
|
||||||
|
}
|
||||||
|
case SettingTypeString:
|
||||||
|
if d.Value == "" {
|
||||||
|
t.Errorf("string setting %s has empty default", d.Key)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
t.Errorf("setting %s has unknown type %q", d.Key, d.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsRegistryGetIntClamping verifies that out-of-range DB values are
|
||||||
|
// clamped to the declared min/max, and that garbage falls back to the default.
|
||||||
|
func TestSettingsRegistryGetIntClamping(t *testing.T) {
|
||||||
|
r := &SettingsRegistry{values: map[string]string{}, q: nil}
|
||||||
|
|
||||||
|
// Seed with an over-max value; expect clamping to the max (3600).
|
||||||
|
r.values["scan_poll_interval_seconds"] = "999999"
|
||||||
|
if got := r.ScanPollInterval(); got.Seconds() != 3600 {
|
||||||
|
t.Errorf("expected clamp to 3600, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed with an under-min value; expect clamp to min (1).
|
||||||
|
r.values["scan_poll_interval_seconds"] = "0"
|
||||||
|
if got := r.ScanPollInterval(); got.Seconds() != 1 {
|
||||||
|
t.Errorf("expected clamp to 1, got %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed with garbage; expect fallback to default (60).
|
||||||
|
r.values["scan_poll_interval_seconds"] = "not-a-number"
|
||||||
|
if got := r.ScanPollInterval(); got.Seconds() != 60 {
|
||||||
|
t.Errorf("expected fallback default 60, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsRegistryGetBoolFallback verifies bool parsing and fallback.
|
||||||
|
func TestSettingsRegistryGetBoolFallback(t *testing.T) {
|
||||||
|
r := &SettingsRegistry{values: map[string]string{}, q: nil}
|
||||||
|
|
||||||
|
r.values["auto_scan_enabled"] = "true"
|
||||||
|
if !r.AutoScanEnabled() {
|
||||||
|
t.Error("expected true")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.values["auto_scan_enabled"] = "garbage"
|
||||||
|
// garbage falls back to default ("true")
|
||||||
|
if !r.AutoScanEnabled() {
|
||||||
|
t.Error("expected fallback to default true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLookupDefaultUnknownKey verifies unknown keys return ok=false.
|
||||||
|
func TestLookupDefaultUnknownKey(t *testing.T) {
|
||||||
|
if _, ok := LookupDefault("does_not_exist"); ok {
|
||||||
|
t.Error("expected ok=false for unknown key")
|
||||||
|
}
|
||||||
|
if _, ok := LookupDefault("session_duration_seconds"); !ok {
|
||||||
|
t.Error("expected ok=true for known key")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
wsync "bookhoard/internal/sync"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeletedAnnotationResponse is one entry of the deleted-annotation history
|
||||||
|
// for a book (the book page's "recently deleted" list). Restoring returns the
|
||||||
|
// row to the active set; purging removes it permanently.
|
||||||
|
type DeletedAnnotationResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
AnnotationType string `json:"annotation_type"`
|
||||||
|
DisplayText string `json:"display_text"`
|
||||||
|
SecondaryText string `json:"secondary_text,omitempty"`
|
||||||
|
Color string `json:"color,omitempty"`
|
||||||
|
DeletedAt time.Time `json:"deleted_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletedAnnotationsForBook builds the deleted-annotation history for a user
|
||||||
|
// and book. Shared by the JSON API and the book page's server-rendered modal.
|
||||||
|
func DeletedAnnotationsForBook(ctx context.Context, db *database.Queries, userID, mediaItemID pgtype.UUID) []DeletedAnnotationResponse {
|
||||||
|
rows, err := db.ListDeletedAnnotationsForBook(ctx, database.ListDeletedAnnotationsForBookParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: userID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return []DeletedAnnotationResponse{}
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]DeletedAnnotationResponse, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
entry := DeletedAnnotationResponse{
|
||||||
|
ID: uuid.UUID(row.ID.Bytes).String(),
|
||||||
|
AnnotationType: row.AnnotationType,
|
||||||
|
DisplayText: row.DisplayText,
|
||||||
|
SecondaryText: row.SecondaryText.String,
|
||||||
|
Color: row.Color.String,
|
||||||
|
}
|
||||||
|
if row.DeletedAt.Valid {
|
||||||
|
entry.DeletedAt = row.DeletedAt.Time
|
||||||
|
}
|
||||||
|
if row.CreatedAt.Valid {
|
||||||
|
entry.CreatedAt = row.CreatedAt.Time
|
||||||
|
}
|
||||||
|
response = append(response, entry)
|
||||||
|
}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeletedAnnotations handles GET /api/media-items/:id/annotations/deleted
|
||||||
|
func (mh *MediaHandler) GetDeletedAnnotations(c *echo.Context) error {
|
||||||
|
userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
response := DeletedAnnotationsForBook(c.Request().Context(), mh.db, userUUID, mediaUUID)
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"deleted_annotations": response,
|
||||||
|
"total": len(response),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreDeletedAnnotation handles POST /api/media-items/:id/annotations/:annotationId/restore
|
||||||
|
// Body/query: annotation_type=highlight|note|bookmark
|
||||||
|
func (mh *MediaHandler) RestoreDeletedAnnotation(c *echo.Context) error {
|
||||||
|
userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, true)
|
||||||
|
if errResp != nil {
|
||||||
|
return errResp
|
||||||
|
}
|
||||||
|
|
||||||
|
if mh.annotationSvc == nil {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"})
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := mh.annotationSvc.RestoreAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to restore annotation"})
|
||||||
|
}
|
||||||
|
if !restored {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"restored": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PurgeDeletedAnnotation handles DELETE /api/media-items/:id/annotations/:annotationId
|
||||||
|
// Query: annotation_type=highlight|note|bookmark. Permanent — removes the
|
||||||
|
// tombstoned row from the history.
|
||||||
|
func (mh *MediaHandler) PurgeDeletedAnnotation(c *echo.Context) error {
|
||||||
|
userUUID, mediaUUID, annotationUUID, kind, errResp := mh.parseAnnotationHistoryRequest(c, false)
|
||||||
|
if errResp != nil {
|
||||||
|
return errResp
|
||||||
|
}
|
||||||
|
|
||||||
|
if mh.annotationSvc == nil {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "annotation service unavailable"})
|
||||||
|
}
|
||||||
|
|
||||||
|
purged, err := mh.annotationSvc.PurgeAnnotationByID(c.Request().Context(), kind, userUUID, mediaUUID, annotationUUID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to purge annotation"})
|
||||||
|
}
|
||||||
|
if !purged {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "deleted annotation not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{"purged": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseUserAndMediaIDs extracts the authenticated user and the media item
|
||||||
|
// from the route. A non-nil error has already been written as the response.
|
||||||
|
func (mh *MediaHandler) parseUserAndMediaIDs(c *echo.Context) (pgtype.UUID, pgtype.UUID, error) {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaIDUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.UUID{}, pgtype.UUID{}, c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
return pgtype.UUID{Bytes: userUUID, Valid: true}, pgtype.UUID{Bytes: mediaIDUUID, Valid: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAnnotationHistoryRequest extracts user, media item, annotation ID, and
|
||||||
|
// the annotation_type (from query param or JSON body — restore posts a body,
|
||||||
|
// purge uses a query param). A non-nil error has already been written.
|
||||||
|
func (mh *MediaHandler) parseAnnotationHistoryRequest(c *echo.Context, allowBody bool) (pgtype.UUID, pgtype.UUID, pgtype.UUID, string, error) {
|
||||||
|
userUUID, mediaUUID, err := mh.parseUserAndMediaIDs(c)
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
annotationID := c.Param("annotationId")
|
||||||
|
annotationUUID, err := uuid.Parse(annotationID)
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid annotation id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := c.QueryParam("annotation_type")
|
||||||
|
if kind == "" && allowBody {
|
||||||
|
var body struct {
|
||||||
|
AnnotationType string `json:"annotation_type"`
|
||||||
|
}
|
||||||
|
if c.Bind(&body) == nil {
|
||||||
|
kind = body.AnnotationType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !wsync.ValidAnnotationKind(kind) {
|
||||||
|
return pgtype.UUID{}, pgtype.UUID{}, pgtype.UUID{}, "", c.JSON(http.StatusBadRequest, map[string]string{"error": "annotation_type must be highlight, note, or bookmark"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return userUUID, mediaUUID, pgtype.UUID{Bytes: annotationUUID, Valid: true}, kind, nil
|
||||||
|
}
|
||||||
+60
-28
@@ -6,6 +6,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
|
"bookhoard/internal/setupstatus"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -25,14 +26,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Session duration constants
|
// DefaultSessionDuration is the fallback session duration used when no
|
||||||
// Follows same pattern as refresh_token.go
|
// settings registry is wired (matches the historical 7-day value).
|
||||||
SessionDuration = 7 * 24 * time.Hour // 7 days
|
DefaultSessionDuration = 7 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionDurationSec is the session duration in seconds for use in cookies and API responses
|
// SessionDurationSec is retained for backward compatibility; new code uses the
|
||||||
// Note: This is computed from SessionDuration to avoid magic numbers
|
// registry via AuthHandler.sessionDuration().
|
||||||
var SessionDurationSec = int(SessionDuration.Seconds())
|
var SessionDurationSec = int(DefaultSessionDuration.Seconds())
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry (optional).
|
||||||
|
func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||||
|
|
||||||
|
// sessionDuration returns the active session duration from the registry.
|
||||||
|
func (h *AuthHandler) sessionDuration() time.Duration {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.SessionDuration()
|
||||||
|
}
|
||||||
|
return DefaultSessionDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshTokenTTL returns the active refresh-token lifetime (shared with the
|
||||||
|
// session duration), with a compiled-default fallback.
|
||||||
|
func (h *AuthHandler) refreshTokenTTL() time.Duration {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.SessionDuration()
|
||||||
|
}
|
||||||
|
return DefaultSessionDuration
|
||||||
|
}
|
||||||
|
|
||||||
var secure = os.Getenv("COOKIE_SECURE")
|
var secure = os.Getenv("COOKIE_SECURE")
|
||||||
|
|
||||||
@@ -40,6 +61,7 @@ type AuthHandler struct {
|
|||||||
db *database.Queries
|
db *database.Queries
|
||||||
jwtKey []byte
|
jwtKey []byte
|
||||||
loginAttemptTracker *middleware.LoginAttemptTracker
|
loginAttemptTracker *middleware.LoginAttemptTracker
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
||||||
@@ -82,22 +104,22 @@ type UserProfile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUpdateUserRequest struct {
|
type AdminUpdateUserRequest struct {
|
||||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||||
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
|
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register handles POST /api/auth/register
|
// Register handles POST /api/auth/register
|
||||||
@@ -190,7 +212,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userRole string
|
var userRole string
|
||||||
if len(users) == 0 {
|
if !adminExists {
|
||||||
userRole = "admin"
|
userRole = "admin"
|
||||||
} else {
|
} else {
|
||||||
userRole = req.Role
|
userRole = req.Role
|
||||||
@@ -232,6 +254,10 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new user may have changed the admin count (e.g. first user becomes
|
||||||
|
// admin), so refresh the setup-status cache.
|
||||||
|
setupstatus.Invalidate()
|
||||||
|
|
||||||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
||||||
@@ -260,7 +286,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: SessionDurationSec,
|
MaxAge: int(h.sessionDuration().Seconds()),
|
||||||
}
|
}
|
||||||
c.SetCookie(cookie)
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
@@ -293,7 +319,7 @@ window.location.href = '/dashboard';
|
|||||||
Token: accessToken,
|
Token: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: SessionDurationSec,
|
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||||
User: UserProfile{
|
User: UserProfile{
|
||||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
@@ -406,7 +432,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
|||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: SessionDurationSec,
|
MaxAge: int(h.sessionDuration().Seconds()),
|
||||||
}
|
}
|
||||||
c.SetCookie(cookie)
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
@@ -445,7 +471,7 @@ window.location.href = '%s';
|
|||||||
Token: accessToken,
|
Token: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: SessionDurationSec,
|
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||||
User: UserProfile{
|
User: UserProfile{
|
||||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
@@ -548,6 +574,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Role changes can affect the admin count, so refresh the setup-status cache.
|
||||||
|
setupstatus.Invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update username (if provided)
|
// Update username (if provided)
|
||||||
@@ -785,9 +814,9 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PasswordRequest struct {
|
type PasswordRequest struct {
|
||||||
CurrentPassword string `json:"current_password,omitempty"`
|
CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
|
||||||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
|
||||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var req PasswordRequest
|
var req PasswordRequest
|
||||||
@@ -934,6 +963,9 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletion may have changed the admin count, so refresh the setup-status cache.
|
||||||
|
setupstatus.Invalidate()
|
||||||
|
|
||||||
// Create success message based on context
|
// Create success message based on context
|
||||||
var message string
|
var message string
|
||||||
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
||||||
@@ -1003,7 +1035,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user
|
|||||||
"user_role": userRole,
|
"user_role": userRole,
|
||||||
"user_email": userEmail,
|
"user_email": userEmail,
|
||||||
"user_username": userUsername,
|
"user_username": userUsername,
|
||||||
"exp": time.Now().Add(SessionDuration).Unix(),
|
"exp": time.Now().Add(h.sessionDuration()).Unix(),
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
}
|
}
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ type BookInfo struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
CoverImagePath string `json:"cover_image_path"`
|
CoverImagePath string `json:"cover_image_path"`
|
||||||
|
HasConflict bool `json:"has_conflict"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionData struct {
|
type SectionData struct {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManag
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ConflictResolutionRequest struct {
|
type ConflictResolutionRequest struct {
|
||||||
Winner string `json:"winner" validate:"required,oneof=koreader kobo web manual"`
|
Winner string `json:"winner" validate:"required"`
|
||||||
ManualData map[string]interface{} `json:"manual_data"`
|
ManualData map[string]interface{} `json:"manual_data"`
|
||||||
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
|
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
@@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
if conflict.ResolutionStatus.String != "unresolved" {
|
if conflict.ResolutionStatus.String == "user_resolved" {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,8 +235,10 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
winnerData := map[string]interface{}{}
|
winnerData := map[string]interface{}{}
|
||||||
|
winnerSource := req.Winner
|
||||||
if req.Winner == "manual" {
|
if req.Winner == "manual" {
|
||||||
winnerData = req.ManualData
|
winnerData = req.ManualData
|
||||||
|
winnerSource = "manual"
|
||||||
} else {
|
} else {
|
||||||
source, ok := conflictData[req.Winner]
|
source, ok := conflictData[req.Winner]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -251,11 +253,17 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if conflict.ConflictType == "progress" {
|
if conflict.ConflictType == "progress" {
|
||||||
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerData); err == nil {
|
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
|
||||||
appliedTo["progress"] = true
|
appliedTo["progress"] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
|
||||||
|
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
|
||||||
|
appliedTo["annotations"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resolutionData := map[string]interface{}{
|
resolutionData := map[string]interface{}{
|
||||||
"winner": req.Winner,
|
"winner": req.Winner,
|
||||||
"applied_to": appliedTo,
|
"applied_to": appliedTo,
|
||||||
@@ -286,7 +294,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, response)
|
return c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
@@ -342,7 +350,7 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
|||||||
CurrentPage: currentPage,
|
CurrentPage: currentPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: "manual", Valid: true},
|
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
||||||
ViewportY: pgtype.Float8{},
|
ViewportY: pgtype.Float8{},
|
||||||
ScrollPositionX: pgtype.Float8{},
|
ScrollPositionX: pgtype.Float8{},
|
||||||
ScrollPositionY: pgtype.Float8{},
|
ScrollPositionY: pgtype.Float8{},
|
||||||
@@ -354,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dedupKey, _ := winnerData["dedup_key"].(string)
|
||||||
|
if dedupKey == "" {
|
||||||
|
return errors.New("missing dedup_key in winner data")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch conflictType {
|
||||||
|
case "annotation_highlight":
|
||||||
|
return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||||
|
case "annotation_bookmark":
|
||||||
|
return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||||
|
case "annotation_note":
|
||||||
|
return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||||
|
default:
|
||||||
|
return errors.New("unknown annotation conflict type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||||
|
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := database.UpdateMediaHighlightForSyncParams{
|
||||||
|
ID: existing.ID,
|
||||||
|
SelectionText: existing.SelectionText,
|
||||||
|
StartPosition: existing.StartPosition,
|
||||||
|
EndPosition: existing.EndPosition,
|
||||||
|
Color: existing.Color,
|
||||||
|
NoteText: existing.NoteText,
|
||||||
|
PercentageStart: existing.PercentageStart,
|
||||||
|
PercentageEnd: existing.PercentageEnd,
|
||||||
|
EpubcfiStart: existing.EpubcfiStart,
|
||||||
|
EpubcfiEnd: existing.EpubcfiEnd,
|
||||||
|
ChapterReference: existing.ChapterReference,
|
||||||
|
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||||
|
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
|
DeviceSyncData: existing.DeviceSyncData,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := data["selection_text"].(string); ok {
|
||||||
|
params.SelectionText = v
|
||||||
|
}
|
||||||
|
if v, ok := data["color"].(string); ok {
|
||||||
|
params.Color = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
if v, ok := data["note_text"].(string); ok {
|
||||||
|
params.NoteText = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
if v, ok := data["start_position"].(string); ok {
|
||||||
|
params.StartPosition = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
if v, ok := data["end_position"].(string); ok {
|
||||||
|
params.EndPosition = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.UpdateMediaHighlightForSync(ctx, params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||||
|
existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := database.UpdateMediaBookmarkForSyncParams{
|
||||||
|
ID: existing.ID,
|
||||||
|
PageNumber: existing.PageNumber,
|
||||||
|
ChapterNumber: existing.ChapterNumber,
|
||||||
|
CfiPosition: existing.CfiPosition,
|
||||||
|
Title: existing.Title,
|
||||||
|
Position: existing.Position,
|
||||||
|
Notes: existing.Notes,
|
||||||
|
PercentageLocation: existing.PercentageLocation,
|
||||||
|
EpubcfiLocation: existing.EpubcfiLocation,
|
||||||
|
ChapterReference: existing.ChapterReference,
|
||||||
|
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||||
|
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
|
DeviceSyncData: existing.DeviceSyncData,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := data["title"].(string); ok {
|
||||||
|
params.Title = v
|
||||||
|
}
|
||||||
|
if v, ok := data["notes"].(string); ok {
|
||||||
|
params.Notes = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.UpdateMediaBookmarkForSync(ctx, params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||||
|
existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := database.UpdateMediaNoteForSyncParams{
|
||||||
|
ID: existing.ID,
|
||||||
|
Content: existing.Content,
|
||||||
|
Position: existing.Position,
|
||||||
|
PercentageLocation: existing.PercentageLocation,
|
||||||
|
CharacterStart: existing.CharacterStart,
|
||||||
|
CharacterEnd: existing.CharacterEnd,
|
||||||
|
EpubcfiLocation: existing.EpubcfiLocation,
|
||||||
|
ChapterReference: existing.ChapterReference,
|
||||||
|
ParagraphReference: existing.ParagraphReference,
|
||||||
|
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||||
|
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
|
DeviceSyncData: existing.DeviceSyncData,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := data["content"].(string); ok {
|
||||||
|
params.Content = v
|
||||||
|
}
|
||||||
|
if v, ok := data["position"].(string); ok {
|
||||||
|
params.Position = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.UpdateMediaNoteForSync(ctx, params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
|
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
|
||||||
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -551,7 +696,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winnerData); err != nil {
|
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
|
||||||
results = append(results, ConflictResult{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
Status: "error",
|
Status: "error",
|
||||||
@@ -635,7 +780,7 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
|
|||||||
return highestSource, highestData
|
return highestSource, highestData
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
@@ -690,8 +835,8 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
|
|||||||
CharacterOffset: characterOffset,
|
CharacterOffset: characterOffset,
|
||||||
CurrentPage: currentPage,
|
CurrentPage: currentPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: "bulk", Valid: true},
|
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
||||||
ViewportY: pgtype.Float8{},
|
ViewportY: pgtype.Float8{},
|
||||||
ScrollPositionX: pgtype.Float8{},
|
ScrollPositionX: pgtype.Float8{},
|
||||||
ScrollPositionY: pgtype.Float8{},
|
ScrollPositionY: pgtype.Float8{},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -64,6 +65,7 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sectionData := BuildSections(sections, libraryID)
|
sectionData := BuildSections(sections, libraryID)
|
||||||
|
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||||||
}
|
}
|
||||||
@@ -188,6 +190,59 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// activeConflictSet returns the set of media item IDs (as strings) that have an
|
||||||
|
// active (unresolved) progress sync conflict for the given user. A single query
|
||||||
|
// is issued; resolved conflicts are filtered out in memory.
|
||||||
|
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
|
||||||
|
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set := make(map[string]bool, len(conflicts))
|
||||||
|
for _, c := range conflicts {
|
||||||
|
if c.ResolutionStatus.String == "unresolved" {
|
||||||
|
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
|
||||||
|
// active progress sync conflict for the user. It performs a single query
|
||||||
|
// regardless of how many books are passed.
|
||||||
|
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
|
||||||
|
if len(books) == 0 {
|
||||||
|
return books
|
||||||
|
}
|
||||||
|
set := activeConflictSet(ctx, db, userID)
|
||||||
|
for i := range books {
|
||||||
|
if set[books[i].MediaItemID] {
|
||||||
|
books[i].HasConflict = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return books
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
|
||||||
|
// used by the dashboard which renders books grouped into sections.
|
||||||
|
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
|
||||||
|
if len(sections) == 0 {
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
set := activeConflictSet(ctx, db, userID)
|
||||||
|
if len(set) == 0 {
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
for s := range sections {
|
||||||
|
for i := range sections[s].Items {
|
||||||
|
if set[sections[s].Items[i].MediaItemID] {
|
||||||
|
sections[s].Items[i].HasConflict = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
func getViewAllURL(collectionID string, libraryID string) string {
|
func getViewAllURL(collectionID string, libraryID string) string {
|
||||||
if collectionID != "" {
|
if collectionID != "" {
|
||||||
if libraryID != "" {
|
if libraryID != "" {
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ type PendingRegistration struct {
|
|||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
Approved bool
|
||||||
|
AuthToken string
|
||||||
|
DeviceID [16]byte
|
||||||
|
SyncEndpoints map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingRegistrations = make(map[string]*PendingRegistration)
|
var pendingRegistrations = make(map[string]*PendingRegistration)
|
||||||
@@ -173,60 +177,21 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if registration.UserID == (uuid.UUID{}) {
|
if registration.Approved {
|
||||||
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
Status: "pending",
|
Status: "approved",
|
||||||
Message: "awaiting user approval",
|
AuthToken: registration.AuthToken,
|
||||||
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
DeviceID: registration.DeviceID,
|
||||||
|
SyncEndpoints: registration.SyncEndpoints,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
authToken, err := generateDeviceToken()
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
|
||||||
}
|
|
||||||
|
|
||||||
userUUID := registration.UserID
|
|
||||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
|
||||||
|
|
||||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
|
||||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
|
||||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
|
||||||
|
|
||||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
|
||||||
UserID: pgUserID,
|
|
||||||
DeviceName: registration.DeviceName,
|
|
||||||
DeviceType: registration.DeviceType,
|
|
||||||
DeviceIdentifier: registration.DeviceIdentifier,
|
|
||||||
AuthToken: authToken,
|
|
||||||
SyncEnabled: syncEnabled,
|
|
||||||
AutoSync: autoSync,
|
|
||||||
SyncFrequencyMinutes: syncFreq,
|
|
||||||
DeviceMetadata: []byte("{}"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(pendingRegistrations, req.RegistrationID)
|
|
||||||
|
|
||||||
syncEndpoints := map[string]string{}
|
|
||||||
switch registration.DeviceType {
|
|
||||||
case "koreader":
|
|
||||||
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
|
||||||
case "kobo":
|
|
||||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
Status: "approved",
|
Status: "pending",
|
||||||
AuthToken: authToken,
|
Message: "awaiting user approval",
|
||||||
DeviceID: device.ID.Bytes,
|
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||||
SyncEndpoints: syncEndpoints,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,14 +562,63 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if registration.Approved {
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "device already approved",
|
||||||
|
"device_name": registration.DeviceName,
|
||||||
|
"device_type": registration.DeviceType,
|
||||||
|
"approved": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
authToken, err := generateDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||||
|
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||||
|
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||||
|
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||||
|
|
||||||
|
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeviceName: registration.DeviceName,
|
||||||
|
DeviceType: registration.DeviceType,
|
||||||
|
DeviceIdentifier: registration.DeviceIdentifier,
|
||||||
|
AuthToken: authToken,
|
||||||
|
SyncEnabled: syncEnabled,
|
||||||
|
AutoSync: autoSync,
|
||||||
|
SyncFrequencyMinutes: syncFreq,
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||||
|
}
|
||||||
|
|
||||||
|
syncEndpoints := map[string]string{}
|
||||||
|
switch registration.DeviceType {
|
||||||
|
case "koreader":
|
||||||
|
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
||||||
|
case "kobo":
|
||||||
|
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
registration.UserID = userUUID
|
registration.UserID = userUUID
|
||||||
|
registration.Approved = true
|
||||||
|
registration.AuthToken = authToken
|
||||||
|
registration.DeviceID = device.ID.Bytes
|
||||||
|
registration.SyncEndpoints = syncEndpoints
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"message": "device approved successfully",
|
"message": "device approved successfully",
|
||||||
"device_name": registration.DeviceName,
|
"device_name": registration.DeviceName,
|
||||||
"device_type": registration.DeviceType,
|
"device_type": registration.DeviceType,
|
||||||
"registration_id": registrationID,
|
"registration_id": registrationID,
|
||||||
"approved": true, // Fixed: Add confirmation field for test compatibility
|
"approved": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,15 +638,9 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||||
userID := c.Get("user_id").(string)
|
|
||||||
userUUID, err := uuid.Parse(userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
registrations := []map[string]interface{}{}
|
registrations := []map[string]interface{}{}
|
||||||
for _, reg := range pendingRegistrations {
|
for _, reg := range pendingRegistrations {
|
||||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
if reg.UserID == (uuid.UUID{}) {
|
||||||
registrations = append(registrations, map[string]interface{}{
|
registrations = append(registrations, map[string]interface{}{
|
||||||
"registration_id": reg.RegistrationID,
|
"registration_id": reg.RegistrationID,
|
||||||
"device_name": reg.DeviceName,
|
"device_name": reg.DeviceName,
|
||||||
@@ -640,7 +648,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
|||||||
"device_identifier": reg.DeviceIdentifier,
|
"device_identifier": reg.DeviceIdentifier,
|
||||||
"expires_at": reg.ExpiresAt,
|
"expires_at": reg.ExpiresAt,
|
||||||
"created_at": reg.CreatedAt,
|
"created_at": reg.CreatedAt,
|
||||||
"is_approved": reg.UserID != (uuid.UUID{}),
|
"is_approved": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashConflictsHandler serves the admin Hash Conflicts page API: listing
|
||||||
|
// content-duplicate groups (same library + SHA-256 at different paths) and
|
||||||
|
// resolving them by keeping every copy or merging all but one.
|
||||||
|
type HashConflictsHandler struct {
|
||||||
|
db *database.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHashConflictsHandler(db *database.Queries) *HashConflictsHandler {
|
||||||
|
return &HashConflictsHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictItem is one copy in a conflict group, hydrated with per-item
|
||||||
|
// user-data counts so the admin can make an informed keep/merge choice.
|
||||||
|
type HashConflictItem struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Author string `json:"author,omitempty"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
FileSize int64 `json:"file_size,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
ProgressCount int64 `json:"progress_count"`
|
||||||
|
HighlightCount int64 `json:"highlight_count"`
|
||||||
|
BookmarkCount int64 `json:"bookmark_count"`
|
||||||
|
NoteCount int64 `json:"note_count"`
|
||||||
|
CollectionCount int64 `json:"collection_count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashConflictResponse is one pending conflict group.
|
||||||
|
type HashConflictResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
LibraryID string `json:"library_id"`
|
||||||
|
LibraryName string `json:"library_name"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
Items []HashConflictItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListHashConflicts returns all pending hash conflict groups with their member
|
||||||
|
// items and usage counts.
|
||||||
|
// GET /api/admin/hash-conflicts
|
||||||
|
func (h *HashConflictsHandler) ListHashConflicts(c *echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
pending, err := h.db.ListPendingHashConflicts(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "failed to list hash conflicts",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts := make([]HashConflictResponse, 0, len(pending))
|
||||||
|
for _, p := range pending {
|
||||||
|
resp := HashConflictResponse{
|
||||||
|
ID: uuid.UUID(p.ID.Bytes).String(),
|
||||||
|
LibraryID: uuid.UUID(p.LibraryID.Bytes).String(),
|
||||||
|
LibraryName: p.LibraryName,
|
||||||
|
SHA256: p.FileSha256,
|
||||||
|
CreatedAt: p.CreatedAt.Time.Format(time.RFC3339),
|
||||||
|
Items: []HashConflictItem{},
|
||||||
|
}
|
||||||
|
items, err := h.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: p.FileSha256, Valid: true},
|
||||||
|
LibraryID: p.LibraryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mi := range items {
|
||||||
|
counts, err := h.db.GetMediaItemUsageCounts(ctx, mi.ID)
|
||||||
|
if err != nil {
|
||||||
|
counts = database.GetMediaItemUsageCountsRow{}
|
||||||
|
}
|
||||||
|
resp.Items = append(resp.Items, HashConflictItem{
|
||||||
|
ID: uuid.UUID(mi.ID.Bytes),
|
||||||
|
Title: mi.Title,
|
||||||
|
Author: mi.Author.String,
|
||||||
|
FilePath: mi.FilePath,
|
||||||
|
FileSize: mi.FileSize.Int64,
|
||||||
|
CreatedAt: mi.CreatedAt.Time.Format(time.RFC3339),
|
||||||
|
ProgressCount: counts.ProgressCount,
|
||||||
|
HighlightCount: counts.HighlightsCount,
|
||||||
|
BookmarkCount: counts.BookmarksCount,
|
||||||
|
NoteCount: counts.NotesCount,
|
||||||
|
CollectionCount: counts.CollectionsCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts = append(conflicts, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"conflicts": conflicts,
|
||||||
|
"total": len(conflicts),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveHashConflict resolves one conflict group.
|
||||||
|
//
|
||||||
|
// Form/JSON fields:
|
||||||
|
// - action=keep_all both copies are intentional; dismiss
|
||||||
|
// - action=keep&keep_uuid=<uuid> merge every other copy's child rows into the
|
||||||
|
// kept item (progress, highlights, bookmarks,
|
||||||
|
// notes, collections, ...) and delete the losers
|
||||||
|
//
|
||||||
|
// POST /api/admin/hash-conflicts/:id/resolve
|
||||||
|
func (h *HashConflictsHandler) ResolveHashConflict(c *echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
conflictID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid conflict ID"})
|
||||||
|
}
|
||||||
|
pgConflictID := pgtype.UUID{Bytes: conflictID, Valid: true}
|
||||||
|
|
||||||
|
conflict, err := h.db.GetHashConflict(ctx, pgConflictID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "conflict not found"})
|
||||||
|
}
|
||||||
|
if conflict.Status != "pending" {
|
||||||
|
return c.JSON(http.StatusConflict, map[string]string{"error": "conflict already resolved"})
|
||||||
|
}
|
||||||
|
|
||||||
|
action := c.FormValue("action")
|
||||||
|
keepUUIDStr := c.FormValue("keep_uuid")
|
||||||
|
if action == "" {
|
||||||
|
// Also accept a JSON body (htmx sends form-encoded, API clients may send JSON)
|
||||||
|
var body struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
KeepUUID string `json:"keep_uuid"`
|
||||||
|
}
|
||||||
|
if err := c.Bind(&body); err == nil && body.Action != "" {
|
||||||
|
action = body.Action
|
||||||
|
if keepUUIDStr == "" {
|
||||||
|
keepUUIDStr = body.KeepUUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var pgUserID pgtype.UUID
|
||||||
|
if userID, ok := c.Get("user_id").(string); ok && userID != "" {
|
||||||
|
if u, err := uuid.Parse(userID); err == nil {
|
||||||
|
pgUserID = pgtype.UUID{Bytes: u, Valid: true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "keep_all":
|
||||||
|
if err := h.db.ResolveHashConflict(ctx, database.ResolveHashConflictParams{
|
||||||
|
ID: pgConflictID,
|
||||||
|
Resolution: pgtype.Text{String: "keep_all", Valid: true},
|
||||||
|
ResolvedBy: pgUserID,
|
||||||
|
}); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflict"})
|
||||||
|
}
|
||||||
|
return renderResolved(c, "All copies kept.")
|
||||||
|
|
||||||
|
case "keep":
|
||||||
|
keepUUID, err := uuid.Parse(keepUUIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_uuid is required for action=keep"})
|
||||||
|
}
|
||||||
|
pgKeepUUID := pgtype.UUID{Bytes: keepUUID, Valid: true}
|
||||||
|
|
||||||
|
// Validate the kept item belongs to this conflict group.
|
||||||
|
items, err := h.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: conflict.FileSha256, Valid: true},
|
||||||
|
LibraryID: conflict.LibraryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load conflict group"})
|
||||||
|
}
|
||||||
|
|
||||||
|
keepValid := false
|
||||||
|
for _, mi := range items {
|
||||||
|
if mi.ID.Bytes == pgKeepUUID.Bytes {
|
||||||
|
keepValid = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !keepValid {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "keep_uuid is not part of this conflict"})
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := 0
|
||||||
|
for _, mi := range items {
|
||||||
|
if mi.ID.Bytes == pgKeepUUID.Bytes {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := h.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{
|
||||||
|
Column1: pgKeepUUID,
|
||||||
|
Column2: mi.ID,
|
||||||
|
}); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": fmt.Sprintf("failed to merge %q: %v", mi.FilePath, err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := h.db.DeleteMediaItem(ctx, mi.ID); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": fmt.Sprintf("failed to delete %q: %v", mi.FilePath, err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
merged++
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.ResolveHashConflict(ctx, database.ResolveHashConflictParams{
|
||||||
|
ID: pgConflictID,
|
||||||
|
Resolution: pgtype.Text{String: "kept:" + keepUUID.String(), Valid: true},
|
||||||
|
ResolvedBy: pgUserID,
|
||||||
|
}); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to resolve conflict"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderResolved(c, fmt.Sprintf("Merged %d duplicate cop%s - all reading data preserved.",
|
||||||
|
merged, map[bool]string{true: "y", false: "ies"}[merged == 1]))
|
||||||
|
|
||||||
|
default:
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "action must be 'keep_all' or 'keep'"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderResolved returns the htmx fragment swapped in place of a conflict card.
|
||||||
|
// Built inline (rather than via the templates package) because templates
|
||||||
|
// imports handlers and a back-import would be a cycle.
|
||||||
|
func renderResolved(c *echo.Context, message string) error {
|
||||||
|
html := fmt.Sprintf(`
|
||||||
|
<div class="card p-6 flex items-center gap-3">
|
||||||
|
<span class="grid place-items-center h-10 w-10 rounded-xl shrink-0"
|
||||||
|
style="background-color: var(--accent-muted); color: var(--accent);">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5" aria-hidden="true">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium" style="color: var(--text-primary);">Conflict resolved</p>
|
||||||
|
<p class="text-sm" style="color: var(--text-secondary);">%s</p>
|
||||||
|
</div>
|
||||||
|
</div>`, message)
|
||||||
|
return c.HTML(http.StatusOK, html)
|
||||||
|
}
|
||||||
+319
-67
@@ -2,8 +2,11 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -15,19 +18,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type KoboHandler struct {
|
type KoboHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
connManager *wsync.ConnectionManager
|
connManager *wsync.ConnectionManager
|
||||||
progressSvc *wsync.ProgressService
|
progressSvc *wsync.ProgressService
|
||||||
|
annotationSvc *wsync.AnnotationService
|
||||||
|
libraryService LibraryPathResolver
|
||||||
|
bookResolver *services.BookResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
||||||
return &KoboHandler{db: db, connManager: connManager}
|
return &KoboHandler{db: db, connManager: connManager, bookResolver: services.NewBookResolver(db)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||||
h.progressSvc = svc
|
h.progressSvc = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||||
|
h.annotationSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||||
|
h.libraryService = svc
|
||||||
|
}
|
||||||
|
|
||||||
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
|
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
|
||||||
// Enhanced Kobo Sync - ContentId Mapping Logic
|
// Enhanced Kobo Sync - ContentId Mapping Logic
|
||||||
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
|
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
|
||||||
@@ -40,8 +54,9 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
|
|||||||
|
|
||||||
// Step 2: ContentId not found - check if it looks like a SHA-256 hash
|
// Step 2: ContentId not found - check if it looks like a SHA-256 hash
|
||||||
if len(contentId) == 64 && looksLikeSHA256(contentId) {
|
if len(contentId) == 64 && looksLikeSHA256(contentId) {
|
||||||
// Try to find media item by SHA-256
|
// Try to find media item by SHA-256 (format-aware: also checks
|
||||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx.Request().Context(), pgtype.Text{String: contentId, Valid: true})
|
// media_item_formats, so a converted/alternate format hash matches).
|
||||||
|
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx.Request().Context(), contentId)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Found by SHA-256! Create device catalog entry for future lookups
|
// Found by SHA-256! Create device catalog entry for future lookups
|
||||||
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
||||||
@@ -239,9 +254,16 @@ type KoboInitResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type KoboSyncStatus struct {
|
type KoboSyncStatus struct {
|
||||||
Status string `json:"Status"`
|
Status string `json:"Status"`
|
||||||
MarkupsSynced int `json:"MarkupsSynced"`
|
MarkupsSynced int `json:"MarkupsSynced"`
|
||||||
BookmarksSynced int `json:"BookmarksSynced"`
|
BookmarksSynced int `json:"BookmarksSynced"`
|
||||||
|
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KoboDeletedAnnotation struct {
|
||||||
|
ContentId string `json:"ContentId"`
|
||||||
|
BookmarkId string `json:"BookmarkId"`
|
||||||
|
Type string `json:"Type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KoboServerSyncData struct {
|
type KoboServerSyncData struct {
|
||||||
@@ -305,7 +327,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bookmarkCount := 0
|
bookmarkCount := 0
|
||||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
@@ -397,6 +419,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
markupsSynced := 0
|
markupsSynced := 0
|
||||||
bookmarksSynced := 0
|
bookmarksSynced := 0
|
||||||
unlinkedBooks := 0
|
unlinkedBooks := 0
|
||||||
|
processedBooks := make(map[pgtype.UUID]string)
|
||||||
|
|
||||||
for _, readingSync := range req.ReadingSync {
|
for _, readingSync := range req.ReadingSync {
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
||||||
@@ -406,8 +429,23 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
|
processedBooks[pgMediaUUID] = readingSync.ContentId
|
||||||
percentage := readingSync.PercentRead / 100.0
|
percentage := readingSync.PercentRead / 100.0
|
||||||
|
|
||||||
|
// Kobo only sends a percentage. For fixed-layout & comic formats the page
|
||||||
|
// index is the canonical locator, so derive it from the known page count.
|
||||||
|
var currentPage, totalPages *int
|
||||||
|
if mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID); mErr == nil {
|
||||||
|
if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
||||||
|
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
||||||
|
total := int(mediaItem.PageCount.Int32)
|
||||||
|
page := wsync.PercentageToPage(percentage, total)
|
||||||
|
currentPage = &page
|
||||||
|
totalPages = &total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if h.progressSvc != nil {
|
if h.progressSvc != nil {
|
||||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
@@ -415,6 +453,8 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
Source: "kobo",
|
Source: "kobo",
|
||||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
Percentage: &percentage,
|
Percentage: &percentage,
|
||||||
|
CurrentPage: currentPage,
|
||||||
|
TotalPages: totalPages,
|
||||||
DeviceType: "kobo",
|
DeviceType: "kobo",
|
||||||
DeviceName: device.DeviceName,
|
DeviceName: device.DeviceName,
|
||||||
Broadcast: true,
|
Broadcast: true,
|
||||||
@@ -443,29 +483,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
|
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
|
||||||
|
|
||||||
switch bookmarkSync.BookmarkType {
|
switch bookmarkSync.BookmarkType {
|
||||||
case "annotation":
|
case "annotation":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
if bookmarkSync.BookmarkText != "" {
|
||||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"bookmark_id": bookmarkSync.BookmarkId,
|
||||||
SelectionText: bookmarkSync.BookmarkText,
|
"date_created": bookmarkSync.DateCreated,
|
||||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
})
|
||||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
|
||||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
})
|
MediaItemID: pgMediaUUID,
|
||||||
bookmarksSynced++
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmarkSync.BookmarkText,
|
||||||
|
StartPosition: bookmarkSync.BookmarkId,
|
||||||
|
EndPosition: bookmarkSync.BookmarkId,
|
||||||
|
Color: "#ffff00",
|
||||||
|
NoteText: bookmarkSync.BookmarkTitle,
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmarkSync.BookmarkText,
|
||||||
|
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "bookmark":
|
case "bookmark":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
if bookmarkSync.BookmarkText != "" {
|
||||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"bookmark_id": bookmarkSync.BookmarkId,
|
||||||
Content: bookmarkSync.BookmarkText,
|
"date_created": bookmarkSync.DateCreated,
|
||||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
})
|
||||||
})
|
|
||||||
bookmarksSynced++
|
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Title: bookmarkSync.BookmarkText,
|
||||||
|
Position: bookmarkSync.BookmarkId,
|
||||||
|
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Content: bookmarkSync.BookmarkText,
|
||||||
|
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "last-read-place":
|
case "last-read-place":
|
||||||
if bookmarkSync.BookmarkId != "" {
|
if bookmarkSync.BookmarkId != "" {
|
||||||
@@ -479,6 +562,23 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
chapter := bookmarkSync.Chapter
|
chapter := bookmarkSync.Chapter
|
||||||
chapterProgress := 0.5
|
chapterProgress := 0.5
|
||||||
|
|
||||||
|
var convertedCFI *string
|
||||||
|
var contextText *string
|
||||||
|
|
||||||
|
if epubcfi != "" && h.libraryService != nil {
|
||||||
|
mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID)
|
||||||
|
if mErr == nil {
|
||||||
|
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
||||||
|
if formatGroup != wsync.FormatGroupFixedLayout && formatGroup != wsync.FormatGroupComicArchive {
|
||||||
|
convertedCFI, contextText = h.convertKoboCFIToStandard(c, mediaItem, epubcfi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if convertedCFI != nil {
|
||||||
|
epubcfi = *convertedCFI
|
||||||
|
}
|
||||||
|
|
||||||
if h.progressSvc != nil {
|
if h.progressSvc != nil {
|
||||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
MediaItemID: pgMediaUUID,
|
MediaItemID: pgMediaUUID,
|
||||||
@@ -486,6 +586,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
Source: "kobo",
|
Source: "kobo",
|
||||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
Epubcfi: &epubcfi,
|
Epubcfi: &epubcfi,
|
||||||
|
ContextText: contextText,
|
||||||
Chapter: &chapter,
|
Chapter: &chapter,
|
||||||
ChapterProgress: &chapterProgress,
|
ChapterProgress: &chapterProgress,
|
||||||
DeviceType: "kobo",
|
DeviceType: "kobo",
|
||||||
@@ -524,6 +625,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
BookmarksSynced: bookmarksSynced,
|
BookmarksSynced: bookmarksSynced,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.annotationSvc != nil && len(processedBooks) > 0 {
|
||||||
|
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
|
||||||
|
for mediaItemID, contentId := range processedBooks {
|
||||||
|
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeletedAt: cutoff,
|
||||||
|
})
|
||||||
|
for _, ts := range tombstones {
|
||||||
|
var dd map[string]interface{}
|
||||||
|
if len(ts.DeviceSyncData) > 0 {
|
||||||
|
json.Unmarshal(ts.DeviceSyncData, &dd)
|
||||||
|
}
|
||||||
|
bookmarkID, _ := dd["bookmark_id"].(string)
|
||||||
|
if bookmarkID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{
|
||||||
|
ContentId: contentId,
|
||||||
|
BookmarkId: bookmarkID,
|
||||||
|
Type: ts.AnnotationType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Include unlinked books count if any
|
// Include unlinked books count if any
|
||||||
if unlinkedBooks > 0 {
|
if unlinkedBooks > 0 {
|
||||||
// For now, just log it. In production, this should trigger an alert
|
// For now, just log it. In production, this should trigger an alert
|
||||||
@@ -566,25 +693,67 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
|||||||
switch bookmarkSync.BookmarkType {
|
switch bookmarkSync.BookmarkType {
|
||||||
case "annotation":
|
case "annotation":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
if bookmarkSync.BookmarkText != "" {
|
||||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"bookmark_id": bookmarkSync.BookmarkId,
|
||||||
SelectionText: bookmarkSync.BookmarkText,
|
"date_created": bookmarkSync.DateCreated,
|
||||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
})
|
||||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
|
||||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
})
|
MediaItemID: pgMediaUUID,
|
||||||
bookmarksSynced++
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmarkSync.BookmarkText,
|
||||||
|
StartPosition: bookmarkSync.BookmarkId,
|
||||||
|
EndPosition: bookmarkSync.BookmarkId,
|
||||||
|
Color: "#ffff00",
|
||||||
|
NoteText: bookmarkSync.BookmarkTitle,
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmarkSync.BookmarkText,
|
||||||
|
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "bookmark":
|
case "bookmark":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
if bookmarkSync.BookmarkText != "" {
|
||||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"bookmark_id": bookmarkSync.BookmarkId,
|
||||||
Content: bookmarkSync.BookmarkText,
|
"date_created": bookmarkSync.DateCreated,
|
||||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
})
|
||||||
})
|
|
||||||
bookmarksSynced++
|
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Title: bookmarkSync.BookmarkText,
|
||||||
|
Position: bookmarkSync.BookmarkId,
|
||||||
|
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Content: bookmarkSync.BookmarkText,
|
||||||
|
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -716,36 +885,79 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
|
|
||||||
for _, bookmark := range syncData.Bookmarks {
|
for _, bookmark := range syncData.Bookmarks {
|
||||||
if bookmark.BookmarkType == "bookmark" {
|
if bookmark.BookmarkType == "bookmark" {
|
||||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
Content: bookmark.BookmarkText,
|
UserID: pgUserID,
|
||||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
Title: bookmark.BookmarkText,
|
||||||
})
|
Position: bookmark.BookmarkId,
|
||||||
bookmarksSent++
|
Source: "kobo",
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSent++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Content: bookmark.BookmarkText,
|
||||||
|
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSent++
|
||||||
|
}
|
||||||
} else if bookmark.BookmarkType == "annotation" {
|
} else if bookmark.BookmarkType == "annotation" {
|
||||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
SelectionText: bookmark.BookmarkText,
|
UserID: pgUserID,
|
||||||
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
SelectionText: bookmark.BookmarkText,
|
||||||
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
StartPosition: bookmark.BookmarkId,
|
||||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
EndPosition: bookmark.BookmarkId,
|
||||||
})
|
Color: "#ffff00",
|
||||||
highlightsSent++
|
Source: "kobo",
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
highlightsSent++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmark.BookmarkText,
|
||||||
|
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||||
|
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||||
|
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||||
|
})
|
||||||
|
highlightsSent++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, highlight := range syncData.Highlights {
|
for _, highlight := range syncData.Highlights {
|
||||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
SelectionText: highlight.BookmarkText,
|
UserID: pgUserID,
|
||||||
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
SelectionText: highlight.BookmarkText,
|
||||||
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
StartPosition: highlight.BookmarkId,
|
||||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
EndPosition: highlight.BookmarkId,
|
||||||
})
|
Color: "#ffff00",
|
||||||
highlightsSent++
|
Source: "kobo",
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
highlightsSent++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: highlight.BookmarkText,
|
||||||
|
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||||
|
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||||
|
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||||
|
})
|
||||||
|
highlightsSent++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,3 +974,43 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
HighlightsSent: highlightsSent,
|
HighlightsSent: highlightsSent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *KoboHandler) convertKoboCFIToStandard(c *echo.Context, mediaItem database.MediaItems, kepubCFI string) (*string, *string) {
|
||||||
|
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Bookhoard: KEPUB→CFI failed to resolve EPUB path: %v", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if epubPath == "" {
|
||||||
|
log.Printf("Bookhoard: KEPUB→CFI resolved empty EPUB path for %s", mediaItem.FilePath)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||||
|
FormatType: "kepub",
|
||||||
|
})
|
||||||
|
if err != nil || !kepubFormat.FilePath.Valid {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
converter := wsync.NewKEPUBCFIConverter(epubPath, kepubFormat.FilePath.String)
|
||||||
|
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.0, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Bookhoard: KEPUB→CFI conversion error: %v", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfi *string
|
||||||
|
if result.CFI != "" {
|
||||||
|
cfi = &result.CFI
|
||||||
|
log.Printf("Bookhoard: KEPUB→CFI converted (precision=%s)", result.Precision)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctx *string
|
||||||
|
if result.ExtractedContext != "" {
|
||||||
|
ctx = &result.ExtractedContext
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfi, ctx
|
||||||
|
}
|
||||||
|
|||||||
+921
-97
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The device pushes deletions as dedup-key arrays on the progress request.
|
||||||
|
// Verify the wire shape the plugin sends (lua json.encode of
|
||||||
|
// { deleted_highlights = { { dedup_key = "..." } } }) binds correctly.
|
||||||
|
func TestKOReaderProgressRequest_DeletedAnnotationsBinding(t *testing.T) {
|
||||||
|
payload := `{
|
||||||
|
"books": [{
|
||||||
|
"sha256": "d1b1c6123d6206017b40798744ed994f00803b97d22ce51bea32e95e1ce7a164",
|
||||||
|
"title": "1984",
|
||||||
|
"percentage": 0.42,
|
||||||
|
"deleted_highlights": [
|
||||||
|
{ "dedup_key": "abc123" },
|
||||||
|
{ "dedup_key": "def456" }
|
||||||
|
],
|
||||||
|
"deleted_bookmarks": [
|
||||||
|
{ "dedup_key": "789xyz" }
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}`
|
||||||
|
|
||||||
|
var req KOReaderProgressRequest
|
||||||
|
err := json.Unmarshal([]byte(payload), &req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Len(t, req.Books, 1)
|
||||||
|
|
||||||
|
book := req.Books[0]
|
||||||
|
assert.Len(t, book.DeletedHighlights, 2)
|
||||||
|
assert.Equal(t, "abc123", book.DeletedHighlights[0].DedupKey)
|
||||||
|
assert.Equal(t, "def456", book.DeletedHighlights[1].DedupKey)
|
||||||
|
assert.Len(t, book.DeletedBookmarks, 1)
|
||||||
|
assert.Equal(t, "789xyz", book.DeletedBookmarks[0].DedupKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request without the arrays (older plugins) must bind with them empty —
|
||||||
|
// deletion propagation is strictly opt-in per push.
|
||||||
|
func TestKOReaderProgressRequest_DeletedAnnotationsOmitted(t *testing.T) {
|
||||||
|
payload := `{"books": [{"sha256": "x", "title": "t", "percentage": 0.1}]}`
|
||||||
|
|
||||||
|
var req KOReaderProgressRequest
|
||||||
|
err := json.Unmarshal([]byte(payload), &req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, req.Books[0].DeletedHighlights)
|
||||||
|
assert.Empty(t, req.Books[0].DeletedBookmarks)
|
||||||
|
}
|
||||||
+383
-86
@@ -11,7 +11,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -19,6 +18,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -114,20 +114,51 @@ type UpdateMediaNoteRequest struct {
|
|||||||
|
|
||||||
// CreateMediaHighlightRequest represents the request for creating a media highlight
|
// CreateMediaHighlightRequest represents the request for creating a media highlight
|
||||||
type CreateMediaHighlightRequest struct {
|
type CreateMediaHighlightRequest struct {
|
||||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||||
StartPosition string `json:"start_position" validate:"required,max=100"`
|
StartPosition string `json:"start_position" validate:"max=1000"`
|
||||||
EndPosition string `json:"end_position" validate:"required,max=100"`
|
EndPosition string `json:"end_position" validate:"max=1000"`
|
||||||
Color string `json:"color" validate:"omitempty,len=7"`
|
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||||
NoteID string `json:"note_id"`
|
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
||||||
|
Color string `json:"color" validate:"omitempty,len=7"`
|
||||||
|
NoteText string `json:"note_text" validate:"max=10000"`
|
||||||
|
NoteID string `json:"note_id"`
|
||||||
|
PercentageStart float64 `json:"percentage_start"`
|
||||||
|
PercentageEnd float64 `json:"percentage_end"`
|
||||||
|
ChapterReference int32 `json:"chapter_reference"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateMediaHighlightRequest represents the request for updating a media highlight
|
// UpdateMediaHighlightRequest represents the request for updating a media highlight
|
||||||
type UpdateMediaHighlightRequest struct {
|
type UpdateMediaHighlightRequest struct {
|
||||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||||
StartPosition string `json:"start_position" validate:"required,max=100"`
|
StartPosition string `json:"start_position" validate:"max=1000"`
|
||||||
EndPosition string `json:"end_position" validate:"required,max=100"`
|
EndPosition string `json:"end_position" validate:"max=1000"`
|
||||||
Color string `json:"color" validate:"omitempty,len=7"`
|
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||||
NoteID string `json:"note_id"`
|
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
||||||
|
Color string `json:"color" validate:"omitempty,len=7"`
|
||||||
|
NoteText string `json:"note_text" validate:"max=10000"`
|
||||||
|
NoteID string `json:"note_id"`
|
||||||
|
PercentageStart float64 `json:"percentage_start"`
|
||||||
|
PercentageEnd float64 `json:"percentage_end"`
|
||||||
|
ChapterReference int32 `json:"chapter_reference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMediaBookmarkRequest represents the request for creating a media bookmark
|
||||||
|
type CreateMediaBookmarkRequest struct {
|
||||||
|
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||||
|
Position string `json:"position" validate:"max=100"`
|
||||||
|
Notes string `json:"notes" validate:"max=10000"`
|
||||||
|
CfiPosition string `json:"cfi_position" validate:"max=255"`
|
||||||
|
PageNumber int32 `json:"page_number"`
|
||||||
|
ChapterNumber int32 `json:"chapter_number"`
|
||||||
|
Percentage float64 `json:"percentage"`
|
||||||
|
ChapterReference int32 `json:"chapter_reference"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
|
||||||
|
type UpdateMediaBookmarkRequest struct {
|
||||||
|
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||||
|
Notes string `json:"notes" validate:"max=10000"`
|
||||||
|
Position string `json:"position" validate:"max=100"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaHandler struct {
|
type MediaHandler struct {
|
||||||
@@ -136,6 +167,7 @@ type MediaHandler struct {
|
|||||||
libraryService *services.LibraryService
|
libraryService *services.LibraryService
|
||||||
searchService *services.SearchService
|
searchService *services.SearchService
|
||||||
progressSvc *wsync.ProgressService
|
progressSvc *wsync.ProgressService
|
||||||
|
annotationSvc *wsync.AnnotationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
||||||
@@ -154,53 +186,8 @@ func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
|||||||
mh.progressSvc = svc
|
mh.progressSvc = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
mh.annotationSvc = svc
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book UUID"})
|
|
||||||
}
|
|
||||||
|
|
||||||
pgBookUUID := pgtype.UUID{Bytes: bookUUID, Valid: true}
|
|
||||||
|
|
||||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgBookUUID)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve relative path to absolute filesystem path
|
|
||||||
fullPath, err := h.getFullFilePath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(fullPath); os.IsNotExist(err) {
|
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found on disk"})
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(fullPath)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to open book file"})
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
mimeType := mediaItem.MimeType.String
|
|
||||||
if !mediaItem.MimeType.Valid || mimeType == "" {
|
|
||||||
mimeType = mime.TypeByExtension(filepath.Ext(mediaItem.FilePath))
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Response().Header().Set("Content-Type", mimeType)
|
|
||||||
c.Response().Header().Set("Content-Disposition", "attachment; filename=\""+filepath.Base(mediaItem.FilePath)+"\"")
|
|
||||||
|
|
||||||
if mediaItem.FileSize.Valid && mediaItem.FileSize.Int64 > 0 {
|
|
||||||
c.Response().Header().Set("Content-Length", strconv.FormatInt(mediaItem.FileSize.Int64, 10))
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = io.Copy(c.Response(), file)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to stream file"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExecuteSearch performs search and returns results with count
|
// ExecuteSearch performs search and returns results with count
|
||||||
@@ -975,6 +962,7 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|||||||
CurrentPage *int32 `json:"current_page"`
|
CurrentPage *int32 `json:"current_page"`
|
||||||
TotalPages *int32 `json:"total_pages"`
|
TotalPages *int32 `json:"total_pages"`
|
||||||
Epubcfi *string `json:"epubcfi"`
|
Epubcfi *string `json:"epubcfi"`
|
||||||
|
ContextText *string `json:"context_text"`
|
||||||
Percentage *float64 `json:"percentage"`
|
Percentage *float64 `json:"percentage"`
|
||||||
Chapter *int `json:"chapter"`
|
Chapter *int `json:"chapter"`
|
||||||
ChapterProgress *float64 `json:"chapter_progress"`
|
ChapterProgress *float64 `json:"chapter_progress"`
|
||||||
@@ -996,6 +984,7 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|||||||
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
Percentage: req.Percentage,
|
Percentage: req.Percentage,
|
||||||
Epubcfi: req.Epubcfi,
|
Epubcfi: req.Epubcfi,
|
||||||
|
ContextText: req.ContextText,
|
||||||
CharacterOffset: req.CharacterOffset,
|
CharacterOffset: req.CharacterOffset,
|
||||||
Chapter: req.Chapter,
|
Chapter: req.Chapter,
|
||||||
ChapterProgress: req.ChapterProgress,
|
ChapterProgress: req.ChapterProgress,
|
||||||
@@ -1018,6 +1007,16 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|||||||
saveReq.TotalPages = &tp
|
saveReq.TotalPages = &tp
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.Percentage != nil && *req.Percentage < 0.005 {
|
||||||
|
existing, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err == nil && existing.Percentage.Valid && existing.Percentage.Float64 > 0.01 {
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "ignored"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
|
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -1376,14 +1375,31 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
var note database.MediaNotes
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
if mh.annotationSvc != nil {
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
|
||||||
Content: req.Content,
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
})
|
Content: req.Content,
|
||||||
if err != nil {
|
Position: req.Position,
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
Source: "web",
|
||||||
|
ModifiedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
note = result.Note
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
Content: req.Content,
|
||||||
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, note)
|
return c.JSON(http.StatusCreated, note)
|
||||||
@@ -1444,7 +1460,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
if mh.annotationSvc != nil {
|
||||||
|
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||||
|
} else {
|
||||||
|
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -1513,9 +1533,35 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
|
|||||||
color = req.Color
|
color = req.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
||||||
|
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||||
|
|
||||||
|
if mh.annotationSvc != nil {
|
||||||
|
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
|
MediaItemID: pgMediaID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: req.SelectionText,
|
||||||
|
StartPosition: req.StartPosition,
|
||||||
|
EndPosition: req.EndPosition,
|
||||||
|
EpubcfiStart: req.EpubcfiStart,
|
||||||
|
EpubcfiEnd: req.EpubcfiEnd,
|
||||||
|
Color: color,
|
||||||
|
NoteText: req.NoteText,
|
||||||
|
PercentageStart: req.PercentageStart,
|
||||||
|
PercentageEnd: req.PercentageEnd,
|
||||||
|
ChapterReference: req.ChapterReference,
|
||||||
|
Source: "web",
|
||||||
|
ModifiedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, result.Highlight)
|
||||||
|
}
|
||||||
|
|
||||||
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
MediaItemID: pgMediaID,
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgUserID,
|
||||||
SelectionText: req.SelectionText,
|
SelectionText: req.SelectionText,
|
||||||
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
||||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||||
@@ -1578,6 +1624,42 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
|||||||
color = req.Color
|
color = req.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer the sync-aware path: the same selection text + CFI resolves to
|
||||||
|
// the same dedup key, so this performs an LWW update of the existing row
|
||||||
|
// (including note_text and CFI columns the plain query cannot touch).
|
||||||
|
if mh.annotationSvc != nil {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
SelectionText: req.SelectionText,
|
||||||
|
StartPosition: req.StartPosition,
|
||||||
|
EndPosition: req.EndPosition,
|
||||||
|
EpubcfiStart: req.EpubcfiStart,
|
||||||
|
EpubcfiEnd: req.EpubcfiEnd,
|
||||||
|
Color: color,
|
||||||
|
NoteText: req.NoteText,
|
||||||
|
PercentageStart: req.PercentageStart,
|
||||||
|
PercentageEnd: req.PercentageEnd,
|
||||||
|
ChapterReference: req.ChapterReference,
|
||||||
|
Source: "web",
|
||||||
|
ModifiedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, result.Highlight)
|
||||||
|
}
|
||||||
|
|
||||||
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
|
highlight, err := mh.db.UpdateMediaHighlight(c.Request().Context(), database.UpdateMediaHighlightParams{
|
||||||
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
||||||
SelectionText: req.SelectionText,
|
SelectionText: req.SelectionText,
|
||||||
@@ -1601,7 +1683,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
||||||
|
|
||||||
|
if mh.annotationSvc != nil {
|
||||||
|
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -1609,6 +1700,152 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
|||||||
return c.NoContent(http.StatusNoContent)
|
return c.NoContent(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetMediaBookmarks handles GET /api/media-items/:id/bookmarks
|
||||||
|
func (mh *MediaHandler) GetMediaBookmarks(c *echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarks, err := mh.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, bookmarks)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMediaBookmark handles POST /api/media-items/:id/bookmarks
|
||||||
|
func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaID := c.Param("id")
|
||||||
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CreateMediaBookmarkRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
|
||||||
|
// to the plain query when the service isn't wired (e.g. some tests).
|
||||||
|
if mh.annotationSvc != nil {
|
||||||
|
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
Title: req.Title,
|
||||||
|
Position: req.Position,
|
||||||
|
Notes: req.Notes,
|
||||||
|
PageNumber: req.PageNumber,
|
||||||
|
ChapterNumber: req.ChapterNumber,
|
||||||
|
CFIPosition: req.CfiPosition,
|
||||||
|
PercentageLoc: req.Percentage,
|
||||||
|
ChapterReference: req.ChapterReference,
|
||||||
|
Source: "web",
|
||||||
|
ModifiedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, result.Bookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmark, err := mh.db.CreateMediaBookmark(c.Request().Context(), database.CreateMediaBookmarkParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
PageNumber: pgtype.Int4{Int32: req.PageNumber, Valid: req.PageNumber > 0},
|
||||||
|
ChapterNumber: pgtype.Int4{Int32: req.ChapterNumber, Valid: req.ChapterNumber > 0},
|
||||||
|
CfiPosition: pgtype.Text{String: req.CfiPosition, Valid: req.CfiPosition != ""},
|
||||||
|
Title: req.Title,
|
||||||
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||||
|
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, bookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateMediaBookmark handles PUT /api/media-items/:id/bookmarks/:bookmarkId
|
||||||
|
func (mh *MediaHandler) UpdateMediaBookmark(c *echo.Context) error {
|
||||||
|
userID := c.Get("user_id").(string)
|
||||||
|
userUUID, err := uuid.Parse(userID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user"})
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmarkID := c.Param("bookmarkId")
|
||||||
|
bookmarkUUID, err := uuid.Parse(bookmarkID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var req UpdateMediaBookmarkRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
if err := c.Validate(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
bookmark, err := mh.db.UpdateMediaBookmark(c.Request().Context(), database.UpdateMediaBookmarkParams{
|
||||||
|
ID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
|
||||||
|
Title: req.Title,
|
||||||
|
Notes: pgtype.Text{String: req.Notes, Valid: req.Notes != ""},
|
||||||
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, bookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMediaBookmark handles DELETE /api/media-items/:id/bookmarks/:bookmarkId
|
||||||
|
func (mh *MediaHandler) DeleteMediaBookmark(c *echo.Context) error {
|
||||||
|
bookmarkID := c.Param("bookmarkId")
|
||||||
|
bookmarkUUID, err := uuid.Parse(bookmarkID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid bookmark id"})
|
||||||
|
}
|
||||||
|
|
||||||
|
pgBookmarkID := pgtype.UUID{Bytes: bookmarkUUID, Valid: true}
|
||||||
|
|
||||||
|
if mh.annotationSvc != nil {
|
||||||
|
if err := mh.annotationSvc.TombstoneBookmarkByID(c.Request().Context(), pgBookmarkID, "web"); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mh.db.DeleteMediaBookmark(c.Request().Context(), pgBookmarkID); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
// SearchMediaItems handles GET /api/media-items/search
|
// SearchMediaItems handles GET /api/media-items/search
|
||||||
// Supports two modes:
|
// Supports two modes:
|
||||||
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
|
// 1. Autocomplete: author=value, genre=value, etc. → returns field values for dropdowns
|
||||||
@@ -1723,6 +1960,10 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
|||||||
"results": []interface{}{},
|
"results": []interface{}{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for i := range results {
|
||||||
|
resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath)
|
||||||
|
results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""}
|
||||||
|
}
|
||||||
return c.JSON(http.StatusOK, results)
|
return c.JSON(http.StatusOK, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1782,30 +2023,86 @@ func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UU
|
|||||||
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
return mh.libraryService.ResolveMediaPath(ctx, libraryID, relativePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeFile serves files (covers or books) via /uploads/library-{id}/path
|
// ServeFile serves stored library files (covers and books).
|
||||||
// Requires JWT authentication
|
//
|
||||||
|
// Two URL forms funnel into this handler:
|
||||||
|
//
|
||||||
|
// /uploads/library-{libraryID}/{relativePath} (covers, reader files)
|
||||||
|
// /api/media-items/{mediaItemID}/download (explicit book download)
|
||||||
|
//
|
||||||
|
// Both require JWT authentication and that the authenticated user can see
|
||||||
|
// the library owning the file - library visibility is the permission gate.
|
||||||
func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
func (mh *MediaHandler) ServeFile(c *echo.Context) error {
|
||||||
// URL format: /uploads/library-{libraryID}/{relativePath}
|
var libraryUUID pgtype.UUID
|
||||||
// Get library ID directly from route parameter
|
var relativePath string
|
||||||
libraryIDStr := c.Param("id")
|
|
||||||
libraryUUID, err := uuid.Parse(libraryIDStr)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get remaining path from URL
|
|
||||||
rawPath := c.Param("*")
|
rawPath := c.Param("*")
|
||||||
relativePath, err := url.QueryUnescape(rawPath)
|
if rawPath != "" {
|
||||||
if err != nil {
|
// Path form: /uploads/library-{libraryID}/{relativePath}
|
||||||
relativePath = rawPath
|
libraryIDStr := c.Param("id")
|
||||||
|
parsed, err := uuid.Parse(libraryIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library ID"})
|
||||||
|
}
|
||||||
|
libraryUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
|
|
||||||
|
relativePath, err = url.QueryUnescape(rawPath)
|
||||||
|
if err != nil {
|
||||||
|
relativePath = rawPath
|
||||||
|
}
|
||||||
|
|
||||||
|
if relativePath == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Item form: /api/media-items/{mediaItemID}/download
|
||||||
|
itemUUID, err := uuid.Parse(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item ID"})
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: itemUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if !mediaItem.LibraryID.Valid || mediaItem.FilePath == "" {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "book file not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryUUID = mediaItem.LibraryID
|
||||||
|
relativePath = mediaItem.FilePath
|
||||||
|
|
||||||
|
// Explicit download endpoint: suggest saving instead of inline display.
|
||||||
|
filename := strings.Map(func(r rune) rune {
|
||||||
|
if r == '"' || r == '\\' || r == '/' {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, filepath.Base(relativePath))
|
||||||
|
c.Response().Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if relativePath == "" {
|
// Library visibility gate: the library is what grants permission to
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid path"})
|
// see and download media.
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
visibleLibraries, err := mh.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
||||||
|
}
|
||||||
|
libraryVisible := false
|
||||||
|
for _, lib := range visibleLibraries {
|
||||||
|
if lib.ID.Valid && lib.ID.Bytes == libraryUUID.Bytes {
|
||||||
|
libraryVisible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !libraryVisible {
|
||||||
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve using service
|
// Resolve using service
|
||||||
fullPath, err := mh.getFullFilePath(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true}, relativePath)
|
fullPath, err := mh.getFullFilePath(c.Request().Context(), libraryUUID, relativePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "file not found"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,4 +18,8 @@ type MediaDetail struct {
|
|||||||
// Computed counts
|
// Computed counts
|
||||||
NotesCount int `json:"notes_count"`
|
NotesCount int `json:"notes_count"`
|
||||||
HighlightsCount int `json:"highlights_count"`
|
HighlightsCount int `json:"highlights_count"`
|
||||||
|
|
||||||
|
// Deleted-annotation history (tombstoned rows, newest first) — the book
|
||||||
|
// page's "recently deleted" list with restore/permanent-delete actions.
|
||||||
|
DeletedAnnotations []DeletedAnnotationResponse `json:"deleted_annotations"`
|
||||||
}
|
}
|
||||||
|
|||||||
+206
-41
@@ -26,6 +26,26 @@ type OPDSHandler struct {
|
|||||||
conversionService interface {
|
conversionService interface {
|
||||||
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
||||||
}
|
}
|
||||||
|
settings *database.SettingsRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry (OPDS page size).
|
||||||
|
func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||||
|
|
||||||
|
// opdsDefaultPageSize returns the configured default page size (50 if unset).
|
||||||
|
func (h *OPDSHandler) opdsDefaultPageSize() int {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.OpdsDefaultPageSize()
|
||||||
|
}
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// opdsMaxPageSize returns the configured maximum page size (200 if unset).
|
||||||
|
func (h *OPDSHandler) opdsMaxPageSize() int {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.OpdsMaxPageSize()
|
||||||
|
}
|
||||||
|
return 200
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
|
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
|
||||||
@@ -38,15 +58,118 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get base URL from system config
|
// Helper function to get base URL from system config with request-derived fallback
|
||||||
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
||||||
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
|
var dbBaseURL string
|
||||||
if err != nil {
|
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
|
||||||
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
|
dbBaseURL = config.Value
|
||||||
|
}
|
||||||
|
baseURL := deriveBaseURL(c, dbBaseURL)
|
||||||
|
opdsBaseURL := baseURL + "/opds"
|
||||||
|
return baseURL, opdsBaseURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
||||||
|
token := c.QueryParam("token")
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendToken(url, token string) string {
|
||||||
|
if token == "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if strings.Contains(url, "?") {
|
||||||
|
return url + "&token=" + token
|
||||||
|
}
|
||||||
|
return url + "?token=" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
|
||||||
|
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||||
|
|
||||||
|
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
|
||||||
|
// previous, next, last) and OpenSearch paging metadata (totalResults,
|
||||||
|
// itemsPerPage, startIndex) to a feed based on the current page position.
|
||||||
|
// catalogBase is the device catalog URL without query parameters. The token
|
||||||
|
// (device auth) is appended to every generated link.
|
||||||
|
func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) {
|
||||||
|
totalPages := 0
|
||||||
|
if totalItems > 0 {
|
||||||
|
totalPages = (totalItems + perPageNum - 1) / perPageNum
|
||||||
|
}
|
||||||
|
startIdx := (pageNum - 1) * perPageNum
|
||||||
|
|
||||||
|
pagedURL := func(page int) string {
|
||||||
|
return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token)
|
||||||
}
|
}
|
||||||
|
|
||||||
opdsBaseURL := baseURL.Value + "/opds"
|
// self reflects the current page; start/first point to the first page
|
||||||
return baseURL.Value, opdsBaseURL, nil
|
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
|
||||||
|
feed.AddLink(pagedURL(1), catalogMediaType, "start")
|
||||||
|
feed.AddLink(pagedURL(1), catalogMediaType, "first")
|
||||||
|
if totalPages > 0 {
|
||||||
|
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
|
||||||
|
}
|
||||||
|
if pageNum > 1 {
|
||||||
|
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
|
||||||
|
}
|
||||||
|
if pageNum < totalPages {
|
||||||
|
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
feed.SetPagination(totalItems, perPageNum, startIdx+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMimeType returns the mime type for a media item, preferring the stored
|
||||||
|
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
||||||
|
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
||||||
|
if mime.Valid && mime.String != "" {
|
||||||
|
return mime.String
|
||||||
|
}
|
||||||
|
if formatMime.Valid && formatMime.String != "" {
|
||||||
|
return formatMime.String
|
||||||
|
}
|
||||||
|
return "application/epub+zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isComicArchive reports whether a format group represents a comic/manga
|
||||||
|
// archive (cbz/cbr/cb7/cbt). Comic archives are served in their native format
|
||||||
|
// and should not be offered as EPUB/KEPUB/PDF conversions.
|
||||||
|
func isComicArchive(formatGroup string) bool {
|
||||||
|
return strings.EqualFold(formatGroup, "comic_archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLabelFromPath derives a short format label (e.g. "epub", "cbz") from a
|
||||||
|
// file path's extension, defaulting to "epub" when it cannot be determined.
|
||||||
|
func formatLabelFromPath(path string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
switch ext {
|
||||||
|
case ".epub":
|
||||||
|
return "epub"
|
||||||
|
case ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
case ".cbz":
|
||||||
|
return "cbz"
|
||||||
|
case ".cbr":
|
||||||
|
return "cbr"
|
||||||
|
case ".cb7":
|
||||||
|
return "cb7"
|
||||||
|
case ".cbt":
|
||||||
|
return "cbt"
|
||||||
|
case ".mobi":
|
||||||
|
return "mobi"
|
||||||
|
case ".azw", ".azw3":
|
||||||
|
return "azw3"
|
||||||
|
case ".txt":
|
||||||
|
return "txt"
|
||||||
|
case "":
|
||||||
|
return "epub"
|
||||||
|
default:
|
||||||
|
return strings.TrimPrefix(ext, ".")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
||||||
@@ -65,9 +188,10 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
perPageNum := 50
|
perPageNum := h.opdsDefaultPageSize()
|
||||||
|
maxPerPage := h.opdsMaxPageSize()
|
||||||
if perPage != "" {
|
if perPage != "" {
|
||||||
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 {
|
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
|
||||||
perPageNum = num
|
perPageNum = num
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,13 +263,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
"Bookhoard Library",
|
"Bookhoard Library",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Feed links, including OPDS pagination links (first/previous/next/last) and
|
||||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
token := h.getAuthToken(c)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
|
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID)
|
// OpenSearch: the search link points to an OpenSearch description document
|
||||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
// (served by the same /search endpoint when no query is supplied) so that
|
||||||
|
// OPDS clients like KOReader can discover how to formulate search requests.
|
||||||
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
|
||||||
|
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||||
|
|
||||||
// Add entries
|
// Add entries
|
||||||
for _, item := range allItems {
|
for _, item := range allItems {
|
||||||
@@ -170,16 +298,21 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
entry.SetSummary(item.Description.String)
|
entry.SetSummary(item.Description.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add acquisition links
|
// Add acquisition link using the item's real mime type
|
||||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||||
|
|
||||||
// Add format variants
|
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic
|
||||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
// archives are served as-is in their native format.
|
||||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
if !isComicArchive(item.FormatGroup) {
|
||||||
|
if device.DeviceType == "kobo" {
|
||||||
|
kepubURL := downloadURL + "&format=kepub"
|
||||||
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||||
|
}
|
||||||
|
|
||||||
pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL)
|
pdfURL := downloadURL + "&format=pdf"
|
||||||
entry.AddAlternateLink(pdfURL, "application/pdf")
|
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||||
|
}
|
||||||
|
|
||||||
// Add canonical identifier
|
// Add canonical identifier
|
||||||
entry.SetIdentifier(bookUUID)
|
entry.SetIdentifier(bookUUID)
|
||||||
@@ -213,16 +346,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
return c.String(http.StatusOK, xmlString)
|
return c.String(http.StatusOK, xmlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchDeviceCatalog searches the OPDS catalog for a device
|
// SearchDeviceCatalog searches the OPDS catalog for a device.
|
||||||
|
//
|
||||||
|
// When no "q" query parameter is supplied it returns an OpenSearch description
|
||||||
|
// document (application/opensearchdescription+xml) so that OPDS clients such as
|
||||||
|
// KOReader can discover the search URL template (which contains the
|
||||||
|
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
|
||||||
|
// acquisition feed of matching books.
|
||||||
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||||
deviceID := c.Param("deviceId")
|
deviceID := c.Param("deviceId")
|
||||||
|
|
||||||
query := c.QueryParam("q")
|
query := c.QueryParam("q")
|
||||||
|
|
||||||
if query == "" {
|
|
||||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get base URLs
|
// Get base URLs
|
||||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -243,12 +377,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
|
|
||||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token := h.getAuthToken(c)
|
||||||
|
|
||||||
|
// No query: serve the OpenSearch description document so clients can learn
|
||||||
|
// the search template (contains the {searchTerms} placeholder).
|
||||||
|
if query == "" {
|
||||||
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
||||||
|
desc := opds.NewSearchDescription(
|
||||||
|
"Bookhoard",
|
||||||
|
"Search the Bookhoard library",
|
||||||
|
searchURL,
|
||||||
|
)
|
||||||
|
xmlString, err := desc.GenerateXMLString()
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
|
||||||
|
}
|
||||||
|
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
|
||||||
|
return c.String(http.StatusOK, xmlString)
|
||||||
|
}
|
||||||
|
|
||||||
// Search media items
|
// Search media items
|
||||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
@@ -267,11 +419,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Add feed links
|
||||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
feed.AddLink(catalogURL, catalogMediaType, "start")
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query)
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
||||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
feed.AddLink(searchURL, catalogMediaType, "self")
|
||||||
|
|
||||||
|
// OpenSearch paging metadata (search results are a single page)
|
||||||
|
feed.SetPagination(len(allItems), len(allItems), 1)
|
||||||
|
|
||||||
// Add entries (same as catalog)
|
// Add entries (same as catalog)
|
||||||
userUUID := uuid.UUID(userID)
|
userUUID := uuid.UUID(userID)
|
||||||
@@ -296,11 +451,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
entry.SetSummary(item.Description.String)
|
entry.SetSummary(item.Description.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||||
|
|
||||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
// Only offer kepub conversion for ebooks; comic archives are served
|
||||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
// as-is in their native format.
|
||||||
|
if !isComicArchive(item.FormatGroup) && device.DeviceType == "kobo" {
|
||||||
|
kepubURL := downloadURL + "&format=kepub"
|
||||||
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||||
|
}
|
||||||
|
|
||||||
entry.SetIdentifier(bookUUID)
|
entry.SetIdentifier(bookUUID)
|
||||||
|
|
||||||
@@ -446,6 +605,12 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
|||||||
if mediaItem.MimeType.Valid {
|
if mediaItem.MimeType.Valid {
|
||||||
mimeType = mediaItem.MimeType.String
|
mimeType = mediaItem.MimeType.String
|
||||||
}
|
}
|
||||||
|
// Always expose the primary content hash so clients (e.g. the koreader
|
||||||
|
// plugin) learn the canonical SHA-256 from the download response itself,
|
||||||
|
// not just from the feed metadata.
|
||||||
|
if mediaItem.FileSha256.Valid {
|
||||||
|
fileSha256 = mediaItem.FileSha256.String
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if file exists
|
// Check if file exists
|
||||||
@@ -599,7 +764,7 @@ func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Add feed links
|
||||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||||
|
|
||||||
@@ -682,14 +847,14 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
|||||||
|
|
||||||
formatList := []FormatInfo{}
|
formatList := []FormatInfo{}
|
||||||
|
|
||||||
// Add EPUB format (always available if media item exists)
|
// Add the primary/native format (always available if media item exists)
|
||||||
fileSize := int64(0)
|
fileSize := int64(0)
|
||||||
if mediaItem.FileSize.Valid {
|
if mediaItem.FileSize.Valid {
|
||||||
fileSize = mediaItem.FileSize.Int64
|
fileSize = mediaItem.FileSize.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
formatList = append(formatList, FormatInfo{
|
formatList = append(formatList, FormatInfo{
|
||||||
FormatType: "epub",
|
FormatType: formatLabelFromPath(mediaItem.FilePath),
|
||||||
FilePath: mediaItem.FilePath,
|
FilePath: mediaItem.FilePath,
|
||||||
FileSha256: func() string {
|
FileSha256: func() string {
|
||||||
if mediaItem.FileSha256.Valid {
|
if mediaItem.FileSha256.Valid {
|
||||||
@@ -791,7 +956,7 @@ func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"opds_token": map[string]interface{}{
|
"opds_token": map[string]interface{}{
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/opds"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rels collects the rel attributes of all links currently on the feed.
|
||||||
|
func rels(feed *opds.Feed) []string {
|
||||||
|
out := make([]string, 0, len(feed.Links))
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
out = append(out, l.Rel)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsRel(feed *opds.Feed, rel string) bool {
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
if l.Rel == rel {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_MiddlePage(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
// 1814 items, 50 per page => 37 pages; on page 2
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 2, 50, 1814, "tok")
|
||||||
|
|
||||||
|
assert.True(t, containsRel(feed, "self"))
|
||||||
|
assert.True(t, containsRel(feed, "start"))
|
||||||
|
assert.True(t, containsRel(feed, "first"))
|
||||||
|
assert.True(t, containsRel(feed, "last"))
|
||||||
|
assert.True(t, containsRel(feed, "previous"), "middle page must have previous")
|
||||||
|
assert.True(t, containsRel(feed, "next"), "middle page must have next")
|
||||||
|
|
||||||
|
// self must point to the current page
|
||||||
|
var selfHref string
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
if l.Rel == "self" {
|
||||||
|
selfHref = l.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Contains(t, selfHref, "page=2&per_page=50")
|
||||||
|
assert.Contains(t, selfHref, "token=tok")
|
||||||
|
|
||||||
|
// next must advance the page
|
||||||
|
var nextHref string
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
if l.Rel == "next" {
|
||||||
|
nextHref = l.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Contains(t, nextHref, "page=3")
|
||||||
|
|
||||||
|
// OpenSearch metadata
|
||||||
|
require.NotNil(t, feed.TotalResults)
|
||||||
|
assert.Equal(t, 1814, *feed.TotalResults)
|
||||||
|
require.NotNil(t, feed.ItemsPerPage)
|
||||||
|
assert.Equal(t, 50, *feed.ItemsPerPage)
|
||||||
|
require.NotNil(t, feed.StartIndex)
|
||||||
|
assert.Equal(t, 51, *feed.StartIndex, "startIndex should be 1-based offset of first item on page 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_FirstPage_NoPrevious(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 1814, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "previous", "first page must not have previous")
|
||||||
|
assert.Contains(t, rels, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_LastPage_NoNext(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 37, 50, 1814, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "next", "last page must not have next")
|
||||||
|
assert.Contains(t, rels, "previous")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_SinglePage(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 10, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "previous")
|
||||||
|
assert.NotContains(t, rels, "next")
|
||||||
|
// still emits self/start/first/last
|
||||||
|
assert.Contains(t, rels, "self")
|
||||||
|
assert.Contains(t, rels, "last")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_EmptyCatalog(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 0, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "next")
|
||||||
|
assert.NotContains(t, rels, "previous")
|
||||||
|
assert.NotContains(t, rels, "last", "empty catalog should not advertise a last page")
|
||||||
|
require.NotNil(t, feed.TotalResults)
|
||||||
|
assert.Equal(t, 0, *feed.TotalResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_TokenAppended(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 100, "abc")
|
||||||
|
|
||||||
|
xml, err := feed.GenerateXMLString()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, strings.Count(xml, "token=abc") >= 3, "token should be appended to generated links")
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -139,3 +140,8 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
|||||||
"message": "Issue deleted",
|
"message": "Issue deleted",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
|
||||||
|
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
|
||||||
|
return h.db.GetProcessingIssueStats(ctx, libraryID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -374,6 +374,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
|||||||
deviceName = progress.LastSyncDevice.String
|
deviceName = progress.LastSyncDevice.String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastUpdated := ""
|
||||||
|
if progress.LastReadAt.Valid {
|
||||||
|
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
progressList = append(progressList, ProgressWithMedia{
|
progressList = append(progressList, ProgressWithMedia{
|
||||||
MediaItemID: progress.MediaItemID.Bytes,
|
MediaItemID: progress.MediaItemID.Bytes,
|
||||||
Title: mediaItem.Title,
|
Title: mediaItem.Title,
|
||||||
@@ -386,6 +391,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
|||||||
Epubcfi: epubcfi,
|
Epubcfi: epubcfi,
|
||||||
LastSyncDevice: deviceName,
|
LastSyncDevice: deviceName,
|
||||||
ProgressPercentage: progress.Percentage.Float64 * 100,
|
ProgressPercentage: progress.Percentage.Float64 * 100,
|
||||||
|
EpubCFI: epubcfi,
|
||||||
|
LastUpdated: lastUpdated,
|
||||||
|
DeviceIcon: getDeviceIcon(deviceName),
|
||||||
|
DeviceName: deviceName,
|
||||||
|
DeviceType: deviceName,
|
||||||
FormatGroup: mediaItem.FormatGroup,
|
FormatGroup: mediaItem.FormatGroup,
|
||||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ import (
|
|||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
refreshTokenExpiration = 7 * 24 * time.Hour // 7 days
|
|
||||||
)
|
|
||||||
|
|
||||||
type RefreshTokenRequest struct {
|
type RefreshTokenRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" validate:"required"`
|
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||||
}
|
}
|
||||||
@@ -72,7 +68,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, RefreshTokenResponse{
|
return c.JSON(http.StatusOK, RefreshTokenResponse{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: SessionDurationSec,
|
ExpiresIn: int(h.refreshTokenTTL().Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +97,7 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro
|
|||||||
tokenUUID := uuid.New()
|
tokenUUID := uuid.New()
|
||||||
refreshToken := tokenUUID.String()
|
refreshToken := tokenUUID.String()
|
||||||
|
|
||||||
expiresAt := time.Now().Add(refreshTokenExpiration)
|
expiresAt := time.Now().Add(h.refreshTokenTTL())
|
||||||
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
|
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
|
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
|
||||||
|
|||||||
@@ -128,8 +128,8 @@ func (h *Handler) StartScanner(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the folder paths
|
// Set the folder paths (watch=true: this long-lived scanner reads events)
|
||||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype
|
|||||||
}
|
}
|
||||||
|
|
||||||
scanner := services.NewMediaScanner(h.db)
|
scanner := services.NewMediaScanner(h.db)
|
||||||
if err := scanner.SetFolders(folderPaths); err != nil {
|
if err := scanner.SetFolders(folderPaths, true); err != nil {
|
||||||
return fmt.Errorf("failed to set scanner folders: %v", err)
|
return fmt.Errorf("failed to set scanner folders: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/setupstatus"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,14 +15,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SidecarHandler struct {
|
type SidecarHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
||||||
return &SidecarHandler{db: db, cfg: cfg}
|
return &SidecarHandler{db: db, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry so the timezone write path
|
||||||
|
// keeps the cache consistent.
|
||||||
|
func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||||
|
|
||||||
type SidecarConfig struct {
|
type SidecarConfig struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
|
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
|
||||||
@@ -81,15 +87,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
|
||||||
// Get base URL and compute paths
|
// Get base URL and compute paths (with request-derived fallback)
|
||||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||||
if baseURL.Value == "" {
|
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||||
baseURL.Value = h.cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate URLs
|
// Generate URLs
|
||||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||||
|
|
||||||
// Get user's visible libraries with media items
|
// Get user's visible libraries with media items
|
||||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||||
@@ -130,6 +134,21 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
SHA256: item.FileSha256.String,
|
SHA256: item.FileSha256.String,
|
||||||
FilePath: item.FilePath,
|
FilePath: item.FilePath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
|
||||||
|
// holding a converted format resolves via the sidecar the same way it
|
||||||
|
// would via BookResolver on the server.
|
||||||
|
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
|
||||||
|
if ferr == nil {
|
||||||
|
entry := books[key]
|
||||||
|
for _, f := range formats {
|
||||||
|
if f.FileSha256.Valid && f.FileSha256.String != "" {
|
||||||
|
if _, exists := books[f.FileSha256.String]; !exists {
|
||||||
|
books[f.FileSha256.String] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get collections
|
// Get collections
|
||||||
@@ -176,8 +195,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
Bookhoard: SidecarBookhoardConfig{
|
Bookhoard: SidecarBookhoardConfig{
|
||||||
OPDSCatalog: opdsCatalogURL,
|
OPDSCatalog: opdsCatalogURL,
|
||||||
SyncAPI: syncAPIURL,
|
SyncAPI: syncAPIURL,
|
||||||
OPDSBaseURL: baseURL.Value + "/opds",
|
OPDSBaseURL: baseURL + "/opds",
|
||||||
APIBaseURL: baseURL.Value + "/api",
|
APIBaseURL: baseURL + "/api",
|
||||||
DeviceID: deviceID.String(),
|
DeviceID: deviceID.String(),
|
||||||
DeviceToken: device.AuthToken,
|
DeviceToken: device.AuthToken,
|
||||||
},
|
},
|
||||||
@@ -219,15 +238,13 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
|||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
|
||||||
// Get base URL and compute paths
|
// Get base URL and compute paths (with request-derived fallback)
|
||||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||||
if baseURL.Value == "" {
|
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||||
baseURL.Value = h.cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate URLs
|
// Generate URLs
|
||||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||||
|
|
||||||
// Get user's visible libraries with media items
|
// Get user's visible libraries with media items
|
||||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||||
@@ -267,6 +284,21 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
|||||||
SHA256: item.FileSha256.String,
|
SHA256: item.FileSha256.String,
|
||||||
FilePath: item.FilePath,
|
FilePath: item.FilePath,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also key the book by each per-format hash (KEPUB/PDF/...), so a device
|
||||||
|
// holding a converted format resolves via the sidecar the same way it
|
||||||
|
// would via BookResolver on the server.
|
||||||
|
formats, ferr := h.db.GetMediaItemFormats(ctx, item.ID)
|
||||||
|
if ferr == nil {
|
||||||
|
entry := books[key]
|
||||||
|
for _, f := range formats {
|
||||||
|
if f.FileSha256.Valid && f.FileSha256.String != "" {
|
||||||
|
if _, exists := books[f.FileSha256.String]; !exists {
|
||||||
|
books[f.FileSha256.String] = entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get collections
|
// Get collections
|
||||||
@@ -309,8 +341,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
|||||||
Bookhoard: SidecarBookhoardConfig{
|
Bookhoard: SidecarBookhoardConfig{
|
||||||
OPDSCatalog: opdsCatalogURL,
|
OPDSCatalog: opdsCatalogURL,
|
||||||
SyncAPI: syncAPIURL,
|
SyncAPI: syncAPIURL,
|
||||||
OPDSBaseURL: baseURL.Value + "/opds",
|
OPDSBaseURL: baseURL + "/opds",
|
||||||
APIBaseURL: baseURL.Value + "/api",
|
APIBaseURL: baseURL + "/api",
|
||||||
DeviceID: deviceID.String(),
|
DeviceID: deviceID.String(),
|
||||||
DeviceToken: device.AuthToken,
|
DeviceToken: device.AuthToken,
|
||||||
},
|
},
|
||||||
@@ -403,6 +435,9 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
"error": "failed to update default timezone",
|
"error": "failed to update default timezone",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if h.settings != nil {
|
||||||
|
h.settings.Reload(ctx)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||||
@@ -417,6 +452,29 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if newBaseURL, ok := req["base_url"]; ok && newBaseURL != "" {
|
||||||
|
derivedConfigs := map[string]string{
|
||||||
|
"opds_base_url": newBaseURL + "/opds",
|
||||||
|
"api_base_url": newBaseURL + "/api",
|
||||||
|
}
|
||||||
|
for derivedKey, derivedValue := range derivedConfigs {
|
||||||
|
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||||
|
Key: derivedKey,
|
||||||
|
Value: derivedValue,
|
||||||
|
UpdatedBy: pgUserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": fmt.Sprintf("failed to update derived config key: %s", derivedKey),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate setup status cache so the middleware picks up the new
|
||||||
|
// base_url immediately (setup is not complete until base_url is set).
|
||||||
|
setupstatus.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
// Check for HTMX request
|
// Check for HTMX request
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
// Fetch updated base_url for template
|
// Fetch updated base_url for template
|
||||||
|
|||||||
@@ -2,17 +2,21 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SystemSettingsHandler struct {
|
type SystemSettingsHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
||||||
@@ -21,6 +25,154 @@ func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry. Required for the unified
|
||||||
|
// /api/system/settings endpoints and for cache invalidation after writes.
|
||||||
|
func (h *SystemSettingsHandler) SetSettings(s *database.SettingsRegistry) {
|
||||||
|
h.settings = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload refreshes the in-memory cache after a write.
|
||||||
|
func (h *SystemSettingsHandler) reload(c *echo.Context) {
|
||||||
|
if h.settings != nil {
|
||||||
|
h.settings.Reload(c.Request().Context())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Unified /api/system/settings endpoints ----
|
||||||
|
|
||||||
|
// GetSettings handles GET /api/system/settings.
|
||||||
|
func (h *SystemSettingsHandler) GetSettings(c *echo.Context) error {
|
||||||
|
if h.settings == nil {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, h.settings.All())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettingRequest is the body for PUT /api/system/settings.
|
||||||
|
type UpdateSettingRequest struct {
|
||||||
|
Key string `json:"key" form:"key"`
|
||||||
|
Value string `json:"value" form:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettingResponse mirrors a settings entry plus a reload hint.
|
||||||
|
type UpdateSettingResponse struct {
|
||||||
|
database.SettingEntry
|
||||||
|
ReloadRequired bool `json:"reload_required"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSetting handles PUT /api/system/settings.
|
||||||
|
func (h *SystemSettingsHandler) UpdateSetting(c *echo.Context) error {
|
||||||
|
if h.settings == nil {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
|
||||||
|
}
|
||||||
|
var req UpdateSettingRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
resp, err := h.ApplySetting(c.Request().Context(), req.Key, req.Value)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySetting validates, persists, and reloads a single setting. Shared by the
|
||||||
|
// JSON API and the HTMX admin endpoint.
|
||||||
|
func (h *SystemSettingsHandler) ApplySetting(ctx context.Context, key, value string) (UpdateSettingResponse, error) {
|
||||||
|
if h.settings == nil {
|
||||||
|
return UpdateSettingResponse{}, fmt.Errorf("settings registry not initialized")
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return UpdateSettingResponse{}, fmt.Errorf("key is required")
|
||||||
|
}
|
||||||
|
def, ok := database.LookupDefault(key)
|
||||||
|
if !ok {
|
||||||
|
return UpdateSettingResponse{}, fmt.Errorf("unknown setting key: %s", key)
|
||||||
|
}
|
||||||
|
if err := validateSettingValue(def, value); err != nil {
|
||||||
|
return UpdateSettingResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := def.Description
|
||||||
|
rType := pgtype.Text{}
|
||||||
|
if def.Type != "" {
|
||||||
|
rType = pgtype.Text{String: def.Type, Valid: true}
|
||||||
|
}
|
||||||
|
var minP, maxP pgtype.Text
|
||||||
|
if def.Min != "" {
|
||||||
|
minP = pgtype.Text{String: def.Min, Valid: true}
|
||||||
|
}
|
||||||
|
if def.Max != "" {
|
||||||
|
maxP = pgtype.Text{String: def.Max, Valid: true}
|
||||||
|
}
|
||||||
|
if _, err := h.db.UpsertSystemSetting(ctx, database.UpsertSystemSettingParams{
|
||||||
|
SettingKey: key,
|
||||||
|
SettingValue: value,
|
||||||
|
Description: pgtype.Text{String: desc, Valid: desc != ""},
|
||||||
|
SettingType: rType,
|
||||||
|
MinValue: minP,
|
||||||
|
MaxValue: maxP,
|
||||||
|
RequiresRestart: pgtype.Bool{Bool: def.RequiresRestart, Valid: true},
|
||||||
|
Category: pgtype.Text{String: def.Category, Valid: def.Category != ""},
|
||||||
|
}); err != nil {
|
||||||
|
return UpdateSettingResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h.settings.Reload(ctx)
|
||||||
|
|
||||||
|
resp := UpdateSettingResponse{ReloadRequired: def.RequiresRestart}
|
||||||
|
for _, e := range h.settings.All() {
|
||||||
|
if e.Key == key {
|
||||||
|
resp.SettingEntry = e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.RequiresRestart {
|
||||||
|
resp.Message = "Saved. Restart the server for this change to take full effect."
|
||||||
|
} else {
|
||||||
|
resp.Message = "Saved."
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSettingValue checks a candidate value against the setting's type and bounds.
|
||||||
|
func validateSettingValue(def database.SettingDefault, value string) error {
|
||||||
|
switch def.Type {
|
||||||
|
case database.SettingTypeInt:
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("value must be an integer")
|
||||||
|
}
|
||||||
|
if def.Min != "" {
|
||||||
|
if mn, err := strconv.Atoi(def.Min); err == nil && n < mn {
|
||||||
|
return fmt.Errorf("value must be >= %s", def.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Max != "" {
|
||||||
|
if mx, err := strconv.Atoi(def.Max); err == nil && n > mx {
|
||||||
|
return fmt.Errorf("value must be <= %s", def.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case database.SettingTypeBool:
|
||||||
|
if _, err := strconv.ParseBool(value); err != nil {
|
||||||
|
return fmt.Errorf("value must be true or false")
|
||||||
|
}
|
||||||
|
case database.SettingTypeString:
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("value must not be empty")
|
||||||
|
}
|
||||||
|
if def.Key == "default_timezone" {
|
||||||
|
if _, err := time.LoadLocation(value); err != nil {
|
||||||
|
return fmt.Errorf("invalid timezone: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Legacy scan-settings endpoints (retained for backward compatibility) ----
|
||||||
|
|
||||||
type UpdateScanSettingsRequest struct {
|
type UpdateScanSettingsRequest struct {
|
||||||
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
|
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
|
||||||
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
||||||
@@ -51,6 +203,7 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
h.reload(c)
|
||||||
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,6 +241,8 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.reload(c)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||||
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
|
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
|
||||||
AutoScanEnabled: req.AutoScanEnabled,
|
AutoScanEnabled: req.AutoScanEnabled,
|
||||||
@@ -96,6 +251,15 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
||||||
|
// Prefer the registry (single source of truth after Load).
|
||||||
|
if h.settings != nil {
|
||||||
|
interval := int32(h.settings.ScanPollInterval().Seconds())
|
||||||
|
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||||
|
ScanPollIntervalSeconds: interval,
|
||||||
|
AutoScanEnabled: h.settings.AutoScanEnabled(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
|
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deriveBaseURL returns the base URL to use for constructing self-referential
|
||||||
|
// links (OPDS feeds, sidecar config, etc.). It prefers the database-configured
|
||||||
|
// base_url when available, and falls back to deriving the URL from the incoming
|
||||||
|
// HTTP request (Host header + scheme), which is always reachable by the client.
|
||||||
|
//
|
||||||
|
// Proxy header support: X-Forwarded-Proto and X-Forwarded-Host are respected so
|
||||||
|
// that deployments behind TLS-terminating reverse proxies advertise the correct
|
||||||
|
// external URL.
|
||||||
|
func deriveBaseURL(c *echo.Context, dbBaseURL string) string {
|
||||||
|
if dbBaseURL != "" {
|
||||||
|
return strings.TrimRight(dbBaseURL, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if c.Request().TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
}
|
||||||
|
|
||||||
|
host := c.Request().Host
|
||||||
|
if forwarded := c.Request().Header.Get("X-Forwarded-Host"); forwarded != "" {
|
||||||
|
host = forwarded
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ type DeviceContext struct {
|
|||||||
type DeviceAuthMiddleware struct {
|
type DeviceAuthMiddleware struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
rateLimiter *DeviceRateLimiter
|
rateLimiter *DeviceRateLimiter
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
||||||
@@ -34,6 +35,41 @@ func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry so device rate limits are
|
||||||
|
// read live on each authenticated request.
|
||||||
|
func (m *DeviceAuthMiddleware) SetSettings(s *database.SettingsRegistry) { m.settings = s }
|
||||||
|
|
||||||
|
// rateLimitConfig returns the active device rate limits from the registry, or
|
||||||
|
// the historical defaults when no registry is wired.
|
||||||
|
func (m *DeviceAuthMiddleware) rateLimitConfig() DeviceRateLimitConfig {
|
||||||
|
if m.settings != nil {
|
||||||
|
dl := m.settings.DeviceRateLimits()
|
||||||
|
return DeviceRateLimitConfig{
|
||||||
|
SyncRequestsPerMinute: dl.Sync,
|
||||||
|
ProgressUpdatesPerMinute: dl.Progress,
|
||||||
|
MetadataRequestsPerMinute: dl.Metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DeviceRateLimitConfig{
|
||||||
|
SyncRequestsPerMinute: DefaultSyncRequestsPerMinute,
|
||||||
|
ProgressUpdatesPerMinute: DefaultProgressUpdatesPerMinute,
|
||||||
|
MetadataRequestsPerMinute: DefaultMetadataRequestsPerMinute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitForRequestType returns the configured per-minute limit for a given
|
||||||
|
// request type, for use in X-RateLimit-* headers.
|
||||||
|
func (m *DeviceAuthMiddleware) rateLimitForRequestType(requestType string, config DeviceRateLimitConfig) int {
|
||||||
|
switch requestType {
|
||||||
|
case "progress":
|
||||||
|
return config.ProgressUpdatesPerMinute
|
||||||
|
case "metadata":
|
||||||
|
return config.MetadataRequestsPerMinute
|
||||||
|
default: // "sync" and any unknown type
|
||||||
|
return config.SyncRequestsPerMinute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
|
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c *echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
var device database.Devices
|
var device database.Devices
|
||||||
@@ -115,15 +151,12 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
|||||||
deviceUUID := uuid.UUID(device.ID.Bytes)
|
deviceUUID := uuid.UUID(device.ID.Bytes)
|
||||||
deviceID := deviceUUID.String()
|
deviceID := deviceUUID.String()
|
||||||
|
|
||||||
config := DeviceRateLimitConfig{
|
config := m.rateLimitConfig()
|
||||||
SyncRequestsPerMinute: 60,
|
limitForType := m.rateLimitForRequestType(requestType, config)
|
||||||
ProgressUpdatesPerMinute: 120,
|
|
||||||
MetadataRequestsPerMinute: 30,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
|
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
|
||||||
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
||||||
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
|
||||||
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||||
c.Response().Header().Set("X-RateLimit-Reset", "60")
|
c.Response().Header().Set("X-RateLimit-Reset", "60")
|
||||||
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||||
@@ -134,7 +167,7 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
|||||||
}
|
}
|
||||||
|
|
||||||
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
||||||
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
|
||||||
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||||
|
|
||||||
ctx := DeviceContext{
|
ctx := DeviceContext{
|
||||||
|
|||||||
@@ -1,96 +1,146 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PasswordValidator validates password complexity requirements
|
// specialCharRegex matches the historical "special character" set used by the
|
||||||
type PasswordValidator struct{}
|
// password complexity rules.
|
||||||
|
const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`
|
||||||
|
|
||||||
// Validate checks if a password meets complexity requirements:
|
// PasswordValidator validates password complexity against the configured rules.
|
||||||
// - Minimum 8 characters
|
// When a database.SettingsRegistry is wired via SetSettings, rules are read live and the
|
||||||
// - At least one uppercase letter
|
// regex set is recompiled under a mutex on each validation. Without a registry
|
||||||
// - At least one lowercase letter
|
// the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply.
|
||||||
// - At least one number
|
type PasswordValidator struct {
|
||||||
// - At least one special character
|
settings *database.SettingsRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry.
|
||||||
|
func (v *PasswordValidator) SetSettings(s *database.SettingsRegistry) { v.settings = s }
|
||||||
|
|
||||||
|
// compileSpecialRegex isolates the regexp compile (which is safe to call
|
||||||
|
// concurrently, but we keep it behind a cached var for the no-registry path).
|
||||||
|
var (
|
||||||
|
specialOnce sync.Once
|
||||||
|
specialRe *regexp.Regexp
|
||||||
|
)
|
||||||
|
|
||||||
|
func specialRegex() *regexp.Regexp {
|
||||||
|
specialOnce.Do(func() {
|
||||||
|
specialRe = regexp.MustCompile(specialCharRegex)
|
||||||
|
})
|
||||||
|
return specialRe
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *PasswordValidator) rules() database.PasswordRules {
|
||||||
|
if v.settings != nil {
|
||||||
|
return v.settings.PasswordRules()
|
||||||
|
}
|
||||||
|
return database.PasswordRules{MinLength: 8, Upper: true, Lower: true, Number: true, Special: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if a password meets the configured complexity requirements.
|
||||||
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
|
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
|
||||||
password := fl.Field().String()
|
return v.CheckPassword(fl.Field().String())
|
||||||
|
}
|
||||||
|
|
||||||
// Check minimum length
|
// CheckPassword applies the active rules to a single password.
|
||||||
if len(password) < 8 {
|
func (v *PasswordValidator) CheckPassword(password string) bool {
|
||||||
|
r := v.rules()
|
||||||
|
if len(password) < r.MinLength {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||||
// Check for uppercase
|
|
||||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
|
||||||
if !hasUpper {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||||
// Check for lowercase
|
|
||||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
|
||||||
if !hasLower {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||||
// Check for number
|
|
||||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
|
||||||
if !hasNumber {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Special && !specialRegex().MatchString(password) {
|
||||||
// Check for special character
|
|
||||||
hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
|
|
||||||
if !hasSpecial {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPasswordRequirements returns a human-readable list of password requirements
|
// GetPasswordRequirements returns a human-readable list of the active password
|
||||||
|
// requirements, driven by the configured rules when a registry is wired.
|
||||||
func GetPasswordRequirements() []string {
|
func GetPasswordRequirements() []string {
|
||||||
return []string{
|
return defaultPasswordValidator.Requirements()
|
||||||
"At least 8 characters long",
|
|
||||||
"At least one uppercase letter (A-Z)",
|
|
||||||
"At least one lowercase letter (a-z)",
|
|
||||||
"At least one number (0-9)",
|
|
||||||
"At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidatePassword checks a password and returns an error if it doesn't meet requirements
|
// Requirements returns the human-readable list for the receiver's active rules.
|
||||||
func ValidatePassword(password string) error {
|
func (v *PasswordValidator) Requirements() []string {
|
||||||
if len(password) < 8 {
|
r := v.rules()
|
||||||
return fmt.Errorf("password must be at least 8 characters long")
|
var out []string
|
||||||
|
out = append(out, fmt.Sprintf("At least %d characters long", r.MinLength))
|
||||||
|
if r.Upper {
|
||||||
|
out = append(out, "At least one uppercase letter (A-Z)")
|
||||||
}
|
}
|
||||||
|
if r.Lower {
|
||||||
|
out = append(out, "At least one lowercase letter (a-z)")
|
||||||
|
}
|
||||||
|
if r.Number {
|
||||||
|
out = append(out, "At least one number (0-9)")
|
||||||
|
}
|
||||||
|
if r.Special {
|
||||||
|
out = append(out, "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
// ValidatePassword checks a password against the default (hardcoded) rules and
|
||||||
|
// returns an error describing the first unmet requirement. Retained for callers
|
||||||
|
// that don't have access to a configured PasswordValidator instance.
|
||||||
|
func ValidatePassword(password string) error {
|
||||||
|
v := defaultPasswordValidator
|
||||||
|
r := v.rules()
|
||||||
|
if len(password) < r.MinLength {
|
||||||
|
return fmt.Errorf("password must be at least %d characters long", r.MinLength)
|
||||||
|
}
|
||||||
|
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||||
return fmt.Errorf("password must contain at least one uppercase letter")
|
return fmt.Errorf("password must contain at least one uppercase letter")
|
||||||
}
|
}
|
||||||
|
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||||
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
|
||||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||||
}
|
}
|
||||||
|
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||||
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
|
||||||
return fmt.Errorf("password must contain at least one number")
|
return fmt.Errorf("password must contain at least one number")
|
||||||
}
|
}
|
||||||
|
if r.Special && !specialRegex().MatchString(password) {
|
||||||
if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
|
|
||||||
return fmt.Errorf("password must contain at least one special character")
|
return fmt.Errorf("password must contain at least one special character")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPasswordValidation registers the password validator with the validator instance
|
// defaultPasswordValidator is used by the package-level helpers
|
||||||
|
// (GetPasswordRequirements, ValidatePassword) and as the fallback inside
|
||||||
|
// RegisterPasswordValidation when no registry has been wired. Callers that want
|
||||||
|
// live rule updates should construct their own PasswordValidator and call
|
||||||
|
// SetSettings.
|
||||||
|
var defaultPasswordValidator = &PasswordValidator{}
|
||||||
|
|
||||||
|
// RegisterPasswordValidation registers the password validator with the
|
||||||
|
// validator instance. The registered func re-evaluates rules on every call, so
|
||||||
|
// changes to the wired registry take effect immediately.
|
||||||
func RegisterPasswordValidation(v *validator.Validate) error {
|
func RegisterPasswordValidation(v *validator.Validate) error {
|
||||||
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
|
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
|
||||||
pv := &PasswordValidator{}
|
return defaultPasswordValidator.CheckPassword(fl.Field().String())
|
||||||
return pv.Validate(fl)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDefaultPasswordSettings wires the settings registry into the package-level
|
||||||
|
// default validator so that the struct-tag validator (used by echo's
|
||||||
|
// CustomValidator) and ValidatePassword follow live configuration. Intended to
|
||||||
|
// be called once at startup.
|
||||||
|
func SetDefaultPasswordSettings(s *database.SettingsRegistry) {
|
||||||
|
defaultPasswordValidator.SetSettings(s)
|
||||||
|
}
|
||||||
|
|||||||
+102
-21
@@ -9,21 +9,25 @@ import (
|
|||||||
// OPDS 1.2 Feed Structures
|
// OPDS 1.2 Feed Structures
|
||||||
|
|
||||||
type Feed struct {
|
type Feed struct {
|
||||||
XMLName xml.Name `xml:"feed"`
|
XMLName xml.Name `xml:"feed"`
|
||||||
Xmlns string `xml:"xmlns,attr"`
|
Xmlns string `xml:"xmlns,attr"`
|
||||||
OpdsNS string `xml:"xmlns:opds,attr"`
|
OpdsNS string `xml:"xmlns:opds,attr"`
|
||||||
DcNS string `xml:"xmlns:dc,attr"`
|
DcNS string `xml:"xmlns:dc,attr"`
|
||||||
ID string `xml:"id"`
|
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
|
||||||
Title string `xml:"title"`
|
ID string `xml:"id"`
|
||||||
Updated string `xml:"updated"`
|
Title string `xml:"title"`
|
||||||
Links []Link `xml:"link"`
|
Updated string `xml:"updated"`
|
||||||
Entries []Entry `xml:"entry"`
|
Links []Link `xml:"link"`
|
||||||
|
TotalResults *int `xml:"opensearch:totalResults,omitempty"`
|
||||||
|
ItemsPerPage *int `xml:"opensearch:itemsPerPage,omitempty"`
|
||||||
|
StartIndex *int `xml:"opensearch:startIndex,omitempty"`
|
||||||
|
Entries []Entry `xml:"entry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string `xml:"id"`
|
ID string `xml:"id"`
|
||||||
Title string `xml:"dc:title"`
|
Title string `xml:"title"`
|
||||||
Creator string `xml:"dc:creator,omitempty"`
|
Author *Author `xml:"author,omitempty"`
|
||||||
Updated string `xml:"updated"`
|
Updated string `xml:"updated"`
|
||||||
Summary string `xml:"summary,omitempty"`
|
Summary string `xml:"summary,omitempty"`
|
||||||
Links []Link `xml:"link"`
|
Links []Link `xml:"link"`
|
||||||
@@ -32,6 +36,12 @@ type Entry struct {
|
|||||||
Categories []Category `xml:"category,omitempty"`
|
Categories []Category `xml:"category,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Author struct {
|
||||||
|
XMLName xml.Name `xml:"author"`
|
||||||
|
Name string `xml:"name"`
|
||||||
|
URI string `xml:"uri,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Href string `xml:"href,attr"`
|
Href string `xml:"href,attr"`
|
||||||
Type string `xml:"type,attr"`
|
Type string `xml:"type,attr"`
|
||||||
@@ -58,17 +68,29 @@ type Category struct {
|
|||||||
func NewFeed(feedID, title string) *Feed {
|
func NewFeed(feedID, title string) *Feed {
|
||||||
now := time.Now().Format(time.RFC3339)
|
now := time.Now().Format(time.RFC3339)
|
||||||
return &Feed{
|
return &Feed{
|
||||||
Xmlns: "http://www.w3.org/2005/Atom",
|
Xmlns: "http://www.w3.org/2005/Atom",
|
||||||
OpdsNS: "http://opds-spec.org/2010/",
|
OpdsNS: "http://opds-spec.org/2010/",
|
||||||
DcNS: "http://purl.org/dc/elements/1.1/",
|
DcNS: "http://purl.org/dc/elements/1.1/",
|
||||||
ID: feedID,
|
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
|
||||||
Title: title,
|
ID: feedID,
|
||||||
Updated: now,
|
Title: title,
|
||||||
Links: []Link{},
|
Updated: now,
|
||||||
Entries: []Entry{},
|
Links: []Link{},
|
||||||
|
Entries: []Entry{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPagination populates the OpenSearch paging metadata (totalResults,
|
||||||
|
// itemsPerPage, startIndex). startIndex is 1-based to match the page model.
|
||||||
|
func (f *Feed) SetPagination(totalResults, itemsPerPage, startIndex int) {
|
||||||
|
tr := totalResults
|
||||||
|
ipp := itemsPerPage
|
||||||
|
si := startIndex
|
||||||
|
f.TotalResults = &tr
|
||||||
|
f.ItemsPerPage = &ipp
|
||||||
|
f.StartIndex = &si
|
||||||
|
}
|
||||||
|
|
||||||
// AddLink adds a link to the feed
|
// AddLink adds a link to the feed
|
||||||
func (f *Feed) AddLink(href, linkType, rel string) {
|
func (f *Feed) AddLink(href, linkType, rel string) {
|
||||||
f.Links = append(f.Links, Link{
|
f.Links = append(f.Links, Link{
|
||||||
@@ -85,14 +107,17 @@ func (f *Feed) AddEntry(entry Entry) {
|
|||||||
|
|
||||||
// NewEntry creates a new OPDS entry
|
// NewEntry creates a new OPDS entry
|
||||||
func NewEntry(id, title, creator, updated string) Entry {
|
func NewEntry(id, title, creator, updated string) Entry {
|
||||||
return Entry{
|
e := Entry{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
Creator: creator,
|
|
||||||
Updated: updated,
|
Updated: updated,
|
||||||
Links: []Link{},
|
Links: []Link{},
|
||||||
Metadata: []Meta{},
|
Metadata: []Meta{},
|
||||||
}
|
}
|
||||||
|
if creator != "" {
|
||||||
|
e.Author = &Author{Name: creator}
|
||||||
|
}
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAcquisitionLink adds an acquisition link to the entry
|
// AddAcquisitionLink adds an acquisition link to the entry
|
||||||
@@ -169,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) {
|
|||||||
return xml.Header + string(output), nil
|
return xml.Header + string(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenSearchUrl is a single <Url> element in an OpenSearch description.
|
||||||
|
type OpenSearchUrl struct {
|
||||||
|
XMLName xml.Name `xml:"Url"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Template string `xml:"template,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSearchDescription is an OpenSearch description document used by OPDS
|
||||||
|
// clients (e.g. KOReader) to discover how to perform catalog searches. Clients
|
||||||
|
// fetch this document at the catalog's rel="search" link, then substitute
|
||||||
|
// {searchTerms} in the Url template to execute a query.
|
||||||
|
type OpenSearchDescription struct {
|
||||||
|
XMLName xml.Name `xml:"OpenSearchDescription"`
|
||||||
|
Xmlns string `xml:"xmlns,attr"`
|
||||||
|
ShortName string `xml:"ShortName"`
|
||||||
|
Description string `xml:"Description"`
|
||||||
|
InputEncoding string `xml:"InputEncoding"`
|
||||||
|
OutputEncoding string `xml:"OutputEncoding"`
|
||||||
|
Url OpenSearchUrl `xml:"Url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSearchDescription creates an OpenSearch description document whose Url
|
||||||
|
// template points clients back to the search results endpoint. The template
|
||||||
|
// must contain the {searchTerms} placeholder.
|
||||||
|
func NewSearchDescription(shortName, description, template string) *OpenSearchDescription {
|
||||||
|
return &OpenSearchDescription{
|
||||||
|
Xmlns: "http://a9.com/-/spec/opensearch/1.1/",
|
||||||
|
ShortName: shortName,
|
||||||
|
Description: description,
|
||||||
|
InputEncoding: "UTF-8",
|
||||||
|
OutputEncoding: "UTF-8",
|
||||||
|
Url: OpenSearchUrl{
|
||||||
|
Type: "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
|
Template: template,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateXML generates the OpenSearch description XML
|
||||||
|
func (d *OpenSearchDescription) GenerateXML() ([]byte, error) {
|
||||||
|
output, err := xml.MarshalIndent(d, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal OpenSearch description: %w", err)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateXMLString generates the OpenSearch description XML as a string
|
||||||
|
func (d *OpenSearchDescription) GenerateXMLString() (string, error) {
|
||||||
|
output, err := d.GenerateXML()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return xml.Header + string(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewErrorFeed creates an error feed
|
// NewErrorFeed creates an error feed
|
||||||
func NewErrorFeed(message string) *Feed {
|
func NewErrorFeed(message string) *Feed {
|
||||||
feed := NewFeed(
|
feed := NewFeed(
|
||||||
|
|||||||
+105
-4
@@ -71,8 +71,8 @@ func TestNewEntry(t *testing.T) {
|
|||||||
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
|
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Creator != "Test Author" {
|
if entry.Author == nil || entry.Author.Name != "Test Author" {
|
||||||
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator)
|
t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Updated != "2023-01-01T00:00:00Z" {
|
if entry.Updated != "2023-01-01T00:00:00Z" {
|
||||||
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
|
|||||||
`<title>Test Feed</title>`,
|
`<title>Test Feed</title>`,
|
||||||
`<entry>`,
|
`<entry>`,
|
||||||
`<id>urn:uuid:book-id</id>`,
|
`<id>urn:uuid:book-id</id>`,
|
||||||
`<dc:title>Test Book</dc:title>`,
|
`<title>Test Book</title>`,
|
||||||
`<dc:creator>Test Author</dc:creator>`,
|
`<name>Test Author</name>`,
|
||||||
`<link href="http://example.com/book.epub"`,
|
`<link href="http://example.com/book.epub"`,
|
||||||
`rel="http://opds-spec.org/acquisition/open-access"`,
|
`rel="http://opds-spec.org/acquisition/open-access"`,
|
||||||
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
|
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
|
||||||
@@ -232,6 +232,107 @@ func TestNewErrorFeed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFeedSetPagination(t *testing.T) {
|
||||||
|
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||||
|
feed.SetPagination(1814, 50, 51)
|
||||||
|
|
||||||
|
if feed.TotalResults == nil || *feed.TotalResults != 1814 {
|
||||||
|
t.Errorf("expected TotalResults to be 1814, got %v", feed.TotalResults)
|
||||||
|
}
|
||||||
|
if feed.ItemsPerPage == nil || *feed.ItemsPerPage != 50 {
|
||||||
|
t.Errorf("expected ItemsPerPage to be 50, got %v", feed.ItemsPerPage)
|
||||||
|
}
|
||||||
|
if feed.StartIndex == nil || *feed.StartIndex != 51 {
|
||||||
|
t.Errorf("expected StartIndex to be 51, got %v", feed.StartIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedGenerateXMLPagination(t *testing.T) {
|
||||||
|
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "first")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "previous")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=2", "application/atom+xml", "self")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=3", "application/atom+xml", "next")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=37", "application/atom+xml", "last")
|
||||||
|
feed.SetPagination(1814, 50, 51)
|
||||||
|
|
||||||
|
output, err := feed.GenerateXML()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate XML: %v", err)
|
||||||
|
}
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
requiredStrings := []string{
|
||||||
|
`xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"`,
|
||||||
|
`<opensearch:totalResults>1814</opensearch:totalResults>`,
|
||||||
|
`<opensearch:itemsPerPage>50</opensearch:itemsPerPage>`,
|
||||||
|
`<opensearch:startIndex>51</opensearch:startIndex>`,
|
||||||
|
`rel="first"`,
|
||||||
|
`rel="previous"`,
|
||||||
|
`rel="next"`,
|
||||||
|
`rel="last"`,
|
||||||
|
`page=3`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, required := range requiredStrings {
|
||||||
|
if !contains(outputStr, required) {
|
||||||
|
t.Errorf("generated XML missing required string: %s", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedGenerateXMLOmitsPaginationWhenUnset(t *testing.T) {
|
||||||
|
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||||
|
|
||||||
|
output, err := feed.GenerateXML()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate XML: %v", err)
|
||||||
|
}
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
if contains(outputStr, "opensearch:totalResults") {
|
||||||
|
t.Errorf("expected no totalResults when pagination unset, but found it")
|
||||||
|
}
|
||||||
|
if contains(outputStr, "opensearch:itemsPerPage") {
|
||||||
|
t.Errorf("expected no itemsPerPage when pagination unset, but found it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSearchDescription(t *testing.T) {
|
||||||
|
template := "http://example.com/opds/devices/abc/search?q={searchTerms}&token=xyz"
|
||||||
|
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
||||||
|
|
||||||
|
if desc.ShortName != "Bookhoard" {
|
||||||
|
t.Errorf("expected ShortName 'Bookhoard', got '%s'", desc.ShortName)
|
||||||
|
}
|
||||||
|
if desc.Url.Template != template {
|
||||||
|
t.Errorf("expected template '%s', got '%s'", template, desc.Url.Template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchDescriptionGenerateXML(t *testing.T) {
|
||||||
|
template := "http://example.com/opds/devices/abc/search?q={searchTerms}"
|
||||||
|
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
||||||
|
|
||||||
|
output, err := desc.GenerateXMLString()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate XML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredStrings := []string{
|
||||||
|
`<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">`,
|
||||||
|
`<ShortName>Bookhoard</ShortName>`,
|
||||||
|
`<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"`,
|
||||||
|
`template="http://example.com/opds/devices/abc/search?q={searchTerms}"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, required := range requiredStrings {
|
||||||
|
if !contains(output, required) {
|
||||||
|
t.Errorf("generated OpenSearch XML missing required string: %s", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contains(s, substr string) bool {
|
func contains(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
|
"bookhoard/templates"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerAdminLibraryRoutes(cfg *Config, frontendProtected *echo.Group) {
|
||||||
|
g := frontendProtected.Group("", handlers.AdminMiddleware)
|
||||||
|
|
||||||
|
// HTMX: Create library
|
||||||
|
g.POST("/admin/library/create", func(c *echo.Context) error {
|
||||||
|
user := c.Get("user").(database.Users)
|
||||||
|
|
||||||
|
name := c.FormValue("name")
|
||||||
|
desc := c.FormValue("description")
|
||||||
|
libType := c.FormValue("type")
|
||||||
|
if name == "" || libType == "" {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name and type are required</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := cfg.LibraryService.CreateLibrary(
|
||||||
|
c.Request().Context(),
|
||||||
|
name,
|
||||||
|
desc,
|
||||||
|
libType,
|
||||||
|
user.ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to create library</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryList(c, cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Update library
|
||||||
|
g.PUT("/admin/library/:id", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := c.FormValue("name")
|
||||||
|
desc := c.FormValue("description")
|
||||||
|
if name == "" {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Name is required</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cfg.LibraryService.UpdateLibrary(c.Request().Context(), libraryID, name, desc)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update library</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryList(c, cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Delete library
|
||||||
|
g.DELETE("/admin/library/:id", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.LibraryService.DeleteLibrary(c.Request().Context(), libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to delete library</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryList(c, cfg)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Library expanded panel
|
||||||
|
g.GET("/admin/library/:id/panel", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Add folder
|
||||||
|
g.POST("/admin/library/:id/folders", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPath := c.FormValue("folder_path")
|
||||||
|
if folderPath == "" {
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(folderPath, "..") {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Path traversal not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanPath := filepath.Clean(folderPath)
|
||||||
|
fileInfo, err := os.Stat(cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Folder path does not exist")
|
||||||
|
}
|
||||||
|
if !fileInfo.IsDir() {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Path must be a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cfg.LibraryService.AddLibraryFolder(c.Request().Context(), libraryID, cleanPath)
|
||||||
|
if err != nil {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to add folder: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Remove folder
|
||||||
|
g.DELETE("/admin/library/:id/folders", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
folderPath := c.FormValue("folder_path")
|
||||||
|
if folderPath == "" {
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cfg.LibraryService.DeleteLibraryFolder(c.Request().Context(), libraryID, folderPath)
|
||||||
|
if err != nil {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "Failed to remove folder")
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Folder browser
|
||||||
|
g.GET("/admin/library/browse", func(c *echo.Context) error {
|
||||||
|
path := c.QueryParam("path")
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
targetInput := c.QueryParam("target_input")
|
||||||
|
libraryID := c.QueryParam("library_id")
|
||||||
|
|
||||||
|
dirs, currentPath, parentPath, err := cfg.LibraryService.BrowseDirectories(c.Request().Context(), path)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Cannot browse: `+err.Error()+`</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries := make([]templates.DirEntry, len(dirs))
|
||||||
|
for i, d := range dirs {
|
||||||
|
fullPath := filepath.Join(currentPath, d)
|
||||||
|
entries[i] = templates.DirEntry{Name: d, Path: fullPath}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.FolderBrowserContent(currentPath, parentPath, entries, targetInput, libraryID).Render(c.Request().Context(), &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX: Set user visibility for library
|
||||||
|
g.POST("/admin/library/:id/visibility", func(c *echo.Context) error {
|
||||||
|
libraryID, err := parseAdminUUID(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid library ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDStr := c.FormValue("user_id")
|
||||||
|
isVisible := c.FormValue("is_visible") == "true"
|
||||||
|
|
||||||
|
userID, err := parseAdminUUID(userIDStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Invalid user ID</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cfg.LibraryService.SetLibraryVisibility(c.Request().Context(), userID, libraryID, isVisible)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to update visibility</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderLibraryPanel(c, cfg, libraryID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLibraryList fetches all libraries + users and renders the LibraryList partial.
|
||||||
|
func renderLibraryList(c *echo.Context, cfg *Config) error {
|
||||||
|
libraries, err := cfg.LibraryHandler.ListLibrariesData(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusInternalServerError, `<div class="text-sm" style="color: var(--status-danger);">Failed to load libraries</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
libData := make([]templates.LibraryData, len(libraries))
|
||||||
|
for i, lib := range libraries {
|
||||||
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||||
|
folderCount := getFolderCount(c.Request().Context(), cfg, lib.ID)
|
||||||
|
libData[i] = templates.LibraryData{
|
||||||
|
ID: libUUID.String(),
|
||||||
|
Name: lib.Name,
|
||||||
|
Description: getText(lib.Description),
|
||||||
|
TypeName: lib.TypeName,
|
||||||
|
TypeValue: lib.TypeName,
|
||||||
|
FolderCount: folderCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := cfg.Queries.ListUsers(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ListUsers failed: %v", err)
|
||||||
|
users = []database.ListUsersRow{}
|
||||||
|
}
|
||||||
|
userData := make([]templates.User, len(users))
|
||||||
|
for i, u := range users {
|
||||||
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
||||||
|
userData[i] = templates.User{
|
||||||
|
ID: userUUID.String(),
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
Role: u.Role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.LibraryList(templates.User{}, libData, userData).Render(c.Request().Context(), &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderLibraryPanel fetches library details and renders the LibraryPanel partial.
|
||||||
|
func renderLibraryPanel(c *echo.Context, cfg *Config, libraryID pgtype.UUID) error {
|
||||||
|
return renderLibraryPanelWithError(c, cfg, libraryID, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderLibraryPanelWithError(c *echo.Context, cfg *Config, libraryID pgtype.UUID, errMsg string) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
libraryIDStr := uuid.UUID(libraryID.Bytes).String()
|
||||||
|
|
||||||
|
// Get library details
|
||||||
|
lib, err := cfg.LibraryService.GetLibrary(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusNotFound, `<div class="text-sm" style="color: var(--status-danger);">Library not found</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
libData := templates.LibraryData{
|
||||||
|
ID: libraryIDStr,
|
||||||
|
Name: lib.Name,
|
||||||
|
Description: getText(lib.Description),
|
||||||
|
TypeName: lib.TypeName,
|
||||||
|
TypeValue: lib.TypeName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get folders
|
||||||
|
dbFolders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetLibraryFolders failed: %v", err)
|
||||||
|
}
|
||||||
|
folders := make([]templates.FolderData, len(dbFolders))
|
||||||
|
for i, f := range dbFolders {
|
||||||
|
folders[i] = templates.FolderData{FolderPath: f.FolderPath}
|
||||||
|
}
|
||||||
|
libData.FolderCount = len(folders)
|
||||||
|
|
||||||
|
// Get users
|
||||||
|
dbUsers, err := cfg.Queries.ListUsers(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ListUsers failed: %v", err)
|
||||||
|
dbUsers = []database.ListUsersRow{}
|
||||||
|
}
|
||||||
|
userData := make([]templates.User, len(dbUsers))
|
||||||
|
for i, u := range dbUsers {
|
||||||
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
||||||
|
userData[i] = templates.User{
|
||||||
|
ID: userUUID.String(),
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get visibility for all users
|
||||||
|
visibility := make([]templates.UserVisibilityData, len(userData))
|
||||||
|
for i, u := range userData {
|
||||||
|
userUUID, _ := parseAdminUUID(u.ID)
|
||||||
|
visibleLibs, err := cfg.LibraryService.GetUserVisibleLibraries(ctx, userUUID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
||||||
|
}
|
||||||
|
isVisible := false
|
||||||
|
for _, vl := range visibleLibs {
|
||||||
|
if vl.ID.Bytes == libraryID.Bytes {
|
||||||
|
isVisible = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visibility[i] = templates.UserVisibilityData{
|
||||||
|
UserID: u.ID,
|
||||||
|
Username: u.Username,
|
||||||
|
Email: u.Email,
|
||||||
|
IsVisible: isVisible,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get issue count
|
||||||
|
issueStats, err := cfg.ProcessingIssuesHandler.GetProcessingIssueStatsData(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetProcessingIssueStats failed: %v", err)
|
||||||
|
}
|
||||||
|
issueCount := issueStats.ErrorCount + issueStats.WarningCount + issueStats.InfoCount
|
||||||
|
|
||||||
|
// Get current user for template
|
||||||
|
tmplUser := templates.User{}
|
||||||
|
if u, ok := c.Get("user").(database.Users); ok {
|
||||||
|
userUUID, _ := uuid.FromBytes(u.ID.Bytes[0:16])
|
||||||
|
tmplUser = templates.User{
|
||||||
|
ID: userUUID.String(),
|
||||||
|
Username: u.Username,
|
||||||
|
Role: u.Role,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.LibraryPanel(tmplUser, libraryIDStr, libData, folders, userData, visibility, int(issueCount)).Render(ctx, &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
html := buf.String()
|
||||||
|
if errMsg != "" {
|
||||||
|
html = `<div class="p-3 mb-3 rounded-lg text-sm" style="background-color: color-mix(in srgb, var(--status-danger) 12%, var(--bg-secondary)); color: var(--status-danger);">` + errMsg + `</div>` + html
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, html)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFolderCount(ctx context.Context, cfg *Config, libraryID pgtype.UUID) int {
|
||||||
|
folders, err := cfg.LibraryService.GetLibraryFolders(ctx, libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return len(folders)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAdminUUID(s string) (pgtype.UUID, error) {
|
||||||
|
parsed, err := uuid.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return pgtype.UUID{}, err
|
||||||
|
}
|
||||||
|
return pgtype.UUID{Bytes: parsed, Valid: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAdminStats(ctx context.Context, cfg *Config) templates.AdminStats {
|
||||||
|
stats := templates.AdminStats{}
|
||||||
|
|
||||||
|
libs, _ := cfg.LibraryHandler.ListLibrariesData(ctx)
|
||||||
|
stats.LibraryCount = len(libs)
|
||||||
|
|
||||||
|
users, _ := cfg.Queries.ListUsers(ctx)
|
||||||
|
stats.UserCount = len(users)
|
||||||
|
|
||||||
|
if pool, ok := cfg.DBPool.(*pgxpool.Pool); ok {
|
||||||
|
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM media_items").Scan(&stats.MediaCount)
|
||||||
|
_ = pool.QueryRow(ctx, "SELECT COUNT(*) FROM devices").Scan(&stats.DeviceCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerAdminSettingsRoutes(cfg *Config, frontendProtected *echo.Group) {
|
||||||
|
g := frontendProtected.Group("", handlers.AdminMiddleware)
|
||||||
|
|
||||||
|
g.PUT("/admin/settings/scan", func(c *echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
autoScan := c.FormValue("auto_scan_enabled") == "true"
|
||||||
|
intervalStr := c.FormValue("scan_poll_interval_seconds")
|
||||||
|
interval, err := strconv.Atoi(intervalStr)
|
||||||
|
if err != nil || interval < 1 || interval > 3600 {
|
||||||
|
return c.HTML(http.StatusBadRequest, `<div class="text-sm" style="color: var(--status-danger);">Interval must be between 1 and 3600 seconds</div>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
autoScanStr := "false"
|
||||||
|
if autoScan {
|
||||||
|
autoScanStr = "true"
|
||||||
|
}
|
||||||
|
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
|
||||||
|
SettingKey: "auto_scan_enabled",
|
||||||
|
SettingValue: autoScanStr,
|
||||||
|
})
|
||||||
|
_ = cfg.Queries.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
|
||||||
|
SettingKey: "scan_poll_interval_seconds",
|
||||||
|
SettingValue: strconv.Itoa(interval),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Refresh the registry cache so the change is visible immediately.
|
||||||
|
if cfg.Settings != nil {
|
||||||
|
cfg.Settings.Reload(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
scanSettings := templates.ScanSettingsData{
|
||||||
|
AutoScanEnabled: autoScan,
|
||||||
|
ScanPollIntervalSeconds: interval,
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
_ = templates.ScanSettingsSection(scanSettings).Render(ctx, &buf)
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
|
||||||
|
// HTMX endpoint for saving a single tunable setting. Returns a small HTML
|
||||||
|
// status snippet rendered into the row's status span.
|
||||||
|
g.PUT("/admin/settings/tunable", func(c *echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
key := c.FormValue("key")
|
||||||
|
value := c.FormValue("value")
|
||||||
|
|
||||||
|
if cfg.SystemSettingsHandler == nil {
|
||||||
|
return c.HTML(http.StatusServiceUnavailable, `<span style="color: var(--status-danger);">settings unavailable</span>`)
|
||||||
|
}
|
||||||
|
resp, err := cfg.SystemSettingsHandler.ApplySetting(ctx, key, value)
|
||||||
|
if err != nil {
|
||||||
|
return c.HTML(http.StatusBadRequest, fmt.Sprintf(`<span style="color: var(--status-danger);">%s</span>`, err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
color := "var(--status-success)"
|
||||||
|
msg := "Saved"
|
||||||
|
if resp.ReloadRequired {
|
||||||
|
color = "var(--status-warning)"
|
||||||
|
msg = "Saved — restart required"
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, fmt.Sprintf(`<span style="color: %s;">%s</span>`, color, msg))
|
||||||
|
})
|
||||||
|
}
|
||||||
+143
-25
@@ -4,12 +4,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bookhoard/internal/config"
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
@@ -215,6 +215,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
bookInfoList = []handlers.BookInfo{}
|
bookInfoList = []handlers.BookInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seriesUserUUID, _ := uuid.Parse(user.ID)
|
||||||
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: seriesUserUUID, Valid: true}, bookInfoList)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -267,6 +270,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
bookInfoList = []handlers.BookInfo{}
|
bookInfoList = []handlers.BookInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tagUserUUID, _ := uuid.Parse(user.ID)
|
||||||
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: tagUserUUID, Valid: true}, bookInfoList)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -354,6 +360,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList)
|
||||||
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -408,7 +415,8 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
// Get only visible sections for the dashboard display
|
// Get only visible sections for the dashboard display
|
||||||
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
||||||
|
|
||||||
sectionData := handlers.BuildSections(visibleSections, libraryID)
|
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||||
|
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
||||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -623,6 +631,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
Description: collection.Description.String,
|
Description: collection.Description.String,
|
||||||
Color: collection.Color.String,
|
Color: collection.Color.String,
|
||||||
Icon: collection.Icon.String,
|
Icon: collection.Icon.String,
|
||||||
|
IsSystem: collection.IsSystemCollection.Bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -725,10 +734,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get base URL from database config with fallback to config/env var
|
// Get base URL from database config with fallback to config/env var
|
||||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = cfg.Cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||||
@@ -805,8 +811,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
|
stats := getAdminStats(c.Request().Context(), cfg)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Admin(user).Render(c.Request().Context(), &buf)
|
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -818,8 +825,9 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
|
stats := getAdminStats(c.Request().Context(), cfg)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Admin(user).Render(c.Request().Context(), &buf)
|
err = templates.Admin(user, stats).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -843,11 +851,14 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
libData := make([]templates.LibraryData, len(libraries))
|
libData := make([]templates.LibraryData, len(libraries))
|
||||||
for i, lib := range libraries {
|
for i, lib := range libraries {
|
||||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||||
|
folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
|
||||||
libData[i] = templates.LibraryData{
|
libData[i] = templates.LibraryData{
|
||||||
ID: libUUID.String(),
|
ID: libUUID.String(),
|
||||||
Name: lib.Name,
|
Name: lib.Name,
|
||||||
Description: getText(lib.Description),
|
Description: getText(lib.Description),
|
||||||
TypeName: lib.TypeName,
|
TypeName: lib.TypeName,
|
||||||
|
TypeValue: lib.TypeName,
|
||||||
|
FolderCount: len(folders),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -934,6 +945,67 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Admin hash conflicts page: content-duplicate groups flagged during hash
|
||||||
|
// backfill or rescan, resolved by keeping all copies or merging into one.
|
||||||
|
frontendProtected.GET("/admin/hash-conflicts", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := cfg.Queries.ListPendingHashConflicts(c.Request().Context())
|
||||||
|
if err != nil {
|
||||||
|
return renderErrorPage(c, "Error loading hash conflicts", "conflicts_load_error")
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts := make([]templates.HashConflictData, 0, len(pending))
|
||||||
|
for _, p := range pending {
|
||||||
|
conflict := templates.HashConflictData{
|
||||||
|
ID: uuid.UUID(p.ID.Bytes).String(),
|
||||||
|
LibraryName: p.LibraryName,
|
||||||
|
SHA256: p.FileSha256,
|
||||||
|
SHAShort: p.FileSha256[:16] + "…",
|
||||||
|
CreatedAt: p.CreatedAt.Time.Format("Jan 2, 2006"),
|
||||||
|
Items: []templates.HashConflictItemData{},
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := cfg.Queries.ListMediaItemsBySHA256AndLibrary(c.Request().Context(), database.ListMediaItemsBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: p.FileSha256, Valid: true},
|
||||||
|
LibraryID: p.LibraryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mi := range items {
|
||||||
|
counts, err := cfg.Queries.GetMediaItemUsageCounts(c.Request().Context(), mi.ID)
|
||||||
|
if err != nil {
|
||||||
|
counts = database.GetMediaItemUsageCountsRow{}
|
||||||
|
}
|
||||||
|
totalData := counts.ProgressCount + counts.HighlightsCount + counts.BookmarksCount + counts.NotesCount + counts.CollectionsCount
|
||||||
|
conflict.Items = append(conflict.Items, templates.HashConflictItemData{
|
||||||
|
ID: uuid.UUID(mi.ID.Bytes).String(),
|
||||||
|
Title: mi.Title,
|
||||||
|
Author: mi.Author.String,
|
||||||
|
FilePath: mi.FilePath,
|
||||||
|
FileSize: mi.FileSize.Int64,
|
||||||
|
UsageSummary: fmt.Sprintf("%d progress, %d highlights, %d bookmarks, %d notes, %d collections",
|
||||||
|
counts.ProgressCount, counts.HighlightsCount, counts.BookmarksCount, counts.NotesCount, counts.CollectionsCount),
|
||||||
|
HasReadingData: totalData > 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
conflicts = append(conflicts, conflict)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
err = templates.AdminHashConflicts(user, conflicts).Render(c.Request().Context(), &buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
}))
|
||||||
|
|
||||||
// Admin users page
|
// Admin users page
|
||||||
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
@@ -985,24 +1057,67 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
// Fetch current system configuration - just base_url
|
// Fetch current system configuration - just base_url
|
||||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
baseURL := cfg.getBaseURL(ctx)
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = cfg.Cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
systemConfig := map[string]string{
|
systemConfig := map[string]string{
|
||||||
"base_url": baseURL,
|
"base_url": baseURL,
|
||||||
"default_timezone": "UTC",
|
"default_timezone": "UTC",
|
||||||
}
|
}
|
||||||
|
|
||||||
defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
|
defaultTimezone, err := cfg.Queries.GetSystemTimezone(ctx)
|
||||||
if err == nil && defaultTimezone != "" {
|
if err == nil && defaultTimezone != "" {
|
||||||
systemConfig["default_timezone"] = defaultTimezone
|
systemConfig["default_timezone"] = defaultTimezone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch scan settings
|
||||||
|
scanSettings := templates.ScanSettingsData{
|
||||||
|
AutoScanEnabled: true,
|
||||||
|
ScanPollIntervalSeconds: 60,
|
||||||
|
}
|
||||||
|
if val, err := cfg.Queries.GetSystemSetting(ctx, "auto_scan_enabled"); err == nil {
|
||||||
|
scanSettings.AutoScanEnabled = val == "true"
|
||||||
|
}
|
||||||
|
if val, err := cfg.Queries.GetSystemSetting(ctx, "scan_poll_interval_seconds"); err == nil {
|
||||||
|
if n, err := strconv.Atoi(val); err == nil {
|
||||||
|
scanSettings.ScanPollIntervalSeconds = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tunable settings entries from the registry. Exclude keys that
|
||||||
|
// already have their own dedicated UI cards (timezone dropdown, scan
|
||||||
|
// settings) so they aren't listed twice.
|
||||||
|
dedicatedUI := map[string]bool{
|
||||||
|
"default_timezone": true,
|
||||||
|
"scan_poll_interval_seconds": true,
|
||||||
|
"auto_scan_enabled": true,
|
||||||
|
}
|
||||||
|
var tunableSettings []templates.SettingEntry
|
||||||
|
if cfg.Settings != nil {
|
||||||
|
for _, e := range cfg.Settings.All() {
|
||||||
|
if dedicatedUI[e.Key] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tunableSettings = append(tunableSettings, templates.SettingEntry{
|
||||||
|
Key: e.Key,
|
||||||
|
Value: e.Value,
|
||||||
|
Type: e.Type,
|
||||||
|
Min: e.Min,
|
||||||
|
Max: e.Max,
|
||||||
|
RequiresRestart: e.RequiresRestart,
|
||||||
|
Category: e.Category,
|
||||||
|
Group: e.Group,
|
||||||
|
Description: e.Description,
|
||||||
|
IsDefault: e.IsDefault,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
liveGroups, restartGroups := templates.GroupTunableSettings(tunableSettings)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.AdminSettings(user, systemConfig, "").Render(c.Request().Context(), &buf)
|
err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1044,6 +1159,11 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
// Admin library HTMX endpoints
|
||||||
|
registerAdminLibraryRoutes(cfg, frontendProtected)
|
||||||
|
// Admin settings HTMX endpoints
|
||||||
|
registerAdminSettingsRoutes(cfg, frontendProtected)
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// LEGACY API ROUTES (for backward compatibility)
|
// LEGACY API ROUTES (for backward compatibility)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1077,10 +1197,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get base URL from database config with fallback to config/env var
|
// Get base URL from database config with fallback to config/env var
|
||||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = cfg.Cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||||
@@ -1212,13 +1329,14 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
|
|
||||||
// Assemble response (no field duplication!)
|
// Assemble response (no field duplication!)
|
||||||
detail := handlers.MediaDetail{
|
detail := handlers.MediaDetail{
|
||||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
Collections: collections,
|
Collections: collections,
|
||||||
ReadingProgress: progress,
|
ReadingProgress: progress,
|
||||||
ActiveConflict: activeConflict,
|
ActiveConflict: activeConflict,
|
||||||
NotesCount: len(notes),
|
NotesCount: len(notes),
|
||||||
HighlightsCount: len(highlights),
|
HighlightsCount: len(highlights),
|
||||||
|
DeletedAnnotations: handlers.DeletedAnnotationsForBook(c.Request().Context(), cfg.Queries, pgUserID, pgMediaUUID),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render template
|
// Render template
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
@@ -66,7 +67,7 @@ func convertPending(pending []map[string]interface{}) []templates.PendingRegistr
|
|||||||
RegistrationID: p["registration_id"].(string),
|
RegistrationID: p["registration_id"].(string),
|
||||||
DeviceName: p["device_name"].(string),
|
DeviceName: p["device_name"].(string),
|
||||||
DeviceType: p["device_type"].(string),
|
DeviceType: p["device_type"].(string),
|
||||||
ExpiresAt: p["expires_at"].(string),
|
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -117,6 +118,17 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
|||||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
libraries = []database.GetUserVisibleLibrariesRow{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
|
||||||
|
if countErr != nil {
|
||||||
|
log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr)
|
||||||
|
counts = []database.GetVisibleLibraryMediaCountsRow{}
|
||||||
|
}
|
||||||
|
countMap := make(map[string]int64, len(counts))
|
||||||
|
for _, mc := range counts {
|
||||||
|
mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16])
|
||||||
|
countMap[mcUUID.String()] = mc.MediaCount
|
||||||
|
}
|
||||||
|
|
||||||
res.Libraries = make([]templates.LibraryData, len(libraries))
|
res.Libraries = make([]templates.LibraryData, len(libraries))
|
||||||
for i, lib := range libraries {
|
for i, lib := range libraries {
|
||||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||||
@@ -125,6 +137,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
|||||||
Name: lib.Name,
|
Name: lib.Name,
|
||||||
Description: getText(lib.Description),
|
Description: getText(lib.Description),
|
||||||
TypeName: lib.TypeName,
|
TypeName: lib.TypeName,
|
||||||
|
MediaCount: countMap[libUUID.String()],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ func registerLibraryRoutes(cfg *Config) {
|
|||||||
adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats)
|
adminLibrary.GET("/:id/stats", cfg.LibraryHandler.GetLibraryStats)
|
||||||
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
adminLibrary.GET("/:id/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
||||||
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
|
adminLibrary.GET("/:id/issues/stats", cfg.ProcessingIssuesHandler.GetProcessingIssueStats)
|
||||||
|
adminLibrary.POST("/:id/issues/:issueId/:mediaItemId/resolve", cfg.ProcessingIssuesHandler.ResolveProcessingIssue)
|
||||||
|
adminLibrary.DELETE("/:id/issues/:issueId", cfg.ProcessingIssuesHandler.DeleteProcessingIssue)
|
||||||
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
|
adminLibrary.POST("/:id/scan", func(c *echo.Context) error {
|
||||||
libraryID := c.Param("id")
|
libraryID := c.Param("id")
|
||||||
scanReq := map[string]interface{}{
|
scanReq := map[string]interface{}{
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
// Media item routes (all authenticated users)
|
// Media item routes (all authenticated users)
|
||||||
protected.GET("/media-items", cfg.MediaHandler.ListMediaItems)
|
protected.GET("/media-items", cfg.MediaHandler.ListMediaItems)
|
||||||
protected.GET("/media-items/:id", cfg.MediaHandler.GetMediaItem)
|
protected.GET("/media-items/:id", cfg.MediaHandler.GetMediaItem)
|
||||||
|
// Book download endpoint - same ServeFile flow as /uploads/library-:id/*
|
||||||
|
// (JWT + library-visibility gated), addressed by media item ID.
|
||||||
|
protected.GET("/media-items/:id/download", cfg.MediaHandler.ServeFile)
|
||||||
|
|
||||||
// Media rating routes (all authenticated users)
|
// Media rating routes (all authenticated users)
|
||||||
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
|
protected.POST("/media-items/:id/rating", cfg.MediaHandler.CreateMediaRating)
|
||||||
@@ -41,6 +44,19 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight)
|
protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight)
|
||||||
protected.DELETE("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.DeleteMediaHighlight)
|
protected.DELETE("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.DeleteMediaHighlight)
|
||||||
|
|
||||||
|
// Bookmark routes (all authenticated users)
|
||||||
|
protected.GET("/media-items/:id/bookmarks", cfg.MediaHandler.GetMediaBookmarks)
|
||||||
|
protected.POST("/media-items/:id/bookmarks", cfg.MediaHandler.CreateMediaBookmark)
|
||||||
|
protected.PUT("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.UpdateMediaBookmark)
|
||||||
|
protected.DELETE("/media-items/:id/bookmarks/:bookmarkId", cfg.MediaHandler.DeleteMediaBookmark)
|
||||||
|
|
||||||
|
// Deleted-annotation history (all authenticated users): tombstoned
|
||||||
|
// highlights/notes/bookmarks restorable or permanently removable from the
|
||||||
|
// book page's "recently deleted" list.
|
||||||
|
protected.GET("/media-items/:id/annotations/deleted", cfg.MediaHandler.GetDeletedAnnotations)
|
||||||
|
protected.POST("/media-items/:id/annotations/:annotationId/restore", cfg.MediaHandler.RestoreDeletedAnnotation)
|
||||||
|
protected.DELETE("/media-items/:id/annotations/:annotationId", cfg.MediaHandler.PurgeDeletedAnnotation)
|
||||||
|
|
||||||
// Admin-only media routes
|
// Admin-only media routes
|
||||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ type Config struct {
|
|||||||
Echo *echo.Echo
|
Echo *echo.Echo
|
||||||
Queries *database.Queries
|
Queries *database.Queries
|
||||||
Cfg *config.Config
|
Cfg *config.Config
|
||||||
|
Settings *database.SettingsRegistry
|
||||||
DBPool interface{} // pgxpool.Pool interface
|
DBPool interface{} // pgxpool.Pool interface
|
||||||
AuthHandler *handlers.AuthHandler
|
AuthHandler *handlers.AuthHandler
|
||||||
LibraryHandler *handlers.LibraryHandler
|
LibraryHandler *handlers.LibraryHandler
|
||||||
@@ -46,6 +47,7 @@ type Config struct {
|
|||||||
MediaHandler *handlers.MediaHandler
|
MediaHandler *handlers.MediaHandler
|
||||||
MatchingHandler *handlers.MatchingHandler
|
MatchingHandler *handlers.MatchingHandler
|
||||||
ProcessingIssuesHandler *handlers.ProcessingIssuesHandler
|
ProcessingIssuesHandler *handlers.ProcessingIssuesHandler
|
||||||
|
HashConflictsHandler *handlers.HashConflictsHandler
|
||||||
KOReaderHandler *handlers.KOReaderHandler
|
KOReaderHandler *handlers.KOReaderHandler
|
||||||
WSHandler *handlers.WSHandler
|
WSHandler *handlers.WSHandler
|
||||||
ConflictHandler *handlers.ConflictHandler
|
ConflictHandler *handlers.ConflictHandler
|
||||||
@@ -62,12 +64,32 @@ type Config struct {
|
|||||||
ConnManager *sync.ConnectionManager
|
ConnManager *sync.ConnectionManager
|
||||||
QueueProcessor *sync.SyncQueueProcessor
|
QueueProcessor *sync.SyncQueueProcessor
|
||||||
ProgressService *sync.ProgressService
|
ProgressService *sync.ProgressService
|
||||||
|
AnnotationService *sync.AnnotationService
|
||||||
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
||||||
LoginTracker *ratelimit.LoginAttemptTracker
|
LoginTracker *ratelimit.LoginAttemptTracker
|
||||||
ScannerHandler *handlers.Handler
|
ScannerHandler *handlers.Handler
|
||||||
JobsHandler *handlers.JobsHandler
|
JobsHandler *handlers.JobsHandler
|
||||||
SidecarHandler *handlers.SidecarHandler
|
SidecarHandler *handlers.SidecarHandler
|
||||||
ReaderHandler *handlers.ReaderHandler
|
ReaderHandler *handlers.ReaderHandler
|
||||||
|
LibraryService *services.LibraryService
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBaseURL returns the configured base URL from the database, falling back to
|
||||||
|
// the env var / config default. Uses a closure to adapt the database query to
|
||||||
|
// config.SystemConfigGetter.
|
||||||
|
func (cfg *Config) getBaseURL(ctx context.Context) string {
|
||||||
|
getter := func(ctx context.Context, key string) (string, error) {
|
||||||
|
row, err := cfg.Queries.GetSystemConfig(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return row.Value, nil
|
||||||
|
}
|
||||||
|
baseURL := config.GetBaseURL(ctx, getter)
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = cfg.Cfg.BaseURL
|
||||||
|
}
|
||||||
|
return baseURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||||
@@ -188,10 +210,15 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
}
|
}
|
||||||
e.Validator = &CustomValidator{validator: v}
|
e.Validator = &CustomValidator{validator: v}
|
||||||
|
|
||||||
// Rate limiter
|
// Setup redirect middleware - must run before all routes
|
||||||
|
e.Pre(setupRedirectMiddleware(cfg))
|
||||||
|
|
||||||
|
// Rate limiter. The per-minute value comes from the settings registry (DB);
|
||||||
|
// the enabled flag stays env-driven since disabling rate limiting is a
|
||||||
|
// deployment-time decision, not a runtime tunable.
|
||||||
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
||||||
Enabled: cfg.Cfg.RateLimitEnabled,
|
Enabled: cfg.Cfg.RateLimitEnabled,
|
||||||
RequestsPerMinute: cfg.Cfg.RequestsPerMinute,
|
RequestsPerMinute: cfg.Settings.AuthRateLimit(),
|
||||||
CleanupInterval: 5 * time.Minute,
|
CleanupInterval: 5 * time.Minute,
|
||||||
}
|
}
|
||||||
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
|
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
|
||||||
@@ -206,6 +233,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
cfg.ScannerHandler = scannerHandler
|
cfg.ScannerHandler = scannerHandler
|
||||||
|
|
||||||
// Register route groups
|
// Register route groups
|
||||||
|
registerSetupRoutes(cfg)
|
||||||
registerAuthRoutes(cfg, rateLimitMiddleware)
|
registerAuthRoutes(cfg, rateLimitMiddleware)
|
||||||
registerLibraryRoutes(cfg)
|
registerLibraryRoutes(cfg)
|
||||||
registerDeviceRoutes(cfg)
|
registerDeviceRoutes(cfg)
|
||||||
@@ -267,6 +295,17 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// One-time hash backfill: compute and store SHA-256 for media items
|
||||||
|
// imported before hashing existed, then flag any content-duplicate groups
|
||||||
|
// for admin review on the Hash Conflicts page. Runs independently of
|
||||||
|
// auto-scan (it is a one-shot self-heal, not a recurring scan) and is a
|
||||||
|
// no-op once every item is hashed. Delayed so it does not compete with
|
||||||
|
// startup scans for disk I/O.
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
services.NewHashBackfillService(cfg.Queries).Run(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
// Register progress routes with actual handler
|
// Register progress routes with actual handler
|
||||||
registerProgressRoutes(cfg, scannerHandler)
|
registerProgressRoutes(cfg, scannerHandler)
|
||||||
|
|
||||||
@@ -274,5 +313,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
|||||||
admin := protected.Group("", handlers.AdminMiddleware)
|
admin := protected.Group("", handlers.AdminMiddleware)
|
||||||
registerScannerRoutes(admin, scannerHandler)
|
registerScannerRoutes(admin, scannerHandler)
|
||||||
|
|
||||||
|
// Hash conflict routes (admin only)
|
||||||
|
admin.GET("/api/admin/hash-conflicts", cfg.HashConflictsHandler.ListHashConflicts)
|
||||||
|
admin.POST("/api/admin/hash-conflicts/:id/resolve", cfg.HashConflictsHandler.ResolveHashConflict)
|
||||||
|
|
||||||
return scannerHandler
|
return scannerHandler
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,9 +112,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
|||||||
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Render using BooksGrid template
|
// Stamp active conflict flags so cards route the play action correctly
|
||||||
|
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList)
|
||||||
|
// Render using BooksGrid template (or BookPickerGrid for collection picker)
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
if c.QueryParam("show_checkbox") == "true" {
|
||||||
|
err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf)
|
||||||
|
} else {
|
||||||
|
err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Template render error: %v", err)
|
log.Printf("Template render error: %v", err)
|
||||||
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
|
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Render error</div>`)
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"bookhoard/internal/setupstatus"
|
||||||
|
"bookhoard/templates"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func isSetupComplete(cfg *Config) bool {
|
||||||
|
getter := func(ctx context.Context) (string, error) {
|
||||||
|
row, err := cfg.Queries.GetSystemConfig(ctx, "base_url")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return row.Value, nil
|
||||||
|
}
|
||||||
|
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries, getter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupAllowedAPIRoutes lists API endpoints that remain accessible before
|
||||||
|
// initial setup is complete so the server can be configured via API.
|
||||||
|
var setupAllowedAPIRoutes = []string{
|
||||||
|
"/api/auth/register",
|
||||||
|
"/api/auth/login",
|
||||||
|
"/api/system/config",
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAllowedDuringSetup reports whether a request path should bypass the setup
|
||||||
|
// gate. This includes the setup page itself, static assets, health checks, and
|
||||||
|
// the minimal set of API routes needed to perform initial configuration.
|
||||||
|
func isAllowedDuringSetup(path string) bool {
|
||||||
|
if path == "/setup" || path == "/setup/" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, route := range setupAllowedAPIRoutes {
|
||||||
|
if path == route || strings.HasPrefix(path, route+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||||
|
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c *echo.Context) error {
|
||||||
|
path := c.Request().URL.Path
|
||||||
|
|
||||||
|
if isAllowedDuringSetup(path) {
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isSetupComplete(cfg) {
|
||||||
|
if strings.HasPrefix(path, "/api/") {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{
|
||||||
|
"error": "Server setup is not complete. Configure an admin account and base_url via the setup wizard or API.",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return c.Redirect(http.StatusFound, "/setup")
|
||||||
|
}
|
||||||
|
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSetupRoutes(cfg *Config) {
|
||||||
|
e := cfg.Echo
|
||||||
|
|
||||||
|
e.GET("/setup", func(c *echo.Context) error {
|
||||||
|
if isSetupComplete(cfg) {
|
||||||
|
return c.Redirect(http.StatusFound, "/")
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil {
|
||||||
|
log.Printf("Failed to render setup template: %v", err)
|
||||||
|
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
|
||||||
|
}
|
||||||
|
return c.HTML(http.StatusOK, buf.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ func registerSyncRoutes(cfg *Config) {
|
|||||||
// KOReader sync routes (device authentication required)
|
// KOReader sync routes (device authentication required)
|
||||||
koreaderSync := e.Group("/api/sync/koreader")
|
koreaderSync := e.Group("/api/sync/koreader")
|
||||||
koreaderSync.POST("/progress", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncProgress))
|
koreaderSync.POST("/progress", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncProgress))
|
||||||
|
koreaderSync.GET("/resolve", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.ResolveBook))
|
||||||
koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata))
|
koreaderSync.GET("/metadata/:uuid", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetMetadata))
|
||||||
koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary))
|
koreaderSync.GET("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary))
|
||||||
koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks))
|
koreaderSync.POST("/bookmarks", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.SyncBookmarks))
|
||||||
@@ -33,6 +34,8 @@ func registerSyncRoutes(cfg *Config) {
|
|||||||
// API clients can use Authorization header: Authorization: Bearer {token}
|
// API clients can use Authorization header: Authorization: Bearer {token}
|
||||||
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
|
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
|
||||||
koboHandler.SetProgressService(cfg.ProgressService)
|
koboHandler.SetProgressService(cfg.ProgressService)
|
||||||
|
koboHandler.SetAnnotationService(cfg.AnnotationService)
|
||||||
|
koboHandler.SetLibraryService(cfg.LibraryService)
|
||||||
koboSync := e.Group("/api/sync/kobo/:token")
|
koboSync := e.Group("/api/sync/kobo/:token")
|
||||||
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
||||||
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
|
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
|
||||||
|
|||||||
@@ -17,4 +17,10 @@ func registerSystemRoutes(cfg *Config) {
|
|||||||
// System configuration routes (admin-only)
|
// System configuration routes (admin-only)
|
||||||
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
|
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
|
||||||
system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration)
|
system.PUT("/config", cfg.SidecarHandler.UpdateSystemConfiguration)
|
||||||
|
|
||||||
|
// Unified tunable settings (admin-only). These back the admin UI's
|
||||||
|
// editable System Settings sections and supersede the legacy
|
||||||
|
// /api/libraries/scan-settings JSON routes.
|
||||||
|
system.GET("/settings", cfg.SystemSettingsHandler.GetSettings)
|
||||||
|
system.PUT("/settings", cfg.SystemSettingsHandler.UpdateSetting)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,13 +46,15 @@ type LinkBookRequest struct {
|
|||||||
|
|
||||||
// BookMatchingService handles universal book matching
|
// BookMatchingService handles universal book matching
|
||||||
type BookMatchingService struct {
|
type BookMatchingService struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
|
resolver *BookResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewBookMatchingService creates a new book matching service
|
// NewBookMatchingService creates a new book matching service
|
||||||
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
|
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
|
||||||
return &BookMatchingService{
|
return &BookMatchingService{
|
||||||
db: db,
|
db: db,
|
||||||
|
resolver: NewBookResolver(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,27 +169,21 @@ func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers []
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchBySHA256 attempts to match by file SHA-256 hash
|
// matchBySHA256 attempts to match by file SHA-256 hash.
|
||||||
|
// Uses the shared BookResolver so it is both indexed (no full-table scan) and
|
||||||
|
// format-aware: a converted/alternate format hash (media_item_formats) matches
|
||||||
|
// in addition to the primary media_items.file_sha256.
|
||||||
func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch {
|
func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch {
|
||||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
item, method, err := s.resolver.ResolveBySHA256(ctx, sha256)
|
||||||
Limit: 1000,
|
if err != nil || !item.ID.Valid {
|
||||||
Offset: 0,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
return &BookMatch{
|
||||||
for _, item := range items {
|
MediaItemID: item.ID.Bytes,
|
||||||
if item.FileSha256.Valid && item.FileSha256.String == sha256 {
|
BookhoardUUID: item.ID.Bytes,
|
||||||
return &BookMatch{
|
Confidence: 0.9,
|
||||||
MediaItemID: item.ID.Bytes,
|
MatchMethod: "sha256_" + string(method),
|
||||||
BookhoardUUID: item.ID.Bytes,
|
|
||||||
Confidence: 0.9,
|
|
||||||
MatchMethod: "sha256_match",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// matchByOPFIdentifier attempts to match by OPF identifier
|
// matchByOPFIdentifier attempts to match by OPF identifier
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResolveMethod describes how a media item was resolved from a client-supplied identifier.
|
||||||
|
type ResolveMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
MethodNone ResolveMethod = ""
|
||||||
|
MethodSHA256 ResolveMethod = "sha256" // matched on media_items.file_sha256
|
||||||
|
MethodSHA256Format ResolveMethod = "sha256_format" // matched on media_item_formats.file_sha256 (converted/alternate format)
|
||||||
|
)
|
||||||
|
|
||||||
|
// BookResolver is the single shared path from a client-supplied identifier to a
|
||||||
|
// media_item.
|
||||||
|
//
|
||||||
|
// All client/sync interfaces (koreader, kobo, OPDS, the device-link UI, and any
|
||||||
|
// future mobile app) should resolve books through BookResolver so they share
|
||||||
|
// identical matching semantics. In particular it provides format-aware SHA-256
|
||||||
|
// matching: a converted file (KEPUB/PDF) whose hash lives in media_item_formats
|
||||||
|
// resolves just as well as the primary format. The import-time SHA-256 is the
|
||||||
|
// canonical shared identifier across every client.
|
||||||
|
type BookResolver struct {
|
||||||
|
db *database.Queries
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBookResolver constructs a resolver backed by the given queries.
|
||||||
|
func NewBookResolver(db *database.Queries) *BookResolver {
|
||||||
|
return &BookResolver{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveBySHA256 resolves a media item by its content hash. It checks the
|
||||||
|
// primary media_items.file_sha256 first, then media_item_formats.file_sha256 so
|
||||||
|
// that a converted/alternate format (KEPUB, PDF, ...) also matches. Returns the
|
||||||
|
// matched item and how it matched, or pgx.ErrNoRows when no item has this hash.
|
||||||
|
func (r *BookResolver) ResolveBySHA256(ctx context.Context, sha256 string) (database.MediaItems, ResolveMethod, error) {
|
||||||
|
if sha256 == "" {
|
||||||
|
return database.MediaItems{}, MethodNone, pgx.ErrNoRows
|
||||||
|
}
|
||||||
|
sha := pgtype.Text{String: sha256, Valid: true}
|
||||||
|
|
||||||
|
// 1. Primary content hash (the file the media item was imported from).
|
||||||
|
if mi, err := r.db.GetMediaItemBySHA256(ctx, sha); err == nil {
|
||||||
|
return mi, MethodSHA256, nil
|
||||||
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return database.MediaItems{}, MethodNone, fmt.Errorf("resolve by sha256 (primary): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Per-format hash (a converted/alternate format: KEPUB, PDF, ...).
|
||||||
|
formatRow, err := r.db.GetMediaItemFormatBySHA256(ctx, sha)
|
||||||
|
if err == nil {
|
||||||
|
if mi, err := r.db.GetMediaItem(ctx, formatRow.MediaItemID); err == nil {
|
||||||
|
return mi, MethodSHA256Format, nil
|
||||||
|
}
|
||||||
|
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return database.MediaItems{}, MethodNone, fmt.Errorf("resolve by sha256 (format): %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return database.MediaItems{}, MethodNone, pgx.ErrNoRows
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user