Compare commits
234
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d65e03555 | ||
|
|
ab465d8e0e | ||
|
|
1461273162 | ||
|
|
598d70f735 | ||
|
|
537330e7e0 | ||
|
|
885f6d8187 | ||
|
|
936a48405b | ||
|
|
757398bf15 | ||
|
|
d12911d3c8 | ||
|
|
457a38306d | ||
|
|
bc47450653 | ||
|
|
64ad10b3f2 | ||
|
|
a42232e67a | ||
|
|
006cc0c2c9 | ||
|
|
706be09dec | ||
|
|
a87c8afc22 | ||
|
|
acc9d08c60 | ||
|
|
e09c55cecc | ||
|
|
6f1c13998c | ||
|
|
0a0caa9533 | ||
|
|
4716790564 | ||
|
|
8e2c1a4b3a | ||
|
|
4a870a7f18 | ||
|
|
40dabfd788 | ||
|
|
ef3c05714a | ||
|
|
816ee0ec80 | ||
|
|
73a2852ee3 | ||
|
|
f67232a20b | ||
|
|
7b465ac97e | ||
|
|
033a012069 | ||
|
|
9165c4eb3f | ||
|
|
9ef6c5b6ed | ||
|
|
987ece38f0 | ||
|
|
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 |
+4
-1
@@ -22,4 +22,7 @@ logs/
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Media uploads (bind-mounted at runtime)
|
||||||
|
uploads/
|
||||||
+18
-1
@@ -10,6 +10,21 @@ JWT_SECRET=your-secure-jwt-secret-key-here
|
|||||||
# Generate with: openssl rand -hex 16
|
# Generate with: openssl rand -hex 16
|
||||||
DBPASS=your-secure-database-password-here
|
DBPASS=your-secure-database-password-here
|
||||||
|
|
||||||
|
# Networking: change a port if it conflicts on your host
|
||||||
|
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
||||||
|
# DB_PORT=15432
|
||||||
|
# App web port, host + container
|
||||||
|
# SERVER_PORT=8765
|
||||||
|
|
||||||
|
# Deployment
|
||||||
|
# External URL for device sync (must include protocol; defaults to http://localhost:8765)
|
||||||
|
# Examples: https://bookhoard.example.com | http://192.168.1.10:8765
|
||||||
|
# BASE_URL=https://bookhoard.example.com
|
||||||
|
# Mark session cookies Secure — set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||||
|
# COOKIE_SECURE=true
|
||||||
|
# Pin or rollback a specific published image version (defaults to "latest")
|
||||||
|
# IMAGE_TAG=1.0.0
|
||||||
|
|
||||||
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
||||||
# Test Mode: WARNING - Only set to true for integration testing
|
# Test Mode: WARNING - Only set to true for integration testing
|
||||||
# TEST_MODE=true
|
# TEST_MODE=true
|
||||||
@@ -19,4 +34,6 @@ DBPASS=your-secure-database-password-here
|
|||||||
# Conversion Tool: Switch from kepubify to ebook-convert
|
# Conversion Tool: Switch from kepubify to ebook-convert
|
||||||
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
||||||
# Conversion Cache TTL: Override default 24h
|
# Conversion Cache TTL: Override default 24h
|
||||||
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
# 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
|
||||||
uploads/
|
uploads/
|
||||||
|
|
||||||
|
# Vendored build artifacts (copied from node_modules at build time)
|
||||||
|
web/static/vendor/
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
|
|||||||
+9
-6
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM golang:1.25-alpine AS builder
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -7,14 +7,16 @@ WORKDIR /app
|
|||||||
RUN apk add --no-cache nodejs npm curl git
|
RUN apk add --no-cache nodejs npm curl git
|
||||||
|
|
||||||
# Install Go tools (cached well)
|
# Install Go tools (cached well)
|
||||||
|
RUN wget -O /tmp/sqlc.tar.gz https://github.com/sqlc-dev/sqlc/releases/download/v1.31.0/sqlc_1.31.0_linux_amd64.tar.gz && \
|
||||||
|
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 \
|
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@v0.3.1020
|
||||||
go install github.com/a-h/templ/cmd/templ@latest
|
|
||||||
|
|
||||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
npm ci
|
npm install
|
||||||
|
|
||||||
# Copy Go mod files (cached unless go.mod changes)
|
# Copy Go mod files (cached unless go.mod changes)
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
@@ -34,11 +36,12 @@ RUN npm run build:ts
|
|||||||
|
|
||||||
# Build Go binary (cached unless Go files or generated code changes)
|
# Build Go binary (cached unless Go files or generated code changes)
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
|
||||||
|
|
||||||
# Test runner stage - includes Go runtime and test dependencies
|
# Test runner stage - includes Go runtime and test dependencies
|
||||||
# This stage is ONLY used for running tests, never deployed to production
|
# This stage is ONLY used for running tests, never deployed to production
|
||||||
FROM golang:1.25-alpine AS test-runner
|
FROM golang:1.26-alpine AS test-runner
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates curl
|
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)
|
# Include .env file for environment variables (single source of truth)
|
||||||
# Ignore if .env doesn't exist yet
|
# Ignore if .env doesn't exist yet
|
||||||
@@ -7,6 +7,14 @@ ifneq (,$(wildcard ./.env))
|
|||||||
export
|
export
|
||||||
endif
|
endif
|
||||||
|
|
||||||
|
# Auto-detect container runtime: prefer docker, fall back to podman
|
||||||
|
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
||||||
|
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
||||||
|
|
||||||
|
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
||||||
|
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
||||||
|
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
help:
|
help:
|
||||||
@echo "Available targets:"
|
@echo "Available targets:"
|
||||||
@@ -36,6 +44,9 @@ help:
|
|||||||
@echo "Verification:"
|
@echo "Verification:"
|
||||||
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
||||||
@echo " make verify-quick - Run quick guidelines check"
|
@echo " make verify-quick - Run quick guidelines check"
|
||||||
|
@echo ""
|
||||||
|
@echo "Release:"
|
||||||
|
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
|
||||||
|
|
||||||
# Run unit tests locally (fast, no containers)
|
# Run unit tests locally (fast, no containers)
|
||||||
test:
|
test:
|
||||||
@@ -44,23 +55,23 @@ test:
|
|||||||
# Run integration tests in containers (matches production environment)
|
# Run integration tests in containers (matches production environment)
|
||||||
test-integration:
|
test-integration:
|
||||||
@echo "Building test containers..."
|
@echo "Building test containers..."
|
||||||
podman compose --profile tests build
|
$(COMPOSE) --profile tests build
|
||||||
@echo "Starting application containers..."
|
@echo "Starting application containers..."
|
||||||
podman compose up -d db app
|
$(COMPOSE) up -d db app
|
||||||
@echo "Waiting for services to be healthy..."
|
@echo "Waiting for services to be healthy..."
|
||||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||||
echo " Database not ready yet..."; \
|
echo " Database not ready yet..."; \
|
||||||
sleep 2; \
|
sleep 2; \
|
||||||
done; \
|
done; \
|
||||||
echo " ✓ Database is ready"
|
echo " ✓ Database is ready"
|
||||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
||||||
echo " Application not ready yet..."; \
|
echo " Application not ready yet..."; \
|
||||||
sleep 2; \
|
sleep 2; \
|
||||||
done; \
|
done; \
|
||||||
echo " ✓ Application is ready"
|
echo " ✓ Application is ready"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Running integration tests in container..."
|
@echo "Running integration tests in container..."
|
||||||
podman compose --profile tests run --rm tests
|
$(COMPOSE) --profile tests run --rm tests
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "✅ Integration tests completed!"
|
@echo "✅ Integration tests completed!"
|
||||||
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
||||||
@@ -71,76 +82,76 @@ test-all: test test-integration
|
|||||||
# Rebuild app container only (preserve DB, with cache)
|
# Rebuild app container only (preserve DB, with cache)
|
||||||
rebuild-app:
|
rebuild-app:
|
||||||
@echo "Rebuilding app container (database stays running)..."
|
@echo "Rebuilding app container (database stays running)..."
|
||||||
podman compose up --build --force-recreate -d app
|
$(COMPOSE) up --build --force-recreate -d app
|
||||||
@echo "✓ App container rebuilt and restarted"
|
@echo "✓ App container rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild app container only (preserve DB, no cache)
|
# Rebuild app container only (preserve DB, no cache)
|
||||||
rebuild-app-force:
|
rebuild-app-force:
|
||||||
@echo "Force rebuilding app container (database stays running, no cache)..."
|
@echo "Force rebuilding app container (database stays running, no cache)..."
|
||||||
podman compose build --no-cache app
|
$(COMPOSE) build --no-cache app
|
||||||
podman compose up --force-recreate -d app
|
$(COMPOSE) up --force-recreate -d app
|
||||||
@echo "✓ App container rebuilt and restarted"
|
@echo "✓ App container rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (preserve DB, with cache)
|
# Rebuild all containers (preserve DB, with cache)
|
||||||
rebuild:
|
rebuild:
|
||||||
@echo "Rebuilding all containers (database preserved)..."
|
@echo "Rebuilding all containers (database preserved)..."
|
||||||
podman compose up --build --force-recreate -d
|
$(COMPOSE) up --build --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (preserve DB, no cache)
|
# Rebuild all containers (preserve DB, no cache)
|
||||||
rebuild-force:
|
rebuild-force:
|
||||||
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
||||||
podman compose build --no-cache
|
$(COMPOSE) build --no-cache
|
||||||
podman compose up --force-recreate -d
|
$(COMPOSE) up --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Rebuild all containers (remove DB, no cache)
|
# Rebuild all containers (remove DB, no cache)
|
||||||
rebuild-force-db:
|
rebuild-force-db:
|
||||||
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
||||||
podman compose down -v
|
$(COMPOSE) down -v
|
||||||
podman compose build --no-cache
|
$(COMPOSE) build --no-cache
|
||||||
podman compose up --force-recreate -d
|
$(COMPOSE) up --force-recreate -d
|
||||||
@echo "✓ All containers rebuilt and restarted"
|
@echo "✓ All containers rebuilt and restarted"
|
||||||
|
|
||||||
# Stop and remove containers
|
# Stop and remove containers
|
||||||
clean:
|
clean:
|
||||||
podman compose down -v
|
$(COMPOSE) down -v
|
||||||
|
|
||||||
# Quick start (if already built)
|
# Quick start (if already built)
|
||||||
up:
|
up:
|
||||||
podman compose up -d
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
# Stop all containers (alias for clean)
|
# Stop all containers (alias for clean)
|
||||||
down:
|
down:
|
||||||
podman compose down
|
$(COMPOSE) down
|
||||||
|
|
||||||
# Restart app container (preserves database)
|
# Restart app container (preserves database)
|
||||||
restart:
|
restart:
|
||||||
@echo "Restarting app container (database stays running)..."
|
@echo "Restarting app container (database stays running)..."
|
||||||
podman compose restart app
|
$(COMPOSE) restart app
|
||||||
@echo "✓ App container restarted"
|
@echo "✓ App container restarted"
|
||||||
|
|
||||||
# Show container status
|
# Show container status
|
||||||
ps:
|
ps:
|
||||||
podman compose ps
|
$(COMPOSE) ps
|
||||||
|
|
||||||
# Show container logs
|
# Show container logs
|
||||||
logs:
|
logs:
|
||||||
podman compose logs -f
|
$(COMPOSE) logs -f
|
||||||
|
|
||||||
# Start containers with test mode enabled for manual testing
|
# Start containers with test mode enabled for manual testing
|
||||||
test-env-up:
|
test-env-up:
|
||||||
@echo "Starting containers with test mode enabled..."
|
@echo "Starting containers with test mode enabled..."
|
||||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 podman compose up --build --force-recreate -d
|
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d
|
||||||
@echo "Waiting for services to be ready..."
|
@echo "Waiting for services to be ready..."
|
||||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
||||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
||||||
@echo "✓ Test environment is ready!"
|
@echo "✓ Test environment is ready!"
|
||||||
@echo "Application available at http://localhost:8765"
|
@echo "Application available at http://localhost:8765"
|
||||||
|
|
||||||
# Stop test environment
|
# Stop test environment
|
||||||
test-env-down:
|
test-env-down:
|
||||||
podman compose down -v
|
$(COMPOSE) down -v
|
||||||
|
|
||||||
# Verify project guidelines compliance
|
# Verify project guidelines compliance
|
||||||
verify-guidelines:
|
verify-guidelines:
|
||||||
@@ -150,3 +161,27 @@ verify-guidelines:
|
|||||||
verify-quick:
|
verify-quick:
|
||||||
@echo "Running quick project guidelines verification..."
|
@echo "Running quick project guidelines verification..."
|
||||||
@./scripts/verify-quick.sh
|
@./scripts/verify-quick.sh
|
||||||
|
|
||||||
|
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
|
||||||
|
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
|
||||||
|
# image and publishes a Gitea Release whose body is this tag's message. Notes come
|
||||||
|
# entirely from Conventional Commits — no hand-written message required.
|
||||||
|
#
|
||||||
|
# git-cliff's --latest needs the tag to exist to scope the notes, so we create a
|
||||||
|
# throwaway lightweight tag, generate the notes, replace it with an annotated tag,
|
||||||
|
# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's
|
||||||
|
# default cleanup would strip lines starting with "#").
|
||||||
|
#
|
||||||
|
# Requires git-cliff: https://git-cliff.org/install
|
||||||
|
# Usage: make release VERSION=v0.3.0
|
||||||
|
release:
|
||||||
|
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; }
|
||||||
|
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
||||||
|
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
||||||
|
@echo "Generating release notes for $(VERSION)..."
|
||||||
|
@git tag "$(VERSION)" HEAD && \
|
||||||
|
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
||||||
|
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
|
||||||
|
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
|
||||||
|
@git push origin "$(VERSION)"
|
||||||
|
@echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release."
|
||||||
|
|||||||
@@ -1,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
|
|
||||||
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone https://github.com/yourusername/bookhoard.git
|
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
||||||
cd bookhoard
|
cd bookhoard
|
||||||
|
|
||||||
# 2. Set up environment
|
# 2. Set up environment
|
||||||
@@ -35,8 +35,10 @@ cp .env.example .env
|
|||||||
# DBPASS: openssl rand -hex 16
|
# DBPASS: openssl rand -hex 16
|
||||||
# Edit .env with your generated values
|
# Edit .env with your generated values
|
||||||
|
|
||||||
# 3. Start the server
|
# 3. Pull images and start the server
|
||||||
podman-compose up --build -d # or: docker-compose up --build -d
|
docker compose pull
|
||||||
|
docker compose up -d
|
||||||
|
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
||||||
|
|
||||||
# 4. Open your browser
|
# 4. Open your browser
|
||||||
open http://localhost:8765
|
open http://localhost:8765
|
||||||
|
|||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml"
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
info:
|
||||||
|
name: SetBaseUrl
|
||||||
|
type: http
|
||||||
|
seq: 3
|
||||||
|
|
||||||
|
http:
|
||||||
|
method: PUT
|
||||||
|
url: '{{base_url}}/api/system/config'
|
||||||
|
auth: inherit
|
||||||
|
body:
|
||||||
|
type: json
|
||||||
|
jsonBody: |-
|
||||||
|
{
|
||||||
|
"base_url": "http://localhost:8765"
|
||||||
|
}
|
||||||
|
headers:
|
||||||
|
- key: Authorization
|
||||||
|
value: Bearer {{token}}
|
||||||
|
- key: Content-Type
|
||||||
|
value: application/json
|
||||||
|
|
||||||
|
settings:
|
||||||
|
encodeUrl: true
|
||||||
|
timeout: 0
|
||||||
|
followRedirects: true
|
||||||
|
maxRedirects: 5
|
||||||
|
|
||||||
|
docs: |-
|
||||||
|
## Set Base URL
|
||||||
|
|
||||||
|
Configures the server's base_url during initial dev database setup.
|
||||||
|
|
||||||
|
Must be run after RegisterUser (which provides the auth token) and before
|
||||||
|
any library/device creation (which require setup to be complete).
|
||||||
|
|
||||||
|
**Method:** PUT
|
||||||
|
**Endpoint:** /api/system/config
|
||||||
|
**Auth:** Bearer token (from RegisterUser)
|
||||||
@@ -2,20 +2,20 @@ name: Bookhoard
|
|||||||
variables:
|
variables:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
value: http://localhost:8765
|
value: http://localhost:8765
|
||||||
- name: media_item_id
|
- secret: true
|
||||||
value: 8bd13107-e1e5-4357-b893-bfc77f1e087c
|
name: media_item_id
|
||||||
- name: fake_book_id
|
- secret: true
|
||||||
value: 123e4567-e89b-12d3-a456-426614174000
|
name: fake_book_id
|
||||||
- name: user_id
|
- secret: true
|
||||||
value: c51118f0-31fc-4c32-827d-517d6599bf21
|
name: user_id
|
||||||
- name: highlight_id
|
- secret: true
|
||||||
value: 660f9501-f29b-51d4-b716-446655440001
|
name: highlight_id
|
||||||
- name: note_id
|
- secret: true
|
||||||
value: 7710a602-g29b-61d4-c716-446655440002
|
name: note_id
|
||||||
- name: ebook_library_id
|
- secret: true
|
||||||
value: 0df0ea2b-1965-494a-a0a3-cce8536d5f28
|
name: ebook_library_id
|
||||||
- name: job_id
|
- secret: true
|
||||||
value: 709e0d8e-b866-496c-b260-a59ab8e2014c
|
name: job_id
|
||||||
- name: rating
|
- name: rating
|
||||||
value: "5"
|
value: "5"
|
||||||
- name: is_visible
|
- name: is_visible
|
||||||
@@ -32,11 +32,11 @@ variables:
|
|||||||
name: kobo_device_token
|
name: kobo_device_token
|
||||||
- secret: true
|
- secret: true
|
||||||
name: other_device_id
|
name: other_device_id
|
||||||
- name: collection_id
|
- secret: true
|
||||||
value: 412c03c3-0843-4bd4-b764-cc9457bb9df2
|
name: collection_id
|
||||||
- name: library_folder_id
|
- secret: true
|
||||||
value: da1f9d91-0c4c-40cc-a050-86f795dfc967
|
name: library_folder_id
|
||||||
- name: comic_library_id
|
- secret: true
|
||||||
value: 7b6d0c8c-73dc-4346-804c-5f6e11c3d658
|
name: comic_library_id
|
||||||
- name: manga_library_id
|
- secret: true
|
||||||
value: b134d16d-9668-4867-b50e-8350037f1a4a
|
name: manga_library_id
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ docs: |-
|
|||||||
- `id` (string, required): Media item UUID
|
- `id` (string, required): Media item UUID
|
||||||
|
|
||||||
**Request Body:**
|
**Request Body:**
|
||||||
- `rating` (number, required): Rating value (typically 1-5)
|
- `rating` (number, required): Rating value (1-10 integer scale; displayed as 1-5 stars with half-star precision)
|
||||||
- `review` (string, optional): Review text
|
- `review` (string, optional): Review text
|
||||||
|
|
||||||
**Response:** Updated rating object
|
**Response:** Updated rating object
|
||||||
|
|||||||
@@ -83,9 +83,10 @@ docs:
|
|||||||
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
||||||
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
||||||
Ratings (All Users)
|
Ratings (All Users)
|
||||||
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated)
|
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
|
||||||
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
|
- **Create/Update Rating**: POST /api/media-items/:id/rating - Rate media item (1-10 scale, displayed as 1-5 stars with half-star precision). POST upserts; PUT also available.
|
||||||
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
|
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
|
||||||
|
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
|
||||||
Collections (All Users)
|
Collections (All Users)
|
||||||
- **List Collections**: GET /api/collections - Get user's collections
|
- **List Collections**: GET /api/collections - Get user's collections
|
||||||
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# git-cliff configuration — generates the body of each Gitea Release from
|
||||||
|
# Conventional Commits accumulated since the previous tag. Invoked in CI by
|
||||||
|
# orhun/git-cliff-action with --latest so only the current tag's section is
|
||||||
|
# emitted (no full history, no header — the Gitea Release title is the tag).
|
||||||
|
# Docs: https://git-cliff.org/docs/configuration
|
||||||
|
|
||||||
|
[changelog]
|
||||||
|
header = ""
|
||||||
|
body = """
|
||||||
|
{% for group, commits in commits | group_by(attribute="group") %}\
|
||||||
|
### {{ group | upper_first }}
|
||||||
|
{% for commit in commits %}\
|
||||||
|
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
|
||||||
|
{% endfor %}\
|
||||||
|
{% endfor %}\
|
||||||
|
"""
|
||||||
|
trim = true
|
||||||
|
footer = ""
|
||||||
|
|
||||||
|
[git]
|
||||||
|
conventional_commits = true
|
||||||
|
filter_unconventional = false
|
||||||
|
require_conventional = false
|
||||||
|
split_commits = false
|
||||||
|
commit_parsers = [
|
||||||
|
{ message = "^feat", group = "Features" },
|
||||||
|
{ message = "^fix", group = "Bug Fixes" },
|
||||||
|
{ message = "^perf", group = "Performance" },
|
||||||
|
{ message = "^refactor", group = "Refactor" },
|
||||||
|
{ message = "^docs", group = "Documentation" },
|
||||||
|
{ message = "^test", group = "Tests" },
|
||||||
|
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
|
||||||
|
{ message = ".*", group = "Other" },
|
||||||
|
]
|
||||||
|
filter_commits = false
|
||||||
|
tag_pattern = "v[0-9].*"
|
||||||
|
sort_commits = "oldest"
|
||||||
+75
-7
@@ -14,6 +14,8 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
@@ -48,46 +50,107 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Println("✅ Database schema initialized and verified, starting server...")
|
log.Println("✅ Database schema initialized and verified, starting server...")
|
||||||
|
|
||||||
// Create login attempt tracker: 5 failed attempts = 15 minute lockout
|
// Load tunable settings from the DB into the registry. All values fall back
|
||||||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
// to compiled defaults if a row is missing, so this never blocks startup.
|
||||||
|
registry := database.NewSettingsRegistry(queries)
|
||||||
|
if err := registry.Load(ctx); err != nil {
|
||||||
|
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||||
|
}
|
||||||
|
// Wire the registry into the package-level password validator so live
|
||||||
|
// rule changes apply to the echo struct-tag validator and ValidatePassword.
|
||||||
|
middleware.SetDefaultPasswordSettings(registry)
|
||||||
|
|
||||||
|
// Seed base_url from env var if not already configured. Uses conditional
|
||||||
|
// UPDATE so admin-set values are never overwritten on restart.
|
||||||
|
if cfg.BaseURL != "" {
|
||||||
|
_, err = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ('base_url', $1)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, cfg.BaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("⚠️ Could not seed base_url: %v", err)
|
||||||
|
} else {
|
||||||
|
// Also seed derived URLs
|
||||||
|
for key, suffix := range map[string]string{
|
||||||
|
"opds_base_url": "/opds",
|
||||||
|
"api_base_url": "/api",
|
||||||
|
} {
|
||||||
|
_, _ = dbPool.Exec(ctx, `
|
||||||
|
INSERT INTO system_config (key, value)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = EXCLUDED.value
|
||||||
|
WHERE system_config.value = ''
|
||||||
|
`, key, cfg.BaseURL+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create login attempt tracker from configured (or default) lockout policy.
|
||||||
|
loginMaxAttempts, loginLockout := registry.LoginLockout()
|
||||||
|
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(loginMaxAttempts, loginLockout, 5*time.Minute)
|
||||||
|
|
||||||
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
authHandler := handlers.NewAuthHandler(queries, cfg.JWTSecret, loginAttemptTracker)
|
||||||
|
authHandler.SetSettings(registry)
|
||||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||||
|
systemSettingsHandler.SetSettings(registry)
|
||||||
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
sidecarHandler := handlers.NewSidecarHandler(queries, cfg)
|
||||||
|
sidecarHandler.SetSettings(registry)
|
||||||
libraryHandler := handlers.NewLibraryHandler(queries)
|
libraryHandler := handlers.NewLibraryHandler(queries)
|
||||||
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
deviceHandler := handlers.NewDeviceHandler(queries, cfg.JWTSecret, cfg)
|
||||||
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
deviceAuthMiddleware := middleware.NewDeviceAuthMiddleware(queries)
|
||||||
|
deviceAuthMiddleware.SetSettings(registry)
|
||||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||||
|
|
||||||
// Create WebSocket connection manager
|
// Create WebSocket connection manager
|
||||||
connManager := sync.NewConnectionManager()
|
connManager := sync.NewConnectionManager()
|
||||||
|
|
||||||
// Create sync queue processor
|
progressService := sync.NewProgressService(queries, connManager)
|
||||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
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
|
// Create library service
|
||||||
libraryService := services.NewLibraryService(queries)
|
libraryService := services.NewLibraryService(queries)
|
||||||
|
|
||||||
|
// Sync Go AllowedExtensions into DB so API clients see correct extensions
|
||||||
|
libraryService.SyncAllowedExtensions(context.Background())
|
||||||
|
|
||||||
// Create worker for background tasks
|
// Create worker for background tasks
|
||||||
worker := services.NewWorker(3, connManager)
|
workerCfg := registry.WorkerPoolConfig()
|
||||||
|
worker := services.NewWorkerWithConfig(workerCfg.Size, workerCfg.QueueCap, connManager)
|
||||||
services.WorkerInstance = worker
|
services.WorkerInstance = worker
|
||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
|
koreaderHandler.SetProgressService(progressService)
|
||||||
|
koreaderHandler.SetAnnotationService(annotationService)
|
||||||
|
koreaderHandler.SetLibraryService(libraryService)
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||||
|
|
||||||
// Create conversion service for EPUB→KEPUB conversion
|
|
||||||
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
conversionService := services.NewConversionService(queries, "/var/bookhoard/cache/kepub")
|
||||||
|
conversionService.SetSettings(registry)
|
||||||
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
opdsHandler := handlers.NewOPDSHandler(queries, libraryService, conversionService)
|
||||||
|
opdsHandler.SetSettings(registry)
|
||||||
|
|
||||||
// NEW: Create refactored handlers
|
|
||||||
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
collectionHandler := handlers.NewCollectionHandler(queries, libraryService, connManager)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries)
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||||
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
|
mediaHandler.SetProgressService(progressService)
|
||||||
|
mediaHandler.SetAnnotationService(annotationService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||||
|
|
||||||
@@ -131,6 +194,7 @@ func main() {
|
|||||||
Echo: e,
|
Echo: e,
|
||||||
Queries: queries,
|
Queries: queries,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
|
Settings: registry,
|
||||||
DBPool: dbPool,
|
DBPool: dbPool,
|
||||||
AuthHandler: authHandler,
|
AuthHandler: authHandler,
|
||||||
LibraryHandler: libraryHandler,
|
LibraryHandler: libraryHandler,
|
||||||
@@ -147,15 +211,19 @@ func main() {
|
|||||||
FiltersHandler: filtersHandler,
|
FiltersHandler: filtersHandler,
|
||||||
DashboardHandler: dashboardHandler,
|
DashboardHandler: dashboardHandler,
|
||||||
DashboardService: dashboardService,
|
DashboardService: dashboardService,
|
||||||
|
SeriesHandler: seriesHandler,
|
||||||
OPDSHandler: opdsHandler,
|
OPDSHandler: opdsHandler,
|
||||||
Worker: worker,
|
Worker: worker,
|
||||||
SystemSettingsHandler: systemSettingsHandler,
|
SystemSettingsHandler: systemSettingsHandler,
|
||||||
SidecarHandler: sidecarHandler,
|
SidecarHandler: sidecarHandler,
|
||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
|
ProgressService: progressService,
|
||||||
|
AnnotationService: annotationService,
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
JobsHandler: jobsHandler,
|
JobsHandler: jobsHandler,
|
||||||
LoginTracker: loginAttemptTracker,
|
LoginTracker: loginAttemptTracker,
|
||||||
|
LibraryService: libraryService,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register all routes and get ebook handler
|
// Register all routes and get ebook handler
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,7 +22,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats", nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -32,12 +35,15 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result handlers.ReadingStatsResponse
|
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.TotalBooksRead, 0)
|
||||||
assert.GreaterOrEqual(t, result.TotalPagesRead, 0)
|
assert.GreaterOrEqual(t, result.TotalPagesRead, 0)
|
||||||
@@ -45,15 +51,17 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetReadingStats_WithCustomDateRange", func(t *testing.T) {
|
t.Run("GetReadingStats_WithCustomDateRange", func(t *testing.T) {
|
||||||
startDate := time.Now().AddDate(0, -2, 0).Format("2006-01-02")
|
startDate := time.Now().AddDate(0, -2, 0).Format("01-02-2006")
|
||||||
endDate := time.Now().Format("2006-01-02")
|
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, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -64,7 +72,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -75,7 +85,9 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -86,12 +98,15 @@ func TestAnalyticsReadingStats(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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
|
// Should return zero values for empty history
|
||||||
assert.Equal(t, 0.0, result["total_books_read"])
|
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)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -119,19 +136,19 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result handlers.DeviceUsageResponse
|
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.NotNil(t, result.Devices)
|
||||||
assert.Equal(t, 0, len(result.Devices))
|
assert.Equal(t, 0, len(result.Devices))
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetDeviceUsage_WithAuth_WithDevices", func(t *testing.T) {
|
t.Run("GetDeviceUsage_WithAuth_WithDevices", func(t *testing.T) {
|
||||||
// First create a device
|
// First, create a device
|
||||||
deviceReq := map[string]interface{}{
|
deviceReq := map[string]interface{}{
|
||||||
"device_name": "Test Kobo",
|
"device_name": "Test Kobo",
|
||||||
"device_type": "kobo",
|
"device_type": "kobo",
|
||||||
@@ -144,7 +161,9 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(deviceReqHTTP)
|
resp, err := client.Do(deviceReqHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Now get device usage
|
// Now get device usage
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/device-usage", nil)
|
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)
|
resp, err = client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
devices, ok := result["devices"].([]interface{})
|
||||||
assert.True(t, ok)
|
assert.True(t, ok)
|
||||||
@@ -171,12 +193,15 @@ func TestAnalyticsDeviceUsage(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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")
|
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)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -214,12 +241,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result handlers.PopularBooksResponse
|
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)
|
assert.NotNil(t, result.Books)
|
||||||
// Default limit is 10, but may be fewer if no reading history
|
// 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)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
books := result["books"].([]interface{})
|
||||||
assert.True(t, len(books) <= 5)
|
assert.True(t, len(books) <= 5)
|
||||||
@@ -249,20 +282,23 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should default to 10 on invalid limit
|
// Should default to 10 on invalid limit
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
books := result["books"].([]interface{})
|
||||||
assert.True(t, len(books) <= 10)
|
assert.True(t, len(books) <= 10)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("GetPopularBooks_ResponseStructure", func(t *testing.T) {
|
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)
|
bookID := createTestMediaItemID(t, setup)
|
||||||
|
|
||||||
// Create reading history for the book
|
// Create reading history for the book
|
||||||
@@ -280,7 +316,9 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(historyHTTP)
|
resp, err := client.Do(historyHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Now get popular books
|
// Now get popular books
|
||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/popular-books", nil)
|
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)
|
resp, err = client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
books := result["books"].([]interface{})
|
||||||
|
|
||||||
@@ -315,12 +356,15 @@ func TestAnalyticsPopularBooks(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
books := result["books"].([]interface{})
|
||||||
// Should return empty array if no reading history
|
// Should return empty array if no reading history
|
||||||
@@ -334,21 +378,24 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("ReadingStats_FutureDateRange", func(t *testing.T) {
|
t.Run("ReadingStats_FutureDateRange", func(t *testing.T) {
|
||||||
startDate := time.Now().AddDate(0, 0, 7).Format("2006-01-02")
|
startDate := time.Now().AddDate(0, 0, 7).Format("01-02-2006")
|
||||||
endDate := time.Now().AddDate(0, 0, 14).Format("2006-01-02")
|
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, _ := http.NewRequest("GET", setup.Server.URL+"/api/analytics/reading-stats?start_date="+startDate+"&end_date="+endDate, nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should succeed but return empty stats
|
// Should succeed but return empty stats
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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"])
|
assert.Equal(t, 0.0, result["total_books_read"])
|
||||||
})
|
})
|
||||||
@@ -359,13 +406,16 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should handle limit=0 gracefully
|
// Should handle limit=0 gracefully
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
books := result["books"].([]interface{})
|
||||||
assert.Equal(t, 0, len(books))
|
assert.Equal(t, 0, len(books))
|
||||||
@@ -377,7 +427,9 @@ func TestAnalyticsEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should handle large limit
|
// Should handle large limit
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -27,7 +28,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -47,12 +50,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "matches")
|
||||||
assert.Contains(t, result, "action")
|
assert.Contains(t, result, "action")
|
||||||
@@ -67,7 +73,9 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -85,12 +93,15 @@ func TestBookMatchingQueryBooks(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{}
|
var matches []interface{}
|
||||||
if matchesIf, ok := result["matches"]; ok && matchesIf != nil {
|
if matchesIf, ok := result["matches"]; ok && matchesIf != nil {
|
||||||
@@ -124,7 +135,9 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -142,12 +155,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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["total"])
|
||||||
assert.Equal(t, 0.0, result["successful"])
|
assert.Equal(t, 0.0, result["successful"])
|
||||||
@@ -175,12 +191,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -221,12 +240,15 @@ func TestBookMatchingBulkLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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.Equal(t, 3.0, result["total"])
|
||||||
results := result["results"].([]interface{})
|
results := result["results"].([]interface{})
|
||||||
@@ -251,7 +273,9 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -267,12 +291,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "auto_linked")
|
||||||
assert.Contains(t, result, "results")
|
assert.Contains(t, result, "results")
|
||||||
@@ -292,12 +319,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "auto_linked")
|
||||||
})
|
})
|
||||||
@@ -315,12 +345,15 @@ func TestBookMatchingAutoLink(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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
|
// Should succeed even with no books to link
|
||||||
assert.Contains(t, result, "auto_linked")
|
assert.Contains(t, result, "auto_linked")
|
||||||
@@ -338,7 +371,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -350,7 +385,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -363,7 +400,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -378,7 +417,9 @@ func TestBookMatchingSuggestions(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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
|
// Even when book not found, we expect 404
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
@@ -396,7 +437,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -409,12 +452,15 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "device_id")
|
||||||
assert.Contains(t, result, "aliases")
|
assert.Contains(t, result, "aliases")
|
||||||
@@ -439,7 +485,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -462,7 +510,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -485,7 +535,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -505,7 +557,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -519,7 +573,9 @@ func TestBookMatchingDeviceFileAliases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -535,7 +591,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -547,12 +605,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "matches")
|
||||||
assert.Contains(t, result, "action")
|
assert.Contains(t, result, "action")
|
||||||
@@ -565,7 +626,9 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -577,12 +640,15 @@ func TestBookMatchingGetBookMatches(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "matches")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir})
|
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
@@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) {
|
|||||||
// Create scanner and configure it
|
// Create scanner and configure it
|
||||||
scanner := services.NewMediaScanner(setup.DB)
|
scanner := services.NewMediaScanner(setup.DB)
|
||||||
scanner.SetAdminID(adminID)
|
scanner.SetAdminID(adminID)
|
||||||
err = scanner.SetFolders([]string{tmpDir})
|
err = scanner.SetFolders([]string{tmpDir}, false)
|
||||||
require.NoError(t, err, "Failed to set scanner folders")
|
require.NoError(t, err, "Failed to set scanner folders")
|
||||||
|
|
||||||
// Scan library
|
// Scan library
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -44,7 +45,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -61,7 +64,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -85,12 +90,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -122,10 +130,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
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)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Now try to add invalid book IDs
|
// Now try to add invalid book IDs
|
||||||
@@ -145,12 +156,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
results := result["results"].([]interface{})
|
||||||
firstResult := results[0].(map[string]interface{})
|
firstResult := results[0].(map[string]interface{})
|
||||||
@@ -171,10 +185,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
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)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Create a book
|
// Create a book
|
||||||
@@ -197,12 +214,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -228,10 +248,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
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)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Create multiple books
|
// Create multiple books
|
||||||
@@ -256,12 +279,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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.Equal(t, 3.0, result["total"])
|
||||||
assert.True(t, result["added"].(float64) > 0)
|
assert.True(t, result["added"].(float64) > 0)
|
||||||
@@ -281,10 +307,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult1 map[string]interface{}
|
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)
|
collectionID1 := collectionResult1["id"].(string)
|
||||||
|
|
||||||
collectionReq2 := map[string]interface{}{
|
collectionReq2 := map[string]interface{}{
|
||||||
@@ -299,10 +328,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(collectionHTTP2)
|
resp, err = client.Do(collectionHTTP2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult2 map[string]interface{}
|
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)
|
collectionID2 := collectionResult2["id"].(string)
|
||||||
|
|
||||||
// Create books
|
// Create books
|
||||||
@@ -330,12 +362,15 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
assert.Equal(t, 3.0, result["total"])
|
assert.Equal(t, 3.0, result["total"])
|
||||||
@@ -355,10 +390,13 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(collectionHTTP)
|
resp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
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)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Create a book
|
// Create a book
|
||||||
@@ -381,7 +419,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err = client.Do(addHTTP)
|
resp, err = client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
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
|
// Try to add same book again - create new request with fresh body
|
||||||
addBody2, _ := json.Marshal(addReq)
|
addBody2, _ := json.Marshal(addReq)
|
||||||
@@ -391,7 +431,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp2, err := client.Do(addHTTP2)
|
resp2, err := client.Do(addHTTP2)
|
||||||
require.NoError(t, err)
|
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)
|
// Should handle duplicate gracefully (either succeed or return error)
|
||||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||||
@@ -405,7 +447,9 @@ func TestCollectionsBulkOperations(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ func TestPreviewCollection(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, rec.Code)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 0, len(items), "Empty rules should return no matched items")
|
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)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 2, len(items), "Should return exactly 2 manually selected books")
|
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)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
items := result["items"].([]interface{})
|
||||||
assert.Greater(t, len(items), 0, "Should return books matching the genre rule")
|
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)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
items := result["items"].([]interface{})
|
||||||
assert.Greater(t, len(items), 0, "Should return books from rules and manual selection")
|
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)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
items := result["items"].([]interface{})
|
||||||
assert.LessOrEqual(t, len(items), 2, "Should respect limit parameter")
|
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)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 1, len(items), "Should still return items when limit exceeds max")
|
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)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 1, len(items), "Limit 0 should default to 20 and still return matched items")
|
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)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 1, len(items), "Invalid book IDs should be skipped, valid ones included")
|
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)
|
assert.Equal(t, http.StatusOK, rec.Code)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
items := result["items"].([]interface{})
|
||||||
assert.Equal(t, 1, len(items), "Duplicate book IDs should result in unique items")
|
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)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -152,7 +154,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -169,7 +173,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -190,7 +196,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -210,7 +218,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -227,7 +237,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -244,7 +256,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -262,7 +276,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -280,7 +296,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -298,7 +316,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -316,7 +336,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -334,7 +356,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -353,7 +377,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -371,7 +397,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -389,7 +417,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -439,7 +469,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -469,7 +501,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -500,7 +534,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -526,7 +562,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -567,7 +605,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -594,7 +634,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/media/"+mediaUUID, nil)
|
req, _ := http.NewRequest("GET", setup.Server.URL+"/media/"+mediaUUID, nil)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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")
|
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)
|
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -629,7 +673,9 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
@@ -719,7 +765,9 @@ func TestComicMetadataDisplay_AllFieldsTogether(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
body := readBody(resp)
|
body := readBody(resp)
|
||||||
|
|||||||
+550
-257
@@ -1,289 +1,582 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/handlers"
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"io"
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConflictDetection_TriggeringConditions(t *testing.T) {
|
type conflictTestEnv struct {
|
||||||
t.Run("conflict detected when different devices sync within 5 minutes", func(t *testing.T) {
|
setup *TestServerSetup
|
||||||
conflictData := map[string]map[string]interface{}{
|
mediaID string
|
||||||
"koreader": {
|
userID pgtype.UUID
|
||||||
"source": "koreader",
|
mediaPGID pgtype.UUID
|
||||||
"timestamp": "2026-01-30T20:10:00Z",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"percentage": 0.45,
|
|
||||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
|
||||||
"chapter": 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"kobo": {
|
|
||||||
"source": "kobo",
|
|
||||||
"timestamp": "2026-01-30T20:05:00Z",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"percentage": 0.42,
|
|
||||||
"page": 89,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
diff := progressData["percentage"].(float64) - existingProgress["percentage"].(float64)
|
|
||||||
if diff < 0 {
|
|
||||||
diff = -diff
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Less(t, diff, 0.01, "Should not trigger conflict for small differences")
|
|
||||||
})
|
|
||||||
|
|
||||||
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"
|
|
||||||
|
|
||||||
var conflictDetected bool
|
|
||||||
if timestamp2 > timestamp1 {
|
|
||||||
conflictDetected = false
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.False(t, conflictDetected, "Should not trigger conflict for old syncs")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConflictResolution_ChoosingWinner(t *testing.T) {
|
func setupConflictTest(t *testing.T) *conflictTestEnv {
|
||||||
t.Run("resolve conflict by choosing koreader source", func(t *testing.T) {
|
t.Helper()
|
||||||
conflictID := uuid.New()
|
|
||||||
|
|
||||||
reqBody := map[string]interface{}{
|
setup := setupTestServer(t)
|
||||||
"winner": "koreader",
|
mediaID := createTestMediaItemID(t, setup)
|
||||||
"manual_data": nil,
|
|
||||||
"apply_to_all_future_conflicts": false,
|
|
||||||
"reason": "More recent progress",
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(reqBody)
|
ctx := context.Background()
|
||||||
assert.NoError(t, err)
|
user, err := setup.DB.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
mediaUUID, err := uuid.Parse(mediaID)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, "POST", req.Method)
|
return &conflictTestEnv{
|
||||||
assert.Contains(t, req.URL.Path, conflictID.String())
|
setup: setup,
|
||||||
assert.Contains(t, string(body), "koreader")
|
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)
|
||||||
|
|
||||||
t.Run("resolve conflict with manual merge data", func(t *testing.T) {
|
return conflict
|
||||||
conflictID := uuid.New()
|
}
|
||||||
|
|
||||||
manualData := map[string]interface{}{
|
func makeConflictData(koreaderPct, koboPct float64) map[string]interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"koreader": map[string]interface{}{
|
||||||
|
"source": "koreader",
|
||||||
|
"timestamp": time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"percentage": koreaderPct,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": map[string]interface{}{
|
||||||
|
"source": "kobo",
|
||||||
|
"timestamp": time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"percentage": koboPct,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictList_UnresolvedCount(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", 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TestConflictGet_NotFound(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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_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,
|
"percentage": 0.43,
|
||||||
"epubcfi": "epubcfi(/6/4/2:20)",
|
"epubcfi": "epubcfi(/6/4/2:20)",
|
||||||
"chapter": 3,
|
"chapter": 3,
|
||||||
"page": 90,
|
},
|
||||||
}
|
"apply_to_all_future_conflicts": false,
|
||||||
|
"reason": "Custom merged position",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
reqBody := map[string]interface{}{
|
req, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/"+conflictID+"/resolve", bytes.NewBuffer(body))
|
||||||
"winner": "manual",
|
req.Header.Set("Content-Type", "application/json")
|
||||||
"manual_data": manualData,
|
req.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
"apply_to_all_future_conflicts": false,
|
|
||||||
"reason": "Custom merged position",
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(reqBody)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictResolve_AlreadyResolved(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",
|
||||||
|
"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 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConflictResolve_NotFound(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
resolveReq := map[string]interface{}{
|
||||||
|
"winner": "koreader",
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(resolveReq)
|
||||||
|
|
||||||
|
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("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("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")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
assert.Contains(t, string(body), "manual")
|
require.NoError(t, err)
|
||||||
assert.Contains(t, string(body), "0.43")
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error when winner is manual but no manual_data provided", func(t *testing.T) {
|
t.Run("delete conflict requires auth", func(t *testing.T) {
|
||||||
conflictID := uuid.New()
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
reqBody := map[string]interface{}{
|
t.Run("dismiss-all requires auth", func(t *testing.T) {
|
||||||
"winner": "manual",
|
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/dismiss-all", nil)
|
||||||
"manual_data": nil,
|
resp, err := client.Do(req)
|
||||||
"apply_to_all_future_conflicts": false,
|
require.NoError(t, err)
|
||||||
"reason": "Test",
|
defer func(Body io.ReadCloser) {
|
||||||
}
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
body, err := json.Marshal(reqBody)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/api/conflicts/"+conflictID.String()+"/resolve", bytes.NewReader(body))
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
|
|
||||||
assert.Contains(t, string(body), "manual")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
})
|
|
||||||
|
|
||||||
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")
|
|
||||||
})
|
|
||||||
|
|
||||||
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")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := json.Marshal(conflictResponse)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
var parsed map[string]interface{}
|
|
||||||
err = json.Unmarshal(body, &parsed)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
|
|
||||||
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")
|
|
||||||
})
|
|
||||||
|
|
||||||
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("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("conflict resolved notification", func(t *testing.T) {
|
|
||||||
conflictID := uuid.New()
|
|
||||||
|
|
||||||
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"])
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -12,414 +13,506 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestConflictsBulkOperations tests bulk conflict resolution operations
|
func TestBulkResolve_MostRecentStrategy(t *testing.T) {
|
||||||
func TestConflictsBulkOperations(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)
|
setup := setupTestServer(t)
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_WithoutAuth", func(t *testing.T) {
|
req := handlers.BulkResolveRequest{
|
||||||
req := handlers.BulkResolveRequest{
|
ConflictIDs: []string{uuid.New().String()},
|
||||||
ConflictIDs: []string{uuid.New().String()},
|
Strategy: "most_recent",
|
||||||
Strategy: "most_recent",
|
}
|
||||||
}
|
body, _ := json.Marshal(req)
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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)
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_EmptyConflictIDs", func(t *testing.T) {
|
var result handlers.BulkResolveResponse
|
||||||
req := handlers.BulkResolveRequest{
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
ConflictIDs: []string{},
|
require.NoError(t, err)
|
||||||
Strategy: "most_recent",
|
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
assert.Equal(t, 1, result.Total)
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
assert.Equal(t, 0, result.Success)
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
assert.Equal(t, 1, result.Failed)
|
||||||
|
assert.Contains(t, result.Results[0].Error, "conflict not found")
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkResolveConflicts_InvalidConflictID", func(t *testing.T) {
|
|
||||||
req := handlers.BulkResolveRequest{
|
|
||||||
ConflictIDs: []string{"invalid-uuid"},
|
|
||||||
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")
|
|
||||||
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, 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)
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
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_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()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConflictsBulkDismiss tests bulk dismiss operations
|
func TestBulkResolve_EmptyConflictIDs(t *testing.T) {
|
||||||
func TestConflictsBulkDismiss(t *testing.T) {
|
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("BulkDismissConflicts_WithoutAuth", func(t *testing.T) {
|
req := handlers.BulkResolveRequest{
|
||||||
req := map[string]interface{}{
|
ConflictIDs: []string{},
|
||||||
"conflict_ids": []string{uuid.New().String()},
|
Strategy: "most_recent",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkDismissConflicts_EmptyConflictIDs", func(t *testing.T) {
|
|
||||||
req := map[string]interface{}{
|
|
||||||
"conflict_ids": []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.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)
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConflictsBulkEscalate tests bulk escalate operations
|
func TestBulkResolve_InvalidConflictID(t *testing.T) {
|
||||||
// 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) {
|
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("BulkEscalateConflicts_WithoutAuth", func(t *testing.T) {
|
req := handlers.BulkResolveRequest{
|
||||||
req := map[string]interface{}{
|
ConflictIDs: []string{"not-a-uuid"},
|
||||||
"conflict_ids": []string{uuid.New().String()},
|
Strategy: "most_recent",
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(req)
|
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-resolve", bytes.NewBuffer(body))
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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)
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("BulkEscalateConflicts_EmptyConflictIDs", func(t *testing.T) {
|
var result handlers.BulkResolveResponse
|
||||||
req := map[string]interface{}{
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
"conflict_ids": []string{},
|
require.NoError(t, err)
|
||||||
}
|
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
assert.Equal(t, 1, result.Failed)
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
assert.Contains(t, result.Results[0].Error, "invalid conflict ID")
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
}
|
||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
func TestBulkResolve_InvalidRequestBody(t *testing.T) {
|
||||||
require.NoError(t, err)
|
setup := setupTestServer(t)
|
||||||
defer resp.Body.Close()
|
client := &http.Client{}
|
||||||
|
|
||||||
// Allow queue processor to process the item before querying for conflicts
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer([]byte("invalid json")))
|
||||||
time.Sleep(3 * time.Second)
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
var result map[string]interface{}
|
require.NoError(t, err)
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
assert.Contains(t, result, "results")
|
}(resp.Body)
|
||||||
assert.Contains(t, result, "total")
|
|
||||||
assert.Contains(t, result, "failed")
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
results := result["results"].([]interface{})
|
|
||||||
firstResult := results[0].(map[string]interface{})
|
func TestBulkResolve_RequiresAuth(t *testing.T) {
|
||||||
assert.Equal(t, "error", firstResult["status"])
|
setup := setupTestServer(t)
|
||||||
})
|
client := &http.Client{}
|
||||||
|
|
||||||
t.Run("BulkEscalateConflicts_MultipleConflicts", func(t *testing.T) {
|
req := handlers.BulkResolveRequest{
|
||||||
conflictIDs := []string{
|
ConflictIDs: []string{uuid.New().String()},
|
||||||
uuid.New().String(),
|
Strategy: "most_recent",
|
||||||
uuid.New().String(),
|
}
|
||||||
}
|
body, _ := json.Marshal(req)
|
||||||
req := map[string]interface{}{
|
|
||||||
"conflict_ids": conflictIDs,
|
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-resolve", bytes.NewBuffer(body))
|
||||||
}
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
body, _ := json.Marshal(req)
|
|
||||||
|
resp, err := client.Do(httpReq)
|
||||||
httpReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/conflicts/bulk-escalate", bytes.NewBuffer(body))
|
require.NoError(t, err)
|
||||||
httpReq.Header.Set("Content-Type", "application/json")
|
defer func(Body io.ReadCloser) {
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
resp, err := client.Do(httpReq)
|
|
||||||
require.NoError(t, err)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
defer resp.Body.Close()
|
}
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
func TestBulkDismiss_RealConflicts(t *testing.T) {
|
||||||
|
env := setupConflictTest(t)
|
||||||
var result map[string]interface{}
|
client := &http.Client{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
|
||||||
|
conflict1 := createTestConflict(t, env, makeConflictData(0.45, 0.42))
|
||||||
assert.Contains(t, result, "results")
|
conflict2 := createTestConflict(t, env, makeConflictData(0.60, 0.55))
|
||||||
assert.Equal(t, float64(2), result["total"])
|
|
||||||
|
id1 := uuid.UUID(conflict1.ID.Bytes).String()
|
||||||
// NEW: Database verification - verify conflicts were escalated
|
id2 := uuid.UUID(conflict2.ID.Bytes).String()
|
||||||
for _, conflictID := range conflictIDs {
|
|
||||||
pgID, err := uuid.Parse(conflictID)
|
req := map[string]interface{}{
|
||||||
if err != nil {
|
"conflict_ids": []string{id1, id2},
|
||||||
continue // Skip invalid UUIDs
|
}
|
||||||
}
|
body, _ := json.Marshal(req)
|
||||||
|
|
||||||
conflict, err := setup.DB.GetSyncConflict(context.Background(), pgtype.UUID{Bytes: [16]byte(pgID), Valid: true})
|
httpReq, _ := http.NewRequest("POST", env.setup.Server.URL+"/api/conflicts/bulk-dismiss", bytes.NewBuffer(body))
|
||||||
if err == nil {
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
// If conflict exists, verify it was escalated
|
httpReq.Header.Set("Authorization", "Bearer "+env.setup.Token)
|
||||||
assert.Equal(t, "escalated", conflict.ResolutionStatus.String, "Conflict should be escalated")
|
|
||||||
}
|
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{}
|
||||||
|
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"conflict_ids": []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 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(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{}
|
||||||
|
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"conflict_ids": []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 func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDismiss_InvalidRequestBody(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
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 func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDismiss_RequiresAuth(t *testing.T) {
|
||||||
|
setup := setupTestServer(t)
|
||||||
|
client := &http.Client{}
|
||||||
|
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"conflict_ids": []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")
|
||||||
|
|
||||||
|
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 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", 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, 1, result.Success)
|
||||||
|
assert.Equal(t, 1, result.Failed)
|
||||||
}
|
}
|
||||||
*/
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -40,7 +41,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
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)
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -50,7 +53,7 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
|||||||
|
|
||||||
sections, ok := response["sections"].([]interface{})
|
sections, ok := response["sections"].([]interface{})
|
||||||
require.True(s.T(), ok, "sections should be an array")
|
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
|
// Verify response structure
|
||||||
sectionMap := make(map[string]map[string]interface{})
|
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-added")
|
||||||
assert.Contains(s.T(), sectionMap, "recently-read")
|
assert.Contains(s.T(), sectionMap, "recently-read")
|
||||||
assert.Contains(s.T(), sectionMap, "not-started")
|
assert.Contains(s.T(), sectionMap, "not-started")
|
||||||
|
assert.Contains(s.T(), sectionMap, "continue-series")
|
||||||
|
|
||||||
// Verify continue-reading is a system collection
|
// Verify continue-reading is a system collection
|
||||||
continueReading := sectionMap["continue-reading"]
|
continueReading := sectionMap["continue-reading"]
|
||||||
@@ -87,7 +91,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
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)
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -101,7 +107,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
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)
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -112,7 +120,9 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_Unauthorized() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
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)
|
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -136,7 +146,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Success() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
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)
|
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -164,7 +176,9 @@ func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_Unauthorized() {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
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)
|
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -184,7 +198,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_InvalidName(
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
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)
|
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -201,7 +217,9 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
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)
|
assert.Equal(s.T(), http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -209,7 +227,7 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_Unauthorized
|
|||||||
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames() {
|
func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames() {
|
||||||
token := s.setup.Token
|
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 {
|
for _, collName := range validCollections {
|
||||||
s.T().Run(collName, func(t *testing.T) {
|
s.T().Run(collName, func(t *testing.T) {
|
||||||
@@ -224,14 +242,16 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
|
|||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(s.T(), err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
require.NoError(t, err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
assert.Contains(t, response, "message")
|
assert.Contains(t, response, "message")
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -68,7 +69,9 @@ func TestUpdateUserMaxDevices(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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")
|
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected status code")
|
||||||
|
|
||||||
@@ -134,7 +137,9 @@ func TestUpdateUserMaxDevicesValidation(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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")
|
assert.Equal(t, tt.expectedStatus, resp.StatusCode, "expected validation error")
|
||||||
})
|
})
|
||||||
@@ -166,7 +171,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -190,7 +197,9 @@ func TestUpdateUserMaxDevicesAuth(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -221,7 +230,9 @@ func TestUpdateUserMaxDevicesNonExistentUser(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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
|
// Should return 500 or 404 depending on implementation
|
||||||
assert.True(t, resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusNotFound)
|
assert.True(t, resp.StatusCode == http.StatusInternalServerError || resp.StatusCode == http.StatusNotFound)
|
||||||
@@ -250,7 +261,9 @@ func TestUpdateUserMaxDevicesMissingUserID(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -271,12 +284,15 @@ func TestListUsersIncludesMaxDevices(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var users []map[string]interface{}
|
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
|
// Verify max_devices and device_count fields are present in response
|
||||||
if len(users) > 0 {
|
if len(users) > 0 {
|
||||||
@@ -306,7 +322,7 @@ func createAdminUser(t *testing.T, ts *httptest.Server, token string) {
|
|||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, _ := client.Do(req)
|
resp, _ := client.Do(req)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to create test user for max devices tests
|
// 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{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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)
|
// Check if user creation succeeded or already exists (409 Conflict)
|
||||||
if resp.StatusCode == http.StatusConflict {
|
if resp.StatusCode == http.StatusConflict {
|
||||||
@@ -343,10 +361,13 @@ func createTestUserForMaxDevices(t *testing.T, ts *httptest.Server, adminToken s
|
|||||||
|
|
||||||
loginResp, err := client.Do(loginReq)
|
loginResp, err := client.Do(loginReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
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
|
// Extract user_id from JWT or response
|
||||||
// The access_token contains the user ID in the JWT claims
|
// 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{}
|
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
|
// Check if user creation was successful
|
||||||
if result["user"] == nil {
|
if result["user"] == nil {
|
||||||
@@ -422,10 +444,13 @@ func getAdminToken(t *testing.T, ts *httptest.Server, userID uuid.UUID) string {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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
|
// Safe type assertion with check
|
||||||
if accessToken, ok := result["access_token"].(string); ok {
|
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{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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
|
// Safe type assertion with check
|
||||||
if accessToken, ok := result["access_token"].(string); ok {
|
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")
|
device := setup.CreateDevice(t, "Test Device", "koreader", "test-device-123")
|
||||||
|
|
||||||
// Update device
|
// Update device
|
||||||
|
syncEnabled := false
|
||||||
|
syncFreq := int32(10)
|
||||||
updateRequest := handlers.DeviceUpdateRequest{
|
updateRequest := handlers.DeviceUpdateRequest{
|
||||||
DeviceName: "Updated Device Name",
|
DeviceName: "Updated Device Name",
|
||||||
SyncEnabled: new(false),
|
SyncEnabled: &syncEnabled,
|
||||||
SyncFrequencyMinutes: new(int32(10)),
|
SyncFrequencyMinutes: &syncFreq,
|
||||||
}
|
}
|
||||||
updateBody, _ := json.Marshal(updateRequest)
|
updateBody, _ := json.Marshal(updateRequest)
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"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)
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters?resource_type=media-items", nil)
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -30,12 +33,15 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var filters []map[string]interface{}
|
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))
|
assert.Equal(t, 0, len(filters))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -56,12 +62,15 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var filter map[string]interface{}
|
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, "My Sci-Fi Books", filter["name"])
|
||||||
assert.Equal(t, "media-items", filter["resource_type"])
|
assert.Equal(t, "media-items", filter["resource_type"])
|
||||||
assert.NotEmpty(t, filter["id"])
|
assert.NotEmpty(t, filter["id"])
|
||||||
@@ -84,7 +93,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp1, err := client.Do(httpReq1)
|
resp1, err := client.Do(httpReq1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp1.Body.Close()
|
_ = resp1.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp1.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp1.StatusCode)
|
||||||
|
|
||||||
@@ -96,7 +105,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp2, err := client.Do(httpReq2)
|
resp2, err := client.Do(httpReq2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp2.Body.Close()
|
_ = resp2.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusConflict, resp2.StatusCode)
|
assert.Equal(t, http.StatusConflict, resp2.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -116,12 +125,15 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
createResp, err := client.Do(createHTTP)
|
createResp, err := client.Do(createHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||||
|
|
||||||
var createdFilter map[string]interface{}
|
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)
|
filterID := createdFilter["id"].(string)
|
||||||
|
|
||||||
// Update filter
|
// Update filter
|
||||||
@@ -138,12 +150,15 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
updateResp, err := client.Do(updateHTTP)
|
updateResp, err := client.Do(updateHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer updateResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(updateResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
|
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||||||
|
|
||||||
var updatedFilter map[string]interface{}
|
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"])
|
assert.Equal(t, "Updated Name", updatedFilter["name"])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -162,10 +177,13 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
createResp, err := client.Do(createHTTP)
|
createResp, err := client.Do(createHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
var createdFilter map[string]interface{}
|
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)
|
filterID := createdFilter["id"].(string)
|
||||||
|
|
||||||
// Delete filter
|
// Delete filter
|
||||||
@@ -174,7 +192,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
deleteResp, err := client.Do(deleteHTTP)
|
deleteResp, err := client.Do(deleteHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
deleteResp.Body.Close()
|
_ = deleteResp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNoContent, deleteResp.StatusCode)
|
assert.Equal(t, http.StatusNoContent, deleteResp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -198,10 +216,13 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
createResp, err := client.Do(createHTTP)
|
createResp, err := client.Do(createHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
var createdFilter map[string]interface{}
|
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)
|
filterID := createdFilter["id"].(string)
|
||||||
|
|
||||||
// Admin user tries to delete regular user's filter
|
// Admin user tries to delete regular user's filter
|
||||||
@@ -210,7 +231,7 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
deleteResp, err := client.Do(deleteHTTP)
|
deleteResp, err := client.Do(deleteHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
deleteResp.Body.Close()
|
_ = deleteResp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, deleteResp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, deleteResp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -230,12 +251,15 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
createResp, err := client.Do(createReq)
|
createResp, err := client.Do(createReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
assert.Equal(t, http.StatusCreated, createResp.StatusCode)
|
||||||
|
|
||||||
var createdFilter map[string]interface{}
|
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)
|
filterID := createdFilter["id"].(string)
|
||||||
|
|
||||||
// Now retrieve the filter by ID
|
// Now retrieve the filter by ID
|
||||||
@@ -244,12 +268,15 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
getResp, err := client.Do(getReq)
|
getResp, err := client.Do(getReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer getResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(getResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
assert.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||||
|
|
||||||
var retrievedFilter map[string]interface{}
|
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, "Test Filter", retrievedFilter["name"])
|
||||||
assert.Equal(t, "media-items", retrievedFilter["resource_type"])
|
assert.Equal(t, "media-items", retrievedFilter["resource_type"])
|
||||||
assert.Equal(t, filterID, retrievedFilter["id"])
|
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)
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/550e8400-e29b-41d4-a716-446655440000", nil)
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -271,7 +300,9 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -283,7 +314,9 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -301,16 +334,21 @@ func TestSavedFilters(t *testing.T) {
|
|||||||
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
createReq.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||||
createResp, err := client.Do(createReq)
|
createResp, err := client.Do(createReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
var createdFilter map[string]interface{}
|
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)
|
filterID := createdFilter["id"].(string)
|
||||||
// Try to access with regular user (setup.RegularToken)
|
// Try to access with regular user (setup.RegularToken)
|
||||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||||
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||||
getResp, err := client.Do(httpReq)
|
getResp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer getResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(getResp.Body)
|
||||||
// Should return 404 (not 403 - hide existence)
|
// Should return 404 (not 403 - hide existence)
|
||||||
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
|
assert.Equal(t, http.StatusNotFound, getResp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -31,10 +32,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
libResp, err := client.Do(libReq)
|
libResp, err := client.Do(libReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer libResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(libResp.Body)
|
||||||
require.Equal(t, http.StatusCreated, libResp.StatusCode)
|
require.Equal(t, http.StatusCreated, libResp.StatusCode)
|
||||||
var libResult map[string]interface{}
|
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)
|
libraryID := libResult["id"].(string)
|
||||||
// Add folder to library
|
// Add folder to library
|
||||||
folderURL := fmt.Sprintf("%s/api/libraries/%s/folders", setup.Server.URL, libraryID)
|
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)
|
folderHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
folderResp, err := client.Do(folderHTTPReq)
|
folderResp, err := client.Do(folderHTTPReq)
|
||||||
require.NoError(t, err)
|
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")
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder should be added to library")
|
||||||
// Create 20 test files simultaneously
|
// Create 20 test files simultaneously
|
||||||
for i := 0; i < 20; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
@@ -61,10 +67,13 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
|||||||
scanHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
scanHTTPReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
scanResp, err := client.Do(scanHTTPReq)
|
scanResp, err := client.Do(scanHTTPReq)
|
||||||
require.NoError(t, err)
|
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")
|
require.Equal(t, http.StatusAccepted, scanResp.StatusCode, "Scan should be accepted")
|
||||||
var scanResponse map[string]interface{}
|
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)
|
jobID, ok := scanResponse["job_id"].(string)
|
||||||
require.True(t, ok, "job_id should be string")
|
require.True(t, ok, "job_id should be string")
|
||||||
require.NotEmpty(t, jobID, "job_id should not be empty")
|
require.NotEmpty(t, jobID, "job_id should not be empty")
|
||||||
@@ -81,16 +90,17 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
if statusResp.StatusCode == http.StatusNotFound {
|
if statusResp.StatusCode == http.StatusNotFound {
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
break // Job completed
|
break // Job completed
|
||||||
}
|
}
|
||||||
|
|
||||||
var status map[string]interface{}
|
var status map[string]interface{}
|
||||||
json.NewDecoder(statusResp.Body).Decode(&status)
|
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||||
statusResp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = statusResp.Body.Close()
|
||||||
|
|
||||||
if status["status"] == "completed" || status["status"] == "failed" {
|
if status["status"] == "completed" || status["status"] == "failed" {
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,9 +109,12 @@ func TestFSNotify_BulkFileDetection(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
itemsResp, err := client.Do(req)
|
itemsResp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer itemsResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(itemsResp.Body)
|
||||||
var itemsResult map[string]interface{}
|
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{})
|
items, ok := itemsResult["data"].([]interface{})
|
||||||
if !ok || items == nil {
|
if !ok || items == nil {
|
||||||
items = []interface{}{} // Handle nil or wrong type
|
items = []interface{}{} // Handle nil or wrong type
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -33,7 +34,9 @@ func TestJobsHandler_CreateJob(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
require.Equal(t, http.StatusAccepted, resp.StatusCode)
|
||||||
|
|
||||||
@@ -65,7 +68,9 @@ func TestJobsHandler_CreateJob_InvalidType(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -88,7 +93,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
createResp, err := client.Do(createReq)
|
createResp, err := client.Do(createReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
var createResponse map[string]interface{}
|
var createResponse map[string]interface{}
|
||||||
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
||||||
@@ -103,7 +110,9 @@ func TestJobsHandler_GetJobStatus(t *testing.T) {
|
|||||||
|
|
||||||
getResp, err := client.Do(getReq)
|
getResp, err := client.Do(getReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer getResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(getResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
require.Equal(t, http.StatusOK, getResp.StatusCode)
|
||||||
|
|
||||||
@@ -128,7 +137,9 @@ func TestJobsHandler_GetJobStatus_NotFound(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -151,7 +162,9 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
createResp, err := client.Do(createReq)
|
createResp, err := client.Do(createReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer createResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(createResp.Body)
|
||||||
|
|
||||||
var createResponse map[string]interface{}
|
var createResponse map[string]interface{}
|
||||||
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
err = json.NewDecoder(createResp.Body).Decode(&createResponse)
|
||||||
@@ -172,7 +185,7 @@ func TestJobsHandler_CreateAndTrackJob(t *testing.T) {
|
|||||||
|
|
||||||
var statusResponse map[string]interface{}
|
var statusResponse map[string]interface{}
|
||||||
err = json.NewDecoder(getResp.Body).Decode(&statusResponse)
|
err = json.NewDecoder(getResp.Body).Decode(&statusResponse)
|
||||||
getResp.Body.Close()
|
_ = getResp.Body.Close()
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
if statusResponse["status"] != nil {
|
if statusResponse["status"] != nil {
|
||||||
@@ -202,7 +215,9 @@ func TestJobsHandler_CreateJob_Unauthorized(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
@@ -218,7 +233,9 @@ func TestJobsHandler_GetJobStatus_Unauthorized(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -37,7 +38,9 @@ func TestKoboInitialization(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -65,7 +68,9 @@ func TestKoboLibrarySync(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -129,11 +134,14 @@ func TestKoboMarkupSync(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var result map[string]interface{}
|
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")
|
assert.Contains(t, result, "Status")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -182,11 +190,14 @@ func TestKoboBookmarkSync(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var result map[string]interface{}
|
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")
|
assert.Contains(t, result, "Status")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -224,11 +235,14 @@ func TestKoboAnalyticsGettests(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var result map[string]interface{}
|
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")
|
assert.Contains(t, result, "Status")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,13 +182,13 @@ func TestLibraryTypesResponse(t *testing.T) {
|
|||||||
"id": "test-id-2",
|
"id": "test-id-2",
|
||||||
"name": "comics",
|
"name": "comics",
|
||||||
"description": "Comic book archives and image formats",
|
"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",
|
"id": "test-id-3",
|
||||||
"name": "manga",
|
"name": "manga",
|
||||||
"description": "Manga files including archives and image folders",
|
"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(),
|
"id": uuid.New().String(),
|
||||||
"name": "comics",
|
"name": "comics",
|
||||||
"description": "Comic book archives and image formats",
|
"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)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -31,7 +32,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
|
|
||||||
@@ -58,7 +61,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -76,12 +81,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -106,12 +114,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
assert.Equal(t, 3.0, result["total"])
|
assert.Equal(t, 3.0, result["total"])
|
||||||
@@ -130,7 +141,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -154,7 +167,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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.StatusUnauthorized, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -179,7 +194,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -204,12 +221,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -246,12 +266,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
assert.Equal(t, 2.0, result["total"])
|
assert.Equal(t, 2.0, result["total"])
|
||||||
@@ -304,12 +327,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
assert.Contains(t, result, "total")
|
assert.Contains(t, result, "total")
|
||||||
@@ -352,12 +378,15 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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, "results")
|
||||||
})
|
})
|
||||||
@@ -371,7 +400,9 @@ func TestMediaBulkOperations(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -31,12 +32,15 @@ func createTestLibrary(t *testing.T, ts *httptest.Server, token, name string) st
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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)
|
return result["id"].(string)
|
||||||
}
|
}
|
||||||
@@ -164,7 +168,9 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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
|
// Check if this is an invalid ISBN case that should return 422
|
||||||
if tc.expected == "" && (tc.input == "---" || tc.input == " ") {
|
if tc.expected == "" && (tc.input == "---" || tc.input == " ") {
|
||||||
@@ -174,7 +180,8 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var response map[string]interface{}
|
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
|
// For valid ISBN responses, verify normalization worked correctly
|
||||||
if resp.StatusCode == http.StatusCreated {
|
if resp.StatusCode == http.StatusCreated {
|
||||||
@@ -209,7 +216,9 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -232,10 +241,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var response map[string]interface{}
|
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, http.StatusCreated, resp.StatusCode)
|
||||||
assert.Equal(t, "9780306406157", response["isbn"])
|
assert.Equal(t, "9780306406157", response["isbn"])
|
||||||
@@ -259,10 +271,13 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var response map[string]interface{}
|
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, http.StatusCreated, resp.StatusCode)
|
||||||
assert.Equal(t, "9780596009652", response["isbn"])
|
assert.Equal(t, "9780596009652", response["isbn"])
|
||||||
@@ -296,7 +311,7 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small delay to allow database to commit before pagination queries
|
// Small delay to allow database to commit before pagination queries
|
||||||
@@ -309,12 +324,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
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{})
|
data := response["data"].([]interface{})
|
||||||
|
|
||||||
// Should get 2 items
|
// Should get 2 items
|
||||||
@@ -328,12 +346,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
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{})
|
data := response["data"].([]interface{})
|
||||||
|
|
||||||
// Should get 2 items starting from offset 2
|
// Should get 2 items starting from offset 2
|
||||||
@@ -347,7 +368,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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
|
// Should return 400 Bad Request or handle it gracefully
|
||||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -360,7 +383,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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
|
// Should return 400 Bad Request or handle it gracefully
|
||||||
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
assert.NotEqual(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -373,7 +398,9 @@ func TestMediaItemsPagination(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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
|
// Should be capped at maximum or return error
|
||||||
// The application uses maxPaginationLimit = 1000
|
// The application uses maxPaginationLimit = 1000
|
||||||
@@ -404,7 +431,9 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should fail - library_id is required
|
// Should fail - library_id is required
|
||||||
assert.NotEqual(t, http.StatusCreated, resp.StatusCode)
|
assert.NotEqual(t, http.StatusCreated, resp.StatusCode)
|
||||||
@@ -431,12 +460,15 @@ func TestMediaItemLibraryRequirement(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
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
|
// VerifyISBN was normalized
|
||||||
assert.Equal(t, "9780306406157", response["isbn"])
|
assert.Equal(t, "9780306406157", response["isbn"])
|
||||||
@@ -472,8 +504,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
var createResponse map[string]interface{}
|
var createResponse map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&createResponse)
|
err = json.NewDecoder(resp.Body).Decode(&createResponse)
|
||||||
resp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
mediaItemID := createResponse["id"].(string)
|
mediaItemID := createResponse["id"].(string)
|
||||||
|
|
||||||
@@ -491,7 +524,9 @@ func TestUpdateMediaItemISBN(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
|
assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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
|
// OPDS endpoints require device authentication via devices.auth_token
|
||||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||||
@@ -37,7 +39,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return 400 for invalid UUID
|
// Should return 400 for invalid UUID
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
@@ -53,7 +57,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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)
|
// Should return 200 with catalog (even if empty)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -69,7 +75,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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)
|
// Should return 200 with catalog (even if empty)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -80,7 +88,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -95,7 +105,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return 200 (even if empty results)
|
// Should return 200 (even if empty results)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -106,7 +118,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -120,7 +134,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return navigation or 404
|
// Should return navigation or 404
|
||||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
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)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -142,7 +160,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -156,7 +176,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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
|
// May return 404 if device/book not linked, or 500 for file not found
|
||||||
// Should not return 400 (invalid IDs)
|
// Should not return 400 (invalid IDs)
|
||||||
@@ -168,7 +190,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -178,7 +202,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -192,7 +218,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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
|
// May return 404 if no cover, but not 400
|
||||||
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
@@ -204,7 +232,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -218,7 +248,9 @@ func TestOPDSEndpoints(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return formats list or 404
|
// Should return formats list or 404
|
||||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusNotFound)
|
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)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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)
|
// Should attempt conversion (may fail if file doesn't exist)
|
||||||
// Important: Should not return 400 for invalid IDs
|
// Important: Should not return 400 for invalid IDs
|
||||||
@@ -262,7 +296,9 @@ func TestOPDSConversion(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should attempt to download original format
|
// Should attempt to download original format
|
||||||
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
assert.NotEqual(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
@@ -278,7 +314,9 @@ func TestOPDSConversion(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
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)
|
// Should handle gracefully (either 400 for unsupported format or 404/500)
|
||||||
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600)
|
assert.True(t, resp.StatusCode >= 400 && resp.StatusCode < 600)
|
||||||
@@ -300,7 +338,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should return empty catalog, not error
|
// Should return empty catalog, not error
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -316,7 +356,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should handle special characters
|
// Should handle special characters
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
@@ -331,7 +373,9 @@ func TestOPDSEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should handle empty query
|
// Should handle empty query
|
||||||
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500)
|
assert.True(t, resp.StatusCode >= 200 && resp.StatusCode < 500)
|
||||||
@@ -371,7 +415,9 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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)
|
t.Logf("OPDS Search Status: %d", resp.StatusCode)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -108,8 +109,8 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Empty UUID",
|
name: "Empty UUID",
|
||||||
libraryID: "",
|
libraryID: "",
|
||||||
expectedStatus: http.StatusNotFound,
|
expectedStatus: http.StatusBadRequest,
|
||||||
description: "Should return 404 for empty ID",
|
description: "Should return 400 for empty ID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "UUID with extra path traversal",
|
name: "UUID with extra path traversal",
|
||||||
@@ -139,7 +140,7 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
@@ -284,8 +285,8 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "Empty UUID",
|
name: "Empty UUID",
|
||||||
libraryID: "",
|
libraryID: "",
|
||||||
expectedStatus: http.StatusNotFound,
|
expectedStatus: http.StatusBadRequest,
|
||||||
description: "Should return 404 for empty ID",
|
description: "Should return 400 for empty ID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "UUID with extra path traversal",
|
name: "UUID with extra path traversal",
|
||||||
@@ -315,7 +316,7 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
|
|||||||
|
|
||||||
for _, tc := range testCases {
|
for _, tc := range testCases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
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)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
@@ -388,7 +389,8 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
|||||||
setup.Server.Config.Handler.ServeHTTP(rec1, req1)
|
setup.Server.Config.Handler.ServeHTTP(rec1, req1)
|
||||||
|
|
||||||
var stats1 map[string]interface{}
|
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
|
// Get stats for library 2
|
||||||
req2 := httptest.NewRequest("GET", "/api/libraries/"+library2ID+"/issues/stats", nil)
|
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)
|
setup.Server.Config.Handler.ServeHTTP(rec2, req2)
|
||||||
|
|
||||||
var stats2 map[string]interface{}
|
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
|
// Both should have zero counts
|
||||||
assert.Equal(t, float64(0), stats1["error_count"])
|
assert.Equal(t, float64(0), stats1["error_count"])
|
||||||
@@ -424,7 +427,6 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
|
|||||||
{"Ebooks library", "ebooks"},
|
{"Ebooks library", "ebooks"},
|
||||||
{"Comics library", "comics"},
|
{"Comics library", "comics"},
|
||||||
{"Manga library", "manga"},
|
{"Manga library", "manga"},
|
||||||
{"Audiobooks library", "audiobooks"},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, lt := range libraryTypes {
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -24,7 +25,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -40,7 +43,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -58,12 +63,15 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := client.Do(loginHTTP)
|
loginResp, err := client.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
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)
|
refreshToken, ok := loginResult["refresh_token"].(string)
|
||||||
require.True(t, ok, "Should have refresh_token")
|
require.True(t, ok, "Should have refresh_token")
|
||||||
@@ -79,12 +87,15 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp, err := client.Do(refreshHTTP)
|
refreshResp, err := client.Do(refreshHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||||
|
|
||||||
var refreshResult map[string]interface{}
|
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.Contains(t, refreshResult, "access_token")
|
||||||
assert.NotEmpty(t, refreshResult["access_token"], "New access token should not be empty")
|
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)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -118,7 +131,9 @@ func TestRefreshTokenFlow(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Should still work or return appropriate error
|
// Should still work or return appropriate error
|
||||||
assert.True(t, resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusBadRequest || resp.StatusCode == http.StatusUnsupportedMediaType)
|
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)
|
loginResp, err := client.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
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)
|
refreshToken := loginResult["refresh_token"].(string)
|
||||||
|
|
||||||
@@ -163,7 +181,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp1, err := client.Do(refreshHTTP1)
|
refreshResp1, err := client.Do(refreshHTTP1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp1.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp1.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, refreshResp1.StatusCode)
|
assert.Equal(t, http.StatusOK, refreshResp1.StatusCode)
|
||||||
|
|
||||||
@@ -173,7 +193,9 @@ func TestRefreshTokenSecurity(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp2, err := client.Do(refreshHTTP2)
|
refreshResp2, err := client.Do(refreshHTTP2)
|
||||||
require.NoError(t, err)
|
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
|
// May return 401 if token reuse is detected, or 200 if not implemented
|
||||||
// Either is acceptable depending on security requirements
|
// Either is acceptable depending on security requirements
|
||||||
@@ -197,7 +219,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -213,7 +237,9 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(httpReq)
|
resp, err := client.Do(httpReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||||
})
|
})
|
||||||
@@ -231,12 +257,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := client.Do(loginHTTP)
|
loginResp, err := client.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
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)
|
refreshToken := loginResult["refresh_token"].(string)
|
||||||
|
|
||||||
@@ -251,12 +280,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp, err := client.Do(refreshHTTP)
|
refreshResp, err := client.Do(refreshHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||||
|
|
||||||
var refreshResult map[string]interface{}
|
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
|
// Verify response structure
|
||||||
assert.Contains(t, refreshResult, "access_token")
|
assert.Contains(t, refreshResult, "access_token")
|
||||||
@@ -279,12 +311,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := client.Do(loginHTTP)
|
loginResp, err := client.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
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)
|
refreshToken := loginResult["refresh_token"].(string)
|
||||||
|
|
||||||
@@ -299,12 +334,15 @@ func TestRefreshTokenEdgeCases(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp, err := client.Do(refreshHTTP)
|
refreshResp, err := client.Do(refreshHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
require.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||||
|
|
||||||
var refreshResult map[string]interface{}
|
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
|
// Verify access token is a string
|
||||||
accessToken, ok := refreshResult["access_token"].(string)
|
accessToken, ok := refreshResult["access_token"].(string)
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ func TestScanSettings_GetSettings(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
resp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
assert.Contains(t, response, "scan_poll_interval_seconds")
|
assert.Contains(t, response, "scan_poll_interval_seconds")
|
||||||
assert.Contains(t, response, "auto_scan_enabled")
|
assert.Contains(t, response, "auto_scan_enabled")
|
||||||
@@ -63,8 +64,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
resp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, float64(45), response["scan_poll_interval_seconds"])
|
assert.Equal(t, float64(45), response["scan_poll_interval_seconds"])
|
||||||
assert.Equal(t, true, response["auto_scan_enabled"])
|
assert.Equal(t, true, response["auto_scan_enabled"])
|
||||||
@@ -74,8 +76,9 @@ func TestScanSettings_UpdateSettings(t *testing.T) {
|
|||||||
getResp, _ := client.Do(getReq)
|
getResp, _ := client.Do(getReq)
|
||||||
|
|
||||||
var getResponse map[string]interface{}
|
var getResponse map[string]interface{}
|
||||||
json.NewDecoder(getResp.Body).Decode(&getResponse)
|
err = json.NewDecoder(getResp.Body).Decode(&getResponse)
|
||||||
getResp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = getResp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, float64(45), getResponse["scan_poll_interval_seconds"])
|
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)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var response map[string]interface{}
|
var response map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&response)
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
resp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
assert.Equal(t, false, response["auto_scan_enabled"])
|
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)
|
require.Equal(s.T(), http.StatusCreated, createLibResp.StatusCode)
|
||||||
|
|
||||||
var createLibResponse map[string]interface{}
|
var createLibResponse map[string]interface{}
|
||||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||||
createLibResp.Body.Close()
|
require.NoError(s.T(), err)
|
||||||
|
_ = createLibResp.Body.Close()
|
||||||
|
|
||||||
libraryID, ok := createLibResponse["id"].(string)
|
libraryID, ok := createLibResponse["id"].(string)
|
||||||
require.True(s.T(), ok, "library_id should be 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)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
require.Equal(s.T(), http.StatusCreated, resp.StatusCode, "Folder creation should succeed")
|
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)
|
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{}
|
var scanResponse map[string]interface{}
|
||||||
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
scanResp.Body.Close()
|
_ = scanResp.Body.Close()
|
||||||
|
|
||||||
jobID, ok := scanResponse["job_id"].(string)
|
jobID, ok := scanResponse["job_id"].(string)
|
||||||
require.True(s.T(), ok, "job_id should be 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)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
if statusResp.StatusCode == http.StatusNotFound {
|
if statusResp.StatusCode == http.StatusNotFound {
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
if gotProgressUpdate {
|
if gotProgressUpdate {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -113,7 +114,7 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_TracksStatistics() {
|
|||||||
|
|
||||||
var status map[string]interface{}
|
var status map[string]interface{}
|
||||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
if _, hasError := status["error"]; hasError {
|
if _, hasError := status["error"]; hasError {
|
||||||
@@ -180,8 +181,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
|||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
var createLibResponse map[string]interface{}
|
var createLibResponse map[string]interface{}
|
||||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||||
createLibResp.Body.Close()
|
require.NoError(s.T(), err)
|
||||||
|
_ = createLibResp.Body.Close()
|
||||||
|
|
||||||
libraryID, ok := createLibResponse["id"].(string)
|
libraryID, ok := createLibResponse["id"].(string)
|
||||||
require.True(s.T(), ok, "library_id should be 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)
|
resp, err := client.Do(req)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
scanURL := fmt.Sprintf("%s/api/libraries/%s/scan", s.setup.Server.URL, libraryID)
|
||||||
scanReq, _ := http.NewRequest("POST", scanURL, nil)
|
scanReq, _ := http.NewRequest("POST", scanURL, nil)
|
||||||
@@ -208,8 +210,9 @@ func (s *ScannerIntegrationTestSuite) TestScanProgress_BatchingWorks() {
|
|||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
var scanResponse map[string]interface{}
|
var scanResponse map[string]interface{}
|
||||||
json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
err = json.NewDecoder(scanResp.Body).Decode(&scanResponse)
|
||||||
scanResp.Body.Close()
|
require.NoError(s.T(), err)
|
||||||
|
_ = scanResp.Body.Close()
|
||||||
|
|
||||||
jobID, ok := scanResponse["job_id"].(string)
|
jobID, ok := scanResponse["job_id"].(string)
|
||||||
require.True(s.T(), ok, "job_id should be 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)
|
require.NoError(s.T(), err)
|
||||||
|
|
||||||
if statusResp.StatusCode == http.StatusNotFound {
|
if statusResp.StatusCode == http.StatusNotFound {
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
var status map[string]interface{}
|
var status map[string]interface{}
|
||||||
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
err = json.NewDecoder(statusResp.Body).Decode(&status)
|
||||||
require.NoError(s.T(), err)
|
require.NoError(s.T(), err)
|
||||||
statusResp.Body.Close()
|
_ = statusResp.Body.Close()
|
||||||
|
|
||||||
if _, hasError := status["error"]; hasError {
|
if _, hasError := status["error"]; hasError {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
|||||||
query string
|
query string
|
||||||
libraryID string
|
libraryID string
|
||||||
expectedCount int
|
expectedCount int
|
||||||
|
expectedStatus int
|
||||||
shouldContain string // Comma-separated list of book IDs to check
|
shouldContain string // Comma-separated list of book IDs to check
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
@@ -412,10 +413,10 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
|||||||
shouldContain: book2ID,
|
shouldContain: book2ID,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "invalid library_id",
|
name: "invalid library_id",
|
||||||
query: "Harry",
|
query: "Harry",
|
||||||
libraryID: "00000000-0000-0000-0000-000000000000",
|
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)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Log response for debugging
|
if tt.expectedStatus != 0 {
|
||||||
if resp.StatusCode != http.StatusOK {
|
require.Equal(t, tt.expectedStatus, resp.StatusCode)
|
||||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
return
|
||||||
t.Logf("ERROR %d: %s", resp.StatusCode, string(bodyBytes))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var result []map[string]interface{}
|
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 {
|
if tt.expectedCount > 0 {
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
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)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
var result map[string]interface{}
|
||||||
json.NewDecoder(resp.Body).Decode(&result)
|
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,10 +515,13 @@ func createTestMediaItemIDInLibrary(t *testing.T, client *http.Client, setup *Te
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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)
|
return result["id"].(string)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -30,7 +31,7 @@ func TestUnifiedSearch(t *testing.T) {
|
|||||||
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
folderResp, err := client.Do(folderHTTP)
|
folderResp, err := client.Do(folderHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
folderResp.Body.Close()
|
_ = folderResp.Body.Close()
|
||||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
|
||||||
|
|
||||||
// Helper to create book with fields
|
// Helper to create book with fields
|
||||||
@@ -54,7 +55,9 @@ func TestUnifiedSearch(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
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")
|
assert.Equal(t, http.StatusOK, rec.Code, "Should filter by has_cover")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("Missing library_id", func(t *testing.T) {
|
t.Run("Missing library_id searches all libraries", func(t *testing.T) {
|
||||||
req := httptest.NewRequest("GET", "/api/media-items/search?q=test", nil)
|
req := httptest.NewRequest("GET", "/api/media-items/search?q=zzzznonexistent", nil)
|
||||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
// library_id is now optional - searches all libraries when omitted
|
// library_id is optional - searches all libraries when omitted
|
||||||
// Returns 404 when no results match the search query
|
// Returns 404 when no results match
|
||||||
assert.Equal(t, http.StatusNotFound, rec.Code, "Should return 404 when no results found")
|
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"
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseTestURL = "http://localhost:8765/api"
|
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)")
|
t.Logf("Cleanup: No existing test user to delete (server not available)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// If login succeeds, try to delete the user
|
// If login succeeds, try to delete the user
|
||||||
if resp.StatusCode == http.StatusOK {
|
if resp.StatusCode == http.StatusOK {
|
||||||
var result map[string]interface{}
|
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 != "" {
|
if token, ok := result["access_token"].(string); ok && token != "" {
|
||||||
// Delete the user using the token
|
// Delete the user using the token
|
||||||
@@ -52,7 +56,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
delResp, err := client.Do(req)
|
delResp, err := client.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer delResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(delResp.Body)
|
||||||
if delResp.StatusCode == http.StatusNoContent {
|
if delResp.StatusCode == http.StatusNoContent {
|
||||||
t.Logf("Cleanup: Deleted existing test user")
|
t.Logf("Cleanup: Deleted existing test user")
|
||||||
} else {
|
} else {
|
||||||
@@ -66,10 +72,13 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
|
|
||||||
listResp, err := client.Do(req)
|
listResp, err := client.Do(req)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer listResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(listResp.Body)
|
||||||
if listResp.StatusCode == http.StatusOK {
|
if listResp.StatusCode == http.StatusOK {
|
||||||
var libsResult map[string]interface{}
|
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 {
|
if data, ok := libsResult["data"].([]interface{}); ok {
|
||||||
for _, lib := range data {
|
for _, lib := range data {
|
||||||
@@ -80,7 +89,7 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
delLibResp, _ := client.Do(req)
|
delLibResp, _ := client.Do(req)
|
||||||
if delLibResp != nil {
|
if delLibResp != nil {
|
||||||
delLibResp.Body.Close()
|
_ = delLibResp.Body.Close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +117,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
body, _ := json.Marshal(userReq)
|
body, _ := json.Marshal(userReq)
|
||||||
resp, err := http.Post(baseTestURL+"/auth/register", "application/json", bytes.NewBuffer(body))
|
resp, err := http.Post(baseTestURL+"/auth/register", "application/json", bytes.NewBuffer(body))
|
||||||
assert.NoError(t, err)
|
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)
|
// Accept 201 (Created) or 409 (Conflict if already exists from previous incomplete test run)
|
||||||
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
|
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
|
||||||
@@ -116,7 +127,8 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var result map[string]interface{}
|
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 we got 409, the user already exists, so we need to login to get the token
|
||||||
if resp.StatusCode == http.StatusConflict {
|
if resp.StatusCode == http.StatusConflict {
|
||||||
@@ -128,10 +140,13 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
body, _ := json.Marshal(loginReq)
|
body, _ := json.Marshal(loginReq)
|
||||||
resp2, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
resp2, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp2.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp2.StatusCode)
|
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 {
|
if result["user"] != nil {
|
||||||
@@ -155,12 +170,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
body, _ := json.Marshal(loginReq)
|
body, _ := json.Marshal(loginReq)
|
||||||
resp, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
resp, err := http.Post(baseTestURL+"/auth/login", "application/json", bytes.NewBuffer(body))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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)
|
token, ok := result["access_token"].(string)
|
||||||
assert.True(t, ok, "Should have access_token")
|
assert.True(t, ok, "Should have access_token")
|
||||||
@@ -185,7 +203,9 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
@@ -225,12 +245,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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"])
|
assert.Equal(t, getUploadPath(), result["folder_path"])
|
||||||
|
|
||||||
@@ -251,13 +274,16 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
// Accept 200 or 202
|
// Accept 200 or 202
|
||||||
assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode)
|
assert.Contains(t, []int{http.StatusOK, http.StatusAccepted}, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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"])
|
assert.Equal(t, "success", result["status"])
|
||||||
|
|
||||||
@@ -276,12 +302,15 @@ func TestFullApplicationSetup(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
var result map[string]interface{}
|
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{})
|
data, ok := result["data"].([]interface{})
|
||||||
assert.True(t, ok, "Data field should exist")
|
assert.True(t, ok, "Data field should exist")
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ func TestSevenDaySession(t *testing.T) {
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -81,12 +83,15 @@ func TestSevenDaySession(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := http.DefaultClient.Do(loginHTTP)
|
loginResp, err := http.DefaultClient.Do(loginHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||||
|
|
||||||
var loginResult map[string]interface{}
|
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)
|
refreshToken, ok := loginResult["refresh_token"].(string)
|
||||||
require.True(t, ok, "Should have refresh_token")
|
require.True(t, ok, "Should have refresh_token")
|
||||||
@@ -103,12 +108,15 @@ func TestSevenDaySession(t *testing.T) {
|
|||||||
|
|
||||||
refreshResp, err := http.DefaultClient.Do(refreshHTTP)
|
refreshResp, err := http.DefaultClient.Do(refreshHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer refreshResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(refreshResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
assert.Equal(t, http.StatusOK, refreshResp.StatusCode)
|
||||||
|
|
||||||
var refreshResult map[string]interface{}
|
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
|
// Verify ExpiresIn is 7 days
|
||||||
expiresIn, ok := refreshResult["expires_in"].(float64)
|
expiresIn, ok := refreshResult["expires_in"].(float64)
|
||||||
@@ -134,7 +142,9 @@ func TestSevenDaySession(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
var authResponse struct {
|
var authResponse struct {
|
||||||
Token string `json:"access_token"`
|
Token string `json:"access_token"`
|
||||||
@@ -230,7 +240,7 @@ func TestNoClientSideCookies(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
resp, err := http.DefaultClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
// Accept both 201 (new user) and 409 (already exists from previous run)
|
// Accept both 201 (new user) and 409 (already exists from previous run)
|
||||||
require.True(t, resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict,
|
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)
|
resp2, err := http.DefaultClient.Do(req2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp2.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
body, err := io.ReadAll(resp2.Body)
|
body, err := io.ReadAll(resp2.Body)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -277,7 +289,9 @@ func TestNoClientSideCookies(t *testing.T) {
|
|||||||
|
|
||||||
loginResp, err := http.DefaultClient.Do(loginReq)
|
loginResp, err := http.DefaultClient.Do(loginReq)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer loginResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(loginResp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, loginResp.StatusCode)
|
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 media_items WHERE title LIKE 'Test %'")
|
||||||
_, _ = dbPool.Exec(ctx, "DELETE FROM libraries WHERE name 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 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()
|
dbPool.Close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -30,7 +31,7 @@ func TestTagsFilter(t *testing.T) {
|
|||||||
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
folderHTTP.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
folderResp, err := client.Do(folderHTTP)
|
folderResp, err := client.Do(folderHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
folderResp.Body.Close()
|
_ = folderResp.Body.Close()
|
||||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
|
||||||
|
|
||||||
// Helper to create book with tags
|
// Helper to create book with tags
|
||||||
@@ -50,7 +51,9 @@ func TestTagsFilter(t *testing.T) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
req.Header.Set("Authorization", "Bearer "+setup.UserToken)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
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")
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "Should create book")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -98,11 +99,14 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(libHTTP)
|
resp, err := client.Do(libHTTP)
|
||||||
require.NoError(t, err)
|
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")
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "Library creation should succeed")
|
||||||
|
|
||||||
var libResponse map[string]interface{}
|
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)
|
libraryID := libResponse["id"].(string)
|
||||||
|
|
||||||
if withFolder {
|
if withFolder {
|
||||||
@@ -117,7 +121,9 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
|||||||
|
|
||||||
folderResp, err := client.Do(folderHTTP)
|
folderResp, err := client.Do(folderHTTP)
|
||||||
require.NoError(t, err)
|
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")
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Folder creation should succeed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -76,19 +77,21 @@ type DeviceTestData struct {
|
|||||||
|
|
||||||
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
|
// TestServerSetup manages the lifecycle of a test server with proper resource cleanup
|
||||||
type TestServerSetup struct {
|
type TestServerSetup struct {
|
||||||
Server *httptest.Server
|
Server *httptest.Server
|
||||||
DB *database.Queries
|
DB *database.Queries
|
||||||
DBPool *pgxpool.Pool
|
DBPool *pgxpool.Pool
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
ConnManager *wsync.ConnectionManager
|
ConnManager *wsync.ConnectionManager
|
||||||
QueueProcessor *wsync.SyncQueueProcessor
|
QueueProcessor *wsync.SyncQueueProcessor
|
||||||
CleanupCancel context.CancelFunc
|
ProgressService *wsync.ProgressService
|
||||||
QueueCtx context.Context
|
AnnotationService *wsync.AnnotationService
|
||||||
QueueCancel context.CancelFunc
|
CleanupCancel context.CancelFunc
|
||||||
Token string
|
QueueCtx context.Context
|
||||||
RegularToken string
|
QueueCancel context.CancelFunc
|
||||||
mu sync.Mutex
|
Token string
|
||||||
closed bool
|
RegularToken string
|
||||||
|
mu sync.Mutex
|
||||||
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close cleans up all resources in the correct order
|
// Close cleans up all resources in the correct order
|
||||||
@@ -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-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||||
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
{"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},
|
{"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 {
|
for _, col := range defaultCollections {
|
||||||
@@ -315,12 +319,15 @@ func loginUserWithCredentials(t *testing.T, ts *httptest.Server, email, password
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err, "Failed to login")
|
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")
|
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||||||
|
|
||||||
var result map[string]interface{}
|
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)
|
token, ok := result["access_token"].(string)
|
||||||
require.True(t, ok, "Should have access_token")
|
require.True(t, ok, "Should have access_token")
|
||||||
@@ -448,17 +455,23 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
connManager := wsync.NewConnectionManager()
|
connManager := wsync.NewConnectionManager()
|
||||||
cleanupCancel := connManager.StartCleanupTask()
|
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 := wsync.NewSyncQueueProcessor(queries)
|
||||||
|
queueProcessor.SetProgressService(progressService)
|
||||||
queueCtx, queueCancel := context.WithCancel(context.Background())
|
queueCtx, queueCancel := context.WithCancel(context.Background())
|
||||||
go queueProcessor.Start(queueCtx)
|
go queueProcessor.Start(queueCtx)
|
||||||
|
|
||||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||||
|
koreaderHandler.SetProgressService(progressService)
|
||||||
|
koreaderHandler.SetAnnotationService(annotationService)
|
||||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||||
|
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||||
|
|
||||||
// Create refactored handlers (matching main.go)
|
// Create refactored handlers (matching main.go)
|
||||||
libraryService := services.NewLibraryService(queries)
|
libraryService := services.NewLibraryService(queries)
|
||||||
@@ -469,7 +482,10 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||||
dashboardService := services.NewDashboardService(queries)
|
dashboardService := services.NewDashboardService(queries)
|
||||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||||
|
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||||
|
mediaHandler.SetProgressService(progressService)
|
||||||
|
mediaHandler.SetAnnotationService(annotationService)
|
||||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||||
|
|
||||||
// Create conversion service for OPDS
|
// Create conversion service for OPDS
|
||||||
@@ -514,14 +530,18 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
AnalyticsHandler: analyticsHandler,
|
AnalyticsHandler: analyticsHandler,
|
||||||
QueueHandler: queueHandler,
|
QueueHandler: queueHandler,
|
||||||
SystemSettingsHandler: systemSettingsHandler,
|
SystemSettingsHandler: systemSettingsHandler,
|
||||||
|
ProcessingIssuesHandler: processingIssuesHandler,
|
||||||
CollectionHandler: collectionHandler,
|
CollectionHandler: collectionHandler,
|
||||||
FiltersHandler: filtersHandler,
|
FiltersHandler: filtersHandler,
|
||||||
DashboardHandler: dashboardHandler,
|
DashboardHandler: dashboardHandler,
|
||||||
DashboardService: dashboardService,
|
DashboardService: dashboardService,
|
||||||
|
SeriesHandler: seriesHandler,
|
||||||
OPDSHandler: opdsHandler,
|
OPDSHandler: opdsHandler,
|
||||||
JobsHandler: jobsHandler,
|
JobsHandler: jobsHandler,
|
||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
|
ProgressService: progressService,
|
||||||
|
AnnotationService: annotationService,
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
LoginTracker: loginAttemptTracker,
|
LoginTracker: loginAttemptTracker,
|
||||||
}
|
}
|
||||||
@@ -543,11 +563,15 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Delete ALL test users (any user with test email domains) to ensure clean state
|
// Delete transient test users but preserve the dev admin user
|
||||||
// This handles users created during tests that may have been promoted to admin, etc.
|
// 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)
|
allUsers, err := queries.ListUsers(ctx)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for _, user := range allUsers {
|
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") {
|
if strings.HasSuffix(user.Email, "@example.com") || strings.HasSuffix(user.Email, "@tests.bookhoard.internal") {
|
||||||
queries.DeleteUser(ctx, user.ID)
|
queries.DeleteUser(ctx, user.ID)
|
||||||
}
|
}
|
||||||
@@ -601,17 +625,18 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
|
|
||||||
// Create TestServerSetup struct with all resources
|
// Create TestServerSetup struct with all resources
|
||||||
setup := &TestServerSetup{
|
setup := &TestServerSetup{
|
||||||
Server: ts,
|
Server: ts,
|
||||||
DB: queries,
|
DB: queries,
|
||||||
DBPool: dbPool,
|
DBPool: dbPool,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
ConnManager: connManager,
|
ConnManager: connManager,
|
||||||
QueueProcessor: queueProcessor,
|
QueueProcessor: queueProcessor,
|
||||||
CleanupCancel: cleanupCancel,
|
ProgressService: progressService,
|
||||||
QueueCtx: queueCtx,
|
CleanupCancel: cleanupCancel,
|
||||||
QueueCancel: queueCancel,
|
QueueCtx: queueCtx,
|
||||||
Token: adminToken,
|
QueueCancel: queueCancel,
|
||||||
RegularToken: regularToken,
|
Token: adminToken,
|
||||||
|
RegularToken: regularToken,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register cleanup function to run automatically when test completes
|
// Register cleanup function to run automatically when test completes
|
||||||
@@ -648,12 +673,15 @@ func loginWithCredentials(t *testing.T, ts *httptest.Server, email, password str
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err, "Failed to login")
|
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")
|
require.Equal(t, http.StatusOK, resp.StatusCode, "Login should succeed")
|
||||||
|
|
||||||
var result map[string]interface{}
|
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)
|
token, ok := result["access_token"].(string)
|
||||||
require.True(t, ok, "Should have access_token")
|
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)
|
resp, err := httpClient.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
var libResult map[string]interface{}
|
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)
|
libData := libResult["id"].(string)
|
||||||
|
|
||||||
@@ -718,7 +749,9 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
|||||||
|
|
||||||
folderResp, err := httpClient.Do(folderReqHTTP)
|
folderResp, err := httpClient.Do(folderReqHTTP)
|
||||||
require.NoError(t, err)
|
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")
|
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Library folder creation is required before adding media items")
|
||||||
|
|
||||||
mediaItemReq := map[string]interface{}{
|
mediaItemReq := map[string]interface{}{
|
||||||
@@ -737,12 +770,15 @@ func createTestMediaItemID(t *testing.T, setup *TestServerSetup) string {
|
|||||||
|
|
||||||
resp2, err := httpClient.Do(req2)
|
resp2, err := httpClient.Do(req2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp2.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp2.Body)
|
||||||
|
|
||||||
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
require.Equal(t, http.StatusCreated, resp2.StatusCode)
|
||||||
|
|
||||||
var mediaItemResult map[string]interface{}
|
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)
|
mediaItemID := mediaItemResult["id"].(string)
|
||||||
|
|
||||||
@@ -771,6 +807,8 @@ func addFolderToLibrary(t *testing.T, setup *TestServerSetup, libraryID string,
|
|||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -83,7 +84,9 @@ func TestWebSocketDeviceAuth(t *testing.T) {
|
|||||||
require.NoError(t, err, "WebSocket connection with device token should succeed")
|
require.NoError(t, err, "WebSocket connection with device token should succeed")
|
||||||
defer ws.Close()
|
defer ws.Close()
|
||||||
if resp != nil {
|
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")
|
require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode, "Should upgrade to WebSocket")
|
||||||
}
|
}
|
||||||
// Read initial state message
|
// Read initial state message
|
||||||
@@ -116,26 +119,23 @@ func TestWebSocketProgressBroadcast(t *testing.T) {
|
|||||||
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
|
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||||
_, _, _ = ws.ReadMessage()
|
_, _, _ = ws.ReadMessage()
|
||||||
|
|
||||||
// Update progress via HTTP API
|
// Update progress via HTTP API to new media-item progress endpoint
|
||||||
progressReq := map[string]interface{}{
|
progressReq := map[string]interface{}{
|
||||||
"source": "test",
|
"percentage": 0.5,
|
||||||
"location": map[string]interface{}{
|
"epubcfi": "epubcfi(/6/4/2:10)",
|
||||||
"percentage": 0.5,
|
|
||||||
},
|
|
||||||
"device_metadata": map[string]interface{}{
|
|
||||||
"device_type": "web",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
body, _ := json.Marshal(progressReq)
|
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("Content-Type", "application/json")
|
||||||
req.Header.Set("Authorization", "Bearer "+token)
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
|
||||||
client := &http.Client{}
|
client := &http.Client{}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer resp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(resp.Body)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
@@ -253,11 +253,14 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
|||||||
|
|
||||||
collectionResp, err := client.Do(collectionHTTP)
|
collectionResp, err := client.Do(collectionHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer collectionResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(collectionResp.Body)
|
||||||
require.Equal(t, http.StatusCreated, collectionResp.StatusCode)
|
require.Equal(t, http.StatusCreated, collectionResp.StatusCode)
|
||||||
|
|
||||||
var collectionResult map[string]interface{}
|
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)
|
collectionID := collectionResult["id"].(string)
|
||||||
|
|
||||||
// Create a test book via API
|
// Create a test book via API
|
||||||
@@ -289,7 +292,9 @@ func TestWebSocketUserScopedBroadcast(t *testing.T) {
|
|||||||
|
|
||||||
addResp, err := client.Do(addHTTP)
|
addResp, err := client.Do(addHTTP)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
defer addResp.Body.Close()
|
defer func(Body io.ReadCloser) {
|
||||||
|
_ = Body.Close()
|
||||||
|
}(addResp.Body)
|
||||||
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
require.Equal(t, http.StatusNoContent, addResp.StatusCode)
|
||||||
|
|
||||||
// Admin should receive collection_updated message
|
// 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)
|
ws, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||||
require.NoError(t, err, "WebSocket connection should succeed")
|
require.NoError(t, err, "WebSocket connection should succeed")
|
||||||
if resp != nil {
|
if resp != nil {
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
require.NotNil(t, ws, "WebSocket connection should be established")
|
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)
|
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
||||||
|
|
||||||
var createLibResponse map[string]interface{}
|
var createLibResponse map[string]interface{}
|
||||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||||
createLibResp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = createLibResp.Body.Close()
|
||||||
|
|
||||||
libraryID, ok := createLibResponse["id"].(string)
|
libraryID, ok := createLibResponse["id"].(string)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
@@ -62,7 +63,7 @@ func TestWorker_DirectoryScanJob(t *testing.T) {
|
|||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
|
|
||||||
// Create test files in the directory
|
// Create test files in the directory
|
||||||
@@ -141,8 +142,9 @@ func TestWorker_SetFoldersJob(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
require.Equal(t, http.StatusCreated, createLibResp.StatusCode)
|
||||||
|
|
||||||
var createLibResponse map[string]interface{}
|
var createLibResponse map[string]interface{}
|
||||||
json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
err = json.NewDecoder(createLibResp.Body).Decode(&createLibResponse)
|
||||||
createLibResp.Body.Close()
|
require.NoError(t, err)
|
||||||
|
_ = createLibResp.Body.Close()
|
||||||
|
|
||||||
libraryID, ok := createLibResponse["id"].(string)
|
libraryID, ok := createLibResponse["id"].(string)
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
|
|||||||
+102
-11
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS library_types (
|
|||||||
INSERT INTO library_types (name, description, allowed_extensions) VALUES
|
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']),
|
('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']),
|
('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;
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
-- Create users table
|
-- Create users table
|
||||||
@@ -32,6 +32,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
||||||
max_devices INTEGER DEFAULT 10,
|
max_devices INTEGER DEFAULT 10,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
timezone VARCHAR(50) DEFAULT 'UTC',
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
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()
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Insert default system settings
|
-- Extend system_settings with typed metadata so it can back the admin UI's
|
||||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
-- configurable tunables. All columns are nullable for backward compatibility
|
||||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
-- with the original three rows and any pre-existing data.
|
||||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide')
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS setting_type VARCHAR(20);
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS min_value TEXT;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS max_value TEXT;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS requires_restart BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
|
||||||
|
|
||||||
|
-- Insert default system settings (original scan/timezone rows + tunables).
|
||||||
|
-- Values match the previous hardcoded literals, so behavior is unchanged on upgrade.
|
||||||
|
-- ON CONFLICT DO NOTHING preserves any admin-modified values.
|
||||||
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
|
||||||
|
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
|
||||||
|
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
|
||||||
|
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
|
||||||
|
-- security / auth (live)
|
||||||
|
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
|
||||||
|
('password_min_length', '8', 'Minimum password length', 'int', '1', '128', FALSE, 'security'),
|
||||||
|
('password_require_upper', 'true', 'Require at least one uppercase letter (A-Z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
|
||||||
|
-- security / auth (restart required)
|
||||||
|
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
|
||||||
|
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
|
||||||
|
('login_lockout_minutes', '15', 'Lockout duration after too many failed logins', 'int', '1', '10080', TRUE, 'security'),
|
||||||
|
-- api (live)
|
||||||
|
('opds_default_page_size', '50', 'Default OPDS page size', 'int', '1', '500', FALSE, 'api'),
|
||||||
|
('opds_max_page_size', '200', 'Maximum OPDS page size', 'int', '1', '1000', FALSE, 'api'),
|
||||||
|
('device_rate_sync_per_min', '60', 'Device sync requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
('device_rate_progress_per_min', '120', 'Device progress requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
('device_rate_metadata_per_min', '30', 'Device metadata requests per minute', 'int', '1', '10000', FALSE, 'api'),
|
||||||
|
-- sync / performance (live)
|
||||||
|
('annotation_tombstone_ttl_days', '30', 'How long deleted annotations are kept before purge', 'int', '1', '3650', FALSE, 'sync'),
|
||||||
|
('conversion_cache_ttl_hours', '24', 'How long converted (kepub) files are cached', 'int', '1', '720', FALSE, 'performance'),
|
||||||
|
-- sync / performance (restart required)
|
||||||
|
('sync_queue_interval_seconds', '5', 'How often the sync queue flushes', 'int', '1', '3600', TRUE, 'sync'),
|
||||||
|
('sync_queue_batch_size', '50', 'Maximum items processed per sync queue flush', 'int', '1', '10000', TRUE, 'sync'),
|
||||||
|
('worker_pool_size', '3', 'Number of background worker goroutines', 'int', '1', '100', TRUE, 'performance'),
|
||||||
|
('worker_queue_cap', '100', 'Background worker job queue capacity', 'int', '1', '10000', TRUE, 'performance')
|
||||||
ON CONFLICT (setting_key) DO NOTHING;
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
|
|
||||||
-- Create refresh_tokens table
|
-- Create refresh_tokens table
|
||||||
@@ -121,6 +159,7 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
google_books_id VARCHAR(100), -- Google Books identifier
|
google_books_id VARCHAR(100), -- Google Books identifier
|
||||||
added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
added_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
imported_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
-- Universal Sync Format Detection
|
-- Universal Sync Format Detection
|
||||||
format_group VARCHAR(20) NOT NULL DEFAULT 'reflowable',
|
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),
|
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
||||||
character_offset BIGINT,
|
character_offset BIGINT,
|
||||||
epubcfi TEXT,
|
epubcfi TEXT,
|
||||||
|
context_text TEXT,
|
||||||
chapter INTEGER,
|
chapter INTEGER,
|
||||||
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
||||||
viewport_x FLOAT DEFAULT 0,
|
viewport_x FLOAT DEFAULT 0,
|
||||||
@@ -457,6 +497,7 @@ CREATE TABLE IF NOT EXISTS reading_history (
|
|||||||
-- Create indexes for better query performance
|
-- Create indexes for better query performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
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_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_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_token ON refresh_tokens(token);
|
||||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
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,
|
percentage = (book_record->>'percentage')::FLOAT,
|
||||||
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
||||||
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
||||||
|
context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
|
||||||
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
||||||
chapter_progress = (book_record->>'percentage')::FLOAT,
|
chapter_progress = (book_record->>'percentage')::FLOAT,
|
||||||
last_sync_device = 'koreader',
|
last_sync_device = 'koreader',
|
||||||
@@ -951,6 +993,7 @@ BEGIN
|
|||||||
percentage,
|
percentage,
|
||||||
character_offset,
|
character_offset,
|
||||||
epubcfi,
|
epubcfi,
|
||||||
|
context_text,
|
||||||
chapter,
|
chapter,
|
||||||
chapter_progress,
|
chapter_progress,
|
||||||
last_sync_device,
|
last_sync_device,
|
||||||
@@ -967,6 +1010,7 @@ BEGIN
|
|||||||
(book_record->>'percentage')::FLOAT,
|
(book_record->>'percentage')::FLOAT,
|
||||||
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
||||||
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
||||||
|
CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
|
||||||
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
||||||
(book_record->>'percentage')::FLOAT,
|
(book_record->>'percentage')::FLOAT,
|
||||||
'koreader',
|
'koreader',
|
||||||
@@ -1141,12 +1185,14 @@ CREATE TABLE IF NOT EXISTS system_config (
|
|||||||
updated_by UUID REFERENCES users(id)
|
updated_by UUID REFERENCES users(id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Pre-seeded values
|
-- One-time cleanup: clear the old placeholder seed so the startup logic
|
||||||
INSERT INTO system_config (key, value) VALUES
|
-- can re-seed from the BASE_URL env var (or the setup wizard can set it).
|
||||||
('base_url', 'https://bookhoard.example.com'),
|
UPDATE system_config SET value = ''
|
||||||
('opds_base_url', 'https://bookhoard.example.com/opds'),
|
WHERE key = 'base_url' AND value = 'https://bookhoard.example.com';
|
||||||
('api_base_url', 'https://bookhoard.example.com/api')
|
UPDATE system_config SET value = ''
|
||||||
ON CONFLICT (key) DO NOTHING;
|
WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds';
|
||||||
|
UPDATE system_config SET value = ''
|
||||||
|
WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api';
|
||||||
|
|
||||||
-- Create opds_tokens table (device-specific OPDS access tokens)
|
-- Create opds_tokens table (device-specific OPDS access tokens)
|
||||||
CREATE TABLE IF NOT EXISTS opds_tokens (
|
CREATE TABLE IF NOT EXISTS opds_tokens (
|
||||||
@@ -1304,3 +1350,48 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_user ON media_bookmarks(user_id);
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ANNOTATION SYNC MIGRATIONS
|
||||||
|
-- Adds dedup_key, LWW timestamps, soft-delete,
|
||||||
|
-- and device_sync_data to annotation tables.
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS note_text TEXT;
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE media_highlights ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE media_notes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS dedup_key VARCHAR(40);
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_at TIMESTAMPTZ;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS last_modified_source VARCHAR(30);
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS device_sync_data JSONB;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS percentage_location FLOAT;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
||||||
|
ON media_highlights (user_id, media_item_id, dedup_key)
|
||||||
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_notes_dedup
|
||||||
|
ON media_notes (user_id, media_item_id, dedup_key)
|
||||||
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_bookmarks_dedup
|
||||||
|
ON media_bookmarks (user_id, media_item_id, dedup_key)
|
||||||
|
WHERE dedup_key IS NOT NULL AND deleted = FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_highlights_deleted_at ON media_highlights(deleted_at) WHERE deleted = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_notes_deleted_at ON media_notes(deleted_at) WHERE deleted = TRUE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_deleted_at ON media_bookmarks(deleted_at) WHERE deleted = TRUE;
|
||||||
|
|||||||
@@ -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:
|
services:
|
||||||
# PostgreSQL Database
|
# PostgreSQL Database
|
||||||
db:
|
db:
|
||||||
@@ -9,44 +7,49 @@ services:
|
|||||||
POSTGRES_DB: bookhoard
|
POSTGRES_DB: bookhoard
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ${DBPASS}
|
POSTGRES_PASSWORD: ${DBPASS}
|
||||||
COOKIE_SECURE: false # make true in production with HTTPS
|
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
|
||||||
|
PGPORT: ${DB_PORT:-5432}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
- ./database/schema:/docker-entrypoint-initdb.d
|
- ./database/schema:/docker-entrypoint-initdb.d
|
||||||
# Make other volumes as needed
|
# Make other volumes as needed
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
|
||||||
# Bookhoard Application
|
# Bookhoard Application
|
||||||
|
# In production this image is pulled from the Gitea container registry.
|
||||||
|
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||||
app:
|
app:
|
||||||
build:
|
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||||
context: .
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
container_name: bookhoard
|
container_name: bookhoard
|
||||||
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
DATABASE_HOST: db
|
DATABASE_HOST: db
|
||||||
DATABASE_PORT: 5432
|
DATABASE_PORT: ${DB_PORT:-5432}
|
||||||
DATABASE_USER: postgres
|
DATABASE_USER: postgres
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
DATABASE_PASSWORD: ${DBPASS}
|
||||||
DATABASE_NAME: bookhoard
|
DATABASE_NAME: bookhoard
|
||||||
|
|
||||||
# Application Configuration
|
# Application Configuration
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
SERVER_PORT: 8765
|
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||||
# IMPORTANT: Device sync requires full URL with protocol
|
# IMPORTANT: Device sync requires full URL with protocol
|
||||||
# Local: http://localhost:8765
|
# Local: http://localhost:8765
|
||||||
# Local network: http://192.168.1.X:8765
|
# Local network: http://192.168.1.X:8765
|
||||||
# Domain: https://bookhoard.example.com
|
# Domain: https://bookhoard.example.com
|
||||||
BASE_URL: http://localhost:${SERVER_PORT}
|
BASE_URL: ${BASE_URL:-http://localhost:8765}
|
||||||
|
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
|
|
||||||
# Rate Limiting Configuration
|
# Rate Limiting Configuration
|
||||||
TEST_MODE: ${TEST_MODE:-false}
|
TEST_MODE: ${TEST_MODE:-false}
|
||||||
@@ -57,8 +60,11 @@ services:
|
|||||||
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
|
BOOKHOARD_CONVERSION_CACHE_DIR: ${BOOKHOARD_CONVERSION_CACHE_DIR:-/app/cache/kepub}
|
||||||
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||||
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||||
|
|
||||||
|
# System timezone (fallback for server-side time operations)
|
||||||
|
TZ: ${TZ:-UTC}
|
||||||
ports:
|
ports:
|
||||||
- "8765:8765"
|
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -66,55 +72,12 @@ services:
|
|||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- bookhoard_conversion_cache:/app/cache/kepub
|
- bookhoard_conversion_cache:/app/cache/kepub
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
|
||||||
interval: 10s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
# Integration Tests - runs against containerized app and db
|
|
||||||
tests:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: ./Dockerfile
|
|
||||||
target: test-runner
|
|
||||||
container_name: bookhoard_tests
|
|
||||||
environment:
|
|
||||||
# Database Configuration
|
|
||||||
DATABASE_HOST: db
|
|
||||||
DATABASE_PORT: 5432
|
|
||||||
DATABASE_USER: postgres
|
|
||||||
DATABASE_PASSWORD: ${DBPASS}
|
|
||||||
DATABASE_NAME: bookhoard
|
|
||||||
COOKIE_SECURE: false
|
|
||||||
|
|
||||||
# Application Configuration
|
|
||||||
JWT_SECRET: ${JWT_SECRET}
|
|
||||||
SERVER_PORT: 8765
|
|
||||||
|
|
||||||
# Test Configuration
|
|
||||||
TEST_MODE: "true"
|
|
||||||
RATE_LIMIT_ENABLED: "false"
|
|
||||||
REQUESTS_PER_MINUTE: 1000
|
|
||||||
|
|
||||||
# Conversion Service Configuration
|
|
||||||
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
|
||||||
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
|
||||||
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
|
||||||
|
|
||||||
# Test upload path (inside container)
|
|
||||||
TEST_UPLOAD_PATH: /app/uploads
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
app:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- ./uploads:/app/uploads
|
|
||||||
- bookhoard_conversion_cache:/app/cache/kepub
|
|
||||||
profiles:
|
|
||||||
- tests
|
|
||||||
|
|
||||||
# Named Volumes
|
# Named Volumes
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
|
|||||||
@@ -986,25 +986,39 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
|
|||||||
- `page` (optional): Page number (default: 1)
|
- `page` (optional): Page number (default: 1)
|
||||||
- `per_page` (optional): Items per page (default: 50, max: 200)
|
- `per_page` (optional): Items per page (default: 50, max: 200)
|
||||||
|
|
||||||
|
The feed is paginated via standard OPDS link relations. Clients (e.g. KOReader)
|
||||||
|
walk pages by following the `rel="next"` link until it is absent. OpenSearch
|
||||||
|
paging metadata (`totalResults`, `itemsPerPage`, `startIndex`) is also included.
|
||||||
|
|
||||||
**Response** (200 - OPDS 1.2 XML):
|
**Response** (200 - OPDS 1.2 XML):
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||||
xmlns:opds="http://opds-spec.org/2010/"
|
xmlns:opds="http://opds-spec.org/2010/"
|
||||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
<id>urn:uuid:device-id</id>
|
<id>urn:uuid:device-id</id>
|
||||||
<title>Bookhoard Library</title>
|
<title>Bookhoard Library</title>
|
||||||
<updated>2026-02-01T12:00:00Z</updated>
|
<updated>2026-02-01T12:00:00Z</updated>
|
||||||
|
|
||||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
|
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
|
||||||
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
|
<link rel="first" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
|
<link rel="previous" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||||
|
<link rel="next" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=3&per_page=50"/>
|
||||||
|
<link rel="last" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=37&per_page=50"/>
|
||||||
|
<link rel="search" type="application/opensearchdescription+xml"
|
||||||
|
href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
||||||
|
|
||||||
|
<opensearch:totalResults>1814</opensearch:totalResults>
|
||||||
|
<opensearch:itemsPerPage>50</opensearch:itemsPerPage>
|
||||||
|
<opensearch:startIndex>51</opensearch:startIndex>
|
||||||
|
|
||||||
<entry>
|
<entry>
|
||||||
<id>urn:uuid:bookhoard-uuid-123</id>
|
<id>urn:uuid:bookhoard-uuid-123</id>
|
||||||
<dc:title>The Hobbit</dc:title>
|
<title>The Hobbit</title>
|
||||||
<dc:creator>J.R.R. Tolkien</dc:creator>
|
<author><name>J.R.R. Tolkien</name></author>
|
||||||
<updated>2026-02-01T10:00:00Z</updated>
|
<updated>2026-02-01T10:00:00Z</updated>
|
||||||
|
|
||||||
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
||||||
@@ -1043,10 +1057,28 @@ GET /opds/devices/{deviceId}/download/{bookId}?format={format}
|
|||||||
### Search OPDS Catalog
|
### Search OPDS Catalog
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /opds/devices/{deviceId}/search?q={query}
|
GET /opds/devices/{deviceId}/search # OpenSearch description
|
||||||
|
GET /opds/devices/{deviceId}/search?q={query} # search results feed
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response** (200 - OPDS 1.2 XML with search results)
|
When called **without** a `q` parameter, returns an OpenSearch description
|
||||||
|
document (`application/opensearchdescription+xml`). OPDS clients fetch this to
|
||||||
|
learn the search URL template, then substitute `{searchTerms}`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||||
|
<ShortName>Bookhoard</ShortName>
|
||||||
|
<Description>Search the Bookhoard library</Description>
|
||||||
|
<InputEncoding>UTF-8</InputEncoding>
|
||||||
|
<OutputEncoding>UTF-8</OutputEncoding>
|
||||||
|
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||||
|
template="http://localhost:8765/opds/devices/kobo-id/search?q={searchTerms}"/>
|
||||||
|
</OpenSearchDescription>
|
||||||
|
```
|
||||||
|
|
||||||
|
When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with
|
||||||
|
search results, including `opensearch:totalResults`).
|
||||||
|
|
||||||
### List Available Formats
|
### List Available Formats
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,20 @@ go 1.26.0
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/ArcadiaLin/go-epub v0.1.1
|
github.com/ArcadiaLin/go-epub v0.1.1
|
||||||
github.com/a-h/templ v0.3.1001
|
github.com/a-h/templ v0.3.1020
|
||||||
github.com/andybalholm/brotli v1.2.0
|
github.com/andybalholm/brotli v1.2.1
|
||||||
github.com/bodgit/plumbing v1.3.0
|
github.com/bodgit/plumbing v1.3.0
|
||||||
github.com/bodgit/windows v1.0.1
|
github.com/bodgit/windows v1.0.1
|
||||||
github.com/fsnotify/fsnotify v1.9.0
|
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/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
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/klauspost/compress v1.18.5
|
||||||
github.com/labstack/echo-jwt/v5 v5.0.1
|
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/microcosm-cc/bluemonday v1.0.27
|
||||||
github.com/nwaples/rardecode v1.1.3
|
github.com/nwaples/rardecode v1.1.3
|
||||||
github.com/pdfcpu/pdfcpu v0.11.1
|
github.com/pdfcpu/pdfcpu v0.11.1
|
||||||
@@ -26,10 +26,11 @@ require (
|
|||||||
github.com/spf13/afero v1.15.0
|
github.com/spf13/afero v1.15.0
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/ulikunitz/xz v0.5.15
|
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
|
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.50.0
|
||||||
golang.org/x/text v0.35.0
|
golang.org/x/net v0.53.0
|
||||||
|
golang.org/x/text v0.36.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -37,7 +38,7 @@ require (
|
|||||||
github.com/aymerick/douceur v0.2.0 // indirect
|
github.com/aymerick/douceur v0.2.0 // indirect
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.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/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/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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/gorilla/css v1.0.1 // indirect
|
||||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||||
github.com/hhrutter/pkcs7 v0.2.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/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // 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/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
github.com/xyproto/randomstring v1.2.0 // indirect
|
github.com/xyproto/randomstring v1.2.0 // indirect
|
||||||
golang.org/x/image v0.37.0 // indirect
|
golang.org/x/image v0.39.0 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.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
|
golang.org/x/time v0.15.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws=
|
||||||
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
|
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.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
github.com/a-h/templ v0.3.1001/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
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 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
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.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
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 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||||
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
|
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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.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.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
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 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
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/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 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
|
||||||
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
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.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0=
|
||||||
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
|
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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
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/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 h1:uIpCHCiDPN3jA8Jb47i4EViToUl1uypMiPvVAAgKpIw=
|
||||||
github.com/labstack/echo-jwt/v5 v5.0.1/go.mod h1:kcHmJPzrVSEJa1FRheVoi9EJrBLLUqr1ntlil6uPe1Q=
|
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.1.0 h1:MvIRydoN+p9cx/zq8Lff6YXqUW2ZaEsOMISzEGSMrBI=
|
||||||
github.com/labstack/echo/v5 v5.0.4/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
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 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
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=
|
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 h1:y7PXAEBM3XlwJjPG2JQg4voxBYZ4+hPgRdGKCfU8wik=
|
||||||
github.com/xyproto/randomstring v1.2.0/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
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.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.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||||
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
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 h1:yHfZyN55+5dp1wG7wDKv8HQ044moxkyGq12KFFMFDxg=
|
||||||
github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594/go.mod h1:U9ihbh+1ZN7fR5Se3daSPoz1CGF9IYtSvWwVQtnzGHU=
|
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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||||
golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA=
|
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
|
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
+2
-1
@@ -2,6 +2,7 @@ package app
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -40,7 +41,7 @@ func (a *App) StartServer(addr string) error {
|
|||||||
|
|
||||||
// Start HTTP server in background
|
// Start HTTP server in background
|
||||||
go func() {
|
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)
|
log.Fatalf("Server failed to start: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ func BenchmarkApp_Shutdown(b *testing.B) {
|
|||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
app := New(e)
|
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)
|
c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetBaseURL returns the base URL from system configuration database with fallback to config/env var
|
// SystemConfigGetter returns the value for a system config key, or an error.
|
||||||
func GetBaseURL(ctx context.Context, db interface{}) string {
|
type SystemConfigGetter func(ctx context.Context, key string) (string, error)
|
||||||
// Try to get from database first
|
|
||||||
type SystemConfigQuerier interface {
|
|
||||||
GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if querier, ok := db.(SystemConfigQuerier); ok {
|
// GetBaseURL returns the base URL from system configuration database, or empty
|
||||||
config, err := querier.GetSystemConfig(ctx, "base_url")
|
// string if not set. The getter abstraction avoids importing the database package.
|
||||||
if err == nil && config.Value != "" {
|
func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string {
|
||||||
return config.Value
|
val, err := getter(ctx, "base_url")
|
||||||
}
|
if err == nil && val != "" {
|
||||||
|
return val
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: return empty string - caller should use their own fallback
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// SystemConfigRow represents a system configuration row
|
|
||||||
type SystemConfigRow struct {
|
|
||||||
Key string
|
|
||||||
Value string
|
|
||||||
}
|
|
||||||
|
|
||||||
func getEnv(key, defaultValue string) string {
|
func getEnv(key, defaultValue string) string {
|
||||||
if value := os.Getenv(key); value != "" {
|
if value := os.Getenv(key); value != "" {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
+66
-37
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -155,40 +155,55 @@ type LibraryVisibility struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MediaBookmarks struct {
|
type MediaBookmarks struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
||||||
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
||||||
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
||||||
Title string `db:"title" json:"title"`
|
Title string `db:"title" json:"title"`
|
||||||
Position pgtype.Text `db:"position" json:"position"`
|
Position pgtype.Text `db:"position" json:"position"`
|
||||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||||
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||||
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||||
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||||
|
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
|
||||||
|
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
|
||||||
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||||
|
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||||
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaHighlights struct {
|
type MediaHighlights struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||||
SelectionText string `db:"selection_text" json:"selection_text"`
|
SelectionText string `db:"selection_text" json:"selection_text"`
|
||||||
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||||
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||||
Color pgtype.Text `db:"color" json:"color"`
|
Color pgtype.Text `db:"color" json:"color"`
|
||||||
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
||||||
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
||||||
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
||||||
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
||||||
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||||
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||||
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
||||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||||
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||||
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||||
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||||
|
NoteText pgtype.Text `db:"note_text" json:"note_text"`
|
||||||
|
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||||
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaItemFormats struct {
|
type MediaItemFormats struct {
|
||||||
@@ -239,6 +254,7 @@ type MediaItems struct {
|
|||||||
GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"`
|
GoogleBooksID pgtype.Text `db:"google_books_id" json:"google_books_id"`
|
||||||
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
|
AddedByAdminID pgtype.UUID `db:"added_by_admin_id" json:"added_by_admin_id"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
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"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
FormatGroup string `db:"format_group" json:"format_group"`
|
FormatGroup string `db:"format_group" json:"format_group"`
|
||||||
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
|
FormatMimetype pgtype.Text `db:"format_mimetype" json:"format_mimetype"`
|
||||||
@@ -303,6 +319,11 @@ type MediaNotes struct {
|
|||||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||||
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
||||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||||
|
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||||
|
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||||
|
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||||
|
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||||
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaRatings struct {
|
type MediaRatings struct {
|
||||||
@@ -379,6 +400,7 @@ type ReadingProgress struct {
|
|||||||
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
||||||
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
||||||
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
||||||
|
ContextText pgtype.Text `db:"context_text" json:"context_text"`
|
||||||
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
||||||
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
||||||
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
||||||
@@ -462,11 +484,16 @@ type SystemConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemSettings struct {
|
type SystemSettings struct {
|
||||||
ID pgtype.UUID `db:"id" json:"id"`
|
ID pgtype.UUID `db:"id" json:"id"`
|
||||||
SettingKey string `db:"setting_key" json:"setting_key"`
|
SettingKey string `db:"setting_key" json:"setting_key"`
|
||||||
SettingValue string `db:"setting_value" json:"setting_value"`
|
SettingValue string `db:"setting_value" json:"setting_value"`
|
||||||
Description pgtype.Text `db:"description" json:"description"`
|
Description pgtype.Text `db:"description" json:"description"`
|
||||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
|
SettingType pgtype.Text `db:"setting_type" json:"setting_type"`
|
||||||
|
MinValue pgtype.Text `db:"min_value" json:"min_value"`
|
||||||
|
MaxValue pgtype.Text `db:"max_value" json:"max_value"`
|
||||||
|
RequiresRestart pgtype.Bool `db:"requires_restart" json:"requires_restart"`
|
||||||
|
Category pgtype.Text `db:"category" json:"category"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UnlinkedBooks struct {
|
type UnlinkedBooks struct {
|
||||||
@@ -507,5 +534,7 @@ type Users struct {
|
|||||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
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"`
|
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -23,15 +23,18 @@ type Querier interface {
|
|||||||
// Bulk update format group for all media items
|
// Bulk update format group for all media items
|
||||||
BulkUpdateFormatGroups(ctx context.Context) error
|
BulkUpdateFormatGroups(ctx context.Context) error
|
||||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
||||||
|
CheckForProgressConflicts(ctx context.Context, arg CheckForProgressConflictsParams) (int64, error)
|
||||||
// Cleanup expired OPDS tokens
|
// Cleanup expired OPDS tokens
|
||||||
CleanupExpiredOpdsTokens(ctx context.Context) error
|
CleanupExpiredOpdsTokens(ctx context.Context) error
|
||||||
CleanupExpiredRefreshTokens(ctx context.Context) error
|
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
||||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||||
|
CountAdmins(ctx context.Context) (int64, error)
|
||||||
// Count unlinked books for a device
|
// Count unlinked books for a device
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
|
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||||
// COLLECTIONS QUERIES
|
// COLLECTIONS QUERIES
|
||||||
// Create collection
|
// Create collection
|
||||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
||||||
@@ -53,8 +56,10 @@ type Querier interface {
|
|||||||
// Libraries queries
|
// Libraries queries
|
||||||
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
||||||
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
||||||
|
CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error)
|
||||||
// Media Highlights queries
|
// Media Highlights queries
|
||||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||||
|
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
||||||
// Media Items queries
|
// Media Items queries
|
||||||
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||||
// MEDIA ITEM FORMATS QUERIES
|
// MEDIA ITEM FORMATS QUERIES
|
||||||
@@ -62,6 +67,7 @@ type Querier interface {
|
|||||||
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
||||||
// Media Notes queries
|
// Media Notes queries
|
||||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||||
|
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
|
||||||
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
||||||
// OPDS TOKENS QUERIES
|
// OPDS TOKENS QUERIES
|
||||||
// Create OPDS token
|
// Create OPDS token
|
||||||
@@ -124,10 +130,16 @@ type Querier interface {
|
|||||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||||
|
// ============================================
|
||||||
|
// ANNOTATION SERVE QUERIES
|
||||||
|
// ============================================
|
||||||
|
GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error)
|
||||||
// Get all system config
|
// Get all system config
|
||||||
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
||||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||||
|
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||||
|
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||||
// Get collection
|
// Get collection
|
||||||
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
||||||
// Get collection items
|
// Get collection items
|
||||||
@@ -141,6 +153,7 @@ type Querier interface {
|
|||||||
GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.UUID) ([]Collections, error)
|
GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.UUID) ([]Collections, error)
|
||||||
// Smart section queries (for system collections)
|
// Smart section queries (for system collections)
|
||||||
GetContinueReadingItems(ctx context.Context, arg GetContinueReadingItemsParams) ([]MediaItems, error)
|
GetContinueReadingItems(ctx context.Context, arg GetContinueReadingItemsParams) ([]MediaItems, error)
|
||||||
|
GetContinueSeriesItems(ctx context.Context, arg GetContinueSeriesItemsParams) ([]GetContinueSeriesItemsRow, error)
|
||||||
// ============================================
|
// ============================================
|
||||||
// CAROUSEL-STYLE DASHBOARD
|
// CAROUSEL-STYLE DASHBOARD
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -166,7 +179,11 @@ type Querier interface {
|
|||||||
// Get device shelf mappings
|
// Get device shelf mappings
|
||||||
GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error)
|
GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error)
|
||||||
GetDictionaryEntry(ctx context.Context, word string) (DictionaryCache, 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)
|
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||||
|
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||||
|
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
|
||||||
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
||||||
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
||||||
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
|
GetKoboEntitlementsForDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboEntitlementsForDeviceRow, error)
|
||||||
@@ -177,6 +194,7 @@ type Querier interface {
|
|||||||
GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error)
|
GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error)
|
||||||
GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
|
GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error)
|
||||||
GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, 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)
|
GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
|
||||||
GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([]MediaItems, error)
|
GetLibraryItems(ctx context.Context, libraryID pgtype.UUID) ([]MediaItems, error)
|
||||||
GetLibraryType(ctx context.Context, id pgtype.UUID) (LibraryTypes, error)
|
GetLibraryType(ctx context.Context, id pgtype.UUID) (LibraryTypes, error)
|
||||||
@@ -188,8 +206,17 @@ type Querier interface {
|
|||||||
// LIBRARY WITH TYPE INFO QUERIES
|
// LIBRARY WITH TYPE INFO QUERIES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
|
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
|
||||||
|
GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error)
|
||||||
|
// ============================================
|
||||||
|
// ANNOTATION SYNC QUERIES (bookmarks)
|
||||||
|
// ============================================
|
||||||
|
GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error)
|
||||||
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
|
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
|
||||||
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error)
|
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error)
|
||||||
|
// ============================================
|
||||||
|
// ANNOTATION SYNC QUERIES (highlights)
|
||||||
|
// ============================================
|
||||||
|
GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error)
|
||||||
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
|
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
|
||||||
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||||
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
|
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
|
||||||
@@ -212,6 +239,10 @@ type Querier interface {
|
|||||||
// Get media item formats
|
// Get media item formats
|
||||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||||
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
||||||
|
// ============================================
|
||||||
|
// ANNOTATION SYNC QUERIES (notes)
|
||||||
|
// ============================================
|
||||||
|
GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error)
|
||||||
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
||||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
||||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
||||||
@@ -235,6 +266,8 @@ type Querier interface {
|
|||||||
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
|
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
|
||||||
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
|
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
|
||||||
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
|
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
|
||||||
|
GetSeriesBooks(ctx context.Context, series pgtype.Text) ([]MediaItems, error)
|
||||||
|
GetSeriesCovers(ctx context.Context, arg GetSeriesCoversParams) ([]GetSeriesCoversRow, error)
|
||||||
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
|
GetStuckSyncQueueItems(ctx context.Context) ([]SyncQueue, error)
|
||||||
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
|
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
|
||||||
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
|
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
|
||||||
@@ -246,6 +279,9 @@ type Querier interface {
|
|||||||
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
|
GetSystemConfig(ctx context.Context, key string) (SystemConfig, error)
|
||||||
// System Settings queries
|
// System Settings queries
|
||||||
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
||||||
|
GetSystemSettingFull(ctx context.Context, settingKey string) (SystemSettings, error)
|
||||||
|
GetSystemTimezone(ctx context.Context) (string, error)
|
||||||
|
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||||
// Get universal progress for a book
|
// Get universal progress for a book
|
||||||
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
||||||
// Get unlinked book by ContentId
|
// Get unlinked book by ContentId
|
||||||
@@ -269,13 +305,17 @@ type Querier interface {
|
|||||||
// Get user reading history for analytics
|
// Get user reading history for analytics
|
||||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
||||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||||
|
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||||
|
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||||
// Check if book is in collection
|
// Check if book is in collection
|
||||||
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
||||||
IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error)
|
IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error)
|
||||||
// Link unlinked book to media item
|
// Link unlinked book to media item
|
||||||
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
|
LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error)
|
||||||
|
ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error)
|
||||||
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error)
|
||||||
|
ListConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListConflictsByUserRow, error)
|
||||||
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||||
@@ -289,8 +329,13 @@ type Querier interface {
|
|||||||
// List unresolved unlinked books with pagination
|
// List unresolved unlinked books with pagination
|
||||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
|
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
|
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
|
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
// Query media items by multiple identifiers with confidence scoring
|
// Query media items by multiple identifiers with confidence scoring
|
||||||
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
||||||
|
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
||||||
|
ReassignMediaItems(ctx context.Context, arg ReassignMediaItemsParams) error
|
||||||
// Remove book from collection
|
// Remove book from collection
|
||||||
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
||||||
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
||||||
@@ -316,6 +361,13 @@ type Querier interface {
|
|||||||
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
|
||||||
// Set system config
|
// Set system config
|
||||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||||
|
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
||||||
|
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
||||||
|
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
|
||||||
|
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
|
||||||
|
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
|
||||||
|
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||||
|
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
|
||||||
// Update collection
|
// Update collection
|
||||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
@@ -339,7 +391,9 @@ type Querier interface {
|
|||||||
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
||||||
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
||||||
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error)
|
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error)
|
||||||
|
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
|
||||||
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
||||||
|
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
||||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
||||||
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||||
// Update media item format
|
// Update media item format
|
||||||
@@ -357,6 +411,7 @@ type Querier interface {
|
|||||||
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
||||||
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
|
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
|
||||||
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
|
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
|
||||||
|
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
||||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||||
@@ -370,10 +425,12 @@ type Querier interface {
|
|||||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||||
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
||||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||||
|
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||||
|
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ Querier = (*Queries)(nil)
|
var _ Querier = (*Queries)(nil)
|
||||||
|
|||||||
+2157
-90
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ SELECT
|
|||||||
u.max_devices,
|
u.max_devices,
|
||||||
u.created_at,
|
u.created_at,
|
||||||
u.updated_at,
|
u.updated_at,
|
||||||
|
u.timezone,
|
||||||
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
||||||
FROM users u
|
FROM users u
|
||||||
WHERE u.id = $1;
|
WHERE u.id = $1;
|
||||||
@@ -60,6 +61,9 @@ SELECT * FROM library_types WHERE id = $1;
|
|||||||
-- name: GetLibraryTypeByName :one
|
-- name: GetLibraryTypeByName :one
|
||||||
SELECT * FROM library_types WHERE name = $1;
|
SELECT * FROM library_types WHERE name = $1;
|
||||||
|
|
||||||
|
-- name: SyncLibraryTypeExtensions :exec
|
||||||
|
UPDATE library_types SET allowed_extensions = $2 WHERE name = $1;
|
||||||
|
|
||||||
-- Libraries queries
|
-- Libraries queries
|
||||||
-- name: CreateLibrary :one
|
-- name: CreateLibrary :one
|
||||||
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
|
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
|
JOIN libraries l ON lf.library_id = l.id
|
||||||
WHERE lf.folder_path = $1;
|
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
|
-- Library Visibility queries
|
||||||
-- name: SetLibraryVisibility :one
|
-- name: SetLibraryVisibility :one
|
||||||
INSERT INTO library_visibility (user_id, library_id, is_visible)
|
INSERT INTO library_visibility (user_id, library_id, is_visible)
|
||||||
@@ -126,10 +137,18 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|||||||
WHERE COALESCE(lv.is_visible, true) = true
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
ORDER BY l.created_at ASC;
|
ORDER BY l.created_at ASC;
|
||||||
|
|
||||||
|
-- name: GetVisibleLibraryMediaCounts :many
|
||||||
|
SELECT l.id, COUNT(mi.id) as media_count
|
||||||
|
FROM libraries l
|
||||||
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||||
|
LEFT JOIN media_items mi ON mi.library_id = l.id
|
||||||
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
|
GROUP BY l.id;
|
||||||
|
|
||||||
-- Media Items queries
|
-- Media Items queries
|
||||||
-- name: CreateMediaItem :one
|
-- name: CreateMediaItem :one
|
||||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43)
|
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)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetMediaItem :one
|
-- name: GetMediaItem :one
|
||||||
@@ -248,6 +267,20 @@ UPDATE media_items SET
|
|||||||
goodreads_id = $21,
|
goodreads_id = $21,
|
||||||
openlibrary_id = $22,
|
openlibrary_id = $22,
|
||||||
google_books_id = $23,
|
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()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
@@ -300,9 +333,36 @@ RETURNING *;
|
|||||||
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
|
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
|
||||||
RETURNING id, email, username, role;
|
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
|
-- name: CountUserDevices :one
|
||||||
SELECT COUNT(*) FROM devices WHERE user_id = $1;
|
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
|
-- name: DeleteUser :exec
|
||||||
DELETE FROM users WHERE id = $1;
|
DELETE FROM users WHERE id = $1;
|
||||||
|
|
||||||
@@ -313,9 +373,29 @@ SELECT setting_value FROM system_settings WHERE setting_key = $1;
|
|||||||
-- name: UpdateSystemSetting :exec
|
-- name: UpdateSystemSetting :exec
|
||||||
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
|
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
|
||||||
|
|
||||||
|
-- name: UpsertSystemSetting :one
|
||||||
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
ON CONFLICT (setting_key) DO UPDATE
|
||||||
|
SET setting_value = EXCLUDED.setting_value,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
setting_type = EXCLUDED.setting_type,
|
||||||
|
min_value = EXCLUDED.min_value,
|
||||||
|
max_value = EXCLUDED.max_value,
|
||||||
|
requires_restart = EXCLUDED.requires_restart,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
updated_at = NOW()
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: GetSystemSettingFull :one
|
||||||
|
SELECT * FROM system_settings WHERE setting_key = $1;
|
||||||
|
|
||||||
-- name: GetAllSystemSettings :many
|
-- name: GetAllSystemSettings :many
|
||||||
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
|
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
|
||||||
|
|
||||||
|
-- name: GetAllSystemSettingsFull :many
|
||||||
|
SELECT * FROM system_settings ORDER BY category, setting_key;
|
||||||
|
|
||||||
-- name: CreateMediaRating :one
|
-- name: CreateMediaRating :one
|
||||||
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
||||||
VALUES ($1, $2, $3)
|
VALUES ($1, $2, $3)
|
||||||
@@ -644,7 +724,7 @@ RETURNING *;
|
|||||||
SELECT * FROM media_notes WHERE id = $1;
|
SELECT * FROM media_notes WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetMediaNotes :many
|
-- name: GetMediaNotes :many
|
||||||
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: UpdateMediaNote :one
|
-- name: UpdateMediaNote :one
|
||||||
UPDATE media_notes SET
|
UPDATE media_notes SET
|
||||||
@@ -667,7 +747,7 @@ RETURNING *;
|
|||||||
SELECT * FROM media_highlights WHERE id = $1;
|
SELECT * FROM media_highlights WHERE id = $1;
|
||||||
|
|
||||||
-- name: GetMediaHighlights :many
|
-- name: GetMediaHighlights :many
|
||||||
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: UpdateMediaHighlight :one
|
-- name: UpdateMediaHighlight :one
|
||||||
UPDATE media_highlights SET
|
UPDATE media_highlights SET
|
||||||
@@ -683,6 +763,251 @@ RETURNING *;
|
|||||||
-- name: DeleteMediaHighlight :exec
|
-- name: DeleteMediaHighlight :exec
|
||||||
DELETE FROM media_highlights WHERE id = $1;
|
DELETE FROM media_highlights WHERE id = $1;
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- ANNOTATION SYNC QUERIES (highlights)
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- name: GetMediaHighlightByDedupKey :one
|
||||||
|
SELECT * FROM media_highlights
|
||||||
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||||
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- name: CreateMediaHighlightFull :one
|
||||||
|
INSERT INTO media_highlights (
|
||||||
|
media_item_id, user_id, selection_text,
|
||||||
|
start_position, end_position, color, note_text,
|
||||||
|
percentage_start, percentage_end,
|
||||||
|
epubcfi_start, epubcfi_end,
|
||||||
|
chapter_reference,
|
||||||
|
dedup_key, last_modified_at, last_modified_source,
|
||||||
|
device_sync_data
|
||||||
|
) VALUES (
|
||||||
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
||||||
|
) RETURNING *;
|
||||||
|
|
||||||
|
-- name: UpdateMediaHighlightForSync :one
|
||||||
|
UPDATE media_highlights SET
|
||||||
|
selection_text = $2,
|
||||||
|
start_position = $3,
|
||||||
|
end_position = $4,
|
||||||
|
color = $5,
|
||||||
|
note_text = $6,
|
||||||
|
percentage_start = $7,
|
||||||
|
percentage_end = $8,
|
||||||
|
epubcfi_start = $9,
|
||||||
|
epubcfi_end = $10,
|
||||||
|
chapter_reference = $11,
|
||||||
|
last_modified_at = $12,
|
||||||
|
last_modified_source = $13,
|
||||||
|
device_sync_data = $14,
|
||||||
|
updated_at = NOW()
|
||||||
|
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()
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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
|
||||||
|
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;
|
||||||
|
|
||||||
-- Refresh Tokens queries
|
-- Refresh Tokens queries
|
||||||
-- name: CreateRefreshToken :one
|
-- name: CreateRefreshToken :one
|
||||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||||
@@ -702,7 +1027,7 @@ UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
|
|||||||
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
|
||||||
|
|
||||||
-- name: CleanupExpiredRefreshTokens :exec
|
-- name: CleanupExpiredRefreshTokens :exec
|
||||||
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days');
|
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision));
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- FORMAT DETECTION & PROGRESS
|
-- FORMAT DETECTION & PROGRESS
|
||||||
@@ -744,6 +1069,7 @@ SELECT
|
|||||||
rp.percentage,
|
rp.percentage,
|
||||||
rp.character_offset,
|
rp.character_offset,
|
||||||
rp.epubcfi,
|
rp.epubcfi,
|
||||||
|
rp.context_text,
|
||||||
rp.chapter,
|
rp.chapter,
|
||||||
rp.chapter_progress,
|
rp.chapter_progress,
|
||||||
rp.viewport_x,
|
rp.viewport_x,
|
||||||
@@ -776,6 +1102,7 @@ INSERT INTO reading_progress (
|
|||||||
percentage,
|
percentage,
|
||||||
character_offset,
|
character_offset,
|
||||||
epubcfi,
|
epubcfi,
|
||||||
|
context_text,
|
||||||
chapter,
|
chapter,
|
||||||
chapter_progress,
|
chapter_progress,
|
||||||
viewport_x,
|
viewport_x,
|
||||||
@@ -793,13 +1120,14 @@ INSERT INTO reading_progress (
|
|||||||
last_read_at
|
last_read_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW()
|
||||||
)
|
)
|
||||||
ON CONFLICT (media_item_id, user_id)
|
ON CONFLICT (media_item_id, user_id)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
percentage = EXCLUDED.percentage,
|
percentage = EXCLUDED.percentage,
|
||||||
character_offset = EXCLUDED.character_offset,
|
character_offset = EXCLUDED.character_offset,
|
||||||
epubcfi = EXCLUDED.epubcfi,
|
epubcfi = EXCLUDED.epubcfi,
|
||||||
|
context_text = EXCLUDED.context_text,
|
||||||
chapter = EXCLUDED.chapter,
|
chapter = EXCLUDED.chapter,
|
||||||
chapter_progress = EXCLUDED.chapter_progress,
|
chapter_progress = EXCLUDED.chapter_progress,
|
||||||
viewport_x = EXCLUDED.viewport_x,
|
viewport_x = EXCLUDED.viewport_x,
|
||||||
@@ -1071,6 +1399,11 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data
|
|||||||
VALUES ($1, $2, $3, $4)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
|
-- name: CreateAutoResolvedSyncConflict :one
|
||||||
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
|
||||||
|
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
|
||||||
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetSyncConflict :one
|
-- name: GetSyncConflict :one
|
||||||
SELECT * FROM sync_conflicts WHERE id = $1;
|
SELECT * FROM sync_conflicts WHERE id = $1;
|
||||||
|
|
||||||
@@ -1099,19 +1432,28 @@ RETURNING *;
|
|||||||
-- name: DeleteSyncConflict :exec
|
-- name: DeleteSyncConflict :exec
|
||||||
DELETE FROM sync_conflicts WHERE id = $1;
|
DELETE FROM sync_conflicts WHERE id = $1;
|
||||||
|
|
||||||
-- name: ListAllConflictsByUserAndStatus :many
|
-- name: ListAllConflictsByUserAndStatus :many
|
||||||
SELECT sc.*, mi.title, mi.author
|
SELECT sc.*, mi.title, mi.author
|
||||||
FROM sync_conflicts sc
|
FROM sync_conflicts sc
|
||||||
JOIN media_items mi ON sc.media_item_id = mi.id
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
||||||
WHERE sc.user_id = $1 AND sc.resolution_status = $2
|
WHERE sc.user_id = $1 AND sc.resolution_status = $2
|
||||||
ORDER BY sc.created_at DESC;
|
ORDER BY sc.created_at DESC;
|
||||||
|
|
||||||
-- name: ListConflictsByUser :many
|
-- name: ListConflictsByUser :many
|
||||||
SELECT sc.*, mi.title, mi.author
|
SELECT sc.*, mi.title, mi.author
|
||||||
FROM sync_conflicts sc
|
FROM sync_conflicts sc
|
||||||
JOIN media_items mi ON sc.media_item_id = mi.id
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
||||||
WHERE sc.user_id = $1
|
WHERE sc.user_id = $1
|
||||||
ORDER BY sc.created_at DESC;
|
ORDER BY sc.created_at DESC;
|
||||||
|
|
||||||
|
-- name: HasRecentConflictResolution :one
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM sync_conflicts
|
||||||
|
WHERE media_item_id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
AND resolution_status != 'unresolved'
|
||||||
|
AND resolved_at > NOW() - INTERVAL '10 minutes'
|
||||||
|
);
|
||||||
|
|
||||||
-- ============================================
|
-- ============================================
|
||||||
-- KOREADER SYNC PROTOCOL
|
-- KOREADER SYNC PROTOCOL
|
||||||
@@ -1161,7 +1503,7 @@ SELECT
|
|||||||
mh.epubcfi_start,
|
mh.epubcfi_start,
|
||||||
mh.epubcfi_end
|
mh.epubcfi_end
|
||||||
FROM media_highlights mh
|
FROM media_highlights mh
|
||||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT
|
SELECT
|
||||||
mn.id,
|
mn.id,
|
||||||
@@ -1177,7 +1519,7 @@ SELECT
|
|||||||
mn.epubcfi_location as epubcfi_start,
|
mn.epubcfi_location as epubcfi_start,
|
||||||
NULL as epubcfi_end
|
NULL as epubcfi_end
|
||||||
FROM media_notes mn
|
FROM media_notes mn
|
||||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
-- name: UpdateDeviceSyncTimestamp :one
|
-- name: UpdateDeviceSyncTimestamp :one
|
||||||
@@ -1216,7 +1558,7 @@ WHERE lv.user_id = $1
|
|||||||
ORDER BY mi.title ASC
|
ORDER BY mi.title ASC
|
||||||
LIMIT 1000;
|
LIMIT 1000;
|
||||||
|
|
||||||
-- name: CheckForProgressConflicts :one
|
-- name: CheckForProgressConflicts :one
|
||||||
SELECT COUNT(*) as conflict_count
|
SELECT COUNT(*) as conflict_count
|
||||||
FROM reading_progress
|
FROM reading_progress
|
||||||
WHERE media_item_id = $1
|
WHERE media_item_id = $1
|
||||||
@@ -1781,7 +2123,7 @@ LIMIT $1 OFFSET $2;
|
|||||||
-- Dashboard preferences queries
|
-- Dashboard preferences queries
|
||||||
-- name: GetDashboardPreferences :one
|
-- name: GetDashboardPreferences :one
|
||||||
SELECT * FROM user_dashboard_preferences
|
SELECT * FROM user_dashboard_preferences
|
||||||
WHERE user_id = $1 AND library_id = $2;
|
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
|
||||||
|
|
||||||
-- name: UpsertDashboardPreferences :one
|
-- name: UpsertDashboardPreferences :one
|
||||||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
||||||
@@ -1845,59 +2187,112 @@ SELECT mi.* FROM media_items mi
|
|||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||||
FROM reading_progress
|
FROM reading_progress
|
||||||
WHERE user_id = $2
|
WHERE user_id = sqlc.narg('user_id')
|
||||||
AND percentage > 0
|
AND percentage > 0
|
||||||
AND percentage < 1
|
AND percentage < 1
|
||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY rp.last_read_at DESC
|
ORDER BY rp.last_read_at DESC
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetRecentlyAddedItems :many
|
-- name: GetRecentlyAddedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY mi.created_at DESC
|
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||||
LIMIT $2;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetRecentlyReadItems :many
|
-- name: GetRecentlyReadItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
INNER JOIN (
|
INNER JOIN (
|
||||||
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
||||||
FROM reading_progress
|
FROM reading_progress
|
||||||
WHERE user_id = $2
|
WHERE user_id = sqlc.narg('user_id')
|
||||||
AND percentage >= 1
|
AND percentage >= 1
|
||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY rp.last_read_at DESC
|
ORDER BY rp.last_read_at DESC
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetNotStartedItems :many
|
-- name: GetNotStartedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM reading_progress rp
|
SELECT 1 FROM reading_progress rp
|
||||||
WHERE rp.media_item_id = mi.id
|
WHERE rp.media_item_id = mi.id
|
||||||
AND rp.user_id = $2
|
AND rp.user_id = sqlc.narg('user_id')
|
||||||
AND rp.percentage > 0
|
AND rp.percentage > 0
|
||||||
)
|
)
|
||||||
ORDER BY mi.created_at DESC
|
ORDER BY mi.created_at DESC
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetCollectionItemsForDashboard :many
|
-- name: GetCollectionItemsForDashboard :many
|
||||||
SELECT mi.*, ci.excluded FROM media_items mi
|
SELECT mi.*, ci.excluded FROM media_items mi
|
||||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||||
WHERE ci.collection_id = $1
|
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||||
AND mi.library_id = $2
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY ci.added_at DESC
|
ORDER BY ci.added_at DESC
|
||||||
LIMIT $3;
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetLibraryItems :many
|
-- name: GetLibraryItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE mi.library_id = $1
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
ORDER BY mi.created_at DESC;
|
ORDER BY mi.created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetDistinctSeries :many
|
||||||
|
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
|
-- name: GetSavedFilters :many
|
||||||
SELECT * FROM saved_filters
|
SELECT * FROM saved_filters
|
||||||
WHERE user_id = @user_id AND resource_type = @resource_type
|
WHERE user_id = @user_id AND resource_type = @resource_type
|
||||||
@@ -1995,9 +2390,12 @@ RETURNING *;
|
|||||||
|
|
||||||
-- name: GetMediaBookmarks :many
|
-- name: GetMediaBookmarks :many
|
||||||
SELECT * FROM media_bookmarks
|
SELECT * FROM media_bookmarks
|
||||||
WHERE media_item_id = $1 AND user_id = $2
|
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
|
||||||
ORDER BY created_at DESC;
|
ORDER BY created_at DESC;
|
||||||
|
|
||||||
|
-- name: GetMediaBookmark :one
|
||||||
|
SELECT * FROM media_bookmarks WHERE id = $1;
|
||||||
|
|
||||||
-- name: CreateMediaBookmark :one
|
-- name: CreateMediaBookmark :one
|
||||||
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
@@ -2093,3 +2491,8 @@ SELECT
|
|||||||
FROM libraries l
|
FROM libraries l
|
||||||
JOIN library_types lt ON l.library_type_id = lt.id
|
JOIN library_types lt ON l.library_type_id = lt.id
|
||||||
WHERE l.id = $1;
|
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"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"bookhoard/templates"
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
highlighting "github.com/yuin/goldmark-highlighting"
|
highlighting "github.com/yuin/goldmark-highlighting"
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
"github.com/yuin/goldmark/parser"
|
"github.com/yuin/goldmark/parser"
|
||||||
"github.com/yuin/goldmark/renderer/html"
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
"golang.org/x/text/cases"
|
||||||
"bookhoard/templates"
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DocsHandler struct {
|
type DocsHandler struct {
|
||||||
@@ -77,7 +79,7 @@ func (h *DocsHandler) LoadDocument(docPath string) (*templates.Document, error)
|
|||||||
// Convert markdown to HTML
|
// Convert markdown to HTML
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
context := parser.NewContext()
|
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)
|
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)
|
// Don't add the last part (current page)
|
||||||
if i < len(parts)-1 {
|
if i < len(parts)-1 {
|
||||||
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
|
breadcrumb = append(breadcrumb, templates.BreadcrumbItem{
|
||||||
Title: strings.Title(strings.ReplaceAll(part, "-", " ")),
|
Title: cases.Title(language.English).String(strings.ReplaceAll(part, "-", " ")),
|
||||||
URL: "/docs" + path,
|
URL: "/docs" + path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
|
|
||||||
|
"golang.org/x/text/cases"
|
||||||
|
"golang.org/x/text/language"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildNavigation creates the navigation structure from the docs filesystem
|
// 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]
|
filename := parts[len(parts)-1]
|
||||||
|
|
||||||
// Convert to title case
|
// Convert to title case
|
||||||
title = strings.Title(strings.ReplaceAll(filename, "-", " "))
|
title = cases.Title(language.English).String(strings.ReplaceAll(filename, "-", " "))
|
||||||
|
|
||||||
// Handle special cases
|
// Handle special cases
|
||||||
switch filename {
|
switch filename {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -74,18 +75,18 @@ func (h *AnalyticsHandler) GetReadingStats(c *echo.Context) error {
|
|||||||
endDate := c.QueryParam("end_date")
|
endDate := c.QueryParam("end_date")
|
||||||
|
|
||||||
if startDate == "" {
|
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 == "" {
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid start_date format")
|
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 {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid end_date format")
|
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},
|
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")
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get reading history")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +130,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
|||||||
longestSession = int(minutes)
|
longestSession = int(minutes)
|
||||||
}
|
}
|
||||||
|
|
||||||
dateKey := entry.CreatedAt.Time.Format("2006-01-02")
|
dateKey := entry.CreatedAt.Time.Format("01-02-2006")
|
||||||
if dailyMap[dateKey] == nil {
|
if dailyMap[dateKey] == nil {
|
||||||
dailyMap[dateKey] = &DailyReading{
|
dailyMap[dateKey] = &DailyReading{
|
||||||
Date: dateKey,
|
Date: dateKey,
|
||||||
@@ -140,7 +141,7 @@ func (h *AnalyticsHandler) calculateReadingStats(history []database.GetUserReadi
|
|||||||
|
|
||||||
if entry.PagesRead.Valid {
|
if entry.PagesRead.Valid {
|
||||||
totalPages += int(entry.PagesRead.Int32)
|
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 {
|
if dailyMap[dateKey] != nil {
|
||||||
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
|
dailyMap[dateKey].Pages += int(entry.PagesRead.Int32)
|
||||||
}
|
}
|
||||||
@@ -211,7 +212,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
usage, err := h.db.GetUserDeviceUsage(ctx, user.ID)
|
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")
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get device usage")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +222,7 @@ func (h *AnalyticsHandler) GetDeviceUsage(c *echo.Context) error {
|
|||||||
lastSync := ""
|
lastSync := ""
|
||||||
if u.LastSync != nil {
|
if u.LastSync != nil {
|
||||||
if t, ok := u.LastSync.(time.Time); ok {
|
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,
|
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")
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get popular books")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,7 +274,7 @@ func (h *AnalyticsHandler) GetPopularBooks(c *echo.Context) error {
|
|||||||
lastRead := ""
|
lastRead := ""
|
||||||
if book.LastRead != nil {
|
if book.LastRead != nil {
|
||||||
if t, ok := book.LastRead.(time.Time); ok {
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+101
-33
@@ -6,7 +6,9 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/middleware"
|
"bookhoard/internal/middleware"
|
||||||
|
"bookhoard/internal/setupstatus"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -24,14 +26,34 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Session duration constants
|
// DefaultSessionDuration is the fallback session duration used when no
|
||||||
// Follows same pattern as refresh_token.go
|
// settings registry is wired (matches the historical 7-day value).
|
||||||
SessionDuration = 7 * 24 * time.Hour // 7 days
|
DefaultSessionDuration = 7 * 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
// SessionDurationSec is the session duration in seconds for use in cookies and API responses
|
// SessionDurationSec is retained for backward compatibility; new code uses the
|
||||||
// Note: This is computed from SessionDuration to avoid magic numbers
|
// registry via AuthHandler.sessionDuration().
|
||||||
var SessionDurationSec = int(SessionDuration.Seconds())
|
var SessionDurationSec = int(DefaultSessionDuration.Seconds())
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry (optional).
|
||||||
|
func (h *AuthHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||||
|
|
||||||
|
// sessionDuration returns the active session duration from the registry.
|
||||||
|
func (h *AuthHandler) sessionDuration() time.Duration {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.SessionDuration()
|
||||||
|
}
|
||||||
|
return DefaultSessionDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshTokenTTL returns the active refresh-token lifetime (shared with the
|
||||||
|
// session duration), with a compiled-default fallback.
|
||||||
|
func (h *AuthHandler) refreshTokenTTL() time.Duration {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.SessionDuration()
|
||||||
|
}
|
||||||
|
return DefaultSessionDuration
|
||||||
|
}
|
||||||
|
|
||||||
var secure = os.Getenv("COOKIE_SECURE")
|
var secure = os.Getenv("COOKIE_SECURE")
|
||||||
|
|
||||||
@@ -39,6 +61,7 @@ type AuthHandler struct {
|
|||||||
db *database.Queries
|
db *database.Queries
|
||||||
jwtKey []byte
|
jwtKey []byte
|
||||||
loginAttemptTracker *middleware.LoginAttemptTracker
|
loginAttemptTracker *middleware.LoginAttemptTracker
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
func NewAuthHandler(db *database.Queries, jwtSecret string, loginAttemptTracker *middleware.LoginAttemptTracker) *AuthHandler {
|
||||||
@@ -81,20 +104,22 @@ type UserProfile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
|
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUpdateUserRequest struct {
|
type AdminUpdateUserRequest struct {
|
||||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||||
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
|
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||||
|
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register handles POST /api/auth/register
|
// Register handles POST /api/auth/register
|
||||||
@@ -187,7 +212,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userRole string
|
var userRole string
|
||||||
if len(users) == 0 {
|
if !adminExists {
|
||||||
userRole = "admin"
|
userRole = "admin"
|
||||||
} else {
|
} else {
|
||||||
userRole = req.Role
|
userRole = req.Role
|
||||||
@@ -229,6 +254,10 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A new user may have changed the admin count (e.g. first user becomes
|
||||||
|
// admin), so refresh the setup-status cache.
|
||||||
|
setupstatus.Invalidate()
|
||||||
|
|
||||||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
||||||
@@ -257,11 +286,11 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
|||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: SessionDurationSec,
|
MaxAge: int(h.sessionDuration().Seconds()),
|
||||||
}
|
}
|
||||||
c.SetCookie(cookie)
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
_, refreshToken, err := h.CreateRefreshToken(uuid.UUID(user.ID.Bytes))
|
_, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
||||||
@@ -290,7 +319,7 @@ window.location.href = '/dashboard';
|
|||||||
Token: accessToken,
|
Token: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: SessionDurationSec,
|
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||||
User: UserProfile{
|
User: UserProfile{
|
||||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
@@ -403,11 +432,11 @@ func (h *AuthHandler) Login(c *echo.Context) error {
|
|||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
Secure: secure == "true", // TODO: Set to true in production with HTTPS
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
MaxAge: SessionDurationSec,
|
MaxAge: int(h.sessionDuration().Seconds()),
|
||||||
}
|
}
|
||||||
c.SetCookie(cookie)
|
c.SetCookie(cookie)
|
||||||
|
|
||||||
_, refreshToken, err := h.CreateRefreshToken(uuid.UUID(user.ID.Bytes))
|
_, refreshToken, err := h.CreateRefreshToken(user.ID.Bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to generate refresh token</div>`)
|
||||||
@@ -442,7 +471,7 @@ window.location.href = '%s';
|
|||||||
Token: accessToken,
|
Token: accessToken,
|
||||||
RefreshToken: refreshToken,
|
RefreshToken: refreshToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: SessionDurationSec,
|
ExpiresIn: int(h.sessionDuration().Seconds()),
|
||||||
User: UserProfile{
|
User: UserProfile{
|
||||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||||
Email: user.Email,
|
Email: user.Email,
|
||||||
@@ -495,7 +524,7 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||||
}
|
}
|
||||||
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
|
||||||
} else {
|
} else {
|
||||||
// Self-edit mode
|
// Self-edit mode
|
||||||
targetUserUUID = currentUser.ID
|
targetUserUUID = currentUser.ID
|
||||||
@@ -545,6 +574,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Role changes can affect the admin count, so refresh the setup-status cache.
|
||||||
|
setupstatus.Invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update username (if provided)
|
// Update username (if provided)
|
||||||
@@ -563,6 +595,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update timezone
|
||||||
|
if req.Timezone != "" {
|
||||||
|
if _, err := time.LoadLocation(req.Timezone); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "Invalid timezone",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
err := h.db.UpdateUserTimezone(c.Request().Context(), database.UpdateUserTimezoneParams{
|
||||||
|
ID: targetUserUUID,
|
||||||
|
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update email (if provided)
|
// Update email (if provided)
|
||||||
if req.Email != "" {
|
if req.Email != "" {
|
||||||
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
|
existingUser, err := h.db.GetUserByEmail(c.Request().Context(), req.Email)
|
||||||
@@ -759,16 +807,16 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||||
}
|
}
|
||||||
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
|
||||||
} else {
|
} else {
|
||||||
// Self-change mode
|
// Self-change mode
|
||||||
targetUserUUID = currentUser.ID
|
targetUserUUID = currentUser.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
type PasswordRequest struct {
|
type PasswordRequest struct {
|
||||||
CurrentPassword string `json:"current_password,omitempty"`
|
CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
|
||||||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
|
||||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var req PasswordRequest
|
var req PasswordRequest
|
||||||
@@ -839,7 +887,7 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||||
}
|
}
|
||||||
targetUserUUID = pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}
|
targetUserUUID = pgtype.UUID{Bytes: parsedUUID, Valid: true}
|
||||||
} else {
|
} else {
|
||||||
// Self-deletion mode
|
// Self-deletion mode
|
||||||
targetUserUUID = currentUser.ID
|
targetUserUUID = currentUser.ID
|
||||||
@@ -884,10 +932,26 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete the last admin account"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot delete the last admin account"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If deleting an admin, reassign their libraries and media items to another admin
|
||||||
|
// before deletion to prevent ON DELETE SET NULL from orphaning ownership
|
||||||
|
if targetUserRole == "admin" {
|
||||||
|
successor, err := h.db.GetFirstAdminExclude(c.Request().Context(), targetUserUUID)
|
||||||
|
if err == nil {
|
||||||
|
_ = h.db.ReassignLibraries(c.Request().Context(), database.ReassignLibrariesParams{
|
||||||
|
CreatedByAdminID: targetUserUUID,
|
||||||
|
CreatedByAdminID_2: successor.ID,
|
||||||
|
})
|
||||||
|
_ = h.db.ReassignMediaItems(c.Request().Context(), database.ReassignMediaItemsParams{
|
||||||
|
AddedByAdminID: targetUserUUID,
|
||||||
|
AddedByAdminID_2: successor.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delete user (this will cascade to delete all related data)
|
// Delete user (this will cascade to delete all related data)
|
||||||
err = h.db.DeleteUser(c.Request().Context(), targetUserUUID)
|
err = h.db.DeleteUser(c.Request().Context(), targetUserUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
return c.HTML(http.StatusNotFound, `<div class="text-red-500">User not found</div>`)
|
return c.HTML(http.StatusNotFound, `<div class="text-red-500">User not found</div>`)
|
||||||
}
|
}
|
||||||
@@ -899,6 +963,9 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletion may have changed the admin count, so refresh the setup-status cache.
|
||||||
|
setupstatus.Invalidate()
|
||||||
|
|
||||||
// Create success message based on context
|
// Create success message based on context
|
||||||
var message string
|
var message string
|
||||||
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
||||||
@@ -940,7 +1007,7 @@ func (h *AuthHandler) UpdateUserMaxDevices(c *echo.Context) error {
|
|||||||
MaxDevices: pgtype.Int4{Int32: req.MaxDevices, Valid: true},
|
MaxDevices: pgtype.Int4{Int32: req.MaxDevices, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "user not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -968,7 +1035,7 @@ func (h *AuthHandler) generateJWTWithAllClaims(userID, userRole, userEmail, user
|
|||||||
"user_role": userRole,
|
"user_role": userRole,
|
||||||
"user_email": userEmail,
|
"user_email": userEmail,
|
||||||
"user_username": userUsername,
|
"user_username": userUsername,
|
||||||
"exp": time.Now().Add(SessionDuration).Unix(),
|
"exp": time.Now().Add(h.sessionDuration()).Unix(),
|
||||||
"iat": time.Now().Unix(),
|
"iat": time.Now().Unix(),
|
||||||
}
|
}
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
@@ -996,6 +1063,7 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
|
|||||||
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||||
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
{"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},
|
{"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 {
|
for _, col := range defaultCollections {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -72,6 +73,7 @@ type BookInfo struct {
|
|||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Author string `json:"author"`
|
Author string `json:"author"`
|
||||||
CoverImagePath string `json:"cover_image_path"`
|
CoverImagePath string `json:"cover_image_path"`
|
||||||
|
HasConflict bool `json:"has_conflict"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionData struct {
|
type SectionData struct {
|
||||||
@@ -137,6 +139,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|||||||
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
||||||
includeAuto := c.QueryParam("include_auto") == "true"
|
includeAuto := c.QueryParam("include_auto") == "true"
|
||||||
sortBy := c.QueryParam("sort_by")
|
sortBy := c.QueryParam("sort_by")
|
||||||
|
libraryID := c.QueryParam("library_id")
|
||||||
|
|
||||||
collections, err := h.GetCollectionsData(c)
|
collections, err := h.GetCollectionsData(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -151,6 +154,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
Icon string `json:"icon"`
|
Icon string `json:"icon"`
|
||||||
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
|
BookCount int `json:"book_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
response := make([]CollectionResponse, 0, len(collections))
|
response := make([]CollectionResponse, 0, len(collections))
|
||||||
@@ -158,14 +162,35 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|||||||
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
if !includeAuto && len(col.AutoAssignRules) > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bookCount := 0
|
||||||
|
if libraryID != "" {
|
||||||
|
libUUID, libErr := uuid.Parse(libraryID)
|
||||||
|
if libErr == nil {
|
||||||
|
items, countErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{
|
||||||
|
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
||||||
|
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||||
|
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||||
|
})
|
||||||
|
if countErr == nil {
|
||||||
|
for _, item := range items {
|
||||||
|
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||||
|
bookCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response = append(response, CollectionResponse{
|
response = append(response, CollectionResponse{
|
||||||
ID: uuid.UUID(col.ID.Bytes),
|
ID: col.ID.Bytes,
|
||||||
Name: col.Name,
|
Name: col.Name,
|
||||||
Description: textToString(col.Description),
|
Description: textToString(col.Description),
|
||||||
Color: textToString(col.Color),
|
Color: textToString(col.Color),
|
||||||
Icon: textToString(col.Icon),
|
Icon: textToString(col.Icon),
|
||||||
AutoAssignRules: json.RawMessage(col.AutoAssignRules),
|
AutoAssignRules: col.AutoAssignRules,
|
||||||
CreatedAt: col.CreatedAt.Time.String(),
|
CreatedAt: col.CreatedAt.Time.String(),
|
||||||
|
BookCount: bookCount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,24 +221,92 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
books, err := h.GetCollectionBooksData(c, collectionID)
|
libraryID := c.QueryParam("library_id")
|
||||||
if err != nil {
|
var bookList []BookInfo
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
||||||
|
var libUUID pgtype.UUID
|
||||||
|
if libraryID != "" {
|
||||||
|
parsed, parseErr := uuid.Parse(libraryID)
|
||||||
|
if parseErr != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||||
|
}
|
||||||
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
bookList := make([]BookInfo, 0, len(books))
|
if collection.QueryType.Valid && collection.QueryType.String != "" {
|
||||||
for _, book := range books {
|
user := c.Get("user").(database.Users)
|
||||||
bookList = append(bookList, BookInfo{
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
dashboardSvc := services.NewDashboardService(h.db)
|
||||||
Title: book.Title,
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
||||||
Author: textToString(book.Author),
|
if secErr != nil {
|
||||||
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
|
||||||
})
|
}
|
||||||
|
for _, section := range sections {
|
||||||
|
if section.CollectionID == collectionID {
|
||||||
|
bookCards := make([]BookInfo, len(section.Items))
|
||||||
|
for i, item := range section.Items {
|
||||||
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
|
bookCards[i] = BookInfo{
|
||||||
|
MediaItemID: itemUUID.String(),
|
||||||
|
Title: item.Title,
|
||||||
|
Author: textToString(item.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookList = bookCards
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if libUUID.Valid {
|
||||||
|
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||||
|
database.GetCollectionItemsForDashboardParams{
|
||||||
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||||
|
LibraryID: libUUID,
|
||||||
|
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||||
|
})
|
||||||
|
if collErr != nil {
|
||||||
|
bookList = []BookInfo{}
|
||||||
|
} else {
|
||||||
|
var validItems []database.GetCollectionItemsForDashboardRow
|
||||||
|
for _, item := range collItems {
|
||||||
|
if !item.Excluded.Valid || !item.Excluded.Bool {
|
||||||
|
validItems = append(validItems, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookCards := make([]BookInfo, len(validItems))
|
||||||
|
for i, item := range validItems {
|
||||||
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
|
bookCards[i] = BookInfo{
|
||||||
|
MediaItemID: itemUUID.String(),
|
||||||
|
Title: item.Title,
|
||||||
|
Author: textToString(item.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookList = bookCards
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
books, booksErr := h.GetCollectionBooksData(c, collectionID)
|
||||||
|
if booksErr != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": booksErr.Error()})
|
||||||
|
}
|
||||||
|
bookList = make([]BookInfo, 0, len(books))
|
||||||
|
for _, book := range books {
|
||||||
|
bookList = append(bookList, BookInfo{
|
||||||
|
MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(),
|
||||||
|
Title: book.Title,
|
||||||
|
Author: textToString(book.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var viewSettings map[string]interface{}
|
var viewSettings map[string]interface{}
|
||||||
if len(collection.ViewSettings) > 0 {
|
if len(collection.ViewSettings) > 0 {
|
||||||
json.Unmarshal(collection.ViewSettings, &viewSettings)
|
err := json.Unmarshal(collection.ViewSettings, &viewSettings)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
@@ -459,8 +552,8 @@ func (h *CollectionHandler) GetDeviceMappings(c *echo.Context) error {
|
|||||||
response := make([]MappingResponse, 0, len(mappings))
|
response := make([]MappingResponse, 0, len(mappings))
|
||||||
for _, m := range mappings {
|
for _, m := range mappings {
|
||||||
response = append(response, MappingResponse{
|
response = append(response, MappingResponse{
|
||||||
ID: uuid.UUID(m.ID.Bytes),
|
ID: m.ID.Bytes,
|
||||||
CollectionID: uuid.UUID(m.CollectionID.Bytes),
|
CollectionID: m.CollectionID.Bytes,
|
||||||
CollectionName: m.CollectionName,
|
CollectionName: m.CollectionName,
|
||||||
DeviceShelfName: textToString(m.DeviceShelfName),
|
DeviceShelfName: textToString(m.DeviceShelfName),
|
||||||
SyncDirection: textToString(m.SyncDirection),
|
SyncDirection: textToString(m.SyncDirection),
|
||||||
@@ -585,7 +678,7 @@ func (h *CollectionHandler) GetBookCollections(c *echo.Context) error {
|
|||||||
response := make([]CollectionResponse, 0, len(collections))
|
response := make([]CollectionResponse, 0, len(collections))
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
response = append(response, CollectionResponse{
|
response = append(response, CollectionResponse{
|
||||||
ID: uuid.UUID(col.ID.Bytes),
|
ID: col.ID.Bytes,
|
||||||
Name: col.Name,
|
Name: col.Name,
|
||||||
Description: textToString(col.Description),
|
Description: textToString(col.Description),
|
||||||
Color: textToString(col.Color),
|
Color: textToString(col.Color),
|
||||||
@@ -891,7 +984,7 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
|||||||
|
|
||||||
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
|||||||
+174
-20
@@ -5,6 +5,7 @@ import (
|
|||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManag
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ConflictResolutionRequest struct {
|
type ConflictResolutionRequest struct {
|
||||||
Winner string `json:"winner" validate:"required,oneof=koreader kobo web manual"`
|
Winner string `json:"winner" validate:"required"`
|
||||||
ManualData map[string]interface{} `json:"manual_data"`
|
ManualData map[string]interface{} `json:"manual_data"`
|
||||||
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
|
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
@@ -83,7 +84,7 @@ func (h *ConflictHandler) GetConflictsData(c *echo.Context) ([]ConflictDetailRes
|
|||||||
conflicts, err = h.db.ListSyncConflictsByUser(ctx, user.ID)
|
conflicts, err = h.db.ListSyncConflictsByUser(ctx, user.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil && err != pgx.ErrNoRows {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, 0, 0, err
|
return nil, 0, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ func (h *ConflictHandler) GetConflict(c *echo.Context) error {
|
|||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||||
}
|
}
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
|
||||||
@@ -214,7 +215,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||||
}
|
}
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
|
||||||
@@ -224,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
if conflict.ResolutionStatus.String != "unresolved" {
|
if conflict.ResolutionStatus.String == "user_resolved" {
|
||||||
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,8 +235,10 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
winnerData := map[string]interface{}{}
|
winnerData := map[string]interface{}{}
|
||||||
|
winnerSource := req.Winner
|
||||||
if req.Winner == "manual" {
|
if req.Winner == "manual" {
|
||||||
winnerData = req.ManualData
|
winnerData = req.ManualData
|
||||||
|
winnerSource = "manual"
|
||||||
} else {
|
} else {
|
||||||
source, ok := conflictData[req.Winner]
|
source, ok := conflictData[req.Winner]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -250,11 +253,17 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if conflict.ConflictType == "progress" {
|
if conflict.ConflictType == "progress" {
|
||||||
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerData); err == nil {
|
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
|
||||||
appliedTo["progress"] = true
|
appliedTo["progress"] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
|
||||||
|
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
|
||||||
|
appliedTo["annotations"] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
resolutionData := map[string]interface{}{
|
resolutionData := map[string]interface{}{
|
||||||
"winner": req.Winner,
|
"winner": req.Winner,
|
||||||
"applied_to": appliedTo,
|
"applied_to": appliedTo,
|
||||||
@@ -285,14 +294,14 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, response)
|
return c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil && err != pgx.ErrNoRows {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,8 +325,12 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
|||||||
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
|
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPage := existingProgress.CurrentPage
|
var currentPage pgtype.Int4
|
||||||
totalPages := existingProgress.TotalPages
|
var totalPages pgtype.Int4
|
||||||
|
if err == nil {
|
||||||
|
currentPage = existingProgress.CurrentPage
|
||||||
|
totalPages = existingProgress.TotalPages
|
||||||
|
}
|
||||||
|
|
||||||
if p, ok := data["page"].(float64); ok {
|
if p, ok := data["page"].(float64); ok {
|
||||||
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
|
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
|
||||||
@@ -337,7 +350,7 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
|||||||
CurrentPage: currentPage,
|
CurrentPage: currentPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: "manual", Valid: true},
|
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
||||||
ViewportY: pgtype.Float8{},
|
ViewportY: pgtype.Float8{},
|
||||||
ScrollPositionX: pgtype.Float8{},
|
ScrollPositionX: pgtype.Float8{},
|
||||||
ScrollPositionY: pgtype.Float8{},
|
ScrollPositionY: pgtype.Float8{},
|
||||||
@@ -349,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
dedupKey, _ := winnerData["dedup_key"].(string)
|
||||||
|
if dedupKey == "" {
|
||||||
|
return errors.New("missing dedup_key in winner data")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch conflictType {
|
||||||
|
case "annotation_highlight":
|
||||||
|
return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||||
|
case "annotation_bookmark":
|
||||||
|
return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||||
|
case "annotation_note":
|
||||||
|
return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||||
|
default:
|
||||||
|
return errors.New("unknown annotation conflict type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||||
|
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := database.UpdateMediaHighlightForSyncParams{
|
||||||
|
ID: existing.ID,
|
||||||
|
SelectionText: existing.SelectionText,
|
||||||
|
StartPosition: existing.StartPosition,
|
||||||
|
EndPosition: existing.EndPosition,
|
||||||
|
Color: existing.Color,
|
||||||
|
NoteText: existing.NoteText,
|
||||||
|
PercentageStart: existing.PercentageStart,
|
||||||
|
PercentageEnd: existing.PercentageEnd,
|
||||||
|
EpubcfiStart: existing.EpubcfiStart,
|
||||||
|
EpubcfiEnd: existing.EpubcfiEnd,
|
||||||
|
ChapterReference: existing.ChapterReference,
|
||||||
|
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||||
|
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
|
DeviceSyncData: existing.DeviceSyncData,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := data["selection_text"].(string); ok {
|
||||||
|
params.SelectionText = v
|
||||||
|
}
|
||||||
|
if v, ok := data["color"].(string); ok {
|
||||||
|
params.Color = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
if v, ok := data["note_text"].(string); ok {
|
||||||
|
params.NoteText = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
if v, ok := data["start_position"].(string); ok {
|
||||||
|
params.StartPosition = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
if v, ok := data["end_position"].(string); ok {
|
||||||
|
params.EndPosition = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.UpdateMediaHighlightForSync(ctx, params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||||
|
existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := database.UpdateMediaBookmarkForSyncParams{
|
||||||
|
ID: existing.ID,
|
||||||
|
PageNumber: existing.PageNumber,
|
||||||
|
ChapterNumber: existing.ChapterNumber,
|
||||||
|
CfiPosition: existing.CfiPosition,
|
||||||
|
Title: existing.Title,
|
||||||
|
Position: existing.Position,
|
||||||
|
Notes: existing.Notes,
|
||||||
|
PercentageLocation: existing.PercentageLocation,
|
||||||
|
EpubcfiLocation: existing.EpubcfiLocation,
|
||||||
|
ChapterReference: existing.ChapterReference,
|
||||||
|
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||||
|
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
|
DeviceSyncData: existing.DeviceSyncData,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := data["title"].(string); ok {
|
||||||
|
params.Title = v
|
||||||
|
}
|
||||||
|
if v, ok := data["notes"].(string); ok {
|
||||||
|
params.Notes = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.UpdateMediaBookmarkForSync(ctx, params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||||
|
existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
params := database.UpdateMediaNoteForSyncParams{
|
||||||
|
ID: existing.ID,
|
||||||
|
Content: existing.Content,
|
||||||
|
Position: existing.Position,
|
||||||
|
PercentageLocation: existing.PercentageLocation,
|
||||||
|
CharacterStart: existing.CharacterStart,
|
||||||
|
CharacterEnd: existing.CharacterEnd,
|
||||||
|
EpubcfiLocation: existing.EpubcfiLocation,
|
||||||
|
ChapterReference: existing.ChapterReference,
|
||||||
|
ParagraphReference: existing.ParagraphReference,
|
||||||
|
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||||
|
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
|
DeviceSyncData: existing.DeviceSyncData,
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := data["content"].(string); ok {
|
||||||
|
params.Content = v
|
||||||
|
}
|
||||||
|
if v, ok := data["position"].(string); ok {
|
||||||
|
params.Position = pgtype.Text{String: v, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.db.UpdateMediaNoteForSync(ctx, params)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
|
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
|
||||||
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -376,7 +526,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
|
|||||||
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
conflictUUID := pgtype.UUID{Bytes: [16]byte(conflictID), Valid: true}
|
||||||
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
conflict, err := h.db.GetSyncConflict(context.Background(), conflictUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
return echo.NewHTTPError(http.StatusNotFound, "conflict not found")
|
||||||
}
|
}
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to get conflict")
|
||||||
@@ -396,7 +546,7 @@ func (h *ConflictHandler) DeleteConflict(c *echo.Context) error {
|
|||||||
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
|
func (h *ConflictHandler) DismissAllResolved(c *echo.Context) error {
|
||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
|
|
||||||
conflicts, err := h.db.ListSyncConflictsByUser(context.Background(), user.ID)
|
conflicts, err := h.db.ListConflictsByUser(context.Background(), user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
|
return echo.NewHTTPError(http.StatusInternalServerError, "failed to list conflicts")
|
||||||
}
|
}
|
||||||
@@ -546,7 +696,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winnerData); err != nil {
|
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
|
||||||
results = append(results, ConflictResult{
|
results = append(results, ConflictResult{
|
||||||
ConflictID: conflictIDStr,
|
ConflictID: conflictIDStr,
|
||||||
Status: "error",
|
Status: "error",
|
||||||
@@ -630,14 +780,14 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
|
|||||||
return highestSource, highestData
|
return highestSource, highestData
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
})
|
})
|
||||||
if err != nil && err != pgx.ErrNoRows {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,8 +811,12 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
|
|||||||
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
|
characterOffset = pgtype.Int8{Int64: int64(c), Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
currentPage := existingProgress.CurrentPage
|
var currentPage pgtype.Int4
|
||||||
totalPages := existingProgress.TotalPages
|
var totalPages pgtype.Int4
|
||||||
|
if err == nil {
|
||||||
|
currentPage = existingProgress.CurrentPage
|
||||||
|
totalPages = existingProgress.TotalPages
|
||||||
|
}
|
||||||
|
|
||||||
if p, ok := data["page"].(float64); ok {
|
if p, ok := data["page"].(float64); ok {
|
||||||
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
|
currentPage = pgtype.Int4{Int32: int32(p), Valid: true}
|
||||||
@@ -681,8 +835,8 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
|
|||||||
CharacterOffset: characterOffset,
|
CharacterOffset: characterOffset,
|
||||||
CurrentPage: currentPage,
|
CurrentPage: currentPage,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: "bulk", Valid: true},
|
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
||||||
ViewportY: pgtype.Float8{},
|
ViewportY: pgtype.Float8{},
|
||||||
ScrollPositionX: pgtype.Float8{},
|
ScrollPositionX: pgtype.Float8{},
|
||||||
ScrollPositionY: pgtype.Float8{},
|
ScrollPositionY: pgtype.Float8{},
|
||||||
|
|||||||
@@ -0,0 +1,309 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetMostRecentSource_KOReaderMoreRecent(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
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)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": {
|
||||||
|
Source: "kobo",
|
||||||
|
Timestamp: time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.42,
|
||||||
|
"page": float64(89),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getMostRecentSource(conflictData)
|
||||||
|
assert.Equal(t, "koreader", source)
|
||||||
|
assert.Equal(t, 0.45, data["percentage"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMostRecentSource_KoboMoreRecent(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Timestamp: time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": {
|
||||||
|
Source: "kobo",
|
||||||
|
Timestamp: time.Date(2026, 1, 30, 20, 15, 0, 0, time.UTC),
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getMostRecentSource(conflictData)
|
||||||
|
assert.Equal(t, "kobo", source)
|
||||||
|
assert.Equal(t, 0.42, data["percentage"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMostRecentSource_SingleSource(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Timestamp: time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getMostRecentSource(conflictData)
|
||||||
|
assert.Equal(t, "koreader", source)
|
||||||
|
assert.Equal(t, 0.50, data["percentage"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMostRecentSource_SameTimestamp(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
ts := time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC)
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Timestamp: ts,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": {
|
||||||
|
Source: "kobo",
|
||||||
|
Timestamp: ts,
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, _ := handler.getMostRecentSource(conflictData)
|
||||||
|
assert.Contains(t, []string{"koreader", "kobo"}, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMostRecentSource_EmptyData(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{}
|
||||||
|
|
||||||
|
source, data := handler.getMostRecentSource(conflictData)
|
||||||
|
assert.Equal(t, "", source)
|
||||||
|
assert.Nil(t, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHighestProgressSource_KOReaderHigher(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.75,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": {
|
||||||
|
Source: "kobo",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.42,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getHighestProgressSource(conflictData)
|
||||||
|
assert.Equal(t, "koreader", source)
|
||||||
|
assert.Equal(t, 0.75, data["percentage"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHighestProgressSource_KoboHigher(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": {
|
||||||
|
Source: "kobo",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.90,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getHighestProgressSource(conflictData)
|
||||||
|
assert.Equal(t, "kobo", source)
|
||||||
|
assert.Equal(t, 0.90, data["percentage"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHighestProgressSource_SingleSource(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.50,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getHighestProgressSource(conflictData)
|
||||||
|
assert.Equal(t, "koreader", source)
|
||||||
|
assert.Equal(t, 0.50, data["percentage"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHighestProgressSource_EmptyData(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{}
|
||||||
|
|
||||||
|
source, data := handler.getHighestProgressSource(conflictData)
|
||||||
|
assert.Equal(t, "", source)
|
||||||
|
assert.Nil(t, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHighestProgressSource_NoPercentageField(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Data: map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getHighestProgressSource(conflictData)
|
||||||
|
assert.Equal(t, "", source)
|
||||||
|
assert.Nil(t, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHighestProgressSource_MixedPercentageTypes(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.60,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": {
|
||||||
|
Source: "kobo",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": float64(0.80),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getHighestProgressSource(conflictData)
|
||||||
|
assert.Equal(t, "kobo", source)
|
||||||
|
assert.Equal(t, float64(0.80), data["percentage"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHighestProgressSource_BothZero(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": {
|
||||||
|
Source: "kobo",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getHighestProgressSource(conflictData)
|
||||||
|
assert.NotEmpty(t, source)
|
||||||
|
assert.NotNil(t, data)
|
||||||
|
assert.Equal(t, 0.0, data["percentage"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetHighestProgressSource_ThreeWayConflict(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": {
|
||||||
|
Source: "kobo",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.92,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
Source: "web",
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.70,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getHighestProgressSource(conflictData)
|
||||||
|
assert.Equal(t, "kobo", source)
|
||||||
|
assert.Equal(t, 0.92, data["percentage"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetMostRecentSource_ThreeWayConflict(t *testing.T) {
|
||||||
|
handler := &ConflictHandler{}
|
||||||
|
|
||||||
|
conflictData := map[string]ConflictSourceData{
|
||||||
|
"koreader": {
|
||||||
|
Source: "koreader",
|
||||||
|
Timestamp: time.Date(2026, 1, 30, 20, 5, 0, 0, time.UTC),
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.45,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"kobo": {
|
||||||
|
Source: "kobo",
|
||||||
|
Timestamp: time.Date(2026, 1, 30, 20, 20, 0, 0, time.UTC),
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.92,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"web": {
|
||||||
|
Source: "web",
|
||||||
|
Timestamp: time.Date(2026, 1, 30, 20, 10, 0, 0, time.UTC),
|
||||||
|
Data: map[string]interface{}{
|
||||||
|
"percentage": 0.70,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
source, data := handler.getMostRecentSource(conflictData)
|
||||||
|
assert.Equal(t, "kobo", source)
|
||||||
|
assert.Equal(t, 0.92, data["percentage"])
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -30,12 +31,13 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
|||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
|
|
||||||
libraryID := c.QueryParam("library_id")
|
libraryID := c.QueryParam("library_id")
|
||||||
if libraryID == "" {
|
var libUUID pgtype.UUID
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
if libraryID != "" {
|
||||||
}
|
parsed, err := uuid.Parse(libraryID)
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
if err != nil {
|
||||||
if err != nil {
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
}
|
||||||
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
prefs, _ := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||||
@@ -63,6 +65,7 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sectionData := BuildSections(sections, libraryID)
|
sectionData := BuildSections(sections, libraryID)
|
||||||
|
sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData})
|
||||||
}
|
}
|
||||||
@@ -133,6 +136,7 @@ func (h *DashboardHandler) RestoreSystemCollection(c *echo.Context) error {
|
|||||||
"Recently Added": true,
|
"Recently Added": true,
|
||||||
"Recently Read": true,
|
"Recently Read": true,
|
||||||
"Not Started": true,
|
"Not Started": true,
|
||||||
|
"Continue Series": true,
|
||||||
}
|
}
|
||||||
if !validCollections[req.CollectionName] {
|
if !validCollections[req.CollectionName] {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid system collection name"})
|
||||||
@@ -186,6 +190,59 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// activeConflictSet returns the set of media item IDs (as strings) that have an
|
||||||
|
// active (unresolved) progress sync conflict for the given user. A single query
|
||||||
|
// is issued; resolved conflicts are filtered out in memory.
|
||||||
|
func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool {
|
||||||
|
conflicts, err := db.ListSyncConflictsByUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
set := make(map[string]bool, len(conflicts))
|
||||||
|
for _, c := range conflicts {
|
||||||
|
if c.ResolutionStatus.String == "unresolved" {
|
||||||
|
set[uuid.UUID(c.MediaItemID.Bytes).String()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkActiveConflicts stamps HasConflict on each book whose media item has an
|
||||||
|
// active progress sync conflict for the user. It performs a single query
|
||||||
|
// regardless of how many books are passed.
|
||||||
|
func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo {
|
||||||
|
if len(books) == 0 {
|
||||||
|
return books
|
||||||
|
}
|
||||||
|
set := activeConflictSet(ctx, db, userID)
|
||||||
|
for i := range books {
|
||||||
|
if set[books[i].MediaItemID] {
|
||||||
|
books[i].HasConflict = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return books
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts,
|
||||||
|
// used by the dashboard which renders books grouped into sections.
|
||||||
|
func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData {
|
||||||
|
if len(sections) == 0 {
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
set := activeConflictSet(ctx, db, userID)
|
||||||
|
if len(set) == 0 {
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
for s := range sections {
|
||||||
|
for i := range sections[s].Items {
|
||||||
|
if set[sections[s].Items[i].MediaItemID] {
|
||||||
|
sections[s].Items[i].HasConflict = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sections
|
||||||
|
}
|
||||||
|
|
||||||
func getViewAllURL(collectionID string, libraryID string) string {
|
func getViewAllURL(collectionID string, libraryID string) string {
|
||||||
if collectionID != "" {
|
if collectionID != "" {
|
||||||
if libraryID != "" {
|
if libraryID != "" {
|
||||||
@@ -201,14 +258,15 @@ func (h *DashboardHandler) GetPreferences(c *echo.Context) error {
|
|||||||
userUUID := uuid.UUID(user.ID.Bytes)
|
userUUID := uuid.UUID(user.ID.Bytes)
|
||||||
libraryID := c.QueryParam("library_id")
|
libraryID := c.QueryParam("library_id")
|
||||||
|
|
||||||
if libraryID == "" {
|
var libUUID pgtype.UUID
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id required"})
|
if libraryID != "" {
|
||||||
|
parsed, err := uuid.Parse(libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||||
|
}
|
||||||
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
libUUID, err := uuid.Parse(libraryID)
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
||||||
}
|
|
||||||
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
prefs, err := h.dashboardService.GetDashboardPreferences(c.Request().Context(), userUUID, libUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Return default preferences instead of 404 when none exist
|
// Return default preferences instead of 404 when none exist
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ type PendingRegistration struct {
|
|||||||
UserID uuid.UUID
|
UserID uuid.UUID
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
|
Approved bool
|
||||||
|
AuthToken string
|
||||||
|
DeviceID [16]byte
|
||||||
|
SyncEndpoints map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var pendingRegistrations = make(map[string]*PendingRegistration)
|
var pendingRegistrations = make(map[string]*PendingRegistration)
|
||||||
@@ -173,60 +177,21 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if registration.UserID == (uuid.UUID{}) {
|
if registration.Approved {
|
||||||
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
Status: "pending",
|
Status: "approved",
|
||||||
Message: "awaiting user approval",
|
AuthToken: registration.AuthToken,
|
||||||
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
DeviceID: registration.DeviceID,
|
||||||
|
SyncEndpoints: registration.SyncEndpoints,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
authToken, err := generateDeviceToken()
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
|
||||||
}
|
|
||||||
|
|
||||||
userUUID := registration.UserID
|
|
||||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
|
||||||
|
|
||||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
|
||||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
|
||||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
|
||||||
|
|
||||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
|
||||||
UserID: pgUserID,
|
|
||||||
DeviceName: registration.DeviceName,
|
|
||||||
DeviceType: registration.DeviceType,
|
|
||||||
DeviceIdentifier: registration.DeviceIdentifier,
|
|
||||||
AuthToken: authToken,
|
|
||||||
SyncEnabled: syncEnabled,
|
|
||||||
AutoSync: autoSync,
|
|
||||||
SyncFrequencyMinutes: syncFreq,
|
|
||||||
DeviceMetadata: []byte("{}"),
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(pendingRegistrations, req.RegistrationID)
|
|
||||||
|
|
||||||
syncEndpoints := map[string]string{}
|
|
||||||
switch registration.DeviceType {
|
|
||||||
case "koreader":
|
|
||||||
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
|
||||||
case "kobo":
|
|
||||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
|
||||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
Status: "approved",
|
Status: "pending",
|
||||||
AuthToken: authToken,
|
Message: "awaiting user approval",
|
||||||
DeviceID: device.ID.Bytes,
|
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||||
SyncEndpoints: syncEndpoints,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +222,8 @@ func (h *DeviceHandler) ListDevices(c *echo.Context) error {
|
|||||||
ID: device.ID.Bytes,
|
ID: device.ID.Bytes,
|
||||||
DeviceName: device.DeviceName,
|
DeviceName: device.DeviceName,
|
||||||
DeviceType: device.DeviceType,
|
DeviceType: device.DeviceType,
|
||||||
LastSync: (*time.Time)(&device.LastSync.Time),
|
LastSync: &device.LastSync.Time,
|
||||||
LastSeen: (*time.Time)(&device.LastSeen.Time),
|
LastSeen: &device.LastSeen.Time,
|
||||||
SyncEnabled: syncEnabled,
|
SyncEnabled: syncEnabled,
|
||||||
AutoSync: autoSync,
|
AutoSync: autoSync,
|
||||||
SyncFrequency: syncFreq,
|
SyncFrequency: syncFreq,
|
||||||
@@ -301,8 +266,8 @@ func (h *DeviceHandler) GetDevicesData(c *echo.Context) ([]DeviceInfo, error) {
|
|||||||
ID: device.ID.Bytes,
|
ID: device.ID.Bytes,
|
||||||
DeviceName: device.DeviceName,
|
DeviceName: device.DeviceName,
|
||||||
DeviceType: device.DeviceType,
|
DeviceType: device.DeviceType,
|
||||||
LastSync: (*time.Time)(&device.LastSync.Time),
|
LastSync: &device.LastSync.Time,
|
||||||
LastSeen: (*time.Time)(&device.LastSeen.Time),
|
LastSeen: &device.LastSeen.Time,
|
||||||
SyncEnabled: syncEnabled,
|
SyncEnabled: syncEnabled,
|
||||||
AutoSync: autoSync,
|
AutoSync: autoSync,
|
||||||
SyncFrequency: syncFreq,
|
SyncFrequency: syncFreq,
|
||||||
@@ -353,8 +318,8 @@ func (h *DeviceHandler) GetDevice(c *echo.Context) error {
|
|||||||
ID: device.ID.Bytes,
|
ID: device.ID.Bytes,
|
||||||
DeviceName: device.DeviceName,
|
DeviceName: device.DeviceName,
|
||||||
DeviceType: device.DeviceType,
|
DeviceType: device.DeviceType,
|
||||||
LastSync: (*time.Time)(&device.LastSync.Time),
|
LastSync: &device.LastSync.Time,
|
||||||
LastSeen: (*time.Time)(&device.LastSeen.Time),
|
LastSeen: &device.LastSeen.Time,
|
||||||
SyncEnabled: syncEnabled,
|
SyncEnabled: syncEnabled,
|
||||||
AutoSync: autoSync,
|
AutoSync: autoSync,
|
||||||
SyncFrequency: syncFreq,
|
SyncFrequency: syncFreq,
|
||||||
@@ -447,8 +412,8 @@ func (h *DeviceHandler) UpdateDevice(c *echo.Context) error {
|
|||||||
ID: updatedDevice.ID.Bytes,
|
ID: updatedDevice.ID.Bytes,
|
||||||
DeviceName: updatedDevice.DeviceName,
|
DeviceName: updatedDevice.DeviceName,
|
||||||
DeviceType: updatedDevice.DeviceType,
|
DeviceType: updatedDevice.DeviceType,
|
||||||
LastSync: (*time.Time)(&updatedDevice.LastSync.Time),
|
LastSync: &updatedDevice.LastSync.Time,
|
||||||
LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time),
|
LastSeen: &updatedDevice.LastSeen.Time,
|
||||||
SyncEnabled: syncEnabled,
|
SyncEnabled: syncEnabled,
|
||||||
AutoSync: autoSync,
|
AutoSync: autoSync,
|
||||||
SyncFrequency: syncFreq, // Now correctly returns the updated value
|
SyncFrequency: syncFreq, // Now correctly returns the updated value
|
||||||
@@ -566,8 +531,8 @@ func (h *DeviceHandler) RegenerateDeviceToken(c *echo.Context) error {
|
|||||||
ID: updatedDevice.ID.Bytes,
|
ID: updatedDevice.ID.Bytes,
|
||||||
DeviceName: updatedDevice.DeviceName,
|
DeviceName: updatedDevice.DeviceName,
|
||||||
DeviceType: updatedDevice.DeviceType,
|
DeviceType: updatedDevice.DeviceType,
|
||||||
LastSync: (*time.Time)(&updatedDevice.LastSync.Time),
|
LastSync: &updatedDevice.LastSync.Time,
|
||||||
LastSeen: (*time.Time)(&updatedDevice.LastSeen.Time),
|
LastSeen: &updatedDevice.LastSeen.Time,
|
||||||
SyncEnabled: syncEnabled,
|
SyncEnabled: syncEnabled,
|
||||||
AutoSync: autoSync,
|
AutoSync: autoSync,
|
||||||
SyncFrequency: syncFreq,
|
SyncFrequency: syncFreq,
|
||||||
@@ -597,14 +562,63 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if registration.Approved {
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"message": "device already approved",
|
||||||
|
"device_name": registration.DeviceName,
|
||||||
|
"device_type": registration.DeviceType,
|
||||||
|
"approved": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
authToken, err := generateDeviceToken()
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||||
|
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||||
|
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||||
|
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||||
|
|
||||||
|
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeviceName: registration.DeviceName,
|
||||||
|
DeviceType: registration.DeviceType,
|
||||||
|
DeviceIdentifier: registration.DeviceIdentifier,
|
||||||
|
AuthToken: authToken,
|
||||||
|
SyncEnabled: syncEnabled,
|
||||||
|
AutoSync: autoSync,
|
||||||
|
SyncFrequencyMinutes: syncFreq,
|
||||||
|
DeviceMetadata: []byte("{}"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||||
|
}
|
||||||
|
|
||||||
|
syncEndpoints := map[string]string{}
|
||||||
|
switch registration.DeviceType {
|
||||||
|
case "koreader":
|
||||||
|
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
||||||
|
case "kobo":
|
||||||
|
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||||
|
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
registration.UserID = userUUID
|
registration.UserID = userUUID
|
||||||
|
registration.Approved = true
|
||||||
|
registration.AuthToken = authToken
|
||||||
|
registration.DeviceID = device.ID.Bytes
|
||||||
|
registration.SyncEndpoints = syncEndpoints
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"message": "device approved successfully",
|
"message": "device approved successfully",
|
||||||
"device_name": registration.DeviceName,
|
"device_name": registration.DeviceName,
|
||||||
"device_type": registration.DeviceType,
|
"device_type": registration.DeviceType,
|
||||||
"registration_id": registrationID,
|
"registration_id": registrationID,
|
||||||
"approved": true, // Fixed: Add confirmation field for test compatibility
|
"approved": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -624,15 +638,9 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||||
userID := c.Get("user_id").(string)
|
|
||||||
userUUID, err := uuid.Parse(userID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
registrations := []map[string]interface{}{}
|
registrations := []map[string]interface{}{}
|
||||||
for _, reg := range pendingRegistrations {
|
for _, reg := range pendingRegistrations {
|
||||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
if reg.UserID == (uuid.UUID{}) {
|
||||||
registrations = append(registrations, map[string]interface{}{
|
registrations = append(registrations, map[string]interface{}{
|
||||||
"registration_id": reg.RegistrationID,
|
"registration_id": reg.RegistrationID,
|
||||||
"device_name": reg.DeviceName,
|
"device_name": reg.DeviceName,
|
||||||
@@ -640,7 +648,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
|||||||
"device_identifier": reg.DeviceIdentifier,
|
"device_identifier": reg.DeviceIdentifier,
|
||||||
"expires_at": reg.ExpiresAt,
|
"expires_at": reg.ExpiresAt,
|
||||||
"created_at": reg.CreatedAt,
|
"created_at": reg.CreatedAt,
|
||||||
"is_approved": reg.UserID != (uuid.UUID{}),
|
"is_approved": false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func (h *FiltersHandler) GetSavedFilters(c *echo.Context) error {
|
|||||||
ID: uuid.Must(uuid.FromBytes(f.ID.Bytes[:])),
|
ID: uuid.Must(uuid.FromBytes(f.ID.Bytes[:])),
|
||||||
Name: f.Name,
|
Name: f.Name,
|
||||||
ResourceType: f.ResourceType,
|
ResourceType: f.ResourceType,
|
||||||
Filters: json.RawMessage(f.Filters), // Return JSONB as-is
|
Filters: f.Filters,
|
||||||
CreatedAt: f.CreatedAt.Time.Format(time.RFC3339),
|
CreatedAt: f.CreatedAt.Time.Format(time.RFC3339),
|
||||||
UpdatedAt: f.UpdatedAt.Time.Format(time.RFC3339),
|
UpdatedAt: f.UpdatedAt.Time.Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|||||||
+406
-148
@@ -3,10 +3,9 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -18,14 +17,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type KoboHandler struct {
|
type KoboHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
connManager *wsync.ConnectionManager
|
connManager *wsync.ConnectionManager
|
||||||
|
progressSvc *wsync.ProgressService
|
||||||
|
annotationSvc *wsync.AnnotationService
|
||||||
|
libraryService LibraryPathResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
||||||
return &KoboHandler{db: db, connManager: connManager}
|
return &KoboHandler{db: db, connManager: connManager}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||||
|
h.progressSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||||
|
h.annotationSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||||
|
h.libraryService = svc
|
||||||
|
}
|
||||||
|
|
||||||
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
|
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
|
||||||
// Enhanced Kobo Sync - ContentId Mapping Logic
|
// Enhanced Kobo Sync - ContentId Mapping Logic
|
||||||
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
|
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
|
||||||
@@ -33,7 +47,7 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
|
|||||||
catalog, err := h.db.GetDeviceCatalogByKoboContentId(ctx.Request().Context(), contentId)
|
catalog, err := h.db.GetDeviceCatalogByKoboContentId(ctx.Request().Context(), contentId)
|
||||||
if err == nil && catalog.ID.Valid {
|
if err == nil && catalog.ID.Valid {
|
||||||
// Found! Use canonical Bookhoard UUID
|
// Found! Use canonical Bookhoard UUID
|
||||||
return uuid.UUID(catalog.BookhoardUuid.Bytes), nil, "catalog_match"
|
return catalog.BookhoardUuid.Bytes, nil, "catalog_match"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: ContentId not found - check if it looks like a SHA-256 hash
|
// Step 2: ContentId not found - check if it looks like a SHA-256 hash
|
||||||
@@ -52,7 +66,7 @@ func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId s
|
|||||||
DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
DeliveryDate: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||||
DeliveryMethod: pgtype.Text{String: "sync", Valid: true},
|
DeliveryMethod: pgtype.Text{String: "sync", Valid: true},
|
||||||
})
|
})
|
||||||
return uuid.UUID(mediaItem.ID.Bytes), nil, "sha256_match"
|
return mediaItem.ID.Bytes, nil, "sha256_match"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,12 +189,6 @@ func looksLikeSHA256(s string) bool {
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
// calculateFileSHA256 calculates SHA-256 hash of file path
|
|
||||||
func calculateFileSHA256(filePath string) string {
|
|
||||||
hash := sha256.Sum256([]byte(filePath))
|
|
||||||
return hex.EncodeToString(hash[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
type KoboDeviceInfo struct {
|
type KoboDeviceInfo struct {
|
||||||
DeviceID string `json:"DeviceId"`
|
DeviceID string `json:"DeviceId"`
|
||||||
Model string `json:"Model"`
|
Model string `json:"Model"`
|
||||||
@@ -243,9 +251,16 @@ type KoboInitResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type KoboSyncStatus struct {
|
type KoboSyncStatus struct {
|
||||||
Status string `json:"Status"`
|
Status string `json:"Status"`
|
||||||
MarkupsSynced int `json:"MarkupsSynced"`
|
MarkupsSynced int `json:"MarkupsSynced"`
|
||||||
BookmarksSynced int `json:"BookmarksSynced"`
|
BookmarksSynced int `json:"BookmarksSynced"`
|
||||||
|
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KoboDeletedAnnotation struct {
|
||||||
|
ContentId string `json:"ContentId"`
|
||||||
|
BookmarkId string `json:"BookmarkId"`
|
||||||
|
Type string `json:"Type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KoboServerSyncData struct {
|
type KoboServerSyncData struct {
|
||||||
@@ -303,12 +318,13 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
|
|||||||
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
|
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
|
||||||
pagesRemaining = new(int(progress.TotalPages.Int32 - progress.CurrentPage.Int32))
|
remaining := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
|
||||||
|
pagesRemaining = &remaining
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bookmarkCount := 0
|
bookmarkCount := 0
|
||||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
@@ -400,41 +416,58 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
markupsSynced := 0
|
markupsSynced := 0
|
||||||
bookmarksSynced := 0
|
bookmarksSynced := 0
|
||||||
unlinkedBooks := 0
|
unlinkedBooks := 0
|
||||||
|
processedBooks := make(map[pgtype.UUID]string)
|
||||||
|
|
||||||
for _, readingSync := range req.ReadingSync {
|
for _, readingSync := range req.ReadingSync {
|
||||||
// Use ContentId mapping with fallback logic
|
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
||||||
if err != nil || bookhoardUUID == uuid.Nil {
|
if err != nil || bookhoardUUID == uuid.Nil {
|
||||||
// Unlinked book detected
|
|
||||||
unlinkedBooks++
|
unlinkedBooks++
|
||||||
// TODO: Create unlinked book entry for manual resolution
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
|
processedBooks[pgMediaUUID] = readingSync.ContentId
|
||||||
percentage := readingSync.PercentRead / 100.0
|
percentage := readingSync.PercentRead / 100.0
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
// Kobo only sends a percentage. For fixed-layout & comic formats the page
|
||||||
MediaItemID: pgMediaUUID,
|
// index is the canonical locator, so derive it from the known page count.
|
||||||
UserID: pgUserID,
|
var currentPage, totalPages *int
|
||||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
if mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID); mErr == nil {
|
||||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
||||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
||||||
})
|
total := int(mediaItem.PageCount.Int32)
|
||||||
|
page := wsync.PercentageToPage(percentage, total)
|
||||||
|
currentPage = &page
|
||||||
|
totalPages = &total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.progressSvc != nil {
|
||||||
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
|
Percentage: &percentage,
|
||||||
|
CurrentPage: currentPage,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
DeviceType: "kobo",
|
||||||
|
DeviceName: device.DeviceName,
|
||||||
|
Broadcast: true,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||||
|
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||||
|
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
markupsSynced++
|
markupsSynced++
|
||||||
|
|
||||||
h.connManager.BroadcastProgressUpdate(
|
|
||||||
bookhoardUUID,
|
|
||||||
percentage,
|
|
||||||
wsync.SourceDevice{
|
|
||||||
ID: uuid.UUID(userID).String(),
|
|
||||||
Name: device.DeviceName,
|
|
||||||
Type: "kobo",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,29 +480,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
|
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
|
||||||
|
|
||||||
switch bookmarkSync.BookmarkType {
|
switch bookmarkSync.BookmarkType {
|
||||||
case "annotation":
|
case "annotation":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
if bookmarkSync.BookmarkText != "" {
|
||||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"bookmark_id": bookmarkSync.BookmarkId,
|
||||||
SelectionText: bookmarkSync.BookmarkText,
|
"date_created": bookmarkSync.DateCreated,
|
||||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
})
|
||||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
|
||||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
})
|
MediaItemID: pgMediaUUID,
|
||||||
bookmarksSynced++
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmarkSync.BookmarkText,
|
||||||
|
StartPosition: bookmarkSync.BookmarkId,
|
||||||
|
EndPosition: bookmarkSync.BookmarkId,
|
||||||
|
Color: "#ffff00",
|
||||||
|
NoteText: bookmarkSync.BookmarkTitle,
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmarkSync.BookmarkText,
|
||||||
|
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "bookmark":
|
case "bookmark":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
if bookmarkSync.BookmarkText != "" {
|
||||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"bookmark_id": bookmarkSync.BookmarkId,
|
||||||
Content: bookmarkSync.BookmarkText,
|
"date_created": bookmarkSync.DateCreated,
|
||||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
})
|
||||||
})
|
|
||||||
bookmarksSynced++
|
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Title: bookmarkSync.BookmarkText,
|
||||||
|
Position: bookmarkSync.BookmarkId,
|
||||||
|
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Content: bookmarkSync.BookmarkText,
|
||||||
|
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "last-read-place":
|
case "last-read-place":
|
||||||
if bookmarkSync.BookmarkId != "" {
|
if bookmarkSync.BookmarkId != "" {
|
||||||
@@ -480,15 +556,51 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
epubcfi = strings.TrimSuffix(epubcfi, ")")
|
epubcfi = strings.TrimSuffix(epubcfi, ")")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
chapter := bookmarkSync.Chapter
|
||||||
MediaItemID: pgMediaUUID,
|
chapterProgress := 0.5
|
||||||
UserID: pgUserID,
|
|
||||||
Epubcfi: pgtype.Text{String: epubcfi, Valid: true},
|
var convertedCFI *string
|
||||||
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
|
var contextText *string
|
||||||
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
|
|
||||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
if epubcfi != "" && h.libraryService != nil {
|
||||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID)
|
||||||
})
|
if mErr == nil {
|
||||||
|
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
||||||
|
if formatGroup != wsync.FormatGroupFixedLayout && formatGroup != wsync.FormatGroupComicArchive {
|
||||||
|
convertedCFI, contextText = h.convertKoboCFIToStandard(c, mediaItem, epubcfi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if convertedCFI != nil {
|
||||||
|
epubcfi = *convertedCFI
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.progressSvc != nil {
|
||||||
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
|
Epubcfi: &epubcfi,
|
||||||
|
ContextText: contextText,
|
||||||
|
Chapter: &chapter,
|
||||||
|
ChapterProgress: &chapterProgress,
|
||||||
|
DeviceType: "kobo",
|
||||||
|
DeviceName: device.DeviceName,
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""},
|
||||||
|
Chapter: pgtype.Int4{Int32: int32(bookmarkSync.Chapter), Valid: true},
|
||||||
|
ChapterProgress: pgtype.Float8{Float64: 0.5, Valid: true},
|
||||||
|
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||||
|
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Failed to store last-read-place: %v", err)
|
fmt.Printf("Failed to store last-read-place: %v", err)
|
||||||
}
|
}
|
||||||
@@ -510,6 +622,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
|||||||
BookmarksSynced: bookmarksSynced,
|
BookmarksSynced: bookmarksSynced,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if h.annotationSvc != nil && len(processedBooks) > 0 {
|
||||||
|
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
|
||||||
|
for mediaItemID, contentId := range processedBooks {
|
||||||
|
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeletedAt: cutoff,
|
||||||
|
})
|
||||||
|
for _, ts := range tombstones {
|
||||||
|
var dd map[string]interface{}
|
||||||
|
if len(ts.DeviceSyncData) > 0 {
|
||||||
|
json.Unmarshal(ts.DeviceSyncData, &dd)
|
||||||
|
}
|
||||||
|
bookmarkID, _ := dd["bookmark_id"].(string)
|
||||||
|
if bookmarkID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{
|
||||||
|
ContentId: contentId,
|
||||||
|
BookmarkId: bookmarkID,
|
||||||
|
Type: ts.AnnotationType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Include unlinked books count if any
|
// Include unlinked books count if any
|
||||||
if unlinkedBooks > 0 {
|
if unlinkedBooks > 0 {
|
||||||
// For now, just log it. In production, this should trigger an alert
|
// For now, just log it. In production, this should trigger an alert
|
||||||
@@ -552,25 +690,67 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
|||||||
switch bookmarkSync.BookmarkType {
|
switch bookmarkSync.BookmarkType {
|
||||||
case "annotation":
|
case "annotation":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
if bookmarkSync.BookmarkText != "" {
|
||||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"bookmark_id": bookmarkSync.BookmarkId,
|
||||||
SelectionText: bookmarkSync.BookmarkText,
|
"date_created": bookmarkSync.DateCreated,
|
||||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
})
|
||||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
|
||||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
})
|
MediaItemID: pgMediaUUID,
|
||||||
bookmarksSynced++
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmarkSync.BookmarkText,
|
||||||
|
StartPosition: bookmarkSync.BookmarkId,
|
||||||
|
EndPosition: bookmarkSync.BookmarkId,
|
||||||
|
Color: "#ffff00",
|
||||||
|
NoteText: bookmarkSync.BookmarkTitle,
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmarkSync.BookmarkText,
|
||||||
|
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case "bookmark":
|
case "bookmark":
|
||||||
if bookmarkSync.BookmarkText != "" {
|
if bookmarkSync.BookmarkText != "" {
|
||||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"bookmark_id": bookmarkSync.BookmarkId,
|
||||||
Content: bookmarkSync.BookmarkText,
|
"date_created": bookmarkSync.DateCreated,
|
||||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
})
|
||||||
})
|
|
||||||
bookmarksSynced++
|
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Title: bookmarkSync.BookmarkText,
|
||||||
|
Position: bookmarkSync.BookmarkId,
|
||||||
|
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||||
|
Source: "kobo",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Content: bookmarkSync.BookmarkText,
|
||||||
|
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -605,34 +785,33 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range req {
|
for _, test := range req {
|
||||||
// Use ContentId mapping with fallback logic
|
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, test.ContentId, deviceUUID)
|
||||||
if err != nil || bookhoardUUID == uuid.Nil {
|
if err != nil || bookhoardUUID == uuid.Nil {
|
||||||
// Unlinked book - skip
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
percentage := test.PercentRead / 100.0
|
percentage := test.PercentRead / 100.0
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
if h.progressSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
UserID: pgUserID,
|
||||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
Source: "kobo",
|
||||||
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
})
|
Percentage: &percentage,
|
||||||
|
DeviceType: "kobo",
|
||||||
if err == nil {
|
DeviceName: device.DeviceName,
|
||||||
h.connManager.BroadcastProgressUpdate(
|
Broadcast: true,
|
||||||
bookhoardUUID,
|
})
|
||||||
percentage,
|
} else {
|
||||||
wsync.SourceDevice{
|
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||||
ID: uuid.UUID(userID).String(),
|
MediaItemID: pgMediaUUID,
|
||||||
Name: device.DeviceName,
|
UserID: pgUserID,
|
||||||
Type: "kobo",
|
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||||
},
|
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||||
)
|
LastSyncSource: pgtype.Text{String: "kobo", Valid: true},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -648,20 +827,6 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseKoboDeviceHeader(c *echo.Context) (KoboDeviceInfo, error) {
|
|
||||||
deviceHeader := c.Request().Header.Get("x-kobo-device")
|
|
||||||
if deviceHeader == "" {
|
|
||||||
return KoboDeviceInfo{}, fmt.Errorf("missing x-kobo-device header")
|
|
||||||
}
|
|
||||||
|
|
||||||
var device KoboDeviceInfo
|
|
||||||
if err := json.Unmarshal([]byte(deviceHeader), &device); err != nil {
|
|
||||||
return KoboDeviceInfo{}, fmt.Errorf("invalid device header format")
|
|
||||||
}
|
|
||||||
|
|
||||||
return device, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||||
device := c.Get("device").(database.Devices)
|
device := c.Get("device").(database.Devices)
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
@@ -682,24 +847,34 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
highlightsSent := 0
|
highlightsSent := 0
|
||||||
|
|
||||||
for _, syncData := range req {
|
for _, syncData := range req {
|
||||||
// Use ContentId mapping with fallback logic
|
|
||||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, syncData.ContentId, deviceUUID)
|
||||||
if err != nil || bookhoardUUID == uuid.Nil {
|
if err != nil || bookhoardUUID == uuid.Nil {
|
||||||
// Unlinked book - skip
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||||
|
|
||||||
percentage := syncData.PercentRead / 100.0
|
percentage := syncData.PercentRead / 100.0
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
if h.progressSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
UserID: pgUserID,
|
||||||
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
Source: "bookhoard",
|
||||||
LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true},
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||||
})
|
Percentage: &percentage,
|
||||||
|
DeviceType: "kobo",
|
||||||
|
DeviceName: device.DeviceName,
|
||||||
|
Broadcast: false,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||||
|
LastSyncDevice: pgtype.Text{String: "kobo", Valid: true},
|
||||||
|
LastSyncSource: pgtype.Text{String: "bookhoard", Valid: true},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
booksSynced++
|
booksSynced++
|
||||||
@@ -707,36 +882,79 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
|
|
||||||
for _, bookmark := range syncData.Bookmarks {
|
for _, bookmark := range syncData.Bookmarks {
|
||||||
if bookmark.BookmarkType == "bookmark" {
|
if bookmark.BookmarkType == "bookmark" {
|
||||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
Content: bookmark.BookmarkText,
|
UserID: pgUserID,
|
||||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
Title: bookmark.BookmarkText,
|
||||||
})
|
Position: bookmark.BookmarkId,
|
||||||
bookmarksSent++
|
Source: "kobo",
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSent++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Content: bookmark.BookmarkText,
|
||||||
|
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||||
|
})
|
||||||
|
bookmarksSent++
|
||||||
|
}
|
||||||
} else if bookmark.BookmarkType == "annotation" {
|
} else if bookmark.BookmarkType == "annotation" {
|
||||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
SelectionText: bookmark.BookmarkText,
|
UserID: pgUserID,
|
||||||
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
SelectionText: bookmark.BookmarkText,
|
||||||
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
StartPosition: bookmark.BookmarkId,
|
||||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
EndPosition: bookmark.BookmarkId,
|
||||||
})
|
Color: "#ffff00",
|
||||||
highlightsSent++
|
Source: "kobo",
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
highlightsSent++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: bookmark.BookmarkText,
|
||||||
|
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||||
|
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||||
|
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||||
|
})
|
||||||
|
highlightsSent++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, highlight := range syncData.Highlights {
|
for _, highlight := range syncData.Highlights {
|
||||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: pgMediaUUID,
|
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
UserID: pgUserID,
|
MediaItemID: pgMediaUUID,
|
||||||
SelectionText: highlight.BookmarkText,
|
UserID: pgUserID,
|
||||||
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
SelectionText: highlight.BookmarkText,
|
||||||
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
StartPosition: highlight.BookmarkId,
|
||||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
EndPosition: highlight.BookmarkId,
|
||||||
})
|
Color: "#ffff00",
|
||||||
highlightsSent++
|
Source: "kobo",
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
highlightsSent++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: pgMediaUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: highlight.BookmarkText,
|
||||||
|
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||||
|
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||||
|
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||||
|
})
|
||||||
|
highlightsSent++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -753,3 +971,43 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
|||||||
HighlightsSent: highlightsSent,
|
HighlightsSent: highlightsSent,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *KoboHandler) convertKoboCFIToStandard(c *echo.Context, mediaItem database.MediaItems, kepubCFI string) (*string, *string) {
|
||||||
|
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Bookhoard: KEPUB→CFI failed to resolve EPUB path: %v", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if epubPath == "" {
|
||||||
|
log.Printf("Bookhoard: KEPUB→CFI resolved empty EPUB path for %s", mediaItem.FilePath)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||||
|
FormatType: "kepub",
|
||||||
|
})
|
||||||
|
if err != nil || !kepubFormat.FilePath.Valid {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
converter := wsync.NewKEPUBCFIConverter(epubPath, kepubFormat.FilePath.String)
|
||||||
|
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.0, "")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Bookhoard: KEPUB→CFI conversion error: %v", err)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfi *string
|
||||||
|
if result.CFI != "" {
|
||||||
|
cfi = &result.CFI
|
||||||
|
log.Printf("Bookhoard: KEPUB→CFI converted (precision=%s)", result.Precision)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ctx *string
|
||||||
|
if result.ExtractedContext != "" {
|
||||||
|
ctx = &result.ExtractedContext
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfi, ctx
|
||||||
|
}
|
||||||
|
|||||||
+557
-201
@@ -3,27 +3,64 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type KOReaderHandler struct {
|
type KOReaderHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
connManager *wsync.ConnectionManager
|
connManager *wsync.ConnectionManager
|
||||||
queue *wsync.SyncQueueProcessor
|
queue *wsync.SyncQueueProcessor
|
||||||
|
progressSvc *wsync.ProgressService
|
||||||
|
annotationSvc *wsync.AnnotationService
|
||||||
|
libraryService LibraryPathResolver
|
||||||
|
}
|
||||||
|
|
||||||
|
type LibraryPathResolver interface {
|
||||||
|
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
func NewKOReaderHandler(db *database.Queries, connManager *wsync.ConnectionManager, queue *wsync.SyncQueueProcessor) *KOReaderHandler {
|
||||||
return &KOReaderHandler{db: db, connManager: connManager, queue: queue}
|
return &KOReaderHandler{db: db, connManager: connManager, queue: queue}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *KOReaderHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||||
|
h.progressSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||||
|
h.annotationSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1 string) (string, string) {
|
||||||
|
if pos0 == "" || h.libraryService == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||||
|
if err != nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
||||||
|
if err != nil || epubPath == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||||
|
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||||
|
return startLoc.CFI, endLoc.CFI
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KOReaderHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||||
|
h.libraryService = svc
|
||||||
|
}
|
||||||
|
|
||||||
type KOReaderProgressRequest struct {
|
type KOReaderProgressRequest struct {
|
||||||
LibraryID *string `json:"library_id,omitempty"`
|
LibraryID *string `json:"library_id,omitempty"`
|
||||||
Books []KOReaderBookProgress `json:"books" validate:"required"`
|
Books []KOReaderBookProgress `json:"books" validate:"required"`
|
||||||
@@ -43,11 +80,12 @@ type KOReaderBookProgress struct {
|
|||||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||||
Chapter *int `json:"chapter,omitempty"`
|
Chapter *int `json:"chapter,omitempty"`
|
||||||
Character *int64 `json:"character,omitempty"`
|
Character *int64 `json:"character,omitempty"`
|
||||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||||
Page *int `json:"page,omitempty"`
|
ContextText *string `json:"context_text,omitempty"`
|
||||||
TotalPages *int `json:"total_pages,omitempty"`
|
Page *int `json:"page,omitempty"`
|
||||||
|
TotalPages *int `json:"total_pages,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderDeviceInfo struct {
|
type KOReaderDeviceInfo struct {
|
||||||
@@ -96,11 +134,18 @@ type KOReaderNote struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderSyncResponse struct {
|
type KOReaderSyncResponse struct {
|
||||||
SyncStatus string `json:"sync_status"`
|
SyncStatus string `json:"sync_status"`
|
||||||
BooksSynced int `json:"books_synced"`
|
BooksSynced int `json:"books_synced"`
|
||||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
|
||||||
Timestamp string `json:"timestamp"`
|
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||||
DeviceUpdated bool `json:"device_updated"`
|
Timestamp string `json:"timestamp"`
|
||||||
|
DeviceUpdated bool `json:"device_updated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type KOReaderBookSyncResult struct {
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
BookUUID string `json:"book_uuid"`
|
||||||
|
Synced bool `json:"synced"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderConflict struct {
|
type KOReaderConflict struct {
|
||||||
@@ -121,19 +166,22 @@ type KOReaderMetadata struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderProgressData struct {
|
type KOReaderProgressData struct {
|
||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
Character *int64 `json:"character,omitempty"`
|
Character *int64 `json:"character,omitempty"`
|
||||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||||
Chapter *int `json:"chapter,omitempty"`
|
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
||||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
Chapter *int `json:"chapter,omitempty"`
|
||||||
Page *int `json:"page,omitempty"`
|
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||||
TotalPages *int `json:"total_pages,omitempty"`
|
Page *int `json:"page,omitempty"`
|
||||||
|
TotalPages *int `json:"total_pages,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderAnnotations struct {
|
type KOReaderAnnotations struct {
|
||||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||||
|
DeletedHighlights []map[string]interface{} `json:"deleted_highlights,omitempty"`
|
||||||
|
DeletedBookmarks []map[string]interface{} `json:"deleted_bookmarks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type KOReaderLibraryResponse struct {
|
type KOReaderLibraryResponse struct {
|
||||||
@@ -184,17 +232,28 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
|||||||
|
|
||||||
booksSynced := 0
|
booksSynced := 0
|
||||||
conflicts := []KOReaderConflict{}
|
conflicts := []KOReaderConflict{}
|
||||||
|
bookResults := []KOReaderBookSyncResult{}
|
||||||
|
|
||||||
for _, book := range req.Books {
|
for _, book := range req.Books {
|
||||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
||||||
if !mediaItemID.Valid {
|
if !mediaItemID.Valid {
|
||||||
|
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||||
|
SHA256: book.SHA256,
|
||||||
|
Synced: false,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.updateProgressForBook(c, pgUserID, mediaItemID, book)
|
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
|
||||||
if err == nil {
|
synced := err == nil
|
||||||
|
if synced {
|
||||||
booksSynced++
|
booksSynced++
|
||||||
}
|
}
|
||||||
|
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||||
|
SHA256: book.SHA256,
|
||||||
|
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
|
||||||
|
Synced: synced,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||||
@@ -208,6 +267,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
||||||
SyncStatus: "accepted",
|
SyncStatus: "accepted",
|
||||||
BooksSynced: booksSynced,
|
BooksSynced: booksSynced,
|
||||||
|
BookResults: bookResults,
|
||||||
Conflicts: conflicts,
|
Conflicts: conflicts,
|
||||||
Timestamp: time.Now().Format(time.RFC3339),
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
DeviceUpdated: true,
|
DeviceUpdated: true,
|
||||||
@@ -217,6 +277,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, KOReaderSyncResponse{
|
return c.JSON(http.StatusOK, KOReaderSyncResponse{
|
||||||
SyncStatus: "completed",
|
SyncStatus: "completed",
|
||||||
BooksSynced: booksSynced,
|
BooksSynced: booksSynced,
|
||||||
|
BookResults: bookResults,
|
||||||
Conflicts: conflicts,
|
Conflicts: conflicts,
|
||||||
Timestamp: time.Now().Format(time.RFC3339),
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
DeviceUpdated: true,
|
DeviceUpdated: true,
|
||||||
@@ -343,17 +404,29 @@ func (h *KOReaderHandler) createDeviceFileAlias(c *echo.Context, deviceID pgtype
|
|||||||
|
|
||||||
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
|
func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.Devices, userID pgtype.UUID, req KOReaderProgressRequest) error {
|
||||||
booksEnqueued := 0
|
booksEnqueued := 0
|
||||||
|
bookResults := []KOReaderBookSyncResult{}
|
||||||
|
|
||||||
for _, book := range req.Books {
|
for _, book := range req.Books {
|
||||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
|
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
|
||||||
if !mediaItemID.Valid {
|
if !mediaItemID.Valid {
|
||||||
|
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||||
|
SHA256: book.SHA256,
|
||||||
|
Synced: false,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
|
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
|
||||||
if err == nil {
|
synced := err == nil
|
||||||
|
if synced {
|
||||||
booksEnqueued++
|
booksEnqueued++
|
||||||
}
|
}
|
||||||
|
h.processBookAnnotations(c.Request().Context(), device.ID, userID, mediaItemID, book)
|
||||||
|
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||||
|
SHA256: book.SHA256,
|
||||||
|
BookUUID: uuid.UUID(mediaItemID.Bytes).String(),
|
||||||
|
Synced: synced,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
_, err := h.db.UpdateDeviceLastSync(c.Request().Context(), device.ID)
|
||||||
@@ -363,11 +436,11 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusAccepted, map[string]interface{}{
|
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
||||||
"sync_status": "checkpoint_enqueued",
|
SyncStatus: "checkpoint_enqueued",
|
||||||
"books_enqueued": booksEnqueued,
|
BooksSynced: booksEnqueued,
|
||||||
"message": "Sync will be processed in the background",
|
BookResults: bookResults,
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
Timestamp: time.Now().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,6 +455,7 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
Percentage: book.Percentage,
|
Percentage: book.Percentage,
|
||||||
Epubcfi: book.Epubcfi,
|
Epubcfi: book.Epubcfi,
|
||||||
|
ContextText: book.ContextText,
|
||||||
Chapter: book.Chapter,
|
Chapter: book.Chapter,
|
||||||
Character: book.Character,
|
Character: book.Character,
|
||||||
Page: book.Page,
|
Page: book.Page,
|
||||||
@@ -393,68 +467,200 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
|||||||
return h.queue.EnqueueProgress(update)
|
return h.queue.EnqueueProgress(update)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
|
||||||
ctx := c.Request().Context()
|
if h.annotationSvc == nil {
|
||||||
|
return
|
||||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
|
||||||
MediaItemID: mediaItemID,
|
|
||||||
UserID: userID,
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil && err != pgx.ErrNoRows {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hasExistingProgress := err != pgx.ErrNoRows
|
for _, hl := range book.Highlights {
|
||||||
conflictDetected := false
|
startPos := hl.Pos0
|
||||||
|
endPos := hl.Pos1
|
||||||
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||||
|
|
||||||
if hasExistingProgress && existingProgress.LastSyncSource.Valid {
|
pctStart := 0.0
|
||||||
if existingProgress.LastSyncSource.String != "koreader" && existingProgress.LastSyncTimestamp.Valid {
|
if hl.Percentage != nil {
|
||||||
timeDiff := time.Since(existingProgress.LastSyncTimestamp.Time)
|
pctStart = *hl.Percentage
|
||||||
if timeDiff < 5*time.Minute {
|
}
|
||||||
percentageDiff := book.Percentage - existingProgress.Percentage.Float64
|
|
||||||
if percentageDiff < 0 {
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
percentageDiff = -percentageDiff
|
"datetime": hl.Datetime,
|
||||||
}
|
"pos0": hl.Pos0,
|
||||||
if percentageDiff > 0.01 {
|
"pos1": hl.Pos1,
|
||||||
conflictDetected = true
|
"page": hl.Page,
|
||||||
|
})
|
||||||
|
|
||||||
|
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: userID,
|
||||||
|
SelectionText: hl.Text,
|
||||||
|
StartPosition: startPos,
|
||||||
|
EndPosition: endPos,
|
||||||
|
Color: hl.Color,
|
||||||
|
NoteText: hl.Notes,
|
||||||
|
PercentageStart: pctStart,
|
||||||
|
EpubcfiStart: epubcfiStart,
|
||||||
|
EpubcfiEnd: epubcfiEnd,
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, note := range book.Notes {
|
||||||
|
startPos := note.Pos0
|
||||||
|
endPos := note.Pos1
|
||||||
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||||
|
|
||||||
|
pctStart := 0.0
|
||||||
|
if note.Percentage != nil {
|
||||||
|
pctStart = *note.Percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"datetime": note.Datetime,
|
||||||
|
"pos0": note.Pos0,
|
||||||
|
"pos1": note.Pos1,
|
||||||
|
"page": note.Page,
|
||||||
|
})
|
||||||
|
|
||||||
|
h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: userID,
|
||||||
|
SelectionText: note.Text,
|
||||||
|
StartPosition: startPos,
|
||||||
|
EndPosition: endPos,
|
||||||
|
NoteText: note.Notes,
|
||||||
|
PercentageStart: pctStart,
|
||||||
|
EpubcfiStart: epubcfiStart,
|
||||||
|
EpubcfiEnd: epubcfiEnd,
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bookmark := range book.Bookmarks {
|
||||||
|
position := ""
|
||||||
|
if bookmark.Pos0 != "" {
|
||||||
|
position = bookmark.Pos0
|
||||||
|
} else if bookmark.Page > 0 {
|
||||||
|
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"datetime": bookmark.Datetime,
|
||||||
|
"pos0": bookmark.Pos0,
|
||||||
|
"page": bookmark.Page,
|
||||||
|
})
|
||||||
|
|
||||||
|
h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: userID,
|
||||||
|
Title: bookmark.Text,
|
||||||
|
Position: position,
|
||||||
|
ChapterNumber: int32(bookmark.Chapter),
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype.UUID, userID pgtype.UUID, mediaItemID pgtype.UUID, book KOReaderBookProgress) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
|
||||||
|
deviceInfo := book.DeviceInfo
|
||||||
|
deviceModel := deviceInfo.DeviceModel
|
||||||
|
if deviceModel == "" {
|
||||||
|
deviceModel = "KOReader Device"
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.progressSvc != nil {
|
||||||
|
epubcfi := book.Epubcfi
|
||||||
|
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
|
||||||
|
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
|
||||||
|
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
|
||||||
|
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) ||
|
||||||
|
mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
||||||
|
// Image-based fixed content (fixed-layout comic EPUBs, PDF,
|
||||||
|
// comic archives) has no extractable text, so CRE→CFI conversion
|
||||||
|
// cannot succeed. The page index (page/total_pages) is the
|
||||||
|
// canonical locator. Keep the incoming xpointer for device-native
|
||||||
|
// restore; the web reader restores by page.
|
||||||
|
log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup)
|
||||||
|
} else if h.libraryService == nil {
|
||||||
|
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
|
||||||
|
} else {
|
||||||
|
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
||||||
|
if resolveErr != nil {
|
||||||
|
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
|
||||||
|
} else if epubPath == "" {
|
||||||
|
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
|
||||||
|
} else {
|
||||||
|
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
|
||||||
|
converter := wsync.NewCFIConverter(epubPath)
|
||||||
|
pct := 0.0
|
||||||
|
if book.Percentage >= 0 {
|
||||||
|
pct = book.Percentage
|
||||||
|
}
|
||||||
|
contextText := ""
|
||||||
|
if book.ContextText != nil {
|
||||||
|
contextText = *book.ContextText
|
||||||
|
}
|
||||||
|
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
|
||||||
|
if convErr != nil {
|
||||||
|
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
|
||||||
|
} else if result != nil {
|
||||||
|
if result.EPUBCFI != "" {
|
||||||
|
convertedCFI := result.EPUBCFI
|
||||||
|
epubcfi = &convertedCFI
|
||||||
|
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
|
||||||
|
} else if result.Href != "" {
|
||||||
|
convertedHref := result.Href
|
||||||
|
epubcfi = &convertedHref
|
||||||
|
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
|
||||||
|
} else {
|
||||||
|
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
saveReq := wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: userID,
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceID: deviceID,
|
||||||
|
Percentage: &book.Percentage,
|
||||||
|
Epubcfi: epubcfi,
|
||||||
|
ContextText: book.ContextText,
|
||||||
|
Chapter: book.Chapter,
|
||||||
|
CharacterOffset: book.Character,
|
||||||
|
CurrentPage: book.Page,
|
||||||
|
TotalPages: book.TotalPages,
|
||||||
|
DeviceType: "koreader",
|
||||||
|
DeviceName: deviceModel,
|
||||||
|
Broadcast: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var epubcfi pgtype.Text
|
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||||
var chapter pgtype.Int4
|
|
||||||
var characterOffset pgtype.Int8
|
|
||||||
var currentPage pgtype.Int4
|
|
||||||
var totalPages pgtype.Int4
|
|
||||||
|
|
||||||
if book.Epubcfi != nil {
|
|
||||||
epubcfi = pgtype.Text{String: *book.Epubcfi, Valid: true}
|
|
||||||
}
|
|
||||||
if book.Chapter != nil {
|
|
||||||
chapter = pgtype.Int4{Int32: int32(*book.Chapter), Valid: true}
|
|
||||||
}
|
|
||||||
if book.Character != nil {
|
|
||||||
characterOffset = pgtype.Int8{Int64: *book.Character, Valid: true}
|
|
||||||
}
|
|
||||||
if book.Page != nil {
|
|
||||||
currentPage = pgtype.Int4{Int32: int32(*book.Page), Valid: true}
|
|
||||||
}
|
|
||||||
if book.TotalPages != nil {
|
|
||||||
totalPages = pgtype.Int4{Int32: int32(*book.TotalPages), Valid: true}
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
|
||||||
MediaItemID: mediaItemID,
|
MediaItemID: mediaItemID,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
Percentage: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
||||||
Epubcfi: epubcfi,
|
Epubcfi: textPtrToPgText(book.Epubcfi),
|
||||||
Chapter: chapter,
|
Chapter: intPtrToPgInt4(book.Chapter),
|
||||||
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
ChapterProgress: pgtype.Float8{Float64: book.Percentage, Valid: true},
|
||||||
CharacterOffset: characterOffset,
|
CharacterOffset: int64PtrToPgInt8(book.Character),
|
||||||
CurrentPage: currentPage,
|
CurrentPage: intPtrToPgInt4(book.Page),
|
||||||
TotalPages: totalPages,
|
TotalPages: intPtrToPgInt4(book.TotalPages),
|
||||||
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "koreader", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
|
LastSyncSource: pgtype.Text{String: "koreader", Valid: true},
|
||||||
ViewportY: pgtype.Float8{},
|
ViewportY: pgtype.Float8{},
|
||||||
@@ -464,97 +670,44 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, userID pgtype.U
|
|||||||
ReadingMode: pgtype.Text{},
|
ReadingMode: pgtype.Text{},
|
||||||
ZoomLevel: pgtype.Float8{},
|
ZoomLevel: pgtype.Float8{},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if conflictDetected {
|
|
||||||
koreaderData := map[string]interface{}{
|
|
||||||
"source": "koreader",
|
|
||||||
"timestamp": time.Now(),
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"percentage": book.Percentage,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if book.Epubcfi != nil {
|
|
||||||
koreaderData["data"].(map[string]interface{})["epubcfi"] = *book.Epubcfi
|
|
||||||
}
|
|
||||||
if book.Chapter != nil {
|
|
||||||
koreaderData["data"].(map[string]interface{})["chapter"] = *book.Chapter
|
|
||||||
}
|
|
||||||
if book.Character != nil {
|
|
||||||
koreaderData["data"].(map[string]interface{})["character"] = *book.Character
|
|
||||||
}
|
|
||||||
if book.Page != nil {
|
|
||||||
koreaderData["data"].(map[string]interface{})["page"] = *book.Page
|
|
||||||
}
|
|
||||||
if book.TotalPages != nil {
|
|
||||||
koreaderData["data"].(map[string]interface{})["total_pages"] = *book.TotalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
existingData := map[string]interface{}{
|
|
||||||
"source": existingProgress.LastSyncSource.String,
|
|
||||||
"timestamp": existingProgress.LastSyncTimestamp.Time,
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"percentage": existingProgress.Percentage.Float64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if existingProgress.Epubcfi.Valid {
|
|
||||||
existingData["data"].(map[string]interface{})["epubcfi"] = existingProgress.Epubcfi.String
|
|
||||||
}
|
|
||||||
if existingProgress.Chapter.Valid {
|
|
||||||
existingData["data"].(map[string]interface{})["chapter"] = existingProgress.Chapter.Int32
|
|
||||||
}
|
|
||||||
if existingProgress.CharacterOffset.Valid {
|
|
||||||
existingData["data"].(map[string]interface{})["character"] = existingProgress.CharacterOffset.Int64
|
|
||||||
}
|
|
||||||
if existingProgress.CurrentPage.Valid {
|
|
||||||
existingData["data"].(map[string]interface{})["page"] = existingProgress.CurrentPage.Int32
|
|
||||||
}
|
|
||||||
if existingProgress.TotalPages.Valid {
|
|
||||||
existingData["data"].(map[string]interface{})["total_pages"] = existingProgress.TotalPages.Int32
|
|
||||||
}
|
|
||||||
|
|
||||||
conflictData := map[string]interface{}{
|
|
||||||
"koreader": koreaderData,
|
|
||||||
"existing": existingData,
|
|
||||||
}
|
|
||||||
conflictDataJSON, _ := json.Marshal(conflictData)
|
|
||||||
|
|
||||||
_, err := h.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
|
|
||||||
MediaItemID: mediaItemID,
|
|
||||||
UserID: userID,
|
|
||||||
ConflictType: "progress",
|
|
||||||
ConflictData: conflictDataJSON,
|
|
||||||
})
|
|
||||||
if err == nil {
|
|
||||||
h.connManager.BroadcastConflictNotification(
|
|
||||||
mediaItemID.Bytes,
|
|
||||||
"detection",
|
|
||||||
"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceInfo := book.DeviceInfo
|
|
||||||
if deviceInfo.DeviceModel == "" {
|
|
||||||
deviceInfo.DeviceModel = "KOReader Device"
|
|
||||||
}
|
|
||||||
|
|
||||||
h.connManager.BroadcastProgressUpdate(
|
h.connManager.BroadcastProgressUpdate(
|
||||||
uuid.UUID(mediaItemID.Bytes),
|
mediaItemID.Bytes,
|
||||||
book.Percentage,
|
book.Percentage,
|
||||||
wsync.SourceDevice{
|
wsync.SourceDevice{
|
||||||
ID: uuid.UUID(userID.Bytes).String(),
|
ID: uuid.UUID(deviceID.Bytes).String(),
|
||||||
Name: deviceInfo.DeviceModel,
|
Name: deviceModel,
|
||||||
Type: "koreader",
|
Type: "koreader",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
_, err = h.db.UpdateDeviceLastSync(ctx, pgtype.UUID{Bytes: [16]byte{}, Valid: false})
|
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||||
|
|
||||||
return err
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textPtrToPgText(s *string) pgtype.Text {
|
||||||
|
if s != nil {
|
||||||
|
return pgtype.Text{String: *s, Valid: true}
|
||||||
|
}
|
||||||
|
return pgtype.Text{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func intPtrToPgInt4(i *int) pgtype.Int4 {
|
||||||
|
if i != nil {
|
||||||
|
return pgtype.Int4{Int32: int32(*i), Valid: true}
|
||||||
|
}
|
||||||
|
return pgtype.Int4{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64PtrToPgInt8(i *int64) pgtype.Int8 {
|
||||||
|
if i != nil {
|
||||||
|
return pgtype.Int8{Int64: *i, Valid: true}
|
||||||
|
}
|
||||||
|
return pgtype.Int8{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||||
@@ -597,26 +750,42 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
Percentage: progress.Percentage.Float64,
|
Percentage: progress.Percentage.Float64,
|
||||||
}
|
}
|
||||||
|
|
||||||
if progress.Epubcfi.Valid {
|
// CFI/xpointer are meaningless for image-based fixed-layout content; the
|
||||||
progressData.Epubcfi = &progress.Epubcfi.String
|
// page index is the canonical locator. Only return them for reflowable docs.
|
||||||
|
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
||||||
|
isFixed := formatGroup == wsync.FormatGroupFixedLayout ||
|
||||||
|
formatGroup == wsync.FormatGroupComicArchive
|
||||||
|
|
||||||
|
if !isFixed {
|
||||||
|
if progress.Epubcfi.Valid {
|
||||||
|
progressData.Epubcfi = &progress.Epubcfi.String
|
||||||
|
}
|
||||||
|
if progress.Epubcfi.Valid && wsync.IsStandardEPUBCFI(progress.Epubcfi.String) {
|
||||||
|
h.convertCFIToXPointer(c, mediaItem, progress, &progressData)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if progress.Chapter.Valid {
|
if progress.Chapter.Valid {
|
||||||
progressData.Chapter = new(int(progress.Chapter.Int32))
|
progress := int(progress.Chapter.Int32)
|
||||||
|
progressData.Chapter = &progress
|
||||||
}
|
}
|
||||||
if progress.ChapterProgress.Valid {
|
if progress.ChapterProgress.Valid {
|
||||||
progressData.ChapterProgress = new(progress.ChapterProgress.Float64)
|
progress := progress.ChapterProgress.Float64
|
||||||
|
progressData.ChapterProgress = &progress
|
||||||
}
|
}
|
||||||
if progress.CharacterOffset.Valid {
|
if progress.CharacterOffset.Valid {
|
||||||
progressData.Character = new(int64(progress.CharacterOffset.Int64))
|
progress := progress.CharacterOffset.Int64
|
||||||
|
progressData.Character = &progress
|
||||||
}
|
}
|
||||||
if progress.CurrentPage.Valid {
|
if progress.CurrentPage.Valid {
|
||||||
progressData.Page = new(int(progress.CurrentPage.Int32))
|
progress := int(progress.CurrentPage.Int32)
|
||||||
|
progressData.Page = &progress
|
||||||
}
|
}
|
||||||
if progress.TotalPages.Valid {
|
if progress.TotalPages.Valid {
|
||||||
progressData.TotalPages = new(int(progress.TotalPages.Int32))
|
progress := int(progress.TotalPages.Int32)
|
||||||
|
progressData.TotalPages = &progress
|
||||||
}
|
}
|
||||||
|
|
||||||
annotations, err := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
annotations, err := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||||
MediaItemID: pgBookUUID,
|
MediaItemID: pgBookUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
@@ -629,13 +798,29 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
|
|
||||||
for _, ann := range annotations {
|
for _, ann := range annotations {
|
||||||
if ann.AnnotationType == "highlight" {
|
if ann.AnnotationType == "highlight" {
|
||||||
annotationsResponse.Highlights = append(annotationsResponse.Highlights, KOReaderHighlight{
|
pos0 := ann.StartPosition.String
|
||||||
|
pos1 := ann.EndPosition.String
|
||||||
|
if ann.EpubcfiStart.Valid && ann.EpubcfiStart.String != "" {
|
||||||
|
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiStart.String); converted != "" {
|
||||||
|
pos0 = converted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ann.EpubcfiEnd.Valid && ann.EpubcfiEnd.String != "" {
|
||||||
|
if converted := h.reverseConvertCFI(c, mediaItem, ann.EpubcfiEnd.String); converted != "" {
|
||||||
|
pos1 = converted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
highlight := KOReaderHighlight{
|
||||||
Text: ann.SelectionText,
|
Text: ann.SelectionText,
|
||||||
Pos0: ann.StartPosition.String,
|
Pos0: pos0,
|
||||||
Pos1: ann.EndPosition.String,
|
Pos1: pos1,
|
||||||
Color: ann.Color.String,
|
Color: ann.Color.String,
|
||||||
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
Datetime: ann.CreatedAt.Time.Format(time.RFC3339),
|
||||||
})
|
}
|
||||||
|
if ann.NoteText.Valid && ann.NoteText.String != "" {
|
||||||
|
highlight.Notes = ann.NoteText.String
|
||||||
|
}
|
||||||
|
annotationsResponse.Highlights = append(annotationsResponse.Highlights, highlight)
|
||||||
} else if ann.AnnotationType == "note" {
|
} else if ann.AnnotationType == "note" {
|
||||||
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
||||||
Text: ann.SelectionText,
|
Text: ann.SelectionText,
|
||||||
@@ -645,6 +830,52 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bookmarks, _ := h.db.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
||||||
|
MediaItemID: pgBookUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
})
|
||||||
|
for _, bm := range bookmarks {
|
||||||
|
pos0 := bm.Position.String
|
||||||
|
if pos0 == "" && bm.CfiPosition.Valid {
|
||||||
|
pos0 = bm.CfiPosition.String
|
||||||
|
}
|
||||||
|
koreaderBookmark := KOReaderBookmark{
|
||||||
|
Text: bm.Title,
|
||||||
|
Pos0: pos0,
|
||||||
|
Pos1: pos0,
|
||||||
|
Datetime: bm.CreatedAt.Time.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
if bm.Notes.Valid && bm.Notes.String != "" {
|
||||||
|
koreaderBookmark.Notes = bm.Notes.String
|
||||||
|
}
|
||||||
|
if bm.ChapterNumber.Valid {
|
||||||
|
koreaderBookmark.Chapter = int(bm.ChapterNumber.Int32)
|
||||||
|
}
|
||||||
|
annotationsResponse.Bookmarks = append(annotationsResponse.Bookmarks, koreaderBookmark)
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-h.annotationSvc.ActiveTombstoneTTL()), Valid: true}
|
||||||
|
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||||
|
MediaItemID: pgBookUUID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
DeletedAt: cutoff,
|
||||||
|
})
|
||||||
|
for _, ts := range tombstones {
|
||||||
|
var dd map[string]interface{}
|
||||||
|
if len(ts.DeviceSyncData) > 0 {
|
||||||
|
json.Unmarshal(ts.DeviceSyncData, &dd)
|
||||||
|
}
|
||||||
|
if dd == nil {
|
||||||
|
dd = map[string]interface{}{}
|
||||||
|
}
|
||||||
|
dd["dedup_key"] = ts.DedupKey.String
|
||||||
|
if ts.AnnotationType == "highlight" {
|
||||||
|
annotationsResponse.DeletedHighlights = append(annotationsResponse.DeletedHighlights, dd)
|
||||||
|
} else if ts.AnnotationType == "bookmark" {
|
||||||
|
annotationsResponse.DeletedBookmarks = append(annotationsResponse.DeletedBookmarks, dd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
lastSync := "never"
|
lastSync := "never"
|
||||||
if progress.LastSyncTimestamp.Valid {
|
if progress.LastSyncTimestamp.Valid {
|
||||||
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
|
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
|
||||||
@@ -662,6 +893,55 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, metadata)
|
return c.JSON(http.StatusOK, metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
|
||||||
|
if h.libraryService == nil {
|
||||||
|
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if epubPath == "" {
|
||||||
|
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
converter := wsync.NewCFIConverter(epubPath)
|
||||||
|
contextText := ""
|
||||||
|
if progress.ContextText.Valid {
|
||||||
|
contextText = progress.ContextText.String
|
||||||
|
}
|
||||||
|
pct := progress.Percentage.Float64
|
||||||
|
|
||||||
|
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result != nil && result.XPointer != "" {
|
||||||
|
progressData.KoreaderXPointer = &result.XPointer
|
||||||
|
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string) string {
|
||||||
|
if h.libraryService == nil || epubcfi == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||||
|
if err != nil || epubPath == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, "", mediaItem.FormatGroup, epubPath, "")
|
||||||
|
if loc.Position != "" && loc.Position != epubcfi {
|
||||||
|
return loc.Position
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||||
device := c.Get("device").(database.Devices)
|
device := c.Get("device").(database.Devices)
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
@@ -693,14 +973,15 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
percentRead = progress.Percentage.Float64
|
percentRead = progress.Percentage.Float64
|
||||||
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
|
if progress.TotalPages.Valid && progress.CurrentPage.Valid {
|
||||||
pagesRemaining = new(int(progress.TotalPages.Int32 - progress.CurrentPage.Int32))
|
pages := int(progress.TotalPages.Int32 - progress.CurrentPage.Int32)
|
||||||
|
pagesRemaining = &pages
|
||||||
}
|
}
|
||||||
if progress.LastReadAt.Valid {
|
if progress.LastReadAt.Valid {
|
||||||
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
lastModified = progress.LastReadAt.Time.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||||
MediaItemID: pgItemUUID,
|
MediaItemID: pgItemUUID,
|
||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
})
|
})
|
||||||
@@ -800,15 +1081,36 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: mediaItemID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"datetime": bookmark.Datetime,
|
||||||
Content: bookmark.Text,
|
"pos0": bookmark.Pos0,
|
||||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
"page": bookmark.Page,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err == nil {
|
result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||||
bookmarksSynced++
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Title: bookmark.Text,
|
||||||
|
Position: position,
|
||||||
|
ChapterNumber: int32(bookmark.Chapter),
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Content: bookmark.Text,
|
||||||
|
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
bookmarksSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -830,15 +1132,35 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
position = fmt.Sprintf("page:%d", note.Page)
|
position = fmt.Sprintf("page:%d", note.Page)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: mediaItemID,
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
UserID: pgUserID,
|
"datetime": note.Datetime,
|
||||||
Content: note.Notes,
|
"pos0": note.Pos0,
|
||||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
"page": note.Page,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err == nil {
|
result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{
|
||||||
notesSynced++
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Content: note.Notes,
|
||||||
|
Position: position,
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
notesSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
Content: note.Notes,
|
||||||
|
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
notesSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,17 +1187,51 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
color = highlight.Color
|
color = highlight.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
if h.annotationSvc != nil {
|
||||||
MediaItemID: mediaItemID,
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1)
|
||||||
UserID: pgUserID,
|
|
||||||
SelectionText: highlight.Text,
|
|
||||||
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
|
|
||||||
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
|
|
||||||
Color: pgtype.Text{String: color, Valid: true},
|
|
||||||
})
|
|
||||||
|
|
||||||
if err == nil {
|
pctStart := 0.0
|
||||||
highlightsSynced++
|
if highlight.Percentage != nil {
|
||||||
|
pctStart = *highlight.Percentage
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"datetime": highlight.Datetime,
|
||||||
|
"pos0": highlight.Pos0,
|
||||||
|
"pos1": highlight.Pos1,
|
||||||
|
"page": highlight.Page,
|
||||||
|
})
|
||||||
|
|
||||||
|
result, err := h.annotationSvc.SaveHighlight(ctx, wsync.SaveHighlightRequest{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: highlight.Text,
|
||||||
|
StartPosition: startPos,
|
||||||
|
EndPosition: endPos,
|
||||||
|
Color: color,
|
||||||
|
NoteText: highlight.Notes,
|
||||||
|
PercentageStart: pctStart,
|
||||||
|
EpubcfiStart: epubcfiStart,
|
||||||
|
EpubcfiEnd: epubcfiEnd,
|
||||||
|
Source: "koreader",
|
||||||
|
DeviceSyncData: deviceData,
|
||||||
|
})
|
||||||
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
|
highlightsSynced++
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_, err := h.db.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||||
|
MediaItemID: mediaItemID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: highlight.Text,
|
||||||
|
StartPosition: pgtype.Text{String: startPos, Valid: startPos != ""},
|
||||||
|
EndPosition: pgtype.Text{String: endPos, Valid: endPos != ""},
|
||||||
|
Color: pgtype.Text{String: color, Valid: true},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
highlightsSynced++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -320,7 +320,7 @@ func parseUUID(uuidStr string) (pgtype.UUID, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return pgtype.UUID{}, err
|
return pgtype.UUID{}, err
|
||||||
}
|
}
|
||||||
return pgtype.UUID{Bytes: [16]byte(parsedUUID), Valid: true}, nil
|
return pgtype.UUID{Bytes: parsedUUID, Valid: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserVisibleLibrariesData returns libraries for SSR (not JSON response)
|
// GetUserVisibleLibrariesData returns libraries for SSR (not JSON response)
|
||||||
|
|||||||
+344
-60
@@ -3,19 +3,23 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
|
wsync "bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"mime"
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
@@ -60,33 +64,41 @@ type CreateMediaItemRequest struct {
|
|||||||
|
|
||||||
// UpdateMediaItemRequest represents the request for updating a media item
|
// UpdateMediaItemRequest represents the request for updating a media item
|
||||||
type UpdateMediaItemRequest struct {
|
type UpdateMediaItemRequest struct {
|
||||||
Title string `json:"title" validate:"required,min=1,max=500"`
|
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
||||||
Author string `json:"author"`
|
Author string `form:"author" json:"author"`
|
||||||
ISBN string `json:"isbn"`
|
ISBN string `form:"isbn" json:"isbn"`
|
||||||
Description string `json:"description"`
|
Description string `form:"description" json:"description"`
|
||||||
CoverImagePath string `json:"cover_image_path"`
|
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
|
||||||
Series string `json:"series"`
|
CoverAction string `form:"cover_action" json:"cover_action"`
|
||||||
SeriesNumber int32 `json:"series_number"`
|
Series string `form:"series" json:"series"`
|
||||||
Tags []string `json:"tags"`
|
SeriesNumber int32 `form:"series_number" json:"series_number"`
|
||||||
ASIN string `json:"asin"`
|
Tags []string `form:"tags" json:"tags"`
|
||||||
DatePublished string `json:"date_published"`
|
ASIN string `form:"asin" json:"asin"`
|
||||||
Publisher string `json:"publisher"`
|
DatePublished string `form:"date_published" json:"date_published"`
|
||||||
Contributors []string `json:"contributors"`
|
Publisher string `form:"publisher" json:"publisher"`
|
||||||
// NEW: Reading direction and comic metadata fields
|
Contributors []string `form:"contributors" json:"contributors"`
|
||||||
MangaType string `json:"manga_type"` // 'unknown' | 'no' | 'yes' | 'yes_and_right_to_left'
|
Language string `form:"language" json:"language"`
|
||||||
ReadingDirection string `json:"reading_direction"` // 'auto' | 'ltr' | 'rtl' | 'vertical'
|
Edition string `form:"edition" json:"edition"`
|
||||||
SeriesCount int32 `json:"series_count"`
|
PageCount int32 `form:"page_count" json:"page_count"`
|
||||||
Volume int32 `json:"volume"`
|
Genre string `form:"genre" json:"genre"`
|
||||||
Imprint string `json:"imprint"`
|
CopyrightYear int32 `form:"copyright_year" json:"copyright_year"`
|
||||||
AgeRating string `json:"age_rating"` // 'Everyone' | 'Teen' | 'Mature' | 'Adult'
|
GoodreadsID string `form:"goodreads_id" json:"goodreads_id"`
|
||||||
WebURL string `json:"web_url"`
|
OpenlibraryID string `form:"openlibrary_id" json:"openlibrary_id"`
|
||||||
MetadataNotes string `json:"metadata_notes"`
|
GoogleBooksID string `form:"google_books_id" json:"google_books_id"`
|
||||||
CommunityRating float64 `json:"community_rating"`
|
MangaType string `form:"manga_type" json:"manga_type"`
|
||||||
StoryArc string `json:"story_arc"`
|
ReadingDirection string `form:"reading_direction" json:"reading_direction"`
|
||||||
IsBlackAndWhite bool `json:"is_black_and_white"`
|
SeriesCount int32 `form:"series_count" json:"series_count"`
|
||||||
AlternateInfo string `json:"alternate_info"` // JSON string
|
Volume int32 `form:"volume" json:"volume"`
|
||||||
ScanInformation string `json:"scan_information"`
|
Imprint string `form:"imprint" json:"imprint"`
|
||||||
Summary string `json:"summary"`
|
AgeRating string `form:"age_rating" json:"age_rating"`
|
||||||
|
WebURL string `form:"web_url" json:"web_url"`
|
||||||
|
MetadataNotes string `form:"metadata_notes" json:"metadata_notes"`
|
||||||
|
CommunityRating float64 `form:"community_rating" json:"community_rating"`
|
||||||
|
StoryArc string `form:"story_arc" json:"story_arc"`
|
||||||
|
IsBlackAndWhite bool `form:"is_black_and_white" json:"is_black_and_white"`
|
||||||
|
AlternateInfo string `form:"alternate_info" json:"alternate_info"`
|
||||||
|
ScanInformation string `form:"scan_information" json:"scan_information"`
|
||||||
|
Summary string `form:"summary" json:"summary"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateMediaNoteRequest represents the request for creating a media note
|
// CreateMediaNoteRequest represents the request for creating a media note
|
||||||
@@ -124,6 +136,8 @@ type MediaHandler struct {
|
|||||||
worker *services.Worker
|
worker *services.Worker
|
||||||
libraryService *services.LibraryService
|
libraryService *services.LibraryService
|
||||||
searchService *services.SearchService
|
searchService *services.SearchService
|
||||||
|
progressSvc *wsync.ProgressService
|
||||||
|
annotationSvc *wsync.AnnotationService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
||||||
@@ -138,6 +152,14 @@ func NewMediaHandler(db *database.Queries, libraryService *services.LibraryServi
|
|||||||
return mh
|
return mh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||||
|
mh.progressSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||||
|
mh.annotationSvc = svc
|
||||||
|
}
|
||||||
|
|
||||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -548,7 +570,22 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
|||||||
PageCount: existingMedia.PageCount,
|
PageCount: existingMedia.PageCount,
|
||||||
GoodreadsID: existingMedia.GoodreadsID,
|
GoodreadsID: existingMedia.GoodreadsID,
|
||||||
OpenlibraryID: existingMedia.OpenlibraryID,
|
OpenlibraryID: existingMedia.OpenlibraryID,
|
||||||
|
GoogleBooksID: existingMedia.GoogleBooksID,
|
||||||
CoverImagePath: existingMedia.CoverImagePath,
|
CoverImagePath: existingMedia.CoverImagePath,
|
||||||
|
MangaType: existingMedia.MangaType,
|
||||||
|
ReadingDirection: existingMedia.ReadingDirection,
|
||||||
|
SeriesCount: existingMedia.SeriesCount,
|
||||||
|
Volume: existingMedia.Volume,
|
||||||
|
Imprint: existingMedia.Imprint,
|
||||||
|
AgeRating: existingMedia.AgeRating,
|
||||||
|
WebUrl: existingMedia.WebUrl,
|
||||||
|
MetadataNotes: existingMedia.MetadataNotes,
|
||||||
|
CommunityRating: existingMedia.CommunityRating,
|
||||||
|
StoryArc: existingMedia.StoryArc,
|
||||||
|
IsBlackAndWhite: existingMedia.IsBlackAndWhite,
|
||||||
|
AlternateInfo: existingMedia.AlternateInfo,
|
||||||
|
ScanInformation: existingMedia.ScanInformation,
|
||||||
|
Summary: existingMedia.Summary,
|
||||||
}
|
}
|
||||||
|
|
||||||
if update.Updates.Title != nil {
|
if update.Updates.Title != nil {
|
||||||
@@ -728,7 +765,7 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
|||||||
|
|
||||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -836,7 +873,7 @@ func (mh *MediaHandler) GetMediaRating(c *echo.Context) error {
|
|||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
|
return c.JSON(http.StatusOK, map[string]interface{}{"rating": nil})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -889,12 +926,12 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
progress, err := mh.db.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
progress, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"current_page": 0,
|
"current_page": 0,
|
||||||
"total_pages": nil,
|
"total_pages": nil,
|
||||||
@@ -903,7 +940,27 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, progress)
|
resp := map[string]interface{}{
|
||||||
|
"id": progress.ID,
|
||||||
|
"media_item_id": progress.MediaItemID,
|
||||||
|
"user_id": progress.UserID,
|
||||||
|
"current_page": progress.CurrentPage,
|
||||||
|
"total_pages": progress.TotalPages,
|
||||||
|
"last_read_at": progress.LastReadAt,
|
||||||
|
"percentage": progress.Percentage,
|
||||||
|
"character_offset": progress.CharacterOffset,
|
||||||
|
"epubcfi": progress.Epubcfi,
|
||||||
|
"chapter": progress.Chapter,
|
||||||
|
"chapter_progress": progress.ChapterProgress,
|
||||||
|
"format_group": progress.FormatGroup,
|
||||||
|
"total_characters": progress.TotalCharacters,
|
||||||
|
"chapter_count": progress.ChapterCount,
|
||||||
|
"last_sync_device": progress.LastSyncDevice,
|
||||||
|
"last_sync_source": progress.LastSyncSource,
|
||||||
|
"last_sync_timestamp": progress.LastSyncTimestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
|
// UpdateMediaReadingProgress handles PUT /api/media-items/:id/progress
|
||||||
@@ -921,24 +978,94 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
CurrentPage int32 `json:"current_page"`
|
CurrentPage *int32 `json:"current_page"`
|
||||||
TotalPages int32 `json:"total_pages"`
|
TotalPages *int32 `json:"total_pages"`
|
||||||
Epubcfi string `json:"epubcfi"`
|
Epubcfi *string `json:"epubcfi"`
|
||||||
Percentage float64 `json:"percentage"`
|
ContextText *string `json:"context_text"`
|
||||||
|
Percentage *float64 `json:"percentage"`
|
||||||
|
Chapter *int `json:"chapter"`
|
||||||
|
ChapterProgress *float64 `json:"chapter_progress"`
|
||||||
|
CharacterOffset *int64 `json:"character_offset"`
|
||||||
|
ReadingMode *string `json:"reading_mode"`
|
||||||
|
ZoomLevel *float64 `json:"zoom_level"`
|
||||||
|
ScrollX *float64 `json:"scroll_position_x"`
|
||||||
|
ScrollY *float64 `json:"scroll_position_y"`
|
||||||
}
|
}
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
}
|
}
|
||||||
if err := c.Validate(&req); err != nil {
|
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
if mh.progressSvc != nil {
|
||||||
|
saveReq := wsync.SaveProgressRequest{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
Source: "web",
|
||||||
|
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
Percentage: req.Percentage,
|
||||||
|
Epubcfi: req.Epubcfi,
|
||||||
|
ContextText: req.ContextText,
|
||||||
|
CharacterOffset: req.CharacterOffset,
|
||||||
|
Chapter: req.Chapter,
|
||||||
|
ChapterProgress: req.ChapterProgress,
|
||||||
|
CurrentPage: nil,
|
||||||
|
TotalPages: nil,
|
||||||
|
ZoomLevel: req.ZoomLevel,
|
||||||
|
ScrollX: req.ScrollX,
|
||||||
|
ScrollY: req.ScrollY,
|
||||||
|
ReadingMode: req.ReadingMode,
|
||||||
|
DeviceType: "web",
|
||||||
|
DeviceName: "Web",
|
||||||
|
Broadcast: true,
|
||||||
|
}
|
||||||
|
if req.CurrentPage != nil {
|
||||||
|
cp := int(*req.CurrentPage)
|
||||||
|
saveReq.CurrentPage = &cp
|
||||||
|
}
|
||||||
|
if req.TotalPages != nil {
|
||||||
|
tp := int(*req.TotalPages)
|
||||||
|
saveReq.TotalPages = &tp
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Percentage != nil && *req.Percentage < 0.005 {
|
||||||
|
existing, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
})
|
||||||
|
if err == nil && existing.Percentage.Valid && existing.Percentage.Float64 > 0.01 {
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"status": "ignored"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
percentage := 0.0
|
||||||
|
if req.Percentage != nil {
|
||||||
|
percentage = *req.Percentage
|
||||||
|
}
|
||||||
|
epubcfi := ""
|
||||||
|
if req.Epubcfi != nil {
|
||||||
|
epubcfi = *req.Epubcfi
|
||||||
|
}
|
||||||
|
currentPage := int32(0)
|
||||||
|
if req.CurrentPage != nil {
|
||||||
|
currentPage = *req.CurrentPage
|
||||||
|
}
|
||||||
|
totalPages := int32(0)
|
||||||
|
if req.TotalPages != nil {
|
||||||
|
totalPages = *req.TotalPages
|
||||||
}
|
}
|
||||||
|
|
||||||
progress, err := mh.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
progress, err := mh.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
Percentage: pgtype.Float8{Float64: req.Percentage, Valid: true},
|
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||||
CharacterOffset: pgtype.Int8{Valid: false},
|
CharacterOffset: pgtype.Int8{Valid: false},
|
||||||
Epubcfi: pgtype.Text{String: req.Epubcfi, Valid: req.Epubcfi != ""},
|
Epubcfi: pgtype.Text{String: epubcfi, Valid: epubcfi != ""},
|
||||||
Chapter: pgtype.Int4{Valid: false},
|
Chapter: pgtype.Int4{Valid: false},
|
||||||
ChapterProgress: pgtype.Float8{Valid: false},
|
ChapterProgress: pgtype.Float8{Valid: false},
|
||||||
ViewportX: pgtype.Float8{Valid: false},
|
ViewportX: pgtype.Float8{Valid: false},
|
||||||
@@ -950,8 +1077,8 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
|||||||
ReadingMode: pgtype.Text{Valid: false},
|
ReadingMode: pgtype.Text{Valid: false},
|
||||||
LastSyncDevice: pgtype.Text{String: "web", Valid: true},
|
LastSyncDevice: pgtype.Text{String: "web", Valid: true},
|
||||||
LastSyncSource: pgtype.Text{String: "web", Valid: true},
|
LastSyncSource: pgtype.Text{String: "web", Valid: true},
|
||||||
CurrentPage: pgtype.Int4{Int32: req.CurrentPage, Valid: true},
|
CurrentPage: pgtype.Int4{Int32: currentPage, Valid: true},
|
||||||
TotalPages: pgtype.Int4{Int32: req.TotalPages, Valid: req.TotalPages > 0},
|
TotalPages: pgtype.Int4{Int32: totalPages, Valid: totalPages > 0},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -1026,7 +1153,7 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
|||||||
|
|
||||||
_, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
|
_, err = mh.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: req.LibraryID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -1109,25 +1236,52 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
tagsSearch := utils.NormalizeTagsSearch(req.Tags)
|
||||||
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
contributorsSearch := utils.NormalizeContributorsSearch(req.Contributors)
|
||||||
|
|
||||||
// Validate and normalize ISBN
|
|
||||||
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
normalizedISBN, err := utils.NormalizeISBN(req.ISBN)
|
||||||
if err != nil && req.ISBN != "" {
|
if err != nil && req.ISBN != "" {
|
||||||
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
|
return c.JSON(http.StatusUnprocessableEntity, map[string]string{"error": "invalid ISBN format"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use normalized ISBN if valid, otherwise empty string
|
|
||||||
isbnValue := normalizedISBN
|
isbnValue := normalizedISBN
|
||||||
if err != nil {
|
if err != nil {
|
||||||
isbnValue = ""
|
isbnValue = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if req.CoverAction == "" {
|
||||||
|
req.CoverAction = "keep"
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||||
|
}
|
||||||
|
|
||||||
|
coverPath := existing.CoverImagePath.String
|
||||||
|
|
||||||
|
if req.CoverAction == "remove" {
|
||||||
|
coverPath = ""
|
||||||
|
} else if req.CoverAction == "upload" {
|
||||||
|
file, err := c.FormFile("cover_file")
|
||||||
|
if err == nil {
|
||||||
|
savedPath, err := mh.saveCoverImage(*c, mediaUUID, file)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
||||||
|
}
|
||||||
|
coverPath = savedPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var alternateInfoBytes []byte
|
||||||
|
if req.AlternateInfo != "" {
|
||||||
|
alternateInfoBytes = []byte(req.AlternateInfo)
|
||||||
|
}
|
||||||
|
|
||||||
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
|
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
|
||||||
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||||
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
||||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""},
|
||||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||||
Tags: req.Tags,
|
Tags: req.Tags,
|
||||||
@@ -1137,11 +1291,37 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||||
Contributors: req.Contributors,
|
Contributors: req.Contributors,
|
||||||
ContributorsSearch: contributorsSearch,
|
ContributorsSearch: contributorsSearch,
|
||||||
|
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},
|
||||||
|
Edition: pgtype.Text{String: req.Edition, Valid: req.Edition != ""},
|
||||||
|
PageCount: pgtype.Int4{Int32: req.PageCount, Valid: req.PageCount > 0},
|
||||||
|
Genre: pgtype.Text{String: req.Genre, Valid: req.Genre != ""},
|
||||||
|
CopyrightYear: pgtype.Int4{Int32: req.CopyrightYear, Valid: req.CopyrightYear > 0},
|
||||||
|
GoodreadsID: pgtype.Text{String: req.GoodreadsID, Valid: req.GoodreadsID != ""},
|
||||||
|
OpenlibraryID: pgtype.Text{String: req.OpenlibraryID, Valid: req.OpenlibraryID != ""},
|
||||||
|
GoogleBooksID: pgtype.Text{String: req.GoogleBooksID, Valid: req.GoogleBooksID != ""},
|
||||||
|
MangaType: pgtype.Text{String: req.MangaType, Valid: req.MangaType != ""},
|
||||||
|
ReadingDirection: pgtype.Text{String: req.ReadingDirection, Valid: req.ReadingDirection != ""},
|
||||||
|
SeriesCount: pgtype.Int4{Int32: req.SeriesCount, Valid: req.SeriesCount > 0},
|
||||||
|
Volume: pgtype.Int4{Int32: req.Volume, Valid: req.Volume > 0},
|
||||||
|
Imprint: pgtype.Text{String: req.Imprint, Valid: req.Imprint != ""},
|
||||||
|
AgeRating: pgtype.Text{String: req.AgeRating, Valid: req.AgeRating != ""},
|
||||||
|
WebUrl: pgtype.Text{String: req.WebURL, Valid: req.WebURL != ""},
|
||||||
|
MetadataNotes: pgtype.Text{String: req.MetadataNotes, Valid: req.MetadataNotes != ""},
|
||||||
|
CommunityRating: pgtype.Float8{Float64: req.CommunityRating, Valid: req.CommunityRating > 0},
|
||||||
|
StoryArc: pgtype.Text{String: req.StoryArc, Valid: req.StoryArc != ""},
|
||||||
|
IsBlackAndWhite: pgtype.Bool{Bool: req.IsBlackAndWhite, Valid: req.IsBlackAndWhite},
|
||||||
|
AlternateInfo: alternateInfoBytes,
|
||||||
|
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
||||||
|
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
|
c.Response().Header().Set("HX-Redirect", "/media/"+mediaID)
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, item)
|
return c.JSON(http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1214,14 +1394,31 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
var note database.MediaNotes
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
if mh.annotationSvc != nil {
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
|
||||||
Content: req.Content,
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
})
|
Content: req.Content,
|
||||||
if err != nil {
|
Position: req.Position,
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
Source: "web",
|
||||||
|
ModifiedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
note = result.Note
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||||
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
|
Content: req.Content,
|
||||||
|
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusCreated, note)
|
return c.JSON(http.StatusCreated, note)
|
||||||
@@ -1237,7 +1434,7 @@ func (mh *MediaHandler) GetMediaNote(c *echo.Context) error {
|
|||||||
|
|
||||||
note, err := mh.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
note, err := mh.db.GetMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "note not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -1282,7 +1479,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
if mh.annotationSvc != nil {
|
||||||
|
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||||
|
} else {
|
||||||
|
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -1351,9 +1552,29 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
|
|||||||
color = req.Color
|
color = req.Color
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
||||||
|
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||||
|
|
||||||
|
if mh.annotationSvc != nil {
|
||||||
|
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||||
|
MediaItemID: pgMediaID,
|
||||||
|
UserID: pgUserID,
|
||||||
|
SelectionText: req.SelectionText,
|
||||||
|
StartPosition: req.StartPosition,
|
||||||
|
EndPosition: req.EndPosition,
|
||||||
|
Color: color,
|
||||||
|
Source: "web",
|
||||||
|
ModifiedAt: time.Now(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusCreated, result.Highlight)
|
||||||
|
}
|
||||||
|
|
||||||
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
highlight, err := mh.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
MediaItemID: pgMediaID,
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgUserID,
|
||||||
SelectionText: req.SelectionText,
|
SelectionText: req.SelectionText,
|
||||||
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
||||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||||
@@ -1377,7 +1598,7 @@ func (mh *MediaHandler) GetMediaHighlight(c *echo.Context) error {
|
|||||||
|
|
||||||
highlight, err := mh.db.GetMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
highlight, err := mh.db.GetMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "highlight not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -1439,7 +1660,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
||||||
|
|
||||||
|
if mh.annotationSvc != nil {
|
||||||
|
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.NoContent(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -1561,6 +1791,10 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
|||||||
"results": []interface{}{},
|
"results": []interface{}{},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
for i := range results {
|
||||||
|
resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath)
|
||||||
|
results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""}
|
||||||
|
}
|
||||||
return c.JSON(http.StatusOK, results)
|
return c.JSON(http.StatusOK, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1684,3 +1918,53 @@ func jsonBytesToMap(b []byte) map[string]interface{} {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file *multipart.FileHeader) (string, error) {
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to open uploaded file: %w", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
imageData, err := io.ReadAll(src)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read uploaded file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(imageData) < 512 {
|
||||||
|
return "", fmt.Errorf("file too small to be a valid image")
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := http.DetectContentType(imageData)
|
||||||
|
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/webp" {
|
||||||
|
return "", fmt.Errorf("invalid image type: %s", contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("media item not found: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
relativeFilePath := mediaItem.FilePath
|
||||||
|
if relativeFilePath == "" {
|
||||||
|
return "", fmt.Errorf("media item has no file path")
|
||||||
|
}
|
||||||
|
|
||||||
|
coverRelPath := relativeFilePath + ".cover.jpg"
|
||||||
|
|
||||||
|
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to resolve cover path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
coverDir := filepath.Dir(coverFullPath)
|
||||||
|
if err := os.MkdirAll(coverDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cover directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(coverFullPath, imageData, 0644); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to write cover file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return coverRelPath, nil
|
||||||
|
}
|
||||||
|
|||||||
+205
-46
@@ -26,6 +26,26 @@ type OPDSHandler struct {
|
|||||||
conversionService interface {
|
conversionService interface {
|
||||||
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
ConvertEPUBToKEPUB(ctx context.Context, mediaItemID pgtype.UUID, epubPath string) (*services.ConvertedKEPUB, error)
|
||||||
}
|
}
|
||||||
|
settings *database.SettingsRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry (OPDS page size).
|
||||||
|
func (h *OPDSHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||||
|
|
||||||
|
// opdsDefaultPageSize returns the configured default page size (50 if unset).
|
||||||
|
func (h *OPDSHandler) opdsDefaultPageSize() int {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.OpdsDefaultPageSize()
|
||||||
|
}
|
||||||
|
return 50
|
||||||
|
}
|
||||||
|
|
||||||
|
// opdsMaxPageSize returns the configured maximum page size (200 if unset).
|
||||||
|
func (h *OPDSHandler) opdsMaxPageSize() int {
|
||||||
|
if h.settings != nil {
|
||||||
|
return h.settings.OpdsMaxPageSize()
|
||||||
|
}
|
||||||
|
return 200
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
|
func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryService, conversionService interface {
|
||||||
@@ -38,15 +58,118 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get base URL from system config
|
// Helper function to get base URL from system config with request-derived fallback
|
||||||
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
||||||
baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url")
|
var dbBaseURL string
|
||||||
if err != nil {
|
if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil {
|
||||||
return "", "", fmt.Errorf("failed to get base_url from config: %w", err)
|
dbBaseURL = config.Value
|
||||||
|
}
|
||||||
|
baseURL := deriveBaseURL(c, dbBaseURL)
|
||||||
|
opdsBaseURL := baseURL + "/opds"
|
||||||
|
return baseURL, opdsBaseURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OPDSHandler) getAuthToken(c *echo.Context) string {
|
||||||
|
token := c.QueryParam("token")
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimPrefix(c.Request().Header.Get("Authorization"), "Bearer ")
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendToken(url, token string) string {
|
||||||
|
if token == "" {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
if strings.Contains(url, "?") {
|
||||||
|
return url + "&token=" + token
|
||||||
|
}
|
||||||
|
return url + "?token=" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
|
||||||
|
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||||
|
|
||||||
|
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
|
||||||
|
// previous, next, last) and OpenSearch paging metadata (totalResults,
|
||||||
|
// itemsPerPage, startIndex) to a feed based on the current page position.
|
||||||
|
// catalogBase is the device catalog URL without query parameters. The token
|
||||||
|
// (device auth) is appended to every generated link.
|
||||||
|
func addCatalogPaginationLinks(feed *opds.Feed, catalogBase string, pageNum, perPageNum, totalItems int, token string) {
|
||||||
|
totalPages := 0
|
||||||
|
if totalItems > 0 {
|
||||||
|
totalPages = (totalItems + perPageNum - 1) / perPageNum
|
||||||
|
}
|
||||||
|
startIdx := (pageNum - 1) * perPageNum
|
||||||
|
|
||||||
|
pagedURL := func(page int) string {
|
||||||
|
return appendToken(fmt.Sprintf("%s?page=%d&per_page=%d", catalogBase, page, perPageNum), token)
|
||||||
}
|
}
|
||||||
|
|
||||||
opdsBaseURL := baseURL.Value + "/opds"
|
// self reflects the current page; start/first point to the first page
|
||||||
return baseURL.Value, opdsBaseURL, nil
|
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
|
||||||
|
feed.AddLink(pagedURL(1), catalogMediaType, "start")
|
||||||
|
feed.AddLink(pagedURL(1), catalogMediaType, "first")
|
||||||
|
if totalPages > 0 {
|
||||||
|
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
|
||||||
|
}
|
||||||
|
if pageNum > 1 {
|
||||||
|
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
|
||||||
|
}
|
||||||
|
if pageNum < totalPages {
|
||||||
|
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
feed.SetPagination(totalItems, perPageNum, startIdx+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveMimeType returns the mime type for a media item, preferring the stored
|
||||||
|
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
||||||
|
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
||||||
|
if mime.Valid && mime.String != "" {
|
||||||
|
return mime.String
|
||||||
|
}
|
||||||
|
if formatMime.Valid && formatMime.String != "" {
|
||||||
|
return formatMime.String
|
||||||
|
}
|
||||||
|
return "application/epub+zip"
|
||||||
|
}
|
||||||
|
|
||||||
|
// isComicArchive reports whether a format group represents a comic/manga
|
||||||
|
// archive (cbz/cbr/cb7/cbt). Comic archives are served in their native format
|
||||||
|
// and should not be offered as EPUB/KEPUB/PDF conversions.
|
||||||
|
func isComicArchive(formatGroup string) bool {
|
||||||
|
return strings.EqualFold(formatGroup, "comic_archive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatLabelFromPath derives a short format label (e.g. "epub", "cbz") from a
|
||||||
|
// file path's extension, defaulting to "epub" when it cannot be determined.
|
||||||
|
func formatLabelFromPath(path string) string {
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
switch ext {
|
||||||
|
case ".epub":
|
||||||
|
return "epub"
|
||||||
|
case ".pdf":
|
||||||
|
return "pdf"
|
||||||
|
case ".cbz":
|
||||||
|
return "cbz"
|
||||||
|
case ".cbr":
|
||||||
|
return "cbr"
|
||||||
|
case ".cb7":
|
||||||
|
return "cb7"
|
||||||
|
case ".cbt":
|
||||||
|
return "cbt"
|
||||||
|
case ".mobi":
|
||||||
|
return "mobi"
|
||||||
|
case ".azw", ".azw3":
|
||||||
|
return "azw3"
|
||||||
|
case ".txt":
|
||||||
|
return "txt"
|
||||||
|
case "":
|
||||||
|
return "epub"
|
||||||
|
default:
|
||||||
|
return strings.TrimPrefix(ext, ".")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
||||||
@@ -65,9 +188,10 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
perPageNum := 50
|
perPageNum := h.opdsDefaultPageSize()
|
||||||
|
maxPerPage := h.opdsMaxPageSize()
|
||||||
if perPage != "" {
|
if perPage != "" {
|
||||||
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= 200 {
|
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
|
||||||
perPageNum = num
|
perPageNum = num
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,13 +263,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
"Bookhoard Library",
|
"Bookhoard Library",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Feed links, including OPDS pagination links (first/previous/next/last) and
|
||||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
token := h.getAuthToken(c)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
|
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID)
|
// OpenSearch: the search link points to an OpenSearch description document
|
||||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
// (served by the same /search endpoint when no query is supplied) so that
|
||||||
|
// OPDS clients like KOReader can discover how to formulate search requests.
|
||||||
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
|
||||||
|
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||||
|
|
||||||
// Add entries
|
// Add entries
|
||||||
for _, item := range allItems {
|
for _, item := range allItems {
|
||||||
@@ -170,16 +298,21 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
entry.SetSummary(item.Description.String)
|
entry.SetSummary(item.Description.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add acquisition links
|
// Add acquisition link using the item's real mime type
|
||||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||||
|
|
||||||
// Add format variants
|
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic
|
||||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
// archives are served as-is in their native format.
|
||||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
if !isComicArchive(item.FormatGroup) {
|
||||||
|
if device.DeviceType == "kobo" {
|
||||||
|
kepubURL := downloadURL + "&format=kepub"
|
||||||
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||||
|
}
|
||||||
|
|
||||||
pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL)
|
pdfURL := downloadURL + "&format=pdf"
|
||||||
entry.AddAlternateLink(pdfURL, "application/pdf")
|
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||||
|
}
|
||||||
|
|
||||||
// Add canonical identifier
|
// Add canonical identifier
|
||||||
entry.SetIdentifier(bookUUID)
|
entry.SetIdentifier(bookUUID)
|
||||||
@@ -194,7 +327,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID {
|
if col.UserID.Valid && col.UserID.Bytes == userUUID {
|
||||||
entry.AddCategory(collectionScheme, col.Name)
|
entry.AddCategory(collectionScheme, col.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,16 +346,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
return c.String(http.StatusOK, xmlString)
|
return c.String(http.StatusOK, xmlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchDeviceCatalog searches the OPDS catalog for a device
|
// SearchDeviceCatalog searches the OPDS catalog for a device.
|
||||||
|
//
|
||||||
|
// When no "q" query parameter is supplied it returns an OpenSearch description
|
||||||
|
// document (application/opensearchdescription+xml) so that OPDS clients such as
|
||||||
|
// KOReader can discover the search URL template (which contains the
|
||||||
|
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
|
||||||
|
// acquisition feed of matching books.
|
||||||
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||||
deviceID := c.Param("deviceId")
|
deviceID := c.Param("deviceId")
|
||||||
|
|
||||||
query := c.QueryParam("q")
|
query := c.QueryParam("q")
|
||||||
|
|
||||||
if query == "" {
|
|
||||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get base URLs
|
// Get base URLs
|
||||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -243,12 +377,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
|
|
||||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
token := h.getAuthToken(c)
|
||||||
|
|
||||||
|
// No query: serve the OpenSearch description document so clients can learn
|
||||||
|
// the search template (contains the {searchTerms} placeholder).
|
||||||
|
if query == "" {
|
||||||
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
||||||
|
desc := opds.NewSearchDescription(
|
||||||
|
"Bookhoard",
|
||||||
|
"Search the Bookhoard library",
|
||||||
|
searchURL,
|
||||||
|
)
|
||||||
|
xmlString, err := desc.GenerateXMLString()
|
||||||
|
if err != nil {
|
||||||
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
|
||||||
|
}
|
||||||
|
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
|
||||||
|
return c.String(http.StatusOK, xmlString)
|
||||||
|
}
|
||||||
|
|
||||||
// Search media items
|
// Search media items
|
||||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
@@ -267,11 +419,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Add feed links
|
||||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
feed.AddLink(catalogURL, catalogMediaType, "start")
|
||||||
|
|
||||||
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query)
|
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
||||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
feed.AddLink(searchURL, catalogMediaType, "self")
|
||||||
|
|
||||||
|
// OpenSearch paging metadata (search results are a single page)
|
||||||
|
feed.SetPagination(len(allItems), len(allItems), 1)
|
||||||
|
|
||||||
// Add entries (same as catalog)
|
// Add entries (same as catalog)
|
||||||
userUUID := uuid.UUID(userID)
|
userUUID := uuid.UUID(userID)
|
||||||
@@ -296,11 +451,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
entry.SetSummary(item.Description.String)
|
entry.SetSummary(item.Description.String)
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||||
|
|
||||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
// Only offer kepub conversion for ebooks; comic archives are served
|
||||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
// as-is in their native format.
|
||||||
|
if !isComicArchive(item.FormatGroup) && device.DeviceType == "kobo" {
|
||||||
|
kepubURL := downloadURL + "&format=kepub"
|
||||||
|
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||||
|
}
|
||||||
|
|
||||||
entry.SetIdentifier(bookUUID)
|
entry.SetIdentifier(bookUUID)
|
||||||
|
|
||||||
@@ -312,7 +471,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
if col.UserID.Valid && uuid.UUID(col.UserID.Bytes) == userUUID {
|
if col.UserID.Valid && col.UserID.Bytes == userUUID {
|
||||||
entry.AddCategory(collectionScheme, col.Name)
|
entry.AddCategory(collectionScheme, col.Name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,7 +526,7 @@ func (h *OPDSHandler) DownloadBook(c *echo.Context) error {
|
|||||||
// Check if book is in visible library
|
// Check if book is in visible library
|
||||||
visible := false
|
visible := false
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
|
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
|
||||||
visible = true
|
visible = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -514,7 +673,7 @@ func (h *OPDSHandler) GetCoverImage(c *echo.Context) error {
|
|||||||
// Check if book is in visible library
|
// Check if book is in visible library
|
||||||
visible := false
|
visible := false
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
|
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
|
||||||
visible = true
|
visible = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -599,7 +758,7 @@ func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Add feed links
|
// Add feed links
|
||||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||||
|
|
||||||
@@ -655,7 +814,7 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
|||||||
// Check if book is in visible library
|
// Check if book is in visible library
|
||||||
visible := false
|
visible := false
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
if uuid.UUID(lib.ID.Bytes) == uuid.UUID(mediaItem.LibraryID.Bytes) {
|
if lib.ID.Bytes == mediaItem.LibraryID.Bytes {
|
||||||
visible = true
|
visible = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -682,14 +841,14 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
|||||||
|
|
||||||
formatList := []FormatInfo{}
|
formatList := []FormatInfo{}
|
||||||
|
|
||||||
// Add EPUB format (always available if media item exists)
|
// Add the primary/native format (always available if media item exists)
|
||||||
fileSize := int64(0)
|
fileSize := int64(0)
|
||||||
if mediaItem.FileSize.Valid {
|
if mediaItem.FileSize.Valid {
|
||||||
fileSize = mediaItem.FileSize.Int64
|
fileSize = mediaItem.FileSize.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
formatList = append(formatList, FormatInfo{
|
formatList = append(formatList, FormatInfo{
|
||||||
FormatType: "epub",
|
FormatType: formatLabelFromPath(mediaItem.FilePath),
|
||||||
FilePath: mediaItem.FilePath,
|
FilePath: mediaItem.FilePath,
|
||||||
FileSha256: func() string {
|
FileSha256: func() string {
|
||||||
if mediaItem.FileSha256.Valid {
|
if mediaItem.FileSha256.Valid {
|
||||||
@@ -791,7 +950,7 @@ func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
||||||
}
|
}
|
||||||
|
|
||||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"opds_token": map[string]interface{}{
|
"opds_token": map[string]interface{}{
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/opds"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rels collects the rel attributes of all links currently on the feed.
|
||||||
|
func rels(feed *opds.Feed) []string {
|
||||||
|
out := make([]string, 0, len(feed.Links))
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
out = append(out, l.Rel)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsRel(feed *opds.Feed, rel string) bool {
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
if l.Rel == rel {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_MiddlePage(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
// 1814 items, 50 per page => 37 pages; on page 2
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 2, 50, 1814, "tok")
|
||||||
|
|
||||||
|
assert.True(t, containsRel(feed, "self"))
|
||||||
|
assert.True(t, containsRel(feed, "start"))
|
||||||
|
assert.True(t, containsRel(feed, "first"))
|
||||||
|
assert.True(t, containsRel(feed, "last"))
|
||||||
|
assert.True(t, containsRel(feed, "previous"), "middle page must have previous")
|
||||||
|
assert.True(t, containsRel(feed, "next"), "middle page must have next")
|
||||||
|
|
||||||
|
// self must point to the current page
|
||||||
|
var selfHref string
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
if l.Rel == "self" {
|
||||||
|
selfHref = l.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Contains(t, selfHref, "page=2&per_page=50")
|
||||||
|
assert.Contains(t, selfHref, "token=tok")
|
||||||
|
|
||||||
|
// next must advance the page
|
||||||
|
var nextHref string
|
||||||
|
for _, l := range feed.Links {
|
||||||
|
if l.Rel == "next" {
|
||||||
|
nextHref = l.Href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Contains(t, nextHref, "page=3")
|
||||||
|
|
||||||
|
// OpenSearch metadata
|
||||||
|
require.NotNil(t, feed.TotalResults)
|
||||||
|
assert.Equal(t, 1814, *feed.TotalResults)
|
||||||
|
require.NotNil(t, feed.ItemsPerPage)
|
||||||
|
assert.Equal(t, 50, *feed.ItemsPerPage)
|
||||||
|
require.NotNil(t, feed.StartIndex)
|
||||||
|
assert.Equal(t, 51, *feed.StartIndex, "startIndex should be 1-based offset of first item on page 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_FirstPage_NoPrevious(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 1814, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "previous", "first page must not have previous")
|
||||||
|
assert.Contains(t, rels, "next")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_LastPage_NoNext(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 37, 50, 1814, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "next", "last page must not have next")
|
||||||
|
assert.Contains(t, rels, "previous")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_SinglePage(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 10, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "previous")
|
||||||
|
assert.NotContains(t, rels, "next")
|
||||||
|
// still emits self/start/first/last
|
||||||
|
assert.Contains(t, rels, "self")
|
||||||
|
assert.Contains(t, rels, "last")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_EmptyCatalog(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 0, "")
|
||||||
|
|
||||||
|
rels := rels(feed)
|
||||||
|
assert.NotContains(t, rels, "next")
|
||||||
|
assert.NotContains(t, rels, "previous")
|
||||||
|
assert.NotContains(t, rels, "last", "empty catalog should not advertise a last page")
|
||||||
|
require.NotNil(t, feed.TotalResults)
|
||||||
|
assert.Equal(t, 0, *feed.TotalResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCatalogPaginationLinks_TokenAppended(t *testing.T) {
|
||||||
|
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||||
|
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 100, "abc")
|
||||||
|
|
||||||
|
xml, err := feed.GenerateXMLString()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, strings.Count(xml, "token=abc") >= 3, "token should be appended to generated links")
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -39,7 +40,7 @@ type ProcessingIssueStats struct {
|
|||||||
|
|
||||||
// ListProcessingIssues returns all processing issues for a library
|
// ListProcessingIssues returns all processing issues for a library
|
||||||
func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||||
libraryID, err := uuid.Parse(c.Param("libraryId"))
|
libraryID, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||||
}
|
}
|
||||||
@@ -73,7 +74,7 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
|||||||
|
|
||||||
// GetProcessingIssueStats returns statistics about processing issues
|
// GetProcessingIssueStats returns statistics about processing issues
|
||||||
func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c *echo.Context) error {
|
func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c *echo.Context) error {
|
||||||
libraryID, err := uuid.Parse(c.Param("libraryId"))
|
libraryID, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||||
}
|
}
|
||||||
@@ -139,3 +140,8 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
|||||||
"message": "Issue deleted",
|
"message": "Issue deleted",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
|
||||||
|
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
|
||||||
|
return h.db.GetProcessingIssueStats(ctx, libraryID)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
wsync "bookhoard/internal/sync"
|
wsync "bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -43,7 +44,7 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
|
|||||||
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"media_item_id": mediaItemID,
|
"media_item_id": mediaItemID,
|
||||||
"progress": nil,
|
"progress": nil,
|
||||||
@@ -75,7 +76,7 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
|
|||||||
response["location_references"].(map[string]interface{})["chapter_progress"] = progress.ChapterProgress.Float64
|
response["location_references"].(map[string]interface{})["chapter_progress"] = progress.ChapterProgress.Float64
|
||||||
}
|
}
|
||||||
if progress.CharacterOffset.Valid {
|
if progress.CharacterOffset.Valid {
|
||||||
response["location_references"].(map[string]interface{})["character"] = int64(progress.CharacterOffset.Int64)
|
response["location_references"].(map[string]interface{})["character"] = progress.CharacterOffset.Int64
|
||||||
}
|
}
|
||||||
|
|
||||||
deviceSync := map[string]interface{}{}
|
deviceSync := map[string]interface{}{}
|
||||||
@@ -138,7 +139,7 @@ func (h *Handler) UpdateUniversalProgress(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPage := 0
|
currentPage := 0
|
||||||
totalPages := 200
|
totalPages := 0
|
||||||
if req.Location.Page != nil {
|
if req.Location.Page != nil {
|
||||||
currentPage = *req.Location.Page
|
currentPage = *req.Location.Page
|
||||||
}
|
}
|
||||||
@@ -260,6 +261,8 @@ type ProgressWithMedia struct {
|
|||||||
ProgressPercentage float64 `json:"-"`
|
ProgressPercentage float64 `json:"-"`
|
||||||
EpubCFI string `json:"-"`
|
EpubCFI string `json:"-"`
|
||||||
LastUpdated string `json:"-"`
|
LastUpdated string `json:"-"`
|
||||||
|
FormatGroup string `json:"format_group"`
|
||||||
|
EstimatedPages int `json:"estimated_pages"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllProgress retrieves all progress for a user with sync source info
|
// GetAllProgress retrieves all progress for a user with sync source info
|
||||||
@@ -303,7 +306,7 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
|
|||||||
|
|
||||||
lastUpdated := ""
|
lastUpdated := ""
|
||||||
if progress.LastReadAt.Valid {
|
if progress.LastReadAt.Valid {
|
||||||
lastUpdated = progress.LastReadAt.Time.Format("2006-01-02 15:04")
|
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||||
}
|
}
|
||||||
|
|
||||||
progressList = append(progressList, ProgressWithMedia{
|
progressList = append(progressList, ProgressWithMedia{
|
||||||
@@ -317,10 +320,12 @@ func (h *Handler) GetAllProgress(c *echo.Context) error {
|
|||||||
LastReadAt: progress.LastReadAt.Time,
|
LastReadAt: progress.LastReadAt.Time,
|
||||||
Epubcfi: epubcfi,
|
Epubcfi: epubcfi,
|
||||||
LastSyncDevice: deviceName,
|
LastSyncDevice: deviceName,
|
||||||
ProgressPercentage: progress.Percentage.Float64,
|
ProgressPercentage: progress.Percentage.Float64 * 100,
|
||||||
EpubCFI: epubcfi,
|
EpubCFI: epubcfi,
|
||||||
LastUpdated: lastUpdated,
|
LastUpdated: lastUpdated,
|
||||||
DeviceIcon: getDeviceIcon(deviceName),
|
DeviceIcon: getDeviceIcon(deviceName),
|
||||||
|
FormatGroup: mediaItem.FormatGroup,
|
||||||
|
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,17 +374,30 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro
|
|||||||
deviceName = progress.LastSyncDevice.String
|
deviceName = progress.LastSyncDevice.String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastUpdated := ""
|
||||||
|
if progress.LastReadAt.Valid {
|
||||||
|
lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
progressList = append(progressList, ProgressWithMedia{
|
progressList = append(progressList, ProgressWithMedia{
|
||||||
MediaItemID: progress.MediaItemID.Bytes,
|
MediaItemID: progress.MediaItemID.Bytes,
|
||||||
Title: mediaItem.Title,
|
Title: mediaItem.Title,
|
||||||
Author: author,
|
Author: author,
|
||||||
CoverImagePath: coverPath,
|
CoverImagePath: coverPath,
|
||||||
Percentage: progress.Percentage.Float64,
|
Percentage: progress.Percentage.Float64,
|
||||||
CurrentPage: progress.CurrentPage.Int32,
|
CurrentPage: progress.CurrentPage.Int32,
|
||||||
TotalPages: progress.TotalPages.Int32,
|
TotalPages: progress.TotalPages.Int32,
|
||||||
LastReadAt: progress.LastReadAt.Time,
|
LastReadAt: progress.LastReadAt.Time,
|
||||||
Epubcfi: epubcfi,
|
Epubcfi: epubcfi,
|
||||||
LastSyncDevice: deviceName,
|
LastSyncDevice: deviceName,
|
||||||
|
ProgressPercentage: progress.Percentage.Float64 * 100,
|
||||||
|
EpubCFI: epubcfi,
|
||||||
|
LastUpdated: lastUpdated,
|
||||||
|
DeviceIcon: getDeviceIcon(deviceName),
|
||||||
|
DeviceName: deviceName,
|
||||||
|
DeviceType: deviceName,
|
||||||
|
FormatGroup: mediaItem.FormatGroup,
|
||||||
|
EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,3 +29,124 @@ func TestGetDeviceIcon_Unknown(t *testing.T) {
|
|||||||
result = getDeviceIcon("")
|
result = getDeviceIcon("")
|
||||||
assert.Equal(t, "📚", result)
|
assert.Equal(t, "📚", result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTextPtrToPgText(t *testing.T) {
|
||||||
|
t.Run("nil returns invalid", func(t *testing.T) {
|
||||||
|
result := textPtrToPgText(nil)
|
||||||
|
assert.False(t, result.Valid)
|
||||||
|
})
|
||||||
|
t.Run("non-nil returns valid", func(t *testing.T) {
|
||||||
|
s := "epubcfi(/6/4/2:10)"
|
||||||
|
result := textPtrToPgText(&s)
|
||||||
|
assert.True(t, result.Valid)
|
||||||
|
assert.Equal(t, s, result.String)
|
||||||
|
})
|
||||||
|
t.Run("empty string returns valid", func(t *testing.T) {
|
||||||
|
s := ""
|
||||||
|
result := textPtrToPgText(&s)
|
||||||
|
assert.True(t, result.Valid)
|
||||||
|
assert.Equal(t, "", result.String)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIntPtrToPgInt4(t *testing.T) {
|
||||||
|
t.Run("nil returns invalid", func(t *testing.T) {
|
||||||
|
result := intPtrToPgInt4(nil)
|
||||||
|
assert.False(t, result.Valid)
|
||||||
|
})
|
||||||
|
t.Run("non-nil returns valid", func(t *testing.T) {
|
||||||
|
v := 5
|
||||||
|
result := intPtrToPgInt4(&v)
|
||||||
|
assert.True(t, result.Valid)
|
||||||
|
assert.Equal(t, int32(5), result.Int32)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInt64PtrToPgInt8(t *testing.T) {
|
||||||
|
t.Run("nil returns invalid", func(t *testing.T) {
|
||||||
|
result := int64PtrToPgInt8(nil)
|
||||||
|
assert.False(t, result.Valid)
|
||||||
|
})
|
||||||
|
t.Run("non-nil returns valid", func(t *testing.T) {
|
||||||
|
v := int64(10000)
|
||||||
|
result := int64PtrToPgInt8(&v)
|
||||||
|
assert.True(t, result.Valid)
|
||||||
|
assert.Equal(t, int64(10000), result.Int64)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFloat64PtrHelpers(t *testing.T) {
|
||||||
|
t.Run("pgtype float64 valid", func(t *testing.T) {
|
||||||
|
v := pgtype.Float8{Float64: 0.5, Valid: true}
|
||||||
|
result := float64PtrVal(v)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.InDelta(t, 0.5, *result, 0.001)
|
||||||
|
})
|
||||||
|
t.Run("pgtype float64 invalid", func(t *testing.T) {
|
||||||
|
v := pgtype.Float8{Valid: false}
|
||||||
|
result := float64PtrVal(v)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
})
|
||||||
|
t.Run("pgtype text valid", func(t *testing.T) {
|
||||||
|
v := pgtype.Text{String: "hello", Valid: true}
|
||||||
|
result := textPtrVal(v)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Equal(t, "hello", *result)
|
||||||
|
})
|
||||||
|
t.Run("pgtype text invalid", func(t *testing.T) {
|
||||||
|
v := pgtype.Text{Valid: false}
|
||||||
|
result := textPtrVal(v)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
})
|
||||||
|
t.Run("pgtype int4 valid", func(t *testing.T) {
|
||||||
|
v := pgtype.Int4{Int32: 42, Valid: true}
|
||||||
|
result := int32PtrVal(v)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Equal(t, 42, *result)
|
||||||
|
})
|
||||||
|
t.Run("pgtype int4 invalid", func(t *testing.T) {
|
||||||
|
v := pgtype.Int4{Valid: false}
|
||||||
|
result := int32PtrVal(v)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
})
|
||||||
|
t.Run("pgtype int8 valid", func(t *testing.T) {
|
||||||
|
v := pgtype.Int8{Int64: 10000, Valid: true}
|
||||||
|
result := int64PtrVal(v)
|
||||||
|
assert.NotNil(t, result)
|
||||||
|
assert.Equal(t, int64(10000), *result)
|
||||||
|
})
|
||||||
|
t.Run("pgtype int8 invalid", func(t *testing.T) {
|
||||||
|
v := pgtype.Int8{Valid: false}
|
||||||
|
result := int64PtrVal(v)
|
||||||
|
assert.Nil(t, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func float64PtrVal(v pgtype.Float8) *float64 {
|
||||||
|
if v.Valid {
|
||||||
|
return &v.Float64
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func textPtrVal(v pgtype.Text) *string {
|
||||||
|
if v.Valid {
|
||||||
|
return &v.String
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func int32PtrVal(v pgtype.Int4) *int {
|
||||||
|
if v.Valid {
|
||||||
|
val := int(v.Int32)
|
||||||
|
return &val
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func int64PtrVal(v pgtype.Int8) *int64 {
|
||||||
|
if v.Valid {
|
||||||
|
return &v.Int64
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -310,7 +310,8 @@ func uuidPtrToString(u pgtype.UUID) *string {
|
|||||||
if !u.Valid {
|
if !u.Valid {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return new(uuid.UUID(u.Bytes).String())
|
s := uuid.UUID(u.Bytes).String()
|
||||||
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
func textPtrToString(t pgtype.Text) *string {
|
func textPtrToString(t pgtype.Text) *string {
|
||||||
@@ -324,5 +325,6 @@ func timestamptzPtrToString(t pgtype.Timestamptz) *string {
|
|||||||
if !t.Valid {
|
if !t.Valid {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return new(t.Time.Format("2006-01-02T15:04:05Z07:00"))
|
timeFormat := t.Time.Format("2006-01-02T15:04:05Z07:00")
|
||||||
|
return &timeFormat
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -63,7 +64,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
|||||||
// Get media item
|
// Get media item
|
||||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "Media item not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch media item"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch media item"})
|
||||||
@@ -108,7 +109,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
|||||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
||||||
UserID: userData.ID,
|
UserID: userData.ID,
|
||||||
})
|
})
|
||||||
if err != nil && err != pgx.ErrNoRows {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
progress = database.ReadingProgress{}
|
progress = database.ReadingProgress{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,7 +317,7 @@ func (h *ReaderHandler) GetReadingSpeed(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
// Return zero values if no reading has occurred
|
// Return zero values if no reading has occurred
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"words_per_minute": 0,
|
"words_per_minute": 0,
|
||||||
@@ -361,7 +362,7 @@ func (h *ReaderHandler) UpdateReadingSpeed(c *echo.Context) error {
|
|||||||
// Update reading speed using service
|
// Update reading speed using service
|
||||||
err = h.readerService.CalculateReadingSpeed(
|
err = h.readerService.CalculateReadingSpeed(
|
||||||
c.Request().Context(),
|
c.Request().Context(),
|
||||||
uuid.UUID(user.ID.Bytes),
|
user.ID.Bytes,
|
||||||
parsedUUID,
|
parsedUUID,
|
||||||
req.PagesRead,
|
req.PagesRead,
|
||||||
req.TimeSpentMinutes,
|
req.TimeSpentMinutes,
|
||||||
@@ -476,7 +477,7 @@ func (h *ReaderHandler) GetSettings(c *echo.Context) error {
|
|||||||
user := c.Get("user").(database.Users)
|
user := c.Get("user").(database.Users)
|
||||||
|
|
||||||
// Use reader service to get settings
|
// Use reader service to get settings
|
||||||
settings, err := h.readerService.GetSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes))
|
settings, err := h.readerService.GetSettings(c.Request().Context(), user.ID.Bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch settings"})
|
||||||
}
|
}
|
||||||
@@ -509,13 +510,13 @@ func (h *ReaderHandler) UpdateSettings(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use reader service to update settings
|
// Use reader service to update settings
|
||||||
err := h.readerService.UpdateSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes), settings)
|
err := h.readerService.UpdateSettings(c.Request().Context(), user.ID.Bytes, settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return updated settings
|
// Return updated settings
|
||||||
updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), uuid.UUID(user.ID.Bytes))
|
updatedSettings, _ := h.readerService.GetSettings(c.Request().Context(), user.ID.Bytes)
|
||||||
return c.JSON(http.StatusOK, updatedSettings)
|
return c.JSON(http.StatusOK, updatedSettings)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +598,7 @@ func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
|
|||||||
// Fetch media item
|
// Fetch media item
|
||||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(404, map[string]string{"error": "Media item not found"})
|
return c.JSON(404, map[string]string{"error": "Media item not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
|
return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,10 +14,6 @@ import (
|
|||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
refreshTokenExpiration = 7 * 24 * time.Hour // 7 days
|
|
||||||
)
|
|
||||||
|
|
||||||
type RefreshTokenRequest struct {
|
type RefreshTokenRequest struct {
|
||||||
RefreshToken string `json:"refresh_token" validate:"required"`
|
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||||
}
|
}
|
||||||
@@ -26,7 +23,7 @@ func parseTokenUUID(tokenStr string) (pgtype.UUID, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return pgtype.UUID{}, err
|
return pgtype.UUID{}, err
|
||||||
}
|
}
|
||||||
return pgtype.UUID{Bytes: [16]byte(tokenUUID), Valid: true}, nil
|
return pgtype.UUID{Bytes: tokenUUID, Valid: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type RefreshTokenResponse struct {
|
type RefreshTokenResponse struct {
|
||||||
@@ -52,7 +49,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
|
|||||||
|
|
||||||
tokenInfo, err := h.db.GetRefreshToken(c.Request().Context(), tokenUUID)
|
tokenInfo, err := h.db.GetRefreshToken(c.Request().Context(), tokenUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid or expired refresh token"})
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid or expired refresh token"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to validate refresh token"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to validate refresh token"})
|
||||||
@@ -71,7 +68,7 @@ func (h *AuthHandler) RefreshAccessToken(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, RefreshTokenResponse{
|
return c.JSON(http.StatusOK, RefreshTokenResponse{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: SessionDurationSec,
|
ExpiresIn: int(h.refreshTokenTTL().Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +85,7 @@ func (h *AuthHandler) Logout(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
err = h.db.RevokeRefreshToken(c.Request().Context(), tokenUUID)
|
err = h.db.RevokeRefreshToken(c.Request().Context(), tokenUUID)
|
||||||
if err != nil && err != pgx.ErrNoRows {
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke refresh token"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke refresh token"})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,10 +97,10 @@ func (h *AuthHandler) CreateRefreshToken(userID uuid.UUID) (string, string, erro
|
|||||||
tokenUUID := uuid.New()
|
tokenUUID := uuid.New()
|
||||||
refreshToken := tokenUUID.String()
|
refreshToken := tokenUUID.String()
|
||||||
|
|
||||||
expiresAt := time.Now().Add(refreshTokenExpiration)
|
expiresAt := time.Now().Add(h.refreshTokenTTL())
|
||||||
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
|
_, err := h.db.CreateRefreshToken(context.Background(), database.CreateRefreshTokenParams{
|
||||||
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
Token: pgtype.UUID{Bytes: [16]byte(tokenUUID), Valid: true},
|
Token: pgtype.UUID{Bytes: tokenUUID, Valid: true},
|
||||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch library folders from database
|
// Fetch library folders from database
|
||||||
libraryFolders, err := h.db.GetLibraryFolders(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryUUID), Valid: true})
|
libraryFolders, err := h.db.GetLibraryFolders(c.Request().Context(), pgtype.UUID{Bytes: libraryUUID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "library not found or has no folders"})
|
||||||
}
|
}
|
||||||
@@ -128,8 +128,8 @@ func (h *Handler) StartScanner(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the folder paths
|
// Set the folder paths (watch=true: this long-lived scanner reads events)
|
||||||
if err := h.scanner.SetFolders(req.FolderPaths); err != nil {
|
if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil {
|
||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype
|
|||||||
}
|
}
|
||||||
|
|
||||||
scanner := services.NewMediaScanner(h.db)
|
scanner := services.NewMediaScanner(h.db)
|
||||||
if err := scanner.SetFolders(folderPaths); err != nil {
|
if err := scanner.SetFolders(folderPaths, true); err != nil {
|
||||||
return fmt.Errorf("failed to set scanner folders: %v", err)
|
return fmt.Errorf("failed to set scanner folders: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +261,7 @@ func (h *Handler) StartWatchMode(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.StartWatchModeForLibrary(c.Request().Context(), pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}, pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}); err != nil {
|
if err := h.StartWatchModeForLibrary(c.Request().Context(), pgtype.UUID{Bytes: libraryID, Valid: true}, pgtype.UUID{Bytes: userUUID, Valid: true}); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +289,7 @@ func (h *Handler) StopWatchMode(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library id"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.StopWatchModeForLibrary(pgtype.UUID{Bytes: [16]byte(libraryID), Valid: true}); err != nil {
|
if err := h.StopWatchModeForLibrary(pgtype.UUID{Bytes: libraryID, Valid: true}); err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/services"
|
||||||
|
"bookhoard/internal/utils"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SeriesHandler struct {
|
||||||
|
seriesService *services.SeriesService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSeriesHandler(db *database.Queries) *SeriesHandler {
|
||||||
|
return &SeriesHandler{
|
||||||
|
seriesService: services.NewSeriesService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||||
|
libraryID := c.QueryParam("library_id")
|
||||||
|
var libUUID pgtype.UUID
|
||||||
|
if libraryID != "" {
|
||||||
|
parsed, err := uuid.Parse(libraryID)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||||
|
}
|
||||||
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 20
|
||||||
|
if l := c.QueryParam("limit"); l != "" {
|
||||||
|
if v, err := strconv.Atoi(l); err == nil && v > 0 {
|
||||||
|
limit = v
|
||||||
|
if limit > 100 {
|
||||||
|
limit = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset := 0
|
||||||
|
if o := c.QueryParam("offset"); o != "" {
|
||||||
|
if v, err := strconv.Atoi(o); err == nil && v >= 0 {
|
||||||
|
offset = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SeriesResponse struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BookCount int64 `json:"book_count"`
|
||||||
|
TotalInSeries int `json:"total_in_series"`
|
||||||
|
CoverPaths []string `json:"cover_paths"`
|
||||||
|
LastEntryAt string `json:"last_entry_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
response := make([]SeriesResponse, 0, len(seriesList))
|
||||||
|
for _, s := range seriesList {
|
||||||
|
response = append(response, SeriesResponse{
|
||||||
|
Name: s.Name,
|
||||||
|
BookCount: s.BookCount,
|
||||||
|
TotalInSeries: s.TotalInSeries,
|
||||||
|
CoverPaths: s.CoverPaths,
|
||||||
|
LastEntryAt: s.LastEntryAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"series": response,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||||
|
seriesName := c.QueryParam("name")
|
||||||
|
if seriesName == "" {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "name required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
books, err := h.seriesService.GetSeriesBooks(c.Request().Context(), seriesName)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series books"})
|
||||||
|
}
|
||||||
|
|
||||||
|
bookCards := make([]BookInfo, 0, len(books))
|
||||||
|
for _, item := range books {
|
||||||
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
||||||
|
bookCards = append(bookCards, BookInfo{
|
||||||
|
MediaItemID: itemUUID.String(),
|
||||||
|
Title: item.Title,
|
||||||
|
Author: textToString(item.Author),
|
||||||
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
|
"name": seriesName,
|
||||||
|
"books": bookCards,
|
||||||
|
"total": len(bookCards),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||||
|
svc := services.NewSeriesService(db)
|
||||||
|
return svc.GetSeriesPage(ctx, libraryID, limit, offset)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSeriesHandler_NilDB(t *testing.T) {
|
||||||
|
handler := NewSeriesHandler(nil)
|
||||||
|
assert.NotNil(t, handler, "Handler should not be nil even with nil DB")
|
||||||
|
assert.NotNil(t, handler.seriesService, "Internal service should be initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeriesHandler_TextToStringConversion(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input pgtype.Text
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid author text",
|
||||||
|
input: pgtype.Text{String: "Brandon Sanderson", Valid: true},
|
||||||
|
expected: "Brandon Sanderson",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty valid text",
|
||||||
|
input: pgtype.Text{String: "", Valid: true},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "null text returns empty",
|
||||||
|
input: pgtype.Text{String: "ignored", Valid: false},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := textToString(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+146
-25
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"bookhoard/internal/config"
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"bookhoard/internal/setupstatus"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -14,14 +15,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type SidecarHandler struct {
|
type SidecarHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
func NewSidecarHandler(db *database.Queries, cfg *config.Config) *SidecarHandler {
|
||||||
return &SidecarHandler{db: db, cfg: cfg}
|
return &SidecarHandler{db: db, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry so the timezone write path
|
||||||
|
// keeps the cache consistent.
|
||||||
|
func (h *SidecarHandler) SetSettings(s *database.SettingsRegistry) { h.settings = s }
|
||||||
|
|
||||||
type SidecarConfig struct {
|
type SidecarConfig struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
|
Bookhoard SidecarBookhoardConfig `json:"bookhoard"`
|
||||||
@@ -81,15 +87,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
|
||||||
// Get base URL and compute paths
|
// Get base URL and compute paths (with request-derived fallback)
|
||||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||||
if baseURL.Value == "" {
|
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||||
baseURL.Value = h.cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate URLs
|
// Generate URLs
|
||||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||||
|
|
||||||
// Get user's visible libraries with media items
|
// Get user's visible libraries with media items
|
||||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||||
@@ -171,13 +175,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build sidecar config
|
// Build sidecar config
|
||||||
config := SidecarConfig{
|
sidecarConfig := SidecarConfig{
|
||||||
Version: "1.0",
|
Version: "1.0",
|
||||||
Bookhoard: SidecarBookhoardConfig{
|
Bookhoard: SidecarBookhoardConfig{
|
||||||
OPDSCatalog: opdsCatalogURL,
|
OPDSCatalog: opdsCatalogURL,
|
||||||
SyncAPI: syncAPIURL,
|
SyncAPI: syncAPIURL,
|
||||||
OPDSBaseURL: baseURL.Value + "/opds",
|
OPDSBaseURL: baseURL + "/opds",
|
||||||
APIBaseURL: baseURL.Value + "/api",
|
APIBaseURL: baseURL + "/api",
|
||||||
DeviceID: deviceID.String(),
|
DeviceID: deviceID.String(),
|
||||||
DeviceToken: device.AuthToken,
|
DeviceToken: device.AuthToken,
|
||||||
},
|
},
|
||||||
@@ -188,7 +192,7 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error {
|
|||||||
LastUpdated: time.Now().Format(time.RFC3339),
|
LastUpdated: time.Now().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, config)
|
return c.JSON(http.StatusOK, sidecarConfig)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DownloadSidecarConfig generates a .bookhoard.json file for device setup
|
// DownloadSidecarConfig generates a .bookhoard.json file for device setup
|
||||||
@@ -219,15 +223,13 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
|||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||||
|
|
||||||
// Get base URL and compute paths
|
// Get base URL and compute paths (with request-derived fallback)
|
||||||
baseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url")
|
||||||
if baseURL.Value == "" {
|
baseURL := deriveBaseURL(c, dbBaseURL.Value)
|
||||||
baseURL.Value = h.cfg.BaseURL
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate URLs
|
// Generate URLs
|
||||||
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String())
|
opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String())
|
||||||
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value)
|
syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL)
|
||||||
|
|
||||||
// Get user's visible libraries with media items
|
// Get user's visible libraries with media items
|
||||||
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID)
|
||||||
@@ -309,8 +311,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error {
|
|||||||
Bookhoard: SidecarBookhoardConfig{
|
Bookhoard: SidecarBookhoardConfig{
|
||||||
OPDSCatalog: opdsCatalogURL,
|
OPDSCatalog: opdsCatalogURL,
|
||||||
SyncAPI: syncAPIURL,
|
SyncAPI: syncAPIURL,
|
||||||
OPDSBaseURL: baseURL.Value + "/opds",
|
OPDSBaseURL: baseURL + "/opds",
|
||||||
APIBaseURL: baseURL.Value + "/api",
|
APIBaseURL: baseURL + "/api",
|
||||||
DeviceID: deviceID.String(),
|
DeviceID: deviceID.String(),
|
||||||
DeviceToken: device.AuthToken,
|
DeviceToken: device.AuthToken,
|
||||||
},
|
},
|
||||||
@@ -352,8 +354,8 @@ func (h *SidecarHandler) GetSystemConfiguration(c *echo.Context) error {
|
|||||||
|
|
||||||
// Build config map
|
// Build config map
|
||||||
result := make(map[string]string)
|
result := make(map[string]string)
|
||||||
for _, config := range configs {
|
for _, systemConfig := range configs {
|
||||||
result[config.Key] = config.Value
|
result[systemConfig.Key] = systemConfig.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, result)
|
return c.JSON(http.StatusOK, result)
|
||||||
@@ -388,6 +390,26 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
|
|
||||||
// Update each config value
|
// Update each config value
|
||||||
for key, value := range req {
|
for key, value := range req {
|
||||||
|
if key == "default_timezone" {
|
||||||
|
if _, err := time.LoadLocation(value); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "invalid timezone",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
err := h.db.UpdateSystemSetting(ctx, database.UpdateSystemSettingParams{
|
||||||
|
SettingKey: "default_timezone",
|
||||||
|
SettingValue: value,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": "failed to update default timezone",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if h.settings != nil {
|
||||||
|
h.settings.Reload(ctx)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: value,
|
Value: value,
|
||||||
@@ -400,6 +422,29 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if newBaseURL, ok := req["base_url"]; ok && newBaseURL != "" {
|
||||||
|
derivedConfigs := map[string]string{
|
||||||
|
"opds_base_url": newBaseURL + "/opds",
|
||||||
|
"api_base_url": newBaseURL + "/api",
|
||||||
|
}
|
||||||
|
for derivedKey, derivedValue := range derivedConfigs {
|
||||||
|
_, err := h.db.SetSystemConfig(ctx, database.SetSystemConfigParams{
|
||||||
|
Key: derivedKey,
|
||||||
|
Value: derivedValue,
|
||||||
|
UpdatedBy: pgUserID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
||||||
|
"error": fmt.Sprintf("failed to update derived config key: %s", derivedKey),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate setup status cache so the middleware picks up the new
|
||||||
|
// base_url immediately (setup is not complete until base_url is set).
|
||||||
|
setupstatus.Invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
// Check for HTMX request
|
// Check for HTMX request
|
||||||
if c.Request().Header.Get("HX-Request") == "true" {
|
if c.Request().Header.Get("HX-Request") == "true" {
|
||||||
// Fetch updated base_url for template
|
// Fetch updated base_url for template
|
||||||
@@ -408,6 +453,12 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to fetch updated configuration</div>`)
|
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to fetch updated configuration</div>`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defaultTimezone := "UTC"
|
||||||
|
tz, err := h.db.GetSystemTimezone(ctx)
|
||||||
|
if err == nil && tz != "" {
|
||||||
|
defaultTimezone = tz
|
||||||
|
}
|
||||||
|
|
||||||
// Render success message with updated form
|
// Render success message with updated form
|
||||||
return c.HTML(http.StatusOK, fmt.Sprintf(`
|
return c.HTML(http.StatusOK, fmt.Sprintf(`
|
||||||
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
|
<div class="mb-4 p-4 rounded-lg" style="background-color: var(--bg-secondary); border: 1px solid var(--accent);">
|
||||||
@@ -437,6 +488,44 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
|
<h3 class="text-xl font-semibold mb-6" style="color: var(--text-primary)">System Defaults</h3>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-primary)">Default Timezone</label>
|
||||||
|
<select name="default_timezone" id="default_timezone" class="w-full px-4 py-2 rounded-lg border" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
|
||||||
|
<option value="UTC"%s>UTC (UTC+0)</option>
|
||||||
|
<option value="Pacific/Honolulu"%s>Hawaii (UTC-10)</option>
|
||||||
|
<option value="America/Anchorage"%s>Alaska (UTC-9/-8)</option>
|
||||||
|
<option value="America/Los_Angeles"%s>Pacific (UTC-8/-7)</option>
|
||||||
|
<option value="America/Denver"%s>Mountain (UTC-7/-6)</option>
|
||||||
|
<option value="America/Phoenix"%s>Mountain - no DST (UTC-7)</option>
|
||||||
|
<option value="America/Chicago"%s>Central (UTC-6/-5)</option>
|
||||||
|
<option value="America/New_York"%s>Eastern (UTC-5/-4)</option>
|
||||||
|
<option value="America/Sao_Paulo"%s>Brasilia (UTC-3/-2)</option>
|
||||||
|
<option value="Europe/London"%s>British (UTC+0/+1)</option>
|
||||||
|
<option value="Europe/Paris"%s>Central European (UTC+1/+2)</option>
|
||||||
|
<option value="Europe/Helsinki"%s>Eastern European (UTC+2/+3)</option>
|
||||||
|
<option value="Europe/Moscow"%s>Moscow (UTC+3)</option>
|
||||||
|
<option value="Asia/Tehran"%s>Iran (UTC+3:30)</option>
|
||||||
|
<option value="Asia/Dubai"%s>Gulf (UTC+4)</option>
|
||||||
|
<option value="Asia/Karachi"%s>Pakistan (UTC+5)</option>
|
||||||
|
<option value="Asia/Kolkata"%s>India (UTC+5:30)</option>
|
||||||
|
<option value="Asia/Dhaka"%s>Bangladesh (UTC+6)</option>
|
||||||
|
<option value="Asia/Bangkok"%s>Indochina (UTC+7)</option>
|
||||||
|
<option value="Asia/Shanghai"%s>China (UTC+8)</option>
|
||||||
|
<option value="Asia/Tokyo"%s>Japan/Korea (UTC+9)</option>
|
||||||
|
<option value="Australia/Darwin"%s>Australian Central (UTC+9:30)</option>
|
||||||
|
<option value="Australia/Sydney"%s>Australian Eastern (UTC+10/+11)</option>
|
||||||
|
<option value="Pacific/Auckland"%s>New Zealand (UTC+12/+13)</option>
|
||||||
|
</select>
|
||||||
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Default timezone for users who haven't set their own.</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-6 flex justify-end">
|
||||||
|
<button type="submit" class="btn-primary px-6 py-2 rounded-lg font-medium">
|
||||||
|
Save Settings
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
<div class="mt-8 card p-6 rounded-lg border" style="background-color: var(--bg-secondary); border-color: var(--border);">
|
||||||
@@ -447,7 +536,32 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error {
|
|||||||
<p><strong>Device Sync:</strong> %s/api/sync</p>
|
<p><strong>Device Sync:</strong> %s/api/sync</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`, baseURL.Value, baseURL.Value, baseURL.Value, baseURL.Value))
|
`, baseURL.Value,
|
||||||
|
selectedAttr(defaultTimezone, "UTC"),
|
||||||
|
selectedAttr(defaultTimezone, "Pacific/Honolulu"),
|
||||||
|
selectedAttr(defaultTimezone, "America/Anchorage"),
|
||||||
|
selectedAttr(defaultTimezone, "America/Los_Angeles"),
|
||||||
|
selectedAttr(defaultTimezone, "America/Denver"),
|
||||||
|
selectedAttr(defaultTimezone, "America/Phoenix"),
|
||||||
|
selectedAttr(defaultTimezone, "America/Chicago"),
|
||||||
|
selectedAttr(defaultTimezone, "America/New_York"),
|
||||||
|
selectedAttr(defaultTimezone, "America/Sao_Paulo"),
|
||||||
|
selectedAttr(defaultTimezone, "Europe/London"),
|
||||||
|
selectedAttr(defaultTimezone, "Europe/Paris"),
|
||||||
|
selectedAttr(defaultTimezone, "Europe/Helsinki"),
|
||||||
|
selectedAttr(defaultTimezone, "Europe/Moscow"),
|
||||||
|
selectedAttr(defaultTimezone, "Asia/Tehran"),
|
||||||
|
selectedAttr(defaultTimezone, "Asia/Dubai"),
|
||||||
|
selectedAttr(defaultTimezone, "Asia/Karachi"),
|
||||||
|
selectedAttr(defaultTimezone, "Asia/Kolkata"),
|
||||||
|
selectedAttr(defaultTimezone, "Asia/Dhaka"),
|
||||||
|
selectedAttr(defaultTimezone, "Asia/Bangkok"),
|
||||||
|
selectedAttr(defaultTimezone, "Asia/Shanghai"),
|
||||||
|
selectedAttr(defaultTimezone, "Asia/Tokyo"),
|
||||||
|
selectedAttr(defaultTimezone, "Australia/Darwin"),
|
||||||
|
selectedAttr(defaultTimezone, "Australia/Sydney"),
|
||||||
|
selectedAttr(defaultTimezone, "Pacific/Auckland"),
|
||||||
|
baseURL.Value, baseURL.Value, baseURL.Value))
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{
|
return c.JSON(http.StatusOK, map[string]string{
|
||||||
@@ -477,3 +591,10 @@ func sanitizeAll(s string, old string, new string) string {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectedAttr(current, value string) string {
|
||||||
|
if current == value {
|
||||||
|
return " selected"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,15 +2,21 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SystemSettingsHandler struct {
|
type SystemSettingsHandler struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
||||||
@@ -19,6 +25,154 @@ func NewSystemSettingsHandler(db *database.Queries) *SystemSettingsHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry. Required for the unified
|
||||||
|
// /api/system/settings endpoints and for cache invalidation after writes.
|
||||||
|
func (h *SystemSettingsHandler) SetSettings(s *database.SettingsRegistry) {
|
||||||
|
h.settings = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload refreshes the in-memory cache after a write.
|
||||||
|
func (h *SystemSettingsHandler) reload(c *echo.Context) {
|
||||||
|
if h.settings != nil {
|
||||||
|
h.settings.Reload(c.Request().Context())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Unified /api/system/settings endpoints ----
|
||||||
|
|
||||||
|
// GetSettings handles GET /api/system/settings.
|
||||||
|
func (h *SystemSettingsHandler) GetSettings(c *echo.Context) error {
|
||||||
|
if h.settings == nil {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, h.settings.All())
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettingRequest is the body for PUT /api/system/settings.
|
||||||
|
type UpdateSettingRequest struct {
|
||||||
|
Key string `json:"key" form:"key"`
|
||||||
|
Value string `json:"value" form:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSettingResponse mirrors a settings entry plus a reload hint.
|
||||||
|
type UpdateSettingResponse struct {
|
||||||
|
database.SettingEntry
|
||||||
|
ReloadRequired bool `json:"reload_required"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateSetting handles PUT /api/system/settings.
|
||||||
|
func (h *SystemSettingsHandler) UpdateSetting(c *echo.Context) error {
|
||||||
|
if h.settings == nil {
|
||||||
|
return c.JSON(http.StatusServiceUnavailable, map[string]string{"error": "settings registry not initialized"})
|
||||||
|
}
|
||||||
|
var req UpdateSettingRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
resp, err := h.ApplySetting(c.Request().Context(), req.Key, req.Value)
|
||||||
|
if err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplySetting validates, persists, and reloads a single setting. Shared by the
|
||||||
|
// JSON API and the HTMX admin endpoint.
|
||||||
|
func (h *SystemSettingsHandler) ApplySetting(ctx context.Context, key, value string) (UpdateSettingResponse, error) {
|
||||||
|
if h.settings == nil {
|
||||||
|
return UpdateSettingResponse{}, fmt.Errorf("settings registry not initialized")
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return UpdateSettingResponse{}, fmt.Errorf("key is required")
|
||||||
|
}
|
||||||
|
def, ok := database.LookupDefault(key)
|
||||||
|
if !ok {
|
||||||
|
return UpdateSettingResponse{}, fmt.Errorf("unknown setting key: %s", key)
|
||||||
|
}
|
||||||
|
if err := validateSettingValue(def, value); err != nil {
|
||||||
|
return UpdateSettingResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := def.Description
|
||||||
|
rType := pgtype.Text{}
|
||||||
|
if def.Type != "" {
|
||||||
|
rType = pgtype.Text{String: def.Type, Valid: true}
|
||||||
|
}
|
||||||
|
var minP, maxP pgtype.Text
|
||||||
|
if def.Min != "" {
|
||||||
|
minP = pgtype.Text{String: def.Min, Valid: true}
|
||||||
|
}
|
||||||
|
if def.Max != "" {
|
||||||
|
maxP = pgtype.Text{String: def.Max, Valid: true}
|
||||||
|
}
|
||||||
|
if _, err := h.db.UpsertSystemSetting(ctx, database.UpsertSystemSettingParams{
|
||||||
|
SettingKey: key,
|
||||||
|
SettingValue: value,
|
||||||
|
Description: pgtype.Text{String: desc, Valid: desc != ""},
|
||||||
|
SettingType: rType,
|
||||||
|
MinValue: minP,
|
||||||
|
MaxValue: maxP,
|
||||||
|
RequiresRestart: pgtype.Bool{Bool: def.RequiresRestart, Valid: true},
|
||||||
|
Category: pgtype.Text{String: def.Category, Valid: def.Category != ""},
|
||||||
|
}); err != nil {
|
||||||
|
return UpdateSettingResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
h.settings.Reload(ctx)
|
||||||
|
|
||||||
|
resp := UpdateSettingResponse{ReloadRequired: def.RequiresRestart}
|
||||||
|
for _, e := range h.settings.All() {
|
||||||
|
if e.Key == key {
|
||||||
|
resp.SettingEntry = e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.RequiresRestart {
|
||||||
|
resp.Message = "Saved. Restart the server for this change to take full effect."
|
||||||
|
} else {
|
||||||
|
resp.Message = "Saved."
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateSettingValue checks a candidate value against the setting's type and bounds.
|
||||||
|
func validateSettingValue(def database.SettingDefault, value string) error {
|
||||||
|
switch def.Type {
|
||||||
|
case database.SettingTypeInt:
|
||||||
|
n, err := strconv.Atoi(value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("value must be an integer")
|
||||||
|
}
|
||||||
|
if def.Min != "" {
|
||||||
|
if mn, err := strconv.Atoi(def.Min); err == nil && n < mn {
|
||||||
|
return fmt.Errorf("value must be >= %s", def.Min)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if def.Max != "" {
|
||||||
|
if mx, err := strconv.Atoi(def.Max); err == nil && n > mx {
|
||||||
|
return fmt.Errorf("value must be <= %s", def.Max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case database.SettingTypeBool:
|
||||||
|
if _, err := strconv.ParseBool(value); err != nil {
|
||||||
|
return fmt.Errorf("value must be true or false")
|
||||||
|
}
|
||||||
|
case database.SettingTypeString:
|
||||||
|
if value == "" {
|
||||||
|
return fmt.Errorf("value must not be empty")
|
||||||
|
}
|
||||||
|
if def.Key == "default_timezone" {
|
||||||
|
if _, err := time.LoadLocation(value); err != nil {
|
||||||
|
return fmt.Errorf("invalid timezone: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Legacy scan-settings endpoints (retained for backward compatibility) ----
|
||||||
|
|
||||||
type UpdateScanSettingsRequest struct {
|
type UpdateScanSettingsRequest struct {
|
||||||
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
|
ScanPollIntervalSeconds int32 `json:"scan_poll_interval_seconds" validate:"required,min=1,max=3600"`
|
||||||
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
AutoScanEnabled bool `json:"auto_scan_enabled"`
|
||||||
@@ -30,6 +184,29 @@ type ScanSettingsResponse struct {
|
|||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateTimezoneSettingsRequest struct {
|
||||||
|
DefaultTimezone string `json:"default_timezone" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
||||||
|
var req UpdateTimezoneSettingsRequest
|
||||||
|
if err := c.Bind(&req); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||||
|
}
|
||||||
|
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
|
||||||
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
|
||||||
|
}
|
||||||
|
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
|
||||||
|
SettingKey: "default_timezone",
|
||||||
|
SettingValue: req.DefaultTimezone,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
h.reload(c)
|
||||||
|
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
||||||
var req UpdateScanSettingsRequest
|
var req UpdateScanSettingsRequest
|
||||||
if err := c.Bind(&req); err != nil {
|
if err := c.Bind(&req); err != nil {
|
||||||
@@ -47,7 +224,7 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
|||||||
SettingValue: scanFrequencyValue,
|
SettingValue: scanFrequencyValue,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -58,12 +235,14 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
|||||||
SettingValue: autoScanValue,
|
SettingValue: autoScanValue,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "system setting not found"})
|
||||||
}
|
}
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h.reload(c)
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||||
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
|
ScanPollIntervalSeconds: req.ScanPollIntervalSeconds,
|
||||||
AutoScanEnabled: req.AutoScanEnabled,
|
AutoScanEnabled: req.AutoScanEnabled,
|
||||||
@@ -72,11 +251,20 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
||||||
|
// Prefer the registry (single source of truth after Load).
|
||||||
|
if h.settings != nil {
|
||||||
|
interval := int32(h.settings.ScanPollInterval().Seconds())
|
||||||
|
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||||
|
ScanPollIntervalSeconds: interval,
|
||||||
|
AutoScanEnabled: h.settings.AutoScanEnabled(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
|
scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||||
ScanPollIntervalSeconds: 60,
|
ScanPollIntervalSeconds: 1800,
|
||||||
AutoScanEnabled: true,
|
AutoScanEnabled: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -85,9 +273,9 @@ func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error {
|
|||||||
|
|
||||||
autoScanSetting, err := h.db.GetSystemSetting(c.Request().Context(), "auto_scan_enabled")
|
autoScanSetting, err := h.db.GetSystemSetting(c.Request().Context(), "auto_scan_enabled")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == pgx.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
return c.JSON(http.StatusOK, ScanSettingsResponse{
|
||||||
ScanPollIntervalSeconds: 60,
|
ScanPollIntervalSeconds: 1800,
|
||||||
AutoScanEnabled: true,
|
AutoScanEnabled: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/labstack/echo/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deriveBaseURL returns the base URL to use for constructing self-referential
|
||||||
|
// links (OPDS feeds, sidecar config, etc.). It prefers the database-configured
|
||||||
|
// base_url when available, and falls back to deriving the URL from the incoming
|
||||||
|
// HTTP request (Host header + scheme), which is always reachable by the client.
|
||||||
|
//
|
||||||
|
// Proxy header support: X-Forwarded-Proto and X-Forwarded-Host are respected so
|
||||||
|
// that deployments behind TLS-terminating reverse proxies advertise the correct
|
||||||
|
// external URL.
|
||||||
|
func deriveBaseURL(c *echo.Context, dbBaseURL string) string {
|
||||||
|
if dbBaseURL != "" {
|
||||||
|
return strings.TrimRight(dbBaseURL, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "http"
|
||||||
|
if c.Request().TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" {
|
||||||
|
scheme = proto
|
||||||
|
}
|
||||||
|
|
||||||
|
host := c.Request().Host
|
||||||
|
if forwarded := c.Request().Header.Get("X-Forwarded-Host"); forwarded != "" {
|
||||||
|
host = forwarded
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheme + "://" + host
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ type DeviceContext struct {
|
|||||||
type DeviceAuthMiddleware struct {
|
type DeviceAuthMiddleware struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
rateLimiter *DeviceRateLimiter
|
rateLimiter *DeviceRateLimiter
|
||||||
|
settings *database.SettingsRegistry
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
||||||
@@ -34,6 +35,41 @@ func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry so device rate limits are
|
||||||
|
// read live on each authenticated request.
|
||||||
|
func (m *DeviceAuthMiddleware) SetSettings(s *database.SettingsRegistry) { m.settings = s }
|
||||||
|
|
||||||
|
// rateLimitConfig returns the active device rate limits from the registry, or
|
||||||
|
// the historical defaults when no registry is wired.
|
||||||
|
func (m *DeviceAuthMiddleware) rateLimitConfig() DeviceRateLimitConfig {
|
||||||
|
if m.settings != nil {
|
||||||
|
dl := m.settings.DeviceRateLimits()
|
||||||
|
return DeviceRateLimitConfig{
|
||||||
|
SyncRequestsPerMinute: dl.Sync,
|
||||||
|
ProgressUpdatesPerMinute: dl.Progress,
|
||||||
|
MetadataRequestsPerMinute: dl.Metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DeviceRateLimitConfig{
|
||||||
|
SyncRequestsPerMinute: DefaultSyncRequestsPerMinute,
|
||||||
|
ProgressUpdatesPerMinute: DefaultProgressUpdatesPerMinute,
|
||||||
|
MetadataRequestsPerMinute: DefaultMetadataRequestsPerMinute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimitForRequestType returns the configured per-minute limit for a given
|
||||||
|
// request type, for use in X-RateLimit-* headers.
|
||||||
|
func (m *DeviceAuthMiddleware) rateLimitForRequestType(requestType string, config DeviceRateLimitConfig) int {
|
||||||
|
switch requestType {
|
||||||
|
case "progress":
|
||||||
|
return config.ProgressUpdatesPerMinute
|
||||||
|
case "metadata":
|
||||||
|
return config.MetadataRequestsPerMinute
|
||||||
|
default: // "sync" and any unknown type
|
||||||
|
return config.SyncRequestsPerMinute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
|
func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
return func(c *echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
var device database.Devices
|
var device database.Devices
|
||||||
@@ -115,15 +151,12 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
|||||||
deviceUUID := uuid.UUID(device.ID.Bytes)
|
deviceUUID := uuid.UUID(device.ID.Bytes)
|
||||||
deviceID := deviceUUID.String()
|
deviceID := deviceUUID.String()
|
||||||
|
|
||||||
config := DeviceRateLimitConfig{
|
config := m.rateLimitConfig()
|
||||||
SyncRequestsPerMinute: 60,
|
limitForType := m.rateLimitForRequestType(requestType, config)
|
||||||
ProgressUpdatesPerMinute: 120,
|
|
||||||
MetadataRequestsPerMinute: 30,
|
|
||||||
}
|
|
||||||
|
|
||||||
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
|
if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) {
|
||||||
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
||||||
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
|
||||||
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||||
c.Response().Header().Set("X-RateLimit-Reset", "60")
|
c.Response().Header().Set("X-RateLimit-Reset", "60")
|
||||||
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
return c.JSON(http.StatusTooManyRequests, map[string]string{
|
||||||
@@ -134,7 +167,7 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF
|
|||||||
}
|
}
|
||||||
|
|
||||||
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config)
|
||||||
c.Response().Header().Set("X-RateLimit-Limit", "60")
|
c.Response().Header().Set("X-RateLimit-Limit", strconv.Itoa(limitForType))
|
||||||
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
c.Response().Header().Set("X-RateLimit-Remaining", strconv.Itoa(remaining))
|
||||||
|
|
||||||
ctx := DeviceContext{
|
ctx := DeviceContext{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ func WrapHandler(fn func(*echo.Context) error) echo.HandlerFunc {
|
|||||||
return func(c *echo.Context) error {
|
return func(c *echo.Context) error {
|
||||||
err := fn(c)
|
err := fn(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if httpErr, ok := err.(*HTTPError); ok {
|
if httpErr, ok := errors.AsType[*HTTPError](err); ok {
|
||||||
return RespondWithHTTPError(c, httpErr)
|
return RespondWithHTTPError(c, httpErr)
|
||||||
}
|
}
|
||||||
return RespondWithError(c, http.StatusInternalServerError, "Internal server error", err)
|
return RespondWithError(c, http.StatusInternalServerError, "Internal server error", err)
|
||||||
|
|||||||
@@ -1,96 +1,146 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bookhoard/internal/database"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PasswordValidator validates password complexity requirements
|
// specialCharRegex matches the historical "special character" set used by the
|
||||||
type PasswordValidator struct{}
|
// password complexity rules.
|
||||||
|
const specialCharRegex = `[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`
|
||||||
|
|
||||||
// Validate checks if a password meets complexity requirements:
|
// PasswordValidator validates password complexity against the configured rules.
|
||||||
// - Minimum 8 characters
|
// When a database.SettingsRegistry is wired via SetSettings, rules are read live and the
|
||||||
// - At least one uppercase letter
|
// regex set is recompiled under a mutex on each validation. Without a registry
|
||||||
// - At least one lowercase letter
|
// the historical hardcoded defaults (8+ chars, upper/lower/number/special) apply.
|
||||||
// - At least one number
|
type PasswordValidator struct {
|
||||||
// - At least one special character
|
settings *database.SettingsRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetSettings wires the tunable settings registry.
|
||||||
|
func (v *PasswordValidator) SetSettings(s *database.SettingsRegistry) { v.settings = s }
|
||||||
|
|
||||||
|
// compileSpecialRegex isolates the regexp compile (which is safe to call
|
||||||
|
// concurrently, but we keep it behind a cached var for the no-registry path).
|
||||||
|
var (
|
||||||
|
specialOnce sync.Once
|
||||||
|
specialRe *regexp.Regexp
|
||||||
|
)
|
||||||
|
|
||||||
|
func specialRegex() *regexp.Regexp {
|
||||||
|
specialOnce.Do(func() {
|
||||||
|
specialRe = regexp.MustCompile(specialCharRegex)
|
||||||
|
})
|
||||||
|
return specialRe
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *PasswordValidator) rules() database.PasswordRules {
|
||||||
|
if v.settings != nil {
|
||||||
|
return v.settings.PasswordRules()
|
||||||
|
}
|
||||||
|
return database.PasswordRules{MinLength: 8, Upper: true, Lower: true, Number: true, Special: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks if a password meets the configured complexity requirements.
|
||||||
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
|
func (v *PasswordValidator) Validate(fl validator.FieldLevel) bool {
|
||||||
password := fl.Field().String()
|
return v.CheckPassword(fl.Field().String())
|
||||||
|
}
|
||||||
|
|
||||||
// Check minimum length
|
// CheckPassword applies the active rules to a single password.
|
||||||
if len(password) < 8 {
|
func (v *PasswordValidator) CheckPassword(password string) bool {
|
||||||
|
r := v.rules()
|
||||||
|
if len(password) < r.MinLength {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||||
// Check for uppercase
|
|
||||||
hasUpper := regexp.MustCompile(`[A-Z]`).MatchString(password)
|
|
||||||
if !hasUpper {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||||
// Check for lowercase
|
|
||||||
hasLower := regexp.MustCompile(`[a-z]`).MatchString(password)
|
|
||||||
if !hasLower {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||||
// Check for number
|
|
||||||
hasNumber := regexp.MustCompile(`[0-9]`).MatchString(password)
|
|
||||||
if !hasNumber {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if r.Special && !specialRegex().MatchString(password) {
|
||||||
// Check for special character
|
|
||||||
hasSpecial := regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password)
|
|
||||||
if !hasSpecial {
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPasswordRequirements returns a human-readable list of password requirements
|
// GetPasswordRequirements returns a human-readable list of the active password
|
||||||
|
// requirements, driven by the configured rules when a registry is wired.
|
||||||
func GetPasswordRequirements() []string {
|
func GetPasswordRequirements() []string {
|
||||||
return []string{
|
return defaultPasswordValidator.Requirements()
|
||||||
"At least 8 characters long",
|
|
||||||
"At least one uppercase letter (A-Z)",
|
|
||||||
"At least one lowercase letter (a-z)",
|
|
||||||
"At least one number (0-9)",
|
|
||||||
"At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidatePassword checks a password and returns an error if it doesn't meet requirements
|
// Requirements returns the human-readable list for the receiver's active rules.
|
||||||
func ValidatePassword(password string) error {
|
func (v *PasswordValidator) Requirements() []string {
|
||||||
if len(password) < 8 {
|
r := v.rules()
|
||||||
return fmt.Errorf("password must be at least 8 characters long")
|
var out []string
|
||||||
|
out = append(out, fmt.Sprintf("At least %d characters long", r.MinLength))
|
||||||
|
if r.Upper {
|
||||||
|
out = append(out, "At least one uppercase letter (A-Z)")
|
||||||
}
|
}
|
||||||
|
if r.Lower {
|
||||||
|
out = append(out, "At least one lowercase letter (a-z)")
|
||||||
|
}
|
||||||
|
if r.Number {
|
||||||
|
out = append(out, "At least one number (0-9)")
|
||||||
|
}
|
||||||
|
if r.Special {
|
||||||
|
out = append(out, "At least one special character (!@#$%^&*()_+-=[]{}|;':\",./<>?)")
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
if !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
// ValidatePassword checks a password against the default (hardcoded) rules and
|
||||||
|
// returns an error describing the first unmet requirement. Retained for callers
|
||||||
|
// that don't have access to a configured PasswordValidator instance.
|
||||||
|
func ValidatePassword(password string) error {
|
||||||
|
v := defaultPasswordValidator
|
||||||
|
r := v.rules()
|
||||||
|
if len(password) < r.MinLength {
|
||||||
|
return fmt.Errorf("password must be at least %d characters long", r.MinLength)
|
||||||
|
}
|
||||||
|
if r.Upper && !regexp.MustCompile(`[A-Z]`).MatchString(password) {
|
||||||
return fmt.Errorf("password must contain at least one uppercase letter")
|
return fmt.Errorf("password must contain at least one uppercase letter")
|
||||||
}
|
}
|
||||||
|
if r.Lower && !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
||||||
if !regexp.MustCompile(`[a-z]`).MatchString(password) {
|
|
||||||
return fmt.Errorf("password must contain at least one lowercase letter")
|
return fmt.Errorf("password must contain at least one lowercase letter")
|
||||||
}
|
}
|
||||||
|
if r.Number && !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
||||||
if !regexp.MustCompile(`[0-9]`).MatchString(password) {
|
|
||||||
return fmt.Errorf("password must contain at least one number")
|
return fmt.Errorf("password must contain at least one number")
|
||||||
}
|
}
|
||||||
|
if r.Special && !specialRegex().MatchString(password) {
|
||||||
if !regexp.MustCompile(`[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]`).MatchString(password) {
|
|
||||||
return fmt.Errorf("password must contain at least one special character")
|
return fmt.Errorf("password must contain at least one special character")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterPasswordValidation registers the password validator with the validator instance
|
// defaultPasswordValidator is used by the package-level helpers
|
||||||
|
// (GetPasswordRequirements, ValidatePassword) and as the fallback inside
|
||||||
|
// RegisterPasswordValidation when no registry has been wired. Callers that want
|
||||||
|
// live rule updates should construct their own PasswordValidator and call
|
||||||
|
// SetSettings.
|
||||||
|
var defaultPasswordValidator = &PasswordValidator{}
|
||||||
|
|
||||||
|
// RegisterPasswordValidation registers the password validator with the
|
||||||
|
// validator instance. The registered func re-evaluates rules on every call, so
|
||||||
|
// changes to the wired registry take effect immediately.
|
||||||
func RegisterPasswordValidation(v *validator.Validate) error {
|
func RegisterPasswordValidation(v *validator.Validate) error {
|
||||||
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
|
return v.RegisterValidation("passwordcomplex", func(fl validator.FieldLevel) bool {
|
||||||
pv := &PasswordValidator{}
|
return defaultPasswordValidator.CheckPassword(fl.Field().String())
|
||||||
return pv.Validate(fl)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDefaultPasswordSettings wires the settings registry into the package-level
|
||||||
|
// default validator so that the struct-tag validator (used by echo's
|
||||||
|
// CustomValidator) and ValidatePassword follow live configuration. Intended to
|
||||||
|
// be called once at startup.
|
||||||
|
func SetDefaultPasswordSettings(s *database.SettingsRegistry) {
|
||||||
|
defaultPasswordValidator.SetSettings(s)
|
||||||
|
}
|
||||||
|
|||||||
+102
-21
@@ -9,21 +9,25 @@ import (
|
|||||||
// OPDS 1.2 Feed Structures
|
// OPDS 1.2 Feed Structures
|
||||||
|
|
||||||
type Feed struct {
|
type Feed struct {
|
||||||
XMLName xml.Name `xml:"feed"`
|
XMLName xml.Name `xml:"feed"`
|
||||||
Xmlns string `xml:"xmlns,attr"`
|
Xmlns string `xml:"xmlns,attr"`
|
||||||
OpdsNS string `xml:"xmlns:opds,attr"`
|
OpdsNS string `xml:"xmlns:opds,attr"`
|
||||||
DcNS string `xml:"xmlns:dc,attr"`
|
DcNS string `xml:"xmlns:dc,attr"`
|
||||||
ID string `xml:"id"`
|
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
|
||||||
Title string `xml:"title"`
|
ID string `xml:"id"`
|
||||||
Updated string `xml:"updated"`
|
Title string `xml:"title"`
|
||||||
Links []Link `xml:"link"`
|
Updated string `xml:"updated"`
|
||||||
Entries []Entry `xml:"entry"`
|
Links []Link `xml:"link"`
|
||||||
|
TotalResults *int `xml:"opensearch:totalResults,omitempty"`
|
||||||
|
ItemsPerPage *int `xml:"opensearch:itemsPerPage,omitempty"`
|
||||||
|
StartIndex *int `xml:"opensearch:startIndex,omitempty"`
|
||||||
|
Entries []Entry `xml:"entry"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
ID string `xml:"id"`
|
ID string `xml:"id"`
|
||||||
Title string `xml:"dc:title"`
|
Title string `xml:"title"`
|
||||||
Creator string `xml:"dc:creator,omitempty"`
|
Author *Author `xml:"author,omitempty"`
|
||||||
Updated string `xml:"updated"`
|
Updated string `xml:"updated"`
|
||||||
Summary string `xml:"summary,omitempty"`
|
Summary string `xml:"summary,omitempty"`
|
||||||
Links []Link `xml:"link"`
|
Links []Link `xml:"link"`
|
||||||
@@ -32,6 +36,12 @@ type Entry struct {
|
|||||||
Categories []Category `xml:"category,omitempty"`
|
Categories []Category `xml:"category,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Author struct {
|
||||||
|
XMLName xml.Name `xml:"author"`
|
||||||
|
Name string `xml:"name"`
|
||||||
|
URI string `xml:"uri,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Link struct {
|
type Link struct {
|
||||||
Href string `xml:"href,attr"`
|
Href string `xml:"href,attr"`
|
||||||
Type string `xml:"type,attr"`
|
Type string `xml:"type,attr"`
|
||||||
@@ -58,17 +68,29 @@ type Category struct {
|
|||||||
func NewFeed(feedID, title string) *Feed {
|
func NewFeed(feedID, title string) *Feed {
|
||||||
now := time.Now().Format(time.RFC3339)
|
now := time.Now().Format(time.RFC3339)
|
||||||
return &Feed{
|
return &Feed{
|
||||||
Xmlns: "http://www.w3.org/2005/Atom",
|
Xmlns: "http://www.w3.org/2005/Atom",
|
||||||
OpdsNS: "http://opds-spec.org/2010/",
|
OpdsNS: "http://opds-spec.org/2010/",
|
||||||
DcNS: "http://purl.org/dc/elements/1.1/",
|
DcNS: "http://purl.org/dc/elements/1.1/",
|
||||||
ID: feedID,
|
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
|
||||||
Title: title,
|
ID: feedID,
|
||||||
Updated: now,
|
Title: title,
|
||||||
Links: []Link{},
|
Updated: now,
|
||||||
Entries: []Entry{},
|
Links: []Link{},
|
||||||
|
Entries: []Entry{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPagination populates the OpenSearch paging metadata (totalResults,
|
||||||
|
// itemsPerPage, startIndex). startIndex is 1-based to match the page model.
|
||||||
|
func (f *Feed) SetPagination(totalResults, itemsPerPage, startIndex int) {
|
||||||
|
tr := totalResults
|
||||||
|
ipp := itemsPerPage
|
||||||
|
si := startIndex
|
||||||
|
f.TotalResults = &tr
|
||||||
|
f.ItemsPerPage = &ipp
|
||||||
|
f.StartIndex = &si
|
||||||
|
}
|
||||||
|
|
||||||
// AddLink adds a link to the feed
|
// AddLink adds a link to the feed
|
||||||
func (f *Feed) AddLink(href, linkType, rel string) {
|
func (f *Feed) AddLink(href, linkType, rel string) {
|
||||||
f.Links = append(f.Links, Link{
|
f.Links = append(f.Links, Link{
|
||||||
@@ -85,14 +107,17 @@ func (f *Feed) AddEntry(entry Entry) {
|
|||||||
|
|
||||||
// NewEntry creates a new OPDS entry
|
// NewEntry creates a new OPDS entry
|
||||||
func NewEntry(id, title, creator, updated string) Entry {
|
func NewEntry(id, title, creator, updated string) Entry {
|
||||||
return Entry{
|
e := Entry{
|
||||||
ID: id,
|
ID: id,
|
||||||
Title: title,
|
Title: title,
|
||||||
Creator: creator,
|
|
||||||
Updated: updated,
|
Updated: updated,
|
||||||
Links: []Link{},
|
Links: []Link{},
|
||||||
Metadata: []Meta{},
|
Metadata: []Meta{},
|
||||||
}
|
}
|
||||||
|
if creator != "" {
|
||||||
|
e.Author = &Author{Name: creator}
|
||||||
|
}
|
||||||
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddAcquisitionLink adds an acquisition link to the entry
|
// AddAcquisitionLink adds an acquisition link to the entry
|
||||||
@@ -169,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) {
|
|||||||
return xml.Header + string(output), nil
|
return xml.Header + string(output), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenSearchUrl is a single <Url> element in an OpenSearch description.
|
||||||
|
type OpenSearchUrl struct {
|
||||||
|
XMLName xml.Name `xml:"Url"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Template string `xml:"template,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenSearchDescription is an OpenSearch description document used by OPDS
|
||||||
|
// clients (e.g. KOReader) to discover how to perform catalog searches. Clients
|
||||||
|
// fetch this document at the catalog's rel="search" link, then substitute
|
||||||
|
// {searchTerms} in the Url template to execute a query.
|
||||||
|
type OpenSearchDescription struct {
|
||||||
|
XMLName xml.Name `xml:"OpenSearchDescription"`
|
||||||
|
Xmlns string `xml:"xmlns,attr"`
|
||||||
|
ShortName string `xml:"ShortName"`
|
||||||
|
Description string `xml:"Description"`
|
||||||
|
InputEncoding string `xml:"InputEncoding"`
|
||||||
|
OutputEncoding string `xml:"OutputEncoding"`
|
||||||
|
Url OpenSearchUrl `xml:"Url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSearchDescription creates an OpenSearch description document whose Url
|
||||||
|
// template points clients back to the search results endpoint. The template
|
||||||
|
// must contain the {searchTerms} placeholder.
|
||||||
|
func NewSearchDescription(shortName, description, template string) *OpenSearchDescription {
|
||||||
|
return &OpenSearchDescription{
|
||||||
|
Xmlns: "http://a9.com/-/spec/opensearch/1.1/",
|
||||||
|
ShortName: shortName,
|
||||||
|
Description: description,
|
||||||
|
InputEncoding: "UTF-8",
|
||||||
|
OutputEncoding: "UTF-8",
|
||||||
|
Url: OpenSearchUrl{
|
||||||
|
Type: "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||||
|
Template: template,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateXML generates the OpenSearch description XML
|
||||||
|
func (d *OpenSearchDescription) GenerateXML() ([]byte, error) {
|
||||||
|
output, err := xml.MarshalIndent(d, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshal OpenSearch description: %w", err)
|
||||||
|
}
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateXMLString generates the OpenSearch description XML as a string
|
||||||
|
func (d *OpenSearchDescription) GenerateXMLString() (string, error) {
|
||||||
|
output, err := d.GenerateXML()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return xml.Header + string(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
// NewErrorFeed creates an error feed
|
// NewErrorFeed creates an error feed
|
||||||
func NewErrorFeed(message string) *Feed {
|
func NewErrorFeed(message string) *Feed {
|
||||||
feed := NewFeed(
|
feed := NewFeed(
|
||||||
|
|||||||
+105
-4
@@ -71,8 +71,8 @@ func TestNewEntry(t *testing.T) {
|
|||||||
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
|
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Creator != "Test Author" {
|
if entry.Author == nil || entry.Author.Name != "Test Author" {
|
||||||
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator)
|
t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
|
||||||
}
|
}
|
||||||
|
|
||||||
if entry.Updated != "2023-01-01T00:00:00Z" {
|
if entry.Updated != "2023-01-01T00:00:00Z" {
|
||||||
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
|
|||||||
`<title>Test Feed</title>`,
|
`<title>Test Feed</title>`,
|
||||||
`<entry>`,
|
`<entry>`,
|
||||||
`<id>urn:uuid:book-id</id>`,
|
`<id>urn:uuid:book-id</id>`,
|
||||||
`<dc:title>Test Book</dc:title>`,
|
`<title>Test Book</title>`,
|
||||||
`<dc:creator>Test Author</dc:creator>`,
|
`<name>Test Author</name>`,
|
||||||
`<link href="http://example.com/book.epub"`,
|
`<link href="http://example.com/book.epub"`,
|
||||||
`rel="http://opds-spec.org/acquisition/open-access"`,
|
`rel="http://opds-spec.org/acquisition/open-access"`,
|
||||||
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
|
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
|
||||||
@@ -232,6 +232,107 @@ func TestNewErrorFeed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFeedSetPagination(t *testing.T) {
|
||||||
|
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||||
|
feed.SetPagination(1814, 50, 51)
|
||||||
|
|
||||||
|
if feed.TotalResults == nil || *feed.TotalResults != 1814 {
|
||||||
|
t.Errorf("expected TotalResults to be 1814, got %v", feed.TotalResults)
|
||||||
|
}
|
||||||
|
if feed.ItemsPerPage == nil || *feed.ItemsPerPage != 50 {
|
||||||
|
t.Errorf("expected ItemsPerPage to be 50, got %v", feed.ItemsPerPage)
|
||||||
|
}
|
||||||
|
if feed.StartIndex == nil || *feed.StartIndex != 51 {
|
||||||
|
t.Errorf("expected StartIndex to be 51, got %v", feed.StartIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedGenerateXMLPagination(t *testing.T) {
|
||||||
|
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "first")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "previous")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=2", "application/atom+xml", "self")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=3", "application/atom+xml", "next")
|
||||||
|
feed.AddLink("http://example.com/catalog?page=37", "application/atom+xml", "last")
|
||||||
|
feed.SetPagination(1814, 50, 51)
|
||||||
|
|
||||||
|
output, err := feed.GenerateXML()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate XML: %v", err)
|
||||||
|
}
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
requiredStrings := []string{
|
||||||
|
`xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"`,
|
||||||
|
`<opensearch:totalResults>1814</opensearch:totalResults>`,
|
||||||
|
`<opensearch:itemsPerPage>50</opensearch:itemsPerPage>`,
|
||||||
|
`<opensearch:startIndex>51</opensearch:startIndex>`,
|
||||||
|
`rel="first"`,
|
||||||
|
`rel="previous"`,
|
||||||
|
`rel="next"`,
|
||||||
|
`rel="last"`,
|
||||||
|
`page=3`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, required := range requiredStrings {
|
||||||
|
if !contains(outputStr, required) {
|
||||||
|
t.Errorf("generated XML missing required string: %s", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFeedGenerateXMLOmitsPaginationWhenUnset(t *testing.T) {
|
||||||
|
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||||
|
|
||||||
|
output, err := feed.GenerateXML()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate XML: %v", err)
|
||||||
|
}
|
||||||
|
outputStr := string(output)
|
||||||
|
|
||||||
|
if contains(outputStr, "opensearch:totalResults") {
|
||||||
|
t.Errorf("expected no totalResults when pagination unset, but found it")
|
||||||
|
}
|
||||||
|
if contains(outputStr, "opensearch:itemsPerPage") {
|
||||||
|
t.Errorf("expected no itemsPerPage when pagination unset, but found it")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSearchDescription(t *testing.T) {
|
||||||
|
template := "http://example.com/opds/devices/abc/search?q={searchTerms}&token=xyz"
|
||||||
|
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
||||||
|
|
||||||
|
if desc.ShortName != "Bookhoard" {
|
||||||
|
t.Errorf("expected ShortName 'Bookhoard', got '%s'", desc.ShortName)
|
||||||
|
}
|
||||||
|
if desc.Url.Template != template {
|
||||||
|
t.Errorf("expected template '%s', got '%s'", template, desc.Url.Template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchDescriptionGenerateXML(t *testing.T) {
|
||||||
|
template := "http://example.com/opds/devices/abc/search?q={searchTerms}"
|
||||||
|
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
||||||
|
|
||||||
|
output, err := desc.GenerateXMLString()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to generate XML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredStrings := []string{
|
||||||
|
`<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">`,
|
||||||
|
`<ShortName>Bookhoard</ShortName>`,
|
||||||
|
`<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"`,
|
||||||
|
`template="http://example.com/opds/devices/abc/search?q={searchTerms}"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, required := range requiredStrings {
|
||||||
|
if !contains(output, required) {
|
||||||
|
t.Errorf("generated OpenSearch XML missing required string: %s", required)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func contains(s, substr string) bool {
|
func contains(s, substr string) bool {
|
||||||
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user