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 |
@@ -23,3 +23,6 @@ node_modules/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# Media uploads (bind-mounted at runtime)
|
||||
uploads/
|
||||
@@ -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
|
||||
@@ -20,3 +35,5 @@ DBPASS=your-secure-database-password-here
|
||||
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
||||
# Conversion Cache TTL: Override default 24h
|
||||
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
||||
# System timezone (fallback for server-side time operations, defaults to UTC)
|
||||
# TZ=America/New_York
|
||||
@@ -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
|
||||
@@ -68,6 +68,9 @@ Thumbs.db
|
||||
# Uploads
|
||||
uploads/
|
||||
|
||||
# Vendored build artifacts (copied from node_modules at build time)
|
||||
web/static/vendor/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
+9
-6
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM golang:1.25-alpine AS builder
|
||||
FROM golang:1.26-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -7,14 +7,16 @@ WORKDIR /app
|
||||
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
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest && \
|
||||
go install github.com/a-h/templ/cmd/templ@latest
|
||||
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 ./
|
||||
@@ -34,11 +36,12 @@ 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
|
||||
FROM golang:1.25-alpine AS test-runner
|
||||
FROM golang:1.26-alpine AS test-runner
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1,777 +0,0 @@
|
||||
# Panel Detection Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Multi-tier panel detection system with fallback chain:
|
||||
**OpenCV → ML (COCO-SSD) → Grid → Manual Editor**
|
||||
|
||||
Designed for a constantly growing library - handles any comic style without custom training.
|
||||
|
||||
---
|
||||
|
||||
## Detection Pipeline
|
||||
|
||||
```
|
||||
1. OpenCV Edge Detection (Primary)
|
||||
├─ Fast, lightweight (~500KB lazy-loaded)
|
||||
├─ Works on 80% of comics with clear panel borders
|
||||
└─ Future-proof: works on unknown future comics
|
||||
|
||||
2. ML Detection (COCO-SSD Fallback)
|
||||
├─ Pre-trained on millions of diverse images
|
||||
├─ Handles irregular layouts
|
||||
└─ ~2MB (TensorFlow.js) + ~2MB (model), lazy-loaded
|
||||
|
||||
3. Grid Detection (Baseline)
|
||||
└─ Always works as final fallback
|
||||
|
||||
4. Manual Editor (Last Resort)
|
||||
└─ User manually draws panels
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
Add to `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@techstark/opencv-js": "^4.12.0",
|
||||
"@tensorflow/tfjs": "^4.22.0",
|
||||
"@tensorflow-models/coco-ssd": "^2.2.3"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Bundle sizes:**
|
||||
- OpenCV.js: ~500KB (lazy-loaded)
|
||||
- TensorFlow.js: ~2MB (lazy-loaded)
|
||||
- COCO-SSD model: ~2MB (lazy-loaded, cached after first load)
|
||||
- **Total: ~4.5MB** (acceptable for modern networks)
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
web/src/reader/comic/
|
||||
├── panel-detection.service.ts [NEW] - Main detection service with fallback chain
|
||||
├── panel-detection.opencv.ts [NEW] - OpenCV edge detection
|
||||
├── panel-detection.ml.ts [NEW] - COCO-SSD ML detection
|
||||
├── panel-detector.ts [MODIFY] - Add export for grid detection
|
||||
├── panel-editor.ts [MODIFY] - Add re-detect, connect to service
|
||||
├── page-cache.ts [OPTIONAL] - On-demand detection
|
||||
├── background-color.ts [KEEP]
|
||||
├── chapter-markers.ts [KEEP]
|
||||
├── page-order.ts [KEEP]
|
||||
├── page-scrubber.ts [KEEP]
|
||||
└── panel-gap.ts [KEEP]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Panel Detection Service (`panel-detection.service.ts`)
|
||||
|
||||
Create this file in `web/src/reader/comic/`:
|
||||
|
||||
```typescript
|
||||
// Main panel detection service with fallback chain
|
||||
// Priority: OpenCV → ML → Grid → Manual Editor
|
||||
|
||||
interface DetectionResult {
|
||||
panels: Panel[];
|
||||
method: "opencv" | "ml" | "grid" | "manual";
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
async function detectPanels(
|
||||
imageData: ImageData,
|
||||
allowManual: boolean = true
|
||||
): Promise<DetectionResult> {
|
||||
|
||||
// Tier 1: OpenCV Edge Detection
|
||||
try {
|
||||
const panels = await detectPanelsOpenCV(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "opencv", confidence: 0.85 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("OpenCV detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 2: ML Detection (COCO-SSD)
|
||||
try {
|
||||
const panels = await detectPanelsML(imageData);
|
||||
if (validatePanels(panels, imageData)) {
|
||||
return { panels, method: "ml", confidence: 0.9 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("ML detection failed:", e);
|
||||
}
|
||||
|
||||
// Tier 3: Grid Detection (baseline)
|
||||
const panels = detectPanelsGrid(imageData);
|
||||
return { panels, method: "grid", confidence: 0.5 };
|
||||
}
|
||||
|
||||
function validatePanels(panels: Panel[], imageData: ImageData): boolean {
|
||||
// Must have at least 1 panel
|
||||
if (panels.length === 0) return false;
|
||||
|
||||
// Should not have too many panels (probably noise)
|
||||
if (panels.length > 30) return false;
|
||||
|
||||
// Panels should cover reasonable area (not all empty space)
|
||||
let totalArea = panels.reduce((sum, p) => sum + (p.width * p.height), 0);
|
||||
if (totalArea < 10 || totalArea > 100) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Import detection methods from other files
|
||||
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]>;
|
||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]>;
|
||||
function detectPanelsGrid(imageData: ImageData, config?: { rows: number; cols: number }): Panel[];
|
||||
|
||||
export { detectPanels, DetectionResult, Panel };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. OpenCV Detection (`panel-detection.opencv.ts`)
|
||||
|
||||
Create this file in `web/src/reader/comic/`:
|
||||
|
||||
```typescript
|
||||
// OpenCV.js-based edge detection for panel boundaries
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let openCVLoaded = false;
|
||||
|
||||
async function loadOpenCV(): Promise<void> {
|
||||
if (openCVLoaded) return;
|
||||
|
||||
// OpenCV.js loads asynchronously and registers globally
|
||||
await import("@techstark/opencv-js");
|
||||
|
||||
// Wait for OpenCV to be ready
|
||||
return new Promise<void>((resolve) => {
|
||||
const check = () => {
|
||||
if ((window as any).cv && (window as any).cv.Mat) {
|
||||
openCVLoaded = true;
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 50);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsOpenCV(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadOpenCV();
|
||||
|
||||
const cv = (window as any).cv;
|
||||
|
||||
// Create matrices from ImageData
|
||||
const src = cv.matFromImageData(imageData);
|
||||
const gray = new cv.Mat();
|
||||
const blurred = new cv.Mat();
|
||||
const edges = new cv.Mat();
|
||||
const contours = new cv.Mat();
|
||||
const hierarchy = new cv.Mat();
|
||||
|
||||
try {
|
||||
// Convert to grayscale
|
||||
cv.cvtColor(src, gray, cv.COLOR_RGBA2GRAY, 0);
|
||||
|
||||
// Apply Gaussian blur to reduce noise
|
||||
cv.GaussianBlur(gray, blurred, new cv.Size(5, 5), 0, 0, cv.BORDER_DEFAULT);
|
||||
|
||||
// Detect edges using Canny
|
||||
cv.Canny(blurred, edges, 50, 150, 3, false);
|
||||
|
||||
// Find contours
|
||||
cv.findContours(
|
||||
edges,
|
||||
contours,
|
||||
hierarchy,
|
||||
cv.RETR_EXTERNAL,
|
||||
cv.CHAIN_APPROX_SIMPLE
|
||||
);
|
||||
|
||||
// Convert contours to panels
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < contours.size(); i++) {
|
||||
const rect = cv.boundingRect(contours.get(i));
|
||||
const aspectRatio = rect.width / rect.height;
|
||||
|
||||
// Filter: reject very small or very thin contours
|
||||
const minSize = Math.min(imgWidth, imgHeight) * 0.05;
|
||||
if (rect.width < minSize || rect.height < minSize) continue;
|
||||
if (aspectRatio < 0.1 || aspectRatio > 10) continue;
|
||||
|
||||
panels.push({
|
||||
id: `opencv-panel-${i}`,
|
||||
x: (rect.x / imgWidth) * 100,
|
||||
y: (rect.y / imgHeight) * 100,
|
||||
width: (rect.width / imgWidth) * 100,
|
||||
height: (rect.height / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort panels by reading order (top-left to bottom-right)
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
// Reassign reading order after sorting
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
|
||||
} finally {
|
||||
// Clean up OpenCV matrices
|
||||
src.delete();
|
||||
gray.delete();
|
||||
blurred.delete();
|
||||
edges.delete();
|
||||
contours.delete();
|
||||
hierarchy.delete();
|
||||
}
|
||||
}
|
||||
|
||||
export { detectPanelsOpenCV, loadOpenCV };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. ML Detection (`panel-detection.ml.ts`)
|
||||
|
||||
Create this file in `web/src/reader/comic/`:
|
||||
|
||||
```typescript
|
||||
// ML-based panel detection using COCO-SSD pre-trained model
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
let model: any = null;
|
||||
let tfLoaded = false;
|
||||
|
||||
async function loadTF(): Promise<void> {
|
||||
if (tfLoaded) return;
|
||||
|
||||
// Load TensorFlow.js
|
||||
await import("@tensorflow/tfjs");
|
||||
tfLoaded = true;
|
||||
}
|
||||
|
||||
async function loadModel(): Promise<void> {
|
||||
if (model) return;
|
||||
|
||||
await loadTF();
|
||||
|
||||
// Load COCO-SSD model (pre-trained on millions of images)
|
||||
const cocoSsd = await import("@tensorflow-models/coco-ssd");
|
||||
model = await cocoSsd.load({
|
||||
base: "lite_mobilenet_v2", // Smaller, faster model
|
||||
});
|
||||
}
|
||||
|
||||
async function detectPanelsML(imageData: ImageData): Promise<Panel[]> {
|
||||
await loadModel();
|
||||
|
||||
// Create HTMLCanvasElement to run model inference
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = imageData.width;
|
||||
canvas.height = imageData.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
// Run COCO-SSD model
|
||||
const predictions = await model.detect(canvas);
|
||||
|
||||
// Filter predictions to find rectangular regions (panels)
|
||||
// COCO-SSD detects common objects, we look for rectangular ones
|
||||
const panels: Panel[] = [];
|
||||
const imgWidth = imageData.width;
|
||||
const imgHeight = imageData.height;
|
||||
|
||||
for (let i = 0; i < predictions.length; i++) {
|
||||
const pred = predictions[i];
|
||||
|
||||
// COCO-SSD detects "book" and similar objects
|
||||
// We filter for reasonable panel-like detections
|
||||
const [x, y, w, h] = pred.bbox;
|
||||
const aspectRatio = w / h;
|
||||
|
||||
const isRectangular =
|
||||
aspectRatio > 0.3 && // Not too tall/thin
|
||||
aspectRatio < 5 && // Not too wide
|
||||
w > imgWidth * 0.05 && // Not too small
|
||||
h > imgHeight * 0.05;
|
||||
|
||||
if (isRectangular) {
|
||||
panels.push({
|
||||
id: `ml-panel-${i}`,
|
||||
x: (x / imgWidth) * 100,
|
||||
y: (y / imgHeight) * 100,
|
||||
width: (w / imgWidth) * 100,
|
||||
height: (h / imgHeight) * 100,
|
||||
reading_order: i,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort panels by reading order
|
||||
panels.sort((a, b) => {
|
||||
const rowA = Math.floor(a.y / 25);
|
||||
const rowB = Math.floor(b.y / 25);
|
||||
if (rowA !== rowB) return rowA - rowB;
|
||||
return a.x - b.x;
|
||||
});
|
||||
|
||||
panels.forEach((p, i) => (p.reading_order = i));
|
||||
|
||||
return panels;
|
||||
}
|
||||
|
||||
export { detectPanelsML, loadModel };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Grid Detection (`panel-detector.ts` - Update)
|
||||
|
||||
Modify the existing `panel-detector.ts` to add the export at the end:
|
||||
|
||||
```typescript
|
||||
// Grid-based panel detection (fast, lightweight)
|
||||
// Keep as final fallback
|
||||
|
||||
interface Panel {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
reading_order: number;
|
||||
}
|
||||
|
||||
interface GridConfig {
|
||||
rows: number;
|
||||
cols: number;
|
||||
}
|
||||
|
||||
function detectPanelsGrid(
|
||||
imageData: ImageData,
|
||||
config: GridConfig = { rows: 3, cols: 3 },
|
||||
): Panel[] {
|
||||
const panels: Panel[] = [];
|
||||
const cellWidth = imageData.width / config.cols;
|
||||
const cellHeight = imageData.height / config.rows;
|
||||
|
||||
for (let y = 0; y < config.rows; y++) {
|
||||
for (let x = 0; x < config.cols; x++) {
|
||||
const cell = extractCell(imageData, x, y, cellWidth, cellHeight);
|
||||
|
||||
if (!isEmpty(cell)) {
|
||||
panels.push({
|
||||
id: `panel-${panels.length}`,
|
||||
x: (x / config.cols) * 100,
|
||||
y: (y / config.rows) * 100,
|
||||
width: (1 / config.cols) * 100,
|
||||
height: (1 / config.rows) * 100,
|
||||
reading_order: panels.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return mergeAdjacentPanels(panels);
|
||||
}
|
||||
|
||||
function isEmpty(cellData: ImageData): boolean {
|
||||
let emptyPixels = 0;
|
||||
const totalPixels = cellData.width * cellData.height;
|
||||
const threshold = 0.95;
|
||||
|
||||
for (let i = 0; i < cellData.data.length; i += 4) {
|
||||
const r = cellData.data[i];
|
||||
const g = cellData.data[i + 1];
|
||||
const b = cellData.data[i + 2];
|
||||
const a = cellData.data[i + 3];
|
||||
|
||||
if (a < 10 || (r > 250 && g > 250 && b > 250)) {
|
||||
emptyPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
return emptyPixels / totalPixels > threshold;
|
||||
}
|
||||
|
||||
function mergeAdjacentPanels(panels: Panel[]): Panel[] {
|
||||
const merged: Panel[] = [];
|
||||
const used = new Set<number>();
|
||||
|
||||
for (let i = 0; i < panels.length; i++) {
|
||||
if (used.has(i)) continue;
|
||||
|
||||
let current = { ...panels[i] };
|
||||
used.add(i);
|
||||
|
||||
for (let j = i + 1; j < panels.length; j++) {
|
||||
if (used.has(j)) continue;
|
||||
if (isAdjacent(current, panels[j])) {
|
||||
current = mergePanels(current, panels[j]);
|
||||
used.add(j);
|
||||
}
|
||||
}
|
||||
|
||||
merged.push(current);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
function extractCell(
|
||||
imageData: ImageData,
|
||||
gridX: number,
|
||||
gridY: number,
|
||||
cellWidth: number,
|
||||
cellHeight: number,
|
||||
): ImageData {
|
||||
const startX = Math.floor(gridX * cellWidth);
|
||||
const startY = Math.floor(gridY * cellHeight);
|
||||
const width = Math.floor(cellWidth);
|
||||
const height = Math.floor(cellHeight);
|
||||
|
||||
const cellData = new Uint8ClampedArray(width * height * 4);
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const srcIdx = ((startY + y) * imageData.width + (startX + x)) * 4;
|
||||
const destIdx = (y * width + x) * 4;
|
||||
cellData[destIdx] = imageData.data[srcIdx];
|
||||
cellData[destIdx + 1] = imageData.data[srcIdx + 1];
|
||||
cellData[destIdx + 2] = imageData.data[srcIdx + 2];
|
||||
cellData[destIdx + 3] = imageData.data[srcIdx + 3];
|
||||
}
|
||||
}
|
||||
|
||||
return new ImageData(cellData, width, height);
|
||||
}
|
||||
|
||||
function isAdjacent(p1: Panel, p2: Panel): boolean {
|
||||
const tolerance = 5;
|
||||
if (Math.abs(p1.y - p2.y) < tolerance && Math.abs(p1.height - p2.height) < tolerance) {
|
||||
return Math.abs(p1.x + p1.width - p2.x) < tolerance || Math.abs(p2.x + p2.width - p1.x) < tolerance;
|
||||
}
|
||||
if (Math.abs(p1.x - p2.x) < tolerance && Math.abs(p1.width - p2.width) < tolerance) {
|
||||
return Math.abs(p1.y + p1.height - p2.y) < tolerance || Math.abs(p2.y + p2.height - p1.y) < tolerance;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function mergePanels(p1: Panel, p2: Panel): Panel {
|
||||
const minX = Math.min(p1.x, p2.x);
|
||||
const minY = Math.min(p1.y, p2.y);
|
||||
const maxX = Math.max(p1.x + p1.width, p2.x + p2.width);
|
||||
const maxY = Math.max(p1.y + p1.height, p2.y + p2.height);
|
||||
|
||||
return {
|
||||
id: p1.id,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: maxX - minX,
|
||||
height: maxY - minY,
|
||||
reading_order: Math.min(p1.reading_order, p2.reading_order),
|
||||
};
|
||||
}
|
||||
|
||||
// ADD THIS EXPORT AT THE END OF THE FILE
|
||||
export { detectPanelsGrid, isEmpty, mergeAdjacentPanels, extractCell, isAdjacent, mergePanels };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Panel Editor Updates (`panel-editor.ts`)
|
||||
|
||||
Modify the existing `panel-editor.ts` to add imports and re-detect function:
|
||||
|
||||
```typescript
|
||||
// Manual panel editor for admins/power users
|
||||
|
||||
import { Alpine } from "../../alpine";
|
||||
import { apiPut } from "../../api";
|
||||
import { detectPanels, Panel } from "./panel-detection.service";
|
||||
|
||||
async function loadImageForPage(pageNumber: number): Promise<HTMLImageElement> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
if (!mediaItemId) {
|
||||
throw new Error("No mediaItemId found");
|
||||
}
|
||||
|
||||
const token = localStorage.getItem("token");
|
||||
const response = await fetch(`/readers/${mediaItemId}/pages/${pageNumber}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load page ${pageNumber}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(blob);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => resolve();
|
||||
});
|
||||
|
||||
return img;
|
||||
}
|
||||
|
||||
function getCurrentPageNumber(): number {
|
||||
const Alpine = (window as any).Alpine;
|
||||
if (Alpine) {
|
||||
const readerEl = document.querySelector('[x-data="readerShell"]');
|
||||
if (readerEl) {
|
||||
const readerShell = Alpine.$data(readerEl);
|
||||
if (readerShell?.currentPage) {
|
||||
return readerShell.currentPage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const content = document.getElementById("reader-content");
|
||||
const pageFromDataset = content?.dataset.currentPage;
|
||||
if (pageFromDataset) {
|
||||
return parseInt(pageFromDataset, 10);
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
function loadPage(pageNumber: number): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("navigate-to-page", { detail: { page: pageNumber } }),
|
||||
);
|
||||
}
|
||||
|
||||
function openPanelEditor(pageNumber: number): void {
|
||||
const modal = document.getElementById("panel-editor-modal");
|
||||
modal?.classList.remove("hidden");
|
||||
|
||||
const canvas = document.getElementById("panel-editor-canvas") as HTMLCanvasElement;
|
||||
const ctx = canvas?.getContext("2d");
|
||||
|
||||
loadImageForPage(pageNumber).then((image) => {
|
||||
canvas!.width = image.width;
|
||||
canvas!.height = image.height;
|
||||
ctx?.drawImage(image, 0, 0);
|
||||
|
||||
enablePanelDrawing(canvas!);
|
||||
});
|
||||
}
|
||||
|
||||
function enablePanelDrawing(canvas: HTMLCanvasElement): void {
|
||||
let isDrawing = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
canvas.addEventListener("mousedown", (e) => {
|
||||
isDrawing = true;
|
||||
startX = e.offsetX;
|
||||
startY = e.offsetY;
|
||||
});
|
||||
|
||||
canvas.addEventListener("mousemove", (e) => {
|
||||
if (!isDrawing) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
// Clear and redraw to show selection rectangle
|
||||
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx?.drawImage(canvas, 0, 0);
|
||||
ctx?.strokeRect(startX, startY, e.offsetX - startX, e.offsetY - startY);
|
||||
});
|
||||
|
||||
canvas.addEventListener("mouseup", (e) => {
|
||||
if (!isDrawing) return;
|
||||
isDrawing = false;
|
||||
|
||||
const panel: Panel = {
|
||||
id: `manual-${Date.now()}`,
|
||||
x: (startX / canvas.width) * 100,
|
||||
y: (startY / canvas.height) * 100,
|
||||
width: ((e.offsetX - startX) / canvas.width) * 100,
|
||||
height: ((e.offsetY - startY) / canvas.height) * 100,
|
||||
reading_order: 0,
|
||||
};
|
||||
|
||||
saveManualPanel(panel);
|
||||
});
|
||||
}
|
||||
|
||||
async function saveManualPanel(panel: Panel): Promise<void> {
|
||||
const mediaItemId = document.body.dataset.mediaItemId;
|
||||
const pageNumber = getCurrentPageNumber();
|
||||
|
||||
await apiPut(`/readers/${mediaItemId}/panels/${pageNumber}`, {
|
||||
detection_method: "manual",
|
||||
panels: [panel],
|
||||
});
|
||||
|
||||
loadPage(pageNumber);
|
||||
}
|
||||
|
||||
// Re-detect panels using detection service
|
||||
async function reDetectPanels(pageNumber: number): Promise<Panel[]> {
|
||||
const image = await loadImageForPage(pageNumber);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
|
||||
// Alpine component
|
||||
Alpine.data("panelEditor", () => ({
|
||||
get isComicOrManga(): boolean {
|
||||
const libraryType = document.body.dataset.mediaType;
|
||||
return libraryType === "comic" || libraryType === "manga";
|
||||
},
|
||||
|
||||
openPanelEditor(pageNumber: number) {
|
||||
openPanelEditor(pageNumber);
|
||||
},
|
||||
|
||||
async reDetectPanels(pageNumber: number) {
|
||||
const panels = await reDetectPanels(pageNumber);
|
||||
return panels;
|
||||
}
|
||||
}));
|
||||
|
||||
export { openPanelEditor, reDetectPanels };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Page Cache Integration (`page-cache.ts` - Optional)
|
||||
|
||||
Optional: Add on-demand panel detection to page-cache.ts:
|
||||
|
||||
```typescript
|
||||
// Add this import at the top
|
||||
import { detectPanels } from "./panel-detection.service";
|
||||
|
||||
// Add to PageCacheState interface
|
||||
interface PageCacheState {
|
||||
cache: Map<number, HTMLImageElement>;
|
||||
loading: Set<number>;
|
||||
maxAhead: number;
|
||||
mediaItemId: string;
|
||||
panelData: Map<number, { panels: any[]; method: string; confidence: number }>;
|
||||
}
|
||||
|
||||
// Add this function
|
||||
async function detectPagePanels(
|
||||
state: PageCacheState,
|
||||
pageNumber: number
|
||||
): Promise<any[]> {
|
||||
// Check if already detected
|
||||
if (state.panelData?.has(pageNumber)) {
|
||||
return state.panelData.get(pageNumber)!.panels;
|
||||
}
|
||||
|
||||
// Get or create image
|
||||
let image: HTMLImageElement;
|
||||
if (state.cache.has(pageNumber)) {
|
||||
image = state.cache.get(pageNumber)!;
|
||||
} else {
|
||||
image = await loadComicPage(state, pageNumber);
|
||||
}
|
||||
|
||||
// Run detection on demand
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const result = await detectPanels(imageData, true);
|
||||
|
||||
if (!state.panelData) {
|
||||
state.panelData = new Map();
|
||||
}
|
||||
state.panelData.set(pageNumber, result);
|
||||
|
||||
return result.panels;
|
||||
}
|
||||
|
||||
// Export the new function
|
||||
export { createPageCache, getCachedPage, loadComicPage, detectPagePanels };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Add dependencies to `package.json`** and run `npm install`
|
||||
2. **Create `panel-detection.service.ts`**
|
||||
3. **Create `panel-detection.opencv.ts`**
|
||||
4. **Create `panel-detection.ml.ts`**
|
||||
5. **Update `panel-detector.ts`** - add export statement (one line at the end)
|
||||
6. **Update `panel-editor.ts`** - add imports and re-detect function
|
||||
7. **(Optional) Update `page-cache.ts`** - add on-demand detection
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **User feedback loop:** Store user corrections to improve detection
|
||||
2. **Per-comic detection:** Different methods for different comic styles
|
||||
3. **Batch detection:** Pre-detect pages in background
|
||||
4. **Detection history:** Track which method works best per comic
|
||||
5. **Panel preview:** Show detected panels before entering panel view
|
||||
@@ -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 |
|
||||
| **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
-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)
|
||||
@@ -2,20 +2,20 @@ name: Bookhoard
|
||||
variables:
|
||||
- name: base_url
|
||||
value: http://localhost:8765
|
||||
- name: media_item_id
|
||||
value: 8bd13107-e1e5-4357-b893-bfc77f1e087c
|
||||
- name: fake_book_id
|
||||
value: 123e4567-e89b-12d3-a456-426614174000
|
||||
- name: user_id
|
||||
value: c51118f0-31fc-4c32-827d-517d6599bf21
|
||||
- name: highlight_id
|
||||
value: 660f9501-f29b-51d4-b716-446655440001
|
||||
- name: note_id
|
||||
value: 7710a602-g29b-61d4-c716-446655440002
|
||||
- name: ebook_library_id
|
||||
value: 0df0ea2b-1965-494a-a0a3-cce8536d5f28
|
||||
- name: job_id
|
||||
value: 709e0d8e-b866-496c-b260-a59ab8e2014c
|
||||
- secret: true
|
||||
name: media_item_id
|
||||
- secret: true
|
||||
name: fake_book_id
|
||||
- secret: true
|
||||
name: user_id
|
||||
- secret: true
|
||||
name: highlight_id
|
||||
- secret: true
|
||||
name: note_id
|
||||
- secret: true
|
||||
name: ebook_library_id
|
||||
- secret: true
|
||||
name: job_id
|
||||
- name: rating
|
||||
value: "5"
|
||||
- name: is_visible
|
||||
@@ -32,11 +32,11 @@ variables:
|
||||
name: kobo_device_token
|
||||
- secret: true
|
||||
name: other_device_id
|
||||
- name: collection_id
|
||||
value: 412c03c3-0843-4bd4-b764-cc9457bb9df2
|
||||
- name: library_folder_id
|
||||
value: da1f9d91-0c4c-40cc-a050-86f795dfc967
|
||||
- name: comic_library_id
|
||||
value: 7b6d0c8c-73dc-4346-804c-5f6e11c3d658
|
||||
- name: manga_library_id
|
||||
value: b134d16d-9668-4867-b50e-8350037f1a4a
|
||||
- secret: true
|
||||
name: collection_id
|
||||
- secret: true
|
||||
name: library_folder_id
|
||||
- secret: true
|
||||
name: comic_library_id
|
||||
- secret: true
|
||||
name: manga_library_id
|
||||
|
||||
@@ -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"
|
||||
+77
-7
@@ -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,46 +50,108 @@ 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()
|
||||
|
||||
// Create sync queue processor
|
||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
||||
progressService := sync.NewProgressService(queries, connManager)
|
||||
annotationService := sync.NewAnnotationService(queries, connManager)
|
||||
annotationService.SetSettings(registry)
|
||||
maintenanceCancel := annotationService.StartDailyMaintenance()
|
||||
defer maintenanceCancel()
|
||||
|
||||
queueProcessor := sync.NewSyncQueueProcessorWithConfig(queries, registry.SyncQueueConfig().Interval, registry.SyncQueueConfig().BatchSize)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
queueProcessor.SetAnnotationService(annotationService)
|
||||
|
||||
// Create library service
|
||||
libraryService := services.NewLibraryService(queries)
|
||||
|
||||
// Sync Go AllowedExtensions into DB so API clients see correct extensions
|
||||
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)
|
||||
|
||||
// Create conversion service for EPUB→KEPUB conversion
|
||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||
conversionService.SetSettings(registry)
|
||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||
opdsHandler.SetSettings(registry)
|
||||
|
||||
// NEW: Create refactored handlers
|
||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||
dashboardService := services.NewDashboardService(queries)
|
||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
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)
|
||||
|
||||
@@ -131,6 +195,7 @@ func main() {
|
||||
Echo: e,
|
||||
Queries: queries,
|
||||
Cfg: cfg,
|
||||
Settings: registry,
|
||||
DBPool: dbPool,
|
||||
AuthHandler: authHandler,
|
||||
LibraryHandler: libraryHandler,
|
||||
@@ -138,6 +203,7 @@ func main() {
|
||||
MediaHandler: mediaHandler,
|
||||
MatchingHandler: matchingHandler,
|
||||
ProcessingIssuesHandler: processingIssuesHandler,
|
||||
HashConflictsHandler: hashConflictsHandler,
|
||||
KOReaderHandler: koreaderHandler,
|
||||
WSHandler: wsHandler,
|
||||
ConflictHandler: conflictHandler,
|
||||
@@ -147,15 +213,19 @@ func main() {
|
||||
FiltersHandler: filtersHandler,
|
||||
DashboardHandler: dashboardHandler,
|
||||
DashboardService: dashboardService,
|
||||
SeriesHandler: seriesHandler,
|
||||
OPDSHandler: opdsHandler,
|
||||
Worker: worker,
|
||||
SystemSettingsHandler: systemSettingsHandler,
|
||||
SidecarHandler: sidecarHandler,
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
JobsHandler: jobsHandler,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
LibraryService: libraryService,
|
||||
}
|
||||
|
||||
// Register all routes and get ebook handler
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/handlers"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -21,7 +22,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -32,12 +35,15 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ReadingStatsResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.GreaterOrEqual(t, result.TotalBooksRead, 0)
|
||||
assert.GreaterOrEqual(t, result.TotalPagesRead, 0)
|
||||
@@ -45,15 +51,17 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("GetReadingStats_WithCustomDateRange", func(t *testing.T) {
|
||||
startDate := time.Now().AddDate(0, -2, 0).Format("2006-01-02")
|
||||
endDate := time.Now().Format("2006-01-02")
|
||||
startDate := time.Now().AddDate(0, -2, 0).Format("01-02-2006")
|
||||
endDate := time.Now().Format("01-02-2006")
|
||||
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
@@ -64,7 +72,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -75,7 +85,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -86,12 +98,15 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should return zero values for empty history
|
||||
assert.Equal(t, 0.0, result["total_books_read"])
|
||||
@@ -108,7 +123,9 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -119,19 +136,19 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.DeviceUsageResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, result.Devices)
|
||||
assert.Equal(t, 0, len(result.Devices))
|
||||
})
|
||||
|
||||
t.Run("GetDeviceUsage_WithAuth_WithDevices", func(t *testing.T) {
|
||||
// First create a device
|
||||
// First, create a device
|
||||
deviceReq := map[string]interface{}{
|
||||
"device_name": "Test Kobo",
|
||||
"device_type": "kobo",
|
||||
@@ -144,7 +161,9 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(deviceReqHTTP)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Now get device usage
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
||||
@@ -152,12 +171,15 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
devices, ok := result["devices"].([]interface{})
|
||||
assert.True(t, ok)
|
||||
@@ -171,12 +193,15 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "devices")
|
||||
|
||||
@@ -203,7 +228,9 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -214,12 +241,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.PopularBooksResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotNil(t, result.Books)
|
||||
// Default limit is 10, but may be fewer if no reading history
|
||||
@@ -232,12 +262,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
assert.True(t, len(books) <= 5)
|
||||
@@ -249,20 +282,23 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should default to 10 on invalid limit
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
assert.True(t, len(books) <= 10)
|
||||
})
|
||||
|
||||
t.Run("GetPopularBooks_ResponseStructure", func(t *testing.T) {
|
||||
// First create a book and some reading history
|
||||
// First, create a book and some reading history
|
||||
bookID := createTestMediaItemID(t, setup)
|
||||
|
||||
// Create reading history for the book
|
||||
@@ -280,7 +316,9 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(historyHTTP)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Now get popular books
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
||||
@@ -288,12 +326,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
|
||||
@@ -315,12 +356,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
// Should return empty array if no reading history
|
||||
@@ -334,21 +378,24 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("ReadingStats_FutureDateRange", func(t *testing.T) {
|
||||
startDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
||||
endDate := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
|
||||
startDate := time.Now().AddDate(0, 0, 7).Format("01-02-2006")
|
||||
endDate := time.Now().AddDate(0, 0, 14).Format("01-02-2006")
|
||||
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should succeed but return empty stats
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0.0, result["total_books_read"])
|
||||
})
|
||||
@@ -359,13 +406,16 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle limit=0 gracefully
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
books := result["books"].([]interface{})
|
||||
assert.Equal(t, 0, len(books))
|
||||
@@ -377,7 +427,9 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle large limit
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -27,7 +28,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -47,12 +50,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "matches")
|
||||
assert.Contains(t, result, "action")
|
||||
@@ -67,7 +73,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -85,12 +93,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
var matches []interface{}
|
||||
if matchesIf, ok := result["matches"]; ok && matchesIf != nil {
|
||||
@@ -124,7 +135,9 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -142,12 +155,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0.0, result["total"])
|
||||
assert.Equal(t, 0.0, result["successful"])
|
||||
@@ -175,12 +191,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -221,12 +240,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 3.0, result["total"])
|
||||
results := result["results"].([]interface{})
|
||||
@@ -251,7 +273,9 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -267,12 +291,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "auto_linked")
|
||||
assert.Contains(t, result, "results")
|
||||
@@ -292,12 +319,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "auto_linked")
|
||||
})
|
||||
@@ -315,12 +345,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should succeed even with no books to link
|
||||
assert.Contains(t, result, "auto_linked")
|
||||
@@ -338,7 +371,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -350,7 +385,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -363,7 +400,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
@@ -378,7 +417,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Even when book not found, we expect 404
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
@@ -396,7 +437,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -409,12 +452,15 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "device_id")
|
||||
assert.Contains(t, result, "aliases")
|
||||
@@ -439,7 +485,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -462,7 +510,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -485,7 +535,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -505,7 +557,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -519,7 +573,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -535,7 +591,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -547,12 +605,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "matches")
|
||||
assert.Contains(t, result, "action")
|
||||
@@ -565,7 +626,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -577,12 +640,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "matches")
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -44,7 +45,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -61,7 +64,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -85,12 +90,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -122,10 +130,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Now try to add invalid book IDs
|
||||
@@ -145,12 +156,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
results := result["results"].([]interface{})
|
||||
firstResult := results[0].(map[string]interface{})
|
||||
@@ -171,10 +185,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Create a book
|
||||
@@ -197,12 +214,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -228,10 +248,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Create multiple books
|
||||
@@ -256,12 +279,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 3.0, result["total"])
|
||||
assert.True(t, result["added"].(float64) > 0)
|
||||
@@ -281,10 +307,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult1 map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult1)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult1)
|
||||
require.NoError(t, err)
|
||||
collectionID1 := collectionResult1["id"].(string)
|
||||
|
||||
collectionReq2 := map[string]interface{}{
|
||||
@@ -299,10 +328,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(collectionHTTP2)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult2 map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult2)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult2)
|
||||
require.NoError(t, err)
|
||||
collectionID2 := collectionResult2["id"].(string)
|
||||
|
||||
// Create books
|
||||
@@ -330,12 +362,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, 3.0, result["total"])
|
||||
@@ -355,10 +390,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Create a book
|
||||
@@ -381,7 +419,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err = client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Try to add same book again - create new request with fresh body
|
||||
addBody2, _ := json.Marshal(addReq)
|
||||
@@ -391,7 +431,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp2, err := client.Do(addHTTP2)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
// Should handle duplicate gracefully (either succeed or return error)
|
||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
@@ -405,7 +447,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
@@ -112,7 +112,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 0, len(items), "Empty rules should return no matched items")
|
||||
@@ -139,7 +140,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 2, len(items), "Should return exactly 2 manually selected books")
|
||||
@@ -171,7 +173,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Greater(t, len(items), 0, "Should return books matching the genre rule")
|
||||
@@ -203,7 +206,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Greater(t, len(items), 0, "Should return books from rules and manual selection")
|
||||
@@ -233,7 +237,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.LessOrEqual(t, len(items), 2, "Should respect limit parameter")
|
||||
@@ -259,7 +264,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 1, len(items), "Should still return items when limit exceeds max")
|
||||
@@ -285,7 +291,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 1, len(items), "Limit 0 should default to 20 and still return matched items")
|
||||
@@ -312,7 +319,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 1, len(items), "Invalid book IDs should be skipped, valid ones included")
|
||||
@@ -340,7 +348,8 @@ func TestPreviewCollection(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(rec.Body).Decode(&result)
|
||||
err := json.NewDecoder(rec.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
items := result["items"].([]interface{})
|
||||
assert.Equal(t, 1, len(items), "Duplicate book IDs should result in unique items")
|
||||
|
||||
@@ -132,7 +132,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -152,7 +154,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -169,7 +173,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -190,7 +196,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -210,7 +218,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -227,7 +237,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -244,7 +256,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -262,7 +276,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -280,7 +296,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -298,7 +316,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -316,7 +336,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -334,7 +356,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -353,7 +377,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -371,7 +397,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -389,7 +417,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -439,7 +469,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -469,7 +501,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -500,7 +534,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -526,7 +562,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -567,7 +605,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -594,7 +634,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/media/"+mediaUUID, nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "Should require authentication")
|
||||
})
|
||||
@@ -610,7 +652,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -629,7 +673,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
@@ -719,7 +765,9 @@ func TestComicMetadataDisplay_AllFieldsTogether(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
|
||||
+503
-210
@@ -1,289 +1,582 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestConflictDetection_TriggeringConditions(t *testing.T) {
|
||||
t.Run("conflict detected when different devices sync within 5 minutes", func(t *testing.T) {
|
||||
conflictData := map[string]map[string]interface{}{
|
||||
"koreader": {
|
||||
type conflictTestEnv struct {
|
||||
setup *TestServerSetup
|
||||
mediaID string
|
||||
userID pgtype.UUID
|
||||
mediaPGID pgtype.UUID
|
||||
}
|
||||
|
||||
func setupConflictTest(t *testing.T) *conflictTestEnv {
|
||||
t.Helper()
|
||||
|
||||
setup := setupTestServer(t)
|
||||
mediaID := createTestMediaItemID(t, setup)
|
||||
|
||||
ctx := context.Background()
|
||||
user, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||||
require.NoError(t, err)
|
||||
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &conflictTestEnv{
|
||||
setup: setup,
|
||||
mediaID: mediaID,
|
||||
userID: user.ID,
|
||||
mediaPGID: pgtype.UUID{Bytes: [16]byte(mediaUUID), Valid: true},
|
||||
}
|
||||
}
|
||||
|
||||
func createTestConflict(t *testing.T, env *conflictTestEnv, conflictData map[string]interface{}) database.SyncConflicts {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
dataJSON, err := json.Marshal(conflictData)
|
||||
require.NoError(t, err)
|
||||
|
||||
conflict, err := env.setup.DB.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
|
||||
MediaItemID: env.mediaPGID,
|
||||
UserID: env.userID,
|
||||
ConflictType: "progress",
|
||||
ConflictData: dataJSON,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return conflict
|
||||
}
|
||||
|
||||
func makeConflictData(koreaderPct, koboPct float64) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"koreader": map[string]interface{}{
|
||||
"source": "koreader",
|
||||
"timestamp": "2026-01-30T20:10:00Z",
|
||||
"timestamp": time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
|
||||
"data": map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"chapter": 3,
|
||||
"percentage": koreaderPct,
|
||||
},
|
||||
},
|
||||
"kobo": {
|
||||
"kobo": map[string]interface{}{
|
||||
"source": "kobo",
|
||||
"timestamp": "2026-01-30T20:05:00Z",
|
||||
"timestamp": time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||
"data": map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
"page": 89,
|
||||
"percentage": koboPct,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(conflictData)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/sync/koreader/progress", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
assert.Equal(t, "POST", req.Method)
|
||||
assert.Contains(t, string(body), "koreader")
|
||||
assert.Contains(t, string(body), "kobo")
|
||||
})
|
||||
|
||||
t.Run("no conflict when progress difference is less than 1%", func(t *testing.T) {
|
||||
progressData := map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
}
|
||||
|
||||
existingProgress := map[string]interface{}{
|
||||
"percentage": 0.451,
|
||||
func TestConflictList_Empty(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ConflictListResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 0, result.Total)
|
||||
assert.Empty(t, result.Conflicts)
|
||||
}
|
||||
|
||||
diff := progressData["percentage"].(float64) - existingProgress["percentage"].(float64)
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
func TestConflictList_WithConflicts(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
|
||||
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts?status=all", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ConflictListResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.GreaterOrEqual(t, result.Total, 1)
|
||||
require.NotEmpty(t, result.Conflicts)
|
||||
|
||||
conflict := result.Conflicts[0]
|
||||
assert.Equal(t, "progress", conflict.ConflictType)
|
||||
assert.Equal(t, "unresolved", conflict.ResolutionStatus)
|
||||
assert.Contains(t, conflict.ConflictData, "koreader")
|
||||
assert.Contains(t, conflict.ConflictData, "kobo")
|
||||
}
|
||||
|
||||
assert.Less(t, diff, 0.01, "Should not trigger conflict for small differences")
|
||||
})
|
||||
func TestConflictList_UnresolvedCount(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("no conflict when sync timestamps are more than 5 minutes apart", func(t *testing.T) {
|
||||
timestamp1 := "2026-01-30T20:00:00Z"
|
||||
timestamp2 := "2026-01-30T20:10:00Z"
|
||||
createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
|
||||
var conflictDetected bool
|
||||
if timestamp2 > timestamp1 {
|
||||
conflictDetected = false
|
||||
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var result handlers.ConflictListResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.GreaterOrEqual(t, result.Unresolved, 1)
|
||||
}
|
||||
|
||||
assert.False(t, conflictDetected, "Should not trigger conflict for old syncs")
|
||||
})
|
||||
func TestConflictGet_ByID(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var detail handlers.ConflictDetailResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&detail)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, conflictID, detail.ID)
|
||||
assert.Equal(t, env.mediaID, detail.MediaItemID)
|
||||
assert.Equal(t, "progress", detail.ConflictType)
|
||||
assert.Contains(t, detail.ConflictData, "koreader")
|
||||
assert.Contains(t, detail.ConflictData, "kobo")
|
||||
}
|
||||
|
||||
func TestConflictResolution_ChoosingWinner(t *testing.T) {
|
||||
t.Run("resolve conflict by choosing koreader source", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
func TestConflictGet_NotFound(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictGet_InvalidID(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/not-a-uuid", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictResolve_ByKOReader(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.75, 0.30))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "koreader",
|
||||
"manual_data": nil,
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "More recent progress",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
assert.Equal(t, "POST", req.Method)
|
||||
assert.Contains(t, req.URL.Path, conflictID.String())
|
||||
assert.Contains(t, string(body), "koreader")
|
||||
})
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
t.Run("resolve conflict with manual merge data", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
manualData := map[string]interface{}{
|
||||
var result handlers.ConflictResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.ConflictResolved)
|
||||
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
|
||||
}
|
||||
|
||||
func TestConflictResolve_ByKobo(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.30, 0.75))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "kobo",
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "Higher progress",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.ConflictResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.ConflictResolved)
|
||||
assert.Equal(t, map[string]bool{"progress": true, "annotations": false}, result.AppliedTo)
|
||||
}
|
||||
|
||||
func TestConflictResolve_WithManualData(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "manual",
|
||||
"manual_data": map[string]interface{}{
|
||||
"percentage": 0.43,
|
||||
"epubcfi": "epubcfi(/6/4/2:20)",
|
||||
"chapter": 3,
|
||||
"page": 90,
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"winner": "manual",
|
||||
"manual_data": manualData,
|
||||
},
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "Custom merged position",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
assert.Contains(t, string(body), "manual")
|
||||
assert.Contains(t, string(body), "0.43")
|
||||
})
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
t.Run("error when winner is manual but no manual_data provided", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
var result handlers.ConflictResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, result.ConflictResolved)
|
||||
}
|
||||
|
||||
func TestConflictResolve_ManualWithoutData(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "manual",
|
||||
"manual_data": nil,
|
||||
"apply_to_all_future_conflicts": false,
|
||||
"reason": "Test",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
body, err := json.Marshal(reqBody)
|
||||
assert.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
assert.Contains(t, string(body), "manual")
|
||||
})
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictListing_Filtering(t *testing.T) {
|
||||
t.Run("list only unresolved conflicts", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/conflicts?status=unresolved", nil)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Contains(t, req.URL.Query().Get("status"), "unresolved")
|
||||
})
|
||||
func TestConflictResolve_AlreadyResolved(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("list all conflicts regardless of status", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/conflicts?status=all", nil)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Contains(t, req.URL.Query().Get("status"), "all")
|
||||
})
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
t.Run("list only resolved conflicts", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/conflicts?status=user_resolved", nil)
|
||||
assert.Equal(t, "GET", req.Method)
|
||||
assert.Contains(t, req.URL.Query().Get("status"), "user_resolved")
|
||||
})
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "koreader",
|
||||
"reason": "First resolution",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp2, err := client.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp2.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictResponse_Structure(t *testing.T) {
|
||||
t.Run("conflict detail response includes all required fields", func(t *testing.T) {
|
||||
conflictResponse := map[string]interface{}{
|
||||
"id": "conflict-uuid-123",
|
||||
"media_item_id": "book-uuid-456",
|
||||
"media_item_title": "Test Book Title",
|
||||
"conflict_type": "progress",
|
||||
"resolution_status": "unresolved",
|
||||
"created_at": "2026-01-30T20:10:00Z",
|
||||
"conflict_data": map[string]interface{}{
|
||||
"koreader": map[string]interface{}{
|
||||
"source": "koreader",
|
||||
"data": map[string]interface{}{
|
||||
"percentage": 0.45,
|
||||
},
|
||||
},
|
||||
"kobo": map[string]interface{}{
|
||||
"source": "kobo",
|
||||
"data": map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
},
|
||||
},
|
||||
},
|
||||
func TestConflictResolve_InvalidWinner(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "nonexistent_source",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := json.Marshal(conflictResponse)
|
||||
assert.NoError(t, err)
|
||||
func TestConflictResolve_NotFound(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
var parsed map[string]interface{}
|
||||
err = json.Unmarshal(body, &parsed)
|
||||
assert.NoError(t, err)
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "koreader",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
assert.Contains(t, parsed, "id")
|
||||
assert.Contains(t, parsed, "media_item_id")
|
||||
assert.Contains(t, parsed, "conflict_data")
|
||||
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "koreader")
|
||||
assert.Contains(t, parsed["conflict_data"].(map[string]interface{}), "kobo")
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictDelete(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req, _ := http.NewRequest("DELETE", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
req2, _ := http.NewRequest("GET", env.setup.Server.URL+"/api/conflicts/"+conflictID, nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp2, err := client.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp2.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictDelete_NotFound(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestConflictDismissAllResolved(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.50, 0.30))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
resolveReq := map[string]interface{}{
|
||||
"winner": "koreader",
|
||||
}
|
||||
body, _ := json.Marshal(resolveReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
req2, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/dismiss-all", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp2, err := client.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp2.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
deleted, ok := result["deleted"].(float64)
|
||||
assert.True(t, ok)
|
||||
assert.GreaterOrEqual(t, int(deleted), 1)
|
||||
}
|
||||
|
||||
func TestConflictEndpoints_RequireAuth(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("list conflicts requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("conflict list response includes summary counts", func(t *testing.T) {
|
||||
listResponse := map[string]interface{}{
|
||||
"conflicts": []interface{}{
|
||||
map[string]string{"id": "conflict-1", "resolution_status": "unresolved"},
|
||||
map[string]string{"id": "conflict-2", "resolution_status": "unresolved"},
|
||||
},
|
||||
"total": 2,
|
||||
"unresolved": 2,
|
||||
}
|
||||
|
||||
body, err := json.Marshal(listResponse)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
err = json.Unmarshal(body, &parsed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(2), parsed["total"])
|
||||
assert.Equal(t, float64(2), parsed["unresolved"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestConflictDeletion(t *testing.T) {
|
||||
t.Run("delete single conflict by ID", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
|
||||
req := httptest.NewRequest("DELETE", "/api/conflicts/"+conflictID.String(), nil)
|
||||
assert.Equal(t, "DELETE", req.Method)
|
||||
assert.Contains(t, req.URL.Path, conflictID.String())
|
||||
t.Run("get conflict requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("dismiss all resolved conflicts", func(t *testing.T) {
|
||||
req := httptest.NewRequest("POST", "/api/conflicts/dismiss-all", nil)
|
||||
assert.Equal(t, "POST", req.Method)
|
||||
assert.Contains(t, req.URL.Path, "dismiss-all")
|
||||
})
|
||||
}
|
||||
|
||||
func TestConflictNotification_WebSocketBroadcast(t *testing.T) {
|
||||
t.Run("conflict detection notification", func(t *testing.T) {
|
||||
notification := map[string]interface{}{
|
||||
"type": "conflict",
|
||||
"timestamp": "2026-01-30T20:10:00Z",
|
||||
"data": map[string]interface{}{
|
||||
"book_id": "book-uuid-123",
|
||||
"notification_type": "detection",
|
||||
"conflict_id": "",
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(notification)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
err = json.Unmarshal(body, &parsed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
data := parsed["data"].(map[string]interface{})
|
||||
assert.Equal(t, "detection", data["notification_type"])
|
||||
t.Run("resolve conflict requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/"+uuid.New().String()+"/resolve", bytes.NewBuffer([]byte(`{}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("conflict resolved notification", func(t *testing.T) {
|
||||
conflictID := uuid.New()
|
||||
t.Run("delete conflict requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/conflicts/"+uuid.New().String(), nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
notification := map[string]interface{}{
|
||||
"type": "conflict",
|
||||
"timestamp": "2026-01-30T20:15:00Z",
|
||||
"data": map[string]interface{}{
|
||||
"book_id": "book-uuid-123",
|
||||
"notification_type": "resolved",
|
||||
"conflict_id": conflictID.String(),
|
||||
},
|
||||
}
|
||||
|
||||
body, err := json.Marshal(notification)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var parsed map[string]interface{}
|
||||
err = json.Unmarshal(body, &parsed)
|
||||
assert.NoError(t, err)
|
||||
|
||||
data := parsed["data"].(map[string]interface{})
|
||||
assert.Equal(t, "resolved", data["notification_type"])
|
||||
assert.Equal(t, conflictID.String(), data["conflict_id"])
|
||||
t.Run("dismiss-all requires auth", func(t *testing.T) {
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/dismiss-all", nil)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bookhoard/internal/handlers"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -12,12 +13,193 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestConflictsBulkOperations tests bulk conflict resolution operations
|
||||
func TestConflictsBulkOperations(t *testing.T) {
|
||||
func TestBulkResolve_MostRecentStrategy(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55))
|
||||
|
||||
id1 := uuid.UUID(conflict1.ID.Bytes).String()
|
||||
id2 := uuid.UUID(conflict2.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{id1, id2},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, result.Total)
|
||||
assert.Equal(t, 2, result.Success)
|
||||
assert.Equal(t, 0, result.Failed)
|
||||
require.Len(t, result.Results, 2)
|
||||
|
||||
for _, r := range result.Results {
|
||||
assert.Equal(t, "success", r.Status)
|
||||
assert.Equal(t, "koreader", r.Winner)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkResolve_HighestProgressStrategy(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflictDataHighKobo := makeConflictData(0.30, 0.90)
|
||||
|
||||
conflict := createTestConflict(t, env, conflictDataHighKobo)
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{conflictID},
|
||||
Strategy: "highest_progress",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, result.Total)
|
||||
assert.Equal(t, 1, result.Success)
|
||||
assert.Equal(t, 0, result.Failed)
|
||||
assert.Equal(t, "kobo", result.Results[0].Winner)
|
||||
}
|
||||
|
||||
func TestBulkResolve_ManualStrategy(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{conflictID},
|
||||
Strategy: "manual",
|
||||
WinningSource: "koreader",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, result.Total)
|
||||
assert.Equal(t, 1, result.Success)
|
||||
assert.Equal(t, "koreader", result.Results[0].Winner)
|
||||
}
|
||||
|
||||
func TestBulkResolve_ManualStrategy_WithoutWinner(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{conflictID},
|
||||
Strategy: "manual",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
assert.Equal(t, "error", result.Results[0].Status)
|
||||
assert.Contains(t, result.Results[0].Error, "winning_source")
|
||||
}
|
||||
|
||||
func TestBulkResolve_ManualStrategy_InvalidWinner(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflictID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{conflictID},
|
||||
Strategy: "manual",
|
||||
WinningSource: "nonexistent_device",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
assert.Contains(t, result.Results[0].Error, "invalid winning source")
|
||||
}
|
||||
|
||||
func TestBulkResolve_ConflictNotFound(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("BulkResolveConflicts_WithoutAuth", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "most_recent",
|
||||
@@ -26,15 +208,30 @@ func TestConflictsBulkOperations(t *testing.T) {
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, result.Total)
|
||||
assert.Equal(t, 0, result.Success)
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
assert.Contains(t, result.Results[0].Error, "conflict not found")
|
||||
}
|
||||
|
||||
func TestBulkResolve_EmptyConflictIDs(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("BulkResolveConflicts_EmptyConflictIDs", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{},
|
||||
Strategy: "most_recent",
|
||||
@@ -47,14 +244,19 @@ func TestConflictsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestBulkResolve_InvalidConflictID(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("BulkResolveConflicts_InvalidConflictID", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{"invalid-uuid"},
|
||||
ConflictIDs: []string{"not-a-uuid"},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
@@ -65,164 +267,106 @@ func TestConflictsBulkOperations(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
assert.Equal(t, 1, result.Total)
|
||||
assert.Equal(t, 0, result.Success)
|
||||
assert.Greater(t, result.Failed, 0)
|
||||
|
||||
firstResult := result.Results[0]
|
||||
assert.Equal(t, "error", firstResult.Status)
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_InvalidStrategy", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "invalid_strategy",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Bulk operations return 200 OK with individual error results
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
assert.Greater(t, result.Total, 0)
|
||||
assert.Greater(t, result.Failed, 0)
|
||||
|
||||
firstResult := result.Results[0]
|
||||
assert.Equal(t, "error", firstResult.Status)
|
||||
// The error will be "conflict not found" since we're using a random UUID
|
||||
// The invalid strategy would be caught for valid conflict IDs
|
||||
assert.Contains(t, firstResult.Error, "conflict")
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_MostRecentStrategy", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String(), uuid.New().String()},
|
||||
Strategy: "most_recent",
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
assert.Contains(t, result.Results[0].Error, "invalid conflict ID")
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
func TestBulkResolve_InvalidRequestBody(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
assert.Equal(t, 2, result.Total)
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_HighestProgressStrategy", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String(), uuid.New().String()},
|
||||
Strategy: "highest_progress",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
assert.Equal(t, 2, result.Total)
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_ManualStrategy_WithoutWinner", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "manual",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result handlers.BulkResolveResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.NotEmpty(t, result.Results, "Should have results")
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_ManualStrategy_WithWinner", func(t *testing.T) {
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "manual",
|
||||
WinningSource: "device",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("BulkResolveConflicts_InvalidRequestBody", func(t *testing.T) {
|
||||
// Send invalid JSON
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json")))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConflictsBulkDismiss tests bulk dismiss operations
|
||||
func TestConflictsBulkDismiss(t *testing.T) {
|
||||
func TestBulkResolve_RequiresAuth(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{uuid.New().String()},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestBulkDismiss_RealConflicts(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55))
|
||||
|
||||
id1 := uuid.UUID(conflict1.ID.Bytes).String()
|
||||
id2 := uuid.UUID(conflict2.ID.Bytes).String()
|
||||
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{id1, id2},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(2), result["total"])
|
||||
assert.Equal(t, float64(2), result["success"])
|
||||
assert.Equal(t, float64(0), result["failed"])
|
||||
|
||||
results := result["results"].([]interface{})
|
||||
require.Len(t, results, 2)
|
||||
for _, r := range results {
|
||||
entry := r.(map[string]interface{})
|
||||
assert.Equal(t, "success", entry["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkDismiss_NotFoundConflict(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("BulkDismissConflicts_WithoutAuth", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{uuid.New().String()},
|
||||
}
|
||||
@@ -230,15 +374,59 @@ func TestConflictsBulkDismiss(t *testing.T) {
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(1), result["total"])
|
||||
assert.Equal(t, float64(0), result["success"])
|
||||
assert.Equal(t, float64(1), result["failed"])
|
||||
}
|
||||
|
||||
func TestBulkDismiss_InvalidConflictID(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{"invalid-uuid"},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, float64(1), result["total"])
|
||||
assert.Equal(t, float64(1), result["failed"])
|
||||
assert.Contains(t, result["results"].([]interface{})[0].(map[string]interface{})["error"], "invalid conflict ID")
|
||||
}
|
||||
|
||||
func TestBulkDismiss_EmptyConflictIDs(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("BulkDismissConflicts_EmptyConflictIDs", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{},
|
||||
}
|
||||
@@ -250,176 +438,81 @@ func TestConflictsBulkDismiss(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("BulkDismissConflicts_InvalidConflictID", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{"invalid-uuid", uuid.New().String()},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
func TestBulkDismiss_InvalidRequestBody(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
assert.Contains(t, result, "success")
|
||||
assert.Contains(t, result, "failed")
|
||||
|
||||
results := result["results"].([]interface{})
|
||||
firstResult := results[0].(map[string]interface{})
|
||||
assert.Equal(t, "error", firstResult["status"])
|
||||
})
|
||||
|
||||
t.Run("BulkDismissConflicts_MultipleConflicts", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{
|
||||
uuid.New().String(),
|
||||
uuid.New().String(),
|
||||
uuid.New().String(),
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, float64(3), result["total"])
|
||||
})
|
||||
|
||||
t.Run("BulkDismissConflicts_InvalidRequestBody", func(t *testing.T) {
|
||||
// Send invalid JSON
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer([]byte("invalid json")))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
// TestConflictsBulkEscalate tests bulk escalate operations
|
||||
// NOTE: This test is commented out because the /api/conflicts/bulk-escalate endpoint
|
||||
// does not exist yet. It was planned in TEST_RELIABILITY_PLAN.md but never implemented.
|
||||
// Uncomment and update when the endpoint is added.
|
||||
/*
|
||||
func TestConflictsBulkEscalate(t *testing.T) {
|
||||
func TestBulkDismiss_RequiresAuth(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
t.Run("BulkEscalateConflicts_WithoutAuth", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{uuid.New().String()},
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("BulkEscalateConflicts_EmptyConflictIDs", func(t *testing.T) {
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": []string{},
|
||||
func TestBulkResolve_MixedSuccessAndFailure(t *testing.T) {
|
||||
env := setupConflictTest(t)
|
||||
client := &http.Client{}
|
||||
|
||||
conflict := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||
realID := uuid.UUID(conflict.ID.Bytes).String()
|
||||
|
||||
req := handlers.BulkResolveRequest{
|
||||
ConflictIDs: []string{realID, uuid.New().String()},
|
||||
Strategy: "most_recent",
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
||||
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Allow queue processor to process the item before querying for conflicts
|
||||
time.Sleep(3 * time.Second)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
assert.Contains(t, result, "failed")
|
||||
|
||||
results := result["results"].([]interface{})
|
||||
firstResult := results[0].(map[string]interface{})
|
||||
assert.Equal(t, "error", firstResult["status"])
|
||||
})
|
||||
|
||||
t.Run("BulkEscalateConflicts_MultipleConflicts", func(t *testing.T) {
|
||||
conflictIDs := []string{
|
||||
uuid.New().String(),
|
||||
uuid.New().String(),
|
||||
}
|
||||
req := map[string]interface{}{
|
||||
"conflict_ids": conflictIDs,
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
|
||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
var result handlers.BulkResolveResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, float64(2), result["total"])
|
||||
|
||||
// NEW: Database verification - verify conflicts were escalated
|
||||
for _, conflictID := range conflictIDs {
|
||||
pgID, err := uuid.Parse(conflictID)
|
||||
if err != nil {
|
||||
continue // Skip invalid UUIDs
|
||||
assert.Equal(t, 2, result.Total)
|
||||
assert.Equal(t, 1, result.Success)
|
||||
assert.Equal(t, 1, result.Failed)
|
||||
}
|
||||
|
||||
conflict, err := setup.DB.GetSyncConflict(context.Background(), pgtype.UUID{Bytes: [16]byte(pgID), Valid: true})
|
||||
if err == nil {
|
||||
// If conflict exists, verify it was escalated
|
||||
assert.Equal(t, "escalated", conflict.ResolutionStatus.String, "Conflict should be escalated")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -40,7 +41,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -50,7 +53,7 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
|
||||
sections, ok := response["sections"].([]interface{})
|
||||
require.True(s.T(), ok, "sections should be an array")
|
||||
require.Len(s.T(), sections, 4, "Should have 4 system collections")
|
||||
require.Len(s.T(), sections, 5, "Should have 5 system collections")
|
||||
|
||||
// Verify response structure
|
||||
sectionMap := make(map[string]map[string]interface{})
|
||||
@@ -71,6 +74,7 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
assert.Contains(s.T(), sectionMap, "recently-added")
|
||||
assert.Contains(s.T(), sectionMap, "recently-read")
|
||||
assert.Contains(s.T(), sectionMap, "not-started")
|
||||
assert.Contains(s.T(), sectionMap, "continue-series")
|
||||
|
||||
// Verify continue-reading is a system collection
|
||||
continueReading := sectionMap["continue-reading"]
|
||||
@@ -87,7 +91,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -101,7 +107,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -112,7 +120,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_Unauthorized() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -136,7 +146,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Success() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -164,7 +176,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Unauthorized() {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -184,7 +198,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName(
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -201,7 +217,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -209,7 +227,7 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
|
||||
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames() {
|
||||
token := s.setup.Token
|
||||
|
||||
validCollections := []string{"Continue Reading", "Recently Added", "Recently Read", "Not Started"}
|
||||
validCollections := []string{"Continue Reading", "Recently Added", "Recently Read", "Not Started", "Continue Series"}
|
||||
|
||||
for _, collName := range validCollections {
|
||||
s.T().Run(collName, func(t *testing.T) {
|
||||
@@ -224,14 +242,16 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
assert.Contains(t, response, "message")
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
@@ -68,7 +69,9 @@ func TestUpdateUserMaxDevices(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected status code")
|
||||
|
||||
@@ -134,7 +137,9 @@ func TestUpdateUserMaxDevicesValidation(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected validation error")
|
||||
})
|
||||
@@ -166,7 +171,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -190,7 +197,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
@@ -221,7 +230,9 @@ func TestUpdateUserMaxDevicesNonExistentUser(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 500 or 404 depending on implementation
|
||||
assert.True(t, resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusNotFound)
|
||||
@@ -250,7 +261,9 @@ func TestUpdateUserMaxDevicesMissingUserID(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -271,12 +284,15 @@ func TestListUsersIncludesMaxDevices(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var users []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&users)
|
||||
err = json.NewDecoder(resp.Body).Decode(&users)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify max_devices and device_count fields are present in response
|
||||
if len(users) > 0 {
|
||||
@@ -306,7 +322,7 @@ func createAdminUser(t *testing.T, ts *httptest.Server, token string) {
|
||||
|
||||
client := &http.Client{}
|
||||
resp, _ := client.Do(req)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// Helper function to create test user for max devices tests
|
||||
@@ -327,7 +343,9 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Check if user creation succeeded or already exists (409 Conflict)
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
@@ -343,10 +361,13 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
||||
|
||||
loginResp, err := client.Do(loginReq)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Extract user_id from JWT or response
|
||||
// The access_token contains the user ID in the JWT claims
|
||||
@@ -392,7 +413,8 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check if user creation was successful
|
||||
if result["user"] == nil {
|
||||
@@ -422,10 +444,13 @@ func getAdminToken(t *testing.T, ts *httptest.Server, userID uuid.UUID) string {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Safe type assertion with check
|
||||
if accessToken, ok := result["access_token"].(string); ok {
|
||||
@@ -450,10 +475,13 @@ func loginTestUserByCredentials(t *testing.T, ts *httptest.Server, email, passwo
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Safe type assertion with check
|
||||
if accessToken, ok := result["access_token"].(string); ok {
|
||||
|
||||
@@ -147,10 +147,12 @@ func TestUpdateDevice(t *testing.T) {
|
||||
device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-123")
|
||||
|
||||
// Update device
|
||||
syncEnabled := false
|
||||
syncFreq := int32(10)
|
||||
updateRequest := handlers.DeviceUpdateRequest{
|
||||
DeviceName: "Updated Device Name",
|
||||
SyncEnabled: new(false),
|
||||
SyncFrequencyMinutes: new(int32(10)),
|
||||
SyncEnabled: &syncEnabled,
|
||||
SyncFrequencyMinutes: &syncFreq,
|
||||
}
|
||||
updateBody, _ := json.Marshal(updateRequest)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -19,7 +20,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters?resource_type=media-items", nil)
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -30,12 +33,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var filters []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&filters)
|
||||
err = json.NewDecoder(resp.Body).Decode(&filters)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(filters))
|
||||
})
|
||||
|
||||
@@ -56,12 +62,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var filter map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&filter)
|
||||
err = json.NewDecoder(resp.Body).Decode(&filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "My Sci-Fi Books", filter["name"])
|
||||
assert.Equal(t, "media-items", filter["resource_type"])
|
||||
assert.NotEmpty(t, filter["id"])
|
||||
@@ -84,7 +93,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp1, err := client.Do(httpReq1)
|
||||
require.NoError(t, err)
|
||||
resp1.Body.Close()
|
||||
_ = resp1.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp1.StatusCode)
|
||||
|
||||
@@ -96,7 +105,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp2, err := client.Do(httpReq2)
|
||||
require.NoError(t, err)
|
||||
resp2.Body.Close()
|
||||
_ = resp2.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusConflict, resp2.StatusCode)
|
||||
})
|
||||
@@ -116,12 +125,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
createResp, err := client.Do(createHTTP)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
|
||||
// Update filter
|
||||
@@ -138,12 +150,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
updateResp, err := client.Do(updateHTTP)
|
||||
require.NoError(t, err)
|
||||
defer updateResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(updateResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||
|
||||
var updatedFilter map[string]interface{}
|
||||
json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
|
||||
err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Name", updatedFilter["name"])
|
||||
})
|
||||
|
||||
@@ -162,10 +177,13 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
createResp, err := client.Do(createHTTP)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
|
||||
// Delete filter
|
||||
@@ -174,7 +192,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
deleteResp, err := client.Do(deleteHTTP)
|
||||
require.NoError(t, err)
|
||||
deleteResp.Body.Close()
|
||||
_ = deleteResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNoContent, deleteResp.StatusCode)
|
||||
})
|
||||
@@ -198,10 +216,13 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
createResp, err := client.Do(createHTTP)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
|
||||
// Admin user tries to delete regular user's filter
|
||||
@@ -210,7 +231,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
deleteResp, err := client.Do(deleteHTTP)
|
||||
require.NoError(t, err)
|
||||
deleteResp.Body.Close()
|
||||
_ = deleteResp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, deleteResp.StatusCode)
|
||||
})
|
||||
@@ -230,12 +251,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
|
||||
// Now retrieve the filter by ID
|
||||
@@ -244,12 +268,15 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
getResp, err := client.Do(getReq)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(getResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
|
||||
var retrievedFilter map[string]interface{}
|
||||
json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
|
||||
err = json.NewDecoder(getResp.Body).Decode(&retrievedFilter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Test Filter", retrievedFilter["name"])
|
||||
assert.Equal(t, "media-items", retrievedFilter["resource_type"])
|
||||
assert.Equal(t, filterID, retrievedFilter["id"])
|
||||
@@ -260,7 +287,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil)
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -271,7 +300,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -283,7 +314,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
})
|
||||
@@ -301,16 +334,21 @@ func TestSavedFilters(t *testing.T) {
|
||||
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
var createdFilter map[string]interface{}
|
||||
json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
// Try to access with regular user (setup.RegularToken)
|
||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
getResp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(getResp.Body)
|
||||
// Should return 404 (not 403 - hide existence)
|
||||
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -31,10 +32,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
libResp, err := client.Do(libReq)
|
||||
require.NoError(t, err)
|
||||
defer libResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(libResp.Body)
|
||||
require.Equal(t, http.StatusCreated, libResp.StatusCode)
|
||||
var libResult map[string]interface{}
|
||||
json.NewDecoder(libResp.Body).Decode(&libResult)
|
||||
err = json.NewDecoder(libResp.Body).Decode(&libResult)
|
||||
require.NoError(t, err)
|
||||
libraryID := libResult["id"].(string)
|
||||
// Add folder to library
|
||||
folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID)
|
||||
@@ -47,7 +51,9 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
folderHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
||||
folderResp, err := client.Do(folderHTTPReq)
|
||||
require.NoError(t, err)
|
||||
defer folderResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(folderResp.Body)
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder should be added to library")
|
||||
// Create 20 test files simultaneously
|
||||
for i := 0; i < 20; i++ {
|
||||
@@ -61,10 +67,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
scanHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
||||
scanResp, err := client.Do(scanHTTPReq)
|
||||
require.NoError(t, err)
|
||||
defer scanResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(scanResp.Body)
|
||||
require.Equal(t, http.StatusAccepted, scanResp.StatusCode, "Scan should be accepted")
|
||||
var scanResponse map[string]interface{}
|
||||
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
require.NoError(t, err)
|
||||
jobID, ok := scanResponse["job_id"].(string)
|
||||
require.True(t, ok, "job_id should be string")
|
||||
require.NotEmpty(t, jobID, "job_id should not be empty")
|
||||
@@ -81,16 +90,17 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
if statusResp.StatusCode == http.StatusNotFound {
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
break // Job completed
|
||||
}
|
||||
|
||||
var status map[string]interface{}
|
||||
json.NewDecoder(statusResp.Body).Decode(&status)
|
||||
statusResp.Body.Close()
|
||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||
require.NoError(t, err)
|
||||
_ = statusResp.Body.Close()
|
||||
|
||||
if status["status"] == "completed" || status["status"] == "failed" {
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -99,9 +109,12 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
itemsResp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer itemsResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(itemsResp.Body)
|
||||
var itemsResult map[string]interface{}
|
||||
json.NewDecoder(itemsResp.Body).Decode(&itemsResult)
|
||||
err = json.NewDecoder(itemsResp.Body).Decode(&itemsResult)
|
||||
require.NoError(t, err)
|
||||
items, ok := itemsResult["data"].([]interface{})
|
||||
if !ok || items == nil {
|
||||
items = []interface{}{} // Handle nil or wrong type
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -33,7 +34,9 @@ func TestJobsHandler_CreateJob(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
|
||||
@@ -65,7 +68,9 @@ func TestJobsHandler_CreateJob_InvalidType(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
@@ -88,7 +93,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
var createResponse map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
||||
@@ -103,7 +110,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
|
||||
|
||||
getResp, err := client.Do(getReq)
|
||||
require.NoError(t, err)
|
||||
defer getResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(getResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
|
||||
@@ -128,7 +137,9 @@ func TestJobsHandler_GetJobStatus_NotFound(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||
}
|
||||
@@ -151,7 +162,9 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
createResp, err := client.Do(createReq)
|
||||
require.NoError(t, err)
|
||||
defer createResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(createResp.Body)
|
||||
|
||||
var createResponse map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
||||
@@ -172,7 +185,7 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
|
||||
|
||||
var statusResponse map[string]interface{}
|
||||
err = json.NewDecoder(getResp.Body).Decode(&statusResponse)
|
||||
getResp.Body.Close()
|
||||
_ = getResp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
if statusResponse["status"] != nil {
|
||||
@@ -202,7 +215,9 @@ func TestJobsHandler_CreateJob_Unauthorized(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
@@ -218,7 +233,9 @@ func TestJobsHandler_GetJobStatus_Unauthorized(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -37,7 +38,9 @@ func TestKoboInitialization(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
@@ -65,7 +68,9 @@ func TestKoboLibrarySync(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
@@ -129,11 +134,14 @@ func TestKoboMarkupSync(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "Status")
|
||||
})
|
||||
}
|
||||
@@ -182,11 +190,14 @@ func TestKoboBookmarkSync(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "Status")
|
||||
})
|
||||
}
|
||||
@@ -224,11 +235,14 @@ func TestKoboAnalyticsGettests(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, result, "Status")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -182,13 +182,13 @@ func TestLibraryTypesResponse(t *testing.T) {
|
||||
"id": "test-id-2",
|
||||
"name": "comics",
|
||||
"description": "Comic book archives and image formats",
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".pdf"},
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||
},
|
||||
{
|
||||
"id": "test-id-3",
|
||||
"name": "manga",
|
||||
"description": "Manga files including archives and image folders",
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"},
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -556,7 +556,7 @@ func TestLibraryTypes(t *testing.T) {
|
||||
"id": uuid.New().String(),
|
||||
"name": "comics",
|
||||
"description": "Comic book archives and image formats",
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".pdf"},
|
||||
"allowed_extensions": []string{".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||
},
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -31,7 +32,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
|
||||
@@ -58,7 +61,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -76,12 +81,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -106,12 +114,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, 3.0, result["total"])
|
||||
@@ -130,7 +141,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -154,7 +167,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
@@ -179,7 +194,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -204,12 +221,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -246,12 +266,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Equal(t, 2.0, result["total"])
|
||||
@@ -304,12 +327,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
assert.Contains(t, result, "total")
|
||||
@@ -352,12 +378,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, result, "results")
|
||||
})
|
||||
@@ -371,7 +400,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -31,12 +32,15 @@ func createTestLibrary(t *testing.T, ts *httptest.Server, token, name string) st
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
return result["id"].(string)
|
||||
}
|
||||
@@ -164,7 +168,9 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Check if this is an invalid ISBN case that should return 422
|
||||
if tc.expected == "" && (tc.input == "---" || tc.input == " ") {
|
||||
@@ -174,7 +180,8 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// For valid ISBN responses, verify normalization worked correctly
|
||||
if resp.StatusCode == http.StatusCreated {
|
||||
@@ -209,7 +216,9 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
})
|
||||
@@ -232,10 +241,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(t, "9780306406157", response["isbn"])
|
||||
@@ -259,10 +271,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
assert.Equal(t, "9780596009652", response["isbn"])
|
||||
@@ -296,7 +311,7 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// Small delay to allow database to commit before pagination queries
|
||||
@@ -309,12 +324,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
data := response["data"].([]interface{})
|
||||
|
||||
// Should get 2 items
|
||||
@@ -328,12 +346,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
data := response["data"].([]interface{})
|
||||
|
||||
// Should get 2 items starting from offset 2
|
||||
@@ -347,7 +368,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 400 Bad Request or handle it gracefully
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -360,7 +383,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 400 Bad Request or handle it gracefully
|
||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -373,7 +398,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should be capped at maximum or return error
|
||||
// The application uses maxPaginationLimit = 1000
|
||||
@@ -404,7 +431,9 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should fail - library_id is required
|
||||
assert.NotEqual(t, http.StatusCreated, resp.StatusCode)
|
||||
@@ -431,12 +460,15 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
|
||||
// VerifyISBN was normalized
|
||||
assert.Equal(t, "9780306406157", response["isbn"])
|
||||
@@ -472,8 +504,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
var createResponse map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&createResponse)
|
||||
resp.Body.Close()
|
||||
err = json.NewDecoder(resp.Body).Decode(&createResponse)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
mediaItemID := createResponse["id"].(string)
|
||||
|
||||
@@ -491,7 +524,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
|
||||
})
|
||||
|
||||
@@ -26,7 +26,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// OPDS endpoints require device authentication via devices.auth_token
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
@@ -37,7 +39,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 400 for invalid UUID
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
@@ -53,7 +57,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 200 with catalog (even if empty)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -69,7 +75,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 200 with catalog (even if empty)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -80,7 +88,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -95,7 +105,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return 200 (even if empty results)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -106,7 +118,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -120,7 +134,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return navigation or 404
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
||||
@@ -131,7 +147,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -142,7 +160,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -156,7 +176,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// May return 404 if device/book not linked, or 500 for file not found
|
||||
// Should not return 400 (invalid IDs)
|
||||
@@ -168,7 +190,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -178,7 +202,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -192,7 +218,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// May return 404 if no cover, but not 400
|
||||
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
||||
@@ -204,7 +232,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -218,7 +248,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return formats list or 404
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
||||
@@ -245,7 +277,9 @@ func TestOPDSConversion(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should attempt conversion (may fail if file doesn't exist)
|
||||
// Important: Should not return 400 for invalid IDs
|
||||
@@ -262,7 +296,9 @@ func TestOPDSConversion(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should attempt to download original format
|
||||
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
||||
@@ -278,7 +314,9 @@ func TestOPDSConversion(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle gracefully (either 400 for unsupported format or 404/500)
|
||||
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600)
|
||||
@@ -300,7 +338,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should return empty catalog, not error
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -316,7 +356,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle special characters
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -331,7 +373,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should handle empty query
|
||||
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500)
|
||||
@@ -371,7 +415,9 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
t.Logf("OPDS Search Status: %d", resp.StatusCode)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -108,8 +109,8 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
|
||||
{
|
||||
name: "Empty UUID",
|
||||
libraryID: "",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
description: "Should return 404 for empty ID",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Should return 400 for empty ID",
|
||||
},
|
||||
{
|
||||
name: "UUID with extra path traversal",
|
||||
@@ -139,7 +140,7 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/list", nil)
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/list", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -284,8 +285,8 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
|
||||
{
|
||||
name: "Empty UUID",
|
||||
libraryID: "",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
description: "Should return 404 for empty ID",
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
description: "Should return 400 for empty ID",
|
||||
},
|
||||
{
|
||||
name: "UUID with extra path traversal",
|
||||
@@ -315,7 +316,7 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+tc.libraryID+"/issues/stats", nil)
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+url.PathEscape(tc.libraryID)+"/issues/stats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -388,7 +389,8 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
||||
setup.Server.Config.Handler.ServeHTTP(rec1, req1)
|
||||
|
||||
var stats1 map[string]interface{}
|
||||
json.NewDecoder(rec1.Body).Decode(&stats1)
|
||||
err := json.NewDecoder(rec1.Body).Decode(&stats1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get stats for library 2
|
||||
req2 := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/stats", nil)
|
||||
@@ -398,7 +400,8 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
||||
setup.Server.Config.Handler.ServeHTTP(rec2, req2)
|
||||
|
||||
var stats2 map[string]interface{}
|
||||
json.NewDecoder(rec2.Body).Decode(&stats2)
|
||||
err = json.NewDecoder(rec2.Body).Decode(&stats2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Both should have zero counts
|
||||
assert.Equal(t, float64(0), stats1["error_count"])
|
||||
@@ -424,7 +427,6 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
|
||||
{"Ebooks library", "ebooks"},
|
||||
{"Comics library", "comics"},
|
||||
{"Manga library", "manga"},
|
||||
{"Audiobooks library", "audiobooks"},
|
||||
}
|
||||
|
||||
for _, lt := range libraryTypes {
|
||||
|
||||
@@ -0,0 +1,658 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func pFloat64(v float64) *float64 { return &v }
|
||||
func pStr(v string) *string { return &v }
|
||||
func pInt(v int) *int { return &v }
|
||||
func pInt64(v int64) *int64 { return &v }
|
||||
|
||||
func doReq(t *testing.T, method, url string, body interface{}, token string) *http.Response {
|
||||
t.Helper()
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
bodyReader = bytes.NewBuffer(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, url, bodyReader)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
func decodeJSON(t *testing.T, resp *http.Response) map[string]interface{} {
|
||||
t.Helper()
|
||||
var result map[string]interface{}
|
||||
err := json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
func progressURL(serverURL, mediaItemID string) string {
|
||||
return serverURL + "/api/media-items/" + mediaItemID + "/progress"
|
||||
}
|
||||
|
||||
func getFloatField(t *testing.T, data map[string]interface{}, field string) float64 {
|
||||
t.Helper()
|
||||
val, ok := data[field]
|
||||
require.True(t, ok, "%s should be present in response", field)
|
||||
require.NotNil(t, val, "%s should not be null", field)
|
||||
f, ok := val.(float64)
|
||||
require.True(t, ok, "%s should be a number, got %T: %v", field, val, val)
|
||||
return f
|
||||
}
|
||||
|
||||
func getStringField(t *testing.T, data map[string]interface{}, field string) string {
|
||||
t.Helper()
|
||||
val, ok := data[field]
|
||||
require.True(t, ok, "%s should be present in response", field)
|
||||
require.NotNil(t, val, "%s should not be null", field)
|
||||
s, ok := val.(string)
|
||||
require.True(t, ok, "%s should be a string, got %T: %v", field, val, val)
|
||||
return s
|
||||
}
|
||||
|
||||
func TestProgressWeb_AuthContexts(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
|
||||
t.Run("unauthenticated PUT returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
}, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("unauthenticated GET returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "GET", url, nil, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("regular user PUT succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.3,
|
||||
}, setup.RegularToken)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("regular user GET succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "GET", url, nil, setup.RegularToken)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("admin PUT succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.7,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("admin GET succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("invalid media item ID returns 400", func(t *testing.T) {
|
||||
badURL := setup.Server.URL + "/api/media-items/not-a-uuid/progress"
|
||||
resp := doReq(t, "GET", badURL, nil, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("nonexistent media item GET returns 200 with empty", func(t *testing.T) {
|
||||
fakeID := uuid.New().String()
|
||||
fakeURL := progressURL(setup.Server.URL, fakeID)
|
||||
resp := doReq(t, "GET", fakeURL, nil, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_MergePreservesFields(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
|
||||
t.Run("second PUT with only percentage preserves epubcfi and chapter from first", func(t *testing.T) {
|
||||
resp1 := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.3,
|
||||
"epubcfi": "epubcfi(/6/4/2:first)",
|
||||
"chapter": 2,
|
||||
}, setup.Token)
|
||||
defer resp1.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp1.StatusCode)
|
||||
|
||||
resp2 := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
}, setup.Token)
|
||||
defer resp2.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
|
||||
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer getResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
|
||||
result := decodeJSON(t, getResp)
|
||||
pct := getFloatField(t, result, "percentage")
|
||||
assert.InDelta(t, 0.5, pct, 0.01)
|
||||
|
||||
epubcfi := getStringField(t, result, "epubcfi")
|
||||
assert.Equal(t, "epubcfi(/6/4/2:first)", epubcfi, "epubcfi should be preserved from first save")
|
||||
|
||||
chapter := getFloatField(t, result, "chapter")
|
||||
assert.Equal(t, float64(2), chapter, "chapter should be preserved from first save")
|
||||
})
|
||||
|
||||
t.Run("web save preserves koreader character_offset", func(t *testing.T) {
|
||||
mediaItemID2 := createTestMediaItemID(t, setup)
|
||||
url2 := progressURL(setup.Server.URL, mediaItemID2)
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID2)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
charOffset := int64(15000)
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "koreader",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: pFloat64(0.45),
|
||||
CharacterOffset: &charOffset,
|
||||
Chapter: pInt(5),
|
||||
DeviceType: "koreader",
|
||||
DeviceName: "KOReader Test",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := doReq(t, "PUT", url2, map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
"epubcfi": "epubcfi(/6/4/2:10)",
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be preserved")
|
||||
assert.Equal(t, int64(15000), progress.CharacterOffset.Int64)
|
||||
assert.True(t, progress.Chapter.Valid, "chapter should be preserved")
|
||||
assert.Equal(t, int32(5), progress.Chapter.Int32)
|
||||
assert.InDelta(t, 0.5, progress.Percentage.Float64, 0.001)
|
||||
assert.Equal(t, "web", progress.LastSyncSource.String)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_EnrichmentComputesFields(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("character_offset computed from percentage when total_characters set", func(t *testing.T) {
|
||||
_, err := setup.DBPool.Exec(ctx, "UPDATE media_items SET total_characters = $1 WHERE id = $2", int64(200000), mediaUUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.CharacterOffset.Valid, "character_offset should be computed from percentage")
|
||||
assert.Equal(t, int64(100000), progress.CharacterOffset.Int64)
|
||||
})
|
||||
|
||||
t.Run("GET returns enriched format_group and total_characters", func(t *testing.T) {
|
||||
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer getResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
|
||||
result := decodeJSON(t, getResp)
|
||||
_, hasFormatGroup := result["format_group"]
|
||||
assert.True(t, hasFormatGroup, "format_group should be present in GET response")
|
||||
_, hasTotalChars := result["total_characters"]
|
||||
assert.True(t, hasTotalChars, "total_characters should be present in GET response")
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_ConflictDetection(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("different sources with >1% diff within 5min creates conflict record", func(t *testing.T) {
|
||||
pct1 := 0.3
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "koreader",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct1,
|
||||
DeviceType: "koreader",
|
||||
DeviceName: "KOReader",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pct2 := 0.6
|
||||
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct2,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: "Kobo",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001)
|
||||
assert.Equal(t, "kobo", progress.LastSyncSource.String)
|
||||
|
||||
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, conflicts, "conflict should be recorded in sync_conflicts table")
|
||||
assert.Equal(t, "progress", conflicts[0].ConflictType)
|
||||
assert.True(t, conflicts[0].ResolutionStatus.Valid)
|
||||
assert.Equal(t, "unresolved", conflicts[0].ResolutionStatus.String)
|
||||
})
|
||||
|
||||
mediaItemID2 := createTestMediaItemID(t, setup)
|
||||
mediaUUID2, _ := uuid.Parse(mediaItemID2)
|
||||
|
||||
t.Run("same source rapid saves create no conflict", func(t *testing.T) {
|
||||
for _, pct := range []float64{0.1, 0.3, 0.5, 0.7} {
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "web",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct,
|
||||
DeviceType: "web",
|
||||
DeviceName: "Web",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, conflicts, "same-source saves should not create conflicts")
|
||||
})
|
||||
|
||||
mediaItemID3 := createTestMediaItemID(t, setup)
|
||||
mediaUUID3, _ := uuid.Parse(mediaItemID3)
|
||||
|
||||
t.Run("different sources with <1% diff creates no conflict", func(t *testing.T) {
|
||||
pct1 := 0.5
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "koreader",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct1,
|
||||
DeviceType: "koreader",
|
||||
DeviceName: "KOReader",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
pct2 := 0.505
|
||||
_, err = setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Percentage: &pct2,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: "Kobo",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
conflicts, err := setup.DB.ListSyncConflictsByMediaItem(ctx, database.ListSyncConflictsByMediaItemParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID3, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, conflicts, "small percentage diff should not create conflict")
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_KoboIntegration(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
ctx := context.Background()
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
|
||||
koboDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
device, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: "Test Kobo Progress",
|
||||
DeviceType: "kobo",
|
||||
DeviceIdentifier: "kobo-progress-test",
|
||||
AuthToken: koboDeviceToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err, "Should create kobo device")
|
||||
_ = device
|
||||
|
||||
t.Run("unauthenticated Kobo markup returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/kobo/invalid-token/markup", map[string]interface{}{
|
||||
"ReadingSync": []map[string]interface{}{
|
||||
{"ContentId": uuid.New().String(), "PercentRead": 50.0},
|
||||
},
|
||||
}, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
|
||||
t.Run("ReadingSync then last-read-place preserves percentage", func(t *testing.T) {
|
||||
readingSyncBody := map[string]interface{}{
|
||||
"ReadingSync": []map[string]interface{}{
|
||||
{
|
||||
"ContentId": mediaItemID,
|
||||
"PercentRead": 55.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), readingSyncBody, "")
|
||||
defer resp.Body.Close()
|
||||
io.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
bookmarkBody := map[string]interface{}{
|
||||
"BookmarkSync": []map[string]interface{}{
|
||||
{
|
||||
"ContentId": mediaItemID,
|
||||
"BookmarkId": "epubcfi(/6/4!/4/2/1:0)",
|
||||
"BookmarkType": "last-read-place",
|
||||
"Chapter": 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp2 := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
|
||||
defer resp2.Body.Close()
|
||||
io.ReadAll(resp2.Body)
|
||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.Percentage.Valid)
|
||||
assert.InDelta(t, 0.55, progress.Percentage.Float64, 0.01, "percentage should still be 55% from ReadingSync")
|
||||
})
|
||||
|
||||
t.Run("Kobo last-read-place without prior ReadingSync sets epubcfi and chapter", func(t *testing.T) {
|
||||
newMediaID := createTestMediaItemID(t, setup)
|
||||
newMediaUUID, _ := uuid.Parse(newMediaID)
|
||||
|
||||
bookmarkBody := map[string]interface{}{
|
||||
"BookmarkSync": []map[string]interface{}{
|
||||
{
|
||||
"ContentId": newMediaID,
|
||||
"BookmarkId": "epubcfi(/6/14!/4/2/1:0)",
|
||||
"BookmarkType": "last-read-place",
|
||||
"Chapter": 3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp := doReq(t, "POST", fmt.Sprintf("%s/api/sync/kobo/%s/markup", setup.Server.URL, koboDeviceToken), bookmarkBody, "")
|
||||
defer resp.Body.Close()
|
||||
io.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: newMediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.Epubcfi.Valid, "epubcfi should be set from last-read-place")
|
||||
assert.True(t, progress.Chapter.Valid, "chapter should be set from last-read-place")
|
||||
assert.Equal(t, int32(3), progress.Chapter.Int32)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_KOReaderIntegration(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
koreaderDeviceToken := fmt.Sprintf("dev_%s", uuid.New().String())
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
_, err := setup.DB.CreateDevice(ctx, database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: "Test KOReader Progress",
|
||||
DeviceType: "koreader",
|
||||
DeviceIdentifier: "koreader-progress-test",
|
||||
AuthToken: koreaderDeviceToken,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
require.NoError(t, err, "Should create koreader device")
|
||||
|
||||
t.Run("KOReader progress sync via HTTP", func(t *testing.T) {
|
||||
progressBody := map[string]interface{}{
|
||||
"books": []map[string]interface{}{
|
||||
{
|
||||
"file_path": "/tmp/test.epub",
|
||||
"percentage": 0.42,
|
||||
"chapter": 3,
|
||||
"device_info": map[string]interface{}{
|
||||
"device_model": "Test Device",
|
||||
"koreader_version": "1.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(progressBody)
|
||||
req, err := http.NewRequest("POST", setup.Server.URL+"/api/sync/koreader/progress", bytes.NewBuffer(bodyBytes))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+koreaderDeviceToken)
|
||||
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
io.ReadAll(resp.Body)
|
||||
assert.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
if err == nil {
|
||||
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unauthenticated KOReader sync returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "POST", setup.Server.URL+"/api/sync/koreader/progress", map[string]interface{}{
|
||||
"books": []map[string]interface{}{
|
||||
{"file_path": "/tmp/test.epub", "percentage": 0.5},
|
||||
},
|
||||
}, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_DeleteProgress(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
|
||||
t.Run("DELETE removes progress", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.75,
|
||||
"epubcfi": "epubcfi(/6/4/2:20)",
|
||||
"chapter": 7,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
delResp := doReq(t, "DELETE", url, nil, setup.Token)
|
||||
defer delResp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, delResp.StatusCode)
|
||||
|
||||
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer getResp.Body.Close()
|
||||
result := decodeJSON(t, getResp)
|
||||
assert.Equal(t, float64(0), result["current_page"], "progress should be cleared after delete")
|
||||
})
|
||||
|
||||
t.Run("DELETE without auth returns 401", func(t *testing.T) {
|
||||
resp := doReq(t, "DELETE", url, nil, "")
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
})
|
||||
}
|
||||
|
||||
func TestProgressWeb_EdgeCases(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
url := progressURL(setup.Server.URL, mediaItemID)
|
||||
|
||||
t.Run("PUT with empty body still succeeds", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("PUT percentage 0.0", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 0.0,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
getResp := doReq(t, "GET", url, nil, setup.Token)
|
||||
defer getResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("PUT percentage 1.0", func(t *testing.T) {
|
||||
resp := doReq(t, "PUT", url, map[string]interface{}{
|
||||
"percentage": 1.0,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("PUT with all fields then GET verifies each", func(t *testing.T) {
|
||||
mediaItemID2 := createTestMediaItemID(t, setup)
|
||||
url2 := progressURL(setup.Server.URL, mediaItemID2)
|
||||
mediaUUID2, _ := uuid.Parse(mediaItemID2)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
resp := doReq(t, "PUT", url2, map[string]interface{}{
|
||||
"percentage": 0.42,
|
||||
"current_page": 84,
|
||||
"total_pages": 200,
|
||||
"epubcfi": "epubcfi(/6/4!/4/2/1:0)",
|
||||
"chapter": 3,
|
||||
"chapter_progress": 0.5,
|
||||
"character_offset": 15000,
|
||||
"reading_mode": "page",
|
||||
"zoom_level": 1.5,
|
||||
"scroll_position_x": 0.0,
|
||||
"scroll_position_y": 100.0,
|
||||
}, setup.Token)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID2, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.Percentage.Valid)
|
||||
assert.InDelta(t, 0.42, progress.Percentage.Float64, 0.01)
|
||||
assert.True(t, progress.Chapter.Valid)
|
||||
assert.Equal(t, int32(3), progress.Chapter.Int32)
|
||||
assert.True(t, progress.Epubcfi.Valid)
|
||||
assert.Equal(t, "epubcfi(/6/4!/4/2/1:0)", progress.Epubcfi.String)
|
||||
assert.True(t, progress.CurrentPage.Valid)
|
||||
assert.Equal(t, int32(84), progress.CurrentPage.Int32)
|
||||
assert.True(t, progress.TotalPages.Valid)
|
||||
assert.Equal(t, int32(200), progress.TotalPages.Int32)
|
||||
assert.True(t, progress.ReadingMode.Valid)
|
||||
assert.Equal(t, "page", progress.ReadingMode.String)
|
||||
assert.True(t, progress.ZoomLevel.Valid)
|
||||
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
|
||||
})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -24,7 +25,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -40,7 +43,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -58,12 +63,15 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
loginResp, err := client.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken, ok := loginResult["refresh_token"].(string)
|
||||
require.True(t, ok, "Should have refresh_token")
|
||||
@@ -79,12 +87,15 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
refreshResp, err := client.Do(refreshHTTP)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||
|
||||
var refreshResult map[string]interface{}
|
||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, refreshResult, "access_token")
|
||||
assert.NotEmpty(t, refreshResult["access_token"], "New access token should not be empty")
|
||||
@@ -102,7 +113,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -118,7 +131,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Should still work or return appropriate error
|
||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnsupportedMediaType)
|
||||
@@ -143,12 +158,15 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
||||
|
||||
loginResp, err := client.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken := loginResult["refresh_token"].(string)
|
||||
|
||||
@@ -163,7 +181,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
||||
|
||||
refreshResp1, err := client.Do(refreshHTTP1)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp1.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp1.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, refreshResp1.StatusCode)
|
||||
|
||||
@@ -173,7 +193,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
||||
|
||||
refreshResp2, err := client.Do(refreshHTTP2)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp2.Body)
|
||||
|
||||
// May return 401 if token reuse is detected, or 200 if not implemented
|
||||
// Either is acceptable depending on security requirements
|
||||
@@ -197,7 +219,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -213,7 +237,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
@@ -231,12 +257,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
loginResp, err := client.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken := loginResult["refresh_token"].(string)
|
||||
|
||||
@@ -251,12 +280,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
refreshResp, err := client.Do(refreshHTTP)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||
|
||||
var refreshResult map[string]interface{}
|
||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify response structure
|
||||
assert.Contains(t, refreshResult, "access_token")
|
||||
@@ -279,12 +311,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
loginResp, err := client.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken := loginResult["refresh_token"].(string)
|
||||
|
||||
@@ -299,12 +334,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
||||
|
||||
refreshResp, err := client.Do(refreshHTTP)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||
|
||||
var refreshResult map[string]interface{}
|
||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify access token is a string
|
||||
accessToken, ok := refreshResult["access_token"].(string)
|
||||
|
||||
@@ -25,8 +25,9 @@ func TestScanSettings_GetSettings(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
resp.Body.Close()
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Contains(t, response, "scan_poll_interval_seconds")
|
||||
assert.Contains(t, response, "auto_scan_enabled")
|
||||
@@ -63,8 +64,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
resp.Body.Close()
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Equal(t, float64(45), response["scan_poll_interval_seconds"])
|
||||
assert.Equal(t, true, response["auto_scan_enabled"])
|
||||
@@ -74,8 +76,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
||||
getResp, _ := client.Do(getReq)
|
||||
|
||||
var getResponse map[string]interface{}
|
||||
json.NewDecoder(getResp.Body).Decode(&getResponse)
|
||||
getResp.Body.Close()
|
||||
err = json.NewDecoder(getResp.Body).Decode(&getResponse)
|
||||
require.NoError(t, err)
|
||||
_ = getResp.Body.Close()
|
||||
|
||||
assert.Equal(t, float64(45), getResponse["scan_poll_interval_seconds"])
|
||||
})
|
||||
@@ -99,8 +102,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&response)
|
||||
resp.Body.Close()
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
assert.Equal(t, false, response["auto_scan_enabled"])
|
||||
})
|
||||
|
||||
@@ -46,8 +46,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
require.Equal(s.T(), http.StatusCreated, createLibResp.StatusCode)
|
||||
|
||||
var createLibResponse map[string]interface{}
|
||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
createLibResp.Body.Close()
|
||||
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
require.NoError(s.T(), err)
|
||||
_ = createLibResp.Body.Close()
|
||||
|
||||
libraryID, ok := createLibResponse["id"].(string)
|
||||
require.True(s.T(), ok, "library_id should be string")
|
||||
@@ -64,7 +65,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Folder creation should succeed")
|
||||
|
||||
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
||||
@@ -78,7 +79,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
var scanResponse map[string]interface{}
|
||||
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
require.NoError(s.T(), err)
|
||||
scanResp.Body.Close()
|
||||
_ = scanResp.Body.Close()
|
||||
|
||||
jobID, ok := scanResponse["job_id"].(string)
|
||||
require.True(s.T(), ok, "job_id should be string")
|
||||
@@ -104,7 +105,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
if statusResp.StatusCode == http.StatusNotFound {
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
if gotProgressUpdate {
|
||||
break
|
||||
}
|
||||
@@ -113,7 +114,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
||||
|
||||
var status map[string]interface{}
|
||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
if _, hasError := status["error"]; hasError {
|
||||
@@ -180,8 +181,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
var createLibResponse map[string]interface{}
|
||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
createLibResp.Body.Close()
|
||||
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
require.NoError(s.T(), err)
|
||||
_ = createLibResp.Body.Close()
|
||||
|
||||
libraryID, ok := createLibResponse["id"].(string)
|
||||
require.True(s.T(), ok, "library_id should be string")
|
||||
@@ -198,7 +200,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
|
||||
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
||||
scanReq, _ := http.NewRequest("POST", scanURL, nil)
|
||||
@@ -208,8 +210,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
var scanResponse map[string]interface{}
|
||||
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
scanResp.Body.Close()
|
||||
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||
require.NoError(s.T(), err)
|
||||
_ = scanResp.Body.Close()
|
||||
|
||||
jobID, ok := scanResponse["job_id"].(string)
|
||||
require.True(s.T(), ok, "job_id should be string")
|
||||
@@ -231,14 +234,14 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
if statusResp.StatusCode == http.StatusNotFound {
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
break
|
||||
}
|
||||
|
||||
var status map[string]interface{}
|
||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||
require.NoError(s.T(), err)
|
||||
statusResp.Body.Close()
|
||||
_ = statusResp.Body.Close()
|
||||
|
||||
if _, hasError := status["error"]; hasError {
|
||||
continue
|
||||
|
||||
@@ -388,6 +388,7 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
||||
query string
|
||||
libraryID string
|
||||
expectedCount int
|
||||
expectedStatus int
|
||||
shouldContain string // Comma-separated list of book IDs to check
|
||||
}{
|
||||
{
|
||||
@@ -415,7 +416,7 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
||||
name: "invalid library_id",
|
||||
query: "Harry",
|
||||
libraryID: "00000000-0000-0000-0000-000000000000",
|
||||
expectedCount: 0,
|
||||
expectedStatus: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -431,16 +432,18 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Log response for debugging
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("ERROR %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
if tt.expectedStatus != 0 {
|
||||
require.Equal(t, tt.expectedStatus, resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.expectedCount > 0 {
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
@@ -483,11 +486,14 @@ func createLibrary(t *testing.T, client *http.Client, setup *TestServerSetup, na
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -509,10 +515,13 @@ func createTestMediaItemIDInLibrary(t *testing.T, client *http.Client, setup *Te
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
return result["id"].(string)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -30,7 +31,7 @@ func TestUnifiedSearch(t *testing.T) {
|
||||
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
folderResp, err := client.Do(folderHTTP)
|
||||
require.NoError(t, err)
|
||||
folderResp.Body.Close()
|
||||
_ = folderResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
|
||||
|
||||
// Helper to create book with fields
|
||||
@@ -54,7 +55,9 @@ func TestUnifiedSearch(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -90,14 +93,14 @@ func TestUnifiedSearch(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code, "Should filter by has_cover")
|
||||
})
|
||||
|
||||
t.Run("Missing library_id", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil)
|
||||
t.Run("Missing library_id searches all libraries", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/media-items/search?q=zzzznonexistent", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
|
||||
// library_id is now optional - searches all libraries when omitted
|
||||
// Returns 404 when no results match the search query
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "Should return 404 when no results found")
|
||||
// library_id is optional - searches all libraries when omitted
|
||||
// Returns 404 when no results match
|
||||
assert.Equal(t, http.StatusNotFound, rec.Code, "Should return 404 when no results match")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type SeriesIntegrationTestSuite struct {
|
||||
suite.Suite
|
||||
setup *TestServerSetup
|
||||
token string
|
||||
libraryID string
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) SetupSuite() {
|
||||
s.setup = setupTestServer(s.T())
|
||||
s.token = s.setup.Token
|
||||
s.libraryID = createTestLibraryWithFolder(s.T(), s.setup.Server, s.token, "Test Series Library", false)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TearDownSuite() {
|
||||
s.setup.Close()
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "library_id required", body["error"])
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id=not-a-uuid", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "invalid library_id", body["error"])
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_EmptyLibrary() {
|
||||
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
series, ok := body["series"].([]interface{})
|
||||
require.True(s.T(), ok, "series should be an array")
|
||||
assert.Empty(s.T(), series, "empty library should have no series")
|
||||
|
||||
total, ok := body["total"].(float64)
|
||||
require.True(s.T(), ok, "total should be a number")
|
||||
assert.Equal(s.T(), float64(0), total)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_Unauthorized() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series?library_id="+s.libraryID, nil)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_PaginationParams() {
|
||||
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=5&offset=0", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
limit, ok := body["limit"].(float64)
|
||||
require.True(s.T(), ok)
|
||||
assert.Equal(s.T(), float64(5), limit)
|
||||
|
||||
offset, ok := body["offset"].(float64)
|
||||
require.True(s.T(), ok)
|
||||
assert.Equal(s.T(), float64(0), offset)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
|
||||
url := fmt.Sprintf("%s/api/series?library_id=%s&limit=999", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
limit, ok := body["limit"].(float64)
|
||||
require.True(s.T(), ok)
|
||||
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresLibraryID() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "library_id required", body["error"])
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
|
||||
url := fmt.Sprintf("%s/api/series/books?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "name required", body["error"])
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NonexistentSeries() {
|
||||
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=Nonexistent+Series", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
books, ok := body["books"].([]interface{})
|
||||
require.True(s.T(), ok, "books should be an array")
|
||||
assert.Empty(s.T(), books, "nonexistent series should return empty books array")
|
||||
|
||||
assert.Equal(s.T(), "Nonexistent Series", body["name"])
|
||||
total, ok := body["total"].(float64)
|
||||
require.True(s.T(), ok)
|
||||
assert.Equal(s.T(), float64(0), total)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_Unauthorized() {
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id="+s.libraryID+"&name=Test", nil)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_SpecialCharactersInName() {
|
||||
seriesName := "Series: Book & Other (Vol. 1)"
|
||||
url := fmt.Sprintf("%s/api/series/books?library_id=%s&name=%s", s.setup.Server.URL, s.libraryID, url.QueryEscape(seriesName))
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_ResponseStructure() {
|
||||
url := fmt.Sprintf("%s/api/series?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
assert.Contains(s.T(), body, "series", "response should contain 'series' key")
|
||||
assert.Contains(s.T(), body, "total", "response should contain 'total' key")
|
||||
assert.Contains(s.T(), body, "limit", "response should contain 'limit' key")
|
||||
assert.Contains(s.T(), body, "offset", "response should contain 'offset' key")
|
||||
|
||||
_, ok := body["series"].([]interface{})
|
||||
assert.True(s.T(), ok, "'series' should be an array")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestRestoreSystemCollection_ContinueSeries() {
|
||||
reqBody := map[string]interface{}{
|
||||
"collection_name": "Continue Series",
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Contains(s.T(), response, "message")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() {
|
||||
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, s.libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(s.T(), err)
|
||||
|
||||
sections, ok := response["sections"].([]interface{})
|
||||
require.True(s.T(), ok, "sections should be an array")
|
||||
require.Len(s.T(), sections, 5, "Should have 5 system collections")
|
||||
|
||||
sectionIDs := make(map[string]bool)
|
||||
for _, sec := range sections {
|
||||
section := sec.(map[string]interface{})
|
||||
sectionIDs[section["id"].(string)] = true
|
||||
}
|
||||
|
||||
assert.Contains(s.T(), sectionIDs, "continue-series", "dashboard should include continue-series section")
|
||||
assert.Contains(s.T(), sectionIDs, "continue-reading")
|
||||
assert.Contains(s.T(), sectionIDs, "recently-added")
|
||||
assert.Contains(s.T(), sectionIDs, "recently-read")
|
||||
assert.Contains(s.T(), sectionIDs, "not-started")
|
||||
}
|
||||
|
||||
func TestSeriesIntegrationTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(SeriesIntegrationTestSuite))
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const baseTestURL = "http://localhost:8765/api"
|
||||
@@ -36,12 +37,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
t.Logf("Cleanup: No existing test user to delete (server not available)")
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// If login succeeds, try to delete the user
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
if token, ok := result["access_token"].(string); ok && token != "" {
|
||||
// Delete the user using the token
|
||||
@@ -52,7 +56,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
delResp, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer delResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(delResp.Body)
|
||||
if delResp.StatusCode == http.StatusNoContent {
|
||||
t.Logf("Cleanup: Deleted existing test user")
|
||||
} else {
|
||||
@@ -66,10 +72,13 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
|
||||
listResp, err := client.Do(req)
|
||||
if err == nil {
|
||||
defer listResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(listResp.Body)
|
||||
if listResp.StatusCode == http.StatusOK {
|
||||
var libsResult map[string]interface{}
|
||||
json.NewDecoder(listResp.Body).Decode(&libsResult)
|
||||
err = json.NewDecoder(listResp.Body).Decode(&libsResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
if data, ok := libsResult["data"].([]interface{}); ok {
|
||||
for _, lib := range data {
|
||||
@@ -80,7 +89,7 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
delLibResp, _ := client.Do(req)
|
||||
if delLibResp != nil {
|
||||
delLibResp.Body.Close()
|
||||
_ = delLibResp.Body.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,7 +117,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
body, _ := json.Marshal(userReq)
|
||||
resp, err := http.Post(baseTestURL+"/auth/register", "application/json", bytes.NewBuffer(body))
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Accept 201 (Created) or 409 (Conflict if already exists from previous incomplete test run)
|
||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
|
||||
@@ -116,7 +127,8 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// If we got 409, the user already exists, so we need to login to get the token
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
@@ -128,10 +140,13 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
body, _ := json.Marshal(loginReq)
|
||||
resp2, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||||
assert.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
json.NewDecoder(resp2.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp2.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
if result["user"] != nil {
|
||||
@@ -155,12 +170,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
body, _ := json.Marshal(loginReq)
|
||||
resp, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, ok := result["access_token"].(string)
|
||||
assert.True(t, ok, "Should have access_token")
|
||||
@@ -185,7 +203,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
@@ -225,12 +245,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, getUploadPath(), result["folder_path"])
|
||||
|
||||
@@ -251,13 +274,16 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
// Accept 200 or 202
|
||||
assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "success", result["status"])
|
||||
|
||||
@@ -276,12 +302,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
data, ok := result["data"].([]interface{})
|
||||
assert.True(t, ok, "Data field should exist")
|
||||
|
||||
@@ -36,7 +36,9 @@ func TestSevenDaySession(t *testing.T) {
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -81,12 +83,15 @@ func TestSevenDaySession(t *testing.T) {
|
||||
|
||||
loginResp, err := http.DefaultClient.Do(loginHTTP)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
var loginResult map[string]interface{}
|
||||
json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
err = json.NewDecoder(loginResp.Body).Decode(&loginResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
refreshToken, ok := loginResult["refresh_token"].(string)
|
||||
require.True(t, ok, "Should have refresh_token")
|
||||
@@ -103,12 +108,15 @@ func TestSevenDaySession(t *testing.T) {
|
||||
|
||||
refreshResp, err := http.DefaultClient.Do(refreshHTTP)
|
||||
require.NoError(t, err)
|
||||
defer refreshResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(refreshResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||
|
||||
var refreshResult map[string]interface{}
|
||||
json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
err = json.NewDecoder(refreshResp.Body).Decode(&refreshResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify ExpiresIn is 7 days
|
||||
expiresIn, ok := refreshResult["expires_in"].(float64)
|
||||
@@ -134,7 +142,9 @@ func TestSevenDaySession(t *testing.T) {
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
var authResponse struct {
|
||||
Token string `json:"access_token"`
|
||||
@@ -230,7 +240,7 @@ func TestNoClientSideCookies(t *testing.T) {
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
|
||||
// Accept both 201 (new user) and 409 (already exists from previous run)
|
||||
require.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict,
|
||||
@@ -253,7 +263,9 @@ func TestNoClientSideCookies(t *testing.T) {
|
||||
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
body, err := io.ReadAll(resp2.Body)
|
||||
require.NoError(t, err)
|
||||
@@ -277,7 +289,9 @@ func TestNoClientSideCookies(t *testing.T) {
|
||||
|
||||
loginResp, err := http.DefaultClient.Do(loginReq)
|
||||
require.NoError(t, err)
|
||||
defer loginResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(loginResp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ func setupSyncTestDB(t *testing.T) *database.Queries {
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM media_items WHERE title LIKE 'Test %'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM libraries WHERE name LIKE 'Test %'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM devices WHERE device_name LIKE 'Test %'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test%'")
|
||||
_, _ = dbPool.Exec(ctx, "DELETE FROM users WHERE email LIKE 'test-sync%'")
|
||||
dbPool.Close()
|
||||
})
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
@@ -30,7 +31,7 @@ func TestTagsFilter(t *testing.T) {
|
||||
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
folderResp, err := client.Do(folderHTTP)
|
||||
require.NoError(t, err)
|
||||
folderResp.Body.Close()
|
||||
_ = folderResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
|
||||
|
||||
// Helper to create book with tags
|
||||
@@ -50,7 +51,9 @@ func TestTagsFilter(t *testing.T) {
|
||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "Should create book")
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
@@ -98,11 +99,14 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(libHTTP)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "Library creation should succeed")
|
||||
|
||||
var libResponse map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&libResponse)
|
||||
err = json.NewDecoder(resp.Body).Decode(&libResponse)
|
||||
require.NoError(t, err)
|
||||
libraryID := libResponse["id"].(string)
|
||||
|
||||
if withFolder {
|
||||
@@ -117,7 +121,9 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
||||
|
||||
folderResp, err := client.Do(folderHTTP)
|
||||
require.NoError(t, err)
|
||||
defer folderResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(folderResp.Body)
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder creation should succeed")
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -82,6 +83,8 @@ type TestServerSetup struct {
|
||||
Config *config.Config
|
||||
ConnManager *wsync.ConnectionManager
|
||||
QueueProcessor *wsync.SyncQueueProcessor
|
||||
ProgressService *wsync.ProgressService
|
||||
AnnotationService *wsync.AnnotationService
|
||||
CleanupCancel context.CancelFunc
|
||||
QueueCtx context.Context
|
||||
QueueCancel context.CancelFunc
|
||||
@@ -284,6 +287,7 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID
|
||||
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||
{"continue-series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||||
}
|
||||
|
||||
for _, col := range defaultCollections {
|
||||
@@ -315,12 +319,15 @@ func loginUserWithCredentials(t *testing.T, ts *httptest.Server, email, password
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "Failed to login")
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, ok := result["access_token"].(string)
|
||||
require.True(t, ok, "Should have access_token")
|
||||
@@ -448,17 +455,23 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
connManager := wsync.NewConnectionManager()
|
||||
cleanupCancel := connManager.StartCleanupTask()
|
||||
|
||||
// Create sync queue processor with cancellable context
|
||||
progressService := wsync.NewProgressService(queries, connManager)
|
||||
annotationService := wsync.NewAnnotationService(queries, connManager)
|
||||
|
||||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
queueCtx, queueCancel := context.WithCancel(context.Background())
|
||||
go queueProcessor.Start(queueCtx)
|
||||
|
||||
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)
|
||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||
|
||||
// Create refactored handlers (matching main.go)
|
||||
libraryService := services.NewLibraryService(queries)
|
||||
@@ -469,7 +482,10 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
dashboardService := services.NewDashboardService(queries)
|
||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||
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
|
||||
@@ -514,14 +530,18 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
AnalyticsHandler: analyticsHandler,
|
||||
QueueHandler: queueHandler,
|
||||
SystemSettingsHandler: systemSettingsHandler,
|
||||
ProcessingIssuesHandler: processingIssuesHandler,
|
||||
CollectionHandler: collectionHandler,
|
||||
FiltersHandler: filtersHandler,
|
||||
DashboardHandler: dashboardHandler,
|
||||
DashboardService: dashboardService,
|
||||
SeriesHandler: seriesHandler,
|
||||
OPDSHandler: opdsHandler,
|
||||
JobsHandler: jobsHandler,
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
}
|
||||
@@ -543,11 +563,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// Delete ALL test users (any user with test email domains) to ensure clean state
|
||||
// This handles users created during tests that may have been promoted to admin, etc.
|
||||
// Delete transient test users but preserve the dev admin user
|
||||
// testuser@tests.bookhoard.internal is the shared dev admin — deleting it
|
||||
// triggers ON DELETE SET NULL on libraries.created_by_admin_id
|
||||
allUsers, err := queries.ListUsers(ctx)
|
||||
if err == nil {
|
||||
for _, user := range allUsers {
|
||||
if user.Email == "testuser@tests.bookhoard.internal" {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(user.Email, "@example.com") || strings.HasSuffix(user.Email, "@tests.bookhoard.internal") {
|
||||
queries.DeleteUser(ctx, user.ID)
|
||||
}
|
||||
@@ -607,6 +631,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
Config: cfg,
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
CleanupCancel: cleanupCancel,
|
||||
QueueCtx: queueCtx,
|
||||
QueueCancel: queueCancel,
|
||||
@@ -648,12 +673,15 @@ func loginWithCredentials(t *testing.T, ts *httptest.Server, email, password str
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err, "Failed to login")
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
|
||||
token, ok := result["access_token"].(string)
|
||||
require.True(t, ok, "Should have access_token")
|
||||
@@ -698,12 +726,15 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
var libResult map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&libResult)
|
||||
err = json.NewDecoder(resp.Body).Decode(&libResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
libData := libResult["id"].(string)
|
||||
|
||||
@@ -718,7 +749,9 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
||||
|
||||
folderResp, err := httpClient.Do(folderReqHTTP)
|
||||
require.NoError(t, err)
|
||||
defer folderResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(folderResp.Body)
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items")
|
||||
|
||||
mediaItemReq := map[string]interface{}{
|
||||
@@ -737,12 +770,15 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
||||
|
||||
resp2, err := httpClient.Do(req2)
|
||||
require.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp2.Body)
|
||||
|
||||
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
||||
|
||||
var mediaItemResult map[string]interface{}
|
||||
json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
|
||||
err = json.NewDecoder(resp2.Body).Decode(&mediaItemResult)
|
||||
require.NoError(t, err)
|
||||
|
||||
mediaItemID := mediaItemResult["id"].(string)
|
||||
|
||||
@@ -771,6 +807,8 @@ func addFolderToLibrary(t *testing.T, setup *TestServerSetup, libraryID string,
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -83,7 +84,9 @@ func TestWebSocketDeviceAuth(t *testing.T) {
|
||||
require.NoError(t, err, "WebSocket connection with device token should succeed")
|
||||
defer ws.Close()
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode, "Should upgrade to WebSocket")
|
||||
}
|
||||
// Read initial state message
|
||||
@@ -116,26 +119,23 @@ func TestWebSocketProgressBroadcast(t *testing.T) {
|
||||
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, _, _ = ws.ReadMessage()
|
||||
|
||||
// Update progress via HTTP API
|
||||
// Update progress via HTTP API to new media-item progress endpoint
|
||||
progressReq := map[string]interface{}{
|
||||
"source": "test",
|
||||
"location": map[string]interface{}{
|
||||
"percentage": 0.5,
|
||||
},
|
||||
"device_metadata": map[string]interface{}{
|
||||
"device_type": "web",
|
||||
},
|
||||
"epubcfi": "epubcfi(/6/4/2:10)",
|
||||
}
|
||||
body, _ := json.Marshal(progressReq)
|
||||
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/progress/"+mediaID, strings.NewReader(string(body)))
|
||||
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+mediaID+"/progress", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
@@ -253,11 +253,14 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
||||
|
||||
collectionResp, err := client.Do(collectionHTTP)
|
||||
require.NoError(t, err)
|
||||
defer collectionResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(collectionResp.Body)
|
||||
require.Equal(t, http.StatusCreated, collectionResp.StatusCode)
|
||||
|
||||
var collectionResult map[string]interface{}
|
||||
json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
|
||||
err = json.NewDecoder(collectionResp.Body).Decode(&collectionResult)
|
||||
require.NoError(t, err)
|
||||
collectionID := collectionResult["id"].(string)
|
||||
|
||||
// Create a test book via API
|
||||
@@ -289,7 +292,9 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
||||
|
||||
addResp, err := client.Do(addHTTP)
|
||||
require.NoError(t, err)
|
||||
defer addResp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(addResp.Body)
|
||||
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||
|
||||
// Admin should receive collection_updated message
|
||||
@@ -313,7 +318,7 @@ func connectWebSocketToServer(t *testing.T, serverURL string, token string) *web
|
||||
ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
require.NoError(t, err, "WebSocket connection should succeed")
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
require.NotNil(t, ws, "WebSocket connection should be established")
|
||||
|
||||
|
||||
@@ -41,8 +41,9 @@ func TestWorker_DirectoryScanJob(t *testing.T) {
|
||||
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
||||
|
||||
var createLibResponse map[string]interface{}
|
||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
createLibResp.Body.Close()
|
||||
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
require.NoError(t, err)
|
||||
_ = createLibResp.Body.Close()
|
||||
|
||||
libraryID, ok := createLibResponse["id"].(string)
|
||||
require.True(t, ok)
|
||||
@@ -62,7 +63,7 @@ func TestWorker_DirectoryScanJob(t *testing.T) {
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
|
||||
// Create test files in the directory
|
||||
@@ -141,8 +142,9 @@ func TestWorker_SetFoldersJob(t *testing.T) {
|
||||
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
||||
|
||||
var createLibResponse map[string]interface{}
|
||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
createLibResp.Body.Close()
|
||||
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||
require.NoError(t, err)
|
||||
_ = createLibResp.Body.Close()
|
||||
|
||||
libraryID, ok := createLibResponse["id"].(string)
|
||||
require.True(t, ok)
|
||||
|
||||
+269
-11
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS library_types (
|
||||
INSERT INTO library_types (name, description, allowed_extensions) VALUES
|
||||
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
|
||||
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
|
||||
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'])
|
||||
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif'])
|
||||
ON CONFLICT (name) DO NOTHING;
|
||||
|
||||
-- Create users table
|
||||
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
||||
max_devices INTEGER DEFAULT 10,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
@@ -44,10 +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')
|
||||
-- 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
|
||||
@@ -121,6 +159,7 @@ CREATE TABLE IF NOT EXISTS media_items (
|
||||
google_books_id VARCHAR(100), -- Google Books identifier
|
||||
added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
imported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
-- Universal Sync Format Detection
|
||||
format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable',
|
||||
@@ -242,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,
|
||||
@@ -457,6 +497,7 @@ CREATE TABLE IF NOT EXISTS reading_history (
|
||||
-- Create indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
|
||||
CREATE INDEX IF NOT EXISTS idx_library_types_name ON library_types(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
@@ -933,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',
|
||||
@@ -951,6 +993,7 @@ BEGIN
|
||||
percentage,
|
||||
character_offset,
|
||||
epubcfi,
|
||||
context_text,
|
||||
chapter,
|
||||
chapter_progress,
|
||||
last_sync_device,
|
||||
@@ -967,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',
|
||||
@@ -1141,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 (
|
||||
@@ -1304,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
|
||||
+20
-57
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
@@ -9,44 +7,49 @@ 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: 5s
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
env_file:
|
||||
- .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}
|
||||
@@ -57,8 +60,11 @@ services:
|
||||
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
|
||||
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||
|
||||
# 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
|
||||
@@ -66,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"]
|
||||
interval: 10s
|
||||
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 |
|
||||
| ------------ | ------ | --------------------- | ----------------------------------------------------------------- |
|
||||
| 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.
|
||||
|
||||
### Bookmark / Note / Highlight Object
|
||||
|
||||
All three types share the same KOReader annotation shape:
|
||||
|
||||
| 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 |
|
||||
| ------------ | ------- | -------- | ---------------------------------------------------- |
|
||||
| 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 |
|
||||
| ---- | -------------------------------------------------- |
|
||||
| 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
|
||||
|
||||
@@ -18,10 +18,12 @@ 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[].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) |
|
||||
@@ -30,7 +32,16 @@ KOReader uses a custom JSON-based sync protocol.
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
+23
-631
@@ -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
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
**Kobo Firmware**: 4.30.0+
|
||||
|
||||
@@ -9,18 +9,19 @@ KOReader is an open-source e-reader application that supports a wide range of e-
|
||||
- Kindle devices (Paperwhite, Oasis, Voyage, etc.)
|
||||
- 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
|
||||
**Last Updated**: August 2026
|
||||
**Bookhoard Version**: 1.0
|
||||
**KOReader Version**: 2024.01+
|
||||
|
||||
@@ -1,60 +1,58 @@
|
||||
## Saving Custom Filters
|
||||
# Saving Custom Filters
|
||||
|
||||
The bookshelf page allows you to save custom filter presets for quick access.
|
||||
The bookshelf (**All Books**) page lets you save custom filter presets for quick access.
|
||||
|
||||
### How to Save a Filter
|
||||
## Bookshelf Toolbar
|
||||
|
||||
The All Books page has a toolbar with:
|
||||
|
||||
- A **search input** for quick text searches
|
||||
- A **sort** dropdown (title, author, date added, page count)
|
||||
- A **Filters** button that opens the filter drawer (author, tags, series, and more)
|
||||
- **Save**, **Load**, and **Clear** buttons for filter presets
|
||||
|
||||
## How to Save a Filter
|
||||
|
||||
1. Navigate to the **All Books** page
|
||||
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"
|
||||
|
||||
|
||||
+41
-38
@@ -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
|
||||
|
||||
@@ -38,18 +38,23 @@
|
||||
### 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. |
|
||||
| **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
|
||||
|
||||
@@ -4,20 +4,20 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/ArcadiaLin/go-epub v0.1.1
|
||||
github.com/a-h/templ v0.3.1001
|
||||
github.com/andybalholm/brotli v1.2.0
|
||||
github.com/a-h/templ v0.3.1020
|
||||
github.com/andybalholm/brotli v1.2.1
|
||||
github.com/bodgit/plumbing v1.3.0
|
||||
github.com/bodgit/windows v1.0.1
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/jackc/pgx/v5 v5.9.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
github.com/klauspost/compress v1.18.5
|
||||
github.com/labstack/echo-jwt/v5 v5.0.1
|
||||
github.com/labstack/echo/v5 v5.0.4
|
||||
github.com/labstack/echo/v5 v5.1.0
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/nwaples/rardecode v1.1.3
|
||||
github.com/pdfcpu/pdfcpu v0.11.1
|
||||
@@ -26,10 +26,11 @@ require (
|
||||
github.com/spf13/afero v1.15.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
github.com/yuin/goldmark v1.7.17
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/text v0.36.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -37,7 +38,7 @@ require (
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -45,21 +46,20 @@ require (
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||
github.com/hhrutter/tiff v1.0.3 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.23 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/xyproto/randomstring v1.2.0 // indirect
|
||||
golang.org/x/image v0.37.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/image v0.39.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
github.com/ArcadiaLin/go-epub v0.1.1 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws=
|
||||
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
|
||||
github.com/a-h/templ v0.3.1001 h1:yHDTgexACdJttyiyamcTHXr2QkIeVF1MukLy44EAhMY=
|
||||
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
||||
@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
@@ -32,8 +32,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@@ -50,14 +50,14 @@ github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
|
||||
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
||||
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
|
||||
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
|
||||
github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0=
|
||||
github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
@@ -68,12 +68,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo-jwt/v5 v5.0.1 h1:uIpCHCiDPN3jA8Jb47i4EViToUl1uypMiPvVAAgKpIw=
|
||||
github.com/labstack/echo-jwt/v5 v5.0.1/go.mod h1:kcHmJPzrVSEJa1FRheVoi9EJrBLLUqr1ntlil6uPe1Q=
|
||||
github.com/labstack/echo/v5 v5.0.4 h1:ll3I/O8BifjMztj9dD1vx/peZQv8cR2CTUdQK6QxGGc=
|
||||
github.com/labstack/echo/v5 v5.0.4/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||
github.com/labstack/echo/v5 v5.1.0 h1:MvIRydoN+p9cx/zq8Lff6YXqUW2ZaEsOMISzEGSMrBI=
|
||||
github.com/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
@@ -110,22 +110,22 @@ github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0o
|
||||
github.com/xyproto/randomstring v1.2.0 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
|
||||
github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.4.5/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
|
||||
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
|
||||
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg=
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@ package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -40,7 +41,7 @@ func (a *App) StartServer(addr string) error {
|
||||
|
||||
// Start HTTP server in background
|
||||
go func() {
|
||||
if err := a.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
if err := a.server.ListenAndServe(); err != nil && errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("Server failed to start: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -55,6 +55,9 @@ func BenchmarkApp_Shutdown(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
app := New(e)
|
||||
app.Shutdown()
|
||||
err := app.Shutdown()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
@@ -165,6 +176,15 @@ type MediaBookmarks struct {
|
||||
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 {
|
||||
@@ -189,6 +209,12 @@ type MediaHighlights struct {
|
||||
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 {
|
||||
@@ -239,6 +265,7 @@ type MediaItems struct {
|
||||
GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"`
|
||||
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
ImportedAt pgtype.Timestamptz `db:"imported_at" json:"imported_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
FormatGroup string `db:"format_group" json:"format_group"`
|
||||
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
|
||||
@@ -303,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 {
|
||||
@@ -379,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"`
|
||||
@@ -467,6 +500,11 @@ type SystemSettings struct {
|
||||
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 {
|
||||
@@ -507,5 +545,6 @@ type Users struct {
|
||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -23,15 +23,18 @@ type Querier interface {
|
||||
// Bulk update format group for all media items
|
||||
BulkUpdateFormatGroups(ctx context.Context) error
|
||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
||||
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)
|
||||
@@ -50,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
|
||||
@@ -62,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
|
||||
@@ -123,11 +133,19 @@ 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
|
||||
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
||||
// Get collection items
|
||||
@@ -141,6 +159,7 @@ type Querier interface {
|
||||
GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.UUID) ([]Collections, error)
|
||||
// Smart section queries (for system collections)
|
||||
GetContinueReadingItems(ctx context.Context, arg GetContinueReadingItemsParams) ([]MediaItems, error)
|
||||
GetContinueSeriesItems(ctx context.Context, arg GetContinueSeriesItemsParams) ([]GetContinueSeriesItemsRow, error)
|
||||
// ============================================
|
||||
// CAROUSEL-STYLE DASHBOARD
|
||||
// ============================================
|
||||
@@ -166,7 +185,12 @@ type Querier interface {
|
||||
// Get device shelf mappings
|
||||
GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error)
|
||||
GetDictionaryEntry(ctx context.Context, word string) (DictionaryCache, error)
|
||||
GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error)
|
||||
GetDistinctSeriesCount(ctx context.Context, libraryID pgtype.UUID) (int32, error)
|
||||
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)
|
||||
@@ -177,6 +201,7 @@ type Querier interface {
|
||||
GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error)
|
||||
GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
|
||||
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error)
|
||||
GetLibraryByFolderPathPrefix(ctx context.Context, folderPath string) (GetLibraryByFolderPathPrefixRow, error)
|
||||
GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
|
||||
GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([]MediaItems, error)
|
||||
GetLibraryType(ctx context.Context, id pgtype.UUID) (LibraryTypes, error)
|
||||
@@ -188,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)
|
||||
@@ -205,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)
|
||||
@@ -235,6 +277,8 @@ 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, 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)
|
||||
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
|
||||
@@ -246,6 +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
|
||||
@@ -269,19 +316,36 @@ 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)
|
||||
IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error)
|
||||
// Link unlinked book to media item
|
||||
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
|
||||
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)
|
||||
@@ -289,16 +353,32 @@ 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
|
||||
ReassignMediaItems(ctx context.Context, arg ReassignMediaItemsParams) error
|
||||
// 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
|
||||
@@ -316,6 +396,13 @@ type Querier interface {
|
||||
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||
// 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)
|
||||
@@ -339,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
|
||||
@@ -357,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)
|
||||
@@ -370,10 +460,12 @@ type Querier interface {
|
||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||
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)
|
||||
|
||||
+2830
-67
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ SELECT
|
||||
u.max_devices,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
u.timezone,
|
||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||
FROM users u
|
||||
WHERE u.id = $1;
|
||||
@@ -60,6 +61,9 @@ SELECT * FROM library_types WHERE id = $1;
|
||||
-- name: GetLibraryTypeByName :one
|
||||
SELECT * FROM library_types WHERE name = $1;
|
||||
|
||||
-- name: SyncLibraryTypeExtensions :exec
|
||||
UPDATE library_types SET allowed_extensions = $2 WHERE name = $1;
|
||||
|
||||
-- Libraries queries
|
||||
-- name: CreateLibrary :one
|
||||
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
|
||||
@@ -104,6 +108,13 @@ SELECT lf.library_id, l.* FROM library_folders lf
|
||||
JOIN libraries l ON lf.library_id = l.id
|
||||
WHERE lf.folder_path = $1;
|
||||
|
||||
-- name: GetLibraryByFolderPathPrefix :one
|
||||
SELECT lf.library_id, lf.folder_path, l.* FROM library_folders lf
|
||||
JOIN libraries l ON lf.library_id = l.id
|
||||
WHERE $1 LIKE lf.folder_path || '%'
|
||||
ORDER BY LENGTH(lf.folder_path) DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- Library Visibility queries
|
||||
-- name: SetLibraryVisibility :one
|
||||
INSERT INTO library_visibility (user_id, library_id, is_visible)
|
||||
@@ -126,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, 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)
|
||||
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
|
||||
@@ -248,6 +268,20 @@ UPDATE media_items SET
|
||||
goodreads_id = $21,
|
||||
openlibrary_id = $22,
|
||||
google_books_id = $23,
|
||||
manga_type = $24,
|
||||
reading_direction = $25,
|
||||
series_count = $26,
|
||||
volume = $27,
|
||||
imprint = $28,
|
||||
age_rating = $29,
|
||||
web_url = $30,
|
||||
metadata_notes = $31,
|
||||
community_rating = $32,
|
||||
story_arc = $33,
|
||||
is_black_and_white = $34,
|
||||
alternate_info = $35,
|
||||
scan_information = $36,
|
||||
summary = $37,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
@@ -300,9 +334,36 @@ RETURNING *;
|
||||
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
|
||||
RETURNING id, email, username, role;
|
||||
|
||||
-- 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';
|
||||
|
||||
-- name: CountUserDevices :one
|
||||
SELECT COUNT(*) FROM devices WHERE user_id = $1;
|
||||
|
||||
-- name: GetFirstAdminExclude :one
|
||||
SELECT id, email, username, theme, first_name, last_name, role, max_devices, created_at, updated_at FROM users
|
||||
WHERE role = 'admin' AND id != $1
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetFirstAdmin :one
|
||||
SELECT id FROM users
|
||||
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;
|
||||
|
||||
-- name: ReassignMediaItems :exec
|
||||
UPDATE media_items SET added_by_admin_id = $2 WHERE added_by_admin_id = $1;
|
||||
|
||||
-- name: DeleteUser :exec
|
||||
DELETE FROM users WHERE id = $1;
|
||||
|
||||
@@ -313,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)
|
||||
@@ -644,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
|
||||
@@ -667,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
|
||||
@@ -683,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)
|
||||
@@ -702,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
|
||||
@@ -744,6 +1169,7 @@ SELECT
|
||||
rp.percentage,
|
||||
rp.character_offset,
|
||||
rp.epubcfi,
|
||||
rp.context_text,
|
||||
rp.chapter,
|
||||
rp.chapter_progress,
|
||||
rp.viewport_x,
|
||||
@@ -776,6 +1202,7 @@ INSERT INTO reading_progress (
|
||||
percentage,
|
||||
character_offset,
|
||||
epubcfi,
|
||||
context_text,
|
||||
chapter,
|
||||
chapter_progress,
|
||||
viewport_x,
|
||||
@@ -793,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,
|
||||
@@ -1071,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;
|
||||
|
||||
@@ -1113,6 +1546,15 @@ DELETE FROM sync_conflicts WHERE id = $1;
|
||||
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
|
||||
-- ============================================
|
||||
@@ -1161,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,
|
||||
@@ -1177,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
|
||||
@@ -1365,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;
|
||||
@@ -1412,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
|
||||
@@ -1781,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)
|
||||
@@ -1845,59 +2356,112 @@ 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
|
||||
ORDER BY mi.created_at DESC
|
||||
LIMIT $2;
|
||||
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 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
|
||||
SELECT series, COUNT(*) as book_count,
|
||||
MAX(series_count) as total_in_series,
|
||||
MAX(created_at) as last_entry_at
|
||||
FROM media_items
|
||||
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 sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: GetDistinctSeriesCount :one
|
||||
SELECT COUNT(DISTINCT series)::int
|
||||
FROM media_items
|
||||
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 (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 sqlc.narg('limit');
|
||||
|
||||
-- name: GetSeriesBooks :many
|
||||
SELECT * FROM media_items
|
||||
WHERE series = sqlc.narg('series')
|
||||
ORDER BY series_number ASC NULLS LAST;
|
||||
|
||||
-- name: GetContinueSeriesItems :many
|
||||
WITH user_series_progress AS (
|
||||
SELECT mi.series,
|
||||
MAX(mi.series_number) as max_read_number,
|
||||
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 = sqlc.narg('user_id')
|
||||
AND rp.percentage > 0
|
||||
AND mi.series IS NOT NULL AND mi.series != ''
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
GROUP BY mi.series
|
||||
),
|
||||
next_books AS (
|
||||
SELECT DISTINCT ON (mi.series) mi.*,
|
||||
usp.last_read_at
|
||||
FROM media_items mi
|
||||
JOIN user_series_progress usp ON mi.series = usp.series
|
||||
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 sqlc.narg('limit');
|
||||
|
||||
-- name: GetSavedFilters :many
|
||||
SELECT * FROM saved_filters
|
||||
WHERE user_id = @user_id AND resource_type = @resource_type
|
||||
@@ -1995,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)
|
||||
@@ -2013,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 *;
|
||||
|
||||
@@ -2093,3 +2660,8 @@ SELECT
|
||||
FROM libraries l
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE l.id = $1;
|
||||
|
||||
-- name: GetBooksByTag :many
|
||||
SELECT * FROM media_items
|
||||
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
||||
ORDER BY title ASC;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
highlighting "github.com/yuin/goldmark-highlighting"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/renderer/html"
|
||||
|
||||
"bookhoard/templates"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type DocsHandler struct {
|
||||
@@ -77,7 +79,7 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
|
||||
// Convert markdown to HTML
|
||||
var buf bytes.Buffer
|
||||
context := parser.NewContext()
|
||||
if err := h.markdown.Convert([]byte(content), &buf, parser.WithContext(context)); err != nil {
|
||||
if err := h.markdown.Convert(content, &buf, parser.WithContext(context)); err != nil {
|
||||
return nil, fmt.Errorf("failed to convert markdown: %w", err)
|
||||
}
|
||||
|
||||
@@ -169,7 +171,7 @@ func (h *DocsHandler) generateBreadcrumb(docPath string) []templates.BreadcrumbI
|
||||
// Don't add the last part (current page)
|
||||
if i < len(parts)-1 {
|
||||
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
|
||||
Title: strings.Title(strings.ReplaceAll(part, "-", " ")),
|
||||
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
|
||||
URL: "/docs" + path,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"strings"
|
||||
|
||||
"bookhoard/templates"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// BuildNavigation creates the navigation structure from the docs filesystem
|
||||
@@ -100,7 +103,7 @@ func (h *DocsHandler) getDocTitle(docPath string) string {
|
||||
filename := parts[len(parts)-1]
|
||||
|
||||
// Convert to title case
|
||||
title = strings.Title(strings.ReplaceAll(filename, "-", " "))
|
||||
title = cases.Title(language.English).String(strings.ReplaceAll(filename, "-", " "))
|
||||
|
||||
// Handle special cases
|
||||
switch filename {
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -74,18 +75,18 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
||||
endDate := c.QueryParam("end_date")
|
||||
|
||||
if startDate == "" {
|
||||
startDate = time.Now().AddDate(0, -1, 0).Format("2006-01-02")
|
||||
startDate = time.Now().AddDate(0, -1, 0).Format("01-02-2006")
|
||||
}
|
||||
if endDate == "" {
|
||||
endDate = time.Now().Format("2006-01-02")
|
||||
endDate = time.Now().Format("01-02-2006")
|
||||
}
|
||||
|
||||
startTime, err := time.Parse("2006-01-02", startDate)
|
||||
startTime, err := time.Parse("01-02-2006", startDate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
|
||||
}
|
||||
|
||||
endTime, err := time.Parse("2006-01-02", endDate)
|
||||
endTime, err := time.Parse("01-02-2006", endDate)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
|
||||
}
|
||||
@@ -98,7 +99,7 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
||||
CreatedAt_2: pgtype.Timestamptz{Time: endTime, Valid: true},
|
||||
})
|
||||
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history")
|
||||
}
|
||||
|
||||
@@ -129,7 +130,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
||||
longestSession = int(minutes)
|
||||
}
|
||||
|
||||
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
|
||||
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
|
||||
if dailyMap[dateKey] == nil {
|
||||
dailyMap[dateKey] = &DailyReading{
|
||||
Date: dateKey,
|
||||
@@ -140,7 +141,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
||||
|
||||
if entry.PagesRead.Valid {
|
||||
totalPages += int(entry.PagesRead.Int32)
|
||||
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
|
||||
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
|
||||
if dailyMap[dateKey] != nil {
|
||||
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
|
||||
}
|
||||
@@ -211,7 +212,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
||||
ctx := context.Background()
|
||||
|
||||
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get device usage")
|
||||
}
|
||||
|
||||
@@ -221,7 +222,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
||||
lastSync := ""
|
||||
if u.LastSync != nil {
|
||||
if t, ok := u.LastSync.(time.Time); ok {
|
||||
lastSync = t.Format("2006-01-02 15:04:05")
|
||||
lastSync = t.Format("01-02-2006 03:04:05 PM")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,7 +264,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
|
||||
Limit: limitInt,
|
||||
})
|
||||
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get popular books")
|
||||
}
|
||||
|
||||
@@ -273,7 +274,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
|
||||
lastRead := ""
|
||||
if book.LastRead != nil {
|
||||
if t, ok := book.LastRead.(time.Time); ok {
|
||||
lastRead = t.Format("2006-01-02 15:04:05")
|
||||
lastRead = t.Format("01-02-2006 03:04:05 PM")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user