Compare commits
308
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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,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."
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
|||||||
|
|
||||||
## ✨ 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,18 @@ 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/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** | 🚧 | 🚧 | Android/iOS apps coming later |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ bruno run
|
|||||||
|
|
||||||
## 🤝 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
+265
-11
@@ -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,
|
||||||
@@ -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,215 @@ 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;
|
||||||
|
|
||||||
|
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:
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -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**: GPL-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)
|
||||||
@@ -243,7 +277,7 @@ type Querier interface {
|
|||||||
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
|
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
|
||||||
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
|
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
|
||||||
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
|
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
|
||||||
GetSeriesBooks(ctx context.Context, arg GetSeriesBooksParams) ([]MediaItems, error)
|
GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]MediaItems, error)
|
||||||
GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error)
|
GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error)
|
||||||
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
|
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
|
||||||
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
|
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, 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)
|
||||||
|
|||||||
+1986
-62
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
|
||||||
@@ -1830,7 +2292,7 @@ LIMIT $1 OFFSET $2;
|
|||||||
-- Dashboard preferences queries
|
-- Dashboard preferences queries
|
||||||
-- name: GetDashboardPreferences :one
|
-- name: GetDashboardPreferences :one
|
||||||
SELECT * FROM user_dashboard_preferences
|
SELECT * FROM user_dashboard_preferences
|
||||||
WHERE user_id = $1 AND library_id = $2;
|
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
|
||||||
|
|
||||||
-- name: UpsertDashboardPreferences :one
|
-- name: UpsertDashboardPreferences :one
|
||||||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
||||||
@@ -1894,57 +2356,57 @@ SELECT mi.* FROM media_items mi
|
|||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||||
FROM reading_progress
|
FROM reading_progress
|
||||||
WHERE user_id = $2
|
WHERE user_id = sqlc.narg('user_id')
|
||||||
AND percentage > 0
|
AND percentage > 0
|
||||||
AND percentage < 1
|
AND percentage < 1
|
||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY rp.last_read_at DESC
|
ORDER BY rp.last_read_at DESC
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetRecentlyAddedItems :many
|
-- name: GetRecentlyAddedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||||
LIMIT $2;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetRecentlyReadItems :many
|
-- name: GetRecentlyReadItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||||
FROM reading_progress
|
FROM reading_progress
|
||||||
WHERE user_id = $2
|
WHERE user_id = sqlc.narg('user_id')
|
||||||
AND percentage >= 1
|
AND percentage >= 1
|
||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY rp.last_read_at DESC
|
ORDER BY rp.last_read_at DESC
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetNotStartedItems :many
|
-- name: GetNotStartedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM reading_progress rp
|
SELECT 1 FROM reading_progress rp
|
||||||
WHERE rp.media_item_id = mi.id
|
WHERE rp.media_item_id = mi.id
|
||||||
AND rp.user_id = $2
|
AND rp.user_id = sqlc.narg('user_id')
|
||||||
AND rp.percentage > 0
|
AND rp.percentage > 0
|
||||||
)
|
)
|
||||||
ORDER BY mi.created_at DESC
|
ORDER BY mi.created_at DESC
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetCollectionItemsForDashboard :many
|
-- name: GetCollectionItemsForDashboard :many
|
||||||
SELECT mi.*, ci.excluded FROM media_items mi
|
SELECT mi.*, ci.excluded FROM media_items mi
|
||||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||||
WHERE ci.collection_id = $1
|
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||||
AND mi.library_id = $2
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY ci.added_at DESC
|
ORDER BY ci.added_at DESC
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetLibraryItems :many
|
-- name: GetLibraryItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY mi.created_at DESC;
|
ORDER BY mi.created_at DESC;
|
||||||
|
|
||||||
-- name: GetDistinctSeries :many
|
-- name: GetDistinctSeries :many
|
||||||
@@ -1952,26 +2414,26 @@ SELECT series, COUNT(*) as book_count,
|
|||||||
MAX(series_count) as total_in_series,
|
MAX(series_count) as total_in_series,
|
||||||
MAX(created_at) as last_entry_at
|
MAX(created_at) as last_entry_at
|
||||||
FROM media_items
|
FROM media_items
|
||||||
WHERE library_id = $1 AND series IS NOT NULL AND series != ''
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != ''
|
||||||
GROUP BY series
|
GROUP BY series
|
||||||
ORDER BY MAX(created_at) DESC
|
ORDER BY MAX(created_at) DESC
|
||||||
LIMIT $2 OFFSET $3;
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||||
|
|
||||||
-- name: GetDistinctSeriesCount :one
|
-- name: GetDistinctSeriesCount :one
|
||||||
SELECT COUNT(DISTINCT series)::int
|
SELECT COUNT(DISTINCT series)::int
|
||||||
FROM media_items
|
FROM media_items
|
||||||
WHERE library_id = $1 AND series IS NOT NULL AND series != '';
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series IS NOT NULL AND series != '';
|
||||||
|
|
||||||
-- name: GetSeriesCovers :many
|
-- name: GetSeriesCovers :many
|
||||||
SELECT cover_image_path, library_id
|
SELECT cover_image_path, library_id
|
||||||
FROM media_items
|
FROM media_items
|
||||||
WHERE library_id = $1 AND series = $2 AND cover_image_path IS NOT NULL AND cover_image_path != ''
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
|
||||||
ORDER BY series_number ASC NULLS LAST
|
ORDER BY series_number ASC NULLS LAST
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetSeriesBooks :many
|
-- name: GetSeriesBooks :many
|
||||||
SELECT * FROM media_items
|
SELECT * FROM media_items
|
||||||
WHERE library_id = $1 AND series = $2
|
WHERE series = sqlc.narg('series')
|
||||||
ORDER BY series_number ASC NULLS LAST;
|
ORDER BY series_number ASC NULLS LAST;
|
||||||
|
|
||||||
-- name: GetContinueSeriesItems :many
|
-- name: GetContinueSeriesItems :many
|
||||||
@@ -1981,10 +2443,10 @@ WITH user_series_progress AS (
|
|||||||
MAX(rp.last_read_at) as last_read_at
|
MAX(rp.last_read_at) as last_read_at
|
||||||
FROM reading_progress rp
|
FROM reading_progress rp
|
||||||
JOIN media_items mi ON mi.id = rp.media_item_id
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
||||||
WHERE rp.user_id = $2
|
WHERE rp.user_id = sqlc.narg('user_id')
|
||||||
AND rp.percentage > 0
|
AND rp.percentage > 0
|
||||||
AND mi.series IS NOT NULL AND mi.series != ''
|
AND mi.series IS NOT NULL AND mi.series != ''
|
||||||
AND mi.library_id = $1
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
GROUP BY mi.series
|
GROUP BY mi.series
|
||||||
),
|
),
|
||||||
next_books AS (
|
next_books AS (
|
||||||
@@ -1992,13 +2454,13 @@ next_books AS (
|
|||||||
usp.last_read_at
|
usp.last_read_at
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN user_series_progress usp ON mi.series = usp.series
|
JOIN user_series_progress usp ON mi.series = usp.series
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
||||||
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
||||||
)
|
)
|
||||||
SELECT * FROM next_books
|
SELECT * FROM next_books
|
||||||
ORDER BY last_read_at DESC NULLS LAST
|
ORDER BY last_read_at DESC NULLS LAST
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetSavedFilters :many
|
-- name: GetSavedFilters :many
|
||||||
SELECT * FROM saved_filters
|
SELECT * FROM saved_filters
|
||||||
@@ -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 {
|
||||||
@@ -138,6 +139,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||||
includeAuto := c.QueryParam("include_auto") == "true"
|
includeAuto := c.QueryParam("include_auto") == "true"
|
||||||
sortBy := c.QueryParam("sort_by")
|
sortBy := c.QueryParam("sort_by")
|
||||||
|
libraryID := c.QueryParam("library_id")
|
||||||
|
|
||||||
collections, err := h.GetCollectionsData(c)
|
collections, err := h.GetCollectionsData(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -152,6 +154,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
BookCount int `json:"book_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
response := make([]CollectionResponse, 0, len(collections))
|
response := make([]CollectionResponse, 0, len(collections))
|
||||||
@@ -159,6 +162,26 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bookCount := 0
|
||||||
|
if libraryID != "" {
|
||||||
|
libUUID, libErr := uuid.Parse(libraryID)
|
||||||
|
if libErr == nil {
|
||||||
|
items, countErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{
|
||||||
|
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
||||||
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
|
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||||
|
})
|
||||||
|
if countErr == nil {
|
||||||
|
for _, item := range items {
|
||||||
|
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||||
|
bookCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response = append(response, CollectionResponse{
|
response = append(response, CollectionResponse{
|
||||||
ID: col.ID.Bytes,
|
ID: col.ID.Bytes,
|
||||||
Name: col.Name,
|
Name: col.Name,
|
||||||
@@ -167,6 +190,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
Icon: textToString(col.Icon),
|
Icon: textToString(col.Icon),
|
||||||
AutoAssignRules: col.AutoAssignRules,
|
AutoAssignRules: col.AutoAssignRules,
|
||||||
CreatedAt: col.CreatedAt.Time.String(),
|
CreatedAt: col.CreatedAt.Time.String(),
|
||||||
|
BookCount: bookCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,19 +221,84 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
books, err := h.GetCollectionBooksData(c, collectionID)
|
libraryID := c.QueryParam("library_id")
|
||||||
if err != nil {
|
var bookList []BookInfo
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
|
var libUUID pgtype.UUID
|
||||||
|
if libraryID != "" {
|
||||||
|
parsed, parseErr := uuid.Parse(libraryID)
|
||||||
|
if parseErr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||||
|
}
|
||||||
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
bookList := make([]BookInfo, 0, len(books))
|
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||||
for _, book := range books {
|
user := c.Get("user").(database.Users)
|
||||||
bookList = append(bookList, BookInfo{
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
dashboardSvc := services.NewDashboardService(h.db)
|
||||||
Title: book.Title,
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||||
Author: textToString(book.Author),
|
if secErr != nil {
|
||||||
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
|
||||||
})
|
}
|
||||||
|
for _, section := range sections {
|
||||||
|
if section.CollectionID == collectionID {
|
||||||
|
bookCards := make([]BookInfo, len(section.Items))
|
||||||
|
for i, item := range section.Items {
|
||||||
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
|
bookCards[i] = BookInfo{
|
||||||
|
MediaItemID: itemUUID.String(),
|
||||||
|
Title: item.Title,
|
||||||
|
Author: textToString(item.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookList = bookCards
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if libUUID.Valid {
|
||||||
|
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||||
|
database.GetCollectionItemsForDashboardParams{
|
||||||
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
LibraryID: libUUID,
|
||||||
|
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||||
|
})
|
||||||
|
if collErr != nil {
|
||||||
|
bookList = []BookInfo{}
|
||||||
|
} else {
|
||||||
|
var validItems []database.GetCollectionItemsForDashboardRow
|
||||||
|
for _, item := range collItems {
|
||||||
|
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||||
|
validItems = append(validItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookCards := make([]BookInfo, len(validItems))
|
||||||
|
for i, item := range validItems {
|
||||||
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
|
bookCards[i] = BookInfo{
|
||||||
|
MediaItemID: itemUUID.String(),
|
||||||
|
Title: item.Title,
|
||||||
|
Author: textToString(item.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookList = bookCards
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
books, booksErr := h.GetCollectionBooksData(c, collectionID)
|
||||||
|
if booksErr != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": booksErr.Error()})
|
||||||
|
}
|
||||||
|
bookList = make([]BookInfo, 0, len(books))
|
||||||
|
for _, book := range books {
|
||||||
|
bookList = append(bookList, BookInfo{
|
||||||
|
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
||||||
|
Title: book.Title,
|
||||||
|
Author: textToString(book.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var viewSettings map[string]interface{}
|
var viewSettings map[string]interface{}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -30,12 +31,13 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
|||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
libraryID := c.QueryParam("library_id")
|
libraryID := c.QueryParam("library_id")
|
||||||
if libraryID == "" {
|
var libUUID pgtype.UUID
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
if libraryID != "" {
|
||||||
}
|
parsed, err := uuid.Parse(libraryID)
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
if err != nil {
|
||||||
if err != nil {
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
}
|
||||||
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||||
@@ -63,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})
|
||||||
}
|
}
|
||||||
@@ -187,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 != "" {
|
||||||
@@ -202,14 +258,15 @@ func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
|
|||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
libraryID := c.QueryParam("library_id")
|
libraryID := c.QueryParam("library_id")
|
||||||
|
|
||||||
if libraryID == "" {
|
var libUUID pgtype.UUID
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
if libraryID != "" {
|
||||||
|
parsed, err := uuid.Parse(libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||||
|
}
|
||||||
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
||||||
}
|
|
||||||
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return default preferences instead of 404 when none exist
|
// Return default preferences instead of 404 when none exist
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+313
-22
@@ -19,6 +19,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 +115,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 +168,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,6 +187,10 @@ func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
|||||||
mh.progressSvc = svc
|
mh.progressSvc = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||||
|
mh.annotationSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -975,6 +1012,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 +1034,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 +1057,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 +1425,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 +1510,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 +1583,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 +1674,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 +1733,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 +1750,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 +2010,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+10
-17
@@ -9,6 +9,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,12 +25,13 @@ func NewSeriesHandler(db *database.Queries) *SeriesHandler {
|
|||||||
|
|
||||||
func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||||
libraryID := c.QueryParam("library_id")
|
libraryID := c.QueryParam("library_id")
|
||||||
if libraryID == "" {
|
var libUUID pgtype.UUID
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
if libraryID != "" {
|
||||||
}
|
parsed, err := uuid.Parse(libraryID)
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
if err != nil {
|
||||||
if err != nil {
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
}
|
||||||
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
limit := 20
|
limit := 20
|
||||||
@@ -81,21 +83,12 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||||
libraryID := c.QueryParam("library_id")
|
|
||||||
if libraryID == "" {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
|
||||||
}
|
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
||||||
}
|
|
||||||
|
|
||||||
seriesName := c.QueryParam("name")
|
seriesName := c.QueryParam("name")
|
||||||
if seriesName == "" {
|
if seriesName == "" {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), libUUID, seriesName)
|
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
|
||||||
}
|
}
|
||||||
@@ -118,7 +111,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID uuid.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||||
svc := services.NewSeriesService(db)
|
svc := services.NewSeriesService(db)
|
||||||
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
|
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
+278
-321
@@ -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"
|
||||||
@@ -111,14 +111,6 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
// Protected frontend routes (no /api prefix)
|
// Protected frontend routes (no /api prefix)
|
||||||
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
|
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
|
||||||
|
|
||||||
// Helper to extract text from pgtype.Text
|
|
||||||
getText := func(t pgtype.Text) string {
|
|
||||||
if t.Valid {
|
|
||||||
return t.String
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Series browse page
|
// Series browse page
|
||||||
frontendProtected.GET("/series", func(c *echo.Context) error {
|
frontendProtected.GET("/series", func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
@@ -128,36 +120,11 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
|
|
||||||
var errorMsg string
|
var errorMsg string
|
||||||
|
|
||||||
libraryID := c.QueryParam("library_id")
|
libRes := resolveLibrary(c, cfg, user.ID)
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
libraryID := libRes.LibraryID
|
||||||
if libraryID == "" {
|
libData := libRes.Libraries
|
||||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
if libRes.IsAll {
|
||||||
if err == nil && len(libraries) > 0 {
|
errorMsg = ""
|
||||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
|
||||||
libraryID = libUUID.String()
|
|
||||||
} else {
|
|
||||||
errorMsg = "No libraries available"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
||||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
||||||
if errorMsg == "" {
|
|
||||||
errorMsg = "Error loading libraries"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libData := make([]templates.LibraryData, len(libraries))
|
|
||||||
for i, lib := range libraries {
|
|
||||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
||||||
libData[i] = templates.LibraryData{
|
|
||||||
ID: libUUID.String(),
|
|
||||||
Name: lib.Name,
|
|
||||||
Description: getText(lib.Description),
|
|
||||||
TypeName: lib.TypeName,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
perSeriesPage := 24
|
perSeriesPage := 24
|
||||||
@@ -172,31 +139,28 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
var seriesCards []templates.SeriesCardData
|
var seriesCards []templates.SeriesCardData
|
||||||
totalPages := 1
|
totalPages := 1
|
||||||
|
|
||||||
if libraryID != "" && errorMsg == "" {
|
if errorMsg == "" {
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, perSeriesPage, offset)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libUUID, perSeriesPage, offset)
|
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||||
if err != nil {
|
errorMsg = "Error loading series"
|
||||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
} else {
|
||||||
errorMsg = "Error loading series"
|
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
||||||
} else {
|
if totalPages < 1 {
|
||||||
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
totalPages = 1
|
||||||
if totalPages < 1 {
|
}
|
||||||
totalPages = 1
|
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
||||||
}
|
for _, s := range seriesList {
|
||||||
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
covers := s.CoverPaths
|
||||||
for _, s := range seriesList {
|
if covers == nil {
|
||||||
covers := s.CoverPaths
|
covers = []string{}
|
||||||
if covers == nil {
|
|
||||||
covers = []string{}
|
|
||||||
}
|
|
||||||
seriesCards = append(seriesCards, templates.SeriesCardData{
|
|
||||||
Name: s.Name,
|
|
||||||
BookCount: s.BookCount,
|
|
||||||
TotalInSeries: s.TotalInSeries,
|
|
||||||
CoverPaths: covers,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
seriesCards = append(seriesCards, templates.SeriesCardData{
|
||||||
|
Name: s.Name,
|
||||||
|
BookCount: s.BookCount,
|
||||||
|
TotalInSeries: s.TotalInSeries,
|
||||||
|
CoverPaths: covers,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,40 +191,23 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return renderErrorPage(c, "Series name required", "bad_request")
|
return renderErrorPage(c, "Series name required", "bad_request")
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryID := c.QueryParam("library_id")
|
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
|
||||||
if libraryID == "" {
|
|
||||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
||||||
if err == nil && len(libraries) > 0 {
|
|
||||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
|
||||||
libraryID = libUUID.String()
|
|
||||||
} else {
|
|
||||||
errorMsg = "No libraries available"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var bookInfoList []handlers.BookInfo
|
var bookInfoList []handlers.BookInfo
|
||||||
|
|
||||||
if libraryID != "" && errorMsg == "" {
|
svc := services.NewSeriesService(cfg.Queries)
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
books, err := svc.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
svc := services.NewSeriesService(cfg.Queries)
|
log.Printf("GetSeriesBooks failed: %v", err)
|
||||||
books, err := svc.GetSeriesBooks(c.Request().Context(), libUUID, seriesName)
|
errorMsg = "Error loading series books"
|
||||||
if err != nil {
|
} else {
|
||||||
log.Printf("GetSeriesBooks failed: %v", err)
|
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
||||||
errorMsg = "Error loading series books"
|
for _, item := range books {
|
||||||
} else {
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
||||||
for _, item := range books {
|
MediaItemID: itemUUID.String(),
|
||||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
Title: item.Title,
|
||||||
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
Author: textToString(item.Author),
|
||||||
MediaItemID: itemUUID.String(),
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||||
Title: item.Title,
|
})
|
||||||
Author: textToString(item.Author),
|
|
||||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,8 +215,11 @@ 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 in this library 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 {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -289,41 +239,29 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return renderErrorPage(c, "Tag name required", "bad_request")
|
return renderErrorPage(c, "Tag name required", "bad_request")
|
||||||
}
|
}
|
||||||
|
|
||||||
libraryID := c.QueryParam("library_id")
|
libRes := resolveLibrary(c, cfg, user.ID)
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
libraryID := libRes.LibraryID
|
||||||
if libraryID == "" {
|
|
||||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
||||||
if err == nil && len(libraries) > 0 {
|
|
||||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
|
||||||
libraryID = libUUID.String()
|
|
||||||
} else {
|
|
||||||
errorMsg = "No libraries available"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var bookInfoList []handlers.BookInfo
|
var bookInfoList []handlers.BookInfo
|
||||||
|
|
||||||
if libraryID != "" && errorMsg == "" {
|
if libraryID != "" && errorMsg == "" {
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
books, err := cfg.Queries.GetBooksByTag(c.Request().Context(), database.GetBooksByTagParams{
|
||||||
if err == nil {
|
LibraryID: libRes.LibUUID,
|
||||||
books, err := cfg.Queries.GetBooksByTag(c.Request().Context(), database.GetBooksByTagParams{
|
Column2: tagName,
|
||||||
LibraryID: uuidToPGType(libUUID),
|
})
|
||||||
Column2: tagName,
|
if err != nil {
|
||||||
})
|
log.Printf("GetBooksByTag failed: %v", err)
|
||||||
if err != nil {
|
errorMsg = "Error loading tag books"
|
||||||
log.Printf("GetBooksByTag failed: %v", err)
|
} else {
|
||||||
errorMsg = "Error loading tag books"
|
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
||||||
} else {
|
for _, item := range books {
|
||||||
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
for _, item := range books {
|
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
||||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
MediaItemID: itemUUID.String(),
|
||||||
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
Title: item.Title,
|
||||||
MediaItemID: itemUUID.String(),
|
Author: textToString(item.Author),
|
||||||
Title: item.Title,
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||||
Author: textToString(item.Author),
|
})
|
||||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,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 {
|
||||||
@@ -348,52 +289,20 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
|
|
||||||
var errorMsg string
|
var errorMsg string
|
||||||
|
|
||||||
// Get library_id from query param or user's first library
|
libRes := resolveLibrary(c, cfg, user.ID)
|
||||||
libraryID := c.QueryParam("library_id")
|
libraryID := libRes.LibraryID
|
||||||
if libraryID == "" {
|
libData := libRes.Libraries
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
|
||||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
||||||
if err == nil && len(libraries) > 0 {
|
|
||||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
|
||||||
libraryID = libUUID.String()
|
|
||||||
} else {
|
|
||||||
errorMsg = "No libraries available"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get libraries for dropdown
|
// Fetch saved filters for SSR
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
userUUID, _ := uuid.Parse(user.ID)
|
||||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
||||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
||||||
if errorMsg == "" {
|
|
||||||
errorMsg = "Error loading libraries"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libData := make([]templates.LibraryData, len(libraries))
|
|
||||||
for i, lib := range libraries {
|
|
||||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
||||||
libData[i] = templates.LibraryData{
|
|
||||||
ID: libUUID.String(),
|
|
||||||
Name: lib.Name,
|
|
||||||
Description: getText(lib.Description),
|
|
||||||
TypeName: lib.TypeName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch saved filters for SSR (using existing query)
|
|
||||||
var savedFilters []database.SavedFilters
|
var savedFilters []database.SavedFilters
|
||||||
if libraryID != "" && errorMsg == "" {
|
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
|
||||||
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
ResourceType: "media-items",
|
||||||
ResourceType: "media-items",
|
})
|
||||||
})
|
if err != nil {
|
||||||
if err != nil {
|
log.Printf("GetSavedFilters failed: %v", err)
|
||||||
log.Printf("GetSavedFilters failed: %v", err)
|
savedFilters = []database.SavedFilters{}
|
||||||
savedFilters = []database.SavedFilters{}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch first page of books for SSR
|
// Fetch first page of books for SSR
|
||||||
@@ -402,68 +311,56 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
limit := 50
|
limit := 50
|
||||||
offset := 0
|
offset := 0
|
||||||
|
|
||||||
if libraryID != "" && errorMsg == "" {
|
if errorMsg == "" {
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
// Check URL params for pagination
|
||||||
if err == nil {
|
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||||||
// Check URL params for pagination
|
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
limit = l
|
||||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
}
|
||||||
limit = l
|
}
|
||||||
|
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
|
||||||
|
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
||||||
|
offset = o
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
params := services.SearchParams{
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
LibraryID: libRes.LibUUID,
|
||||||
|
SearchQuery: "",
|
||||||
|
AuthorFilter: "",
|
||||||
|
SeriesFilter: "",
|
||||||
|
GenreFilter: "",
|
||||||
|
TagsFilter: "",
|
||||||
|
LanguageFilter: "",
|
||||||
|
YearMin: 0,
|
||||||
|
YearMax: 0,
|
||||||
|
HasCover: pgtype.Bool{Valid: false},
|
||||||
|
Sort: "created_at DESC",
|
||||||
|
Limit: limit,
|
||||||
|
Offset: offset,
|
||||||
|
}
|
||||||
|
var results []database.SearchMediaItemsUnifiedRow
|
||||||
|
results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("ExecuteSearch failed: %v", err)
|
||||||
|
} else {
|
||||||
|
bookInfoList = make([]handlers.BookInfo, len(results))
|
||||||
|
for i, book := range results {
|
||||||
|
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
|
||||||
|
bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16])
|
||||||
|
bookInfoList[i] = handlers.BookInfo{
|
||||||
|
MediaItemID: bookUUID.String(),
|
||||||
|
Title: book.Title,
|
||||||
|
Author: textToString(book.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if offsetStr := c.QueryParam("offset"); offsetStr != "" {
|
|
||||||
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
|
|
||||||
offset = o
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Convert user.ID (string) to pgtype.UUID for service layer
|
|
||||||
userUUID, err := uuid.Parse(user.ID)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Failed to parse user ID: %v", err)
|
|
||||||
return renderErrorPage(c, "Error loading user", "user_id_error")
|
|
||||||
}
|
|
||||||
// Build search params (same as search.go:76-91)
|
|
||||||
params := services.SearchParams{
|
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
|
||||||
SearchQuery: "", // Empty for initial SSR load
|
|
||||||
AuthorFilter: "",
|
|
||||||
SeriesFilter: "",
|
|
||||||
GenreFilter: "",
|
|
||||||
TagsFilter: "",
|
|
||||||
LanguageFilter: "",
|
|
||||||
YearMin: 0,
|
|
||||||
YearMax: 0,
|
|
||||||
HasCover: pgtype.Bool{Valid: false},
|
|
||||||
Sort: "created_at DESC",
|
|
||||||
Limit: limit,
|
|
||||||
Offset: offset,
|
|
||||||
}
|
|
||||||
// Execute search using the same handler as API (search.go:93)
|
|
||||||
var results []database.SearchMediaItemsUnifiedRow
|
|
||||||
results, totalCount, err = cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("ExecuteSearch failed: %v", err)
|
|
||||||
// Continue without books - will show empty state
|
|
||||||
} else {
|
|
||||||
// Convert to BookInfo (same as search.go:99-109)
|
|
||||||
bookInfoList = make([]handlers.BookInfo, len(results))
|
|
||||||
for i, book := range results {
|
|
||||||
bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
|
|
||||||
bookLibUUID, _ := uuid.FromBytes(book.LibraryID.Bytes[0:16])
|
|
||||||
bookInfoList[i] = handlers.BookInfo{
|
|
||||||
MediaItemID: bookUUID.String(),
|
|
||||||
Title: book.Title,
|
|
||||||
Author: textToString(book.Author),
|
|
||||||
CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log.Printf("SSR: fetched %d books for library %s", len(bookInfoList), libraryID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
@@ -480,20 +377,13 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
|
|
||||||
var errorMsg string
|
var errorMsg string
|
||||||
|
|
||||||
libraryID := c.QueryParam("library_id")
|
libRes := resolveLibrary(c, cfg, user.ID)
|
||||||
if libraryID == "" {
|
libraryID := libRes.LibraryID
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
|
||||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
||||||
if err == nil && len(libraries) > 0 {
|
|
||||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
|
||||||
libraryID = libUUID.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libUUID, _ := uuid.Parse(libraryID)
|
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
userUUID, _ := uuid.Parse(user.ID)
|
||||||
|
pgLibUUID := libRes.LibUUID
|
||||||
|
|
||||||
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
prefs, err := cfg.DashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, pgLibUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("GetDashboardPreferences failed: %v", err)
|
log.Printf("GetDashboardPreferences failed: %v", err)
|
||||||
prefs = database.UserDashboardPreferences{
|
prefs = database.UserDashboardPreferences{
|
||||||
@@ -511,7 +401,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
allSections, err := cfg.DashboardService.GetDashboardSections(
|
allSections, err := cfg.DashboardService.GetDashboardSections(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
userUUID,
|
userUUID,
|
||||||
libUUID,
|
pgLibUUID,
|
||||||
limit,
|
limit,
|
||||||
prefs.CollectionOrder,
|
prefs.CollectionOrder,
|
||||||
[]string{}, // No filtering - get all sections
|
[]string{}, // No filtering - get all sections
|
||||||
@@ -525,32 +415,12 @@ 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)
|
||||||
|
|
||||||
userUUID2, _ := uuid.Parse(user.ID)
|
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
|
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
||||||
if err != nil {
|
|
||||||
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
|
||||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
|
||||||
if errorMsg == "" {
|
|
||||||
errorMsg = "Error loading libraries"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libData := make([]templates.LibraryData, len(libraries))
|
|
||||||
for i, lib := range libraries {
|
|
||||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
|
||||||
libData[i] = templates.LibraryData{
|
|
||||||
ID: libUUID.String(),
|
|
||||||
Name: lib.Name,
|
|
||||||
Description: getText(lib.Description),
|
|
||||||
TypeName: lib.TypeName,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sectionData := handlers.BuildSections(visibleSections, libraryID)
|
|
||||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Dashboard(user, sectionData, allSectionsData, libData, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
|
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, errorMsg).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -674,30 +544,18 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
userUUID, _ := uuid.Parse(user.ID)
|
userUUID, _ := uuid.Parse(user.ID)
|
||||||
var books []handlers.BookInfo
|
var books []handlers.BookInfo
|
||||||
|
|
||||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
libRes := resolveLibrary(c, cfg, user.ID)
|
||||||
// System collection - use query type
|
libraryID := libRes.LibraryID
|
||||||
// System collection - need library_id for system collections
|
|
||||||
// Get library_id from query param or default to user's first library
|
|
||||||
libraryID := c.QueryParam("library_id")
|
|
||||||
if libraryID == "" {
|
|
||||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID))
|
|
||||||
if err == nil && len(libraries) > 0 {
|
|
||||||
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
|
||||||
libraryID = libUUID.String()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libUUID, _ := uuid.Parse(libraryID)
|
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||||
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
||||||
sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
|
||||||
if err != nil {
|
if secErr != nil {
|
||||||
return renderErrorPage(c, "Error loading books", "books_load_error")
|
return renderErrorPage(c, "Error loading books", "books_load_error")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the matching section and convert items
|
|
||||||
for _, section := range sections {
|
for _, section := range sections {
|
||||||
if section.CollectionID.String() == collectionID {
|
if section.CollectionID.String() == collectionID {
|
||||||
// Convert []database.MediaItems to []handlers.BookInfo
|
|
||||||
bookCards := make([]handlers.BookInfo, len(section.Items))
|
bookCards := make([]handlers.BookInfo, len(section.Items))
|
||||||
for i, item := range section.Items {
|
for i, item := range section.Items {
|
||||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
@@ -713,27 +571,21 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// User collection - check if library_id filter is present
|
if libraryID != "" && !libRes.IsAll {
|
||||||
libraryID := c.QueryParam("library_id")
|
libUUID, parseErr := uuid.Parse(libraryID)
|
||||||
|
if parseErr != nil {
|
||||||
if libraryID != "" {
|
|
||||||
// Filter by library - reuse dashboard query
|
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
|
||||||
if err != nil {
|
|
||||||
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
|
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use GetCollectionItemsForDashboard for library-filtered results
|
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||||
collItems, err := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
|
||||||
database.GetCollectionItemsForDashboardParams{
|
database.GetCollectionItemsForDashboardParams{
|
||||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
Limit: 1000,
|
Limit: pgtype.Int4{Int32: 1000, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if collErr != nil {
|
||||||
books = []handlers.BookInfo{}
|
books = []handlers.BookInfo{}
|
||||||
} else {
|
} else {
|
||||||
// Convert to BookInfo format (non-excluded only)
|
|
||||||
var validItems []database.GetCollectionItemsForDashboardRow
|
var validItems []database.GetCollectionItemsForDashboardRow
|
||||||
for _, item := range collItems {
|
for _, item := range collItems {
|
||||||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||||
@@ -754,13 +606,11 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
books = bookCards
|
books = bookCards
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No library filter - show all books in collection
|
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||||
collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
if collErr != nil {
|
||||||
if err != nil {
|
|
||||||
books = []handlers.BookInfo{}
|
books = []handlers.BookInfo{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to BookInfo format
|
|
||||||
bookCards := make([]handlers.BookInfo, len(collItems))
|
bookCards := make([]handlers.BookInfo, len(collItems))
|
||||||
for i, item := range collItems {
|
for i, item := range collItems {
|
||||||
itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
|
itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
|
||||||
@@ -781,13 +631,11 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get library_id from query params for template
|
|
||||||
libraryID := c.QueryParam("library_id")
|
|
||||||
// Render the CollectionDetail template
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.CollectionDetail(user, colData, books, libraryID).Render(c.Request().Context(), &buf)
|
err = templates.CollectionDetail(user, colData, books, libraryID, libRes.Libraries).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -886,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)
|
||||||
@@ -966,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
|
||||||
}
|
}
|
||||||
@@ -979,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
|
||||||
}
|
}
|
||||||
@@ -1004,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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,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)
|
||||||
@@ -1146,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
|
||||||
}
|
}
|
||||||
@@ -1205,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)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1238,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)
|
||||||
@@ -1373,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
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ package router
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/database"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -64,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
|
||||||
@@ -86,3 +89,89 @@ func parseUUID(s string) (uuid.UUID, error) {
|
|||||||
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||||||
return pgtype.UUID{Bytes: u, Valid: true}
|
return pgtype.UUID{Bytes: u, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedLibraryCookie = "selectedLibrary"
|
||||||
|
const allLibrariesSentinel = "__all__"
|
||||||
|
|
||||||
|
type LibraryResolution struct {
|
||||||
|
LibraryID string
|
||||||
|
IsAll bool
|
||||||
|
LibUUID pgtype.UUID
|
||||||
|
Libraries []templates.LibraryData
|
||||||
|
FirstID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func getText(t pgtype.Text) string {
|
||||||
|
if t.Valid {
|
||||||
|
return t.String
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolution {
|
||||||
|
res := LibraryResolution{}
|
||||||
|
|
||||||
|
userU, _ := uuid.Parse(userUUID)
|
||||||
|
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userU))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetUserVisibleLibraries failed: %v", err)
|
||||||
|
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))
|
||||||
|
for i, lib := range libraries {
|
||||||
|
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||||
|
res.Libraries[i] = templates.LibraryData{
|
||||||
|
ID: libUUID.String(),
|
||||||
|
Name: lib.Name,
|
||||||
|
Description: getText(lib.Description),
|
||||||
|
TypeName: lib.TypeName,
|
||||||
|
MediaCount: countMap[libUUID.String()],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(libraries) > 0 {
|
||||||
|
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
|
||||||
|
res.FirstID = libUUID.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryID := c.QueryParam("library_id")
|
||||||
|
if libraryID == "" {
|
||||||
|
if cookie, err := c.Cookie(selectedLibraryCookie); err == nil {
|
||||||
|
val, _ := url.QueryUnescape(cookie.Value)
|
||||||
|
if val == allLibrariesSentinel {
|
||||||
|
res.IsAll = true
|
||||||
|
res.LibraryID = ""
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
if _, parseErr := uuid.Parse(val); parseErr == nil {
|
||||||
|
libraryID = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if libraryID == "" {
|
||||||
|
res.LibraryID = res.FirstID
|
||||||
|
if res.LibraryID != "" {
|
||||||
|
parsed, _ := uuid.Parse(res.LibraryID)
|
||||||
|
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
|
}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
res.LibraryID = libraryID
|
||||||
|
parsed, _ := uuid.Parse(libraryID)
|
||||||
|
res.LibUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|||||||
@@ -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{}{
|
||||||
|
|||||||
@@ -41,6 +41,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
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// defaultConversionCacheTTL is the fallback kepub cache lifetime when no
|
||||||
|
// settings registry is wired. Matches the historical hardcoded 24h.
|
||||||
|
const defaultConversionCacheTTL = 24 * time.Hour
|
||||||
|
|
||||||
type ConvertedKEPUB struct {
|
type ConvertedKEPUB struct {
|
||||||
Path string
|
Path string
|
||||||
SHA256 string
|
SHA256 string
|
||||||
@@ -27,6 +31,7 @@ type ConversionService struct {
|
|||||||
cacheDir string
|
cacheDir string
|
||||||
conversionTool string
|
conversionTool string
|
||||||
conversionCacheTTL time.Duration
|
conversionCacheTTL time.Duration
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
|
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
|
||||||
@@ -34,17 +39,32 @@ func NewConversionService(db *database.Queries, cacheDir string) *ConversionServ
|
|||||||
db: db,
|
db: db,
|
||||||
cacheDir: cacheDir,
|
cacheDir: cacheDir,
|
||||||
conversionTool: "/usr/bin/kepubify",
|
conversionTool: "/usr/bin/kepubify",
|
||||||
conversionCacheTTL: 24 * time.Hour,
|
conversionCacheTTL: defaultConversionCacheTTL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry. When wired, the cache TTL
|
||||||
|
// is read live on each conversion request.
|
||||||
|
func (s *ConversionService) SetSettings(reg *database.SettingsRegistry) { s.settings = reg }
|
||||||
|
|
||||||
|
// cacheTTL returns the active conversion cache TTL.
|
||||||
|
func (s *ConversionService) cacheTTL() time.Duration {
|
||||||
|
if s.settings != nil {
|
||||||
|
return s.settings.ConversionCacheTTL()
|
||||||
|
}
|
||||||
|
if s.conversionCacheTTL > 0 {
|
||||||
|
return s.conversionCacheTTL
|
||||||
|
}
|
||||||
|
return defaultConversionCacheTTL
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) {
|
func (s *ConversionService) ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*ConvertedKEPUB, error) {
|
||||||
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
FormatType: "kepub",
|
FormatType: "kepub",
|
||||||
})
|
})
|
||||||
if err == nil && existing.FilePath.Valid {
|
if err == nil && existing.FilePath.Valid {
|
||||||
if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL {
|
if time.Since(existing.CreatedAt.Time) < s.cacheTTL() {
|
||||||
return &ConvertedKEPUB{
|
return &ConvertedKEPUB{
|
||||||
Path: existing.FilePath.String,
|
Path: existing.FilePath.String,
|
||||||
SHA256: existing.FileSha256.String,
|
SHA256: existing.FileSha256.String,
|
||||||
|
|||||||
@@ -65,5 +65,7 @@ func TestConversionServiceDefaults(t *testing.T) {
|
|||||||
assert.NotNil(t, service)
|
assert.NotNil(t, service)
|
||||||
assert.Equal(t, cacheDir, service.cacheDir)
|
assert.Equal(t, cacheDir, service.cacheDir)
|
||||||
assert.Equal(t, "/usr/bin/kepubify", service.conversionTool)
|
assert.Equal(t, "/usr/bin/kepubify", service.conversionTool)
|
||||||
assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL should be 24 hours")
|
assert.Equal(t, int64(24*3600*1000000000), service.conversionCacheTTL.Nanoseconds(), "Default TTL field should be 24 hours")
|
||||||
|
// cacheTTL() must reflect the same default when no registry is wired.
|
||||||
|
assert.Equal(t, int64(24*3600*1000000000), service.cacheTTL().Nanoseconds(), "Default TTL accessor should return 24 hours")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,8 @@ type DashboardSection struct {
|
|||||||
|
|
||||||
func (s *DashboardService) GetDashboardSections(
|
func (s *DashboardService) GetDashboardSections(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, libraryID uuid.UUID,
|
userID uuid.UUID,
|
||||||
|
libraryID pgtype.UUID,
|
||||||
limit int,
|
limit int,
|
||||||
collectionOrder []string,
|
collectionOrder []string,
|
||||||
hiddenCollections []string,
|
hiddenCollections []string,
|
||||||
@@ -267,36 +268,36 @@ func (s *DashboardService) sortByPriority(sections []DashboardSection) []Dashboa
|
|||||||
return sorted
|
return sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
|
func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
||||||
switch coll.QueryType.String {
|
switch coll.QueryType.String {
|
||||||
case "continue-reading":
|
case "continue-reading":
|
||||||
return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
|
return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
LibraryID: libraryID,
|
||||||
Limit: int32(limit),
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||||
})
|
})
|
||||||
case "recently-added":
|
case "recently-added":
|
||||||
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
|
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
LibraryID: libraryID,
|
||||||
Limit: int32(limit),
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||||
})
|
})
|
||||||
case "recently-read":
|
case "recently-read":
|
||||||
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
|
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
LibraryID: libraryID,
|
||||||
Limit: int32(limit),
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||||
})
|
})
|
||||||
case "not-started":
|
case "not-started":
|
||||||
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
|
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
LibraryID: libraryID,
|
||||||
Limit: int32(limit),
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||||
})
|
})
|
||||||
case "continue-series":
|
case "continue-series":
|
||||||
rows, err := s.db.GetContinueSeriesItems(ctx, database.GetContinueSeriesItemsParams{
|
rows, err := s.db.GetContinueSeriesItems(ctx, database.GetContinueSeriesItemsParams{
|
||||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
LibraryID: libraryID,
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
Limit: int32(limit),
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -311,13 +312,13 @@ func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
|
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
||||||
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
||||||
|
|
||||||
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
||||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
LibraryID: libraryID,
|
||||||
Limit: int32(limit),
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -334,7 +335,7 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
|||||||
if len(coll.AutoAssignRules) > 0 {
|
if len(coll.AutoAssignRules) > 0 {
|
||||||
var rules []Rule
|
var rules []Rule
|
||||||
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
|
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 {
|
||||||
allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true})
|
allLibraryItems, err := s.db.GetLibraryItems(ctx, libraryID)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, item := range allLibraryItems {
|
for _, item := range allLibraryItems {
|
||||||
alreadyInCollection := false
|
alreadyInCollection := false
|
||||||
@@ -374,10 +375,10 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
|||||||
return finalItems, nil
|
return finalItems, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
|
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
||||||
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
LibraryID: libraryID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HashBackfillService is a one-time self-heal pass that computes and stores the
|
||||||
|
// SHA-256 for media items imported before hashing existed (file_sha256 IS
|
||||||
|
// NULL). It runs once shortly after startup, independently of auto-scan, and
|
||||||
|
// also performs a final conflict sweep that flags any content-duplicate groups
|
||||||
|
// (same library + SHA-256 at different paths) on the admin Hash Conflicts page.
|
||||||
|
//
|
||||||
|
// The sweep runs after the per-item pass because during the pass only one side
|
||||||
|
// of a preexisting duplicate pair may be hashed at a time - the group only
|
||||||
|
// becomes visible once every item has its hash.
|
||||||
|
type HashBackfillService struct {
|
||||||
|
db *database.Queries
|
||||||
|
libSvc *LibraryService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHashBackfillService creates a backfill service.
|
||||||
|
func NewHashBackfillService(db *database.Queries) *HashBackfillService {
|
||||||
|
return &HashBackfillService{db: db, libSvc: NewLibraryService(db)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run performs the backfill pass followed by the conflict sweep. It logs
|
||||||
|
// progress and never returns an error - failures on individual items are
|
||||||
|
// skipped so one unreadable file cannot block the rest.
|
||||||
|
func (s *HashBackfillService) Run(ctx context.Context) {
|
||||||
|
items, err := s.db.ListMediaItemsMissingHash(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] failed to list items missing hash: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
log.Printf("[HASH-BACKFILL] all media items already hashed, nothing to do")
|
||||||
|
s.sweepConflicts(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[HASH-BACKFILL] computing SHA-256 for %d unhashed media items", len(items))
|
||||||
|
started := time.Now()
|
||||||
|
hashed, failed := 0, 0
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] cancelled after %d items", hashed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := s.libSvc.ResolveMediaPath(ctx, item.LibraryID, item.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] could not resolve path for %q: %v", item.FilePath, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
sha, err := computeFileSHA256(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] could not hash %q: %v", path, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.UpdateMediaItemIdentifiers(ctx, database.UpdateMediaItemIdentifiersParams{
|
||||||
|
ID: item.ID,
|
||||||
|
FileSha256: pgtype.Text{String: sha, Valid: true},
|
||||||
|
HashConfidence: pgtype.Text{String: "sha256_full", Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] could not store hash for %q: %v", item.FilePath, err)
|
||||||
|
failed++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hashed++
|
||||||
|
|
||||||
|
if hashed%25 == 0 {
|
||||||
|
log.Printf("[HASH-BACKFILL] progress: %d/%d hashed", hashed, len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[HASH-BACKFILL] done in %s: %d hashed, %d failed (of %d)",
|
||||||
|
time.Since(started).Round(time.Second), hashed, failed, len(items))
|
||||||
|
|
||||||
|
s.sweepConflicts(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepConflicts flags every content-duplicate group (same library + SHA-256,
|
||||||
|
// more than one item) as a pending hash conflict. The upsert is a no-op for
|
||||||
|
// groups that are already tracked or resolved, so admins who chose "keep both"
|
||||||
|
// are never re-prompted.
|
||||||
|
func (s *HashBackfillService) sweepConflicts(ctx context.Context) {
|
||||||
|
groups, err := s.db.FindHashConflictGroups(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] conflict sweep failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(groups) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
flagged := 0
|
||||||
|
for _, g := range groups {
|
||||||
|
if err := s.db.CreateHashConflict(ctx, database.CreateHashConflictParams{
|
||||||
|
LibraryID: g.LibraryID,
|
||||||
|
FileSha256: g.FileSha256.String,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("[HASH-BACKFILL] could not record conflict group: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flagged++
|
||||||
|
}
|
||||||
|
log.Printf("[HASH-BACKFILL] flagged %d content-duplicate group(s) for admin review", flagged)
|
||||||
|
}
|
||||||
@@ -146,16 +146,18 @@ type CalibreOPFMetadata struct {
|
|||||||
Timestamp *time.Time
|
Timestamp *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMediaScanner creates a new media scanner instance
|
// NewMediaScanner creates a new media scanner instance.
|
||||||
|
//
|
||||||
|
// The fsnotify watcher is NOT created here. It is created lazily inside
|
||||||
|
// SetFolders only when watch=true (the long-lived watch-mode scanner).
|
||||||
|
// Ephemeral one-off scan jobs pass watch=false, so they never allocate a
|
||||||
|
// watcher (and thus can never panic on EMFILE/ENOSPC). This fixes the
|
||||||
|
// fd/inotify-watch leak where every scan job created a watcher that was
|
||||||
|
// never closed.
|
||||||
func NewMediaScanner(db *database.Queries) *MediaScanner {
|
func NewMediaScanner(db *database.Queries) *MediaScanner {
|
||||||
watcher, err := fsnotify.NewWatcher()
|
|
||||||
if err != nil {
|
|
||||||
panic(fmt.Sprintf("Failed to create file watcher: %v", err))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &MediaScanner{
|
return &MediaScanner{
|
||||||
db: db,
|
db: db,
|
||||||
watcher: watcher,
|
watcher: nil,
|
||||||
settingsCache: NewSettingsCache(30 * time.Second),
|
settingsCache: NewSettingsCache(30 * time.Second),
|
||||||
dirtyDirs: make(map[string]time.Time),
|
dirtyDirs: make(map[string]time.Time),
|
||||||
fileStability: make(map[string]*atomic.Bool),
|
fileStability: make(map[string]*atomic.Bool),
|
||||||
@@ -238,24 +240,34 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
|||||||
return s.totalFiles, s.newItems, s.errors
|
return s.totalFiles, s.newItems, s.errors
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) SetFolders(folders []string) error {
|
// SetFolders configures the scanner's folders and (optionally) sets up an
|
||||||
|
// fsnotify watcher over the full directory tree.
|
||||||
|
//
|
||||||
|
// watch should be true only for the single long-lived watch-mode scanner that
|
||||||
|
// actually consumes watcher.Events. Ephemeral scan jobs must pass false so no
|
||||||
|
// watcher (and thus no fd/inotify watches) is allocated — the watcher is never
|
||||||
|
// read by scan jobs and previously leaked one watcher per job.
|
||||||
|
func (s *MediaScanner) SetFolders(folders []string, watch bool) error {
|
||||||
s.folders = folders
|
s.folders = folders
|
||||||
|
|
||||||
// Remove old watch if exists
|
// Always close any previously-owned watcher so reconfiguration doesn't leak.
|
||||||
if s.watcher != nil {
|
if s.watcher != nil {
|
||||||
if s.watcher != nil {
|
if err := s.watcher.Close(); err != nil {
|
||||||
if err := s.watcher.Close(); err != nil {
|
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
s.watcher = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new watcher
|
// Create + populate a fresh watcher only when the caller intends to read events.
|
||||||
watcher, err := fsnotify.NewWatcher()
|
if watch {
|
||||||
if err != nil {
|
watcher, err := fsnotify.NewWatcher()
|
||||||
return fmt.Errorf("failed to create watcher: %v", err)
|
if err != nil {
|
||||||
|
// Return an error instead of panicking so a failed watcher can't
|
||||||
|
// take down the whole process.
|
||||||
|
return fmt.Errorf("failed to create watcher: %w", err)
|
||||||
|
}
|
||||||
|
s.watcher = watcher
|
||||||
}
|
}
|
||||||
s.watcher = watcher
|
|
||||||
|
|
||||||
// Build cache of allowed extensions per folder
|
// Build cache of allowed extensions per folder
|
||||||
// Uses Go AllowedExtensions map as source of truth (not DB)
|
// Uses Go AllowedExtensions map as source of truth (not DB)
|
||||||
@@ -286,31 +298,36 @@ func (s *MediaScanner) SetFolders(folders []string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add all folders and their subdirectories to the watcher (like Audiobookshelf)
|
// Add all folders and their subdirectories to the watcher (like Audiobookshelf).
|
||||||
watchCount := 0
|
// Only when watching; scan jobs (watch=false) skip this entirely.
|
||||||
for _, folder := range folders {
|
if s.watcher != nil {
|
||||||
if err := s.watcher.Add(folder); err != nil {
|
watchCount := 0
|
||||||
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
|
for _, folder := range folders {
|
||||||
} else {
|
if err := s.watcher.Add(folder); err != nil {
|
||||||
watchCount++
|
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
|
||||||
}
|
|
||||||
filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !d.IsDir() || path == folder {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := s.watcher.Add(path); err != nil {
|
|
||||||
fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err)
|
|
||||||
} else {
|
} else {
|
||||||
watchCount++
|
watchCount++
|
||||||
}
|
}
|
||||||
return nil
|
filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||||||
})
|
if err != nil {
|
||||||
}
|
return err
|
||||||
|
}
|
||||||
|
if !d.IsDir() || path == folder {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.watcher.Add(path); err != nil {
|
||||||
|
fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||||
|
} else {
|
||||||
|
watchCount++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders))
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[SCANNER] Configured %d root folders (watch mode disabled, no inotify watcher)\n", len(folders))
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -417,8 +434,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
if err := s.watcher.Add(path); err != nil {
|
if s.watcher != nil {
|
||||||
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
if err := s.watcher.Add(path); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -680,14 +699,24 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
if err := s.updateMediaItem(ctx, existingItem.ID, path, info); err != nil {
|
if err := s.updateMediaItem(ctx, existingItem.ID, path, info); err != nil {
|
||||||
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
fmt.Printf("Warning: failed to update existing media item: %v\n", err)
|
||||||
}
|
}
|
||||||
|
// Recompute hash identifiers too - a force rescan is the admin's
|
||||||
|
// backfill tool and must refresh stale or missing hashes.
|
||||||
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
||||||
return false, nil
|
return false, nil
|
||||||
} else {
|
} else {
|
||||||
// Normal behavior: check if file has changed (by size)
|
// Normal behavior: check if file has changed (by size)
|
||||||
if existingItem.FileSize.Int64 != info.Size() {
|
if existingItem.FileSize.Int64 != info.Size() {
|
||||||
fmt.Printf("File size changed, updating media item: %s\n", path)
|
fmt.Printf("File size changed, updating media item: %s\n", path)
|
||||||
_ = s.updateMediaItem(ctx, existingItem.ID, path, info)
|
_ = s.updateMediaItem(ctx, existingItem.ID, path, info)
|
||||||
|
// The bytes changed, so any stored hash is stale.
|
||||||
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
// Self-heal items imported before hashing existed: even an unchanged
|
||||||
|
// file gets its hash computed if missing.
|
||||||
|
if !existingItem.FileSha256.Valid || existingItem.FileSha256.String == "" {
|
||||||
|
s.recomputeHashInfo(ctx, existingItem.ID, libraryID, path)
|
||||||
|
}
|
||||||
fmt.Printf("Media item already exists with same size, skipping: %s\n", path)
|
fmt.Printf("Media item already exists with same size, skipping: %s\n", path)
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
@@ -716,6 +745,26 @@ func (s *MediaScanner) processMediaFile(ctx context.Context, path string) (bool,
|
|||||||
path, hashInfo.FileSHA256, hashInfo.OPFIdentifier, hashInfo.OPFUUID, hashInfo.HashConfidence)
|
path, hashInfo.FileSHA256, hashInfo.OPFIdentifier, hashInfo.OPFUUID, hashInfo.HashConfidence)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content dedup: if an item with the same SHA-256 already exists in this
|
||||||
|
// library (same file at a different path), treat it as existing rather than
|
||||||
|
// creating a duplicate. The file bytes are identical, so metadata matches.
|
||||||
|
if hashInfo.FileSHA256 != "" {
|
||||||
|
existingByHash, err := s.db.GetMediaItemBySHA256AndLibrary(ctx, database.GetMediaItemBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: hashInfo.FileSHA256, Valid: true},
|
||||||
|
LibraryID: libraryID,
|
||||||
|
})
|
||||||
|
if err == nil && existingByHash.ID.Valid {
|
||||||
|
fmt.Printf("Media item with same SHA-256 already exists in library (path %q), skipping duplicate: %s\n",
|
||||||
|
existingByHash.FilePath, path)
|
||||||
|
if s.forceRescan {
|
||||||
|
_ = s.updateMediaItem(ctx, existingByHash.ID, path, info)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
} else if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
fmt.Printf("Warning: failed to check media item by SHA-256 for %s: %v\n", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// REMOVED: Comic metadata extraction now handled by mergeMetadata()
|
// REMOVED: Comic metadata extraction now handled by mergeMetadata()
|
||||||
// This avoids duplicate extraction and ensures smart merging happens
|
// This avoids duplicate extraction and ensures smart merging happens
|
||||||
|
|
||||||
@@ -2576,6 +2625,68 @@ func (s *MediaScanner) getMediaItemByFilePath(ctx context.Context, filePath stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recomputeHashInfo recomputes the file's hash identifiers and stores them on
|
||||||
|
// the media item (plus its per-format row). Called on force rescan, on file
|
||||||
|
// size change, and when an unchanged item is found with no stored hash, so
|
||||||
|
// items imported before hashing existed are backfilled by ordinary scans.
|
||||||
|
// After storing, it records a hash conflict if the same content now exists at
|
||||||
|
// more than one path in the library.
|
||||||
|
func (s *MediaScanner) recomputeHashInfo(ctx context.Context, mediaItemID pgtype.UUID, libraryID pgtype.UUID, path string) {
|
||||||
|
hashInfo, formatInfo, err := s.extractHashInfo(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to extract hash info from %s: %v\n", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hashInfo == nil || hashInfo.FileSHA256 == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.db.UpdateMediaItemIdentifiers(ctx, database.UpdateMediaItemIdentifiersParams{
|
||||||
|
ID: mediaItemID,
|
||||||
|
FileSha256: pgtype.Text{String: hashInfo.FileSHA256, Valid: true},
|
||||||
|
OpfIdentifier: pgtype.Text{String: hashInfo.OPFIdentifier, Valid: hashInfo.OPFIdentifier != ""},
|
||||||
|
OpfUuid: pgtype.Text{String: hashInfo.OPFUUID, Valid: hashInfo.OPFUUID != ""},
|
||||||
|
HashConfidence: pgtype.Text{String: hashInfo.HashConfidence, Valid: hashInfo.HashConfidence != ""},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Warning: failed to update hash identifiers for %s: %v\n", path, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if formatInfo != nil {
|
||||||
|
_, _ = s.db.CreateMediaItemFormat(ctx, database.CreateMediaItemFormatParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
FormatType: formatInfo.FormatType,
|
||||||
|
FilePath: pgtype.Text{String: s.getRelativePath(formatInfo.FilePath), Valid: true},
|
||||||
|
FileSha256: pgtype.Text{String: formatInfo.FileSHA256, Valid: true},
|
||||||
|
FileSizeBytes: pgtype.Int8{Int64: formatInfo.FileSizeBytes, Valid: true},
|
||||||
|
MimeType: pgtype.Text{String: formatInfo.MimeType, Valid: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
s.recordHashConflictIfAny(ctx, libraryID, hashInfo.FileSHA256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordHashConflictIfAny flags a pending hash conflict when the given content
|
||||||
|
// hash is now shared by more than one media item in the same library. The
|
||||||
|
// upsert is a no-op for already-tracked (including resolved) groups.
|
||||||
|
func (s *MediaScanner) recordHashConflictIfAny(ctx context.Context, libraryID pgtype.UUID, fileSHA256 string) {
|
||||||
|
items, err := s.db.ListMediaItemsBySHA256AndLibrary(ctx, database.ListMediaItemsBySHA256AndLibraryParams{
|
||||||
|
FileSha256: pgtype.Text{String: fileSHA256, Valid: true},
|
||||||
|
LibraryID: libraryID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(items) > 1 {
|
||||||
|
fmt.Printf("Hash conflict: %d media items share SHA-256 %s in one library\n", len(items), fileSHA256)
|
||||||
|
_ = s.db.CreateHashConflict(ctx, database.CreateHashConflictParams{
|
||||||
|
LibraryID: libraryID,
|
||||||
|
FileSha256: fileSHA256,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) getMimeType(path string) string {
|
func (s *MediaScanner) getMimeType(path string) string {
|
||||||
ext := strings.ToLower(filepath.Ext(path))
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
if mime, ok := MimeTypes[ext]; ok {
|
if mime, ok := MimeTypes[ext]; ok {
|
||||||
@@ -2601,6 +2712,13 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error {
|
|||||||
go s.startBackupScan(ctx)
|
go s.startBackupScan(ctx)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
// The event loop only runs if a real watcher was set up (watch=true).
|
||||||
|
// If watching with no watcher (e.g. inotify unavailable through a Docker
|
||||||
|
// bind mount), polling via startBackupScan above still handles detection.
|
||||||
|
if s.watcher == nil {
|
||||||
|
fmt.Printf("[WATCHER] No inotify watcher configured; relying on periodic polling for change detection\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
|
fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders))
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -2992,6 +3110,12 @@ func (s *MediaScanner) startBackupScan(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *MediaScanner) calculateFileSHA256(filePath string) (string, error) {
|
func (s *MediaScanner) calculateFileSHA256(filePath string) (string, error) {
|
||||||
|
return computeFileSHA256(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// computeFileSHA256 is the package-level full-file SHA-256 used by the hash
|
||||||
|
// backfill service; the MediaScanner method delegates to it.
|
||||||
|
func computeFileSHA256(filePath string) (string, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := os.Open(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to open file: %v", err)
|
return "", fmt.Errorf("failed to open file: %v", err)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user