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
|
||||
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)
|
||||
# Test Mode: WARNING - Only set to true for integration testing
|
||||
# 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)
|
||||
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 && \
|
||||
rm /tmp/sqlc.tar.gz
|
||||
tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \
|
||||
rm /tmp/sqlc.tar.gz
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
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*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
npm install
|
||||
|
||||
# Copy Go mod files (cached unless go.mod changes)
|
||||
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)
|
||||
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
|
||||
# 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)
|
||||
# Ignore if .env doesn't exist yet
|
||||
@@ -7,6 +7,14 @@ ifneq (,$(wildcard ./.env))
|
||||
export
|
||||
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
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@@ -36,6 +44,9 @@ help:
|
||||
@echo "Verification:"
|
||||
@echo " make verify-guidelines - Run comprehensive 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)
|
||||
test:
|
||||
@@ -44,23 +55,23 @@ test:
|
||||
# Run integration tests in containers (matches production environment)
|
||||
test-integration:
|
||||
@echo "Building test containers..."
|
||||
podman compose --profile tests build
|
||||
$(COMPOSE) --profile tests build
|
||||
@echo "Starting application containers..."
|
||||
podman compose up -d db app
|
||||
$(COMPOSE) up -d db app
|
||||
@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..."; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
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..."; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
echo " ✓ Application is ready"
|
||||
@echo ""
|
||||
@echo "Running integration tests in container..."
|
||||
podman compose --profile tests run --rm tests
|
||||
$(COMPOSE) --profile tests run --rm tests
|
||||
@echo ""
|
||||
@echo "✅ Integration tests completed!"
|
||||
@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:
|
||||
@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"
|
||||
|
||||
# Rebuild app container only (preserve DB, no cache)
|
||||
rebuild-app-force:
|
||||
@echo "Force rebuilding app container (database stays running, no cache)..."
|
||||
podman compose build --no-cache app
|
||||
podman compose up --force-recreate -d app
|
||||
$(COMPOSE) build --no-cache app
|
||||
$(COMPOSE) up --force-recreate -d app
|
||||
@echo "✓ App container rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (preserve DB, with cache)
|
||||
rebuild:
|
||||
@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"
|
||||
|
||||
# Rebuild all containers (preserve DB, no cache)
|
||||
rebuild-force:
|
||||
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
||||
podman compose build --no-cache
|
||||
podman compose up --force-recreate -d
|
||||
$(COMPOSE) build --no-cache
|
||||
$(COMPOSE) up --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (remove DB, no cache)
|
||||
rebuild-force-db:
|
||||
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
||||
podman compose down -v
|
||||
podman compose build --no-cache
|
||||
podman compose up --force-recreate -d
|
||||
$(COMPOSE) down -v
|
||||
$(COMPOSE) build --no-cache
|
||||
$(COMPOSE) up --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Stop and remove containers
|
||||
clean:
|
||||
podman compose down -v
|
||||
$(COMPOSE) down -v
|
||||
|
||||
# Quick start (if already built)
|
||||
up:
|
||||
podman compose up -d
|
||||
$(COMPOSE) up -d
|
||||
|
||||
# Stop all containers (alias for clean)
|
||||
down:
|
||||
podman compose down
|
||||
$(COMPOSE) down
|
||||
|
||||
# Restart app container (preserves database)
|
||||
restart:
|
||||
@echo "Restarting app container (database stays running)..."
|
||||
podman compose restart app
|
||||
$(COMPOSE) restart app
|
||||
@echo "✓ App container restarted"
|
||||
|
||||
# Show container status
|
||||
ps:
|
||||
podman compose ps
|
||||
$(COMPOSE) ps
|
||||
|
||||
# Show container logs
|
||||
logs:
|
||||
podman compose logs -f
|
||||
$(COMPOSE) logs -f
|
||||
|
||||
# Start containers with test mode enabled for manual testing
|
||||
test-env-up:
|
||||
@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..."
|
||||
@until podman 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_db pg_isready -U postgres > /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 "Application available at http://localhost:8765"
|
||||
|
||||
# Stop test environment
|
||||
test-env-down:
|
||||
podman compose down -v
|
||||
$(COMPOSE) down -v
|
||||
|
||||
# Verify project guidelines compliance
|
||||
verify-guidelines:
|
||||
@@ -150,3 +161,27 @@ verify-guidelines:
|
||||
verify-quick:
|
||||
@echo "Running quick project guidelines verification..."
|
||||
@./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?
|
||||
|
||||
**🔄 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.
|
||||
|
||||
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/yourusername/bookhoard.git
|
||||
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
||||
cd bookhoard
|
||||
|
||||
# 2. Set up environment
|
||||
@@ -35,8 +35,10 @@ cp .env.example .env
|
||||
# DBPASS: openssl rand -hex 16
|
||||
# Edit .env with your generated values
|
||||
|
||||
# 3. Start the server
|
||||
podman-compose up --build -d # or: docker-compose up --build -d
|
||||
# 3. Pull images and start the server
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
||||
|
||||
# 4. Open your browser
|
||||
open http://localhost:8765
|
||||
@@ -50,13 +52,13 @@ The first user to register automatically becomes an admin.
|
||||
|
||||
### 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
|
||||
- **Offline Queue**: Changes sync when you reconnect, priority-processed
|
||||
- **Conflict Resolution**: Smart handling when same book read on multiple devices
|
||||
- **Book Matching**: Automatic matching using SHA-256, ISBN, UUID
|
||||
- **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
|
||||
|
||||
@@ -72,7 +74,7 @@ The first user to register automatically becomes an admin.
|
||||
### Smart Collections
|
||||
|
||||
- **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
|
||||
|
||||
### 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/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/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/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
|
||||
@@ -109,18 +111,18 @@ The first user to register automatically becomes an admin.
|
||||
### For Developers
|
||||
|
||||
- **[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
|
||||
|
||||
| Platform | Sync | OPDS | Status |
|
||||
| ---------------- | ---- | ---- | ------------------------ |
|
||||
| **Web Browser** | ✅ | ✅ | Full support |
|
||||
| **KOReader** | ✅ | ✅ | Kindle, Kobo, PocketBook |
|
||||
| **Kobo Devices** | ✅ | ✅ | Clara, Libra, Sage, etc. |
|
||||
| **Mobile Apps** | 🚧 | 🚧 | Coming Q2 2026 |
|
||||
| Platform | Sync | OPDS | Status |
|
||||
| ---------------- | ---- | ---- | ------------------------------------------------------------- |
|
||||
| **Web Browser** | ✅ | ✅ | Full support |
|
||||
| **KOReader** | ✅ | ✅ | Runs on Kindle, Kobo, PocketBook hardware |
|
||||
| **Kobo Devices** | 🚧 | 🚧 | Native Kobo sync coming soon (use KOReader on Kobo today) |
|
||||
| **Mobile Apps** | 🚧 | 🚧 | Android/iOS apps coming later |
|
||||
|
||||
---
|
||||
|
||||
@@ -160,7 +162,7 @@ bruno run
|
||||
|
||||
## 🤝 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
|
||||
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
**Response:** Updated rating object
|
||||
|
||||
@@ -83,9 +83,10 @@ docs:
|
||||
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
||||
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
||||
Ratings (All Users)
|
||||
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated)
|
||||
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
|
||||
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
|
||||
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
|
||||
- **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.
|
||||
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
|
||||
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
|
||||
Collections (All Users)
|
||||
- **List Collections**: GET /api/collections - Get user's collections
|
||||
- **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"
|
||||
"time"
|
||||
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -48,24 +50,74 @@ func main() {
|
||||
}
|
||||
log.Println("✅ Database schema initialized and verified, starting server...")
|
||||
|
||||
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
|
||||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
||||
// Load tunable settings from the DB into the registry. All values fall back
|
||||
// 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.SetSettings(registry)
|
||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||
systemSettingsHandler.SetSettings(registry)
|
||||
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
||||
sidecarHandler.SetSettings(registry)
|
||||
libraryHandler := handlers.NewLibraryHandler(queries)
|
||||
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
||||
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||||
deviceAuthMiddleware.SetSettings(registry)
|
||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||
hashConflictsHandler := handlers.NewHashConflictsHandler(queries)
|
||||
|
||||
// Create WebSocket connection manager
|
||||
connManager := sync.NewConnectionManager()
|
||||
|
||||
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.SetAnnotationService(annotationService)
|
||||
|
||||
// Create library service
|
||||
libraryService := services.NewLibraryService(queries)
|
||||
@@ -74,18 +126,23 @@ func main() {
|
||||
libraryService.SyncAllowedExtensions(context.Background())
|
||||
|
||||
// Create worker for background tasks
|
||||
worker := services.NewWorker(3, connManager)
|
||||
workerCfg := registry.WorkerPoolConfig()
|
||||
worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
|
||||
services.WorkerInstance = worker
|
||||
|
||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||
koreaderHandler.SetProgressService(progressService)
|
||||
koreaderHandler.SetAnnotationService(annotationService)
|
||||
koreaderHandler.SetLibraryService(libraryService)
|
||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||
|
||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||
conversionService.SetSettings(registry)
|
||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||
opdsHandler.SetSettings(registry)
|
||||
|
||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||
dashboardService := services.NewDashboardService(queries)
|
||||
@@ -94,6 +151,7 @@ func main() {
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
mediaHandler.SetAnnotationService(annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||
|
||||
@@ -137,6 +195,7 @@ func main() {
|
||||
Echo: e,
|
||||
Queries: queries,
|
||||
Cfg: cfg,
|
||||
Settings: registry,
|
||||
DBPool: dbPool,
|
||||
AuthHandler: authHandler,
|
||||
LibraryHandler: libraryHandler,
|
||||
@@ -144,6 +203,7 @@ func main() {
|
||||
MediaHandler: mediaHandler,
|
||||
MatchingHandler: matchingHandler,
|
||||
ProcessingIssuesHandler: processingIssuesHandler,
|
||||
HashConflictsHandler: hashConflictsHandler,
|
||||
KOReaderHandler: koreaderHandler,
|
||||
WSHandler: wsHandler,
|
||||
ConflictHandler: conflictHandler,
|
||||
@@ -161,9 +221,11 @@ func main() {
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
JobsHandler: jobsHandler,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
LibraryService: libraryService,
|
||||
}
|
||||
|
||||
// Register all routes and get ebook handler
|
||||
|
||||
@@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
||||
// Create scanner and configure it
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
scanner.SetAdminID(adminID)
|
||||
err = scanner.SetFolders([]string{tmpDir})
|
||||
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||
require.NoError(t, err, "Failed to set scanner folders")
|
||||
|
||||
// Scan library
|
||||
@@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
|
||||
// Create scanner and configure it
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
scanner.SetAdminID(adminID)
|
||||
err = scanner.SetFolders([]string{tmpDir})
|
||||
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||
require.NoError(t, err, "Failed to set scanner folders")
|
||||
|
||||
// Scan library
|
||||
|
||||
@@ -84,6 +84,7 @@ type TestServerSetup struct {
|
||||
ConnManager *wsync.ConnectionManager
|
||||
QueueProcessor *wsync.SyncQueueProcessor
|
||||
ProgressService *wsync.ProgressService
|
||||
AnnotationService *wsync.AnnotationService
|
||||
CleanupCancel context.CancelFunc
|
||||
QueueCtx context.Context
|
||||
QueueCancel context.CancelFunc
|
||||
@@ -455,6 +456,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
cleanupCancel := connManager.StartCleanupTask()
|
||||
|
||||
progressService := wsync.NewProgressService(queries, connManager)
|
||||
annotationService := wsync.NewAnnotationService(queries, connManager)
|
||||
|
||||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
@@ -463,6 +465,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
|
||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||
koreaderHandler.SetProgressService(progressService)
|
||||
koreaderHandler.SetAnnotationService(annotationService)
|
||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||
@@ -482,6 +485,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
mediaHandler.SetAnnotationService(annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
|
||||
// Create conversion service for OPDS
|
||||
@@ -537,6 +541,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
}
|
||||
|
||||
+265
-11
@@ -45,11 +45,47 @@ CREATE TABLE IF NOT EXISTS system_settings (
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Insert default system settings
|
||||
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')
|
||||
-- Extend system_settings with typed metadata so it can back the admin UI's
|
||||
-- configurable tunables. All columns are nullable for backward compatibility
|
||||
-- with the original three rows and any pre-existing data.
|
||||
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
|
||||
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;
|
||||
|
||||
-- Create refresh_tokens table
|
||||
@@ -245,6 +281,7 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
||||
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
||||
character_offset BIGINT,
|
||||
epubcfi TEXT,
|
||||
context_text TEXT,
|
||||
chapter INTEGER,
|
||||
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
||||
viewport_x FLOAT DEFAULT 0,
|
||||
@@ -937,6 +974,7 @@ BEGIN
|
||||
percentage = (book_record->>'percentage')::FLOAT,
|
||||
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,
|
||||
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_progress = (book_record->>'percentage')::FLOAT,
|
||||
last_sync_device = 'koreader',
|
||||
@@ -955,6 +993,7 @@ BEGIN
|
||||
percentage,
|
||||
character_offset,
|
||||
epubcfi,
|
||||
context_text,
|
||||
chapter,
|
||||
chapter_progress,
|
||||
last_sync_device,
|
||||
@@ -971,6 +1010,7 @@ BEGIN
|
||||
(book_record->>'percentage')::FLOAT,
|
||||
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 ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
|
||||
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
||||
(book_record->>'percentage')::FLOAT,
|
||||
'koreader',
|
||||
@@ -1145,12 +1185,14 @@ CREATE TABLE IF NOT EXISTS system_config (
|
||||
updated_by UUID REFERENCES users(id)
|
||||
);
|
||||
|
||||
-- Pre-seeded values
|
||||
INSERT INTO system_config (key, value) VALUES
|
||||
('base_url', 'https://bookhoard.example.com'),
|
||||
('opds_base_url', 'https://bookhoard.example.com/opds'),
|
||||
('api_base_url', 'https://bookhoard.example.com/api')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
-- One-time cleanup: clear the old placeholder seed so the startup logic
|
||||
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
|
||||
UPDATE system_config SET value = ''
|
||||
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
|
||||
UPDATE system_config SET value = ''
|
||||
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 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_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:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
@@ -9,14 +7,15 @@ services:
|
||||
POSTGRES_DB: bookhoard
|
||||
POSTGRES_USER: postgres
|
||||
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:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./database/schema:/docker-entrypoint-initdb.d
|
||||
# Make other volumes as needed
|
||||
- ./uploads:/app/uploads
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
@@ -27,27 +26,30 @@ services:
|
||||
- .env
|
||||
|
||||
# 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:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||
container_name: bookhoard
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_PORT: ${DB_PORT:-5432}
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: 8765
|
||||
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||
# IMPORTANT: Device sync requires full URL with protocol
|
||||
# Local: http://localhost:8765
|
||||
# Local network: http://192.168.1.X:8765
|
||||
# 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
|
||||
TEST_MODE: ${TEST_MODE:-false}
|
||||
@@ -62,7 +64,7 @@ services:
|
||||
# System timezone (fallback for server-side time operations)
|
||||
TZ: ${TZ:-UTC}
|
||||
ports:
|
||||
- "8765:8765"
|
||||
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -70,55 +72,12 @@ services:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
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
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
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
|
||||
volumes:
|
||||
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)
|
||||
9. [Analytics](#analytics)
|
||||
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)
|
||||
13. [Sync Protocol - KOReader](#sync-protocol---koreader)
|
||||
14. [Sync Protocol - Kobo](#sync-protocol---kobo)
|
||||
15. [Universal Progress](#universal-progress)
|
||||
16. [Conflicts](#conflicts)
|
||||
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
|
||||
|
||||
@@ -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
|
||||
PUT /api/libraries/scan-settings
|
||||
@@ -665,18 +669,23 @@ Content-Type: application/json
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "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...",
|
||||
"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
|
||||
|
||||
```http
|
||||
POST /api/devices/auth/status
|
||||
POST /api/devices/register/status
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
@@ -688,13 +697,13 @@ Content-Type: application/json
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "pending|approved|expired",
|
||||
"status": "pending|approved",
|
||||
"auth_token": "device-bearer-token...",
|
||||
"device_id": "uuid",
|
||||
"sync_endpoints": {
|
||||
"progress": "https://bookhoard.com/api/sync/progress",
|
||||
"metadata": "https://bookhoard.com/api/sync/metadata",
|
||||
"annotations": "https://bookhoard.com/api/sync/annotations"
|
||||
"progress": "https://bookhoard.com/api/sync/koreader/progress",
|
||||
"metadata": "https://bookhoard.com/api/sync/koreader/metadata",
|
||||
"bookmarks": "https://bookhoard.com/api/sync/koreader/bookmarks"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -747,6 +756,22 @@ DELETE /api/devices/{device_id}
|
||||
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
|
||||
|
||||
### Get Reading Statistics
|
||||
@@ -953,7 +978,7 @@ Authorization: Bearer <token>
|
||||
|
||||
## 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**:
|
||||
|
||||
@@ -986,25 +1011,39 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
|
||||
- `page` (optional): Page number (default: 1)
|
||||
- `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):
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||
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>
|
||||
<title>Bookhoard Library</title>
|
||||
<updated>2026-02-01T12:00:00Z</updated>
|
||||
|
||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
|
||||
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
|
||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
|
||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||
<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>
|
||||
<id>urn:uuid:bookhoard-uuid-123</id>
|
||||
<dc:title>The Hobbit</dc:title>
|
||||
<dc:creator>J.R.R. Tolkien</dc:creator>
|
||||
<title>The Hobbit</title>
|
||||
<author><name>J.R.R. Tolkien</name></author>
|
||||
<updated>2026-02-01T10:00:00Z</updated>
|
||||
|
||||
<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
|
||||
|
||||
```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
|
||||
|
||||
@@ -1171,6 +1228,8 @@ Authorization: Bearer <device_token>
|
||||
|
||||
## 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
|
||||
|
||||
```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
|
||||
|
||||
### Connect to WebSocket
|
||||
@@ -1661,10 +1810,10 @@ bruno run bruno/devices/
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [README.md](README.md) - Getting started guide
|
||||
- [UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md](UNIVERSAL_SYNC_IMPLEMENTATION_GUIDE.md) - Sync architecture
|
||||
- [KOBOREADER_SETUP.md](KOBOREADER_SETUP.md) - KOReader device setup
|
||||
- [KOBO_SETUP.md](KOBO_SETUP.md) - Kobo device setup
|
||||
- [README.md](../../README.md) - Getting started guide
|
||||
- [Sync Guide](../user/sync-guide.md) - Sync concepts and conflict resolution
|
||||
- [KOReader Setup](../user/devices/koreader-setup.md) - KOReader 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
|
||||
- [Queue](queue/) - Sync queue management
|
||||
- [Scanner](scanner/) - Library scanning and watch mode (admin)
|
||||
- [System](system/) - Tunable system settings and configuration (admin)
|
||||
- [OPDS](opds/) - Open Publication Distribution
|
||||
- [KOReader](koreader/) - KOReader sync protocol
|
||||
- [Kobo](kobo/) - Kobo sync protocol
|
||||
- [Kobo](kobo/) - Kobo sync protocol (coming soon)
|
||||
- [WebSocket](websocket/) - Real-time sync events
|
||||
|
||||
---
|
||||
@@ -51,6 +52,8 @@ See [Admin Operations](admin/)
|
||||
|
||||
- GET /api/auth/users - List all users (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
|
||||
|
||||
@@ -71,7 +74,10 @@ See [Library Management](libraries/)
|
||||
- DELETE /api/libraries/:id/folders - Delete library folder (admin)
|
||||
- GET /api/libraries/:id/stats - Get library statistics (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)
|
||||
- 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
|
||||
- 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
|
||||
- PUT /api/media-items/:id/highlights/:highlightId - Update 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)
|
||||
- PUT /api/media-items/:id - Update 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/approve/:registration_id - Approve 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
|
||||
- DELETE /api/devices/:id/shelves - Remove from 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
|
||||
|
||||
@@ -220,12 +241,15 @@ See [OPDS Feeds](opds/)
|
||||
See [KOReader Sync](koreader/) and [Sync Protocol](sync/koreader-protocol.md)
|
||||
|
||||
- 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/library - Get device library
|
||||
- POST /api/sync/koreader/bookmarks - Sync bookmarks
|
||||
|
||||
## 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)
|
||||
|
||||
- POST /api/sync/kobo/markup - Sync markup highlights
|
||||
|
||||
@@ -158,7 +158,7 @@ The frontend toast.js interceptor:
|
||||
- **Backend**: Automatically manages HTTP-only cookie
|
||||
- **Frontend**: Store tokens in localStorage for API calls
|
||||
|
||||
### Mobile Applications
|
||||
### Mobile Applications (coming later)
|
||||
|
||||
- Store access token in secure storage (Keychain/Keystore)
|
||||
- Store refresh token in secure storage
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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
|
||||
**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
|
||||
{
|
||||
"device_id": "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...",
|
||||
"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
|
||||
|
||||
| Code | Description |
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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).
|
||||
|
||||
**Endpoint**: `POST /api/sync/kobo/v1/analytics/gettests`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
**Endpoint**: `POST /api/sync/kobo/bookmark`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
**Endpoint**: `GET /api/sync/kobo/v1/initialization`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
**Endpoint**: `POST /api/sync/kobo/markup`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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.
|
||||
|
||||
**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 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`
|
||||
**Auth**: Required (Device authentication)
|
||||
|
||||
## Device Authentication
|
||||
|
||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
||||
**Auth**: Device token (Bearer)
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | ------------------------- |
|
||||
| device_id | string (UUID) | Yes | Device UUID |
|
||||
| bookmarks | array | Yes | Array of bookmark objects |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------ | --------------------- | ----------------------------------------------------------------- |
|
||||
| book_uuid | string | one of uuid/sha | Book UUID (highest-confidence match) |
|
||||
| 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 |
|
||||
| ---------------- | ------- | -------- | -------------------------- |
|
||||
| book | string | Yes | Book identifier |
|
||||
| chapter | string | No | Chapter title |
|
||||
| page | integer | No | Page number |
|
||||
| position | float | Yes | Position in document (0-1) |
|
||||
| notes | string | No | Bookmark notes |
|
||||
| highlighted_text | string | No | Highlighted text |
|
||||
| time | string | Yes | ISO 8601 timestamp |
|
||||
| created_at | string | Yes | ISO 8601 timestamp |
|
||||
### Bookmark / Note / Highlight Object
|
||||
|
||||
All three types share the same KOReader annotation shape:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------ | ------- | -------- | ---------------------------------------------------- |
|
||||
| chapter | int | No | Chapter index |
|
||||
| datetime | string | No | ISO 8601 creation/edit timestamp |
|
||||
| pos0 / pos1 | string | No | Start/end xpointer (or `page:N` / bare page) |
|
||||
| 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
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"book_sha256": "64-hex-char-sha256",
|
||||
"bookmarks": [
|
||||
{
|
||||
"book": "book.epub",
|
||||
"chapter": "Chapter 1",
|
||||
"chapter": 3,
|
||||
"datetime": "2026-08-20T10:00:00Z",
|
||||
"pos0": "/body/Doc[4]/Sec[2]",
|
||||
"page": 25,
|
||||
"position": 0.125,
|
||||
"notes": "Important section",
|
||||
"highlighted_text": "Text to remember",
|
||||
"time": "2026-02-08T10:00:00Z",
|
||||
"created_at": "2026-02-08T10:00:00Z"
|
||||
"text": "",
|
||||
"type": "bookmark",
|
||||
"percentage": 0.125
|
||||
}
|
||||
],
|
||||
"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
|
||||
{
|
||||
"message": "Bookmarks synced successfully",
|
||||
"synced_count": 1
|
||||
"sync_status": "ok",
|
||||
"bookmarks_synced": 1,
|
||||
"notes_synced": 0,
|
||||
"highlights_synced": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ---------------------------- |
|
||||
| 401 | Device authentication failed |
|
||||
| 400 | Invalid request data |
|
||||
| 404 | Device not found |
|
||||
| Code | Description |
|
||||
| ---- | -------------------------------------------------- |
|
||||
| 400 | Invalid request, or neither uuid nor SHA provided |
|
||||
| 401 | Missing/invalid device token |
|
||||
| 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
|
||||
|
||||
> **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 Markup Sync
|
||||
|
||||
@@ -17,20 +17,31 @@ KOReader uses a custom JSON-based sync protocol.
|
||||
|
||||
### Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------ | ------- | -------- | ----------------------------- |
|
||||
| library_id | string | No | Library UUID |
|
||||
| books | array | Yes | Array of book sync data |
|
||||
| books[].uuid | string | Yes | Book UUID |
|
||||
| books[].title | string | Yes | Book title |
|
||||
| books[].authors | array | Yes | Array of author names |
|
||||
| books[].progress | float | Yes | Progress percentage (0-1) |
|
||||
| books[].percentage | float | Yes | Progress percentage (0-1) |
|
||||
| books[].last_read | string | Yes | ISO 8601 timestamp |
|
||||
| books[].chapter | integer | No | Current chapter |
|
||||
| books[].epubcfi | string | No | EPUB CFI location |
|
||||
| books[].character | integer | No | Character offset |
|
||||
| books[].bookmarks | array | No | Array of bookmarks/highlights |
|
||||
| Field | Type | Required | Description |
|
||||
| ------------------ | ------- | -------- | ---------------------------------------------------- |
|
||||
| library_id | string | No | Library UUID |
|
||||
| books | array | Yes | Array of book sync data |
|
||||
| books[].uuid | string | No\* | Book UUID (highest-confidence match; omitted on first sync of a newly downloaded book) |
|
||||
| books[].sha256 | string | No\* | Full-file SHA-256 (64 hex chars); used to resolve the book when `uuid` is absent |
|
||||
| books[].file_path | string | No | Device-local file path; used to create/look up a device file alias |
|
||||
| books[].title | string | Yes | Book title |
|
||||
| books[].authors | array | Yes | Array of author names |
|
||||
| books[].progress | float | Yes | Progress percentage (0-1) |
|
||||
| books[].percentage | float | Yes | Progress percentage (0-1) |
|
||||
| books[].last_read | string | Yes | ISO 8601 timestamp |
|
||||
| books[].chapter | integer | No | Current chapter |
|
||||
| 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
|
||||
|
||||
@@ -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
|
||||
|
||||
**Endpoint**: `GET /api/sync/koreader/metadata/{book_uuid}`
|
||||
@@ -102,6 +148,7 @@ Authorization: Bearer device-token
|
||||
```json
|
||||
{
|
||||
"uuid": "book-uuid",
|
||||
"sha256": "ff3e4501bf9d72dea2ae28731a6cb5b83d7a7532c05b5d2dd083d0dbc9193ebf",
|
||||
"title": "Book Title",
|
||||
"authors": ["Author Name"],
|
||||
"progress": {
|
||||
@@ -119,3 +166,44 @@ Authorization: Bearer device-token
|
||||
"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
|
||||
|
||||
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
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
@@ -12,49 +12,61 @@ The System Scan Settings API allows administrators to configure system-wide scan
|
||||
|
||||
## 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
|
||||
|
||||
**Response**:
|
||||
|
||||
- **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**:
|
||||
**Response**: **200 OK**
|
||||
|
||||
```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)
|
||||
- `auto_scan_enabled` (boolean): Whether auto-scanning is enabled system-wide
|
||||
| Field | Type | Description |
|
||||
| ------------------ | ------- | -------------------------------------------------------- |
|
||||
| `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**:
|
||||
|
||||
```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>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
@@ -62,128 +74,123 @@ Update the system-wide scan settings.
|
||||
|
||||
```json
|
||||
{
|
||||
"scan_poll_interval_seconds": 30,
|
||||
"auto_scan_enabled": true
|
||||
"key": "scan_poll_interval_seconds",
|
||||
"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
|
||||
- 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**:
|
||||
**Response**: **200 OK**
|
||||
|
||||
```json
|
||||
{
|
||||
"scan_poll_interval_seconds": 30,
|
||||
"auto_scan_enabled": true,
|
||||
"message": "scan settings updated successfully"
|
||||
"key": "scan_poll_interval_seconds",
|
||||
"value": "30",
|
||||
"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
|
||||
{
|
||||
"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
|
||||
**Errors**: `400` (unknown key, invalid value, out of range), `401`, `403`, `503` (settings registry not initialized).
|
||||
|
||||
**Example**:
|
||||
|
||||
```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 "Content-Type: application/json" \
|
||||
-d '{
|
||||
"scan_poll_interval_seconds": 30,
|
||||
"auto_scan_enabled": true
|
||||
}'
|
||||
-d '{"key": "scan_poll_interval_seconds", "value": "30"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- Maximum: 3600 seconds (1 hour)
|
||||
- Default: 60 seconds
|
||||
**General** (`general`)
|
||||
|
||||
### 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
|
||||
- When `false`: No automatic file monitoring occurs (manual scans still available)
|
||||
| Key | Default | Range | Restart | Description |
|
||||
| ---------------------------- | --------- | ------------ | ------- | ---------------------------------------------- |
|
||||
| `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
|
||||
2. **Polling fallback**: If file watching fails or is unavailable, polls folders at the configured interval
|
||||
**Sync** (`sync`)
|
||||
|
||||
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 |
|
||||
| ----------- | ---------------------------------------------------------- |
|
||||
| 400 | Invalid request parameters (e.g., frequency outside range) |
|
||||
| 401 | Missing or invalid JWT token |
|
||||
| 403 | User lacks admin role |
|
||||
| 500 | Internal server error (e.g., database connection issue) |
|
||||
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`:
|
||||
|
||||
- `GET /api/libraries/scan-settings` — returns only `scan_poll_interval_seconds` and `auto_scan_enabled`
|
||||
- `PUT /api/libraries/scan-settings` — accepts `{ "scan_poll_interval_seconds": int, "auto_scan_enabled": bool }`
|
||||
|
||||
Both fields are backed by the same registry entries documented above.
|
||||
|
||||
---
|
||||
|
||||
## Related Endpoints
|
||||
|
||||
- `POST /api/libraries/{id}/scan` - Manually trigger a scan for a specific library (admin only)
|
||||
- `GET /api/libraries` - List all libraries
|
||||
- `GET /api/libraries/{id}` - Get details for a specific library
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- `GET/PUT /api/system/config` — raw key/value system configuration (see [System Config API](config.md))
|
||||
- `POST /api/scanner/scan` — trigger a manual scan (see [Scanner API](../scanner/))
|
||||
- `GET /api/admin/hash-conflicts` — duplicates found during hashing (see [Hash Conflicts API](../admin/hash-conflicts.md))
|
||||
|
||||
+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
|
||||
|
||||
- **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
|
||||
- [KOReader Setup Guide](user/devices/koreader-setup.md) - KOReader on Kindle/Kobo/PocketBook hardware
|
||||
- [Kobo Setup Guide](user/devices/kobo-setup.md) - Native Kobo sync (coming soon; use KOReader today)
|
||||
|
||||
- **Sync Configuration**
|
||||
- [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
|
||||
- [Scanner API](developer/api/scanner/) - Library scanning and automated watch mode (admin)
|
||||
- [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
|
||||
|
||||
- **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
|
||||
- [Kobo Sync Protocol](developer/api/sync/kobo-protocol.md) - Kobo device sync (coming soon)
|
||||
- [WebSocket API](developer/websocket-api.md) - Real-time events
|
||||
|
||||
### 🔧 For Operations
|
||||
@@ -62,8 +62,8 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
|
||||
**[Contributing Portal](contributing/contributing.md)** - Development workflow
|
||||
|
||||
- [Development Guide](contributing/Development.md) - Architecture, setup, testing
|
||||
- [PROJECT_GUIDELINES.md](PROJECT_GUIDELINES.md) - Development rules and standards
|
||||
- [Development Guide](contributing/development.md) - Architecture, setup, testing
|
||||
- [../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) |
|
||||
| **Use the API** | [Developer Portal → API Docs](developer/development.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) |
|
||||
|
||||
---
|
||||
@@ -87,13 +87,13 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
| Question | Answer |
|
||||
| --------------------------- | ------------------------------------------------------ |
|
||||
| ...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) |
|
||||
| ...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) |
|
||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.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..."
|
||||
|
||||
@@ -112,7 +112,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
|
||||
### 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)
|
||||
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
|
||||
2. Configure environment: [.env.example](../.env.example)
|
||||
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
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -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:
|
||||
|
||||
1. Navigate to **Admin → Library Management**
|
||||
2. Find the library you want to manage
|
||||
3. Click the **Folders** button
|
||||
4. Click **Browse** next to "Add folder path"
|
||||
1. Open the **Administration** panel in the sidebar (admins only) and go to **Libraries**
|
||||
2. Click a library in the list to expand its panel
|
||||
3. Find the **Folders** section
|
||||
4. Click **Browse** next to the folder path input — this opens the **Browse Folders** dialog
|
||||
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
|
||||
|
||||
|
||||
@@ -58,20 +58,22 @@ Calibre Library/
|
||||
|
||||
### Step 2: Add Library in Bookhoard
|
||||
|
||||
1. Navigate to **Admin** → **Libraries**
|
||||
2. Click **Add Library**
|
||||
1. Open the **Administration** panel in the sidebar and go to **Libraries**
|
||||
2. Click **Create Library**
|
||||
3. Configure:
|
||||
- **Name**: "My Calibre Library"
|
||||
- **Type**: Ebook (or Audiobook/Comic)
|
||||
- **Folder**: Path to your Calibre library
|
||||
- **Scan on save**: ✅ Checked
|
||||
4. Click **Save**
|
||||
- **Library Name**: "My Calibre Library"
|
||||
- **Description**: Optional
|
||||
- **Library Type**: Ebook (or Audiobook/Comic)
|
||||
4. Click the library in the list to expand its panel
|
||||
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
|
||||
|
||||
1. Navigate to **Library** view
|
||||
1. Open the **Dashboard** or **All Books** page (sidebar navigation)
|
||||
2. Browse your imported books
|
||||
3. Check that:
|
||||
- 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.
|
||||
|
||||
**Steps**:
|
||||
1. Add the Calibre library folder in Bookhoard
|
||||
2. Enable "Scan on save"
|
||||
1. Add the Calibre library folder in Bookhoard (Administration → Libraries → expand the library → **Folders**)
|
||||
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:
|
||||
- Correct titles and authors
|
||||
- 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**:
|
||||
1. Edit metadata in Calibre (it updates `metadata.opf`)
|
||||
2. In Bookhoard, trigger a rescan:
|
||||
- Navigate to **Admin** → **Libraries**
|
||||
- Click **Rescan** on your library
|
||||
- Or use the **Scanner API** to force rescan
|
||||
- Via the **Scanner API** (`POST /api/scanner/scan`), or
|
||||
- Let watch mode detect the changed files automatically (see File Watcher on the admin dashboard)
|
||||
3. Bookhoard detects updated `metadata.opf` and refreshes metadata
|
||||
|
||||
**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**:
|
||||
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)
|
||||
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
|
||||
|
||||
### Incorrect Metadata
|
||||
@@ -219,7 +220,7 @@ As long as a `metadata.opf` file exists in the folder, Bookhoard will import the
|
||||
|
||||
**Do**:
|
||||
- ✅ 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
|
||||
|
||||
**Don't**:
|
||||
@@ -259,12 +260,11 @@ Stay tuned for updates!
|
||||
|
||||
### OPDS Integration
|
||||
|
||||
You can access your Bookhoard library (including Calibre-imported books) via OPDS from Calibre-aware devices:
|
||||
- Kobo e-readers
|
||||
- KOReader
|
||||
You can access your Bookhoard library (including Calibre-imported books) via OPDS from OPDS-capable clients:
|
||||
- KOReader (Kindle, Kobo, PocketBook hardware)
|
||||
- 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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Collections allow you to organize books across multiple libraries.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -19,8 +19,24 @@ When viewing a specific library's dashboard, collections only show books from th
|
||||
|
||||
## 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
|
||||
|
||||
[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
|
||||
|
||||
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. Go to Collections
|
||||
2. Edit a collection
|
||||
3. Toggle "Show on Dashboard"
|
||||
4. Save
|
||||
1. Select the library in the **Library** bar
|
||||
2. Click the **Customize Dashboard** icon button
|
||||
3. Toggle the collection on or off
|
||||
4. Click "Save Changes"
|
||||
|
||||
### Customizing Your Dashboard
|
||||
|
||||
1. Click the ⚙️ (gear icon) in the top-right
|
||||
2. **Drag sections** to reorder them
|
||||
3. **Toggle visibility** with the switches
|
||||
4. **Adjust items per section** (10-50 items)
|
||||
5. Click "Save Changes"
|
||||
1. In the **Library** bar below the top bar, select the library you want to customize (a specific library, not "All Libraries")
|
||||
2. Click the **Customize Dashboard** icon button at the right end of the Library bar (next to Refresh)
|
||||
3. **Drag sections** to reorder them
|
||||
4. **Toggle visibility** with the switches
|
||||
5. **Adjust items per section** (10-50 items)
|
||||
6. Click "Save Changes"
|
||||
|
||||
Settings are saved per library.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -45,6 +44,8 @@ Use the dropdown in the sticky header to switch between libraries. Each library
|
||||
- **Arrow Keys**: Scroll carousels horizontally
|
||||
- **Enter**: Open selected book
|
||||
|
||||
Hovering a carousel shows chevron buttons on either side for scrolling.
|
||||
|
||||
### Touch Gestures (Mobile)
|
||||
|
||||
- **Swipe**: Drag carousel left/right to scroll
|
||||
|
||||
+24
-632
@@ -1,650 +1,42 @@
|
||||
# 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
|
||||
- Sync highlights and bookmarks
|
||||
- Sync reading statistics
|
||||
- Maintain device-specific metadata
|
||||
See the **[KOReader Setup Guide](koreader-setup.md)** for complete instructions.
|
||||
|
||||
## 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.)
|
||||
- ✅ A Bookhoard instance running and accessible on your network
|
||||
- ✅ Your Bookhoard credentials (username and password)
|
||||
- ✅ USB cable to connect your Kobo to your computer
|
||||
- ✅ Your Kobo connected to the same Wi-Fi network as your Bookhoard instance
|
||||
- **Reading position sync** — percentages, pages, and reading statistics
|
||||
- **Bookmarks, highlights, and notes** — synced with the web and other devices
|
||||
- **OPDS wireless delivery** — browse and download books directly on the Kobo
|
||||
- **Automatic EPUB → KEPUB conversion** — for better Kobo rendering
|
||||
- **Shelf mappings** — Bookhoard collections appearing as Kobo shelves
|
||||
|
||||
## Supported Kobo 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)
|
||||
The server-side protocol endpoints are already implemented and under test; the feature will be announced when it's ready for real devices.
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Can I sync books (files) between devices?**
|
||||
A: No, Bookhoard only syncs reading progress and annotations. You must sideload book files to each device manually.
|
||||
**Q: Should I buy a Kobo to use with Bookhoard today?**
|
||||
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?**
|
||||
A: No, Kobo doesn't fetch book files from Bookhoard. You must transfer books via USB.
|
||||
**Q: What happens to my KOReader setup when native sync arrives?**
|
||||
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?**
|
||||
A: Yes, but they may conflict. It's recommended to choose one sync method.
|
||||
## Additional Resources
|
||||
|
||||
**Q: What happens if I read the same book on Kobo and KOReader?**
|
||||
A: Bookhoard will detect conflicts and you can resolve them in the Conflicts UI.
|
||||
|
||||
**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
|
||||
- [KOReader Setup Guide](koreader-setup.md) — works on Kobo today
|
||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||
- [Bookhoard API Reference](../../developer/api-reference.md)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-31
|
||||
**Bookhoard Version**: 1.0
|
||||
**Kobo Firmware**: 4.30.0+
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.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.)
|
||||
- Kobo devices (Clara, Aura, Nia, etc.)
|
||||
- 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
|
||||
|
||||
Before you begin, make sure you have:
|
||||
|
||||
- ✅ 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
|
||||
- ✅ Your device connected to the same Wi-Fi network as your Bookhoard instance
|
||||
|
||||
## Installation
|
||||
## Installing KOReader
|
||||
|
||||
### Kindle Devices
|
||||
|
||||
@@ -64,469 +65,132 @@ Before you begin, make sure you have:
|
||||
- Open KOReader from your apps menu
|
||||
- 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`
|
||||
- **Localhost (if testing)**: `http://localhost:8765`
|
||||
- **Domain (if configured)**: `https://bookhoard.yourdomain.com`
|
||||
1. Clone the [Bookhoard KOReader plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||
2. Copy it to your KOReader `plugins/` directory
|
||||
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
|
||||
2. Navigate to **Device Management** → **Add New Device**
|
||||
3. Fill in the device details:
|
||||
- **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
|
||||
1. Open KOReader, tap the **wrench icon** at the top
|
||||
2. Find and tap **Bookhoard sync**
|
||||
3. Tap **Server URL**, enter your server address, then tap **OK**:
|
||||
|
||||
```
|
||||
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"
|
||||
2. **Username**: Your Bookhoard email or username
|
||||
3. **Password**: Your Bookhoard password
|
||||
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.
|
||||
|
||||
### 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
|
||||
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)
|
||||
### Auth Token (Advanced)
|
||||
|
||||
3. **What to Sync**: Enable:
|
||||
- ✅ Reading progress
|
||||
- ✅ Bookmarks
|
||||
- ✅ Highlights
|
||||
- ✅ Notes
|
||||
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.
|
||||
|
||||
### Step 5: Test Connection
|
||||
## What Syncs
|
||||
|
||||
1. Tap **Test Connection** in the Calibre settings
|
||||
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
|
||||
Once connected, the following sync automatically in both directions between KOReader and Bookhoard (web and other devices):
|
||||
|
||||
## 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
|
||||
|
||||
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`
|
||||
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.
|
||||
|
||||
## OPDS Wireless Book Delivery
|
||||
|
||||
### What is OPDS?
|
||||
|
||||
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!
|
||||
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.
|
||||
|
||||
### 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
|
||||
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:
|
||||
### Supported Formats
|
||||
|
||||
- **EPUB**: Standard ebook format
|
||||
- **KEPUB**: Kobo-optimized format (KOReader handles this well)
|
||||
- **KEPUB**: Kobo-optimized format
|
||||
- **PDF**: Fixed-layout documents
|
||||
- **CBZ**: Comic book archives
|
||||
- **TXT**: Plain text files
|
||||
- **RTF**: Rich text format
|
||||
|
||||
#### Automatic Book Matching
|
||||
|
||||
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
|
||||
Books downloaded via OPDS are automatically matched to your library, so their progress syncs from the first page.
|
||||
|
||||
## 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
|
||||
|
||||
**Problem**: "Connection refused" error
|
||||
**Problem**: "Connection refused" error on the device
|
||||
|
||||
**Solutions**:
|
||||
|
||||
- Verify Bookhoard is running on your computer
|
||||
- Check the server URL and port (8765)
|
||||
- Ensure device is on same Wi-Fi network
|
||||
- Try using your computer's IP address instead of "localhost"
|
||||
- Verify Bookhoard is running
|
||||
- Check the server address and port (default `8765`)
|
||||
- Ensure the device is on the same Wi-Fi network as the server
|
||||
- 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**:
|
||||
|
||||
- Verify username and password
|
||||
- Check your account is active and not locked
|
||||
- Try logging in to Bookhoard web interface first
|
||||
- Reset password if needed
|
||||
|
||||
### 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
|
||||
- Trigger a manual sync from the plugin menu
|
||||
- Check the device shows as enabled on the **Devices** page (open its settings from the icon next to the device)
|
||||
- Verify the book appears as an unlinked book for the device and link it if needed
|
||||
- Check Bookhoard server logs for errors
|
||||
|
||||
### Conflicts Detected
|
||||
|
||||
**Problem**: Sync conflicts when reading on multiple devices
|
||||
**Problem**: Sync conflicts when reading the same book on multiple devices
|
||||
|
||||
**Solutions**:
|
||||
|
||||
1. Go to Bookhoard **Conflicts** page
|
||||
2. Review conflicting progress from each device
|
||||
1. Open the book's detail page and click **Sync Progress**, or open the Conflicts page (`/conflicts`)
|
||||
2. Review the progress reported by each device
|
||||
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
|
||||
|
||||
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. **Device Authorization**: Only approve devices you recognize
|
||||
5. **Regular Updates**: Keep KOReader updated to the latest version
|
||||
1. **Use HTTPS**: If exposing Bookhoard beyond your LAN, configure SSL/TLS
|
||||
2. **Network Security**: Ensure your Wi-Fi network is secure (WPA2/WPA3)
|
||||
3. **Device Authorization**: Only approve pending registrations you initiated
|
||||
4. **Revoke lost devices**: Remove devices you no longer use from the Devices page
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [KOReader Documentation](https://github.com/koreader/koreader)
|
||||
- [KOReader Forum](https://www.mobileread.com/forums/forumdisplay.php?f=271)
|
||||
- [Bookhoard Universal Sync Guide](../sync-guide.md)
|
||||
- [Kobo Setup Guide](kobo-setup.md)
|
||||
|
||||
## 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
|
||||
- [Bookhoard KOReader Plugin](https://git.linuxhg.com/Bookhoard/bookhoard.koplugin)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-31
|
||||
**Bookhoard Version**: 1.0
|
||||
**KOReader Version**: 2024.01+
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
|
||||
@@ -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
|
||||
2. Set your desired filters (genre, author, series, etc.)
|
||||
3. Click the **💾 Save Filter** button
|
||||
2. Click **Filters** to open the drawer, set your desired filters, and click **Apply Filters**
|
||||
3. Click the **Save** button in the toolbar
|
||||
4. Enter a name for your filter (e.g., "My Sci-Fi Books")
|
||||
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)
|
||||
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
|
||||
## Managing Saved Filters
|
||||
|
||||
**Tips:**
|
||||
- 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)
|
||||
**Delete a filter:**
|
||||
|
||||
### Managing Saved Filters
|
||||
|
||||
**View Saved Filters:**
|
||||
- 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
|
||||
1. Click the **Load** button to open the **Saved Filters** dropdown
|
||||
2. Click the trash icon next to the filter you want to remove
|
||||
3. Confirm deletion
|
||||
|
||||
**Filter Privacy:**
|
||||
|
||||
Saved filters are **private to your account**. Other users cannot see or modify your filters.
|
||||
|
||||
### Common Use Cases
|
||||
## Common Use Cases
|
||||
|
||||
**Reading by Genre:**
|
||||
1. Filter by genre: "Science Fiction"
|
||||
|
||||
1. Filter by tag: "Science Fiction"
|
||||
2. Save as "Sci-Fi Books"
|
||||
3. Quickly access all your sci-fi collection anytime
|
||||
|
||||
**Author Collections:**
|
||||
|
||||
1. Filter by author: "Isaac Asimov"
|
||||
2. Save as "Asimov Books"
|
||||
3. Switch between different author collections instantly
|
||||
|
||||
**Series Tracking:**
|
||||
|
||||
1. Filter by series: "Foundation"
|
||||
2. Save as "Foundation Series"
|
||||
3. Track your progress through a series
|
||||
|
||||
@@ -6,10 +6,10 @@ Your profile contains your account information and preferences.
|
||||
|
||||
### How to Update
|
||||
|
||||
1. Click on your **username** (top-right)
|
||||
2. Select **Profile** from the dropdown
|
||||
1. Click on your **username** at the bottom of the sidebar to expand the account menu
|
||||
2. Select **Profile**
|
||||
3. Edit any fields in the "Account Information" section
|
||||
4. Click **Update Profile**
|
||||
4. Click **Save Changes**
|
||||
5. Changes take effect immediately
|
||||
|
||||
### Fields You Can Update
|
||||
@@ -65,7 +65,7 @@ When you delete your account:
|
||||
1. Go to **Profile** page
|
||||
2. Scroll to "Danger Zone" (bottom of page)
|
||||
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.
|
||||
|
||||
@@ -75,10 +75,12 @@ Personalize your reading experience with different color themes.
|
||||
|
||||
### Quick Theme Switch
|
||||
|
||||
1. Click the **paintbrush icon** (top-right, next to your username)
|
||||
2. Select a theme from the dropdown
|
||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||
2. Select a theme from the list; your active theme is marked with a checkmark
|
||||
3. Changes apply instantly
|
||||
|
||||
For the full theme list and bookshelf background (wood) options, see [Themes and Wood Paneling](themes.md).
|
||||
|
||||
### Available Themes
|
||||
|
||||
- **Tokyo Night** (default) - Blue/purple accents
|
||||
@@ -88,9 +90,7 @@ Personalize your reading experience with different color themes.
|
||||
- **Monokai** - Classic vibrant colors
|
||||
- **One Dark Pro** - Atom editor inspired
|
||||
- **Material Dark** - Google Material Design
|
||||
- **Wood Light** - Light wood texture
|
||||
- **Wood Dark** - Dark wood texture
|
||||
- **Wood Mahogany** - Reddish-brown wood
|
||||
- **Catppuccin Mocha / Macchiato / Frappé / Latte** - Soothing pastel palettes (Latte is light)
|
||||
|
||||
## For Admin Users
|
||||
|
||||
|
||||
@@ -13,10 +13,11 @@ Tags are keywords or categories assigned to books, such as:
|
||||
|
||||
### Filtering by Tags
|
||||
|
||||
1. Navigate to the **Bookshelf** page
|
||||
2. Use the **Tags** filter input
|
||||
3. Start typing to see autocomplete suggestions
|
||||
4. Select a tag or press Enter to filter
|
||||
1. Navigate to the **All Books** page
|
||||
2. Click **Filters** in the toolbar to open the filter drawer
|
||||
3. Use the **Tags** filter input
|
||||
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"
|
||||
|
||||
|
||||
+43
-40
@@ -21,7 +21,7 @@
|
||||
|
||||
🔄 **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
|
||||
|
||||
@@ -37,19 +37,24 @@
|
||||
|
||||
### Currently Supported ✅
|
||||
|
||||
| Platform | Status | Sync Method | Notes |
|
||||
| ---------------- | ------------------ | --------------------------- | ------------------------------ |
|
||||
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
||||
| **KOReader** | ✅ Fully Supported | Wi-Fi (Calibre-compatible) | Kindle, Kobo, PocketBook, etc. |
|
||||
| **Kobo Devices** | ✅ Fully Supported | Wi-Fi (Kobo API-compatible) | Clara, Libra, Sage, etc. |
|
||||
| Platform | Status | Sync Method | Notes |
|
||||
| ---------------- | ------------------ | --------------------------- | ---------------------------------- |
|
||||
| **Web Browser** | ✅ Fully Supported | Real-time WebSocket | Any modern browser |
|
||||
| **KOReader** | ✅ Fully Supported | Wi-Fi (Bookhoard plugin) | Kindle, Kobo, PocketBook hardware |
|
||||
|
||||
### Coming Soon 🚧
|
||||
|
||||
| Platform | Expected Release |
|
||||
| --------------------- | ---------------- |
|
||||
| **Mobile Apps** | Q2 2026 |
|
||||
| **Kindle Devices** | Q3 2026 |
|
||||
| **Remarkable Tablet** | Q4 2026 |
|
||||
| Platform | Status |
|
||||
| --------------------- | ------------------------------------------------------------- |
|
||||
| **Kobo Devices** | Native sync coming soon — use KOReader on Kobo hardware today |
|
||||
| **Mobile Apps** | Android/iOS apps coming later |
|
||||
|
||||
### 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:
|
||||
|
||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Kobo e-reader configuration
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - KOReader configuration (Kindle, Kobo, and PocketBook hardware)
|
||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon; use KOReader today)
|
||||
|
||||
### Quick Overview
|
||||
|
||||
**Registration Process**:
|
||||
**Registration Process** (KOReader):
|
||||
|
||||
1. Register device in Bookhoard web interface (Settings → Devices)
|
||||
2. Approve device via QR code or approval URL
|
||||
3. Configure sync settings on your device
|
||||
4. Start reading - progress syncs automatically!
|
||||
1. Install the Bookhoard plugin and enter your server URL in KOReader
|
||||
2. Approve the pending registration on the Bookhoard **Devices** page (sidebar navigation)
|
||||
3. That's it — sync starts automatically once approved
|
||||
|
||||
**Device Management**:
|
||||
|
||||
```
|
||||
Settings → Devices
|
||||
Devices page (sidebar navigation)
|
||||
```
|
||||
|
||||
You can:
|
||||
@@ -124,7 +128,7 @@ Sometimes a book on your device can't be automatically matched to your library.
|
||||
### Viewing Unlinked Books
|
||||
|
||||
```
|
||||
Settings → Devices → Select Device → View Unlinked Books
|
||||
Devices page → select device → unlinked books
|
||||
```
|
||||
|
||||
### Resolving Unlinked Books
|
||||
@@ -284,7 +288,7 @@ This ensures your highlights work across all devices, even with different page c
|
||||
|
||||
**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
|
||||
3. Check sync URL is correct
|
||||
4. Ensure device has network connection
|
||||
@@ -318,7 +322,7 @@ This ensures your highlights work across all devices, even with different page c
|
||||
|
||||
**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
|
||||
3. Choose which device's progress to keep
|
||||
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
|
||||
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**:
|
||||
|
||||
- Use checkpoint mode when on cellular data
|
||||
- Use checkpoint mode when on slow connections
|
||||
- Keep device firmware updated
|
||||
- Use Wi-Fi when available
|
||||
- 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
|
||||
- **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**:
|
||||
|
||||
@@ -393,7 +397,7 @@ This ensures your highlights work across all devices, even with different page c
|
||||
**Manual Resolution**:
|
||||
|
||||
```
|
||||
Settings → Conflicts → Select conflict → Choose winner
|
||||
Book detail → Sync Progress → choose winner
|
||||
```
|
||||
|
||||
**Options**:
|
||||
@@ -408,7 +412,7 @@ Settings → Conflicts → Select conflict → Choose winner
|
||||
**View Queue Status**:
|
||||
|
||||
```
|
||||
Settings → Devices → Select Device → View Queue
|
||||
Devices page → sync queue section
|
||||
```
|
||||
|
||||
**Queue Stats**:
|
||||
@@ -436,7 +440,7 @@ Settings → Devices → Select Device → View Queue
|
||||
**View History**:
|
||||
|
||||
```
|
||||
Book → Reading History
|
||||
Progress page (sidebar navigation), or the book's detail page
|
||||
```
|
||||
|
||||
**Privacy**:
|
||||
@@ -503,9 +507,8 @@ Book → Reading History
|
||||
### For Better Battery Life
|
||||
|
||||
1. **Checkpoint mode** - Fewer sync requests
|
||||
2. **Wi-Fi only** - Disable cellular
|
||||
3. **Increase sync interval** - Fewer updates
|
||||
4. **Close when not reading** - Reduces background activity
|
||||
2. **Increase sync interval** - Fewer updates
|
||||
3. **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.
|
||||
|
||||
**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?**
|
||||
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.
|
||||
|
||||
**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?**
|
||||
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
|
||||
|
||||
### Version 1.0.0 (January 2026)
|
||||
### Version 1.0.x (2026)
|
||||
|
||||
- ✅ Initial release
|
||||
- ✅ KOReader sync support
|
||||
- ✅ Kobo device support
|
||||
- ✅ Web sync support
|
||||
- ✅ KOReader sync (progress, bookmarks, highlights, notes)
|
||||
- ✅ Conflict resolution
|
||||
- ✅ Offline queue
|
||||
- ✅ Real-time WebSocket sync
|
||||
- 🚧 Native Kobo sync (coming soon)
|
||||
- 🚧 Mobile apps (coming later)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: January 31, 2026
|
||||
**Version**: 1.0.0
|
||||
**License**: MIT
|
||||
**Last Updated**: August 2026
|
||||
**Version**: 1.0
|
||||
**License**: GPL-3.0
|
||||
|
||||
+8
-6
@@ -18,10 +18,12 @@ Bookhoard includes multiple color themes to suit your preferences:
|
||||
|
||||
### Changing Your Theme
|
||||
|
||||
1. Click the theme icon (palette) in the header
|
||||
2. Select your preferred color theme
|
||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||
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
|
||||
|
||||
On small screens, open the sidebar with the menu button in the top bar first.
|
||||
|
||||
## Wood Paneling
|
||||
|
||||
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
|
||||
|
||||
1. Click the theme icon (palette) in the header
|
||||
2. Scroll to "Bookshelf Background" section
|
||||
3. Select your preferred wood texture
|
||||
4. Texture is applied to dashboard bookshelf only
|
||||
1. In the sidebar, open the **Appearance** panel (palette icon, near the bottom)
|
||||
2. Scroll to the **Bookshelf** section below the theme list
|
||||
3. Select your preferred wood texture (each option shows a texture swatch; **None** is the default)
|
||||
4. Texture is applied to the dashboard bookshelf background
|
||||
|
||||
**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:
|
||||
|
||||
- **[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
|
||||
- Installation on Kindle/Kobo/PocketBook
|
||||
- Sync setup
|
||||
- Plugin setup with server-side device approval
|
||||
- Progress, bookmark, highlight, and note sync
|
||||
- OPDS catalog access
|
||||
- Troubleshooting
|
||||
|
||||
- **[Kobo Setup Guide](devices/kobo-setup.md)** - Native Kobo sync (coming soon)
|
||||
- In the meantime, KOReader works great on Kobo hardware
|
||||
|
||||
## 🔄 Sync Configuration
|
||||
|
||||
- **[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-highlighting v0.0.0-20220208100518-594be1970594
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
)
|
||||
|
||||
@@ -57,7 +58,6 @@ require (
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/xyproto/randomstring v1.2.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/sys v0.43.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)
|
||||
}
|
||||
|
||||
// GetBaseURL returns the base URL from system configuration database with fallback to config/env var
|
||||
func GetBaseURL(ctx context.Context, db interface{}) string {
|
||||
// Try to get from database first
|
||||
type SystemConfigQuerier interface {
|
||||
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
|
||||
}
|
||||
// SystemConfigGetter returns the value for a system config key, or an error.
|
||||
type SystemConfigGetter func(ctx context.Context, key string) (string, error)
|
||||
|
||||
if querier, ok := db.(SystemConfigQuerier); ok {
|
||||
config, err := querier.GetSystemConfig(ctx, "base_url")
|
||||
if err == nil && config.Value != "" {
|
||||
return config.Value
|
||||
}
|
||||
// GetBaseURL returns the base URL from system configuration database, or empty
|
||||
// string if not set. The getter abstraction avoids importing the database package.
|
||||
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
|
||||
val, err := getter(ctx, "base_url")
|
||||
if err == nil && val != "" {
|
||||
return val
|
||||
}
|
||||
|
||||
// Fallback: return empty string - caller should use their own fallback
|
||||
return ""
|
||||
}
|
||||
|
||||
// SystemConfigRow represents a system configuration row
|
||||
type SystemConfigRow struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
|
||||
+73
-36
@@ -92,6 +92,17 @@ type DictionaryCache struct {
|
||||
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 {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
@@ -155,40 +166,55 @@ type LibraryVisibility struct {
|
||||
}
|
||||
|
||||
type MediaBookmarks struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
||||
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
||||
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Position pgtype.Text `db:"position" json:"position"`
|
||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
||||
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
||||
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Position pgtype.Text `db:"position" json:"position"`
|
||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||
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 {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
SelectionText string `db:"selection_text" json:"selection_text"`
|
||||
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||
Color pgtype.Text `db:"color" json:"color"`
|
||||
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
||||
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
||||
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
||||
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
||||
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
SelectionText string `db:"selection_text" json:"selection_text"`
|
||||
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||
Color pgtype.Text `db:"color" json:"color"`
|
||||
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
||||
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
||||
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
||||
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
||||
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||
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 {
|
||||
@@ -304,6 +330,11 @@ type MediaNotes struct {
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
||||
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 {
|
||||
@@ -380,6 +411,7 @@ type ReadingProgress struct {
|
||||
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
||||
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
||||
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
||||
ContextText pgtype.Text `db:"context_text" json:"context_text"`
|
||||
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
||||
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
||||
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
||||
@@ -463,11 +495,16 @@ type SystemConfig struct {
|
||||
}
|
||||
|
||||
type SystemSettings struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
SettingKey string `db:"setting_key" json:"setting_key"`
|
||||
SettingValue string `db:"setting_value" json:"setting_value"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
SettingKey string `db:"setting_key" json:"setting_key"`
|
||||
SettingValue string `db:"setting_value" json:"setting_value"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
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 {
|
||||
|
||||
@@ -26,13 +26,15 @@ type Querier interface {
|
||||
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
||||
// Cleanup expired OPDS tokens
|
||||
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
|
||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
// Count unlinked books for a device
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||
// COLLECTIONS QUERIES
|
||||
// Create collection
|
||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
||||
@@ -51,11 +53,17 @@ type Querier interface {
|
||||
// Create device shelf mapping
|
||||
CreateDeviceShelfMapping(ctx context.Context, arg CreateDeviceShelfMappingParams) (DeviceShelfMappings, 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
|
||||
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
||||
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
||||
CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error)
|
||||
// Media Highlights queries
|
||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
||||
// Media Items queries
|
||||
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||
// MEDIA ITEM FORMATS QUERIES
|
||||
@@ -63,6 +71,7 @@ type Querier interface {
|
||||
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
||||
// Media Notes queries
|
||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
|
||||
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
||||
// OPDS TOKENS QUERIES
|
||||
// Create OPDS token
|
||||
@@ -124,10 +133,17 @@ type Querier interface {
|
||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUser(ctx context.Context, id pgtype.UUID) 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)
|
||||
// ============================================
|
||||
// ANNOTATION SERVE QUERIES
|
||||
// ============================================
|
||||
GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error)
|
||||
// Get all system config
|
||||
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||
// Get collection
|
||||
@@ -174,6 +190,7 @@ type Querier interface {
|
||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, 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)
|
||||
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
||||
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
|
||||
@@ -196,8 +213,17 @@ type Querier interface {
|
||||
// LIBRARY WITH TYPE INFO QUERIES
|
||||
// ============================================================================
|
||||
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)
|
||||
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)
|
||||
GetMediaItem(ctx context.Context, id pgtype.UUID) (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)
|
||||
// Get media item by SHA-256 hash
|
||||
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
|
||||
GetMediaItemFormatBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItemFormats, error)
|
||||
// Get media item format by type
|
||||
GetMediaItemFormatByType(ctx context.Context, arg GetMediaItemFormatByTypeParams) (MediaItemFormats, error)
|
||||
// Get media item formats
|
||||
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)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (notes)
|
||||
// ============================================
|
||||
GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error)
|
||||
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, 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)
|
||||
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (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)
|
||||
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, 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)
|
||||
// System Settings queries
|
||||
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
||||
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
|
||||
GetSystemTimezone(ctx context.Context) (string, error)
|
||||
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||
// Get universal progress for a book
|
||||
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
||||
// Get unlinked book by ContentId
|
||||
@@ -280,6 +316,8 @@ type Querier interface {
|
||||
// Get user reading history for analytics
|
||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, 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)
|
||||
// Check if book is in collection
|
||||
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
||||
@@ -289,12 +327,25 @@ type Querier interface {
|
||||
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
||||
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, 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)
|
||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, 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)
|
||||
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
|
||||
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||
@@ -302,6 +353,14 @@ type Querier interface {
|
||||
// List unresolved unlinked books with pagination
|
||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, 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
|
||||
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
||||
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
||||
@@ -309,11 +368,17 @@ type Querier interface {
|
||||
// Remove book from collection
|
||||
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) 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
|
||||
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
||||
ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error)
|
||||
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
|
||||
// Resolve unlinked book
|
||||
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
|
||||
RevokeDevice(ctx context.Context, id pgtype.UUID) error
|
||||
// Revoke OPDS token
|
||||
@@ -332,6 +397,12 @@ type Querier interface {
|
||||
// Set system config
|
||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, 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
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
@@ -355,7 +426,9 @@ type Querier interface {
|
||||
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
||||
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, 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)
|
||||
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
||||
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||
// Update media item format
|
||||
@@ -373,6 +446,7 @@ type Querier interface {
|
||||
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
||||
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, 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)
|
||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||
@@ -391,6 +465,7 @@ type Querier interface {
|
||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
-- 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)
|
||||
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 *;
|
||||
|
||||
-- name: GetMediaItem :one
|
||||
@@ -346,6 +355,9 @@ WHERE role = 'admin'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CountAdmins :one
|
||||
SELECT COUNT(*) FROM users WHERE role = 'admin';
|
||||
|
||||
-- name: ReassignLibraries :exec
|
||||
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
|
||||
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
|
||||
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
|
||||
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
||||
VALUES ($1, $2, $3)
|
||||
@@ -693,7 +725,7 @@ RETURNING *;
|
||||
SELECT * FROM media_notes WHERE id = $1;
|
||||
|
||||
-- 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
|
||||
UPDATE media_notes SET
|
||||
@@ -716,7 +748,7 @@ RETURNING *;
|
||||
SELECT * FROM media_highlights WHERE id = $1;
|
||||
|
||||
-- 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
|
||||
UPDATE media_highlights SET
|
||||
@@ -732,6 +764,350 @@ RETURNING *;
|
||||
-- name: DeleteMediaHighlight :exec
|
||||
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
|
||||
-- name: CreateRefreshToken :one
|
||||
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;
|
||||
|
||||
-- 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
|
||||
@@ -793,6 +1169,7 @@ SELECT
|
||||
rp.percentage,
|
||||
rp.character_offset,
|
||||
rp.epubcfi,
|
||||
rp.context_text,
|
||||
rp.chapter,
|
||||
rp.chapter_progress,
|
||||
rp.viewport_x,
|
||||
@@ -825,6 +1202,7 @@ INSERT INTO reading_progress (
|
||||
percentage,
|
||||
character_offset,
|
||||
epubcfi,
|
||||
context_text,
|
||||
chapter,
|
||||
chapter_progress,
|
||||
viewport_x,
|
||||
@@ -842,13 +1220,14 @@ INSERT INTO reading_progress (
|
||||
last_read_at
|
||||
)
|
||||
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)
|
||||
DO UPDATE SET
|
||||
percentage = EXCLUDED.percentage,
|
||||
character_offset = EXCLUDED.character_offset,
|
||||
epubcfi = EXCLUDED.epubcfi,
|
||||
context_text = EXCLUDED.context_text,
|
||||
chapter = EXCLUDED.chapter,
|
||||
chapter_progress = EXCLUDED.chapter_progress,
|
||||
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)
|
||||
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
|
||||
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
|
||||
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
|
||||
-- ============================================
|
||||
@@ -1210,7 +1603,7 @@ SELECT
|
||||
mh.epubcfi_start,
|
||||
mh.epubcfi_end
|
||||
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
|
||||
SELECT
|
||||
mn.id,
|
||||
@@ -1226,7 +1619,7 @@ SELECT
|
||||
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
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateDeviceSyncTimestamp :one
|
||||
@@ -1414,6 +1807,70 @@ RETURNING *;
|
||||
-- name: GetMediaItemBySHA256 :one
|
||||
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
|
||||
-- name: GetMediaItemByOPFIdentifier :one
|
||||
SELECT * FROM media_items WHERE opf_identifier = $1;
|
||||
@@ -1461,6 +1918,11 @@ ORDER BY confidence_score DESC;
|
||||
-- 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)
|
||||
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 *;
|
||||
|
||||
-- Get media item formats
|
||||
@@ -1830,7 +2292,7 @@ LIMIT $1 OFFSET $2;
|
||||
-- Dashboard preferences queries
|
||||
-- name: GetDashboardPreferences :one
|
||||
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
|
||||
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 (
|
||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||
FROM reading_progress
|
||||
WHERE user_id = $2
|
||||
WHERE user_id = sqlc.narg('user_id')
|
||||
AND percentage > 0
|
||||
AND percentage < 1
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) 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
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetRecentlyAddedItems :many
|
||||
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
|
||||
LIMIT $2;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetRecentlyReadItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
INNER JOIN (
|
||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||
FROM reading_progress
|
||||
WHERE user_id = $2
|
||||
WHERE user_id = sqlc.narg('user_id')
|
||||
AND percentage >= 1
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) 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
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetNotStartedItems :many
|
||||
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 (
|
||||
SELECT 1 FROM reading_progress rp
|
||||
WHERE rp.media_item_id = mi.id
|
||||
AND rp.user_id = $2
|
||||
AND rp.user_id = sqlc.narg('user_id')
|
||||
AND rp.percentage > 0
|
||||
)
|
||||
ORDER BY mi.created_at DESC
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetCollectionItemsForDashboard :many
|
||||
SELECT mi.*, ci.excluded FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
AND mi.library_id = $2
|
||||
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
ORDER BY ci.added_at DESC
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetLibraryItems :many
|
||||
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;
|
||||
|
||||
-- name: GetDistinctSeries :many
|
||||
@@ -1952,26 +2414,26 @@ SELECT series, COUNT(*) as book_count,
|
||||
MAX(series_count) as total_in_series,
|
||||
MAX(created_at) as last_entry_at
|
||||
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
|
||||
ORDER BY MAX(created_at) DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: GetDistinctSeriesCount :one
|
||||
SELECT COUNT(DISTINCT series)::int
|
||||
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
|
||||
SELECT cover_image_path, library_id
|
||||
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
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetSeriesBooks :many
|
||||
SELECT * FROM media_items
|
||||
WHERE library_id = $1 AND series = $2
|
||||
WHERE series = sqlc.narg('series')
|
||||
ORDER BY series_number ASC NULLS LAST;
|
||||
|
||||
-- name: GetContinueSeriesItems :many
|
||||
@@ -1981,10 +2443,10 @@ WITH user_series_progress AS (
|
||||
MAX(rp.last_read_at) as last_read_at
|
||||
FROM reading_progress rp
|
||||
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 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
|
||||
),
|
||||
next_books AS (
|
||||
@@ -1992,13 +2454,13 @@ next_books AS (
|
||||
usp.last_read_at
|
||||
FROM media_items mi
|
||||
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)
|
||||
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
||||
)
|
||||
SELECT * FROM next_books
|
||||
ORDER BY last_read_at DESC NULLS LAST
|
||||
LIMIT $3;
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetSavedFilters :many
|
||||
SELECT * FROM saved_filters
|
||||
@@ -2097,9 +2559,12 @@ RETURNING *;
|
||||
|
||||
-- name: GetMediaBookmarks :many
|
||||
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;
|
||||
|
||||
-- name: GetMediaBookmark :one
|
||||
SELECT * FROM media_bookmarks WHERE id = $1;
|
||||
|
||||
-- name: CreateMediaBookmark :one
|
||||
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)
|
||||
@@ -2115,7 +2580,7 @@ SET
|
||||
title = $2,
|
||||
notes = $3,
|
||||
position = $4,
|
||||
updated_at = NOW()
|
||||
last_modified_at = NOW()
|
||||
WHERE id = $1 AND user_id = $5
|
||||
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 (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/middleware"
|
||||
"bookhoard/internal/setupstatus"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -25,14 +26,34 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// Session duration constants
|
||||
// Follows same pattern as refresh_token.go
|
||||
SessionDuration = 7 * 24 * time.Hour // 7 days
|
||||
// DefaultSessionDuration is the fallback session duration used when no
|
||||
// settings registry is wired (matches the historical 7-day value).
|
||||
DefaultSessionDuration = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// SessionDurationSec is the session duration in seconds for use in cookies and API responses
|
||||
// Note: This is computed from SessionDuration to avoid magic numbers
|
||||
var SessionDurationSec = int(SessionDuration.Seconds())
|
||||
// SessionDurationSec is retained for backward compatibility; new code uses the
|
||||
// registry via AuthHandler.sessionDuration().
|
||||
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")
|
||||
|
||||
@@ -40,6 +61,7 @@ type AuthHandler struct {
|
||||
db *database.Queries
|
||||
jwtKey []byte
|
||||
loginAttemptTracker *middleware.LoginAttemptTracker
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
||||
@@ -82,22 +104,22 @@ type UserProfile struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type AdminUpdateUserRequest 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"`
|
||||
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/auth/register
|
||||
@@ -190,7 +212,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
}
|
||||
|
||||
var userRole string
|
||||
if len(users) == 0 {
|
||||
if !adminExists {
|
||||
userRole = "admin"
|
||||
} else {
|
||||
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()})
|
||||
}
|
||||
|
||||
// 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 c.Request().Header.Get("HX-Request") == "true" {
|
||||
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,
|
||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: SessionDurationSec,
|
||||
MaxAge: int(h.sessionDuration().Seconds()),
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
|
||||
@@ -293,7 +319,7 @@ window.location.href = '/dashboard';
|
||||
Token: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: SessionDurationSec,
|
||||
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||
User: UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
@@ -406,7 +432,7 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
||||
HttpOnly: true,
|
||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
MaxAge: SessionDurationSec,
|
||||
MaxAge: int(h.sessionDuration().Seconds()),
|
||||
}
|
||||
c.SetCookie(cookie)
|
||||
|
||||
@@ -445,7 +471,7 @@ window.location.href = '%s';
|
||||
Token: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: SessionDurationSec,
|
||||
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||
User: UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
@@ -548,6 +574,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
if err != nil {
|
||||
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)
|
||||
@@ -785,9 +814,9 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
|
||||
}
|
||||
|
||||
type PasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password,omitempty"`
|
||||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||||
CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
|
||||
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
|
||||
}
|
||||
|
||||
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()})
|
||||
}
|
||||
|
||||
// Deletion may have changed the admin count, so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
|
||||
// Create success message based on context
|
||||
var message string
|
||||
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
||||
@@ -1003,7 +1035,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user
|
||||
"user_role": userRole,
|
||||
"user_email": userEmail,
|
||||
"user_username": userUsername,
|
||||
"exp": time.Now().Add(SessionDuration).Unix(),
|
||||
"exp": time.Now().Add(h.sessionDuration()).Unix(),
|
||||
"iat": time.Now().Unix(),
|
||||
}
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
|
||||
@@ -73,6 +73,7 @@ type BookInfo struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
HasConflict bool `json:"has_conflict"`
|
||||
}
|
||||
|
||||
type SectionData struct {
|
||||
@@ -138,6 +139,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
includeAuto := c.QueryParam("include_auto") == "true"
|
||||
sortBy := c.QueryParam("sort_by")
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
collections, err := h.GetCollectionsData(c)
|
||||
if err != nil {
|
||||
@@ -152,6 +154,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
Icon string `json:"icon"`
|
||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
BookCount int `json:"book_count"`
|
||||
}
|
||||
|
||||
response := make([]CollectionResponse, 0, len(collections))
|
||||
@@ -159,6 +162,26 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
||||
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{
|
||||
ID: col.ID.Bytes,
|
||||
Name: col.Name,
|
||||
@@ -167,6 +190,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||
Icon: textToString(col.Icon),
|
||||
AutoAssignRules: col.AutoAssignRules,
|
||||
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"})
|
||||
}
|
||||
|
||||
books, err := h.GetCollectionBooksData(c, collectionID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
libraryID := c.QueryParam("library_id")
|
||||
var bookList []BookInfo
|
||||
|
||||
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))
|
||||
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),
|
||||
})
|
||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||
user := c.Get("user").(database.Users)
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
dashboardSvc := services.NewDashboardService(h.db)
|
||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||
if secErr != nil {
|
||||
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{}
|
||||
|
||||
@@ -28,7 +28,7 @@ func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManag
|
||||
}
|
||||
|
||||
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"`
|
||||
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
|
||||
Reason string `json:"reason"`
|
||||
@@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -235,8 +235,10 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
winnerData := map[string]interface{}{}
|
||||
winnerSource := req.Winner
|
||||
if req.Winner == "manual" {
|
||||
winnerData = req.ManualData
|
||||
winnerSource = "manual"
|
||||
} else {
|
||||
source, ok := conflictData[req.Winner]
|
||||
if !ok {
|
||||
@@ -251,11 +253,17 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
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{}{
|
||||
"winner": req.Winner,
|
||||
"applied_to": appliedTo,
|
||||
@@ -286,7 +294,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
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()
|
||||
|
||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
@@ -342,7 +350,7 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
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{},
|
||||
ScrollPositionX: pgtype.Float8{},
|
||||
ScrollPositionY: pgtype.Float8{},
|
||||
@@ -354,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
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 {
|
||||
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
||||
if err != nil {
|
||||
@@ -551,7 +696,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
||||
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{
|
||||
ConflictID: conflictIDStr,
|
||||
Status: "error",
|
||||
@@ -635,7 +780,7 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
|
||||
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()
|
||||
|
||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
@@ -690,8 +835,8 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
|
||||
CharacterOffset: characterOffset,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "bulk", Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
||||
ViewportY: pgtype.Float8{},
|
||||
ScrollPositionX: pgtype.Float8{},
|
||||
ScrollPositionY: pgtype.Float8{},
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -30,12 +31,13 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
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"})
|
||||
var libUUID pgtype.UUID
|
||||
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}
|
||||
}
|
||||
|
||||
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 = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||||
}
|
||||
@@ -187,6 +190,59 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
|
||||
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 {
|
||||
if collectionID != "" {
|
||||
if libraryID != "" {
|
||||
@@ -202,14 +258,15 @@ func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
||||
var libUUID pgtype.UUID
|
||||
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)
|
||||
if err != nil {
|
||||
// Return default preferences instead of 404 when none exist
|
||||
|
||||
@@ -100,6 +100,10 @@ type PendingRegistration struct {
|
||||
UserID uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
Approved bool
|
||||
AuthToken string
|
||||
DeviceID [16]byte
|
||||
SyncEndpoints map[string]string
|
||||
}
|
||||
|
||||
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"})
|
||||
}
|
||||
|
||||
if registration.UserID == (uuid.UUID{}) {
|
||||
if registration.Approved {
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
|
||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||
Status: "pending",
|
||||
Message: "awaiting user approval",
|
||||
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||
Status: "approved",
|
||||
AuthToken: registration.AuthToken,
|
||||
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{
|
||||
Status: "approved",
|
||||
AuthToken: authToken,
|
||||
DeviceID: device.ID.Bytes,
|
||||
SyncEndpoints: syncEndpoints,
|
||||
Status: "pending",
|
||||
Message: "awaiting user approval",
|
||||
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -597,14 +562,63 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
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.Approved = true
|
||||
registration.AuthToken = authToken
|
||||
registration.DeviceID = device.ID.Bytes
|
||||
registration.SyncEndpoints = syncEndpoints
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device approved successfully",
|
||||
"device_name": registration.DeviceName,
|
||||
"device_type": registration.DeviceType,
|
||||
"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) {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registrations := []map[string]interface{}{}
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||
if reg.UserID == (uuid.UUID{}) {
|
||||
registrations = append(registrations, map[string]interface{}{
|
||||
"registration_id": reg.RegistrationID,
|
||||
"device_name": reg.DeviceName,
|
||||
@@ -640,7 +648,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
||||
"device_identifier": reg.DeviceIdentifier,
|
||||
"expires_at": reg.ExpiresAt,
|
||||
"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 (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -15,19 +18,30 @@ import (
|
||||
)
|
||||
|
||||
type KoboHandler struct {
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
progressSvc *wsync.ProgressService
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
libraryService LibraryPathResolver
|
||||
bookResolver *services.BookResolver
|
||||
}
|
||||
|
||||
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) {
|
||||
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
|
||||
// Enhanced Kobo Sync - ContentId Mapping Logic
|
||||
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
|
||||
if len(contentId) == 64 && looksLikeSHA256(contentId) {
|
||||
// Try to find media item by SHA-256
|
||||
mediaItem, err := h.db.GetMediaItemBySHA256(ctx.Request().Context(), pgtype.Text{String: contentId, Valid: true})
|
||||
// Try to find media item by SHA-256 (format-aware: also checks
|
||||
// media_item_formats, so a converted/alternate format hash matches).
|
||||
mediaItem, _, err := h.bookResolver.ResolveBySHA256(ctx.Request().Context(), contentId)
|
||||
if err == nil {
|
||||
// Found by SHA-256! Create device catalog entry for future lookups
|
||||
_, _ = h.db.CreateDeviceCatalog(ctx.Request().Context(), database.CreateDeviceCatalogParams{
|
||||
@@ -239,9 +254,16 @@ type KoboInitResponse struct {
|
||||
}
|
||||
|
||||
type KoboSyncStatus struct {
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
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 {
|
||||
@@ -305,7 +327,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
|
||||
}
|
||||
|
||||
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},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -397,6 +419,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
markupsSynced := 0
|
||||
bookmarksSynced := 0
|
||||
unlinkedBooks := 0
|
||||
processedBooks := make(map[pgtype.UUID]string)
|
||||
|
||||
for _, readingSync := range req.ReadingSync {
|
||||
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}
|
||||
processedBooks[pgMediaUUID] = readingSync.ContentId
|
||||
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 {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
@@ -415,6 +453,8 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
@@ -443,29 +483,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
|
||||
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
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++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
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":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
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":
|
||||
if bookmarkSync.BookmarkId != "" {
|
||||
@@ -479,6 +562,23 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
chapter := bookmarkSync.Chapter
|
||||
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 {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
@@ -486,6 +586,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Epubcfi: &epubcfi,
|
||||
ContextText: contextText,
|
||||
Chapter: &chapter,
|
||||
ChapterProgress: &chapterProgress,
|
||||
DeviceType: "kobo",
|
||||
@@ -524,6 +625,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
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
|
||||
if unlinkedBooks > 0 {
|
||||
// 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 {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
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++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
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":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
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 {
|
||||
if bookmark.BookmarkType == "bookmark" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmark.BookmarkText,
|
||||
Position: bookmark.BookmarkId,
|
||||
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" {
|
||||
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++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: bookmark.BookmarkId,
|
||||
EndPosition: bookmark.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
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 {
|
||||
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++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: highlight.BookmarkId,
|
||||
EndPosition: highlight.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -114,20 +115,51 @@ type UpdateMediaNoteRequest struct {
|
||||
|
||||
// CreateMediaHighlightRequest represents the request for creating a media highlight
|
||||
type CreateMediaHighlightRequest struct {
|
||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||
StartPosition string `json:"start_position" validate:"required,max=100"`
|
||||
EndPosition string `json:"end_position" validate:"required,max=100"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteID string `json:"note_id"`
|
||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||
StartPosition string `json:"start_position" validate:"max=1000"`
|
||||
EndPosition string `json:"end_position" validate:"max=1000"`
|
||||
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||
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
|
||||
type UpdateMediaHighlightRequest struct {
|
||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||
StartPosition string `json:"start_position" validate:"required,max=100"`
|
||||
EndPosition string `json:"end_position" validate:"required,max=100"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteID string `json:"note_id"`
|
||||
SelectionText string `json:"selection_text" validate:"required,min=1,max=5000"`
|
||||
StartPosition string `json:"start_position" validate:"max=1000"`
|
||||
EndPosition string `json:"end_position" validate:"max=1000"`
|
||||
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||
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 {
|
||||
@@ -136,6 +168,7 @@ type MediaHandler struct {
|
||||
libraryService *services.LibraryService
|
||||
searchService *services.SearchService
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
mh.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||
if err != nil {
|
||||
@@ -975,6 +1012,7 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
CurrentPage *int32 `json:"current_page"`
|
||||
TotalPages *int32 `json:"total_pages"`
|
||||
Epubcfi *string `json:"epubcfi"`
|
||||
ContextText *string `json:"context_text"`
|
||||
Percentage *float64 `json:"percentage"`
|
||||
Chapter *int `json:"chapter"`
|
||||
ChapterProgress *float64 `json:"chapter_progress"`
|
||||
@@ -996,6 +1034,7 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Percentage: req.Percentage,
|
||||
Epubcfi: req.Epubcfi,
|
||||
ContextText: req.ContextText,
|
||||
CharacterOffset: req.CharacterOffset,
|
||||
Chapter: req.Chapter,
|
||||
ChapterProgress: req.ChapterProgress,
|
||||
@@ -1018,6 +1057,16 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
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)
|
||||
if err != nil {
|
||||
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()})
|
||||
}
|
||||
|
||||
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()})
|
||||
var note database.MediaNotes
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: req.Position,
|
||||
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)
|
||||
@@ -1444,7 +1510,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgtype.Text{String: req.StartPosition, 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
|
||||
}
|
||||
|
||||
// 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{
|
||||
ID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
||||
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"})
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// Supports two modes:
|
||||
// 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{}{},
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,4 +18,8 @@ type MediaDetail struct {
|
||||
// Computed counts
|
||||
NotesCount int `json:"notes_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 {
|
||||
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 {
|
||||
@@ -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) {
|
||||
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
|
||||
var dbBaseURL string
|
||||
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
|
||||
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"
|
||||
return baseURL.Value, opdsBaseURL, nil
|
||||
// self reflects the current page; start/first point to the first page
|
||||
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
|
||||
@@ -65,9 +188,10 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
perPageNum := 50
|
||||
perPageNum := h.opdsDefaultPageSize()
|
||||
maxPerPage := h.opdsMaxPageSize()
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -139,13 +263,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
"Bookhoard Library",
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
// Feed links, including OPDS pagination links (first/previous/next/last) and
|
||||
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
|
||||
token := h.getAuthToken(c)
|
||||
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)
|
||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
||||
// OpenSearch: the search link points to an OpenSearch description document
|
||||
// (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
|
||||
for _, item := range allItems {
|
||||
@@ -170,16 +298,21 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
entry.SetSummary(item.Description.String)
|
||||
}
|
||||
|
||||
// Add acquisition links
|
||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||
// Add acquisition link using the item's real mime type
|
||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||
|
||||
// Add format variants
|
||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic
|
||||
// archives are served as-is in their native format.
|
||||
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)
|
||||
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||
pdfURL := downloadURL + "&format=pdf"
|
||||
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||
}
|
||||
|
||||
// Add canonical identifier
|
||||
entry.SetIdentifier(bookUUID)
|
||||
@@ -213,16 +346,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
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 {
|
||||
deviceID := c.Param("deviceId")
|
||||
|
||||
query := c.QueryParam("q")
|
||||
|
||||
if query == "" {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
|
||||
}
|
||||
|
||||
// Get base URLs
|
||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||
if err != nil {
|
||||
@@ -243,12 +377,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
// Get user's visible libraries
|
||||
userID := device.UserID.Bytes
|
||||
|
||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
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
|
||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
@@ -267,11 +419,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(catalogURL, catalogMediaType, "start")
|
||||
|
||||
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query)
|
||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
||||
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)
|
||||
userUUID := uuid.UUID(userID)
|
||||
@@ -296,11 +451,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
entry.SetSummary(item.Description.String)
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||
|
||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
// Only offer kepub conversion for ebooks; comic archives are served
|
||||
// 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)
|
||||
|
||||
@@ -446,6 +605,12 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
||||
if mediaItem.MimeType.Valid {
|
||||
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
|
||||
@@ -599,7 +764,7 @@ func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
|
||||
)
|
||||
|
||||
// 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", "self")
|
||||
|
||||
@@ -682,14 +847,14 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
||||
|
||||
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)
|
||||
if mediaItem.FileSize.Valid {
|
||||
fileSize = mediaItem.FileSize.Int64
|
||||
}
|
||||
|
||||
formatList = append(formatList, FormatInfo{
|
||||
FormatType: "epub",
|
||||
FormatType: formatLabelFromPath(mediaItem.FilePath),
|
||||
FilePath: mediaItem.FilePath,
|
||||
FileSha256: func() string {
|
||||
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"})
|
||||
}
|
||||
|
||||
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{}{
|
||||
"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 (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -139,3 +140,8 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
||||
"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
|
||||
}
|
||||
|
||||
lastUpdated := ""
|
||||
if progress.LastReadAt.Valid {
|
||||
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||
}
|
||||
|
||||
progressList = append(progressList, ProgressWithMedia{
|
||||
MediaItemID: progress.MediaItemID.Bytes,
|
||||
Title: mediaItem.Title,
|
||||
@@ -386,6 +391,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
||||
Epubcfi: epubcfi,
|
||||
LastSyncDevice: deviceName,
|
||||
ProgressPercentage: progress.Percentage.Float64 * 100,
|
||||
EpubCFI: epubcfi,
|
||||
LastUpdated: lastUpdated,
|
||||
DeviceIcon: getDeviceIcon(deviceName),
|
||||
DeviceName: deviceName,
|
||||
DeviceType: deviceName,
|
||||
FormatGroup: mediaItem.FormatGroup,
|
||||
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
})
|
||||
|
||||
@@ -14,10 +14,6 @@ import (
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
const (
|
||||
refreshTokenExpiration = 7 * 24 * time.Hour // 7 days
|
||||
)
|
||||
|
||||
type RefreshTokenRequest struct {
|
||||
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{
|
||||
AccessToken: accessToken,
|
||||
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()
|
||||
refreshToken := tokenUUID.String()
|
||||
|
||||
expiresAt := time.Now().Add(refreshTokenExpiration)
|
||||
expiresAt := time.Now().Add(h.refreshTokenTTL())
|
||||
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, 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"})
|
||||
}
|
||||
|
||||
// Set the folder paths
|
||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
||||
// Set the folder paths (watch=true: this long-lived scanner reads events)
|
||||
if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+10
-17
@@ -9,6 +9,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
@@ -24,12 +25,13 @@ func NewSeriesHandler(db *database.Queries) *SeriesHandler {
|
||||
|
||||
func (h *SeriesHandler) GetSeries(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"})
|
||||
var libUUID pgtype.UUID
|
||||
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}
|
||||
}
|
||||
|
||||
limit := 20
|
||||
@@ -81,21 +83,12 @@ func (h *SeriesHandler) GetSeries(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")
|
||||
if seriesName == "" {
|
||||
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 {
|
||||
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)
|
||||
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/setupstatus"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -14,14 +15,19 @@ import (
|
||||
)
|
||||
|
||||
type SidecarHandler struct {
|
||||
db *database.Queries
|
||||
cfg *config.Config
|
||||
db *database.Queries
|
||||
cfg *config.Config
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
||||
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 {
|
||||
Version string `json:"version"`
|
||||
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
|
||||
@@ -81,15 +87,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
||||
userID := device.UserID.Bytes
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
// Get base URL and compute paths
|
||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
if baseURL.Value == "" {
|
||||
baseURL.Value = h.cfg.BaseURL
|
||||
}
|
||||
// Get base URL and compute paths (with request-derived fallback)
|
||||
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||
|
||||
// Generate URLs
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||
|
||||
// Get user's visible libraries with media items
|
||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||
@@ -130,6 +134,21 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
||||
SHA256: item.FileSha256.String,
|
||||
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
|
||||
@@ -176,8 +195,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
||||
Bookhoard: SidecarBookhoardConfig{
|
||||
OPDSCatalog: opdsCatalogURL,
|
||||
SyncAPI: syncAPIURL,
|
||||
OPDSBaseURL: baseURL.Value + "/opds",
|
||||
APIBaseURL: baseURL.Value + "/api",
|
||||
OPDSBaseURL: baseURL + "/opds",
|
||||
APIBaseURL: baseURL + "/api",
|
||||
DeviceID: deviceID.String(),
|
||||
DeviceToken: device.AuthToken,
|
||||
},
|
||||
@@ -219,15 +238,13 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
||||
userID := device.UserID.Bytes
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
// Get base URL and compute paths
|
||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
if baseURL.Value == "" {
|
||||
baseURL.Value = h.cfg.BaseURL
|
||||
}
|
||||
// Get base URL and compute paths (with request-derived fallback)
|
||||
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||
|
||||
// Generate URLs
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||
|
||||
// Get user's visible libraries with media items
|
||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||
@@ -267,6 +284,21 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
||||
SHA256: item.FileSha256.String,
|
||||
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
|
||||
@@ -309,8 +341,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
||||
Bookhoard: SidecarBookhoardConfig{
|
||||
OPDSCatalog: opdsCatalogURL,
|
||||
SyncAPI: syncAPIURL,
|
||||
OPDSBaseURL: baseURL.Value + "/opds",
|
||||
APIBaseURL: baseURL.Value + "/api",
|
||||
OPDSBaseURL: baseURL + "/opds",
|
||||
APIBaseURL: baseURL + "/api",
|
||||
DeviceID: deviceID.String(),
|
||||
DeviceToken: device.AuthToken,
|
||||
},
|
||||
@@ -403,6 +435,9 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
||||
"error": "failed to update default timezone",
|
||||
})
|
||||
}
|
||||
if h.settings != nil {
|
||||
h.settings.Reload(ctx)
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, 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
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
// Fetch updated base_url for template
|
||||
|
||||
@@ -2,17 +2,21 @@ package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
type SystemSettingsHandler struct {
|
||||
db *database.Queries
|
||||
db *database.Queries
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
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 {
|
||||
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
|
||||
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
||||
@@ -51,6 +203,7 @@ func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.reload(c)
|
||||
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()})
|
||||
}
|
||||
|
||||
h.reload(c)
|
||||
|
||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
|
||||
AutoScanEnabled: req.AutoScanEnabled,
|
||||
@@ -96,6 +251,15 @@ func (h *SystemSettingsHandler) UpdateScanSettings(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")
|
||||
if err != nil {
|
||||
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 {
|
||||
db *database.Queries
|
||||
rateLimiter *DeviceRateLimiter
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
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 {
|
||||
return func(c *echo.Context) error {
|
||||
var device database.Devices
|
||||
@@ -115,15 +151,12 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
||||
deviceUUID := uuid.UUID(device.ID.Bytes)
|
||||
deviceID := deviceUUID.String()
|
||||
|
||||
config := DeviceRateLimitConfig{
|
||||
SyncRequestsPerMinute: 60,
|
||||
ProgressUpdatesPerMinute: 120,
|
||||
MetadataRequestsPerMinute: 30,
|
||||
}
|
||||
config := m.rateLimitConfig()
|
||||
limitForType := m.rateLimitForRequestType(requestType, config)
|
||||
|
||||
if !m.rateLimiter.CheckRateLimit(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-Reset", "60")
|
||||
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)
|
||||
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))
|
||||
|
||||
ctx := DeviceContext{
|
||||
|
||||
@@ -1,96 +1,146 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sync"
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// PasswordValidator validates password complexity requirements
|
||||
type PasswordValidator struct{}
|
||||
// specialCharRegex matches the historical "special character" set used by the
|
||||
// password complexity rules.
|
||||
const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`
|
||||
|
||||
// Validate checks if a password meets complexity requirements:
|
||||
// - Minimum 8 characters
|
||||
// - At least one uppercase letter
|
||||
// - At least one lowercase letter
|
||||
// - At least one number
|
||||
// - At least one special character
|
||||
// PasswordValidator validates password complexity against the configured rules.
|
||||
// When a database.SettingsRegistry is wired via SetSettings, rules are read live and the
|
||||
// regex set is recompiled under a mutex on each validation. Without a registry
|
||||
// the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply.
|
||||
type PasswordValidator struct {
|
||||
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 {
|
||||
password := fl.Field().String()
|
||||
return v.CheckPassword(fl.Field().String())
|
||||
}
|
||||
|
||||
// Check minimum length
|
||||
if len(password) < 8 {
|
||||
// CheckPassword applies the active rules to a single password.
|
||||
func (v *PasswordValidator) CheckPassword(password string) bool {
|
||||
r := v.rules()
|
||||
if len(password) < r.MinLength {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for uppercase
|
||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
||||
if !hasUpper {
|
||||
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for lowercase
|
||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
||||
if !hasLower {
|
||||
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for number
|
||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
||||
if !hasNumber {
|
||||
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check for special character
|
||||
hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
|
||||
if !hasSpecial {
|
||||
if r.Special && !specialRegex().MatchString(password) {
|
||||
return false
|
||||
}
|
||||
|
||||
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 {
|
||||
return []string{
|
||||
"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 (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
|
||||
}
|
||||
return defaultPasswordValidator.Requirements()
|
||||
}
|
||||
|
||||
// ValidatePassword checks a password and returns an error if it doesn't meet requirements
|
||||
func ValidatePassword(password string) error {
|
||||
if len(password) < 8 {
|
||||
return fmt.Errorf("password must be at least 8 characters long")
|
||||
// Requirements returns the human-readable list for the receiver's active rules.
|
||||
func (v *PasswordValidator) Requirements() []string {
|
||||
r := v.rules()
|
||||
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")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one number")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
|
||||
if r.Special && !specialRegex().MatchString(password) {
|
||||
return fmt.Errorf("password must contain at least one special character")
|
||||
}
|
||||
|
||||
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 {
|
||||
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
|
||||
pv := &PasswordValidator{}
|
||||
return pv.Validate(fl)
|
||||
return defaultPasswordValidator.CheckPassword(fl.Field().String())
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
type Feed struct {
|
||||
XMLName xml.Name `xml:"feed"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
OpdsNS string `xml:"xmlns:opds,attr"`
|
||||
DcNS string `xml:"xmlns:dc,attr"`
|
||||
ID string `xml:"id"`
|
||||
Title string `xml:"title"`
|
||||
Updated string `xml:"updated"`
|
||||
Links []Link `xml:"link"`
|
||||
Entries []Entry `xml:"entry"`
|
||||
XMLName xml.Name `xml:"feed"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
OpdsNS string `xml:"xmlns:opds,attr"`
|
||||
DcNS string `xml:"xmlns:dc,attr"`
|
||||
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
|
||||
ID string `xml:"id"`
|
||||
Title string `xml:"title"`
|
||||
Updated string `xml:"updated"`
|
||||
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 {
|
||||
ID string `xml:"id"`
|
||||
Title string `xml:"dc:title"`
|
||||
Creator string `xml:"dc:creator,omitempty"`
|
||||
Title string `xml:"title"`
|
||||
Author *Author `xml:"author,omitempty"`
|
||||
Updated string `xml:"updated"`
|
||||
Summary string `xml:"summary,omitempty"`
|
||||
Links []Link `xml:"link"`
|
||||
@@ -32,6 +36,12 @@ type Entry struct {
|
||||
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 {
|
||||
Href string `xml:"href,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
@@ -58,17 +68,29 @@ type Category struct {
|
||||
func NewFeed(feedID, title string) *Feed {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
return &Feed{
|
||||
Xmlns: "http://www.w3.org/2005/Atom",
|
||||
OpdsNS: "http://opds-spec.org/2010/",
|
||||
DcNS: "http://purl.org/dc/elements/1.1/",
|
||||
ID: feedID,
|
||||
Title: title,
|
||||
Updated: now,
|
||||
Links: []Link{},
|
||||
Entries: []Entry{},
|
||||
Xmlns: "http://www.w3.org/2005/Atom",
|
||||
OpdsNS: "http://opds-spec.org/2010/",
|
||||
DcNS: "http://purl.org/dc/elements/1.1/",
|
||||
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
|
||||
ID: feedID,
|
||||
Title: title,
|
||||
Updated: now,
|
||||
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
|
||||
func (f *Feed) AddLink(href, linkType, rel string) {
|
||||
f.Links = append(f.Links, Link{
|
||||
@@ -85,14 +107,17 @@ func (f *Feed) AddEntry(entry Entry) {
|
||||
|
||||
// NewEntry creates a new OPDS entry
|
||||
func NewEntry(id, title, creator, updated string) Entry {
|
||||
return Entry{
|
||||
e := Entry{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Creator: creator,
|
||||
Updated: updated,
|
||||
Links: []Link{},
|
||||
Metadata: []Meta{},
|
||||
}
|
||||
if creator != "" {
|
||||
e.Author = &Author{Name: creator}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// AddAcquisitionLink adds an acquisition link to the entry
|
||||
@@ -169,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) {
|
||||
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
|
||||
func NewErrorFeed(message string) *Feed {
|
||||
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)
|
||||
}
|
||||
|
||||
if entry.Creator != "Test Author" {
|
||||
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator)
|
||||
if entry.Author == nil || entry.Author.Name != "Test Author" {
|
||||
t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
|
||||
}
|
||||
|
||||
if entry.Updated != "2023-01-01T00:00:00Z" {
|
||||
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
|
||||
`<title>Test Feed</title>`,
|
||||
`<entry>`,
|
||||
`<id>urn:uuid:book-id</id>`,
|
||||
`<dc:title>Test Book</dc:title>`,
|
||||
`<dc:creator>Test Author</dc:creator>`,
|
||||
`<title>Test Book</title>`,
|
||||
`<name>Test Author</name>`,
|
||||
`<link href="http://example.com/book.epub"`,
|
||||
`rel="http://opds-spec.org/acquisition/open-access"`,
|
||||
`<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 {
|
||||
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"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
@@ -111,14 +111,6 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Protected frontend routes (no /api prefix)
|
||||
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
|
||||
frontendProtected.GET("/series", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
@@ -128,36 +120,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
var errorMsg string
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
libData := libRes.Libraries
|
||||
if libRes.IsAll {
|
||||
errorMsg = ""
|
||||
}
|
||||
|
||||
perSeriesPage := 24
|
||||
@@ -172,31 +139,28 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
var seriesCards []templates.SeriesCardData
|
||||
totalPages := 1
|
||||
|
||||
if libraryID != "" && errorMsg == "" {
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err == nil {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libUUID, perSeriesPage, offset)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
} else {
|
||||
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
covers := s.CoverPaths
|
||||
if covers == nil {
|
||||
covers = []string{}
|
||||
}
|
||||
seriesCards = append(seriesCards, templates.SeriesCardData{
|
||||
Name: s.Name,
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
})
|
||||
if errorMsg == "" {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, perSeriesPage, offset)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
} else {
|
||||
totalPages = (total + perSeriesPage - 1) / perSeriesPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
seriesCards = make([]templates.SeriesCardData, 0, len(seriesList))
|
||||
for _, s := range seriesList {
|
||||
covers := s.CoverPaths
|
||||
if covers == nil {
|
||||
covers = []string{}
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if libraryID != "" && errorMsg == "" {
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err == nil {
|
||||
svc := services.NewSeriesService(cfg.Queries)
|
||||
books, err := svc.GetSeriesBooks(c.Request().Context(), libUUID, seriesName)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesBooks failed: %v", err)
|
||||
errorMsg = "Error loading series books"
|
||||
} else {
|
||||
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
||||
for _, item := range books {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
})
|
||||
}
|
||||
}
|
||||
svc := services.NewSeriesService(cfg.Queries)
|
||||
books, err := svc.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesBooks failed: %v", err)
|
||||
errorMsg = "Error loading series books"
|
||||
} else {
|
||||
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
||||
for _, item := range books {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
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{}
|
||||
}
|
||||
|
||||
seriesUserUUID, _ := uuid.Parse(user.ID)
|
||||
bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: seriesUserUUID, Valid: true}, bookInfoList)
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -289,41 +239,29 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return renderErrorPage(c, "Tag 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"
|
||||
}
|
||||
}
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
|
||||
var bookInfoList []handlers.BookInfo
|
||||
|
||||
if libraryID != "" && errorMsg == "" {
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err == nil {
|
||||
books, err := cfg.Queries.GetBooksByTag(c.Request().Context(), database.GetBooksByTagParams{
|
||||
LibraryID: uuidToPGType(libUUID),
|
||||
Column2: tagName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("GetBooksByTag failed: %v", err)
|
||||
errorMsg = "Error loading tag books"
|
||||
} else {
|
||||
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
||||
for _, item := range books {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
})
|
||||
}
|
||||
books, err := cfg.Queries.GetBooksByTag(c.Request().Context(), database.GetBooksByTagParams{
|
||||
LibraryID: libRes.LibUUID,
|
||||
Column2: tagName,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("GetBooksByTag failed: %v", err)
|
||||
errorMsg = "Error loading tag books"
|
||||
} else {
|
||||
bookInfoList = make([]handlers.BookInfo, 0, len(books))
|
||||
for _, item := range books {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
bookInfoList = append(bookInfoList, handlers.BookInfo{
|
||||
MediaItemID: itemUUID.String(),
|
||||
Title: item.Title,
|
||||
Author: textToString(item.Author),
|
||||
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -332,6 +270,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
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
|
||||
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 {
|
||||
@@ -348,52 +289,20 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
var errorMsg string
|
||||
|
||||
// Get library_id from query param or user's first library
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if 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()
|
||||
} else {
|
||||
errorMsg = "No libraries available"
|
||||
}
|
||||
}
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
libData := libRes.Libraries
|
||||
|
||||
// Get libraries for dropdown
|
||||
// Fetch saved filters for SSR
|
||||
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
|
||||
if libraryID != "" && errorMsg == "" {
|
||||
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
ResourceType: "media-items",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("GetSavedFilters failed: %v", err)
|
||||
savedFilters = []database.SavedFilters{}
|
||||
}
|
||||
savedFilters, err = cfg.Queries.GetSavedFilters(c.Request().Context(), database.GetSavedFiltersParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
ResourceType: "media-items",
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("GetSavedFilters failed: %v", err)
|
||||
savedFilters = []database.SavedFilters{}
|
||||
}
|
||||
|
||||
// Fetch first page of books for SSR
|
||||
@@ -402,68 +311,56 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
limit := 50
|
||||
offset := 0
|
||||
|
||||
if libraryID != "" && errorMsg == "" {
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err == nil {
|
||||
// Check URL params for pagination
|
||||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
if errorMsg == "" {
|
||||
// Check URL params for pagination
|
||||
if limitStr := c.QueryParam("limit"); limitStr != "" {
|
||||
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
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -480,20 +377,13 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
var errorMsg string
|
||||
|
||||
libraryID := c.QueryParam("library_id")
|
||||
if 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()
|
||||
}
|
||||
}
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
|
||||
libUUID, _ := uuid.Parse(libraryID)
|
||||
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 {
|
||||
log.Printf("GetDashboardPreferences failed: %v", err)
|
||||
prefs = database.UserDashboardPreferences{
|
||||
@@ -511,7 +401,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
allSections, err := cfg.DashboardService.GetDashboardSections(
|
||||
c.Request().Context(),
|
||||
userUUID,
|
||||
libUUID,
|
||||
pgLibUUID,
|
||||
limit,
|
||||
prefs.CollectionOrder,
|
||||
[]string{}, // No filtering - get all sections
|
||||
@@ -525,32 +415,12 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Get only visible sections for the dashboard display
|
||||
visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections)
|
||||
|
||||
userUUID2, _ := uuid.Parse(user.ID)
|
||||
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), uuidToPGType(userUUID2))
|
||||
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)
|
||||
userPgID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID))
|
||||
allSectionsData := handlers.BuildSections(allSections, libraryID)
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -674,30 +544,18 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
var books []handlers.BookInfo
|
||||
|
||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||
// System collection - use query type
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
libRes := resolveLibrary(c, cfg, user.ID)
|
||||
libraryID := libRes.LibraryID
|
||||
|
||||
libUUID, _ := uuid.Parse(libraryID)
|
||||
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||
dashboardSvc := services.NewDashboardService(cfg.Queries)
|
||||
sections, err := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||
if err != nil {
|
||||
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{})
|
||||
if secErr != nil {
|
||||
return renderErrorPage(c, "Error loading books", "books_load_error")
|
||||
}
|
||||
|
||||
// Find the matching section and convert items
|
||||
for _, section := range sections {
|
||||
if section.CollectionID.String() == collectionID {
|
||||
// Convert []database.MediaItems to []handlers.BookInfo
|
||||
bookCards := make([]handlers.BookInfo, len(section.Items))
|
||||
for i, item := range section.Items {
|
||||
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||
@@ -713,27 +571,21 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User collection - check if library_id filter is present
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
if libraryID != "" {
|
||||
// Filter by library - reuse dashboard query
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
if libraryID != "" && !libRes.IsAll {
|
||||
libUUID, parseErr := uuid.Parse(libraryID)
|
||||
if parseErr != nil {
|
||||
return renderErrorPage(c, "Invalid library ID", "invalid_library_id")
|
||||
}
|
||||
|
||||
// Use GetCollectionItemsForDashboard for library-filtered results
|
||||
collItems, err := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||
collItems, collErr := cfg.Queries.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||
database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collUUID, 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{}
|
||||
} else {
|
||||
// Convert to BookInfo format (non-excluded only)
|
||||
var validItems []database.GetCollectionItemsForDashboardRow
|
||||
for _, item := range collItems {
|
||||
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||
@@ -754,13 +606,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
books = bookCards
|
||||
}
|
||||
} else {
|
||||
// No library filter - show all books in collection
|
||||
collItems, err := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||
if err != nil {
|
||||
collItems, collErr := cfg.Queries.GetCollectionItems(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true})
|
||||
if collErr != nil {
|
||||
books = []handlers.BookInfo{}
|
||||
}
|
||||
|
||||
// Convert to BookInfo format
|
||||
bookCards := make([]handlers.BookInfo, len(collItems))
|
||||
for i, item := range collItems {
|
||||
itemUUID, _ := uuid.FromBytes(item.MediaItemID.Bytes[0:16])
|
||||
@@ -781,13 +631,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
Description: collection.Description.String,
|
||||
Color: collection.Color.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
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -886,10 +734,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
// Get base URL from database config with fallback to config/env var
|
||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf)
|
||||
@@ -966,8 +811,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
stats := getAdminStats(c.Request().Context(), cfg)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -979,8 +825,9 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
stats := getAdminStats(c.Request().Context(), cfg)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -1004,11 +851,14 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
libData := make([]templates.LibraryData, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
folders, _ := cfg.LibraryService.GetLibraryFolders(c.Request().Context(), lib.ID)
|
||||
libData[i] = templates.LibraryData{
|
||||
ID: libUUID.String(),
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
TypeValue: lib.TypeName,
|
||||
FolderCount: len(folders),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1095,6 +945,67 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
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
|
||||
frontendProtected.GET("/admin/users", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
@@ -1146,24 +1057,67 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
|
||||
// Fetch current system configuration - just base_url
|
||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
baseURL := cfg.getBaseURL(ctx)
|
||||
|
||||
systemConfig := map[string]string{
|
||||
"base_url": baseURL,
|
||||
"default_timezone": "UTC",
|
||||
}
|
||||
|
||||
defaultTimezone, err := cfg.Queries.GetSystemTimezone(c.Request().Context())
|
||||
defaultTimezone, err := cfg.Queries.GetSystemTimezone(ctx)
|
||||
if err == nil && 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
|
||||
err = templates.AdminSettings(user, systemConfig, "").Render(c.Request().Context(), &buf)
|
||||
err = templates.AdminSettings(user, systemConfig, scanSettings, liveGroups, restartGroups, "").Render(ctx, &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1205,6 +1159,11 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
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)
|
||||
// ============================================================================
|
||||
@@ -1238,10 +1197,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
// Get base URL from database config with fallback to config/env var
|
||||
baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries)
|
||||
if baseURL == "" {
|
||||
baseURL = cfg.Cfg.BaseURL
|
||||
}
|
||||
baseURL := cfg.getBaseURL(c.Request().Context())
|
||||
|
||||
var buf bytes.Buffer
|
||||
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!)
|
||||
detail := handlers.MediaDetail{
|
||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||
Rating: rating,
|
||||
Collections: collections,
|
||||
ReadingProgress: progress,
|
||||
ActiveConflict: activeConflict,
|
||||
NotesCount: len(notes),
|
||||
HighlightsCount: len(highlights),
|
||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||
Rating: rating,
|
||||
Collections: collections,
|
||||
ReadingProgress: progress,
|
||||
ActiveConflict: activeConflict,
|
||||
NotesCount: len(notes),
|
||||
HighlightsCount: len(highlights),
|
||||
DeletedAnnotations: handlers.DeletedAnnotationsForBook(c.Request().Context(), cfg.Queries, pgUserID, pgMediaUUID),
|
||||
}
|
||||
|
||||
// Render template
|
||||
|
||||
@@ -3,7 +3,10 @@ package router
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -64,7 +67,7 @@ func convertPending(pending []map[string]interface{}) []templates.PendingRegistr
|
||||
RegistrationID: p["registration_id"].(string),
|
||||
DeviceName: p["device_name"].(string),
|
||||
DeviceType: p["device_type"].(string),
|
||||
ExpiresAt: p["expires_at"].(string),
|
||||
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -86,3 +89,89 @@ func parseUUID(s string) (uuid.UUID, error) {
|
||||
func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||||
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/issues/list", cfg.ProcessingIssuesHandler.ListProcessingIssues)
|
||||
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 {
|
||||
libraryID := c.Param("id")
|
||||
scanReq := map[string]interface{}{
|
||||
|
||||
@@ -41,6 +41,19 @@ func registerMediaRoutes(cfg *Config) {
|
||||
protected.PUT("/media-items/:id/highlights/:highlightId", cfg.MediaHandler.UpdateMediaHighlight)
|
||||
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.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||
|
||||
@@ -39,6 +39,7 @@ type Config struct {
|
||||
Echo *echo.Echo
|
||||
Queries *database.Queries
|
||||
Cfg *config.Config
|
||||
Settings *database.SettingsRegistry
|
||||
DBPool interface{} // pgxpool.Pool interface
|
||||
AuthHandler *handlers.AuthHandler
|
||||
LibraryHandler *handlers.LibraryHandler
|
||||
@@ -46,6 +47,7 @@ type Config struct {
|
||||
MediaHandler *handlers.MediaHandler
|
||||
MatchingHandler *handlers.MatchingHandler
|
||||
ProcessingIssuesHandler *handlers.ProcessingIssuesHandler
|
||||
HashConflictsHandler *handlers.HashConflictsHandler
|
||||
KOReaderHandler *handlers.KOReaderHandler
|
||||
WSHandler *handlers.WSHandler
|
||||
ConflictHandler *handlers.ConflictHandler
|
||||
@@ -62,12 +64,32 @@ type Config struct {
|
||||
ConnManager *sync.ConnectionManager
|
||||
QueueProcessor *sync.SyncQueueProcessor
|
||||
ProgressService *sync.ProgressService
|
||||
AnnotationService *sync.AnnotationService
|
||||
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
||||
LoginTracker *ratelimit.LoginAttemptTracker
|
||||
ScannerHandler *handlers.Handler
|
||||
JobsHandler *handlers.JobsHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
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
|
||||
@@ -188,10 +210,15 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
}
|
||||
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{
|
||||
Enabled: cfg.Cfg.RateLimitEnabled,
|
||||
RequestsPerMinute: cfg.Cfg.RequestsPerMinute,
|
||||
RequestsPerMinute: cfg.Settings.AuthRateLimit(),
|
||||
CleanupInterval: 5 * time.Minute,
|
||||
}
|
||||
rateLimiter := ratelimit.NewRateLimiter(rateLimiterConfig)
|
||||
@@ -206,6 +233,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
cfg.ScannerHandler = scannerHandler
|
||||
|
||||
// Register route groups
|
||||
registerSetupRoutes(cfg)
|
||||
registerAuthRoutes(cfg, rateLimitMiddleware)
|
||||
registerLibraryRoutes(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
|
||||
registerProgressRoutes(cfg, scannerHandler)
|
||||
|
||||
@@ -274,5 +313,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
admin := protected.Group("", handlers.AdminMiddleware)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -112,9 +112,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
||||
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
|
||||
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 {
|
||||
log.Printf("Template render error: %v", err)
|
||||
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)
|
||||
koreaderSync := e.Group("/api/sync/koreader")
|
||||
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("/library", cfg.DeviceAuthMiddleware.Authenticate(cfg.KOReaderHandler.GetLibrary))
|
||||
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}
|
||||
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
|
||||
koboHandler.SetProgressService(cfg.ProgressService)
|
||||
koboHandler.SetAnnotationService(cfg.AnnotationService)
|
||||
koboHandler.SetLibraryService(cfg.LibraryService)
|
||||
koboSync := e.Group("/api/sync/kobo/:token")
|
||||
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
||||
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
|
||||
|
||||
@@ -17,4 +17,10 @@ func registerSystemRoutes(cfg *Config) {
|
||||
// System configuration routes (admin-only)
|
||||
system.GET("/config", cfg.SidecarHandler.GetSystemConfiguration)
|
||||
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
|
||||
type BookMatchingService struct {
|
||||
db *database.Queries
|
||||
db *database.Queries
|
||||
resolver *BookResolver
|
||||
}
|
||||
|
||||
// NewBookMatchingService creates a new book matching service
|
||||
func NewBookMatchingService(db *database.Queries) *BookMatchingService {
|
||||
return &BookMatchingService{
|
||||
db: db,
|
||||
db: db,
|
||||
resolver: NewBookResolver(db),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,27 +169,21 @@ func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers []
|
||||
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 {
|
||||
items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{
|
||||
Limit: 1000,
|
||||
Offset: 0,
|
||||
})
|
||||
if err != nil {
|
||||
item, method, err := s.resolver.ResolveBySHA256(ctx, sha256)
|
||||
if err != nil || !item.ID.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if item.FileSha256.Valid && item.FileSha256.String == sha256 {
|
||||
return &BookMatch{
|
||||
MediaItemID: item.ID.Bytes,
|
||||
BookhoardUUID: item.ID.Bytes,
|
||||
Confidence: 0.9,
|
||||
MatchMethod: "sha256_match",
|
||||
}
|
||||
}
|
||||
return &BookMatch{
|
||||
MediaItemID: item.ID.Bytes,
|
||||
BookhoardUUID: item.ID.Bytes,
|
||||
Confidence: 0.9,
|
||||
MatchMethod: "sha256_" + string(method),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
Path string
|
||||
SHA256 string
|
||||
@@ -27,6 +31,7 @@ type ConversionService struct {
|
||||
cacheDir string
|
||||
conversionTool string
|
||||
conversionCacheTTL time.Duration
|
||||
settings *database.SettingsRegistry
|
||||
}
|
||||
|
||||
func NewConversionService(db *database.Queries, cacheDir string) *ConversionService {
|
||||
@@ -34,17 +39,32 @@ func NewConversionService(db *database.Queries, cacheDir string) *ConversionServ
|
||||
db: db,
|
||||
cacheDir: cacheDir,
|
||||
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) {
|
||||
existing, err := s.db.GetMediaItemFormatByType(ctx, database.GetMediaItemFormatByTypeParams{
|
||||
MediaItemID: mediaItemID,
|
||||
FormatType: "kepub",
|
||||
})
|
||||
if err == nil && existing.FilePath.Valid {
|
||||
if time.Since(existing.CreatedAt.Time) < s.conversionCacheTTL {
|
||||
if time.Since(existing.CreatedAt.Time) < s.cacheTTL() {
|
||||
return &ConvertedKEPUB{
|
||||
Path: existing.FilePath.String,
|
||||
SHA256: existing.FileSha256.String,
|
||||
|
||||
@@ -65,5 +65,7 @@ func TestConversionServiceDefaults(t *testing.T) {
|
||||
assert.NotNil(t, service)
|
||||
assert.Equal(t, cacheDir, service.cacheDir)
|
||||
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(
|
||||
ctx context.Context,
|
||||
userID, libraryID uuid.UUID,
|
||||
userID uuid.UUID,
|
||||
libraryID pgtype.UUID,
|
||||
limit int,
|
||||
collectionOrder []string,
|
||||
hiddenCollections []string,
|
||||
@@ -267,36 +268,36 @@ func (s *DashboardService) sortByPriority(sections []DashboardSection) []Dashboa
|
||||
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 {
|
||||
case "continue-reading":
|
||||
return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
case "recently-added":
|
||||
return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
case "recently-read":
|
||||
return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
case "not-started":
|
||||
return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
case "continue-series":
|
||||
rows, err := s.db.GetContinueSeriesItems(ctx, database.GetContinueSeriesItemsParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
LibraryID: libraryID,
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
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])
|
||||
|
||||
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||||
Limit: int32(limit),
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -334,7 +335,7 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
||||
if len(coll.AutoAssignRules) > 0 {
|
||||
var rules []Rule
|
||||
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 {
|
||||
for _, item := range allLibraryItems {
|
||||
alreadyInCollection := false
|
||||
@@ -374,10 +375,10 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
||||
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{
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("Failed to create file watcher: %v", err))
|
||||
}
|
||||
|
||||
return &MediaScanner{
|
||||
db: db,
|
||||
watcher: watcher,
|
||||
watcher: nil,
|
||||
settingsCache: NewSettingsCache(30 * time.Second),
|
||||
dirtyDirs: make(map[string]time.Time),
|
||||
fileStability: make(map[string]*atomic.Bool),
|
||||
@@ -238,24 +240,34 @@ func (s *MediaScanner) GetStats() (int, int, int) {
|
||||
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
|
||||
|
||||
// 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 err := s.watcher.Close(); err != nil {
|
||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||
}
|
||||
if err := s.watcher.Close(); err != nil {
|
||||
fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err)
|
||||
}
|
||||
s.watcher = nil
|
||||
}
|
||||
|
||||
// Create new watcher
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create watcher: %v", err)
|
||||
// Create + populate a fresh watcher only when the caller intends to read events.
|
||||
if watch {
|
||||
watcher, err := fsnotify.NewWatcher()
|
||||
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
|
||||
// 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)
|
||||
watchCount := 0
|
||||
for _, folder := range folders {
|
||||
if err := s.watcher.Add(folder); err != nil {
|
||||
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
|
||||
} else {
|
||||
watchCount++
|
||||
}
|
||||
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)
|
||||
// Add all folders and their subdirectories to the watcher (like Audiobookshelf).
|
||||
// Only when watching; scan jobs (watch=false) skip this entirely.
|
||||
if s.watcher != nil {
|
||||
watchCount := 0
|
||||
for _, folder := range folders {
|
||||
if err := s.watcher.Add(folder); err != nil {
|
||||
fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err)
|
||||
} else {
|
||||
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
|
||||
}
|
||||
@@ -417,8 +434,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error {
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
if err := s.watcher.Add(path); err != nil {
|
||||
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||
if s.watcher != nil {
|
||||
if err := s.watcher.Add(path); err != nil {
|
||||
fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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
|
||||
} else {
|
||||
// Normal behavior: check if file has changed (by size)
|
||||
if existingItem.FileSize.Int64 != info.Size() {
|
||||
fmt.Printf("File size changed, updating media item: %s\n", path)
|
||||
_ = 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
|
||||
}
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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()
|
||||
// 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 {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if mime, ok := MimeTypes[ext]; ok {
|
||||
@@ -2601,6 +2712,13 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error {
|
||||
go s.startBackupScan(ctx)
|
||||
|
||||
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))
|
||||
for {
|
||||
select {
|
||||
@@ -2992,6 +3110,12 @@ func (s *MediaScanner) startBackupScan(ctx context.Context) {
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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