Compare commits
192
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fffe0b17e6 | ||
|
|
451aa48aec | ||
|
|
5ac407057e | ||
|
|
bf83492bf7 | ||
|
|
33c69e7c71 | ||
|
|
c2f72ca785 | ||
|
|
ca8c592496 | ||
|
|
1f5b0d0164 | ||
|
|
13cc689bff | ||
|
|
9920fd47b9 | ||
|
|
26f695f480 | ||
|
|
05370d236a | ||
|
|
114a4574b0 | ||
|
|
3c9e4d8126 | ||
|
|
e7c4c931ee | ||
|
|
bac84e24ec | ||
|
|
de8f71b2be | ||
|
|
1129fcae6f | ||
|
|
76c6826920 | ||
|
|
75b33fdae6 | ||
|
|
635a9439cb | ||
|
|
3b15766149 | ||
|
|
2a15effc3e | ||
|
|
78176c57a5 | ||
|
|
980aaee0d9 | ||
|
|
acfb298b74 | ||
|
|
ce3ab8bcbc | ||
|
|
6d44ae884c | ||
|
|
f6d98dd7cc | ||
|
|
e584200369 | ||
|
|
9bd23fc1d7 | ||
|
|
762181c123 | ||
|
|
d11a623f9f | ||
|
|
caaf27427e | ||
|
|
423a1c0bf7 | ||
|
|
b1d2ccc87c | ||
|
|
3170aa6b77 | ||
|
|
f75d68bf66 | ||
|
|
f37de6c07c | ||
|
|
cea8e64da2 | ||
|
|
307a43f6b0 | ||
|
|
d5018936a0 | ||
|
|
3a54dda5c5 | ||
|
|
3c2a504747 | ||
|
|
b0dc0c591e | ||
|
|
0f225c39b9 | ||
|
|
bc59159cde | ||
|
|
f57d64563b | ||
|
|
1f8c3c21ae | ||
|
|
5d22021e8e | ||
|
|
8e4412544d | ||
|
|
d764f820b2 | ||
|
|
39a2829cc8 | ||
|
|
795f10d2af | ||
|
|
44f38803dc | ||
|
|
b2b1804aa3 | ||
|
|
c82f20c3f2 | ||
|
|
5a76fed099 | ||
|
|
342c2f88b2 | ||
|
|
854a888306 | ||
|
|
a4b91393a6 | ||
|
|
0af2ee4951 | ||
|
|
d78a182f0d | ||
|
|
13260d8c1f | ||
|
|
6fcd7f4400 | ||
|
|
6e8986bea5 | ||
|
|
87da5c3cf5 | ||
|
|
fb9427a864 | ||
|
|
0af940c7b3 | ||
|
|
f8745da6b2 | ||
|
|
604a2458e9 | ||
|
|
666b72c4fd | ||
|
|
7221906d53 | ||
|
|
0a9cde0fc8 | ||
|
|
d3a510d2e0 | ||
|
|
a6f5d8d693 | ||
|
|
6a352a6afb | ||
|
|
e7a4f0f758 | ||
|
|
64d3e8d272 | ||
|
|
8e48aa4334 | ||
|
|
2fa3764df5 | ||
|
|
faef4d9fff | ||
|
|
a2fc2613b4 | ||
|
|
f23ee6e66a | ||
|
|
73fc609d7b | ||
|
|
5dca78789d | ||
|
|
37e092c2a8 | ||
|
|
4f7794767d | ||
|
|
d61abb1be1 | ||
|
|
1afc202ba9 | ||
|
|
5caebcfe45 | ||
|
|
b1e85dca79 | ||
|
|
b8dc4e87d5 | ||
|
|
0855dd7b3b | ||
|
|
d0460885ff | ||
|
|
ced90cd1f4 | ||
|
|
4b43bd04ba | ||
|
|
553a1dc19b | ||
|
|
f3cacd1b16 | ||
|
|
b829b7fe22 | ||
|
|
d42d09b8db | ||
|
|
71832bee24 | ||
|
|
504f145f64 | ||
|
|
46e0744802 | ||
|
|
a93be75408 | ||
|
|
dc07a2f19f | ||
|
|
fa38ca0d1b | ||
|
|
ae2e03d499 | ||
|
|
65c08fb523 | ||
|
|
0680e051c2 | ||
|
|
03156e7866 | ||
|
|
4f2e1e36bf | ||
|
|
36b2afeed7 | ||
|
|
b088ec97f6 | ||
|
|
a92739d210 | ||
|
|
f199918775 | ||
|
|
a3504f1ae5 | ||
|
|
355ae5f7a9 | ||
|
|
ddc14e3314 | ||
|
|
a57694b738 | ||
|
|
3bcae0abf5 | ||
|
|
cce7ad4907 | ||
|
|
c5cda015b3 | ||
|
|
b83e319a34 | ||
|
|
004b761381 | ||
|
|
9405afa2c5 | ||
|
|
d48404e800 | ||
|
|
ed68f92f4a | ||
|
|
607ce8ce3a | ||
|
|
ff0517d038 | ||
|
|
e14bbeabdd | ||
|
|
a9f8e23ddd | ||
|
|
540fb147d2 | ||
|
|
ffaa561cb7 | ||
|
|
4305c77df4 | ||
|
|
1da7765466 | ||
|
|
5da91b9c7c | ||
|
|
f87fc45377 | ||
|
|
5be6fec408 | ||
|
|
55a9ec00e1 | ||
|
|
c592c745c3 | ||
|
|
e5726e12be | ||
|
|
f589bedad5 | ||
|
|
27e9a654bf | ||
|
|
caf50ade31 | ||
|
|
dd1ddff08d | ||
|
|
1863fb3380 | ||
|
|
bc58103c62 | ||
|
|
37f84dd3ea | ||
|
|
2736409a79 | ||
|
|
94102af4d7 | ||
|
|
cb214b16b2 | ||
|
|
4fe36ba0a5 | ||
|
|
283b2f2ed7 | ||
|
|
f699899408 | ||
|
|
e87f481988 | ||
|
|
d68f72f21b | ||
|
|
d400377474 | ||
|
|
7cd88b6107 | ||
|
|
d38804e910 | ||
|
|
192c978a38 | ||
|
|
44ec2f496a | ||
|
|
d3ddecb840 | ||
|
|
da1732285f | ||
|
|
0269403a5d | ||
|
|
a4962a87b2 | ||
|
|
35c8ffe33e | ||
|
|
70d8ffd528 | ||
|
|
3a3fa8763e | ||
|
|
4119a5e38e | ||
|
|
86f44230c2 | ||
|
|
3c3c4e8bf5 | ||
|
|
133ca1fdaa | ||
|
|
c7f0eb406a | ||
|
|
2fdf894216 | ||
|
|
17f2dc3120 | ||
|
|
a06c85e72a | ||
|
|
e389df92c3 | ||
|
|
ad27902790 | ||
|
|
855ef161d8 | ||
|
|
a6700f73e0 | ||
|
|
8baecad379 | ||
|
|
9ccff320a1 | ||
|
|
d8d6334052 | ||
|
|
5b2d105609 | ||
|
|
18811cea1b | ||
|
|
3e73a582ba | ||
|
|
8aeae33167 | ||
|
|
3cecb04e8d | ||
|
|
6820208a36 | ||
|
|
77cbbf600b | ||
|
|
4e326dfc86 |
@@ -10,6 +10,21 @@ JWT_SECRET=your-secure-jwt-secret-key-here
|
||||
# Generate with: openssl rand -hex 16
|
||||
DBPASS=your-secure-database-password-here
|
||||
|
||||
# Networking: change a port if it conflicts on your host
|
||||
# Postgres port, host + container (e.g. another local DB already uses 5432)
|
||||
# DB_PORT=15432
|
||||
# App web port, host + container
|
||||
# SERVER_PORT=8765
|
||||
|
||||
# Deployment
|
||||
# External URL for device sync (must include protocol; defaults to http://localhost:8765)
|
||||
# Examples: https://bookhoard.example.com | http://192.168.1.10:8765
|
||||
# BASE_URL=https://bookhoard.example.com
|
||||
# Mark session cookies Secure — set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||
# COOKIE_SECURE=true
|
||||
# Pin or rollback a specific published image version (defaults to "latest")
|
||||
# IMAGE_TAG=1.0.0
|
||||
|
||||
# Optional: Override Defaults (defaults are set in docker-compose.yml)
|
||||
# Test Mode: WARNING - Only set to true for integration testing
|
||||
# TEST_MODE=true
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Release
|
||||
|
||||
# Overrides the default run name (the tagged commit's message) so the Actions
|
||||
# runs list shows "Release v0.3.0" instead.
|
||||
run-name: "Release ${{ gitea.event.inputs.tag || gitea.ref_name }}"
|
||||
|
||||
# Publishes the Bookhoard container image to the Gitea container registry AND
|
||||
# creates a Gitea Release whose body is the annotated tag's message (generated
|
||||
# locally by `make release VERSION=...` via git-cliff). Triggered by a version
|
||||
# tag push, or manually via workflow_dispatch with a tag. Pushing to main does
|
||||
# nothing, so work-in-progress commits never ship. Each release publishes two
|
||||
# image tags: the version (e.g. v0.3.0) and "latest".
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Tag to release (e.g. v0.3.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
# Resolve the target tag for both triggers: explicit input on manual
|
||||
# dispatch, otherwise the pushed tag ref.
|
||||
TAG: ${{ gitea.event.inputs.tag || gitea.ref_name }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full history ensures the tag annotation (the release notes) is present.
|
||||
fetch-depth: 0
|
||||
ref: ${{ gitea.event.inputs.tag || gitea.ref }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.linuxhg.com
|
||||
username: ${{ gitea.actor }}
|
||||
# PAT stored as a repo Actions secret (auto GITHUB_TOKEN lacks package scope in Gitea)
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
# Publishes both the exact version (e.g. v0.2.0) and the movable "latest" tag.
|
||||
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
|
||||
# pin or roll back by setting IMAGE_TAG in .env.
|
||||
tags: |
|
||||
git.linuxhg.com/bookhoard/bookhoard:${{ env.TAG }}
|
||||
git.linuxhg.com/bookhoard/bookhoard:latest
|
||||
|
||||
- name: Create Gitea Release
|
||||
env:
|
||||
# REGISTRY_TOKEN is reused for release creation because Gitea's auto
|
||||
# GITHUB_TOKEN cannot create releases on this instance. The PAT must
|
||||
# carry write:repository scope. Idempotent: re-runs update an existing
|
||||
# release for this tag instead of failing with 409. On any HTTP error
|
||||
# the API response body is printed so a 403 names the missing scope.
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${TAG:?TAG is required}"
|
||||
API="https://git.linuxhg.com/api/v1/repos/${REPO}/releases"
|
||||
AUTH="Authorization: token ${TOKEN}"
|
||||
# Release body = the annotated tag's message (the git-cliff notes).
|
||||
BODY="$(git tag -l --format='%(contents)' "${TAG}")"
|
||||
|
||||
# Tags containing a '-' (e.g. v0.3.0-rc1) are published as pre-releases.
|
||||
PRE="false"; case "${TAG}" in *-*) PRE="true";; esac
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg t "${TAG}" --arg n "${TAG}" --arg b "${BODY}" --argjson p "${PRE}" \
|
||||
'{tag_name:$t, name:$n, body:$b, draft:false, prerelease:$p}')
|
||||
|
||||
# POST/PATCH the release, surfacing Gitea's error message on failure
|
||||
# (e.g. "token does not have write scope") instead of failing silently.
|
||||
api_call() {
|
||||
local method="$1" url="$2" resp code rbody
|
||||
resp="$(curl -sS -w '\n%{http_code}' -X "${method}" \
|
||||
-H "${AUTH}" -H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" "${url}")"
|
||||
code="$(printf '%s' "${resp}" | tail -n1)"
|
||||
rbody="$(printf '%s' "${resp}" | sed '$d')"
|
||||
if [ "${code}" -ge 400 ]; then
|
||||
echo "::error::Release API ${code} (${method} ${url}): ${rbody}" >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
EXISTING_ID="$(curl -sS -H "${AUTH}" "${API}/tags/${TAG}" | jq -r '.id // empty' 2>/dev/null || true)"
|
||||
if [ -n "${EXISTING_ID}" ]; then
|
||||
api_call PATCH "${API}/${EXISTING_ID}"
|
||||
echo "Updated existing release id=${EXISTING_ID} for ${TAG}"
|
||||
else
|
||||
api_call POST "${API}"
|
||||
echo "Created new release for ${TAG}"
|
||||
fi
|
||||
+5
-4
@@ -8,15 +8,15 @@ RUN apk add --no-cache nodejs npm curl git
|
||||
|
||||
# Install Go tools (cached well)
|
||||
RUN wget -O /tmp/sqlc.tar.gz https://github.com/sqlc-dev/sqlc/releases/download/v1.31.0/sqlc_1.31.0_linux_amd64.tar.gz && \
|
||||
tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \
|
||||
rm /tmp/sqlc.tar.gz
|
||||
tar -xzf /tmp/sqlc.tar.gz -C /usr/local/bin sqlc && \
|
||||
rm /tmp/sqlc.tar.gz
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
go install github.com/a-h/templ/cmd/templ@v0.3.1020
|
||||
|
||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||
COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci
|
||||
npm install
|
||||
|
||||
# Copy Go mod files (cached unless go.mod changes)
|
||||
COPY go.mod go.sum ./
|
||||
@@ -36,7 +36,8 @@ RUN npm run build:ts
|
||||
|
||||
# Build Go binary (cached unless Go files or generated code changes)
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
|
||||
|
||||
# Test runner stage - includes Go runtime and test dependencies
|
||||
# This stage is ONLY used for running tests, never deployed to production
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick
|
||||
.PHONY: help test test-integration test-all rebuild rebuild-force rebuild-app rebuild-app-force rebuild-force-db clean restart up down logs ps test-env-up test-env-down verify-guidelines verify-quick release
|
||||
|
||||
# Include .env file for environment variables (single source of truth)
|
||||
# Ignore if .env doesn't exist yet
|
||||
@@ -7,6 +7,14 @@ ifneq (,$(wildcard ./.env))
|
||||
export
|
||||
endif
|
||||
|
||||
# Auto-detect container runtime: prefer docker, fall back to podman
|
||||
# Override with: CONTAINER_RUNTIME=podman make rebuild-app
|
||||
CONTAINER_RUNTIME ?= $(shell command -v docker 2>/dev/null || command -v podman 2>/dev/null)
|
||||
|
||||
# Dev compose stack: base prod file merged with the dev override (local build + tests).
|
||||
# Prod deploy does NOT use this — it runs plain `docker compose` against the base file only.
|
||||
COMPOSE := $(CONTAINER_RUNTIME) compose -f docker-compose.yml -f docker-compose.dev.yml
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Available targets:"
|
||||
@@ -36,6 +44,9 @@ help:
|
||||
@echo "Verification:"
|
||||
@echo " make verify-guidelines - Run comprehensive guidelines check"
|
||||
@echo " make verify-quick - Run quick guidelines check"
|
||||
@echo ""
|
||||
@echo "Release:"
|
||||
@echo " ./release v0.3.0 - Tag, push, and release (notes auto-generated from commits)"
|
||||
|
||||
# Run unit tests locally (fast, no containers)
|
||||
test:
|
||||
@@ -44,23 +55,23 @@ test:
|
||||
# Run integration tests in containers (matches production environment)
|
||||
test-integration:
|
||||
@echo "Building test containers..."
|
||||
podman compose --profile tests build
|
||||
$(COMPOSE) --profile tests build
|
||||
@echo "Starting application containers..."
|
||||
podman compose up -d db app
|
||||
$(COMPOSE) up -d db app
|
||||
@echo "Waiting for services to be healthy..."
|
||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \
|
||||
echo " Database not ready yet..."; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
echo " ✓ Database is ready"
|
||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do \
|
||||
echo " Application not ready yet..."; \
|
||||
sleep 2; \
|
||||
done; \
|
||||
echo " ✓ Application is ready"
|
||||
@echo ""
|
||||
@echo "Running integration tests in container..."
|
||||
podman compose --profile tests run --rm tests
|
||||
$(COMPOSE) --profile tests run --rm tests
|
||||
@echo ""
|
||||
@echo "✅ Integration tests completed!"
|
||||
@echo "📝 Containers are still running. Use 'make logs' to view logs or 'make clean' to stop."
|
||||
@@ -71,76 +82,76 @@ test-all: test test-integration
|
||||
# Rebuild app container only (preserve DB, with cache)
|
||||
rebuild-app:
|
||||
@echo "Rebuilding app container (database stays running)..."
|
||||
podman compose up --build --force-recreate -d app
|
||||
$(COMPOSE) up --build --force-recreate -d app
|
||||
@echo "✓ App container rebuilt and restarted"
|
||||
|
||||
# Rebuild app container only (preserve DB, no cache)
|
||||
rebuild-app-force:
|
||||
@echo "Force rebuilding app container (database stays running, no cache)..."
|
||||
podman compose build --no-cache app
|
||||
podman compose up --force-recreate -d app
|
||||
$(COMPOSE) build --no-cache app
|
||||
$(COMPOSE) up --force-recreate -d app
|
||||
@echo "✓ App container rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (preserve DB, with cache)
|
||||
rebuild:
|
||||
@echo "Rebuilding all containers (database preserved)..."
|
||||
podman compose up --build --force-recreate -d
|
||||
$(COMPOSE) up --build --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (preserve DB, no cache)
|
||||
rebuild-force:
|
||||
@echo "Force rebuilding all containers (database preserved, no cache)..."
|
||||
podman compose build --no-cache
|
||||
podman compose up --force-recreate -d
|
||||
$(COMPOSE) build --no-cache
|
||||
$(COMPOSE) up --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Rebuild all containers (remove DB, no cache)
|
||||
rebuild-force-db:
|
||||
@echo "Force rebuilding all containers (database will be DELETED, no cache)..."
|
||||
podman compose down -v
|
||||
podman compose build --no-cache
|
||||
podman compose up --force-recreate -d
|
||||
$(COMPOSE) down -v
|
||||
$(COMPOSE) build --no-cache
|
||||
$(COMPOSE) up --force-recreate -d
|
||||
@echo "✓ All containers rebuilt and restarted"
|
||||
|
||||
# Stop and remove containers
|
||||
clean:
|
||||
podman compose down -v
|
||||
$(COMPOSE) down -v
|
||||
|
||||
# Quick start (if already built)
|
||||
up:
|
||||
podman compose up -d
|
||||
$(COMPOSE) up -d
|
||||
|
||||
# Stop all containers (alias for clean)
|
||||
down:
|
||||
podman compose down
|
||||
$(COMPOSE) down
|
||||
|
||||
# Restart app container (preserves database)
|
||||
restart:
|
||||
@echo "Restarting app container (database stays running)..."
|
||||
podman compose restart app
|
||||
$(COMPOSE) restart app
|
||||
@echo "✓ App container restarted"
|
||||
|
||||
# Show container status
|
||||
ps:
|
||||
podman compose ps
|
||||
$(COMPOSE) ps
|
||||
|
||||
# Show container logs
|
||||
logs:
|
||||
podman compose logs -f
|
||||
$(COMPOSE) logs -f
|
||||
|
||||
# Start containers with test mode enabled for manual testing
|
||||
test-env-up:
|
||||
@echo "Starting containers with test mode enabled..."
|
||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 podman compose up --build --force-recreate -d
|
||||
TEST_MODE=true RATE_LIMIT_ENABLED=false REQUESTS_PER_MINUTE=1000 $(COMPOSE) up --build --force-recreate -d
|
||||
@echo "Waiting for services to be ready..."
|
||||
@until podman exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
||||
@until podman exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do sleep 1; done
|
||||
@until $(CONTAINER_RUNTIME) exec bookhoard curl -sf http://localhost:8765/health > /dev/null 2>&1; do sleep 1; done
|
||||
@echo "✓ Test environment is ready!"
|
||||
@echo "Application available at http://localhost:8765"
|
||||
|
||||
# Stop test environment
|
||||
test-env-down:
|
||||
podman compose down -v
|
||||
$(COMPOSE) down -v
|
||||
|
||||
# Verify project guidelines compliance
|
||||
verify-guidelines:
|
||||
@@ -150,3 +161,27 @@ verify-guidelines:
|
||||
verify-quick:
|
||||
@echo "Running quick project guidelines verification..."
|
||||
@./scripts/verify-quick.sh
|
||||
|
||||
# Create an annotated version tag carrying auto-generated release notes (git-cliff)
|
||||
# and push it. The tag push triggers .gitea/workflows/release.yml, which builds the
|
||||
# image and publishes a Gitea Release whose body is this tag's message. Notes come
|
||||
# entirely from Conventional Commits — no hand-written message required.
|
||||
#
|
||||
# git-cliff's --latest needs the tag to exist to scope the notes, so we create a
|
||||
# throwaway lightweight tag, generate the notes, replace it with an annotated tag,
|
||||
# then push. --cleanup=verbatim keeps the markdown "###" group headers (git's
|
||||
# default cleanup would strip lines starting with "#").
|
||||
#
|
||||
# Requires git-cliff: https://git-cliff.org/install
|
||||
# Usage: make release VERSION=v0.3.0
|
||||
release:
|
||||
@test -n "$(VERSION)" || { echo "Usage: make release VERSION=v0.3.0"; exit 1; }
|
||||
@command -v git-cliff >/dev/null 2>&1 || { echo "git-cliff not found — install: https://git-cliff.org/install"; exit 1; }
|
||||
@if git rev-parse "$(VERSION)" >/dev/null 2>&1; then echo "Tag $(VERSION) already exists locally — delete it first: git tag -d $(VERSION)"; exit 1; fi
|
||||
@echo "Generating release notes for $(VERSION)..."
|
||||
@git tag "$(VERSION)" HEAD && \
|
||||
(git cliff --latest --config cliff.toml > .release-notes.tmp && git tag -d "$(VERSION)" >/dev/null) || \
|
||||
{ git tag -d "$(VERSION)" >/dev/null 2>&1; rm -f .release-notes.tmp; echo "git-cliff failed"; exit 1; }
|
||||
@git tag -a --cleanup=verbatim -F .release-notes.tmp "$(VERSION)" HEAD && rm -f .release-notes.tmp
|
||||
@git push origin "$(VERSION)"
|
||||
@echo "Pushed $(VERSION) — Gitea Actions will build the image and publish the Release."
|
||||
|
||||
@@ -25,7 +25,7 @@ A modern self-hosted media library system built with Go, PostgreSQL, HTMX, and T
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository
|
||||
git clone https://github.com/yourusername/bookhoard.git
|
||||
git clone https://git.linuxhg.com/Bookhoard/bookhoard.git
|
||||
cd bookhoard
|
||||
|
||||
# 2. Set up environment
|
||||
@@ -35,8 +35,10 @@ cp .env.example .env
|
||||
# DBPASS: openssl rand -hex 16
|
||||
# Edit .env with your generated values
|
||||
|
||||
# 3. Start the server
|
||||
podman-compose up --build -d # or: docker-compose up --build -d
|
||||
# 3. Pull images and start the server
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
# Optionally pin a specific version: set IMAGE_TAG in .env (defaults to "latest")
|
||||
|
||||
# 4. Open your browser
|
||||
open http://localhost:8765
|
||||
|
||||
@@ -1,379 +0,0 @@
|
||||
# Timezone Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Add per-user timezone support with system-wide fallback (set via docker-compose), defaulting to UTC. The database already stores all timestamps as UTC via `TIMESTAMPTZ` columns, so this is primarily a display-layer feature.
|
||||
|
||||
**Display format:** MM-DD-YYYY HH:MM (US convention, no timezone abbreviation shown)
|
||||
|
||||
**Timezone selection:** Manual dropdown only (no browser auto-detect)
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Database Schema
|
||||
|
||||
**File:** `database/schema/schema.sql`
|
||||
|
||||
1. Add `timezone` column directly to the `users` table definition (line ~36):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
first_name VARCHAR(255),
|
||||
last_name VARCHAR(255),
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||
theme VARCHAR(50) DEFAULT 'tokyo-night',
|
||||
max_devices INTEGER DEFAULT 10,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
timezone VARCHAR(50) DEFAULT 'UTC'
|
||||
);
|
||||
```
|
||||
|
||||
> Note: The `timezone` column is already present at line 36 in the current schema. No change needed for this step.
|
||||
|
||||
1. Add `default_timezone` to the `system_settings` INSERT block (line ~49-52):
|
||||
|
||||
```sql
|
||||
INSERT INTO system_settings (setting_key, setting_value, description) VALUES
|
||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'),
|
||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'),
|
||||
('default_timezone', 'UTC', 'System default timezone')
|
||||
ON CONFLICT (setting_key) DO NOTHING;
|
||||
```
|
||||
|
||||
1. Add index in the indexes section (after line ~460, with other user indexes):
|
||||
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_users_timezone ON users(timezone);
|
||||
```
|
||||
|
||||
1. Regenerate sqlc code:
|
||||
|
||||
```bash
|
||||
cd internal/database && sqlc generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Database Queries
|
||||
|
||||
**File:** `internal/database/queries/queries.sql`
|
||||
|
||||
Add new queries:
|
||||
|
||||
```sql
|
||||
-- name: UpdateUserTimezone :exec
|
||||
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: GetSystemTimezone :one
|
||||
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
||||
```
|
||||
|
||||
> Note: `UpdateSystemTimezone` is omitted because the existing `UpdateSystemSetting` query handles it by passing `'default_timezone'` as the key parameter.
|
||||
|
||||
Regenerate after adding queries:
|
||||
|
||||
```bash
|
||||
cd internal/database && sqlc generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Template Utilities
|
||||
|
||||
**File:** `templates/utils.go`
|
||||
|
||||
Add timezone-aware time formatting helpers:
|
||||
|
||||
```go
|
||||
package templates
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// FormatInTimezone formats a time.Time in the specified timezone as MM-DD-YYYY HH:MM
|
||||
func FormatInTimezone(t time.Time, timezone string) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
return t.In(loc).Format("01-02-2006 03:04 PM")
|
||||
}
|
||||
|
||||
// FormatTimestamptzInTimezone formats a pgtype.Timestamptz in the specified timezone
|
||||
func FormatTimestamptzInTimezone(t pgtype.Timestamptz, timezone string) string {
|
||||
if !t.Valid {
|
||||
return ""
|
||||
}
|
||||
return FormatInTimezone(t.Time, timezone)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Context Update
|
||||
|
||||
**File:** `templates/types.go`
|
||||
|
||||
Add `Timezone` field to the `User` struct:
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
Username string
|
||||
Role string
|
||||
Theme string
|
||||
FirstName string
|
||||
LastName string
|
||||
CreatedAt time.Time
|
||||
Token string
|
||||
Timezone string
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `internal/router/helpers.go`
|
||||
|
||||
Update `getTemplateUserWithTheme()` to include timezone:
|
||||
|
||||
```go
|
||||
userTimezone := "UTC"
|
||||
if userDB.Timezone.Valid {
|
||||
userTimezone = userDB.Timezone.String
|
||||
}
|
||||
|
||||
return templates.User{
|
||||
// ... existing fields ...
|
||||
Timezone: userTimezone,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Handlers
|
||||
|
||||
**File:** `internal/handlers/auth.go`
|
||||
|
||||
Update `UpdateProfileRequest` struct:
|
||||
|
||||
```go
|
||||
type UpdateProfileRequest struct {
|
||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
Add timezone update logic in `UpdateProfile()`:
|
||||
|
||||
```go
|
||||
if req.Timezone != "" {
|
||||
if _, err := time.LoadLocation(req.Timezone); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "Invalid timezone",
|
||||
})
|
||||
}
|
||||
err := h.db.UpdateUserTimezone(ctx, database.UpdateUserTimezoneParams{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Timezone: pgtype.Text{String: req.Timezone, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `internal/handlers/system_settings.go`
|
||||
|
||||
Add timezone settings handler:
|
||||
|
||||
```go
|
||||
type UpdateTimezoneSettingsRequest struct {
|
||||
DefaultTimezone string `json:"default_timezone" validate:"required"`
|
||||
}
|
||||
|
||||
func (h *SystemSettingsHandler) UpdateTimezoneSettings(c *echo.Context) error {
|
||||
var req UpdateTimezoneSettingsRequest
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||
}
|
||||
if _, err := time.LoadLocation(req.DefaultTimezone); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid timezone"})
|
||||
}
|
||||
err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{
|
||||
SettingKey: "default_timezone",
|
||||
SettingValue: req.DefaultTimezone,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "Timezone updated"})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: User Profile UI
|
||||
|
||||
**File:** `templates/profile_form.templ`
|
||||
|
||||
Add timezone dropdown after the theme field:
|
||||
|
||||
```templ
|
||||
<div class="form-group">
|
||||
<label for="timezone">Timezone</label>
|
||||
<select name="timezone" id="timezone" class="form-select">
|
||||
<option value="UTC" selected?={ user.Timezone == "UTC" }>UTC (Coordinated Universal Time)</option>
|
||||
<option value="America/New_York" selected?={ user.Timezone == "America/New_York" }>Eastern Time</option>
|
||||
<option value="America/Chicago" selected?={ user.Timezone == "America/Chicago" }>Central Time</option>
|
||||
<option value="America/Denver" selected?={ user.Timezone == "America/Denver" }>Mountain Time</option>
|
||||
<option value="America/Los_Angeles" selected?={ user.Timezone == "America/Los_Angeles" }>Pacific Time</option>
|
||||
<option value="America/Phoenix" selected?={ user.Timezone == "America/Phoenix" }>Mountain Time (no DST)</option>
|
||||
<option value="America/Anchorage" selected?={ user.Timezone == "America/Anchorage" }>Alaska Time</option>
|
||||
<option value="Pacific/Honolulu" selected?={ user.Timezone == "Pacific/Honolulu" }>Hawaii Time</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
Include timezone in the HTMX form submission payload.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Admin Settings UI
|
||||
|
||||
**File:** `templates/admin_settings.templ`
|
||||
|
||||
Add system default timezone setting:
|
||||
|
||||
```templ
|
||||
<div class="setting-group">
|
||||
<h3>System Defaults</h3>
|
||||
<label for="default_timezone">Default Timezone</label>
|
||||
<select name="default_timezone" id="default_timezone">
|
||||
<option value="UTC">UTC (Coordinated Universal Time)</option>
|
||||
<option value="America/New_York">Eastern Time</option>
|
||||
<option value="America/Chicago">Central Time</option>
|
||||
<option value="America/Denver">Mountain Time</option>
|
||||
<option value="America/Los_Angeles">Pacific Time</option>
|
||||
<option value="America/Phoenix">Mountain Time (no DST)</option>
|
||||
<option value="America/Anchorage">Alaska Time</option>
|
||||
<option value="Pacific/Honolulu">Hawaii Time</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 8: Template Time Display Updates
|
||||
|
||||
### Files to update
|
||||
|
||||
| Template | Line(s) | Field(s) |
|
||||
| ------------------------------------ | -------------- | ----------------------------------- |
|
||||
| `templates/book_detail.templ` | ~253, ~282 | `LastReadAt`, `DatePublished` |
|
||||
| `templates/book_detail_modals.templ` | ~68, ~113 | `Timestamp`, `LastReadAt` |
|
||||
| `templates/devices.templ` | ~83, ~91, ~174 | `LastSync`, `LastSeen`, `ExpiresAt` |
|
||||
| `templates/conflicts.templ` | ~114 | `CreatedAt` |
|
||||
| `templates/admin_users.templ` | ~89 | `CreatedAt` |
|
||||
| `templates/queue.templ` | ~138 | `CreatedAt` |
|
||||
|
||||
### Change pattern
|
||||
|
||||
```templ
|
||||
<!-- Before -->
|
||||
{ book.ReadingProgress.LastReadAt.Time.Format("01-02-2006 03:04 PM") }
|
||||
|
||||
<!-- After -->
|
||||
{ templates.FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
|
||||
```
|
||||
|
||||
For `time.Time` fields:
|
||||
|
||||
```templ
|
||||
<!-- Before -->
|
||||
{ device.LastSync.Format("01-02-2006 03:04 PM") }
|
||||
|
||||
<!-- After -->
|
||||
{ templates.FormatInTimezone(device.LastSync, user.Timezone) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 9: Docker Configuration
|
||||
|
||||
**File:** `docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
services:
|
||||
server:
|
||||
environment:
|
||||
- TZ=UTC
|
||||
```
|
||||
|
||||
**File:** `.env.example`
|
||||
|
||||
```
|
||||
# System default timezone (fallback if not set in DB)
|
||||
TZ=UTC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Files Modified Summary
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------- |
|
||||
| `database/schema/schema.sql` | Add timezone column to users, system_setting row |
|
||||
| `internal/database/queries/queries.sql` | Add UpdateUserTimezone, GetSystemTimezone (reuses existing UpdateSystemSetting) |
|
||||
| `templates/utils.go` | Add FormatInTimezone, FormatTimestamptzInTimezone |
|
||||
| `templates/types.go` | Add Timezone field to User struct |
|
||||
| `internal/router/helpers.go` | Pass timezone to template User |
|
||||
| `internal/handlers/auth.go` | Handle timezone updates in UpdateProfile |
|
||||
| `internal/handlers/system_settings.go` | Add timezone settings handler |
|
||||
| `templates/profile_form.templ` | Add timezone dropdown |
|
||||
| `templates/admin_settings.templ` | Add default timezone setting |
|
||||
| `templates/book_detail.templ` | Update time displays |
|
||||
| `templates/book_detail_modals.templ` | Update time displays |
|
||||
| `templates/devices.templ` | Update time displays |
|
||||
| `templates/conflicts.templ` | Update time displays |
|
||||
| `templates/admin_users.templ` | Update time displays |
|
||||
| `templates/queue.templ` | Update time displays |
|
||||
| `docker-compose.yml` | Add TZ env var |
|
||||
| `.env.example` | Add TZ example |
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Create user, set timezone to Eastern, verify times display in MM-DD-YYYY HH:MM format
|
||||
- [ ] Create user, set timezone to Pacific, verify different offset
|
||||
- [ ] Test system default timezone fallback for users with no timezone set
|
||||
- [ ] Verify invalid timezone values are rejected by the API
|
||||
- [ ] Verify existing users (no timezone set) fall back to system default
|
||||
- [ ] Verify all templates show consistent MM-DD-YYYY HH:MM format
|
||||
- [ ] Run `make test-integration` to verify no regressions
|
||||
|
||||
---
|
||||
|
||||
## Deployment Steps
|
||||
|
||||
1. Update `database/schema/schema.sql` with new column and settings
|
||||
2. Regenerate sqlc: `cd internal/database && sqlc generate`
|
||||
3. Apply schema changes (restart database container with `make rebuild-force-db`)
|
||||
4. Deploy backend code changes
|
||||
5. Verify with existing data
|
||||
@@ -47,7 +47,7 @@ docs: |-
|
||||
- `id` (string, required): Media item UUID
|
||||
|
||||
**Request Body:**
|
||||
- `rating` (number, required): Rating value (typically 1-5)
|
||||
- `rating` (number, required): Rating value (1-10 integer scale; displayed as 1-5 stars with half-star precision)
|
||||
- `review` (string, optional): Review text
|
||||
|
||||
**Response:** Updated rating object
|
||||
|
||||
@@ -83,9 +83,10 @@ docs:
|
||||
- **Update Highlight**: PUT /api/highlights/:id - Update highlight
|
||||
- **Delete Highlight**: DELETE /api/highlights/:id - Remove highlight
|
||||
Ratings (All Users)
|
||||
- **Get Rating**: GET /api/ratings/:media_id - User's rating (returns 0 if unrated)
|
||||
- **Create/Update Rating**: POST /api/ratings - Rate media item (1-5 stars, half-star precision)
|
||||
- **Delete Rating**: DELETE /api/ratings/:media_id - Remove rating
|
||||
- **Get Rating**: GET /api/media-items/:id/rating - User's rating (returns null if unrated)
|
||||
- **Create/Update Rating**: POST /api/media-items/:id/rating - Rate media item (1-10 scale, displayed as 1-5 stars with half-star precision). POST upserts; PUT also available.
|
||||
- **Update Rating**: PUT /api/media-items/:id/rating - Update rating (upsert)
|
||||
- **Delete Rating**: DELETE /api/media-items/:id/rating - Remove rating
|
||||
Collections (All Users)
|
||||
- **List Collections**: GET /api/collections - Get user's collections
|
||||
- **Get Collection**: GET /api/collections/:id - Collection details with media items
|
||||
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# git-cliff configuration — generates the body of each Gitea Release from
|
||||
# Conventional Commits accumulated since the previous tag. Invoked in CI by
|
||||
# orhun/git-cliff-action with --latest so only the current tag's section is
|
||||
# emitted (no full history, no header — the Gitea Release title is the tag).
|
||||
# Docs: https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
header = ""
|
||||
body = """
|
||||
{% for group, commits in commits | group_by(attribute="group") %}\
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}\
|
||||
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}{{ commit.message | upper_first }} ({{ commit.id | truncate(length=7, end="") }})
|
||||
{% endfor %}\
|
||||
{% endfor %}\
|
||||
"""
|
||||
trim = true
|
||||
footer = ""
|
||||
|
||||
[git]
|
||||
conventional_commits = true
|
||||
filter_unconventional = false
|
||||
require_conventional = false
|
||||
split_commits = false
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^refactor", group = "Refactor" },
|
||||
{ message = "^docs", group = "Documentation" },
|
||||
{ message = "^test", group = "Tests" },
|
||||
{ message = "^chore|^ci", group = "Miscellaneous Tasks" },
|
||||
{ message = ".*", group = "Other" },
|
||||
]
|
||||
filter_commits = false
|
||||
tag_pattern = "v[0-9].*"
|
||||
sort_commits = "oldest"
|
||||
@@ -63,9 +63,13 @@ func main() {
|
||||
connManager := sync.NewConnectionManager()
|
||||
|
||||
progressService := sync.NewProgressService(queries, connManager)
|
||||
annotationService := sync.NewAnnotationService(queries, connManager)
|
||||
tombstonePurgerCancel := annotationService.StartTombstonePurger()
|
||||
defer tombstonePurgerCancel()
|
||||
|
||||
queueProcessor := sync.NewSyncQueueProcessor(queries)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
queueProcessor.SetAnnotationService(annotationService)
|
||||
|
||||
// Create library service
|
||||
libraryService := services.NewLibraryService(queries)
|
||||
@@ -79,6 +83,8 @@ func main() {
|
||||
|
||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||
koreaderHandler.SetProgressService(progressService)
|
||||
koreaderHandler.SetAnnotationService(annotationService)
|
||||
koreaderHandler.SetLibraryService(libraryService)
|
||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||
@@ -94,6 +100,7 @@ func main() {
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
mediaHandler.SetAnnotationService(annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||
|
||||
@@ -161,9 +168,11 @@ func main() {
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
JobsHandler: jobsHandler,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
LibraryService: libraryService,
|
||||
}
|
||||
|
||||
// Register all routes and get ebook handler
|
||||
|
||||
@@ -84,6 +84,7 @@ type TestServerSetup struct {
|
||||
ConnManager *wsync.ConnectionManager
|
||||
QueueProcessor *wsync.SyncQueueProcessor
|
||||
ProgressService *wsync.ProgressService
|
||||
AnnotationService *wsync.AnnotationService
|
||||
CleanupCancel context.CancelFunc
|
||||
QueueCtx context.Context
|
||||
QueueCancel context.CancelFunc
|
||||
@@ -455,6 +456,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
cleanupCancel := connManager.StartCleanupTask()
|
||||
|
||||
progressService := wsync.NewProgressService(queries, connManager)
|
||||
annotationService := wsync.NewAnnotationService(queries, connManager)
|
||||
|
||||
queueProcessor := wsync.NewSyncQueueProcessor(queries)
|
||||
queueProcessor.SetProgressService(progressService)
|
||||
@@ -463,6 +465,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
|
||||
koreaderHandler := handlers.NewKOReaderHandler(queries, connManager, queueProcessor)
|
||||
koreaderHandler.SetProgressService(progressService)
|
||||
koreaderHandler.SetAnnotationService(annotationService)
|
||||
wsHandler := handlers.NewWSHandler(queries, connManager, cfg.JWTSecret, deviceAuthMiddleware)
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager)
|
||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||
@@ -482,6 +485,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, worker)
|
||||
mediaHandler.SetProgressService(progressService)
|
||||
mediaHandler.SetAnnotationService(annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
|
||||
// Create conversion service for OPDS
|
||||
@@ -537,6 +541,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
ConnManager: connManager,
|
||||
QueueProcessor: queueProcessor,
|
||||
ProgressService: progressService,
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ CREATE TABLE IF NOT EXISTS reading_progress (
|
||||
percentage FLOAT CHECK (percentage >= 0 AND percentage <= 1),
|
||||
character_offset BIGINT,
|
||||
epubcfi TEXT,
|
||||
context_text TEXT,
|
||||
chapter INTEGER,
|
||||
chapter_progress FLOAT CHECK (chapter_progress >= 0 AND chapter_progress <= 1),
|
||||
viewport_x FLOAT DEFAULT 0,
|
||||
@@ -937,6 +938,7 @@ BEGIN
|
||||
percentage = (book_record->>'percentage')::FLOAT,
|
||||
character_offset = CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE existing_progress.character_offset END,
|
||||
epubcfi = CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE existing_progress.epubcfi END,
|
||||
context_text = CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE existing_progress.context_text END,
|
||||
chapter = CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE existing_progress.chapter END,
|
||||
chapter_progress = (book_record->>'percentage')::FLOAT,
|
||||
last_sync_device = 'koreader',
|
||||
@@ -955,6 +957,7 @@ BEGIN
|
||||
percentage,
|
||||
character_offset,
|
||||
epubcfi,
|
||||
context_text,
|
||||
chapter,
|
||||
chapter_progress,
|
||||
last_sync_device,
|
||||
@@ -971,6 +974,7 @@ BEGIN
|
||||
(book_record->>'percentage')::FLOAT,
|
||||
CASE WHEN book_record ? 'character' THEN (book_record->>'character')::BIGINT ELSE NULL END,
|
||||
CASE WHEN book_record ? 'epubcfi' THEN (book_record->>'epubcfi')::TEXT ELSE NULL END,
|
||||
CASE WHEN book_record ? 'context_text' THEN (book_record->>'context_text')::TEXT ELSE NULL END,
|
||||
CASE WHEN book_record ? 'chapter' THEN (book_record->>'chapter')::INTEGER ELSE NULL END,
|
||||
(book_record->>'percentage')::FLOAT,
|
||||
'koreader',
|
||||
@@ -1308,3 +1312,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_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
|
||||
+13
-55
@@ -1,5 +1,3 @@
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
@@ -9,14 +7,15 @@ services:
|
||||
POSTGRES_DB: bookhoard
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${DBPASS}
|
||||
COOKIE_SECURE: false # make true in production with HTTPS
|
||||
# PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT)
|
||||
PGPORT: ${DB_PORT:-5432}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./database/schema:/docker-entrypoint-initdb.d
|
||||
# Make other volumes as needed
|
||||
- ./uploads:/app/uploads
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
@@ -27,27 +26,29 @@ services:
|
||||
- .env
|
||||
|
||||
# Bookhoard Application
|
||||
# In production this image is pulled from the Gitea container registry.
|
||||
# Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest").
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest}
|
||||
container_name: bookhoard
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_PORT: ${DB_PORT:-5432}
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: 8765
|
||||
SERVER_PORT: ${SERVER_PORT:-8765}
|
||||
# IMPORTANT: Device sync requires full URL with protocol
|
||||
# Local: http://localhost:8765
|
||||
# Local network: http://192.168.1.X:8765
|
||||
# Domain: https://bookhoard.example.com
|
||||
BASE_URL: http://localhost:${SERVER_PORT}
|
||||
BASE_URL: ${BASE_URL:-http://localhost:8765}
|
||||
# Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik)
|
||||
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||
|
||||
# Rate Limiting Configuration
|
||||
TEST_MODE: ${TEST_MODE:-false}
|
||||
@@ -62,7 +63,7 @@ services:
|
||||
# System timezone (fallback for server-side time operations)
|
||||
TZ: ${TZ:-UTC}
|
||||
ports:
|
||||
- "8765:8765"
|
||||
- "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -70,55 +71,12 @@ services:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"]
|
||||
test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# Integration Tests - runs against containerized app and db
|
||||
tests:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile
|
||||
target: test-runner
|
||||
container_name: bookhoard_tests
|
||||
environment:
|
||||
# Database Configuration
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: 5432
|
||||
DATABASE_USER: postgres
|
||||
DATABASE_PASSWORD: ${DBPASS}
|
||||
DATABASE_NAME: bookhoard
|
||||
COOKIE_SECURE: false
|
||||
|
||||
# Application Configuration
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
SERVER_PORT: 8765
|
||||
|
||||
# Test Configuration
|
||||
TEST_MODE: "true"
|
||||
RATE_LIMIT_ENABLED: "false"
|
||||
REQUESTS_PER_MINUTE: 1000
|
||||
|
||||
# Conversion Service Configuration
|
||||
BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub
|
||||
BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: 24h
|
||||
|
||||
# Test upload path (inside container)
|
||||
TEST_UPLOAD_PATH: /app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
app:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- bookhoard_conversion_cache:/app/cache/kepub
|
||||
profiles:
|
||||
- tests
|
||||
|
||||
# Named Volumes
|
||||
volumes:
|
||||
postgres_data:
|
||||
|
||||
@@ -986,25 +986,39 @@ GET /opds/devices/{deviceId}/catalog?page={page}&per_page={per_page}
|
||||
- `page` (optional): Page number (default: 1)
|
||||
- `per_page` (optional): Items per page (default: 50, max: 200)
|
||||
|
||||
The feed is paginated via standard OPDS link relations. Clients (e.g. KOReader)
|
||||
walk pages by following the `rel="next"` link until it is absent. OpenSearch
|
||||
paging metadata (`totalResults`, `itemsPerPage`, `startIndex`) is also included.
|
||||
|
||||
**Response** (200 - OPDS 1.2 XML):
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom"
|
||||
xmlns:opds="http://opds-spec.org/2010/"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<id>urn:uuid:device-id</id>
|
||||
<title>Bookhoard Library</title>
|
||||
<updated>2026-02-01T12:00:00Z</updated>
|
||||
|
||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog"/>
|
||||
<link rel="search" href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/nav"/>
|
||||
<link rel="self" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=2&per_page=50"/>
|
||||
<link rel="start" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||
<link rel="first" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||
<link rel="previous" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=1&per_page=50"/>
|
||||
<link rel="next" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=3&per_page=50"/>
|
||||
<link rel="last" href="http://localhost:8765/opds/devices/kobo-id/catalog?page=37&per_page=50"/>
|
||||
<link rel="search" type="application/opensearchdescription+xml"
|
||||
href="http://localhost:8765/opds/devices/kobo-id/search"/>
|
||||
|
||||
<opensearch:totalResults>1814</opensearch:totalResults>
|
||||
<opensearch:itemsPerPage>50</opensearch:itemsPerPage>
|
||||
<opensearch:startIndex>51</opensearch:startIndex>
|
||||
|
||||
<entry>
|
||||
<id>urn:uuid:bookhoard-uuid-123</id>
|
||||
<dc:title>The Hobbit</dc:title>
|
||||
<dc:creator>J.R.R. Tolkien</dc:creator>
|
||||
<title>The Hobbit</title>
|
||||
<author><name>J.R.R. Tolkien</name></author>
|
||||
<updated>2026-02-01T10:00:00Z</updated>
|
||||
|
||||
<link href="http://localhost:8765/opds/devices/kobo-id/download/uuid-123"
|
||||
@@ -1043,10 +1057,28 @@ GET /opds/devices/{deviceId}/download/{bookId}?format={format}
|
||||
### Search OPDS Catalog
|
||||
|
||||
```http
|
||||
GET /opds/devices/{deviceId}/search?q={query}
|
||||
GET /opds/devices/{deviceId}/search # OpenSearch description
|
||||
GET /opds/devices/{deviceId}/search?q={query} # search results feed
|
||||
```
|
||||
|
||||
**Response** (200 - OPDS 1.2 XML with search results)
|
||||
When called **without** a `q` parameter, returns an OpenSearch description
|
||||
document (`application/opensearchdescription+xml`). OPDS clients fetch this to
|
||||
learn the search URL template, then substitute `{searchTerms}`:
|
||||
|
||||
```xml
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
|
||||
<ShortName>Bookhoard</ShortName>
|
||||
<Description>Search the Bookhoard library</Description>
|
||||
<InputEncoding>UTF-8</InputEncoding>
|
||||
<OutputEncoding>UTF-8</OutputEncoding>
|
||||
<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||
template="http://localhost:8765/opds/devices/kobo-id/search?q={searchTerms}"/>
|
||||
</OpenSearchDescription>
|
||||
```
|
||||
|
||||
When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with
|
||||
search results, including `opensearch:totalResults`).
|
||||
|
||||
### List Available Formats
|
||||
|
||||
|
||||
+52
-31
@@ -155,40 +155,55 @@ type LibraryVisibility struct {
|
||||
}
|
||||
|
||||
type MediaBookmarks struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
||||
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
||||
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Position pgtype.Text `db:"position" json:"position"`
|
||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
PageNumber pgtype.Int4 `db:"page_number" json:"page_number"`
|
||||
ChapterNumber pgtype.Int4 `db:"chapter_number" json:"chapter_number"`
|
||||
CfiPosition pgtype.Text `db:"cfi_position" json:"cfi_position"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Position pgtype.Text `db:"position" json:"position"`
|
||||
Notes pgtype.Text `db:"notes" json:"notes"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"`
|
||||
EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"`
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaHighlights struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
SelectionText string `db:"selection_text" json:"selection_text"`
|
||||
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||
Color pgtype.Text `db:"color" json:"color"`
|
||||
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
||||
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
||||
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
||||
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
||||
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
SelectionText string `db:"selection_text" json:"selection_text"`
|
||||
StartPosition pgtype.Text `db:"start_position" json:"start_position"`
|
||||
EndPosition pgtype.Text `db:"end_position" json:"end_position"`
|
||||
Color pgtype.Text `db:"color" json:"color"`
|
||||
NoteID pgtype.UUID `db:"note_id" json:"note_id"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"`
|
||||
PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"`
|
||||
CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"`
|
||||
CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"`
|
||||
EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"`
|
||||
EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"`
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphStart pgtype.Int4 `db:"paragraph_start" json:"paragraph_start"`
|
||||
ParagraphEnd pgtype.Int4 `db:"paragraph_end" json:"paragraph_end"`
|
||||
PanelNumber pgtype.Int4 `db:"panel_number" json:"panel_number"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||
NoteText pgtype.Text `db:"note_text" json:"note_text"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaItemFormats struct {
|
||||
@@ -304,6 +319,11 @@ type MediaNotes struct {
|
||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||
ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"`
|
||||
DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"`
|
||||
DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"`
|
||||
LastModifiedAt pgtype.Timestamptz `db:"last_modified_at" json:"last_modified_at"`
|
||||
LastModifiedSource pgtype.Text `db:"last_modified_source" json:"last_modified_source"`
|
||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type MediaRatings struct {
|
||||
@@ -380,6 +400,7 @@ type ReadingProgress struct {
|
||||
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
|
||||
CharacterOffset pgtype.Int8 `db:"character_offset" json:"character_offset"`
|
||||
Epubcfi pgtype.Text `db:"epubcfi" json:"epubcfi"`
|
||||
ContextText pgtype.Text `db:"context_text" json:"context_text"`
|
||||
Chapter pgtype.Int4 `db:"chapter" json:"chapter"`
|
||||
ChapterProgress pgtype.Float8 `db:"chapter_progress" json:"chapter_progress"`
|
||||
ViewportX pgtype.Float8 `db:"viewport_x" json:"viewport_x"`
|
||||
|
||||
@@ -30,9 +30,11 @@ type Querier interface {
|
||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
// Count unlinked books for a device
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
|
||||
// COLLECTIONS QUERIES
|
||||
// Create collection
|
||||
CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error)
|
||||
@@ -54,8 +56,10 @@ type Querier interface {
|
||||
// Libraries queries
|
||||
CreateLibrary(ctx context.Context, arg CreateLibraryParams) (Libraries, error)
|
||||
CreateMediaBookmark(ctx context.Context, arg CreateMediaBookmarkParams) (MediaBookmarks, error)
|
||||
CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error)
|
||||
// Media Highlights queries
|
||||
CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error)
|
||||
CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error)
|
||||
// Media Items queries
|
||||
CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error)
|
||||
// MEDIA ITEM FORMATS QUERIES
|
||||
@@ -63,6 +67,7 @@ type Querier interface {
|
||||
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
||||
// Media Notes queries
|
||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
|
||||
CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error)
|
||||
// OPDS TOKENS QUERIES
|
||||
// Create OPDS token
|
||||
@@ -125,6 +130,10 @@ type Querier interface {
|
||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||
// ============================================
|
||||
// ANNOTATION SERVE QUERIES
|
||||
// ============================================
|
||||
GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error)
|
||||
// Get all system config
|
||||
GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error)
|
||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||
@@ -196,8 +205,17 @@ type Querier interface {
|
||||
// LIBRARY WITH TYPE INFO QUERIES
|
||||
// ============================================================================
|
||||
GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLibraryWithTypeRow, error)
|
||||
GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (bookmarks)
|
||||
// ============================================
|
||||
GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error)
|
||||
GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksParams) ([]MediaBookmarks, error)
|
||||
GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (highlights)
|
||||
// ============================================
|
||||
GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error)
|
||||
GetMediaHighlights(ctx context.Context, arg GetMediaHighlightsParams) ([]MediaHighlights, error)
|
||||
GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error)
|
||||
GetMediaItemByFilePath(ctx context.Context, arg GetMediaItemByFilePathParams) (MediaItems, error)
|
||||
@@ -220,6 +238,10 @@ type Querier interface {
|
||||
// Get media item formats
|
||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (notes)
|
||||
// ============================================
|
||||
GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error)
|
||||
GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error)
|
||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
||||
@@ -257,6 +279,7 @@ type Querier interface {
|
||||
// System Settings queries
|
||||
GetSystemSetting(ctx context.Context, settingKey string) (string, error)
|
||||
GetSystemTimezone(ctx context.Context) (string, error)
|
||||
GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error)
|
||||
// Get universal progress for a book
|
||||
GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error)
|
||||
// Get unlinked book by ContentId
|
||||
@@ -280,6 +303,8 @@ type Querier interface {
|
||||
// Get user reading history for analytics
|
||||
GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error)
|
||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
|
||||
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
|
||||
// Check if book is in collection
|
||||
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
|
||||
@@ -302,6 +327,9 @@ type Querier interface {
|
||||
// List unresolved unlinked books with pagination
|
||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||
// Query media items by multiple identifiers with confidence scoring
|
||||
QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error)
|
||||
ReassignLibraries(ctx context.Context, arg ReassignLibrariesParams) error
|
||||
@@ -332,6 +360,12 @@ type Querier interface {
|
||||
// Set system config
|
||||
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
|
||||
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
|
||||
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
|
||||
TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error
|
||||
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
|
||||
// Update collection
|
||||
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
|
||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
@@ -355,7 +389,9 @@ type Querier interface {
|
||||
UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error)
|
||||
UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error)
|
||||
UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookmarkParams) (MediaBookmarks, error)
|
||||
UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error)
|
||||
UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error)
|
||||
UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error)
|
||||
UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error)
|
||||
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||
// Update media item format
|
||||
@@ -373,6 +409,7 @@ type Querier interface {
|
||||
UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error)
|
||||
UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error)
|
||||
UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error)
|
||||
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
|
||||
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
|
||||
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
|
||||
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
|
||||
|
||||
+1141
-18
File diff suppressed because it is too large
Load Diff
@@ -137,6 +137,14 @@ LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
ORDER BY l.created_at ASC;
|
||||
|
||||
-- name: GetVisibleLibraryMediaCounts :many
|
||||
SELECT l.id, COUNT(mi.id) as media_count
|
||||
FROM libraries l
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
||||
LEFT JOIN media_items mi ON mi.library_id = l.id
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
GROUP BY l.id;
|
||||
|
||||
-- Media Items queries
|
||||
-- name: CreateMediaItem :one
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||
@@ -346,6 +354,9 @@ WHERE role = 'admin'
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CountAdmins :one
|
||||
SELECT COUNT(*) FROM users WHERE role = 'admin';
|
||||
|
||||
-- name: ReassignLibraries :exec
|
||||
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
|
||||
|
||||
@@ -693,7 +704,7 @@ RETURNING *;
|
||||
SELECT * FROM media_notes WHERE id = $1;
|
||||
|
||||
-- name: GetMediaNotes :many
|
||||
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
||||
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateMediaNote :one
|
||||
UPDATE media_notes SET
|
||||
@@ -716,7 +727,7 @@ RETURNING *;
|
||||
SELECT * FROM media_highlights WHERE id = $1;
|
||||
|
||||
-- name: GetMediaHighlights :many
|
||||
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC;
|
||||
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateMediaHighlight :one
|
||||
UPDATE media_highlights SET
|
||||
@@ -732,6 +743,251 @@ RETURNING *;
|
||||
-- name: DeleteMediaHighlight :exec
|
||||
DELETE FROM media_highlights WHERE id = $1;
|
||||
|
||||
-- ============================================
|
||||
-- ANNOTATION SYNC QUERIES (highlights)
|
||||
-- ============================================
|
||||
|
||||
-- name: GetMediaHighlightByDedupKey :one
|
||||
SELECT * FROM media_highlights
|
||||
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
||||
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
||||
LIMIT 1;
|
||||
|
||||
-- name: CreateMediaHighlightFull :one
|
||||
INSERT INTO media_highlights (
|
||||
media_item_id, user_id, selection_text,
|
||||
start_position, end_position, color, note_text,
|
||||
percentage_start, percentage_end,
|
||||
epubcfi_start, epubcfi_end,
|
||||
chapter_reference,
|
||||
dedup_key, last_modified_at, last_modified_source,
|
||||
device_sync_data
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateMediaHighlightForSync :one
|
||||
UPDATE media_highlights SET
|
||||
selection_text = $2,
|
||||
start_position = $3,
|
||||
end_position = $4,
|
||||
color = $5,
|
||||
note_text = $6,
|
||||
percentage_start = $7,
|
||||
percentage_end = $8,
|
||||
epubcfi_start = $9,
|
||||
epubcfi_end = $10,
|
||||
chapter_reference = $11,
|
||||
last_modified_at = $12,
|
||||
last_modified_source = $13,
|
||||
device_sync_data = $14,
|
||||
updated_at = NOW()
|
||||
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
|
||||
-- name: CreateRefreshToken :one
|
||||
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
||||
@@ -793,6 +1049,7 @@ SELECT
|
||||
rp.percentage,
|
||||
rp.character_offset,
|
||||
rp.epubcfi,
|
||||
rp.context_text,
|
||||
rp.chapter,
|
||||
rp.chapter_progress,
|
||||
rp.viewport_x,
|
||||
@@ -825,6 +1082,7 @@ INSERT INTO reading_progress (
|
||||
percentage,
|
||||
character_offset,
|
||||
epubcfi,
|
||||
context_text,
|
||||
chapter,
|
||||
chapter_progress,
|
||||
viewport_x,
|
||||
@@ -842,13 +1100,14 @@ INSERT INTO reading_progress (
|
||||
last_read_at
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW(), $17, $18, NOW()
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, NOW()
|
||||
)
|
||||
ON CONFLICT (media_item_id, user_id)
|
||||
DO UPDATE SET
|
||||
percentage = EXCLUDED.percentage,
|
||||
character_offset = EXCLUDED.character_offset,
|
||||
epubcfi = EXCLUDED.epubcfi,
|
||||
context_text = EXCLUDED.context_text,
|
||||
chapter = EXCLUDED.chapter,
|
||||
chapter_progress = EXCLUDED.chapter_progress,
|
||||
viewport_x = EXCLUDED.viewport_x,
|
||||
@@ -1120,6 +1379,11 @@ INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateAutoResolvedSyncConflict :one
|
||||
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
|
||||
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSyncConflict :one
|
||||
SELECT * FROM sync_conflicts WHERE id = $1;
|
||||
|
||||
@@ -1162,6 +1426,15 @@ JOIN media_items mi ON sc.media_item_id = mi.id
|
||||
WHERE sc.user_id = $1
|
||||
ORDER BY sc.created_at DESC;
|
||||
|
||||
-- name: HasRecentConflictResolution :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM sync_conflicts
|
||||
WHERE media_item_id = $1
|
||||
AND user_id = $2
|
||||
AND resolution_status != 'unresolved'
|
||||
AND resolved_at > NOW() - INTERVAL '10 minutes'
|
||||
);
|
||||
|
||||
-- ============================================
|
||||
-- KOREADER SYNC PROTOCOL
|
||||
-- ============================================
|
||||
@@ -1210,7 +1483,7 @@ SELECT
|
||||
mh.epubcfi_start,
|
||||
mh.epubcfi_end
|
||||
FROM media_highlights mh
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2
|
||||
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
|
||||
UNION ALL
|
||||
SELECT
|
||||
mn.id,
|
||||
@@ -1226,7 +1499,7 @@ SELECT
|
||||
mn.epubcfi_location as epubcfi_start,
|
||||
NULL as epubcfi_end
|
||||
FROM media_notes mn
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2
|
||||
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: UpdateDeviceSyncTimestamp :one
|
||||
@@ -2097,9 +2370,12 @@ RETURNING *;
|
||||
|
||||
-- name: GetMediaBookmarks :many
|
||||
SELECT * FROM media_bookmarks
|
||||
WHERE media_item_id = $1 AND user_id = $2
|
||||
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- name: GetMediaBookmark :one
|
||||
SELECT * FROM media_bookmarks WHERE id = $1;
|
||||
|
||||
-- name: CreateMediaBookmark :one
|
||||
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
|
||||
+28
-17
@@ -6,6 +6,7 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/middleware"
|
||||
"bookhoard/internal/setupstatus"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -82,22 +83,22 @@ type UserProfile struct {
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
}
|
||||
|
||||
type AdminUpdateUserRequest struct {
|
||||
Username string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" validate:"omitempty,oneof=user admin"`
|
||||
Username string `json:"username,omitempty" form:"username" validate:"omitempty,min=3,max=50"`
|
||||
Email string `json:"email,omitempty" form:"email" validate:"omitempty,email"`
|
||||
FirstName string `json:"first_name,omitempty" form:"first_name" validate:"omitempty,max=100"`
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/auth/register
|
||||
@@ -190,7 +191,7 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
}
|
||||
|
||||
var userRole string
|
||||
if len(users) == 0 {
|
||||
if !adminExists {
|
||||
userRole = "admin"
|
||||
} else {
|
||||
userRole = req.Role
|
||||
@@ -232,6 +233,10 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// A new user may have changed the admin count (e.g. first user becomes
|
||||
// admin), so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
|
||||
if err := h.CreateDefaultCollectionsForUser(c.Request().Context(), user.ID); err != nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusInternalServerError, `<div class="text-red-500">Failed to create default collections</div>`)
|
||||
@@ -548,6 +553,9 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Role changes can affect the admin count, so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
}
|
||||
|
||||
// Update username (if provided)
|
||||
@@ -785,9 +793,9 @@ func (h *AuthHandler) UpdatePassword(c *echo.Context) error {
|
||||
}
|
||||
|
||||
type PasswordRequest struct {
|
||||
CurrentPassword string `json:"current_password,omitempty"`
|
||||
NewPassword string `json:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" validate:"required"`
|
||||
CurrentPassword string `json:"current_password,omitempty" form:"current_password"`
|
||||
NewPassword string `json:"new_password" form:"new_password" validate:"required,passwordcomplex"`
|
||||
ConfirmPassword string `json:"confirm_password" form:"confirm_password" validate:"required"`
|
||||
}
|
||||
|
||||
var req PasswordRequest
|
||||
@@ -934,6 +942,9 @@ func (h *AuthHandler) DeleteUser(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// Deletion may have changed the admin count, so refresh the setup-status cache.
|
||||
setupstatus.Invalidate()
|
||||
|
||||
// Create success message based on context
|
||||
var message string
|
||||
if targetUserID != "" && targetUserUUID.Bytes != currentUser.ID.Bytes {
|
||||
|
||||
@@ -28,7 +28,7 @@ func NewConflictHandler(db *database.Queries, connManager *wsync.ConnectionManag
|
||||
}
|
||||
|
||||
type ConflictResolutionRequest struct {
|
||||
Winner string `json:"winner" validate:"required,oneof=koreader kobo web manual"`
|
||||
Winner string `json:"winner" validate:"required"`
|
||||
ManualData map[string]interface{} `json:"manual_data"`
|
||||
ApplyToAll bool `json:"apply_to_all_future_conflicts"`
|
||||
Reason string `json:"reason"`
|
||||
@@ -225,7 +225,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "access denied")
|
||||
}
|
||||
|
||||
if conflict.ResolutionStatus.String != "unresolved" {
|
||||
if conflict.ResolutionStatus.String == "user_resolved" {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "conflict already resolved")
|
||||
}
|
||||
|
||||
@@ -235,8 +235,10 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
winnerData := map[string]interface{}{}
|
||||
winnerSource := req.Winner
|
||||
if req.Winner == "manual" {
|
||||
winnerData = req.ManualData
|
||||
winnerSource = "manual"
|
||||
} else {
|
||||
source, ok := conflictData[req.Winner]
|
||||
if !ok {
|
||||
@@ -251,11 +253,17 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
}
|
||||
|
||||
if conflict.ConflictType == "progress" {
|
||||
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerData); err == nil {
|
||||
if err := h.applyProgressResolution(conflict.MediaItemID, conflict.UserID, winnerSource, winnerData); err == nil {
|
||||
appliedTo["progress"] = true
|
||||
}
|
||||
}
|
||||
|
||||
if conflict.ConflictType == "annotation_highlight" || conflict.ConflictType == "annotation_bookmark" || conflict.ConflictType == "annotation_note" {
|
||||
if err := h.applyAnnotationResolution(conflict.MediaItemID, conflict.UserID, winnerData, conflict.ConflictType); err == nil {
|
||||
appliedTo["annotations"] = true
|
||||
}
|
||||
}
|
||||
|
||||
resolutionData := map[string]interface{}{
|
||||
"winner": req.Winner,
|
||||
"applied_to": appliedTo,
|
||||
@@ -286,7 +294,7 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
||||
func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||
ctx := context.Background()
|
||||
|
||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
@@ -342,7 +350,7 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "manual", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
||||
ViewportY: pgtype.Float8{},
|
||||
ScrollPositionX: pgtype.Float8{},
|
||||
ScrollPositionY: pgtype.Float8{},
|
||||
@@ -354,6 +362,143 @@ func (h *ConflictHandler) applyProgressResolution(mediaItemID pgtype.UUID, userI
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyAnnotationResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerData map[string]interface{}, conflictType string) error {
|
||||
ctx := context.Background()
|
||||
|
||||
dedupKey, _ := winnerData["dedup_key"].(string)
|
||||
if dedupKey == "" {
|
||||
return errors.New("missing dedup_key in winner data")
|
||||
}
|
||||
|
||||
switch conflictType {
|
||||
case "annotation_highlight":
|
||||
return h.applyHighlightResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
case "annotation_bookmark":
|
||||
return h.applyBookmarkResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
case "annotation_note":
|
||||
return h.applyNoteResolution(ctx, mediaItemID, userID, dedupKey, winnerData)
|
||||
default:
|
||||
return errors.New("unknown annotation conflict type")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyHighlightResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: existing.SelectionText,
|
||||
StartPosition: existing.StartPosition,
|
||||
EndPosition: existing.EndPosition,
|
||||
Color: existing.Color,
|
||||
NoteText: existing.NoteText,
|
||||
PercentageStart: existing.PercentageStart,
|
||||
PercentageEnd: existing.PercentageEnd,
|
||||
EpubcfiStart: existing.EpubcfiStart,
|
||||
EpubcfiEnd: existing.EpubcfiEnd,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["selection_text"].(string); ok {
|
||||
params.SelectionText = v
|
||||
}
|
||||
if v, ok := data["color"].(string); ok {
|
||||
params.Color = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["note_text"].(string); ok {
|
||||
params.NoteText = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["start_position"].(string); ok {
|
||||
params.StartPosition = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
if v, ok := data["end_position"].(string); ok {
|
||||
params.EndPosition = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaHighlightForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyBookmarkResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: existing.PageNumber,
|
||||
ChapterNumber: existing.ChapterNumber,
|
||||
CfiPosition: existing.CfiPosition,
|
||||
Title: existing.Title,
|
||||
Position: existing.Position,
|
||||
Notes: existing.Notes,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["title"].(string); ok {
|
||||
params.Title = v
|
||||
}
|
||||
if v, ok := data["notes"].(string); ok {
|
||||
params.Notes = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaBookmarkForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyNoteResolution(ctx context.Context, mediaItemID pgtype.UUID, userID pgtype.UUID, dedupKey string, data map[string]interface{}) error {
|
||||
existing, err := h.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := database.UpdateMediaNoteForSyncParams{
|
||||
ID: existing.ID,
|
||||
Content: existing.Content,
|
||||
Position: existing.Position,
|
||||
PercentageLocation: existing.PercentageLocation,
|
||||
CharacterStart: existing.CharacterStart,
|
||||
CharacterEnd: existing.CharacterEnd,
|
||||
EpubcfiLocation: existing.EpubcfiLocation,
|
||||
ChapterReference: existing.ChapterReference,
|
||||
ParagraphReference: existing.ParagraphReference,
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
DeviceSyncData: existing.DeviceSyncData,
|
||||
}
|
||||
|
||||
if v, ok := data["content"].(string); ok {
|
||||
params.Content = v
|
||||
}
|
||||
if v, ok := data["position"].(string); ok {
|
||||
params.Position = pgtype.Text{String: v, Valid: true}
|
||||
}
|
||||
|
||||
_, err = h.db.UpdateMediaNoteForSync(ctx, params)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) notifyDevicesOfResolution(mediaItemID pgtype.UUID, data map[string]interface{}) []string {
|
||||
devices, err := h.db.ListDevicesByType(context.Background(), "koreader")
|
||||
if err != nil {
|
||||
@@ -551,7 +696,7 @@ func (h *ConflictHandler) BulkResolveConflicts(c *echo.Context) error {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winnerData); err != nil {
|
||||
if err := h.applyResolution(conflict.MediaItemID, conflict.UserID, winningSource, winnerData); err != nil {
|
||||
results = append(results, ConflictResult{
|
||||
ConflictID: conflictIDStr,
|
||||
Status: "error",
|
||||
@@ -635,7 +780,7 @@ func (h *ConflictHandler) getHighestProgressSource(conflictData map[string]Confl
|
||||
return highestSource, highestData
|
||||
}
|
||||
|
||||
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, data map[string]interface{}) error {
|
||||
func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype.UUID, winnerSource string, data map[string]interface{}) error {
|
||||
ctx := context.Background()
|
||||
|
||||
existingProgress, err := h.db.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
@@ -690,8 +835,8 @@ func (h *ConflictHandler) applyResolution(mediaItemID pgtype.UUID, userID pgtype
|
||||
CharacterOffset: characterOffset,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
LastSyncDevice: pgtype.Text{String: "bulk_resolution", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: "bulk", Valid: true},
|
||||
LastSyncDevice: pgtype.Text{String: "conflict_resolution", Valid: true},
|
||||
LastSyncSource: pgtype.Text{String: winnerSource, Valid: true},
|
||||
ViewportY: pgtype.Float8{},
|
||||
ScrollPositionX: pgtype.Float8{},
|
||||
ScrollPositionY: pgtype.Float8{},
|
||||
|
||||
@@ -100,6 +100,10 @@ type PendingRegistration struct {
|
||||
UserID uuid.UUID
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
Approved bool
|
||||
AuthToken string
|
||||
DeviceID [16]byte
|
||||
SyncEndpoints map[string]string
|
||||
}
|
||||
|
||||
var pendingRegistrations = make(map[string]*PendingRegistration)
|
||||
@@ -173,60 +177,21 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
|
||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||
}
|
||||
|
||||
if registration.UserID == (uuid.UUID{}) {
|
||||
if registration.Approved {
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
|
||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||
Status: "pending",
|
||||
Message: "awaiting user approval",
|
||||
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||
Status: "approved",
|
||||
AuthToken: registration.AuthToken,
|
||||
DeviceID: registration.DeviceID,
|
||||
SyncEndpoints: registration.SyncEndpoints,
|
||||
})
|
||||
}
|
||||
|
||||
authToken, err := generateDeviceToken()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
||||
}
|
||||
|
||||
userUUID := registration.UserID
|
||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||
|
||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||
|
||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: registration.DeviceName,
|
||||
DeviceType: registration.DeviceType,
|
||||
DeviceIdentifier: registration.DeviceIdentifier,
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequencyMinutes: syncFreq,
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||
}
|
||||
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
|
||||
syncEndpoints := map[string]string{}
|
||||
switch registration.DeviceType {
|
||||
case "koreader":
|
||||
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
||||
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
||||
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
||||
case "kobo":
|
||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||
Status: "approved",
|
||||
AuthToken: authToken,
|
||||
DeviceID: device.ID.Bytes,
|
||||
SyncEndpoints: syncEndpoints,
|
||||
Status: "pending",
|
||||
Message: "awaiting user approval",
|
||||
ExpiresIn: int(time.Until(registration.ExpiresAt).Seconds()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -597,14 +562,63 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||
}
|
||||
|
||||
if registration.Approved {
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device already approved",
|
||||
"device_name": registration.DeviceName,
|
||||
"device_type": registration.DeviceType,
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
|
||||
authToken, err := generateDeviceToken()
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate auth token"})
|
||||
}
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||
|
||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: registration.DeviceName,
|
||||
DeviceType: registration.DeviceType,
|
||||
DeviceIdentifier: registration.DeviceIdentifier,
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequencyMinutes: syncFreq,
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||
}
|
||||
|
||||
syncEndpoints := map[string]string{}
|
||||
switch registration.DeviceType {
|
||||
case "koreader":
|
||||
syncEndpoints["progress"] = fmt.Sprintf("%s/api/sync/koreader/progress", h.cfg.BaseURL)
|
||||
syncEndpoints["metadata"] = fmt.Sprintf("%s/api/sync/koreader/metadata", h.cfg.BaseURL)
|
||||
syncEndpoints["bookmarks"] = fmt.Sprintf("%s/api/sync/koreader/bookmarks", h.cfg.BaseURL)
|
||||
case "kobo":
|
||||
syncEndpoints["markup"] = fmt.Sprintf("%s/api/sync/kobo/markup", h.cfg.BaseURL)
|
||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||
}
|
||||
|
||||
registration.UserID = userUUID
|
||||
registration.Approved = true
|
||||
registration.AuthToken = authToken
|
||||
registration.DeviceID = device.ID.Bytes
|
||||
registration.SyncEndpoints = syncEndpoints
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device approved successfully",
|
||||
"device_name": registration.DeviceName,
|
||||
"device_type": registration.DeviceType,
|
||||
"registration_id": registrationID,
|
||||
"approved": true, // Fixed: Add confirmation field for test compatibility
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -624,15 +638,9 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
||||
}
|
||||
|
||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||
userID := c.Get("user_id").(string)
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
registrations := []map[string]interface{}{}
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||
if reg.UserID == (uuid.UUID{}) {
|
||||
registrations = append(registrations, map[string]interface{}{
|
||||
"registration_id": reg.RegistrationID,
|
||||
"device_name": reg.DeviceName,
|
||||
@@ -640,7 +648,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
||||
"device_identifier": reg.DeviceIdentifier,
|
||||
"expires_at": reg.ExpiresAt,
|
||||
"created_at": reg.CreatedAt,
|
||||
"is_approved": reg.UserID != (uuid.UUID{}),
|
||||
"is_approved": false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+313
-64
@@ -3,7 +3,9 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -15,9 +17,11 @@ import (
|
||||
)
|
||||
|
||||
type KoboHandler struct {
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
progressSvc *wsync.ProgressService
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
libraryService LibraryPathResolver
|
||||
}
|
||||
|
||||
func NewKoboHandler(db *database.Queries, connManager *wsync.ConnectionManager) *KoboHandler {
|
||||
@@ -28,6 +32,14 @@ func (h *KoboHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
h.progressSvc = svc
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
h.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *KoboHandler) SetLibraryService(svc LibraryPathResolver) {
|
||||
h.libraryService = svc
|
||||
}
|
||||
|
||||
// mapContentIdToBookhoardUUID maps Kobo ContentId to Bookhoard UUID with multiple fallback strategies
|
||||
// Enhanced Kobo Sync - ContentId Mapping Logic
|
||||
func (h *KoboHandler) mapContentIdToBookhoardUUID(ctx *echo.Context, contentId string, deviceID uuid.UUID) (uuid.UUID, error, string) {
|
||||
@@ -239,9 +251,16 @@ type KoboInitResponse struct {
|
||||
}
|
||||
|
||||
type KoboSyncStatus struct {
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
Status string `json:"Status"`
|
||||
MarkupsSynced int `json:"MarkupsSynced"`
|
||||
BookmarksSynced int `json:"BookmarksSynced"`
|
||||
DeletedAnnotations []KoboDeletedAnnotation `json:"DeletedAnnotations,omitempty"`
|
||||
}
|
||||
|
||||
type KoboDeletedAnnotation struct {
|
||||
ContentId string `json:"ContentId"`
|
||||
BookmarkId string `json:"BookmarkId"`
|
||||
Type string `json:"Type"`
|
||||
}
|
||||
|
||||
type KoboServerSyncData struct {
|
||||
@@ -305,7 +324,7 @@ func (h *KoboHandler) Initialization(c *echo.Context) error {
|
||||
}
|
||||
|
||||
bookmarkCount := 0
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: item.ID.Bytes, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -397,6 +416,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
markupsSynced := 0
|
||||
bookmarksSynced := 0
|
||||
unlinkedBooks := 0
|
||||
processedBooks := make(map[pgtype.UUID]string)
|
||||
|
||||
for _, readingSync := range req.ReadingSync {
|
||||
bookhoardUUID, err, _ := h.mapContentIdToBookhoardUUID(c, readingSync.ContentId, deviceUUID)
|
||||
@@ -406,8 +426,23 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
processedBooks[pgMediaUUID] = readingSync.ContentId
|
||||
percentage := readingSync.PercentRead / 100.0
|
||||
|
||||
// Kobo only sends a percentage. For fixed-layout & comic formats the page
|
||||
// index is the canonical locator, so derive it from the known page count.
|
||||
var currentPage, totalPages *int
|
||||
if mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID); mErr == nil {
|
||||
if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) || mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
||||
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
||||
total := int(mediaItem.PageCount.Int32)
|
||||
page := wsync.PercentageToPage(percentage, total)
|
||||
currentPage = &page
|
||||
totalPages = &total
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if h.progressSvc != nil {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
@@ -415,6 +450,8 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Percentage: &percentage,
|
||||
CurrentPage: currentPage,
|
||||
TotalPages: totalPages,
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
@@ -443,29 +480,72 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgMediaUUID := pgtype.UUID{Bytes: bookhoardUUID, Valid: true}
|
||||
processedBooks[pgMediaUUID] = bookmarkSync.ContentId
|
||||
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: bookmarkSync.BookmarkId,
|
||||
EndPosition: bookmarkSync.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
NoteText: bookmarkSync.BookmarkTitle,
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "bookmark":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmarkSync.BookmarkText,
|
||||
Position: bookmarkSync.BookmarkId,
|
||||
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "last-read-place":
|
||||
if bookmarkSync.BookmarkId != "" {
|
||||
@@ -479,6 +559,23 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
chapter := bookmarkSync.Chapter
|
||||
chapterProgress := 0.5
|
||||
|
||||
var convertedCFI *string
|
||||
var contextText *string
|
||||
|
||||
if epubcfi != "" && h.libraryService != nil {
|
||||
mediaItem, mErr := h.db.GetMediaItem(c.Request().Context(), pgMediaUUID)
|
||||
if mErr == nil {
|
||||
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
||||
if formatGroup != wsync.FormatGroupFixedLayout && formatGroup != wsync.FormatGroupComicArchive {
|
||||
convertedCFI, contextText = h.convertKoboCFIToStandard(c, mediaItem, epubcfi)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if convertedCFI != nil {
|
||||
epubcfi = *convertedCFI
|
||||
}
|
||||
|
||||
if h.progressSvc != nil {
|
||||
_, err = h.progressSvc.SaveProgress(c.Request().Context(), wsync.SaveProgressRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
@@ -486,6 +583,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
Source: "kobo",
|
||||
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
||||
Epubcfi: &epubcfi,
|
||||
ContextText: contextText,
|
||||
Chapter: &chapter,
|
||||
ChapterProgress: &chapterProgress,
|
||||
DeviceType: "kobo",
|
||||
@@ -524,6 +622,32 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
BookmarksSynced: bookmarksSynced,
|
||||
}
|
||||
|
||||
if h.annotationSvc != nil && len(processedBooks) > 0 {
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-wsync.TombstoneTTL), Valid: true}
|
||||
for mediaItemID, contentId := range processedBooks {
|
||||
tombstones, _ := h.db.GetTombstonedAnnotationsForBook(c.Request().Context(), database.GetTombstonedAnnotationsForBookParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
DeletedAt: cutoff,
|
||||
})
|
||||
for _, ts := range tombstones {
|
||||
var dd map[string]interface{}
|
||||
if len(ts.DeviceSyncData) > 0 {
|
||||
json.Unmarshal(ts.DeviceSyncData, &dd)
|
||||
}
|
||||
bookmarkID, _ := dd["bookmark_id"].(string)
|
||||
if bookmarkID == "" {
|
||||
continue
|
||||
}
|
||||
response.DeletedAnnotations = append(response.DeletedAnnotations, KoboDeletedAnnotation{
|
||||
ContentId: contentId,
|
||||
BookmarkId: bookmarkID,
|
||||
Type: ts.AnnotationType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Include unlinked books count if any
|
||||
if unlinkedBooks > 0 {
|
||||
// For now, just log it. In production, this should trigger an alert
|
||||
@@ -566,25 +690,67 @@ func (h *KoboHandler) Bookmark(c *echo.Context) error {
|
||||
switch bookmarkSync.BookmarkType {
|
||||
case "annotation":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: bookmarkSync.BookmarkId,
|
||||
EndPosition: bookmarkSync.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
NoteText: bookmarkSync.BookmarkTitle,
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmarkSync.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
case "bookmark":
|
||||
if bookmarkSync.BookmarkText != "" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"bookmark_id": bookmarkSync.BookmarkId,
|
||||
"date_created": bookmarkSync.DateCreated,
|
||||
})
|
||||
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmarkSync.BookmarkText,
|
||||
Position: bookmarkSync.BookmarkId,
|
||||
ChapterNumber: int32(bookmarkSync.Chapter),
|
||||
Source: "kobo",
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSynced++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmarkSync.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmarkSync.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSynced++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -716,36 +882,79 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
|
||||
for _, bookmark := range syncData.Bookmarks {
|
||||
if bookmark.BookmarkType == "bookmark" {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Title: bookmark.BookmarkText,
|
||||
Position: bookmark.BookmarkId,
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
bookmarksSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.BookmarkText,
|
||||
Position: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
})
|
||||
bookmarksSent++
|
||||
}
|
||||
} else if bookmark.BookmarkType == "annotation" {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: bookmark.BookmarkId,
|
||||
EndPosition: bookmark.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: bookmark.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: bookmark.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, highlight := range syncData.Highlights {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
if h.annotationSvc != nil {
|
||||
result, err := h.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: highlight.BookmarkId,
|
||||
EndPosition: highlight.BookmarkId,
|
||||
Color: "#ffff00",
|
||||
Source: "kobo",
|
||||
})
|
||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||
highlightsSent++
|
||||
}
|
||||
} else {
|
||||
h.db.CreateMediaHighlight(c.Request().Context(), database.CreateMediaHighlightParams{
|
||||
MediaItemID: pgMediaUUID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: highlight.BookmarkText,
|
||||
StartPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
EndPosition: pgtype.Text{String: highlight.BookmarkId, Valid: true},
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
highlightsSent++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,3 +971,43 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
HighlightsSent: highlightsSent,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *KoboHandler) convertKoboCFIToStandard(c *echo.Context, mediaItem database.MediaItems, kepubCFI string) (*string, *string) {
|
||||
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: KEPUB→CFI failed to resolve EPUB path: %v", err)
|
||||
return nil, nil
|
||||
}
|
||||
if epubPath == "" {
|
||||
log.Printf("Bookhoard: KEPUB→CFI resolved empty EPUB path for %s", mediaItem.FilePath)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
kepubFormat, err := h.db.GetMediaItemFormatByType(c.Request().Context(), database.GetMediaItemFormatByTypeParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItem.ID.Bytes, Valid: true},
|
||||
FormatType: "kepub",
|
||||
})
|
||||
if err != nil || !kepubFormat.FilePath.Valid {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
converter := wsync.NewKEPUBCFIConverter(epubPath, kepubFormat.FilePath.String)
|
||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.0, "")
|
||||
if err != nil {
|
||||
log.Printf("Bookhoard: KEPUB→CFI conversion error: %v", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var cfi *string
|
||||
if result.CFI != "" {
|
||||
cfi = &result.CFI
|
||||
log.Printf("Bookhoard: KEPUB→CFI converted (precision=%s)", result.Precision)
|
||||
}
|
||||
|
||||
var ctx *string
|
||||
if result.ExtractedContext != "" {
|
||||
ctx = &result.ExtractedContext
|
||||
}
|
||||
|
||||
return cfi, ctx
|
||||
}
|
||||
|
||||
+491
-67
@@ -3,7 +3,10 @@ package handlers
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
wsync "bookhoard/internal/sync"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@@ -13,10 +16,16 @@ import (
|
||||
)
|
||||
|
||||
type KOReaderHandler struct {
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
queue *wsync.SyncQueueProcessor
|
||||
progressSvc *wsync.ProgressService
|
||||
db *database.Queries
|
||||
connManager *wsync.ConnectionManager
|
||||
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 {
|
||||
@@ -27,6 +36,31 @@ 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 {
|
||||
LibraryID *string `json:"library_id,omitempty"`
|
||||
Books []KOReaderBookProgress `json:"books" validate:"required"`
|
||||
@@ -46,11 +80,12 @@ type KOReaderBookProgress struct {
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
ContextText *string `json:"context_text,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderDeviceInfo struct {
|
||||
@@ -99,11 +134,18 @@ type KOReaderNote struct {
|
||||
}
|
||||
|
||||
type KOReaderSyncResponse struct {
|
||||
SyncStatus string `json:"sync_status"`
|
||||
BooksSynced int `json:"books_synced"`
|
||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
DeviceUpdated bool `json:"device_updated"`
|
||||
SyncStatus string `json:"sync_status"`
|
||||
BooksSynced int `json:"books_synced"`
|
||||
BookResults []KOReaderBookSyncResult `json:"book_results,omitempty"`
|
||||
Conflicts []KOReaderConflict `json:"conflicts,omitempty"`
|
||||
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 {
|
||||
@@ -124,19 +166,22 @@ type KOReaderMetadata struct {
|
||||
}
|
||||
|
||||
type KOReaderProgressData struct {
|
||||
Percentage float64 `json:"percentage"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Character *int64 `json:"character,omitempty"`
|
||||
Epubcfi *string `json:"epubcfi,omitempty"`
|
||||
KoreaderXPointer *string `json:"koreader_xpointer,omitempty"`
|
||||
Chapter *int `json:"chapter,omitempty"`
|
||||
ChapterProgress *float64 `json:"chapter_progress,omitempty"`
|
||||
Page *int `json:"page,omitempty"`
|
||||
TotalPages *int `json:"total_pages,omitempty"`
|
||||
}
|
||||
|
||||
type KOReaderAnnotations struct {
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,omitempty"`
|
||||
Bookmarks []KOReaderBookmark `json:"bookmarks,omitempty"`
|
||||
Highlights []KOReaderHighlight `json:"highlights,omitempty"`
|
||||
Notes []KOReaderNote `json:"notes,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 {
|
||||
@@ -187,17 +232,28 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
||||
|
||||
booksSynced := 0
|
||||
conflicts := []KOReaderConflict{}
|
||||
bookResults := []KOReaderBookSyncResult{}
|
||||
|
||||
for _, book := range req.Books {
|
||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, pgUserID, book)
|
||||
if !mediaItemID.Valid {
|
||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||
SHA256: book.SHA256,
|
||||
Synced: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
err := h.updateProgressForBook(c, device.ID, pgUserID, mediaItemID, book)
|
||||
if err == nil {
|
||||
synced := err == nil
|
||||
if synced {
|
||||
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)
|
||||
@@ -211,6 +267,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
||||
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
||||
SyncStatus: "accepted",
|
||||
BooksSynced: booksSynced,
|
||||
BookResults: bookResults,
|
||||
Conflicts: conflicts,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
DeviceUpdated: true,
|
||||
@@ -220,6 +277,7 @@ func (h *KOReaderHandler) SyncProgress(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, KOReaderSyncResponse{
|
||||
SyncStatus: "completed",
|
||||
BooksSynced: booksSynced,
|
||||
BookResults: bookResults,
|
||||
Conflicts: conflicts,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
DeviceUpdated: true,
|
||||
@@ -346,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 {
|
||||
booksEnqueued := 0
|
||||
bookResults := []KOReaderBookSyncResult{}
|
||||
|
||||
for _, book := range req.Books {
|
||||
mediaItemID, _ := h.resolveBookToMediaItem(c, device.ID, userID, book)
|
||||
if !mediaItemID.Valid {
|
||||
bookResults = append(bookResults, KOReaderBookSyncResult{
|
||||
SHA256: book.SHA256,
|
||||
Synced: false,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
err := h.enqueueProgressForBook(c, device.ID, userID, mediaItemID, book)
|
||||
if err == nil {
|
||||
synced := err == nil
|
||||
if synced {
|
||||
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)
|
||||
@@ -366,11 +436,11 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database.
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusAccepted, map[string]interface{}{
|
||||
"sync_status": "checkpoint_enqueued",
|
||||
"books_enqueued": booksEnqueued,
|
||||
"message": "Sync will be processed in the background",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
return c.JSON(http.StatusAccepted, KOReaderSyncResponse{
|
||||
SyncStatus: "checkpoint_enqueued",
|
||||
BooksSynced: booksEnqueued,
|
||||
BookResults: bookResults,
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -385,6 +455,7 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
||||
UserID: userID,
|
||||
Percentage: book.Percentage,
|
||||
Epubcfi: book.Epubcfi,
|
||||
ContextText: book.ContextText,
|
||||
Chapter: book.Chapter,
|
||||
Character: book.Character,
|
||||
Page: book.Page,
|
||||
@@ -396,6 +467,102 @@ func (h *KOReaderHandler) enqueueProgressForBook(c *echo.Context, deviceID pgtyp
|
||||
return h.queue.EnqueueProgress(update)
|
||||
}
|
||||
|
||||
func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID, userID, mediaItemID pgtype.UUID, book KOReaderBookProgress) {
|
||||
if h.annotationSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, hl := range book.Highlights {
|
||||
startPos := hl.Pos0
|
||||
endPos := hl.Pos1
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos)
|
||||
|
||||
pctStart := 0.0
|
||||
if hl.Percentage != nil {
|
||||
pctStart = *hl.Percentage
|
||||
}
|
||||
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": hl.Datetime,
|
||||
"pos0": hl.Pos0,
|
||||
"pos1": hl.Pos1,
|
||||
"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()
|
||||
|
||||
@@ -406,13 +573,67 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
}
|
||||
|
||||
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: book.Epubcfi,
|
||||
Epubcfi: epubcfi,
|
||||
ContextText: book.ContextText,
|
||||
Chapter: book.Chapter,
|
||||
CharacterOffset: book.Character,
|
||||
CurrentPage: book.Page,
|
||||
@@ -423,7 +644,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
}
|
||||
|
||||
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := h.db.UpdateUniversalProgress(ctx, database.UpdateUniversalProgressParams{
|
||||
@@ -459,6 +684,8 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
},
|
||||
)
|
||||
|
||||
h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -523,8 +750,19 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
Percentage: progress.Percentage.Float64,
|
||||
}
|
||||
|
||||
if progress.Epubcfi.Valid {
|
||||
progressData.Epubcfi = &progress.Epubcfi.String
|
||||
// CFI/xpointer are meaningless for image-based fixed-layout content; the
|
||||
// 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 {
|
||||
progress := int(progress.Chapter.Int32)
|
||||
@@ -547,7 +785,7 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
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,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -560,13 +798,29 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
|
||||
for _, ann := range annotations {
|
||||
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,
|
||||
Pos0: ann.StartPosition.String,
|
||||
Pos1: ann.EndPosition.String,
|
||||
Pos0: pos0,
|
||||
Pos1: pos1,
|
||||
Color: ann.Color.String,
|
||||
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" {
|
||||
annotationsResponse.Notes = append(annotationsResponse.Notes, KOReaderNote{
|
||||
Text: ann.SelectionText,
|
||||
@@ -576,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(-wsync.TombstoneTTL), 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"
|
||||
if progress.LastSyncTimestamp.Valid {
|
||||
lastSync = progress.LastSyncTimestamp.Time.Format(time.RFC3339)
|
||||
@@ -593,6 +893,55 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
||||
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 {
|
||||
device := c.Get("device").(database.Devices)
|
||||
userID := device.UserID.Bytes
|
||||
@@ -632,7 +981,7 @@ func (h *KOReaderHandler) GetLibrary(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
annotations, _ := h.db.GetAnnotationsForBook(c.Request().Context(), database.GetAnnotationsForBookParams{
|
||||
annotations, _ := h.db.GetActiveAnnotationsForBook(c.Request().Context(), database.GetActiveAnnotationsForBookParams{
|
||||
MediaItemID: pgItemUUID,
|
||||
UserID: pgUserID,
|
||||
})
|
||||
@@ -732,15 +1081,36 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: bookmark.Text,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": bookmark.Datetime,
|
||||
"pos0": bookmark.Pos0,
|
||||
"page": bookmark.Page,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
bookmarksSynced++
|
||||
result, err := h.annotationSvc.SaveBookmark(ctx, wsync.SaveBookmarkRequest{
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,15 +1132,35 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
position = fmt.Sprintf("page:%d", note.Page)
|
||||
}
|
||||
|
||||
_, err := h.db.CreateMediaNote(ctx, database.CreateMediaNoteParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: pgUserID,
|
||||
Content: note.Notes,
|
||||
Position: pgtype.Text{String: position, Valid: position != ""},
|
||||
})
|
||||
if h.annotationSvc != nil {
|
||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||
"datetime": note.Datetime,
|
||||
"pos0": note.Pos0,
|
||||
"page": note.Page,
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
notesSynced++
|
||||
result, err := h.annotationSvc.SaveNote(ctx, wsync.SaveNoteRequest{
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -797,17 +1187,51 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
||||
color = highlight.Color
|
||||
}
|
||||
|
||||
_, 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 h.annotationSvc != nil {
|
||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1)
|
||||
|
||||
if err == nil {
|
||||
highlightsSynced++
|
||||
pctStart := 0.0
|
||||
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++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
-12
@@ -19,6 +19,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -136,6 +137,7 @@ type MediaHandler struct {
|
||||
libraryService *services.LibraryService
|
||||
searchService *services.SearchService
|
||||
progressSvc *wsync.ProgressService
|
||||
annotationSvc *wsync.AnnotationService
|
||||
}
|
||||
|
||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, worker ...*services.Worker) *MediaHandler {
|
||||
@@ -154,6 +156,10 @@ func (mh *MediaHandler) SetProgressService(svc *wsync.ProgressService) {
|
||||
mh.progressSvc = svc
|
||||
}
|
||||
|
||||
func (mh *MediaHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
||||
mh.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (h *MediaHandler) DownloadBook(c *echo.Context) error {
|
||||
bookUUID, err := uuid.Parse(c.Param("uuid"))
|
||||
if err != nil {
|
||||
@@ -975,6 +981,7 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
CurrentPage *int32 `json:"current_page"`
|
||||
TotalPages *int32 `json:"total_pages"`
|
||||
Epubcfi *string `json:"epubcfi"`
|
||||
ContextText *string `json:"context_text"`
|
||||
Percentage *float64 `json:"percentage"`
|
||||
Chapter *int `json:"chapter"`
|
||||
ChapterProgress *float64 `json:"chapter_progress"`
|
||||
@@ -996,6 +1003,7 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
DeviceID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Percentage: req.Percentage,
|
||||
Epubcfi: req.Epubcfi,
|
||||
ContextText: req.ContextText,
|
||||
CharacterOffset: req.CharacterOffset,
|
||||
Chapter: req.Chapter,
|
||||
ChapterProgress: req.ChapterProgress,
|
||||
@@ -1018,6 +1026,16 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
|
||||
saveReq.TotalPages = &tp
|
||||
}
|
||||
|
||||
if req.Percentage != nil && *req.Percentage < 0.005 {
|
||||
existing, err := mh.db.GetUniversalProgress(c.Request().Context(), database.GetUniversalProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
})
|
||||
if err == nil && existing.Percentage.Valid && existing.Percentage.Float64 > 0.01 {
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "ignored"})
|
||||
}
|
||||
}
|
||||
|
||||
result, err := mh.progressSvc.SaveProgress(c.Request().Context(), saveReq)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
@@ -1376,14 +1394,31 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
note, err := mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
var note database.MediaNotes
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveNote(c.Request().Context(), wsync.SaveNoteRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: req.Position,
|
||||
Source: "web",
|
||||
ModifiedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
note = result.Note
|
||||
} else {
|
||||
var err error
|
||||
note, err = mh.db.CreateMediaNote(c.Request().Context(), database.CreateMediaNoteParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Content: req.Content,
|
||||
Position: pgtype.Text{String: req.Position, Valid: req.Position != ""},
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusCreated, note)
|
||||
@@ -1444,7 +1479,11 @@ func (mh *MediaHandler) DeleteMediaNote(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid note id"})
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
if mh.annotationSvc != nil {
|
||||
err = mh.annotationSvc.TombstoneNoteByID(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
} else {
|
||||
err = mh.db.DeleteMediaNote(c.Request().Context(), pgtype.UUID{Bytes: noteUUID, Valid: true})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1513,9 +1552,29 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
|
||||
color = req.Color
|
||||
}
|
||||
|
||||
pgMediaID := pgtype.UUID{Bytes: mediaUUID, Valid: true}
|
||||
pgUserID := pgtype.UUID{Bytes: userUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
result, err := mh.annotationSvc.SaveHighlight(c.Request().Context(), wsync.SaveHighlightRequest{
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: req.StartPosition,
|
||||
EndPosition: req.EndPosition,
|
||||
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{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
MediaItemID: pgMediaID,
|
||||
UserID: pgUserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgtype.Text{String: req.StartPosition, Valid: true},
|
||||
EndPosition: pgtype.Text{String: req.EndPosition, Valid: true},
|
||||
@@ -1601,7 +1660,16 @@ func (mh *MediaHandler) DeleteMediaHighlight(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid highlight id"})
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgtype.UUID{Bytes: highlightUUID, Valid: true})
|
||||
pgHighlightID := pgtype.UUID{Bytes: highlightUUID, Valid: true}
|
||||
|
||||
if mh.annotationSvc != nil {
|
||||
if err := mh.annotationSvc.TombstoneHighlightByID(c.Request().Context(), pgHighlightID, "web"); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
err = mh.db.DeleteMediaHighlight(c.Request().Context(), pgHighlightID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1723,6 +1791,10 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
"results": []interface{}{},
|
||||
})
|
||||
}
|
||||
for i := range results {
|
||||
resolved := utils.ResolveMediaURL(results[i].LibraryID, results[i].CoverImagePath)
|
||||
results[i].CoverImagePath = pgtype.Text{String: resolved, Valid: resolved != ""}
|
||||
}
|
||||
return c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
|
||||
+171
-33
@@ -49,6 +49,109 @@ func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) {
|
||||
return baseURL.Value, 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)
|
||||
}
|
||||
|
||||
// self reflects the current page; start/first point to the first page
|
||||
feed.AddLink(pagedURL(pageNum), catalogMediaType, "self")
|
||||
feed.AddLink(pagedURL(1), catalogMediaType, "start")
|
||||
feed.AddLink(pagedURL(1), catalogMediaType, "first")
|
||||
if totalPages > 0 {
|
||||
feed.AddLink(pagedURL(totalPages), catalogMediaType, "last")
|
||||
}
|
||||
if pageNum > 1 {
|
||||
feed.AddLink(pagedURL(pageNum-1), catalogMediaType, "previous")
|
||||
}
|
||||
if pageNum < totalPages {
|
||||
feed.AddLink(pagedURL(pageNum+1), catalogMediaType, "next")
|
||||
}
|
||||
|
||||
feed.SetPagination(totalItems, perPageNum, startIdx+1)
|
||||
}
|
||||
|
||||
// resolveMimeType returns the mime type for a media item, preferring the stored
|
||||
// mime_type, then format_mimetype, and finally falling back to EPUB.
|
||||
func resolveMimeType(mime, formatMime pgtype.Text) string {
|
||||
if mime.Valid && mime.String != "" {
|
||||
return mime.String
|
||||
}
|
||||
if formatMime.Valid && formatMime.String != "" {
|
||||
return formatMime.String
|
||||
}
|
||||
return "application/epub+zip"
|
||||
}
|
||||
|
||||
// isComicArchive reports whether a format group represents a comic/manga
|
||||
// archive (cbz/cbr/cb7/cbt). Comic archives are served in their native format
|
||||
// and should not be offered as EPUB/KEPUB/PDF conversions.
|
||||
func isComicArchive(formatGroup string) bool {
|
||||
return strings.EqualFold(formatGroup, "comic_archive")
|
||||
}
|
||||
|
||||
// formatLabelFromPath derives a short format label (e.g. "epub", "cbz") from a
|
||||
// file path's extension, defaulting to "epub" when it cannot be determined.
|
||||
func formatLabelFromPath(path string) string {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".epub":
|
||||
return "epub"
|
||||
case ".pdf":
|
||||
return "pdf"
|
||||
case ".cbz":
|
||||
return "cbz"
|
||||
case ".cbr":
|
||||
return "cbr"
|
||||
case ".cb7":
|
||||
return "cb7"
|
||||
case ".cbt":
|
||||
return "cbt"
|
||||
case ".mobi":
|
||||
return "mobi"
|
||||
case ".azw", ".azw3":
|
||||
return "azw3"
|
||||
case ".txt":
|
||||
return "txt"
|
||||
case "":
|
||||
return "epub"
|
||||
default:
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
||||
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
deviceID := c.Param("deviceId")
|
||||
@@ -139,13 +242,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
"Bookhoard Library",
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
// Feed links, including OPDS pagination links (first/previous/next/last) and
|
||||
// OpenSearch paging metadata (totalResults/itemsPerPage/startIndex).
|
||||
token := h.getAuthToken(c)
|
||||
catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
||||
|
||||
searchURL := fmt.Sprintf("%s/opds/devices/%s/search", opdsBaseURL, deviceID)
|
||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "search")
|
||||
// OpenSearch: the search link points to an OpenSearch description document
|
||||
// (served by the same /search endpoint when no query is supplied) so that
|
||||
// OPDS clients like KOReader can discover how to formulate search requests.
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||
|
||||
// Add entries
|
||||
for _, item := range allItems {
|
||||
@@ -170,16 +277,21 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
entry.SetSummary(item.Description.String)
|
||||
}
|
||||
|
||||
// Add acquisition links
|
||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||
// Add acquisition link using the item's real mime type
|
||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||
|
||||
// Add format variants
|
||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
// Only offer reflowable conversions (kepub/pdf) for ebooks; comic
|
||||
// archives are served as-is in their native format.
|
||||
if !isComicArchive(item.FormatGroup) {
|
||||
if device.DeviceType == "kobo" {
|
||||
kepubURL := downloadURL + "&format=kepub"
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
}
|
||||
|
||||
pdfURL := fmt.Sprintf("%s?format=pdf", downloadURL)
|
||||
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||
pdfURL := downloadURL + "&format=pdf"
|
||||
entry.AddAlternateLink(pdfURL, "application/pdf")
|
||||
}
|
||||
|
||||
// Add canonical identifier
|
||||
entry.SetIdentifier(bookUUID)
|
||||
@@ -213,16 +325,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
// SearchDeviceCatalog searches the OPDS catalog for a device
|
||||
// SearchDeviceCatalog searches the OPDS catalog for a device.
|
||||
//
|
||||
// When no "q" query parameter is supplied it returns an OpenSearch description
|
||||
// document (application/opensearchdescription+xml) so that OPDS clients such as
|
||||
// KOReader can discover the search URL template (which contains the
|
||||
// {searchTerms} placeholder). When "q" is supplied it returns an OPDS
|
||||
// acquisition feed of matching books.
|
||||
func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
deviceID := c.Param("deviceId")
|
||||
|
||||
query := c.QueryParam("q")
|
||||
|
||||
if query == "" {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Missing search query"))
|
||||
}
|
||||
|
||||
// Get base URLs
|
||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||
if err != nil {
|
||||
@@ -243,12 +356,30 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
// Get user's visible libraries
|
||||
userID := device.UserID.Bytes
|
||||
|
||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||
}
|
||||
|
||||
token := h.getAuthToken(c)
|
||||
|
||||
// No query: serve the OpenSearch description document so clients can learn
|
||||
// the search template (contains the {searchTerms} placeholder).
|
||||
if query == "" {
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
||||
desc := opds.NewSearchDescription(
|
||||
"Bookhoard",
|
||||
"Search the Bookhoard library",
|
||||
searchURL,
|
||||
)
|
||||
xmlString, err := desc.GenerateXMLString()
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate search description"))
|
||||
}
|
||||
c.Response().Header().Set("Content-Type", "application/opensearchdescription+xml")
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
// Search media items
|
||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
@@ -267,11 +398,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(catalogURL, catalogMediaType, "start")
|
||||
|
||||
searchURL := fmt.Sprintf("%s/opds/devices/%s/search?q=%s", opdsBaseURL, deviceID, query)
|
||||
feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token)
|
||||
feed.AddLink(searchURL, catalogMediaType, "self")
|
||||
|
||||
// OpenSearch paging metadata (search results are a single page)
|
||||
feed.SetPagination(len(allItems), len(allItems), 1)
|
||||
|
||||
// Add entries (same as catalog)
|
||||
userUUID := uuid.UUID(userID)
|
||||
@@ -296,11 +430,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
entry.SetSummary(item.Description.String)
|
||||
}
|
||||
|
||||
downloadURL := fmt.Sprintf("%s/opds/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID)
|
||||
entry.AddAcquisitionLink(downloadURL, "application/epub+zip")
|
||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||
|
||||
kepubURL := fmt.Sprintf("%s?format=kepub", downloadURL)
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
// Only offer kepub conversion for ebooks; comic archives are served
|
||||
// as-is in their native format.
|
||||
if !isComicArchive(item.FormatGroup) && device.DeviceType == "kobo" {
|
||||
kepubURL := downloadURL + "&format=kepub"
|
||||
entry.AddAlternateLink(kepubURL, "application/vnd.kobo+xml+zip")
|
||||
}
|
||||
|
||||
entry.SetIdentifier(bookUUID)
|
||||
|
||||
@@ -599,7 +737,7 @@ func (h *OPDSHandler) GetDeviceNavigation(c *echo.Context) error {
|
||||
)
|
||||
|
||||
// Add feed links
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start")
|
||||
feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self")
|
||||
|
||||
@@ -682,14 +820,14 @@ func (h *OPDSHandler) ListFormats(c *echo.Context) error {
|
||||
|
||||
formatList := []FormatInfo{}
|
||||
|
||||
// Add EPUB format (always available if media item exists)
|
||||
// Add the primary/native format (always available if media item exists)
|
||||
fileSize := int64(0)
|
||||
if mediaItem.FileSize.Valid {
|
||||
fileSize = mediaItem.FileSize.Int64
|
||||
}
|
||||
|
||||
formatList = append(formatList, FormatInfo{
|
||||
FormatType: "epub",
|
||||
FormatType: formatLabelFromPath(mediaItem.FilePath),
|
||||
FilePath: mediaItem.FilePath,
|
||||
FileSha256: func() string {
|
||||
if mediaItem.FileSha256.Valid {
|
||||
@@ -791,7 +929,7 @@ func (h *OPDSHandler) RegisterOPDS(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create OPDS token"})
|
||||
}
|
||||
|
||||
catalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
catalogURL := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID)
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"opds_token": map[string]interface{}{
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bookhoard/internal/opds"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// rels collects the rel attributes of all links currently on the feed.
|
||||
func rels(feed *opds.Feed) []string {
|
||||
out := make([]string, 0, len(feed.Links))
|
||||
for _, l := range feed.Links {
|
||||
out = append(out, l.Rel)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsRel(feed *opds.Feed, rel string) bool {
|
||||
for _, l := range feed.Links {
|
||||
if l.Rel == rel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_MiddlePage(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
// 1814 items, 50 per page => 37 pages; on page 2
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 2, 50, 1814, "tok")
|
||||
|
||||
assert.True(t, containsRel(feed, "self"))
|
||||
assert.True(t, containsRel(feed, "start"))
|
||||
assert.True(t, containsRel(feed, "first"))
|
||||
assert.True(t, containsRel(feed, "last"))
|
||||
assert.True(t, containsRel(feed, "previous"), "middle page must have previous")
|
||||
assert.True(t, containsRel(feed, "next"), "middle page must have next")
|
||||
|
||||
// self must point to the current page
|
||||
var selfHref string
|
||||
for _, l := range feed.Links {
|
||||
if l.Rel == "self" {
|
||||
selfHref = l.Href
|
||||
}
|
||||
}
|
||||
assert.Contains(t, selfHref, "page=2&per_page=50")
|
||||
assert.Contains(t, selfHref, "token=tok")
|
||||
|
||||
// next must advance the page
|
||||
var nextHref string
|
||||
for _, l := range feed.Links {
|
||||
if l.Rel == "next" {
|
||||
nextHref = l.Href
|
||||
}
|
||||
}
|
||||
assert.Contains(t, nextHref, "page=3")
|
||||
|
||||
// OpenSearch metadata
|
||||
require.NotNil(t, feed.TotalResults)
|
||||
assert.Equal(t, 1814, *feed.TotalResults)
|
||||
require.NotNil(t, feed.ItemsPerPage)
|
||||
assert.Equal(t, 50, *feed.ItemsPerPage)
|
||||
require.NotNil(t, feed.StartIndex)
|
||||
assert.Equal(t, 51, *feed.StartIndex, "startIndex should be 1-based offset of first item on page 2")
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_FirstPage_NoPrevious(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 1814, "")
|
||||
|
||||
rels := rels(feed)
|
||||
assert.NotContains(t, rels, "previous", "first page must not have previous")
|
||||
assert.Contains(t, rels, "next")
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_LastPage_NoNext(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 37, 50, 1814, "")
|
||||
|
||||
rels := rels(feed)
|
||||
assert.NotContains(t, rels, "next", "last page must not have next")
|
||||
assert.Contains(t, rels, "previous")
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_SinglePage(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 10, "")
|
||||
|
||||
rels := rels(feed)
|
||||
assert.NotContains(t, rels, "previous")
|
||||
assert.NotContains(t, rels, "next")
|
||||
// still emits self/start/first/last
|
||||
assert.Contains(t, rels, "self")
|
||||
assert.Contains(t, rels, "last")
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_EmptyCatalog(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 0, "")
|
||||
|
||||
rels := rels(feed)
|
||||
assert.NotContains(t, rels, "next")
|
||||
assert.NotContains(t, rels, "previous")
|
||||
assert.NotContains(t, rels, "last", "empty catalog should not advertise a last page")
|
||||
require.NotNil(t, feed.TotalResults)
|
||||
assert.Equal(t, 0, *feed.TotalResults)
|
||||
}
|
||||
|
||||
func TestAddCatalogPaginationLinks_TokenAppended(t *testing.T) {
|
||||
feed := opds.NewFeed("urn:uuid:dev", "Library")
|
||||
addCatalogPaginationLinks(feed, "http://h/opds/devices/dev/catalog", 1, 50, 100, "abc")
|
||||
|
||||
xml, err := feed.GenerateXMLString()
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.Count(xml, "token=abc") >= 3, "token should be appended to generated links")
|
||||
}
|
||||
@@ -417,6 +417,25 @@ 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for HTMX request
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
// Fetch updated base_url for template
|
||||
|
||||
+102
-21
@@ -9,21 +9,25 @@ import (
|
||||
// OPDS 1.2 Feed Structures
|
||||
|
||||
type Feed struct {
|
||||
XMLName xml.Name `xml:"feed"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
OpdsNS string `xml:"xmlns:opds,attr"`
|
||||
DcNS string `xml:"xmlns:dc,attr"`
|
||||
ID string `xml:"id"`
|
||||
Title string `xml:"title"`
|
||||
Updated string `xml:"updated"`
|
||||
Links []Link `xml:"link"`
|
||||
Entries []Entry `xml:"entry"`
|
||||
XMLName xml.Name `xml:"feed"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
OpdsNS string `xml:"xmlns:opds,attr"`
|
||||
DcNS string `xml:"xmlns:dc,attr"`
|
||||
OpenSearchNS string `xml:"xmlns:opensearch,attr,omitempty"`
|
||||
ID string `xml:"id"`
|
||||
Title string `xml:"title"`
|
||||
Updated string `xml:"updated"`
|
||||
Links []Link `xml:"link"`
|
||||
TotalResults *int `xml:"opensearch:totalResults,omitempty"`
|
||||
ItemsPerPage *int `xml:"opensearch:itemsPerPage,omitempty"`
|
||||
StartIndex *int `xml:"opensearch:startIndex,omitempty"`
|
||||
Entries []Entry `xml:"entry"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string `xml:"id"`
|
||||
Title string `xml:"dc:title"`
|
||||
Creator string `xml:"dc:creator,omitempty"`
|
||||
Title string `xml:"title"`
|
||||
Author *Author `xml:"author,omitempty"`
|
||||
Updated string `xml:"updated"`
|
||||
Summary string `xml:"summary,omitempty"`
|
||||
Links []Link `xml:"link"`
|
||||
@@ -32,6 +36,12 @@ type Entry struct {
|
||||
Categories []Category `xml:"category,omitempty"`
|
||||
}
|
||||
|
||||
type Author struct {
|
||||
XMLName xml.Name `xml:"author"`
|
||||
Name string `xml:"name"`
|
||||
URI string `xml:"uri,omitempty"`
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
Href string `xml:"href,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
@@ -58,17 +68,29 @@ type Category struct {
|
||||
func NewFeed(feedID, title string) *Feed {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
return &Feed{
|
||||
Xmlns: "http://www.w3.org/2005/Atom",
|
||||
OpdsNS: "http://opds-spec.org/2010/",
|
||||
DcNS: "http://purl.org/dc/elements/1.1/",
|
||||
ID: feedID,
|
||||
Title: title,
|
||||
Updated: now,
|
||||
Links: []Link{},
|
||||
Entries: []Entry{},
|
||||
Xmlns: "http://www.w3.org/2005/Atom",
|
||||
OpdsNS: "http://opds-spec.org/2010/",
|
||||
DcNS: "http://purl.org/dc/elements/1.1/",
|
||||
OpenSearchNS: "http://a9.com/-/spec/opensearch/1.1/",
|
||||
ID: feedID,
|
||||
Title: title,
|
||||
Updated: now,
|
||||
Links: []Link{},
|
||||
Entries: []Entry{},
|
||||
}
|
||||
}
|
||||
|
||||
// SetPagination populates the OpenSearch paging metadata (totalResults,
|
||||
// itemsPerPage, startIndex). startIndex is 1-based to match the page model.
|
||||
func (f *Feed) SetPagination(totalResults, itemsPerPage, startIndex int) {
|
||||
tr := totalResults
|
||||
ipp := itemsPerPage
|
||||
si := startIndex
|
||||
f.TotalResults = &tr
|
||||
f.ItemsPerPage = &ipp
|
||||
f.StartIndex = &si
|
||||
}
|
||||
|
||||
// AddLink adds a link to the feed
|
||||
func (f *Feed) AddLink(href, linkType, rel string) {
|
||||
f.Links = append(f.Links, Link{
|
||||
@@ -85,14 +107,17 @@ func (f *Feed) AddEntry(entry Entry) {
|
||||
|
||||
// NewEntry creates a new OPDS entry
|
||||
func NewEntry(id, title, creator, updated string) Entry {
|
||||
return Entry{
|
||||
e := Entry{
|
||||
ID: id,
|
||||
Title: title,
|
||||
Creator: creator,
|
||||
Updated: updated,
|
||||
Links: []Link{},
|
||||
Metadata: []Meta{},
|
||||
}
|
||||
if creator != "" {
|
||||
e.Author = &Author{Name: creator}
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// AddAcquisitionLink adds an acquisition link to the entry
|
||||
@@ -169,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) {
|
||||
return xml.Header + string(output), nil
|
||||
}
|
||||
|
||||
// OpenSearchUrl is a single <Url> element in an OpenSearch description.
|
||||
type OpenSearchUrl struct {
|
||||
XMLName xml.Name `xml:"Url"`
|
||||
Type string `xml:"type,attr"`
|
||||
Template string `xml:"template,attr"`
|
||||
}
|
||||
|
||||
// OpenSearchDescription is an OpenSearch description document used by OPDS
|
||||
// clients (e.g. KOReader) to discover how to perform catalog searches. Clients
|
||||
// fetch this document at the catalog's rel="search" link, then substitute
|
||||
// {searchTerms} in the Url template to execute a query.
|
||||
type OpenSearchDescription struct {
|
||||
XMLName xml.Name `xml:"OpenSearchDescription"`
|
||||
Xmlns string `xml:"xmlns,attr"`
|
||||
ShortName string `xml:"ShortName"`
|
||||
Description string `xml:"Description"`
|
||||
InputEncoding string `xml:"InputEncoding"`
|
||||
OutputEncoding string `xml:"OutputEncoding"`
|
||||
Url OpenSearchUrl `xml:"Url"`
|
||||
}
|
||||
|
||||
// NewSearchDescription creates an OpenSearch description document whose Url
|
||||
// template points clients back to the search results endpoint. The template
|
||||
// must contain the {searchTerms} placeholder.
|
||||
func NewSearchDescription(shortName, description, template string) *OpenSearchDescription {
|
||||
return &OpenSearchDescription{
|
||||
Xmlns: "http://a9.com/-/spec/opensearch/1.1/",
|
||||
ShortName: shortName,
|
||||
Description: description,
|
||||
InputEncoding: "UTF-8",
|
||||
OutputEncoding: "UTF-8",
|
||||
Url: OpenSearchUrl{
|
||||
Type: "application/atom+xml;profile=opds-catalog;kind=acquisition",
|
||||
Template: template,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GenerateXML generates the OpenSearch description XML
|
||||
func (d *OpenSearchDescription) GenerateXML() ([]byte, error) {
|
||||
output, err := xml.MarshalIndent(d, "", " ")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal OpenSearch description: %w", err)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// GenerateXMLString generates the OpenSearch description XML as a string
|
||||
func (d *OpenSearchDescription) GenerateXMLString() (string, error) {
|
||||
output, err := d.GenerateXML()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return xml.Header + string(output), nil
|
||||
}
|
||||
|
||||
// NewErrorFeed creates an error feed
|
||||
func NewErrorFeed(message string) *Feed {
|
||||
feed := NewFeed(
|
||||
|
||||
+105
-4
@@ -71,8 +71,8 @@ func TestNewEntry(t *testing.T) {
|
||||
t.Errorf("expected Title to be 'Test Title', got '%s'", entry.Title)
|
||||
}
|
||||
|
||||
if entry.Creator != "Test Author" {
|
||||
t.Errorf("expected Creator to be 'Test Author', got '%s'", entry.Creator)
|
||||
if entry.Author == nil || entry.Author.Name != "Test Author" {
|
||||
t.Errorf("expected Author.Name to be 'Test Author', got %v", entry.Author)
|
||||
}
|
||||
|
||||
if entry.Updated != "2023-01-01T00:00:00Z" {
|
||||
@@ -201,8 +201,8 @@ func TestFeedGenerateXML(t *testing.T) {
|
||||
`<title>Test Feed</title>`,
|
||||
`<entry>`,
|
||||
`<id>urn:uuid:book-id</id>`,
|
||||
`<dc:title>Test Book</dc:title>`,
|
||||
`<dc:creator>Test Author</dc:creator>`,
|
||||
`<title>Test Book</title>`,
|
||||
`<name>Test Author</name>`,
|
||||
`<link href="http://example.com/book.epub"`,
|
||||
`rel="http://opds-spec.org/acquisition/open-access"`,
|
||||
`<dc:identifier id="bookhoard">book-uuid-123</dc:identifier>`,
|
||||
@@ -232,6 +232,107 @@ func TestNewErrorFeed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedSetPagination(t *testing.T) {
|
||||
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||
feed.SetPagination(1814, 50, 51)
|
||||
|
||||
if feed.TotalResults == nil || *feed.TotalResults != 1814 {
|
||||
t.Errorf("expected TotalResults to be 1814, got %v", feed.TotalResults)
|
||||
}
|
||||
if feed.ItemsPerPage == nil || *feed.ItemsPerPage != 50 {
|
||||
t.Errorf("expected ItemsPerPage to be 50, got %v", feed.ItemsPerPage)
|
||||
}
|
||||
if feed.StartIndex == nil || *feed.StartIndex != 51 {
|
||||
t.Errorf("expected StartIndex to be 51, got %v", feed.StartIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGenerateXMLPagination(t *testing.T) {
|
||||
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "first")
|
||||
feed.AddLink("http://example.com/catalog?page=1", "application/atom+xml", "previous")
|
||||
feed.AddLink("http://example.com/catalog?page=2", "application/atom+xml", "self")
|
||||
feed.AddLink("http://example.com/catalog?page=3", "application/atom+xml", "next")
|
||||
feed.AddLink("http://example.com/catalog?page=37", "application/atom+xml", "last")
|
||||
feed.SetPagination(1814, 50, 51)
|
||||
|
||||
output, err := feed.GenerateXML()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate XML: %v", err)
|
||||
}
|
||||
outputStr := string(output)
|
||||
|
||||
requiredStrings := []string{
|
||||
`xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"`,
|
||||
`<opensearch:totalResults>1814</opensearch:totalResults>`,
|
||||
`<opensearch:itemsPerPage>50</opensearch:itemsPerPage>`,
|
||||
`<opensearch:startIndex>51</opensearch:startIndex>`,
|
||||
`rel="first"`,
|
||||
`rel="previous"`,
|
||||
`rel="next"`,
|
||||
`rel="last"`,
|
||||
`page=3`,
|
||||
}
|
||||
|
||||
for _, required := range requiredStrings {
|
||||
if !contains(outputStr, required) {
|
||||
t.Errorf("generated XML missing required string: %s", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeedGenerateXMLOmitsPaginationWhenUnset(t *testing.T) {
|
||||
feed := NewFeed("urn:uuid:test-id", "Test Feed")
|
||||
|
||||
output, err := feed.GenerateXML()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate XML: %v", err)
|
||||
}
|
||||
outputStr := string(output)
|
||||
|
||||
if contains(outputStr, "opensearch:totalResults") {
|
||||
t.Errorf("expected no totalResults when pagination unset, but found it")
|
||||
}
|
||||
if contains(outputStr, "opensearch:itemsPerPage") {
|
||||
t.Errorf("expected no itemsPerPage when pagination unset, but found it")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSearchDescription(t *testing.T) {
|
||||
template := "http://example.com/opds/devices/abc/search?q={searchTerms}&token=xyz"
|
||||
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
||||
|
||||
if desc.ShortName != "Bookhoard" {
|
||||
t.Errorf("expected ShortName 'Bookhoard', got '%s'", desc.ShortName)
|
||||
}
|
||||
if desc.Url.Template != template {
|
||||
t.Errorf("expected template '%s', got '%s'", template, desc.Url.Template)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchDescriptionGenerateXML(t *testing.T) {
|
||||
template := "http://example.com/opds/devices/abc/search?q={searchTerms}"
|
||||
desc := NewSearchDescription("Bookhoard", "Search the library", template)
|
||||
|
||||
output, err := desc.GenerateXMLString()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate XML: %v", err)
|
||||
}
|
||||
|
||||
requiredStrings := []string{
|
||||
`<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">`,
|
||||
`<ShortName>Bookhoard</ShortName>`,
|
||||
`<Url type="application/atom+xml;profile=opds-catalog;kind=acquisition"`,
|
||||
`template="http://example.com/opds/devices/abc/search?q={searchTerms}"`,
|
||||
}
|
||||
|
||||
for _, required := range requiredStrings {
|
||||
if !contains(output, required) {
|
||||
t.Errorf("generated OpenSearch XML missing required string: %s", required)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/templates"
|
||||
@@ -66,7 +67,7 @@ func convertPending(pending []map[string]interface{}) []templates.PendingRegistr
|
||||
RegistrationID: p["registration_id"].(string),
|
||||
DeviceName: p["device_name"].(string),
|
||||
DeviceType: p["device_type"].(string),
|
||||
ExpiresAt: p["expires_at"].(string),
|
||||
ExpiresAt: p["expires_at"].(time.Time).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -117,6 +118,17 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
libraries = []database.GetUserVisibleLibrariesRow{}
|
||||
}
|
||||
|
||||
counts, countErr := cfg.Queries.GetVisibleLibraryMediaCounts(c.Request().Context(), uuidToPGType(userU))
|
||||
if countErr != nil {
|
||||
log.Printf("GetVisibleLibraryMediaCounts failed: %v", countErr)
|
||||
counts = []database.GetVisibleLibraryMediaCountsRow{}
|
||||
}
|
||||
countMap := make(map[string]int64, len(counts))
|
||||
for _, mc := range counts {
|
||||
mcUUID, _ := uuid.FromBytes(mc.ID.Bytes[0:16])
|
||||
countMap[mcUUID.String()] = mc.MediaCount
|
||||
}
|
||||
|
||||
res.Libraries = make([]templates.LibraryData, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
@@ -125,6 +137,7 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
MediaCount: countMap[libUUID.String()],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,12 +62,14 @@ type Config struct {
|
||||
ConnManager *sync.ConnectionManager
|
||||
QueueProcessor *sync.SyncQueueProcessor
|
||||
ProgressService *sync.ProgressService
|
||||
AnnotationService *sync.AnnotationService
|
||||
DeviceAuthMiddleware *middleware.DeviceAuthMiddleware
|
||||
LoginTracker *ratelimit.LoginAttemptTracker
|
||||
ScannerHandler *handlers.Handler
|
||||
JobsHandler *handlers.JobsHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
SidecarHandler *handlers.SidecarHandler
|
||||
ReaderHandler *handlers.ReaderHandler
|
||||
LibraryService *services.LibraryService
|
||||
}
|
||||
|
||||
// createJWTMiddleware creates a JWT middleware with proper user context setup
|
||||
@@ -188,6 +190,9 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
}
|
||||
e.Validator = &CustomValidator{validator: v}
|
||||
|
||||
// Setup redirect middleware - must run before all routes
|
||||
e.Pre(setupRedirectMiddleware(cfg))
|
||||
|
||||
// Rate limiter
|
||||
rateLimiterConfig := ratelimit.RateLimiterConfig{
|
||||
Enabled: cfg.Cfg.RateLimitEnabled,
|
||||
@@ -206,6 +211,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
cfg.ScannerHandler = scannerHandler
|
||||
|
||||
// Register route groups
|
||||
registerSetupRoutes(cfg)
|
||||
registerAuthRoutes(cfg, rateLimitMiddleware)
|
||||
registerLibraryRoutes(cfg)
|
||||
registerDeviceRoutes(cfg)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"bookhoard/internal/setupstatus"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
func isSetupComplete(cfg *Config) bool {
|
||||
return setupstatus.IsSetupComplete(context.Background(), cfg.Queries)
|
||||
}
|
||||
|
||||
func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
path := c.Request().URL.Path
|
||||
|
||||
if path == "/setup" || path == "/setup/" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/api/") {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
if !isSetupComplete(cfg) {
|
||||
return c.Redirect(http.StatusFound, "/setup")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func registerSetupRoutes(cfg *Config) {
|
||||
e := cfg.Echo
|
||||
|
||||
e.GET("/setup", func(c *echo.Context) error {
|
||||
if isSetupComplete(cfg) {
|
||||
return c.Redirect(http.StatusFound, "/")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := templates.Setup().Render(c.Request().Context(), &buf); err != nil {
|
||||
log.Printf("Failed to render setup template: %v", err)
|
||||
return c.HTML(http.StatusInternalServerError, "Failed to render setup page")
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
}
|
||||
@@ -33,6 +33,8 @@ func registerSyncRoutes(cfg *Config) {
|
||||
// API clients can use Authorization header: Authorization: Bearer {token}
|
||||
koboHandler := handlers.NewKoboHandler(cfg.Queries, cfg.ConnManager)
|
||||
koboHandler.SetProgressService(cfg.ProgressService)
|
||||
koboHandler.SetAnnotationService(cfg.AnnotationService)
|
||||
koboHandler.SetLibraryService(cfg.LibraryService)
|
||||
koboSync := e.Group("/api/sync/kobo/:token")
|
||||
koboSync.POST("/markup", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Markup))
|
||||
koboSync.POST("/bookmark", cfg.DeviceAuthMiddleware.Authenticate(koboHandler.Bookmark))
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package setupstatus reports whether the application's initial setup has been
|
||||
// completed. Setup is considered complete as soon as at least one admin user
|
||||
// exists, regardless of how that user was created (setup wizard, API, or a
|
||||
// future CLI). This keeps the setup gate a derived property of real data
|
||||
// rather than a manually-flipped flag that can drift out of sync.
|
||||
package setupstatus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AdminCounter is satisfied by *database.Queries. It is defined as an interface
|
||||
// here so this package does not import the database package, keeping the
|
||||
// dependency graph flat and avoiding import cycles.
|
||||
type AdminCounter interface {
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
}
|
||||
|
||||
var (
|
||||
cacheMu sync.RWMutex
|
||||
cacheComplete bool = true
|
||||
cacheExpiry time.Time
|
||||
cacheTTL = 10 * time.Second
|
||||
)
|
||||
|
||||
// IsSetupComplete reports whether setup is complete. Setup is complete when at
|
||||
// least one admin user exists. A short in-memory cache avoids hammering the
|
||||
// database on every request. On a database error the function fails open
|
||||
// (returns true) so a transient outage does not lock users out of the app.
|
||||
func IsSetupComplete(ctx context.Context, q AdminCounter) bool {
|
||||
cacheMu.RLock()
|
||||
if time.Now().Before(cacheExpiry) {
|
||||
complete := cacheComplete
|
||||
cacheMu.RUnlock()
|
||||
return complete
|
||||
}
|
||||
cacheMu.RUnlock()
|
||||
|
||||
count, err := q.CountAdmins(ctx)
|
||||
complete := true
|
||||
if err == nil {
|
||||
complete = count > 0
|
||||
}
|
||||
|
||||
cacheMu.Lock()
|
||||
cacheComplete = complete
|
||||
cacheExpiry = time.Now().Add(cacheTTL)
|
||||
cacheMu.Unlock()
|
||||
return complete
|
||||
}
|
||||
|
||||
// Invalidate clears the cached setup status so the next call to IsSetupComplete
|
||||
// re-reads from the database. Call this after any write that could change the
|
||||
// admin user count (user creation, role promotion/demotion, user deletion).
|
||||
func Invalidate() {
|
||||
cacheMu.Lock()
|
||||
cacheComplete = true
|
||||
cacheExpiry = time.Time{}
|
||||
cacheMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,754 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const TombstoneTTL = 30 * 24 * time.Hour
|
||||
|
||||
type SaveOutcome string
|
||||
|
||||
const (
|
||||
SaveOutcomeCreated SaveOutcome = "created"
|
||||
SaveOutcomeUpdated SaveOutcome = "updated"
|
||||
SaveOutcomeSkipped SaveOutcome = "skipped"
|
||||
SaveOutcomeDeleted SaveOutcome = "deleted"
|
||||
)
|
||||
|
||||
type AnnotationService struct {
|
||||
db *database.Queries
|
||||
connMgr *ConnectionManager
|
||||
}
|
||||
|
||||
func NewAnnotationService(db *database.Queries, connMgr *ConnectionManager) *AnnotationService {
|
||||
return &AnnotationService{db: db, connMgr: connMgr}
|
||||
}
|
||||
|
||||
type SaveHighlightRequest struct {
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
SelectionText string
|
||||
StartPosition string
|
||||
EndPosition string
|
||||
Color string
|
||||
NoteText string
|
||||
PercentageStart float64
|
||||
PercentageEnd float64
|
||||
EpubcfiStart string
|
||||
EpubcfiEnd string
|
||||
ChapterReference int32
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData json.RawMessage
|
||||
}
|
||||
|
||||
type SaveHighlightResult struct {
|
||||
Highlight database.MediaHighlights
|
||||
Outcome SaveOutcome
|
||||
Conflict bool
|
||||
}
|
||||
|
||||
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
||||
dedupKey := ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
||||
|
||||
existing, err := s.db.GetMediaHighlightByDedupKey(ctx, database.GetMediaHighlightByDedupKeyParams{
|
||||
UserID: req.UserID,
|
||||
MediaItemID: req.MediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("query existing highlight: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return s.createHighlight(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
if existing.Deleted.Bool {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
|
||||
return &SaveHighlightResult{Highlight: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
return s.createHighlight(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
return s.applyLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
func (s *AnnotationService) createHighlight(
|
||||
ctx context.Context,
|
||||
req SaveHighlightRequest,
|
||||
dedupKey string,
|
||||
) (*SaveHighlightResult, error) {
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
|
||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||
|
||||
highlight, err := s.db.CreateMediaHighlightFull(ctx, database.CreateMediaHighlightFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create highlight: %w", err)
|
||||
}
|
||||
|
||||
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
|
||||
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeCreated}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) applyLWW(
|
||||
ctx context.Context,
|
||||
req SaveHighlightRequest,
|
||||
existing database.MediaHighlights,
|
||||
dedupKey string,
|
||||
) (*SaveHighlightResult, error) {
|
||||
incomingNewer, contentChanged := s.compareIncoming(req, existing)
|
||||
|
||||
if !incomingNewer && !contentChanged {
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "existing")
|
||||
}
|
||||
return &SaveHighlightResult{
|
||||
Highlight: existing,
|
||||
Outcome: SaveOutcomeSkipped,
|
||||
Conflict: conflict,
|
||||
}, nil
|
||||
}
|
||||
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
||||
ID: existing.ID,
|
||||
SelectionText: req.SelectionText,
|
||||
StartPosition: pgText(req.StartPosition),
|
||||
EndPosition: pgText(req.EndPosition),
|
||||
Color: pgText(req.Color),
|
||||
NoteText: pgText(req.NoteText),
|
||||
PercentageStart: pgFloat8(req.PercentageStart),
|
||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update highlight: %w", err)
|
||||
}
|
||||
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_highlight",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "incoming")
|
||||
}
|
||||
s.broadcast(highlight.ID, req.UserID, req.MediaItemID, "highlight", req.Source)
|
||||
return &SaveHighlightResult{Highlight: highlight, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing database.MediaHighlights) (incomingNewer bool, contentChanged bool) {
|
||||
if req.ModifiedAt.IsZero() {
|
||||
contentSame := strings.EqualFold(req.SelectionText, existing.SelectionText) &&
|
||||
textEq(req.Color, existing.Color) &&
|
||||
textEq(req.NoteText, existing.NoteText) &&
|
||||
floatEq(req.PercentageStart, existing.PercentageStart) &&
|
||||
floatEq(req.PercentageEnd, existing.PercentageEnd)
|
||||
return !contentSame, !contentSame
|
||||
}
|
||||
|
||||
existingMod := existing.LastModifiedAt
|
||||
if !existingMod.Valid {
|
||||
existingMod = existing.UpdatedAt
|
||||
}
|
||||
return req.ModifiedAt.After(existingMod.Time), true
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneHighlight(
|
||||
ctx context.Context,
|
||||
userID, mediaItemID pgtype.UUID,
|
||||
dedupKey string,
|
||||
source string,
|
||||
) error {
|
||||
err := s.db.TombstoneMediaHighlightByDedupKey(ctx, database.TombstoneMediaHighlightByDedupKeyParams{
|
||||
UserID: userID,
|
||||
MediaItemID: mediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("tombstone highlight: %w", err)
|
||||
}
|
||||
s.broadcast(pgtype.UUID{}, userID, mediaItemID, "highlight_delete", source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneHighlightByID(
|
||||
ctx context.Context,
|
||||
highlightID pgtype.UUID,
|
||||
source string,
|
||||
) error {
|
||||
h, err := s.db.GetMediaHighlight(ctx, highlightID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get highlight for tombstone: %w", err)
|
||||
}
|
||||
err = s.db.TombstoneMediaHighlightByID(ctx, highlightID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tombstone highlight by ID: %w", err)
|
||||
}
|
||||
s.broadcast(pgtype.UUID{}, h.UserID, h.MediaItemID, "highlight_delete", source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) PurgeExpiredTombstones(ctx context.Context) error {
|
||||
cutoff := pgtype.Timestamptz{Time: time.Now().Add(-TombstoneTTL), Valid: true}
|
||||
if err := s.db.PurgeExpiredHighlightTombstones(ctx, cutoff); err != nil {
|
||||
return fmt.Errorf("purge highlight tombstones: %w", err)
|
||||
}
|
||||
if err := s.db.PurgeExpiredNoteTombstones(ctx, cutoff); err != nil {
|
||||
return fmt.Errorf("purge note tombstones: %w", err)
|
||||
}
|
||||
if err := s.db.PurgeExpiredBookmarkTombstones(ctx, cutoff); err != nil {
|
||||
return fmt.Errorf("purge bookmark tombstones: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) StartTombstonePurger() context.CancelFunc {
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.PurgeExpiredTombstones(ctx); err != nil {
|
||||
log.Printf("AnnotationService: tombstone purge failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return cancel
|
||||
}
|
||||
|
||||
type SaveNoteRequest struct {
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Content string
|
||||
Position string
|
||||
PercentageLocation float64
|
||||
CharacterStart int32
|
||||
CharacterEnd int32
|
||||
EpubcfiLocation string
|
||||
ChapterReference int32
|
||||
ParagraphReference int32
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData []byte
|
||||
}
|
||||
|
||||
type SaveNoteResult struct {
|
||||
Note database.MediaNotes
|
||||
Outcome SaveOutcome
|
||||
Conflict bool
|
||||
}
|
||||
|
||||
func (s *AnnotationService) SaveNote(ctx context.Context, req SaveNoteRequest) (*SaveNoteResult, error) {
|
||||
if !req.UserID.Valid || !req.MediaItemID.Valid {
|
||||
return nil, errors.New("invalid user_id or media_item_id")
|
||||
}
|
||||
|
||||
dedupKey := ComputeDedupKey(req.Content, req.EpubcfiLocation, req.Position)
|
||||
|
||||
existing, err := s.db.GetMediaNoteByDedupKey(ctx, database.GetMediaNoteByDedupKeyParams{
|
||||
UserID: req.UserID,
|
||||
MediaItemID: req.MediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("get note by dedup key: %w", err)
|
||||
}
|
||||
return s.createNote(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
if existing.Deleted.Valid && existing.Deleted.Bool {
|
||||
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
|
||||
return s.applyNoteLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
func (s *AnnotationService) createNote(ctx context.Context, req SaveNoteRequest, dedupKey string) (*SaveNoteResult, error) {
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
|
||||
note, err := s.db.CreateMediaNoteFull(ctx, database.CreateMediaNoteFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
Content: req.Content,
|
||||
Position: pgText(req.Position),
|
||||
PercentageLocation: pgFloat8(req.PercentageLocation),
|
||||
CharacterStart: pgInt4(req.CharacterStart),
|
||||
CharacterEnd: pgInt4(req.CharacterEnd),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
ParagraphReference: pgInt4(req.ParagraphReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: req.DeviceSyncData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create note: %w", err)
|
||||
}
|
||||
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
|
||||
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeCreated}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) applyNoteLWW(ctx context.Context, req SaveNoteRequest, existing database.MediaNotes, dedupKey string) (*SaveNoteResult, error) {
|
||||
incomingNewer, contentChanged := s.compareIncomingNote(req, existing)
|
||||
|
||||
if !incomingNewer && !contentChanged {
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "existing")
|
||||
}
|
||||
return &SaveNoteResult{Note: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
note, err := s.db.UpdateMediaNoteForSync(ctx, database.UpdateMediaNoteForSyncParams{
|
||||
ID: existing.ID,
|
||||
Content: req.Content,
|
||||
Position: pgText(req.Position),
|
||||
PercentageLocation: pgFloat8(req.PercentageLocation),
|
||||
CharacterStart: pgInt4(req.CharacterStart),
|
||||
CharacterEnd: pgInt4(req.CharacterEnd),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
ParagraphReference: pgInt4(req.ParagraphReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update note: %w", err)
|
||||
}
|
||||
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_note",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "incoming")
|
||||
}
|
||||
s.broadcast(note.ID, req.UserID, req.MediaItemID, "note", req.Source)
|
||||
return &SaveNoteResult{Note: note, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) compareIncomingNote(req SaveNoteRequest, existing database.MediaNotes) (incomingNewer bool, contentChanged bool) {
|
||||
if req.ModifiedAt.IsZero() {
|
||||
contentSame := strings.EqualFold(req.Content, existing.Content) &&
|
||||
textEq(req.Position, existing.Position)
|
||||
return !contentSame, !contentSame
|
||||
}
|
||||
existingMod := existing.LastModifiedAt
|
||||
if !existingMod.Valid {
|
||||
existingMod = existing.UpdatedAt
|
||||
}
|
||||
if !existingMod.Valid {
|
||||
return true, true
|
||||
}
|
||||
return req.ModifiedAt.After(existingMod.Time), true
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneNoteByID(ctx context.Context, id pgtype.UUID) error {
|
||||
return s.db.TombstoneMediaNoteByID(ctx, id)
|
||||
}
|
||||
|
||||
type SaveBookmarkRequest struct {
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Title string
|
||||
Position string
|
||||
Notes string
|
||||
PageNumber int32
|
||||
ChapterNumber int32
|
||||
CFIPosition string
|
||||
PercentageLoc float64
|
||||
EpubcfiLocation string
|
||||
ChapterReference int32
|
||||
Source string
|
||||
ModifiedAt time.Time
|
||||
DeviceSyncData json.RawMessage
|
||||
}
|
||||
|
||||
type SaveBookmarkResult struct {
|
||||
Bookmark database.MediaBookmarks
|
||||
Outcome SaveOutcome
|
||||
Conflict bool
|
||||
}
|
||||
|
||||
func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRequest) (*SaveBookmarkResult, error) {
|
||||
dedupKey := ComputeDedupKey(req.Title, req.EpubcfiLocation, req.Position)
|
||||
|
||||
existing, err := s.db.GetMediaBookmarkByDedupKey(ctx, database.GetMediaBookmarkByDedupKeyParams{
|
||||
UserID: req.UserID,
|
||||
MediaItemID: req.MediaItemID,
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
})
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, fmt.Errorf("query existing bookmark: %w", err)
|
||||
}
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return s.createBookmark(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
if existing.Deleted.Bool {
|
||||
if existing.DeletedAt.Valid && time.Since(existing.DeletedAt.Time) < TombstoneTTL {
|
||||
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||
}
|
||||
return s.createBookmark(ctx, req, dedupKey)
|
||||
}
|
||||
|
||||
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
|
||||
}
|
||||
|
||||
func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmarkRequest, dedupKey string) (*SaveBookmarkResult, error) {
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
deviceData := mergeDeviceSyncData(nil, req.Source, req.DeviceSyncData)
|
||||
|
||||
bm, err := s.db.CreateMediaBookmarkFull(ctx, database.CreateMediaBookmarkFullParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
DedupKey: pgtype.Text{String: dedupKey, Valid: true},
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create bookmark: %w", err)
|
||||
}
|
||||
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
|
||||
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeCreated}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookmarkRequest, existing database.MediaBookmarks, dedupKey string) (*SaveBookmarkResult, error) {
|
||||
incomingNewer, contentChanged := s.compareIncomingBookmark(req, existing)
|
||||
|
||||
if !incomingNewer && !contentChanged {
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "existing")
|
||||
}
|
||||
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeSkipped, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
modifiedAt := req.ModifiedAt
|
||||
if modifiedAt.IsZero() {
|
||||
modifiedAt = time.Now()
|
||||
}
|
||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||
|
||||
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
||||
ID: existing.ID,
|
||||
PageNumber: pgInt4(req.PageNumber),
|
||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||
CfiPosition: pgText(req.CFIPosition),
|
||||
Title: req.Title,
|
||||
Position: pgText(req.Position),
|
||||
Notes: pgText(req.Notes),
|
||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||
ChapterReference: pgInt4(req.ChapterReference),
|
||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||
DeviceSyncData: deviceData,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update bookmark: %w", err)
|
||||
}
|
||||
conflict := isCrossSource(req.Source, existing.LastModifiedSource)
|
||||
if conflict {
|
||||
s.recordConflict(ctx, req.UserID, req.MediaItemID, "annotation_bookmark",
|
||||
existing.DedupKey.String, req.Source, existing.LastModifiedSource.String,
|
||||
req, existing, "incoming")
|
||||
}
|
||||
s.broadcast(bm.ID, req.UserID, req.MediaItemID, "bookmark", req.Source)
|
||||
return &SaveBookmarkResult{Bookmark: bm, Outcome: SaveOutcomeUpdated, Conflict: conflict}, nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
|
||||
if req.ModifiedAt.IsZero() {
|
||||
contentSame := strings.EqualFold(req.Title, existing.Title) &&
|
||||
textEq(req.Notes, existing.Notes)
|
||||
return !contentSame, !contentSame
|
||||
}
|
||||
existingMod := existing.LastModifiedAt
|
||||
if !existingMod.Valid {
|
||||
existingMod = existing.CreatedAt
|
||||
}
|
||||
return req.ModifiedAt.After(existingMod.Time), true
|
||||
}
|
||||
|
||||
func (s *AnnotationService) TombstoneBookmarkByID(ctx context.Context, bookmarkID pgtype.UUID, source string) error {
|
||||
bm, err := s.db.GetMediaBookmark(ctx, bookmarkID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get bookmark for tombstone: %w", err)
|
||||
}
|
||||
err = s.db.TombstoneMediaBookmarkByID(ctx, bookmarkID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tombstone bookmark by ID: %w", err)
|
||||
}
|
||||
s.broadcast(pgtype.UUID{}, bm.UserID, bm.MediaItemID, "bookmark_delete", source)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AnnotationService) recordConflict(
|
||||
ctx context.Context,
|
||||
userID, mediaItemID pgtype.UUID,
|
||||
conflictType, dedupKey string,
|
||||
incomingSource, existingSource string,
|
||||
incoming any,
|
||||
existing any,
|
||||
winner string,
|
||||
) {
|
||||
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
|
||||
return
|
||||
}
|
||||
|
||||
incomingJSON, _ := json.Marshal(incoming)
|
||||
existingJSON, _ := json.Marshal(existing)
|
||||
|
||||
var incomingMap, existingMap map[string]interface{}
|
||||
json.Unmarshal(incomingJSON, &incomingMap)
|
||||
json.Unmarshal(existingJSON, &existingMap)
|
||||
if incomingMap == nil {
|
||||
incomingMap = map[string]interface{}{}
|
||||
}
|
||||
if existingMap == nil {
|
||||
existingMap = map[string]interface{}{}
|
||||
}
|
||||
incomingMap["dedup_key"] = dedupKey
|
||||
existingMap["dedup_key"] = dedupKey
|
||||
|
||||
conflictData, _ := json.Marshal(map[string]interface{}{
|
||||
"incoming": map[string]interface{}{
|
||||
"source": incomingSource,
|
||||
"data": incomingMap,
|
||||
},
|
||||
"existing": map[string]interface{}{
|
||||
"source": existingSource,
|
||||
"data": existingMap,
|
||||
},
|
||||
})
|
||||
|
||||
resolutionData, _ := json.Marshal(map[string]interface{}{
|
||||
"winner": winner,
|
||||
"reason": "last_modified_at_wins",
|
||||
})
|
||||
|
||||
conflict, err := s.db.CreateAutoResolvedSyncConflict(ctx, database.CreateAutoResolvedSyncConflictParams{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
ConflictType: conflictType,
|
||||
ConflictData: conflictData,
|
||||
ResolutionData: resolutionData,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("AnnotationService: failed to record conflict: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var conflictIDStr string
|
||||
if conflict.ID.Valid {
|
||||
conflictIDStr = uuid.UUID(conflict.ID.Bytes).String()
|
||||
}
|
||||
s.connMgr.BroadcastConflictNotification(
|
||||
uuid.UUID(mediaItemID.Bytes),
|
||||
"annotation_conflict",
|
||||
conflictIDStr,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *AnnotationService) broadcast(
|
||||
highlightID, userID, mediaItemID pgtype.UUID,
|
||||
annotationType string,
|
||||
source string,
|
||||
) {
|
||||
if s.connMgr == nil || !userID.Valid || !mediaItemID.Valid {
|
||||
return
|
||||
}
|
||||
src := SourceDevice{Type: source}
|
||||
s.connMgr.BroadcastAnnotationUpdate(
|
||||
uuid.UUID(mediaItemID.Bytes),
|
||||
annotationType,
|
||||
map[string]interface{}{
|
||||
"highlight_id": uuid.UUID(highlightID.Bytes),
|
||||
},
|
||||
src,
|
||||
)
|
||||
}
|
||||
|
||||
func ComputeDedupKey(selectionText, epubcfiStart, startPosition string) string {
|
||||
normalized := normalizeText(selectionText)
|
||||
posBucket := bucketPosition(epubcfiStart)
|
||||
if posBucket == "" {
|
||||
posBucket = bucketPosition(startPosition)
|
||||
}
|
||||
|
||||
h := sha1.New()
|
||||
h.Write([]byte(normalized))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(posBucket))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func normalizeText(s string) string {
|
||||
fields := strings.Fields(strings.ToLower(s))
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
func bucketPosition(pos string) string {
|
||||
if pos == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(pos, "epubcfi(") {
|
||||
if idx := strings.LastIndex(pos, ":"); idx > 0 {
|
||||
return pos[:idx]
|
||||
}
|
||||
}
|
||||
if len(pos) > 50 {
|
||||
return pos[:50]
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
func mergeDeviceSyncData(existing []byte, source string, data json.RawMessage) []byte {
|
||||
if source == "" && len(data) == 0 {
|
||||
return existing
|
||||
}
|
||||
m := make(map[string]interface{})
|
||||
if len(existing) > 0 {
|
||||
_ = json.Unmarshal(existing, &m)
|
||||
}
|
||||
if source != "" {
|
||||
if len(data) > 0 {
|
||||
var val interface{}
|
||||
_ = json.Unmarshal(data, &val)
|
||||
m[source] = val
|
||||
} else {
|
||||
m[source] = map[string]interface{}{"synced_at": time.Now().UTC().Format(time.RFC3339)}
|
||||
}
|
||||
}
|
||||
result, _ := json.Marshal(m)
|
||||
return result
|
||||
}
|
||||
|
||||
func isCrossSource(incoming string, existing pgtype.Text) bool {
|
||||
if incoming == "" || !existing.Valid {
|
||||
return false
|
||||
}
|
||||
return incoming != existing.String
|
||||
}
|
||||
|
||||
func pgText(s string) pgtype.Text {
|
||||
if s == "" {
|
||||
return pgtype.Text{Valid: false}
|
||||
}
|
||||
return pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
|
||||
func pgFloat8(f float64) pgtype.Float8 {
|
||||
if f == 0 {
|
||||
return pgtype.Float8{Valid: false}
|
||||
}
|
||||
return pgtype.Float8{Float64: f, Valid: true}
|
||||
}
|
||||
|
||||
func pgInt4(i int32) pgtype.Int4 {
|
||||
if i == 0 {
|
||||
return pgtype.Int4{Valid: false}
|
||||
}
|
||||
return pgtype.Int4{Int32: i, Valid: true}
|
||||
}
|
||||
|
||||
func textEq(a string, b pgtype.Text) bool {
|
||||
if !b.Valid {
|
||||
return a == ""
|
||||
}
|
||||
return a == b.String
|
||||
}
|
||||
|
||||
func floatEq(a float64, b pgtype.Float8) bool {
|
||||
if !b.Valid {
|
||||
return a == 0
|
||||
}
|
||||
return math.Abs(a-b.Float64) < 0.001
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func TestComputeDedupKey_Deterministic(t *testing.T) {
|
||||
k1 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
|
||||
k2 := ComputeDedupKey("Hello World", "epubcfi(/6/4!/4/10/3:100)", "")
|
||||
if k1 != k2 {
|
||||
t.Errorf("same input should produce same key: %q vs %q", k1, k2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_Normalization(t *testing.T) {
|
||||
cases := [][]string{
|
||||
{"Hello World", " Hello World "},
|
||||
{"HELLO WORLD", "hello world"},
|
||||
{"Hello World", "Hello World"},
|
||||
{"Hello\t\nWorld", "Hello World"},
|
||||
}
|
||||
cfi := "epubcfi(/6/4!/4/10/3:100)"
|
||||
for _, c := range cases {
|
||||
k1 := ComputeDedupKey(c[0], cfi, "")
|
||||
k2 := ComputeDedupKey(c[1], cfi, "")
|
||||
if k1 != k2 {
|
||||
t.Errorf("normalized texts should match: %q vs %q → %q vs %q", c[0], c[1], k1, k2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_PositionSensitivity(t *testing.T) {
|
||||
text := "same text"
|
||||
k1 := ComputeDedupKey(text, "epubcfi(/6/4!/4/10/3:100)", "")
|
||||
k2 := ComputeDedupKey(text, "epubcfi(/6/4!/4/20/3:100)", "")
|
||||
if k1 == k2 {
|
||||
t.Error("different element paths should produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_OffsetInsensitive(t *testing.T) {
|
||||
text := "same text"
|
||||
base := "epubcfi(/6/4!/4/10/3:100)"
|
||||
offsetShift := "epubcfi(/6/4!/4/10/3:200)"
|
||||
k1 := ComputeDedupKey(text, base, "")
|
||||
k2 := ComputeDedupKey(text, offsetShift, "")
|
||||
if k1 != k2 {
|
||||
t.Error("same element path with different char offsets should produce same key (bucket)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_FallbackToRawPosition(t *testing.T) {
|
||||
text := "same text"
|
||||
k1 := ComputeDedupKey(text, "", "page:42")
|
||||
k2 := ComputeDedupKey(text, "", "page:42")
|
||||
if k1 != k2 {
|
||||
t.Error("same raw position should produce same key")
|
||||
}
|
||||
k3 := ComputeDedupKey(text, "", "page:99")
|
||||
if k1 == k3 {
|
||||
t.Error("different raw positions should produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeDedupKey_DifferentTextSamePosition(t *testing.T) {
|
||||
cfi := "epubcfi(/6/4!/4/10/3:100)"
|
||||
k1 := ComputeDedupKey("first highlight", cfi, "")
|
||||
k2 := ComputeDedupKey("second highlight", cfi, "")
|
||||
if k1 == k2 {
|
||||
t.Error("different selection text should produce different keys")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeText(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"Hello World", "hello world"},
|
||||
{" Hello World ", "hello world"},
|
||||
{"Hello\t\nWorld", "hello world"},
|
||||
{"", ""},
|
||||
{" ", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := normalizeText(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("normalizeText(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketPosition(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"epubcfi(/6/4!/4/10/3:100)", "epubcfi(/6/4!/4/10/3"},
|
||||
{"epubcfi(/6/4!/4/10/3:0)", "epubcfi(/6/4!/4/10/3"},
|
||||
{"page:42", "page:42"},
|
||||
{"short", "short"},
|
||||
{"", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := bucketPosition(c.in)
|
||||
if got != c.want {
|
||||
t.Errorf("bucketPosition(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBucketPosition_LongString(t *testing.T) {
|
||||
long := "this_is_a_very_long_position_string_that_exceeds_fifty_characters_total"
|
||||
got := bucketPosition(long)
|
||||
if len(got) > 50 {
|
||||
t.Errorf("bucketPosition should truncate to <=50 chars, got %d", len(got))
|
||||
}
|
||||
if got != long[:50] {
|
||||
t.Errorf("bucketPosition truncated wrong: got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDeviceSyncData_NewEntry(t *testing.T) {
|
||||
result := mergeDeviceSyncData(nil, "koreader", json.RawMessage(`{"datetime":"2024-01-01"}`))
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
entry, ok := m["koreader"]
|
||||
if !ok {
|
||||
t.Fatal("expected koreader entry")
|
||||
}
|
||||
entryMap := entry.(map[string]interface{})
|
||||
if entryMap["datetime"] != "2024-01-01" {
|
||||
t.Errorf("unexpected datetime: %v", entryMap["datetime"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDeviceSyncData_PreservesExisting(t *testing.T) {
|
||||
existing := []byte(`{"koreader":{"datetime":"2024-01-01"}}`)
|
||||
result := mergeDeviceSyncData(existing, "kobo", json.RawMessage(`{"bookmark_id":"abc"}`))
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(result, &m); err != nil {
|
||||
t.Fatalf("unmarshal failed: %v", err)
|
||||
}
|
||||
if _, ok := m["koreader"]; !ok {
|
||||
t.Error("koreader entry should be preserved")
|
||||
}
|
||||
if _, ok := m["kobo"]; !ok {
|
||||
t.Error("kobo entry should be added")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDeviceSyncData_OverwritesSameSource(t *testing.T) {
|
||||
existing := []byte(`{"koreader":{"datetime":"old"}}`)
|
||||
result := mergeDeviceSyncData(existing, "koreader", json.RawMessage(`{"datetime":"new"}`))
|
||||
var m map[string]interface{}
|
||||
json.Unmarshal(result, &m)
|
||||
entry := m["koreader"].(map[string]interface{})
|
||||
if entry["datetime"] != "new" {
|
||||
t.Errorf("expected overwritten datetime 'new', got %v", entry["datetime"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCrossSource(t *testing.T) {
|
||||
if isCrossSource("koreader", pgtype.Text{String: "kobo", Valid: true}) != true {
|
||||
t.Error("different sources should be cross-source")
|
||||
}
|
||||
if isCrossSource("koreader", pgtype.Text{String: "koreader", Valid: true}) != false {
|
||||
t.Error("same sources should not be cross-source")
|
||||
}
|
||||
if isCrossSource("", pgtype.Text{String: "koreader", Valid: true}) != false {
|
||||
t.Error("empty incoming source should not be cross-source")
|
||||
}
|
||||
if isCrossSource("koreader", pgtype.Text{Valid: false}) != false {
|
||||
t.Error("invalid existing source should not be cross-source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_FieldDiff_Identical(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "hello",
|
||||
Color: "#ffff00",
|
||||
NoteText: "a note",
|
||||
PercentageStart: 10.5,
|
||||
PercentageEnd: 11.0,
|
||||
}
|
||||
existing := pgHighlights("hello", "#ffff00", "a note", 10.5, 11.0)
|
||||
newer, changed := svc.compareIncoming(req, existing)
|
||||
if newer {
|
||||
t.Error("identical content should not be newer")
|
||||
}
|
||||
if changed {
|
||||
t.Error("identical content should not be changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_FieldDiff_DifferentText(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "edited text",
|
||||
}
|
||||
existing := pgHighlights("original text", "#ffff00", "", 0, 0)
|
||||
newer, changed := svc.compareIncoming(req, existing)
|
||||
if !newer {
|
||||
t.Error("different content should be newer")
|
||||
}
|
||||
if !changed {
|
||||
t.Error("different content should be changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_FieldDiff_DifferentColor(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "same",
|
||||
Color: "#ff0000",
|
||||
}
|
||||
existing := pgHighlights("same", "#ffff00", "", 0, 0)
|
||||
_, changed := svc.compareIncoming(req, existing)
|
||||
if !changed {
|
||||
t.Error("different color should be detected as changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_LWW_NewerWins(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
now := time.Now()
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "same",
|
||||
ModifiedAt: now.Add(1 * time.Hour),
|
||||
}
|
||||
existing := pgHighlights("same", "", "", 0, 0)
|
||||
existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
|
||||
newer, changed := svc.compareIncoming(req, existing)
|
||||
if !newer {
|
||||
t.Error("future timestamp should be newer")
|
||||
}
|
||||
if !changed {
|
||||
t.Error("LWW mode should always report changed=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_LWW_OlderSkipped(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
now := time.Now()
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "same",
|
||||
ModifiedAt: now.Add(-1 * time.Hour),
|
||||
}
|
||||
existing := pgHighlights("same", "", "", 0, 0)
|
||||
existing.LastModifiedAt = pgtype.Timestamptz{Time: now, Valid: true}
|
||||
newer, _ := svc.compareIncoming(req, existing)
|
||||
if newer {
|
||||
t.Error("past timestamp should not be newer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareIncoming_LWW_FallsBackToUpdatedAt(t *testing.T) {
|
||||
svc := &AnnotationService{}
|
||||
now := time.Now()
|
||||
req := SaveHighlightRequest{
|
||||
SelectionText: "same",
|
||||
ModifiedAt: now.Add(1 * time.Hour),
|
||||
}
|
||||
existing := pgHighlights("same", "", "", 0, 0)
|
||||
existing.LastModifiedAt = pgtype.Timestamptz{Valid: false}
|
||||
existing.UpdatedAt = pgtype.Timestamptz{Time: now, Valid: true}
|
||||
newer, _ := svc.compareIncoming(req, existing)
|
||||
if !newer {
|
||||
t.Error("should fall back to updated_at when last_modified_at is invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgText(t *testing.T) {
|
||||
if pgText("").Valid {
|
||||
t.Error("empty string should produce invalid pgtype.Text")
|
||||
}
|
||||
v := pgText("hello")
|
||||
if !v.Valid || v.String != "hello" {
|
||||
t.Errorf("expected valid 'hello', got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgFloat8(t *testing.T) {
|
||||
if pgFloat8(0).Valid {
|
||||
t.Error("zero should produce invalid pgtype.Float8")
|
||||
}
|
||||
v := pgFloat8(1.5)
|
||||
if !v.Valid || v.Float64 != 1.5 {
|
||||
t.Errorf("expected valid 1.5, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPgInt4(t *testing.T) {
|
||||
if pgInt4(0).Valid {
|
||||
t.Error("zero should produce invalid pgtype.Int4")
|
||||
}
|
||||
v := pgInt4(3)
|
||||
if !v.Valid || v.Int32 != 3 {
|
||||
t.Errorf("expected valid 3, got %+v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFloatEq(t *testing.T) {
|
||||
if !floatEq(0, pgtype.Float8{Valid: false}) {
|
||||
t.Error("0 vs invalid should be equal")
|
||||
}
|
||||
if !floatEq(10.5, pgtype.Float8{Float64: 10.5, Valid: true}) {
|
||||
t.Error("10.5 vs 10.5 should be equal")
|
||||
}
|
||||
if floatEq(10.6, pgtype.Float8{Float64: 10.5, Valid: true}) {
|
||||
t.Error("10.6 vs 10.5 should not be equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextEq(t *testing.T) {
|
||||
if !textEq("", pgtype.Text{Valid: false}) {
|
||||
t.Error("empty vs invalid should be equal")
|
||||
}
|
||||
if !textEq("hi", pgtype.Text{String: "hi", Valid: true}) {
|
||||
t.Error("same strings should be equal")
|
||||
}
|
||||
if textEq("hi", pgtype.Text{String: "bye", Valid: true}) {
|
||||
t.Error("different strings should not be equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTombstoneTTL(t *testing.T) {
|
||||
if TombstoneTTL != 30*24*time.Hour {
|
||||
t.Errorf("expected 30 days, got %v", TombstoneTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func pgHighlights(text, color, note string, pctStart, pctEnd float64) database.MediaHighlights {
|
||||
return database.MediaHighlights{
|
||||
SelectionText: text,
|
||||
Color: pgtype.Text{String: color, Valid: color != ""},
|
||||
NoteText: pgtype.Text{String: note, Valid: note != ""},
|
||||
PercentageStart: pgtype.Float8{Float64: pctStart, Valid: pctStart != 0},
|
||||
PercentageEnd: pgtype.Float8{Float64: pctEnd, Valid: pctEnd != 0},
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,449 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
func TestParseCREXPointer(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantFrag int
|
||||
wantPath int
|
||||
wantChar int
|
||||
}{
|
||||
{"/body/DocFragment[0]/body/div[4]/p[38]/text().541", 0, 2, 541},
|
||||
{"/body/DocFragment[2]/body/div/p[5]/text()[2].16", 2, 2, 16},
|
||||
{"/body/DocFragment[1]/body", 1, 0, 0},
|
||||
{"/body/DocFragment[5]/body/div[3]/p[28]", 5, 2, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
xp, err := ParseCREXPointer(tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("ParseCREXPointer(%q) error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
if xp.FragmentIndex != tt.wantFrag {
|
||||
t.Errorf("FragmentIndex = %d, want %d", xp.FragmentIndex, tt.wantFrag)
|
||||
}
|
||||
if len(xp.ElementPath) != tt.wantPath {
|
||||
t.Errorf("len(ElementPath) = %d, want %d (got %+v)", len(xp.ElementPath), tt.wantPath, xp.ElementPath)
|
||||
}
|
||||
if xp.CharOffset != tt.wantChar {
|
||||
t.Errorf("CharOffset = %d, want %d", xp.CharOffset, tt.wantChar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCREXPointerInvalid(t *testing.T) {
|
||||
_, err := ParseCREXPointer("epubcfi(/6/4!/4/2/1:0)")
|
||||
if err == nil {
|
||||
t.Error("expected error for standard epubcfi")
|
||||
}
|
||||
_, err = ParseCREXPointer("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCREXPointer(t *testing.T) {
|
||||
if !IsCREXPointer("/body/DocFragment[0]/body/div/p") {
|
||||
t.Error("should recognize CRE XPointer")
|
||||
}
|
||||
if !IsCREXPointer("#_doc_fragment_5_ link2HCH0002") {
|
||||
t.Error("should recognize CRE fragment ID")
|
||||
}
|
||||
if IsCREXPointer("epubcfi(/6/4!/4/2/1:0)") {
|
||||
t.Error("should not recognize standard epubcfi as CRE")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCREFragmentID(t *testing.T) {
|
||||
if !IsCREFragmentID("#_doc_fragment_5_ link2HCH0002") {
|
||||
t.Error("should recognize fragment ID")
|
||||
}
|
||||
if IsCREFragmentID("/body/DocFragment[2]/body") {
|
||||
t.Error("should not recognize XPointer as fragment ID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCREFragmentID(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantSpine int
|
||||
wantAnchor string
|
||||
}{
|
||||
{"#_doc_fragment_5_ link2HCH0002", 5, "link2HCH0002"},
|
||||
{"#_doc_fragment_0_", 0, ""},
|
||||
{"#_doc_fragment_12_someid123", 12, "someid123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
frag, err := ParseCREFragmentID(tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("ParseCREFragmentID(%q) error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
if frag.SpineIndex != tt.wantSpine {
|
||||
t.Errorf("SpineIndex = %d, want %d", frag.SpineIndex, tt.wantSpine)
|
||||
}
|
||||
if frag.Anchor != tt.wantAnchor {
|
||||
t.Errorf("Anchor = %q, want %q", frag.Anchor, tt.wantAnchor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCREFragmentIDInvalid(t *testing.T) {
|
||||
_, err := ParseCREFragmentID("/body/DocFragment[2]/body")
|
||||
if err == nil {
|
||||
t.Error("expected error for XPointer input")
|
||||
}
|
||||
_, err = ParseCREFragmentID("#_doc_fragment_")
|
||||
if err == nil {
|
||||
t.Error("expected error for missing index")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsStandardEPUBCFI(t *testing.T) {
|
||||
if !IsStandardEPUBCFI("epubcfi(/6/4!/4/2/1:0)") {
|
||||
t.Error("should recognize standard epubcfi")
|
||||
}
|
||||
if IsStandardEPUBCFI("/body/DocFragment[0]/body") {
|
||||
t.Error("should not recognize CRE as standard")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvert1984(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
xp := "/body/DocFragment[2]/body/div/p[5]/text().500"
|
||||
result, err := c.ConvertCREToStandard(xp, 0.01, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("Input: %s", xp)
|
||||
t.Logf("EPUBCFI: %s", result.EPUBCFI)
|
||||
t.Logf("Href: %s", result.Href)
|
||||
t.Logf("Precision: %s", result.Precision)
|
||||
t.Logf("Percentage: %.4f", result.Percentage)
|
||||
|
||||
if result.Precision == "percentage" {
|
||||
t.Error("expected better than percentage precision")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertCrimeAndPunishmentFragmentID(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
xp := "#_doc_fragment_5_ link2HCH0002"
|
||||
result, err := c.ConvertCREToStandard(xp, 0.0303, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("Input: %s", xp)
|
||||
t.Logf("EPUBCFI: %s", result.EPUBCFI)
|
||||
t.Logf("Href: %s", result.Href)
|
||||
t.Logf("Precision: %s", result.Precision)
|
||||
t.Logf("Percentage: %.4f", result.Percentage)
|
||||
|
||||
if result.Precision == "percentage" {
|
||||
t.Error("expected better than percentage precision")
|
||||
}
|
||||
if result.Href == "" {
|
||||
t.Error("expected non-empty href")
|
||||
}
|
||||
if result.Precision != "element" {
|
||||
t.Errorf("expected element precision, got %s", result.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEPUBCFI(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
wantSpine int
|
||||
wantSteps int
|
||||
}{
|
||||
{"epubcfi(/6/12!/4/2/90/1:7)", 5, 4},
|
||||
{"epubcfi(/6/4!/4/2/1:0)", 1, 3},
|
||||
{"epubcfi(/6/2!/4)", 0, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
spineIndex, steps, err := parseEPUBCFI(tt.input)
|
||||
if err != nil {
|
||||
t.Errorf("parseEPUBCFI(%q) error: %v", tt.input, err)
|
||||
continue
|
||||
}
|
||||
if spineIndex != tt.wantSpine {
|
||||
t.Errorf("spineIndex = %d, want %d", spineIndex, tt.wantSpine)
|
||||
}
|
||||
if len(steps) != tt.wantSteps {
|
||||
t.Errorf("len(steps) = %d, want %d", len(steps), tt.wantSteps)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEPUBCFIRange(t *testing.T) {
|
||||
spineIndex, steps, err := parseEPUBCFI("epubcfi(/6/40!/4,/24/20,/40/5:61)")
|
||||
if err != nil {
|
||||
t.Fatalf("parseEPUBCFI range error: %v", err)
|
||||
}
|
||||
if spineIndex != 19 {
|
||||
t.Errorf("spineIndex = %d, want 19", spineIndex)
|
||||
}
|
||||
t.Logf("Range CFI steps: %d", len(steps))
|
||||
for i, s := range steps {
|
||||
t.Logf(" step %d: index=%d offset=%d hasOffset=%v", i, s.Index, s.Offset, s.HasOffset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseEPUBCFIInvalid(t *testing.T) {
|
||||
_, _, err := parseEPUBCFI("not-a-cfi")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid CFI")
|
||||
}
|
||||
_, _, err = parseEPUBCFI("epubcfi(/6/12)")
|
||||
if err == nil {
|
||||
t.Error("expected error for CFI without indirection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip1984(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
originalXP := "/body/DocFragment[2]/body/div/p[5]/text().500"
|
||||
forward, err := c.ConvertCREToStandard(originalXP, 0.01, "")
|
||||
if err != nil {
|
||||
t.Fatalf("forward conversion error: %v", err)
|
||||
}
|
||||
if forward.EPUBCFI == "" {
|
||||
t.Fatal("forward conversion produced empty epubcfi")
|
||||
}
|
||||
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, "")
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Fatal("reverse conversion produced empty XPointer")
|
||||
}
|
||||
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
|
||||
t.Logf("Reverse precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTripCP(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
originalXP := "/body/DocFragment[6]/body/div/p[47]/text().2399"
|
||||
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
|
||||
forward, err := c.ConvertCREToStandard(originalXP, 0.0579, contextText)
|
||||
if err != nil {
|
||||
t.Fatalf("forward conversion error: %v", err)
|
||||
}
|
||||
if forward.EPUBCFI == "" {
|
||||
t.Fatal("forward conversion produced empty epubcfi")
|
||||
}
|
||||
t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI)
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, contextText)
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Fatal("reverse conversion produced empty XPointer")
|
||||
}
|
||||
t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer)
|
||||
t.Logf("Reverse precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", reverse.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseTextSearchFallback(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l"
|
||||
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.0579, contextText)
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
t.Logf("Text search fallback XPointer: %s", reverse.XPointer)
|
||||
t.Logf("Precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "exact" {
|
||||
t.Errorf("expected exact precision from text search, got %s", reverse.Precision)
|
||||
}
|
||||
if reverse.XPointer == "" {
|
||||
t.Error("expected non-empty XPointer from text search")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReversePercentageFallback(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
|
||||
c := NewCFIConverter(epubPath)
|
||||
|
||||
reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.5, "")
|
||||
if err != nil {
|
||||
t.Fatalf("reverse conversion error: %v", err)
|
||||
}
|
||||
t.Logf("Percentage fallback precision: %s", reverse.Precision)
|
||||
|
||||
if reverse.Precision != "percentage" {
|
||||
t.Errorf("expected percentage precision, got %s with XPointer %s", reverse.Precision, reverse.XPointer)
|
||||
}
|
||||
if reverse.XPointer != "" {
|
||||
t.Error("expected empty XPointer for percentage fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTextInNode_SingleTextNode(t *testing.T) {
|
||||
doc := parseTestHTML(`<html><body><p>Hello world this is a test</p></body></html>`)
|
||||
body := findBody(doc)
|
||||
|
||||
node, offset := findTextInNode(body, "Hello world")
|
||||
if node == nil {
|
||||
t.Fatal("expected to find text")
|
||||
}
|
||||
if offset != 0 {
|
||||
t.Errorf("offset = %d, want 0", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTextInNode_CrossEmElement(t *testing.T) {
|
||||
doc := parseTestHTML(`<html><body><p>the countries <em>Vokalia</em> and <em>Consonantia</em> live here</p></body></html>`)
|
||||
body := findBody(doc)
|
||||
|
||||
node, offset := findTextInNode(body, "Vokalia and Consonantia")
|
||||
if node == nil {
|
||||
t.Fatal("expected to find text across <em> elements")
|
||||
}
|
||||
if node.Data != "Vokalia" {
|
||||
t.Errorf("expected match in 'Vokalia' text node, got %q", node.Data)
|
||||
}
|
||||
if offset != 0 {
|
||||
t.Errorf("offset = %d, want 0", offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTextInNode_CrossStrongElement(t *testing.T) {
|
||||
doc := parseTestHTML(`<html><body><p>Some <strong>bold and italic</strong> text here</p></body></html>`)
|
||||
body := findBody(doc)
|
||||
|
||||
node, _ := findTextInNode(body, "bold and italic text")
|
||||
if node == nil {
|
||||
t.Fatal("expected to find text across <strong> boundary")
|
||||
}
|
||||
if node.Data != "bold and italic" {
|
||||
t.Errorf("expected match in 'bold and italic' text node, got %q", node.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTextInNode_DoesNotCrossParagraphs(t *testing.T) {
|
||||
doc := parseTestHTML(`<html><body><p>first paragraph</p><p>second paragraph</p></body></html>`)
|
||||
body := findBody(doc)
|
||||
|
||||
node, _ := findTextInNode(body, "paragraph second")
|
||||
if node != nil {
|
||||
t.Error("should not match text across <p> boundaries")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTextInNode_NestedFormatting(t *testing.T) {
|
||||
doc := parseTestHTML(`<html><body><p>before <em><strong>bold italic</strong></em> after</p></body></html>`)
|
||||
body := findBody(doc)
|
||||
|
||||
node, offset := findTextInNode(body, "bold italic after")
|
||||
if node == nil {
|
||||
t.Fatal("expected to find text across nested formatting")
|
||||
}
|
||||
if node.Data != "bold italic" {
|
||||
t.Errorf("expected match in 'bold italic' text node, got %q", node.Data)
|
||||
}
|
||||
_ = offset
|
||||
}
|
||||
|
||||
func TestFindBlockParent(t *testing.T) {
|
||||
doc := parseTestHTML(`<html><body><p>text <em>inside <strong>deep</strong></em></p></body></html>`)
|
||||
body := findBody(doc)
|
||||
|
||||
var deepNode *html.Node
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode && n.Data == "deep" {
|
||||
deepNode = n
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(body)
|
||||
|
||||
if deepNode == nil {
|
||||
t.Fatal("could not find 'deep' text node")
|
||||
}
|
||||
|
||||
block := findBlockParent(deepNode)
|
||||
if block == nil {
|
||||
t.Fatal("expected block parent")
|
||||
}
|
||||
if block.Data != "p" {
|
||||
t.Errorf("block parent = %q, want 'p'", block.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectInlineText(t *testing.T) {
|
||||
doc := parseTestHTML(`<html><body><p>the countries <em>Vokalia</em> and <em>Consonantia</em> live</p></body></html>`)
|
||||
body := findBody(doc)
|
||||
|
||||
var p *html.Node
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "p" {
|
||||
p = n
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(body)
|
||||
|
||||
if p == nil {
|
||||
t.Fatal("could not find <p> element")
|
||||
}
|
||||
|
||||
segments := collectInlineText(p)
|
||||
|
||||
var collected []rune
|
||||
for _, seg := range segments {
|
||||
collected = append(collected, seg.runes...)
|
||||
}
|
||||
|
||||
flattened := strings.TrimSpace(string(collected))
|
||||
if flattened != "the countries Vokalia and Consonantia live" {
|
||||
t.Errorf("collected text = %q", flattened)
|
||||
}
|
||||
}
|
||||
|
||||
func parseTestHTML(s string) *html.Node {
|
||||
doc, err := html.Parse(strings.NewReader(s))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type KEPUBCFIConverter struct {
|
||||
epubConverter *CFIConverter
|
||||
kepubConverter *CFIConverter
|
||||
}
|
||||
|
||||
type KEPUBConversionResult struct {
|
||||
CFI string
|
||||
ExtractedContext string
|
||||
Percentage float64
|
||||
Precision string
|
||||
}
|
||||
|
||||
func NewKEPUBCFIConverter(epubPath, kepubPath string) *KEPUBCFIConverter {
|
||||
return &KEPUBCFIConverter{
|
||||
epubConverter: NewCFIConverter(epubPath),
|
||||
kepubConverter: NewCFIConverter(kepubPath),
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KEPUBCFIConverter) ConvertKEPUBCFIToStandard(kepubCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) {
|
||||
if !IsStandardEPUBCFI(kepubCFI) {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
spineIndex, steps, err := parseEPUBCFI(kepubCFI)
|
||||
if err != nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
|
||||
if docErr != nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
kepubTextNode, kepubOffset, resolveErr := resolveCFIToNode(kepubDoc, steps)
|
||||
if resolveErr != nil {
|
||||
return k.kepubToStandardByPercentage(spineIndex, percentage)
|
||||
}
|
||||
|
||||
var searchText string
|
||||
if contextText != "" {
|
||||
searchText = normalizeWhitespace(contextText)
|
||||
} else if kepubTextNode != nil && kepubTextNode.Type == html.TextNode {
|
||||
searchText = extractSurroundingText(kepubTextNode, kepubOffset, 80)
|
||||
}
|
||||
|
||||
if searchText != "" {
|
||||
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
|
||||
if docErr == nil {
|
||||
epubBody := findBody(epubDoc)
|
||||
if epubBody != nil {
|
||||
matchNode, matchOffset := findTextInNode(epubBody, searchText)
|
||||
if matchNode != nil {
|
||||
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
|
||||
if buildErr == nil && cfi != "" {
|
||||
return &KEPUBConversionResult{
|
||||
CFI: cfi,
|
||||
ExtractedContext: searchText,
|
||||
Percentage: percentage,
|
||||
Precision: "exact",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, _ := k.kepubToStandardByPercentage(spineIndex, percentage)
|
||||
result.ExtractedContext = searchText
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (k *KEPUBCFIConverter) ConvertStandardCFIToKEPUB(standardCFI string, percentage float64, contextText string) (*KEPUBConversionResult, error) {
|
||||
if !IsStandardEPUBCFI(standardCFI) {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
spineIndex, steps, err := parseEPUBCFI(standardCFI)
|
||||
if err != nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
|
||||
if docErr != nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
epubTextNode, epubOffset, resolveErr := resolveCFIToNode(epubDoc, steps)
|
||||
if resolveErr != nil {
|
||||
return k.standardToKEPUBByPercentage(spineIndex, percentage)
|
||||
}
|
||||
|
||||
var searchText string
|
||||
if contextText != "" {
|
||||
searchText = normalizeWhitespace(contextText)
|
||||
} else if epubTextNode != nil && epubTextNode.Type == html.TextNode {
|
||||
searchText = extractSurroundingText(epubTextNode, epubOffset, 80)
|
||||
}
|
||||
|
||||
if searchText != "" {
|
||||
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
|
||||
if docErr == nil {
|
||||
kepubBody := findBody(kepubDoc)
|
||||
if kepubBody != nil {
|
||||
matchNode, matchOffset := findTextInNode(kepubBody, searchText)
|
||||
if matchNode != nil {
|
||||
cfi, buildErr := buildCFI(spineIndex, matchNode, matchOffset)
|
||||
if buildErr == nil && cfi != "" {
|
||||
return &KEPUBConversionResult{
|
||||
CFI: cfi,
|
||||
ExtractedContext: searchText,
|
||||
Percentage: percentage,
|
||||
Precision: "exact",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result, _ := k.standardToKEPUBByPercentage(spineIndex, percentage)
|
||||
result.ExtractedContext = searchText
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (k *KEPUBCFIConverter) kepubToStandardByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) {
|
||||
if percentage <= 0 {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
epubDoc, _, docErr := k.epubConverter.getContentDoc(spineIndex + 1)
|
||||
if docErr != nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
epubBody := findBody(epubDoc)
|
||||
if epubBody == nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
totalChars := countTextChars(epubBody)
|
||||
if totalChars <= 0 {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
targetOffset := int(float64(totalChars) * percentage)
|
||||
targetNode, foundOffset := findNodeAtCharOffset(epubBody, targetOffset)
|
||||
if targetNode == nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
charInNode := targetOffset - foundOffset
|
||||
cfi, err := buildCFI(spineIndex, targetNode, charInNode)
|
||||
if err != nil || cfi == "" {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
return &KEPUBConversionResult{
|
||||
CFI: cfi,
|
||||
Percentage: percentage,
|
||||
Precision: "percentage",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (k *KEPUBCFIConverter) standardToKEPUBByPercentage(spineIndex int, percentage float64) (*KEPUBConversionResult, error) {
|
||||
if percentage <= 0 {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
|
||||
if docErr != nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
kepubBody := findBody(kepubDoc)
|
||||
if kepubBody == nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
totalChars := countTextChars(kepubBody)
|
||||
if totalChars <= 0 {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
targetOffset := int(float64(totalChars) * percentage)
|
||||
targetNode, foundOffset := findNodeAtCharOffset(kepubBody, targetOffset)
|
||||
if targetNode == nil {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
charInNode := targetOffset - foundOffset
|
||||
cfi, err := buildCFI(spineIndex, targetNode, charInNode)
|
||||
if err != nil || cfi == "" {
|
||||
return &KEPUBConversionResult{Percentage: percentage, Precision: "percentage"}, nil
|
||||
}
|
||||
|
||||
return &KEPUBConversionResult{
|
||||
CFI: cfi,
|
||||
Percentage: percentage,
|
||||
Precision: "percentage",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func extractSurroundingText(textNode *html.Node, offset int, window int) string {
|
||||
if textNode == nil || textNode.Type != html.TextNode {
|
||||
return ""
|
||||
}
|
||||
|
||||
block := findBlockParent(textNode)
|
||||
if block == nil {
|
||||
text := textNode.Data
|
||||
runes := []rune(text)
|
||||
start := offset - window
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := offset + window
|
||||
if end > len(runes) {
|
||||
end = len(runes)
|
||||
}
|
||||
if start >= end {
|
||||
return normalizeWhitespace(text)
|
||||
}
|
||||
return normalizeWhitespace(string(runes[start:end]))
|
||||
}
|
||||
|
||||
segments := collectInlineText(block)
|
||||
|
||||
globalOffset := 0
|
||||
for _, seg := range segments {
|
||||
if seg.node == textNode {
|
||||
globalOffset += offset
|
||||
break
|
||||
}
|
||||
globalOffset += len(seg.runes)
|
||||
}
|
||||
|
||||
var allRunes []rune
|
||||
for _, seg := range segments {
|
||||
allRunes = append(allRunes, seg.runes...)
|
||||
}
|
||||
|
||||
start := globalOffset - window
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end := globalOffset + window
|
||||
if end > len(allRunes) {
|
||||
end = len(allRunes)
|
||||
}
|
||||
|
||||
if start >= end {
|
||||
return normalizeWhitespace(string(allRunes))
|
||||
}
|
||||
|
||||
return normalizeWhitespace(string(allRunes[start:end]))
|
||||
}
|
||||
|
||||
func (k *KEPUBCFIConverter) ComputeKEPUBPercentage(kepubCFI string) (float64, error) {
|
||||
if !IsStandardEPUBCFI(kepubCFI) {
|
||||
return -1, fmt.Errorf("not a standard epubcfi")
|
||||
}
|
||||
|
||||
spineIndex, steps, err := parseEPUBCFI(kepubCFI)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
kepubDoc, _, docErr := k.kepubConverter.getContentDoc(spineIndex + 1)
|
||||
if docErr != nil {
|
||||
return -1, docErr
|
||||
}
|
||||
|
||||
kepubBody := findBody(kepubDoc)
|
||||
if kepubBody == nil {
|
||||
return -1, fmt.Errorf("no body in kepub doc")
|
||||
}
|
||||
|
||||
textNode, textOffset, resolveErr := resolveCFIToNode(kepubBody, steps)
|
||||
if resolveErr != nil {
|
||||
return -1, resolveErr
|
||||
}
|
||||
|
||||
charOffset := countTextCharsBefore(textNode) + textOffset
|
||||
|
||||
total := 0
|
||||
kepubSpine, spineErr := k.kepubConverter.loadSpine()
|
||||
if spineErr != nil {
|
||||
return -1, spineErr
|
||||
}
|
||||
|
||||
for i := range kepubSpine.items {
|
||||
doc, _, dErr := k.kepubConverter.getContentDoc(i + 1)
|
||||
if dErr != nil {
|
||||
continue
|
||||
}
|
||||
b := findBody(doc)
|
||||
if b != nil {
|
||||
total += countTextChars(b)
|
||||
}
|
||||
}
|
||||
|
||||
if total <= 0 {
|
||||
return -1, fmt.Errorf("no text in kepub")
|
||||
}
|
||||
|
||||
return float64(charOffset) / float64(total), nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
_ = log.Printf
|
||||
}
|
||||
@@ -0,0 +1,884 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const epubChapter1 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>Test Book</title></head>
|
||||
<body>
|
||||
<h1 id="ch1">Chapter One</h1>
|
||||
<p>It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair.</p>
|
||||
<p id="p2">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.</p>
|
||||
<p>Far far away, behind the word mountains, far from the countries <em>Vokalia</em> and <em>Consonantia</em>, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.</p>
|
||||
<p>A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my whole heart. I am alone, and feel the charm of existence in this spot, which was created for the bliss of souls like mine.</p>
|
||||
<p>I should be incapable of drawing a single stroke at the present moment; and yet I feel that I never was a greater artist than now. When, while the lovely valley teems with vapour around me.</p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const epubChapter2 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>Test Book</title></head>
|
||||
<body>
|
||||
<h1 id="ch2">Chapter Two</h1>
|
||||
<p>Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.</p>
|
||||
<p>It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul.</p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const kepubChapter1 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>Test Book</title></head>
|
||||
<body>
|
||||
<h1 id="ch1">Chapter One</h1>
|
||||
<p><span class="koboSpan" id="kobo.1.1">It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of Darkness, it was the spring of hope, it was the winter of despair.</span></p>
|
||||
<p id="p2"><span class="koboSpan" id="kobo.2.1">The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs. How vexingly quick daft zebras jump.</span></p>
|
||||
<p><span class="koboSpan" id="kobo.3.1">Far far away, behind the word mountains, far from the countries </span><em><span class="koboSpan" id="kobo.3.2">Vokalia</span></em><span class="koboSpan" id="kobo.3.3"> and </span><em><span class="koboSpan" id="kobo.3.4">Consonantia</span></em><span class="koboSpan" id="kobo.3.5">, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean.</span></p>
|
||||
<p><span class="koboSpan" id="kobo.4.1">A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my whole heart. I am alone, and feel the charm of existence in this spot, which was created for the bliss of souls like mine.</span></p>
|
||||
<p><span class="koboSpan" id="kobo.5.1">I should be incapable of drawing a single stroke at the present moment; and yet I feel that I never was a greater artist than now. When, while the lovely valley teems with vapour around me.</span></p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const kepubChapter2 = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>Test Book</title></head>
|
||||
<body>
|
||||
<h1 id="ch2">Chapter Two</h1>
|
||||
<p><span class="koboSpan" id="kobo.6.1">Call me Ishmael. Some years ago—never mind how long precisely—having little or no money in my purse, and nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of the world.</span></p>
|
||||
<p><span class="koboSpan" id="kobo.7.1">It is a way I have of driving off the spleen and regulating the circulation. Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul.</span></p>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
const containerXML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
|
||||
<rootfiles>
|
||||
<rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/>
|
||||
</rootfiles>
|
||||
</container>`
|
||||
|
||||
const contentOPF = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="uid">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:identifier id="uid">urn:uuid:test-book-00000001</dc:identifier>
|
||||
<dc:title>Test Book</dc:title>
|
||||
<dc:language>en</dc:language>
|
||||
<meta property="dcterms:modified">2026-01-01T00:00:00Z</meta>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="ch1" href="chapter1.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="ch2" href="chapter2.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>
|
||||
</manifest>
|
||||
<spine>
|
||||
<itemref idref="ch1"/>
|
||||
<itemref idref="ch2"/>
|
||||
</spine>
|
||||
</package>`
|
||||
|
||||
const navXHTML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
|
||||
<head><title>TOC</title></head>
|
||||
<body>
|
||||
<nav epub:type="toc"><ol><li><a href="chapter1.xhtml">Chapter 1</a></li><li><a href="chapter2.xhtml">Chapter 2</a></li></ol></nav>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
func createTestEPUB(t *testing.T, dir, name, ch1Content, ch2Content string) string {
|
||||
t.Helper()
|
||||
epubPath := filepath.Join(dir, name)
|
||||
f, err := os.Create(epubPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
|
||||
files := map[string]string{
|
||||
"mimetype": "application/epub+zip",
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/content.opf": contentOPF,
|
||||
"OEBPS/nav.xhtml": navXHTML,
|
||||
"OEBPS/chapter1.xhtml": ch1Content,
|
||||
"OEBPS/chapter2.xhtml": ch2Content,
|
||||
}
|
||||
|
||||
mimetype, _ := w.Create("mimetype")
|
||||
mimetype.Write([]byte("application/epub+zip"))
|
||||
|
||||
for path, content := range files {
|
||||
if path == "mimetype" {
|
||||
continue
|
||||
}
|
||||
fw, err := w.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write([]byte(content))
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return epubPath
|
||||
}
|
||||
|
||||
func setupKEPUBTestEnv(t *testing.T) (epubPath, kepubPath string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
epubPath = createTestEPUB(t, dir, "test.epub", epubChapter1, epubChapter2)
|
||||
kepubPath = createTestEPUB(t, dir, "test.kepub.epub", kepubChapter1, kepubChapter2)
|
||||
return epubPath, kepubPath
|
||||
}
|
||||
|
||||
func TestKEPUBConvertKEPUBToStandard_TextSearch(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubSpine, err := epubOnly.loadSpine()
|
||||
if err != nil {
|
||||
t.Fatalf("load epub spine: %v", err)
|
||||
}
|
||||
if len(epubSpine.items) < 1 {
|
||||
t.Fatal("expected at least 1 spine item")
|
||||
}
|
||||
|
||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get epub content doc: %v", err)
|
||||
}
|
||||
epubBody := findBody(epubDoc)
|
||||
if epubBody == nil {
|
||||
t.Fatal("no body in epub doc")
|
||||
}
|
||||
|
||||
searchText := normalizeWhitespace("it was the worst of times")
|
||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
||||
if epubNode == nil {
|
||||
t.Fatal("could not find target text in EPUB")
|
||||
}
|
||||
|
||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
||||
if err != nil {
|
||||
t.Fatalf("build standard CFI: %v", err)
|
||||
}
|
||||
t.Logf("Standard EPUB CFI: %s", standardCFI)
|
||||
|
||||
kepubOnly := NewCFIConverter(kepubPath)
|
||||
kepubDoc, _, err := kepubOnly.getContentDoc(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get kepub content doc: %v", err)
|
||||
}
|
||||
kepubBody := findBody(kepubDoc)
|
||||
if kepubBody == nil {
|
||||
t.Fatal("no body in kepub doc")
|
||||
}
|
||||
|
||||
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
|
||||
if kepubNode == nil {
|
||||
t.Fatal("could not find target text in KEPUB")
|
||||
}
|
||||
|
||||
kepubCFI, err := buildCFI(0, kepubNode, kepubOffset)
|
||||
if err != nil {
|
||||
t.Fatalf("build kepub CFI: %v", err)
|
||||
}
|
||||
t.Logf("KEPUB CFI: %s", kepubCFI)
|
||||
|
||||
if standardCFI == kepubCFI {
|
||||
t.Error("KEPUB and standard CFIs should differ due to koboSpan wrappers")
|
||||
}
|
||||
|
||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.05, searchText)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertKEPUBCFIToStandard error: %v", err)
|
||||
}
|
||||
t.Logf("Converted CFI: %s (precision: %s)", result.CFI, result.Precision)
|
||||
|
||||
if result.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", result.Precision)
|
||||
}
|
||||
if result.CFI == "" {
|
||||
t.Fatal("expected non-empty CFI")
|
||||
}
|
||||
|
||||
spineIdx1, steps1, _ := parseEPUBCFI(result.CFI)
|
||||
spineIdx2, steps2, _ := parseEPUBCFI(standardCFI)
|
||||
if spineIdx1 != spineIdx2 {
|
||||
t.Errorf("spine indices differ: %d vs %d", spineIdx1, spineIdx2)
|
||||
}
|
||||
|
||||
resultNode, _, err := resolveCFIToNode(epubDoc, steps1)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve converted CFI: %v", err)
|
||||
}
|
||||
expectedNode, _, _ := resolveCFIToNode(epubDoc, steps2)
|
||||
|
||||
if resultNode != expectedNode {
|
||||
t.Errorf("resolved to different text nodes: got %q, want %q",
|
||||
truncateText(resultNode.Data, 40),
|
||||
truncateText(expectedNode.Data, 40))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBConvertStandardToKEPUB_TextSearch(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get epub doc: %v", err)
|
||||
}
|
||||
epubBody := findBody(epubDoc)
|
||||
|
||||
searchText := normalizeWhitespace("The quick brown fox jumps over the lazy dog")
|
||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
||||
if epubNode == nil {
|
||||
t.Fatal("could not find target text in EPUB")
|
||||
}
|
||||
|
||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
||||
if err != nil {
|
||||
t.Fatalf("build CFI: %v", err)
|
||||
}
|
||||
t.Logf("Standard CFI: %s", standardCFI)
|
||||
|
||||
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.1, searchText)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertStandardCFIToKEPUB error: %v", err)
|
||||
}
|
||||
t.Logf("KEPUB CFI: %s (precision: %s)", result.CFI, result.Precision)
|
||||
|
||||
if result.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", result.Precision)
|
||||
}
|
||||
if result.CFI == "" {
|
||||
t.Fatal("expected non-empty CFI")
|
||||
}
|
||||
|
||||
if !strings.Contains(result.CFI, "kobo") {
|
||||
t.Errorf("KEPUB CFI should contain koboSpan step: %s", result.CFI)
|
||||
}
|
||||
|
||||
backResult, err := converter.ConvertKEPUBCFIToStandard(result.CFI, 0.1, searchText)
|
||||
if err != nil {
|
||||
t.Fatalf("KEPUB→standard round-trip: %v", err)
|
||||
}
|
||||
if backResult.CFI != standardCFI {
|
||||
t.Errorf("round-trip mismatch: got %s, want %s", backResult.CFI, standardCFI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBRoundTrip(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get epub doc: %v", err)
|
||||
}
|
||||
epubBody := findBody(epubDoc)
|
||||
|
||||
phrases := []string{
|
||||
"It was the best of times",
|
||||
"The quick brown fox jumps",
|
||||
"A wonderful serenity has taken possession",
|
||||
}
|
||||
|
||||
for _, phrase := range phrases {
|
||||
t.Run(phrase, func(t *testing.T) {
|
||||
searchText := normalizeWhitespace(phrase)
|
||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
||||
if epubNode == nil {
|
||||
t.Fatalf("could not find %q in epub", phrase)
|
||||
}
|
||||
|
||||
originalCFI, err := buildCFI(0, epubNode, epubOffset)
|
||||
if err != nil {
|
||||
t.Fatalf("build CFI: %v", err)
|
||||
}
|
||||
t.Logf("Original standard CFI: %s", originalCFI)
|
||||
|
||||
toKEPUB, err := converter.ConvertStandardCFIToKEPUB(originalCFI, 0.1, searchText)
|
||||
if err != nil {
|
||||
t.Fatalf("standard→KEPUB: %v", err)
|
||||
}
|
||||
if toKEPUB.Precision != "exact" {
|
||||
t.Fatalf("expected exact precision in standard→KEPUB, got %s", toKEPUB.Precision)
|
||||
}
|
||||
t.Logf("KEPUB CFI: %s", toKEPUB.CFI)
|
||||
|
||||
backToStandard, err := converter.ConvertKEPUBCFIToStandard(toKEPUB.CFI, 0.1, searchText)
|
||||
if err != nil {
|
||||
t.Fatalf("KEPUB→standard: %v", err)
|
||||
}
|
||||
if backToStandard.Precision != "exact" {
|
||||
t.Fatalf("expected exact precision in KEPUB→standard, got %s", backToStandard.Precision)
|
||||
}
|
||||
t.Logf("Round-trip standard CFI: %s", backToStandard.CFI)
|
||||
|
||||
origSpine, origSteps, _ := parseEPUBCFI(originalCFI)
|
||||
rtSpine, rtSteps, _ := parseEPUBCFI(backToStandard.CFI)
|
||||
|
||||
if origSpine != rtSpine {
|
||||
t.Errorf("spine mismatch: original=%d roundtrip=%d", origSpine, rtSpine)
|
||||
}
|
||||
|
||||
origNode, _, _ := resolveCFIToNode(epubDoc, origSteps)
|
||||
rtNode, _, _ := resolveCFIToNode(epubDoc, rtSteps)
|
||||
|
||||
if origNode != rtNode {
|
||||
t.Errorf("resolved to different nodes:\n original: %q\n roundtrip: %q",
|
||||
truncateText(origNode.Data, 50),
|
||||
truncateText(rtNode.Data, 50))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBConvertKEPUBToStandard_Chapter2(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
searchText := normalizeWhitespace("Call me Ishmael")
|
||||
|
||||
kepubOnly := NewCFIConverter(kepubPath)
|
||||
kepubDoc, _, err := kepubOnly.getContentDoc(2)
|
||||
if err != nil {
|
||||
t.Fatalf("get kepub doc ch2: %v", err)
|
||||
}
|
||||
kepubBody := findBody(kepubDoc)
|
||||
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
|
||||
if kepubNode == nil {
|
||||
t.Fatal("could not find text in kepub ch2")
|
||||
}
|
||||
|
||||
kepubCFI, err := buildCFI(1, kepubNode, kepubOffset)
|
||||
if err != nil {
|
||||
t.Fatalf("build kepub CFI: %v", err)
|
||||
}
|
||||
t.Logf("KEPUB CFI (ch2): %s", kepubCFI)
|
||||
|
||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.55, searchText)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertKEPUBCFIToStandard: %v", err)
|
||||
}
|
||||
|
||||
if result.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", result.Precision)
|
||||
}
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubDoc, _, _ := epubOnly.getContentDoc(2)
|
||||
epubBody := findBody(epubDoc)
|
||||
|
||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
||||
expectedCFI, _ := buildCFI(1, epubNode, epubOffset)
|
||||
t.Logf("Expected standard CFI: %s", expectedCFI)
|
||||
t.Logf("Got standard CFI: %s", result.CFI)
|
||||
|
||||
_, resultSteps, _ := parseEPUBCFI(result.CFI)
|
||||
resultNode, _, resolveErr := resolveCFIToNode(epubDoc, resultSteps)
|
||||
if resolveErr != nil {
|
||||
t.Fatalf("resolve result CFI: %v", resolveErr)
|
||||
}
|
||||
|
||||
if resultNode == nil {
|
||||
t.Fatal("resolved to nil node")
|
||||
}
|
||||
|
||||
if !strings.Contains(strings.ToLower(resultNode.Data), "call me ishmael") {
|
||||
t.Errorf("resolved to wrong text: %q", truncateText(resultNode.Data, 50))
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBConvertWithEmElements(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
searchText := normalizeWhitespace("Vokalia and Consonantia")
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get epub doc: %v", err)
|
||||
}
|
||||
epubBody := findBody(epubDoc)
|
||||
|
||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
||||
if epubNode == nil {
|
||||
t.Fatalf("findTextInNode should find text spanning <em> elements: %q", searchText)
|
||||
}
|
||||
|
||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
||||
if err != nil {
|
||||
t.Fatalf("build CFI: %v", err)
|
||||
}
|
||||
t.Logf("Standard CFI (em): %s", standardCFI)
|
||||
|
||||
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.2, searchText)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertStandardCFIToKEPUB: %v", err)
|
||||
}
|
||||
t.Logf("KEPUB CFI (em elements): %s (precision: %s)", result.CFI, result.Precision)
|
||||
|
||||
if result.CFI == "" {
|
||||
t.Error("expected non-empty CFI")
|
||||
}
|
||||
if result.Precision != "exact" {
|
||||
t.Errorf("expected exact precision for cross-element text, got %s", result.Precision)
|
||||
}
|
||||
|
||||
backResult, err := converter.ConvertKEPUBCFIToStandard(result.CFI, 0.2, searchText)
|
||||
if err != nil {
|
||||
t.Fatalf("KEPUB→standard round-trip: %v", err)
|
||||
}
|
||||
if backResult.Precision != "exact" {
|
||||
t.Errorf("expected exact precision on round-trip, got %s", backResult.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBConvertInvalidCFI(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
result, err := converter.ConvertKEPUBCFIToStandard("not-a-cfi", 0.5, "some text")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Precision != "percentage" {
|
||||
t.Errorf("expected percentage fallback, got %s", result.Precision)
|
||||
}
|
||||
|
||||
result, err = converter.ConvertStandardCFIToKEPUB("not-a-cfi", 0.5, "some text")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Precision != "percentage" {
|
||||
t.Errorf("expected percentage fallback, got %s", result.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBConvertNoContextText(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get epub doc: %v", err)
|
||||
}
|
||||
epubBody := findBody(epubDoc)
|
||||
|
||||
epubNode, epubOffset := findTextInNode(epubBody, normalizeWhitespace("It was the best of times"))
|
||||
if epubNode == nil {
|
||||
t.Fatal("could not find text")
|
||||
}
|
||||
|
||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
||||
if err != nil {
|
||||
t.Fatalf("build CFI: %v", err)
|
||||
}
|
||||
|
||||
result, err := converter.ConvertStandardCFIToKEPUB(standardCFI, 0.1, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertStandardCFIToKEPUB (no context): %v", err)
|
||||
}
|
||||
t.Logf("No-context result: CFI=%s precision=%s", result.CFI, result.Precision)
|
||||
|
||||
if result.CFI == "" {
|
||||
t.Error("expected non-empty CFI even without context text")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBConvertKEPUBToStandard_NoContextText(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
kepubOnly := NewCFIConverter(kepubPath)
|
||||
kepubDoc, _, err := kepubOnly.getContentDoc(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get kepub doc: %v", err)
|
||||
}
|
||||
kepubBody := findBody(kepubDoc)
|
||||
|
||||
kepubNode, kepubOffset := findTextInNode(kepubBody, normalizeWhitespace("It was the best of times"))
|
||||
if kepubNode == nil {
|
||||
t.Fatal("could not find text in kepub")
|
||||
}
|
||||
|
||||
kepubCFI, err := buildCFI(0, kepubNode, kepubOffset)
|
||||
if err != nil {
|
||||
t.Fatalf("build CFI: %v", err)
|
||||
}
|
||||
t.Logf("KEPUB CFI: %s", kepubCFI)
|
||||
|
||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.05, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertKEPUBCFIToStandard (no context): %v", err)
|
||||
}
|
||||
t.Logf("No-context result: CFI=%s precision=%s", result.CFI, result.Precision)
|
||||
|
||||
if result.CFI == "" {
|
||||
t.Error("expected non-empty CFI even without context text")
|
||||
}
|
||||
|
||||
if result.Precision == "exact" {
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubDoc, _, _ := epubOnly.getContentDoc(1)
|
||||
epubBody := findBody(epubDoc)
|
||||
|
||||
expectedNode, _ := findTextInNode(epubBody, normalizeWhitespace("It was the best of times"))
|
||||
_, resultSteps, _ := parseEPUBCFI(result.CFI)
|
||||
actualNode, _, _ := resolveCFIToNode(epubDoc, resultSteps)
|
||||
|
||||
if actualNode != expectedNode {
|
||||
t.Errorf("resolved to wrong node: got %q, want text containing 'It was the best of times'",
|
||||
truncateText(actualNode.Data, 40))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBPercentageFallback(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
badCFI := "epubcfi(/6/2!/4/99999/1:0)"
|
||||
result, err := converter.ConvertKEPUBCFIToStandard(badCFI, 0.5, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Precision != "percentage" {
|
||||
t.Errorf("expected percentage precision for unresolvable CFI, got %s", result.Precision)
|
||||
}
|
||||
|
||||
result2, err := converter.ConvertStandardCFIToKEPUB(badCFI, 0.5, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result2.Precision != "percentage" {
|
||||
t.Errorf("expected percentage precision for unresolvable CFI, got %s", result2.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSurroundingText(t *testing.T) {
|
||||
text := "Hello world, this is a test of the surrounding text extraction function."
|
||||
runes := []rune(text)
|
||||
|
||||
tests := []struct {
|
||||
offset int
|
||||
window int
|
||||
want string
|
||||
}{
|
||||
{30, 10, "a test of the surro"},
|
||||
{0, 5, "Hello"},
|
||||
{len(runes) - 1, 5, "ction."},
|
||||
{15, 100, text},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
node := &html.Node{Type: html.TextNode, Data: text}
|
||||
got := extractSurroundingText(node, tt.offset, tt.window)
|
||||
if got != tt.want {
|
||||
t.Errorf("extractSurroundingText(offset=%d, window=%d) = %q, want %q", tt.offset, tt.window, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBMultipleParagraphsRoundTrip(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubDoc, _, _ := epubOnly.getContentDoc(1)
|
||||
epubBody := findBody(epubDoc)
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
searchText string
|
||||
percentage float64
|
||||
}
|
||||
|
||||
cases := []testCase{
|
||||
{"first paragraph", "It was the best of times", 0.01},
|
||||
{"second paragraph", "Pack my box with five dozen", 0.15},
|
||||
{"third paragraph", "Far far away, behind the word mountains", 0.25},
|
||||
{"fourth paragraph", "A wonderful serenity has taken possession", 0.55},
|
||||
{"fifth paragraph", "I should be incapable of drawing", 0.80},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
text := normalizeWhitespace(tc.searchText)
|
||||
epubNode, epubOffset := findTextInNode(epubBody, text)
|
||||
if epubNode == nil {
|
||||
t.Fatalf("could not find %q in epub", tc.searchText)
|
||||
}
|
||||
|
||||
standardCFI, _ := buildCFI(0, epubNode, epubOffset)
|
||||
|
||||
kepubResult, err := converter.ConvertStandardCFIToKEPUB(standardCFI, tc.percentage, text)
|
||||
if err != nil {
|
||||
t.Fatalf("standard→KEPUB: %v", err)
|
||||
}
|
||||
|
||||
backResult, err := converter.ConvertKEPUBCFIToStandard(kepubResult.CFI, tc.percentage, text)
|
||||
if err != nil {
|
||||
t.Fatalf("KEPUB→standard: %v", err)
|
||||
}
|
||||
|
||||
if backResult.Precision != "exact" {
|
||||
t.Errorf("round-trip precision = %s, want exact (CFI: %s)", backResult.Precision, backResult.CFI)
|
||||
}
|
||||
|
||||
_, origSteps, _ := parseEPUBCFI(standardCFI)
|
||||
_, rtSteps, _ := parseEPUBCFI(backResult.CFI)
|
||||
|
||||
origNode, _, _ := resolveCFIToNode(epubDoc, origSteps)
|
||||
rtNode, _, _ := resolveCFIToNode(epubDoc, rtSteps)
|
||||
|
||||
if origNode != rtNode {
|
||||
t.Errorf("round-trip resolved to different nodes:\n orig: %q\n rt: %q",
|
||||
truncateText(origNode.Data, 40),
|
||||
truncateText(rtNode.Data, 40))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKEPUBCFIsDifferFromStandard(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubDoc, _, _ := epubOnly.getContentDoc(1)
|
||||
epubBody := findBody(epubDoc)
|
||||
|
||||
kepubOnly := NewCFIConverter(kepubPath)
|
||||
kepubDoc, _, _ := kepubOnly.getContentDoc(1)
|
||||
kepubBody := findBody(kepubDoc)
|
||||
|
||||
text := normalizeWhitespace("It was the best of times")
|
||||
|
||||
epubNode, epubOffset := findTextInNode(epubBody, text)
|
||||
kepubNode, kepubOffset := findTextInNode(kepubBody, text)
|
||||
|
||||
if epubNode == nil || kepubNode == nil {
|
||||
t.Fatal("could not find text in one or both docs")
|
||||
}
|
||||
|
||||
epubCFI, _ := buildCFI(0, epubNode, epubOffset)
|
||||
kepubCFI, _ := buildCFI(0, kepubNode, kepubOffset)
|
||||
|
||||
t.Logf("EPUB CFI: %s", epubCFI)
|
||||
t.Logf("KEPUB CFI: %s", kepubCFI)
|
||||
|
||||
if epubCFI == kepubCFI {
|
||||
t.Error("EPUB and KEPUB CFIs should differ due to koboSpan wrappers")
|
||||
}
|
||||
|
||||
_, epubSteps, _ := parseEPUBCFI(epubCFI)
|
||||
_, kepubSteps, _ := parseEPUBCFI(kepubCFI)
|
||||
|
||||
if len(kepubSteps) <= len(epubSteps) {
|
||||
t.Errorf("KEPUB CFI should have more steps than EPUB CFI (koboSpan adds nesting): epub=%d kepub=%d",
|
||||
len(epubSteps), len(kepubSteps))
|
||||
}
|
||||
}
|
||||
|
||||
func truncateText(s string, maxRunes int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxRunes {
|
||||
return s
|
||||
}
|
||||
return string(runes[:maxRunes]) + "..."
|
||||
}
|
||||
|
||||
func TestKEPUBChapterSpineIndices(t *testing.T) {
|
||||
epubPath, kepubPath := setupKEPUBTestEnv(t)
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
kepubOnly := NewCFIConverter(kepubPath)
|
||||
|
||||
epubSpine, err := epubOnly.loadSpine()
|
||||
if err != nil {
|
||||
t.Fatalf("load epub spine: %v", err)
|
||||
}
|
||||
kepubSpine, err := kepubOnly.loadSpine()
|
||||
if err != nil {
|
||||
t.Fatalf("load kepub spine: %v", err)
|
||||
}
|
||||
|
||||
if len(epubSpine.items) != len(kepubSpine.items) {
|
||||
t.Errorf("spine count mismatch: epub=%d kepub=%d", len(epubSpine.items), len(kepubSpine.items))
|
||||
}
|
||||
|
||||
for i := range epubSpine.items {
|
||||
if epubSpine.items[i].href != kepubSpine.items[i].href {
|
||||
t.Errorf("spine item %d href mismatch: epub=%s kepub=%s",
|
||||
i, epubSpine.items[i].href, kepubSpine.items[i].href)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Both files have %d spine items, matching correctly", len(epubSpine.items))
|
||||
}
|
||||
|
||||
func TestKEPUBRealBookConvert(t *testing.T) {
|
||||
epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Charles Dickens/A Tale of Two Cities (111)/A Tale of Two Cities - Charles Dickens.epub"
|
||||
if _, err := os.Stat(epubPath); err != nil {
|
||||
t.Skipf("EPUB not found: %s", epubPath)
|
||||
}
|
||||
|
||||
dir := t.TempDir()
|
||||
kepubPath := filepath.Join(dir, "test.kepub.epub")
|
||||
|
||||
epubOnly := NewCFIConverter(epubPath)
|
||||
epubDoc, _, err := epubOnly.getContentDoc(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get epub doc: %v", err)
|
||||
}
|
||||
epubBody := findBody(epubDoc)
|
||||
if epubBody == nil {
|
||||
t.Fatal("no body")
|
||||
}
|
||||
|
||||
createMinimalKEPUBFromEPUB(t, epubPath, kepubPath)
|
||||
|
||||
kepubOnly := NewCFIConverter(kepubPath)
|
||||
kepubDoc, _, err := kepubOnly.getContentDoc(1)
|
||||
if err != nil {
|
||||
t.Fatalf("get kepub doc: %v", err)
|
||||
}
|
||||
kepubBody := findBody(kepubDoc)
|
||||
|
||||
searchText := normalizeWhitespace("It was the best of times")
|
||||
epubNode, epubOffset := findTextInNode(epubBody, searchText)
|
||||
if epubNode == nil {
|
||||
t.Skip("phrase not found in epub")
|
||||
}
|
||||
|
||||
standardCFI, err := buildCFI(0, epubNode, epubOffset)
|
||||
if err != nil {
|
||||
t.Fatalf("build standard CFI: %v", err)
|
||||
}
|
||||
t.Logf("Standard CFI: %s", standardCFI)
|
||||
|
||||
kepubNode, kepubOffset := findTextInNode(kepubBody, searchText)
|
||||
if kepubNode == nil {
|
||||
t.Skip("phrase not found in kepub")
|
||||
}
|
||||
kepubCFI, _ := buildCFI(0, kepubNode, kepubOffset)
|
||||
t.Logf("KEPUB CFI: %s", kepubCFI)
|
||||
|
||||
if standardCFI == kepubCFI {
|
||||
t.Log("Note: CFIs are same (koboSpan wrappers may not have changed structure in this position)")
|
||||
}
|
||||
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
result, err := converter.ConvertKEPUBCFIToStandard(kepubCFI, 0.01, searchText)
|
||||
if err != nil {
|
||||
t.Fatalf("ConvertKEPUBCFIToStandard: %v", err)
|
||||
}
|
||||
t.Logf("Converted: CFI=%s precision=%s", result.CFI, result.Precision)
|
||||
|
||||
if result.Precision != "exact" {
|
||||
t.Errorf("expected exact precision, got %s", result.Precision)
|
||||
}
|
||||
}
|
||||
|
||||
func createMinimalKEPUBFromEPUB(t *testing.T, epubPath, kepubPath string) {
|
||||
t.Helper()
|
||||
|
||||
r, err := zip.OpenReader(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open epub: %v", err)
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
f, err := os.Create(kepubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("create kepub: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := zip.NewWriter(f)
|
||||
|
||||
for _, file := range r.File {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", file.Name, err)
|
||||
}
|
||||
|
||||
fw, err := w.Create(file.Name)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
t.Fatalf("create %s: %v", file.Name, err)
|
||||
}
|
||||
|
||||
if isXHTML(file.Name) {
|
||||
var buf []byte
|
||||
buf, err = readZipFileData(rc)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
t.Fatalf("read %s: %v", file.Name, err)
|
||||
}
|
||||
|
||||
kepubContent := addKoboSpans(string(buf))
|
||||
fw.Write([]byte(kepubContent))
|
||||
} else {
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := rc.Read(buf)
|
||||
if n > 0 {
|
||||
fw.Write(buf[:n])
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rc.Close()
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatalf("close kepub writer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func isXHTML(name string) bool {
|
||||
return strings.HasSuffix(strings.ToLower(name), ".xhtml") ||
|
||||
strings.HasSuffix(strings.ToLower(name), ".html") ||
|
||||
strings.HasSuffix(strings.ToLower(name), ".htm")
|
||||
}
|
||||
|
||||
func addKoboSpans(xhtmlContent string) string {
|
||||
result := xhtmlContent
|
||||
result = strings.ReplaceAll(result, "</p>", "</span></p>")
|
||||
result = strings.ReplaceAll(result, "<p>", "<p><span class=\"koboSpan\">")
|
||||
return result
|
||||
}
|
||||
|
||||
func readZipFileData(rc io.ReadCloser) ([]byte, error) {
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
var _ = io.ReadAll
|
||||
@@ -0,0 +1,133 @@
|
||||
package sync
|
||||
|
||||
import "log"
|
||||
|
||||
type LocatorSource string
|
||||
|
||||
const (
|
||||
LocatorSourceKOReader LocatorSource = "koreader"
|
||||
LocatorSourceKobo LocatorSource = "kobo"
|
||||
LocatorSourceWeb LocatorSource = "web"
|
||||
)
|
||||
|
||||
type CanonicalLocator struct {
|
||||
CFI string
|
||||
Precision string
|
||||
Percentage float64
|
||||
}
|
||||
|
||||
type DeviceLocator struct {
|
||||
Position string
|
||||
Precision string
|
||||
Percentage float64
|
||||
}
|
||||
|
||||
func isConvertible(formatGroup string) bool {
|
||||
return formatGroup == string(FormatGroupReflowable)
|
||||
}
|
||||
|
||||
func ConvertToCanonical(
|
||||
source LocatorSource,
|
||||
devicePos string,
|
||||
percentage float64,
|
||||
contextText string,
|
||||
formatGroup string,
|
||||
epubPath string,
|
||||
kepubPath string,
|
||||
) CanonicalLocator {
|
||||
if !isConvertible(formatGroup) || epubPath == "" {
|
||||
return CanonicalLocator{
|
||||
CFI: devicePos,
|
||||
Precision: "passthrough",
|
||||
Percentage: percentage,
|
||||
}
|
||||
}
|
||||
|
||||
switch source {
|
||||
case LocatorSourceKOReader:
|
||||
if !IsCREXPointer(devicePos) {
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "already-standard", Percentage: percentage}
|
||||
}
|
||||
converter := NewCFIConverter(epubPath)
|
||||
result, err := converter.ConvertCREToStandard(devicePos, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CRE→CFI conversion failed: %v", err)
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
if result.EPUBCFI != "" {
|
||||
return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
if result.Href != "" {
|
||||
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
|
||||
|
||||
case LocatorSourceKobo:
|
||||
if kepubPath == "" {
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "no-kepub", Percentage: percentage}
|
||||
}
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
result, err := converter.ConvertKEPUBCFIToStandard(devicePos, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator KEPUB→CFI conversion failed: %v", err)
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
if result.CFI != "" {
|
||||
return CanonicalLocator{CFI: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
return CanonicalLocator{CFI: devicePos, Precision: result.Precision, Percentage: percentage}
|
||||
|
||||
default:
|
||||
return CanonicalLocator{CFI: devicePos, Precision: "passthrough", Percentage: percentage}
|
||||
}
|
||||
}
|
||||
|
||||
func ConvertFromCanonical(
|
||||
source LocatorSource,
|
||||
canonicalCFI string,
|
||||
percentage float64,
|
||||
contextText string,
|
||||
formatGroup string,
|
||||
epubPath string,
|
||||
kepubPath string,
|
||||
) DeviceLocator {
|
||||
if !isConvertible(formatGroup) || epubPath == "" || canonicalCFI == "" {
|
||||
return DeviceLocator{
|
||||
Position: canonicalCFI,
|
||||
Precision: "passthrough",
|
||||
Percentage: percentage,
|
||||
}
|
||||
}
|
||||
|
||||
switch source {
|
||||
case LocatorSourceKOReader:
|
||||
converter := NewCFIConverter(epubPath)
|
||||
result, err := converter.ConvertStandardToCRE(canonicalCFI, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CFI→CRE conversion failed: %v", err)
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
if result.XPointer != "" {
|
||||
return DeviceLocator{Position: result.XPointer, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
|
||||
|
||||
case LocatorSourceKobo:
|
||||
if kepubPath == "" {
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: "no-kepub", Percentage: percentage}
|
||||
}
|
||||
converter := NewKEPUBCFIConverter(epubPath, kepubPath)
|
||||
result, err := converter.ConvertStandardCFIToKEPUB(canonicalCFI, percentage, contextText)
|
||||
if err != nil || result == nil {
|
||||
log.Printf("Bookhoard: locator CFI→KEPUB conversion failed: %v", err)
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: "fallback", Percentage: percentage}
|
||||
}
|
||||
if result.CFI != "" {
|
||||
return DeviceLocator{Position: result.CFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||
}
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: result.Precision, Percentage: percentage}
|
||||
|
||||
default:
|
||||
return DeviceLocator{Position: canonicalCFI, Precision: "passthrough", Percentage: percentage}
|
||||
}
|
||||
}
|
||||
+65
-35
@@ -288,6 +288,7 @@ type SaveProgressRequest struct {
|
||||
|
||||
Percentage *float64
|
||||
Epubcfi *string
|
||||
ContextText *string
|
||||
CharacterOffset *int64
|
||||
Chapter *int
|
||||
ChapterProgress *float64
|
||||
@@ -334,6 +335,7 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
params.Percentage = existing.Percentage
|
||||
params.CharacterOffset = existing.CharacterOffset
|
||||
params.Epubcfi = existing.Epubcfi
|
||||
params.ContextText = existing.ContextText
|
||||
params.Chapter = existing.Chapter
|
||||
params.ChapterProgress = existing.ChapterProgress
|
||||
params.ViewportX = existing.ViewportX
|
||||
@@ -355,6 +357,9 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
if req.Epubcfi != nil {
|
||||
params.Epubcfi = pgtype.Text{String: *req.Epubcfi, Valid: *req.Epubcfi != ""}
|
||||
}
|
||||
if req.ContextText != nil {
|
||||
params.ContextText = pgtype.Text{String: *req.ContextText, Valid: *req.ContextText != ""}
|
||||
}
|
||||
if req.CharacterOffset != nil {
|
||||
params.CharacterOffset = pgtype.Int8{Int64: *req.CharacterOffset, Valid: true}
|
||||
}
|
||||
@@ -395,21 +400,39 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true}
|
||||
params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true}
|
||||
|
||||
if params.Percentage.Valid && !params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 {
|
||||
charOff := PercentageToCharacter(params.Percentage.Float64, mediaItem.TotalCharacters.Int64)
|
||||
params.CharacterOffset = pgtype.Int8{Int64: charOff, Valid: true}
|
||||
}
|
||||
if params.Percentage.Valid && !params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 {
|
||||
page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32))
|
||||
params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true}
|
||||
}
|
||||
if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.Percentage.Valid {
|
||||
pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32))
|
||||
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
|
||||
}
|
||||
if params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 && !params.Percentage.Valid {
|
||||
pct := CharacterToPercentage(params.CharacterOffset.Int64, mediaItem.TotalCharacters.Int64)
|
||||
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
|
||||
formatGroup := FormatGroup(mediaItem.FormatGroup)
|
||||
isFixed := formatGroup == FormatGroupFixedLayout || formatGroup == FormatGroupComicArchive
|
||||
if isFixed {
|
||||
// Fixed-layout & comic formats: the page index is the canonical locator.
|
||||
// CFI/character-offset are meaningless for image-based content, so clear
|
||||
// any stale value that may have been stored by the web reader (which
|
||||
// generates fake CFIs for comics).
|
||||
params.Epubcfi = pgtype.Text{}
|
||||
params.CharacterOffset = pgtype.Int8{}
|
||||
if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 {
|
||||
pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32))
|
||||
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
|
||||
} else if params.Percentage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.CurrentPage.Valid {
|
||||
page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32))
|
||||
params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true}
|
||||
}
|
||||
} else {
|
||||
if params.Percentage.Valid && !params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 {
|
||||
charOff := PercentageToCharacter(params.Percentage.Float64, mediaItem.TotalCharacters.Int64)
|
||||
params.CharacterOffset = pgtype.Int8{Int64: charOff, Valid: true}
|
||||
}
|
||||
if params.Percentage.Valid && !params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 {
|
||||
page := PercentageToPage(params.Percentage.Float64, int(params.TotalPages.Int32))
|
||||
params.CurrentPage = pgtype.Int4{Int32: int32(page), Valid: true}
|
||||
}
|
||||
if params.CurrentPage.Valid && params.TotalPages.Valid && params.TotalPages.Int32 > 0 && !params.Percentage.Valid {
|
||||
pct := PageToPercentage(int(params.CurrentPage.Int32), int(params.TotalPages.Int32))
|
||||
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
|
||||
}
|
||||
if params.CharacterOffset.Valid && mediaItem.TotalCharacters.Valid && mediaItem.TotalCharacters.Int64 > 0 && !params.Percentage.Valid {
|
||||
pct := CharacterToPercentage(params.CharacterOffset.Int64, mediaItem.TotalCharacters.Int64)
|
||||
params.Percentage = pgtype.Float8{Float64: pct, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
conflictDetected := false
|
||||
@@ -430,7 +453,13 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
diff = -diff
|
||||
}
|
||||
if diff > 0.01 {
|
||||
conflictDetected = true
|
||||
recentlyResolved, _ := s.db.HasRecentConflictResolution(ctx, database.HasRecentConflictResolutionParams{
|
||||
MediaItemID: req.MediaItemID,
|
||||
UserID: req.UserID,
|
||||
})
|
||||
if !recentlyResolved {
|
||||
conflictDetected = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,26 +471,24 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
}
|
||||
|
||||
if conflictDetected {
|
||||
newData := map[string]interface{}{
|
||||
"source": req.Source,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"data": buildProgressSnapshot(req),
|
||||
}
|
||||
existingData := map[string]interface{}{
|
||||
"source": existing.LastSyncSource.String,
|
||||
"timestamp": existing.LastSyncTimestamp.Time.Format(time.RFC3339),
|
||||
"data": map[string]interface{}{
|
||||
"percentage": float64Ptr(existing.Percentage),
|
||||
"epubcfi": textPtr(existing.Epubcfi),
|
||||
"chapter": int32Ptr(existing.Chapter),
|
||||
"character": int64Ptr(existing.CharacterOffset),
|
||||
"page": int32Ptr(existing.CurrentPage),
|
||||
"total_pages": int32Ptr(existing.TotalPages),
|
||||
},
|
||||
}
|
||||
conflictData := map[string]interface{}{
|
||||
"new": newData,
|
||||
"existing": existingData,
|
||||
req.Source: map[string]interface{}{
|
||||
"source": req.Source,
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
"data": buildProgressSnapshot(req),
|
||||
},
|
||||
existing.LastSyncSource.String: map[string]interface{}{
|
||||
"source": existing.LastSyncSource.String,
|
||||
"timestamp": existing.LastSyncTimestamp.Time.Format(time.RFC3339),
|
||||
"data": map[string]interface{}{
|
||||
"percentage": float64Ptr(existing.Percentage),
|
||||
"epubcfi": textPtr(existing.Epubcfi),
|
||||
"chapter": int32Ptr(existing.Chapter),
|
||||
"character": int64Ptr(existing.CharacterOffset),
|
||||
"page": int32Ptr(existing.CurrentPage),
|
||||
"total_pages": int32Ptr(existing.TotalPages),
|
||||
},
|
||||
},
|
||||
}
|
||||
conflictJSON, _ := json.Marshal(conflictData)
|
||||
_, err := s.db.CreateSyncConflict(ctx, database.CreateSyncConflictParams{
|
||||
@@ -508,6 +535,9 @@ func buildProgressSnapshot(req SaveProgressRequest) map[string]interface{} {
|
||||
if req.Epubcfi != nil {
|
||||
data["epubcfi"] = *req.Epubcfi
|
||||
}
|
||||
if req.ContextText != nil {
|
||||
data["context_text"] = *req.ContextText
|
||||
}
|
||||
if req.Chapter != nil {
|
||||
data["chapter"] = *req.Chapter
|
||||
}
|
||||
|
||||
+242
-7
@@ -35,11 +35,12 @@ const (
|
||||
)
|
||||
|
||||
type SyncQueueProcessor struct {
|
||||
db *database.Queries
|
||||
progressSvc *ProgressService
|
||||
progressChan chan *ProgressUpdate
|
||||
interval time.Duration
|
||||
batchSize int
|
||||
db *database.Queries
|
||||
progressSvc *ProgressService
|
||||
annotationSvc *AnnotationService
|
||||
progressChan chan *ProgressUpdate
|
||||
interval time.Duration
|
||||
batchSize int
|
||||
}
|
||||
|
||||
type ProgressUpdate struct {
|
||||
@@ -48,6 +49,7 @@ type ProgressUpdate struct {
|
||||
UserID pgtype.UUID
|
||||
Percentage float64
|
||||
Epubcfi *string
|
||||
ContextText *string
|
||||
Chapter *int
|
||||
Character *int64
|
||||
Page *int
|
||||
@@ -84,6 +86,10 @@ func (p *SyncQueueProcessor) SetProgressService(svc *ProgressService) {
|
||||
p.progressSvc = svc
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) SetAnnotationService(svc *AnnotationService) {
|
||||
p.annotationSvc = svc
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) Start(ctx context.Context) {
|
||||
log.Printf("Starting sync queue processor (interval: %v, batch: %d)", p.interval, p.batchSize)
|
||||
|
||||
@@ -112,6 +118,100 @@ func (p *SyncQueueProcessor) EnqueueProgress(update *ProgressUpdate) error {
|
||||
}
|
||||
}
|
||||
|
||||
type HighlightUpdate struct {
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
SelectionText string
|
||||
StartPosition string
|
||||
EndPosition string
|
||||
Color string
|
||||
NoteText string
|
||||
EpubcfiStart string
|
||||
EpubcfiEnd string
|
||||
PercentageStart float64
|
||||
PercentageEnd float64
|
||||
Source string
|
||||
DeviceSyncData map[string]interface{}
|
||||
}
|
||||
|
||||
type NoteUpdate struct {
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Content string
|
||||
Position string
|
||||
Source string
|
||||
DeviceSyncData map[string]interface{}
|
||||
}
|
||||
|
||||
type BookmarkUpdate struct {
|
||||
DeviceID pgtype.UUID
|
||||
MediaItemID pgtype.UUID
|
||||
UserID pgtype.UUID
|
||||
Title string
|
||||
Position string
|
||||
Notes string
|
||||
Source string
|
||||
DeviceSyncData map[string]interface{}
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) EnqueueHighlight(ctx context.Context, update *HighlightUpdate) error {
|
||||
syncData := map[string]interface{}{
|
||||
"selection_text": update.SelectionText,
|
||||
"start_position": update.StartPosition,
|
||||
"end_position": update.EndPosition,
|
||||
"color": update.Color,
|
||||
"note_text": update.NoteText,
|
||||
"source": update.Source,
|
||||
"epubcfi_start": update.EpubcfiStart,
|
||||
"epubcfi_end": update.EpubcfiEnd,
|
||||
"percentage_start": update.PercentageStart,
|
||||
"percentage_end": update.PercentageEnd,
|
||||
"device_sync_data": update.DeviceSyncData,
|
||||
}
|
||||
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeHighlight, syncData)
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) EnqueueNote(ctx context.Context, update *NoteUpdate) error {
|
||||
syncData := map[string]interface{}{
|
||||
"content": update.Content,
|
||||
"position": update.Position,
|
||||
"source": update.Source,
|
||||
"device_sync_data": update.DeviceSyncData,
|
||||
}
|
||||
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeNote, syncData)
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) EnqueueBookmark(ctx context.Context, update *BookmarkUpdate) error {
|
||||
syncData := map[string]interface{}{
|
||||
"title": update.Title,
|
||||
"position": update.Position,
|
||||
"notes": update.Notes,
|
||||
"source": update.Source,
|
||||
"device_sync_data": update.DeviceSyncData,
|
||||
}
|
||||
return p.enqueueAnnotation(ctx, update.DeviceID, update.MediaItemID, SyncTypeBookmark, syncData)
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) enqueueAnnotation(ctx context.Context, deviceID, mediaItemID pgtype.UUID, syncType string, syncData map[string]interface{}) error {
|
||||
syncDataJSON, err := json.Marshal(syncData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal sync data: %w", err)
|
||||
}
|
||||
|
||||
_, err = p.db.CreateSyncQueueItem(ctx, database.CreateSyncQueueItemParams{
|
||||
DeviceID: deviceID,
|
||||
MediaItemID: mediaItemID,
|
||||
SyncType: syncType,
|
||||
SyncData: syncDataJSON,
|
||||
Priority: pgtype.Int4{Int32: int32(PriorityCriticalNote), Valid: true},
|
||||
MaxAttempts: pgtype.Int4{Int32: 3, Valid: true},
|
||||
Status: pgtype.Text{String: SyncStatusPending, Valid: true},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *ProgressUpdate) {
|
||||
syncData := map[string]interface{}{
|
||||
"percentage": update.Percentage,
|
||||
@@ -122,6 +222,9 @@ func (p *SyncQueueProcessor) enqueueProgressUpdate(ctx context.Context, update *
|
||||
if update.Epubcfi != nil {
|
||||
syncData["epubcfi"] = *update.Epubcfi
|
||||
}
|
||||
if update.ContextText != nil {
|
||||
syncData["context_text"] = *update.ContextText
|
||||
}
|
||||
if update.Chapter != nil {
|
||||
syncData["chapter"] = *update.Chapter
|
||||
}
|
||||
@@ -304,6 +407,8 @@ func (p *SyncQueueProcessor) executeSync(ctx context.Context, item SyncQueueItem
|
||||
return p.syncNote(ctx, device.UserID, item.MediaItemID, syncData)
|
||||
case SyncTypeHighlight:
|
||||
return p.syncHighlight(ctx, device.UserID, item.MediaItemID, syncData)
|
||||
case SyncTypeBookmark:
|
||||
return p.syncBookmark(ctx, device.UserID, item.MediaItemID, syncData)
|
||||
default:
|
||||
return fmt.Errorf("unsupported sync type: %s", item.SyncType)
|
||||
}
|
||||
@@ -331,6 +436,9 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
|
||||
if v, ok := syncData["epubcfi"].(string); ok {
|
||||
req.Epubcfi = &v
|
||||
}
|
||||
if v, ok := syncData["context_text"].(string); ok {
|
||||
req.ContextText = &v
|
||||
}
|
||||
if v, ok := syncData["chapter"].(float64); ok {
|
||||
ch := int(v)
|
||||
req.Chapter = &ch
|
||||
@@ -400,11 +508,138 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) syncNote(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
|
||||
return fmt.Errorf("note sync not yet implemented")
|
||||
if p.annotationSvc == nil {
|
||||
return fmt.Errorf("annotation service not available")
|
||||
}
|
||||
|
||||
req := SaveNoteRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := syncData["content"].(string); ok {
|
||||
req.Content = v
|
||||
}
|
||||
if v, ok := syncData["position"].(string); ok {
|
||||
req.Position = v
|
||||
}
|
||||
if v, ok := syncData["source"].(string); ok {
|
||||
req.Source = v
|
||||
}
|
||||
if v, ok := syncData["epubcfi_location"].(string); ok {
|
||||
req.EpubcfiLocation = v
|
||||
}
|
||||
if v, ok := syncData["percentage_location"].(float64); ok {
|
||||
req.PercentageLocation = v
|
||||
}
|
||||
if v, ok := syncData["chapter_reference"].(float64); ok {
|
||||
req.ChapterReference = int32(v)
|
||||
}
|
||||
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
|
||||
req.DeviceSyncData, _ = json.Marshal(v)
|
||||
}
|
||||
|
||||
_, err := p.annotationSvc.SaveNote(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) syncHighlight(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
|
||||
return fmt.Errorf("highlight sync not yet implemented")
|
||||
if p.annotationSvc == nil {
|
||||
return fmt.Errorf("annotation service not available")
|
||||
}
|
||||
|
||||
req := SaveHighlightRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := syncData["selection_text"].(string); ok {
|
||||
req.SelectionText = v
|
||||
}
|
||||
if v, ok := syncData["start_position"].(string); ok {
|
||||
req.StartPosition = v
|
||||
}
|
||||
if v, ok := syncData["end_position"].(string); ok {
|
||||
req.EndPosition = v
|
||||
}
|
||||
if v, ok := syncData["color"].(string); ok {
|
||||
req.Color = v
|
||||
}
|
||||
if v, ok := syncData["note_text"].(string); ok {
|
||||
req.NoteText = v
|
||||
}
|
||||
if v, ok := syncData["source"].(string); ok {
|
||||
req.Source = v
|
||||
}
|
||||
if v, ok := syncData["epubcfi_start"].(string); ok {
|
||||
req.EpubcfiStart = v
|
||||
}
|
||||
if v, ok := syncData["epubcfi_end"].(string); ok {
|
||||
req.EpubcfiEnd = v
|
||||
}
|
||||
if v, ok := syncData["percentage_start"].(float64); ok {
|
||||
req.PercentageStart = v
|
||||
}
|
||||
if v, ok := syncData["percentage_end"].(float64); ok {
|
||||
req.PercentageEnd = v
|
||||
}
|
||||
if v, ok := syncData["chapter_reference"].(float64); ok {
|
||||
req.ChapterReference = int32(v)
|
||||
}
|
||||
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
|
||||
req.DeviceSyncData, _ = json.Marshal(v)
|
||||
}
|
||||
|
||||
_, err := p.annotationSvc.SaveHighlight(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) syncBookmark(ctx context.Context, userID pgtype.UUID, mediaItemID pgtype.UUID, syncData map[string]interface{}) error {
|
||||
if p.annotationSvc == nil {
|
||||
return fmt.Errorf("annotation service not available")
|
||||
}
|
||||
|
||||
req := SaveBookmarkRequest{
|
||||
MediaItemID: mediaItemID,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
if v, ok := syncData["title"].(string); ok {
|
||||
req.Title = v
|
||||
}
|
||||
if v, ok := syncData["position"].(string); ok {
|
||||
req.Position = v
|
||||
}
|
||||
if v, ok := syncData["notes"].(string); ok {
|
||||
req.Notes = v
|
||||
}
|
||||
if v, ok := syncData["source"].(string); ok {
|
||||
req.Source = v
|
||||
}
|
||||
if v, ok := syncData["cfi_position"].(string); ok {
|
||||
req.CFIPosition = v
|
||||
}
|
||||
if v, ok := syncData["epubcfi_location"].(string); ok {
|
||||
req.EpubcfiLocation = v
|
||||
}
|
||||
if v, ok := syncData["percentage_loc"].(float64); ok {
|
||||
req.PercentageLoc = v
|
||||
}
|
||||
if v, ok := syncData["page_number"].(float64); ok {
|
||||
req.PageNumber = int32(v)
|
||||
}
|
||||
if v, ok := syncData["chapter_number"].(float64); ok {
|
||||
req.ChapterNumber = int32(v)
|
||||
}
|
||||
if v, ok := syncData["chapter_reference"].(float64); ok {
|
||||
req.ChapterReference = int32(v)
|
||||
}
|
||||
if v, ok := syncData["device_sync_data"].(map[string]interface{}); ok {
|
||||
req.DeviceSyncData, _ = json.Marshal(v)
|
||||
}
|
||||
|
||||
_, err := p.annotationSvc.SaveBookmark(ctx, req)
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *SyncQueueProcessor) markItemFailed(ctx context.Context, item SyncQueueItem, errMsg string) {
|
||||
|
||||
@@ -4,14 +4,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// titleCaser is a global caser for titlecase conversion
|
||||
var titleCaser = cases.Title(language.Und)
|
||||
|
||||
// titlecase converts a string to title case while preserving hyphenation and apostrophes
|
||||
// Example: "science fiction" → "Science Fiction", "non-fiction" → "Non-Fiction", "o'reilly" → "O'Reilly"
|
||||
func titlecase(s string) string {
|
||||
@@ -34,7 +28,12 @@ func titlecase(s string) string {
|
||||
}
|
||||
words[i] = result.String()
|
||||
} else {
|
||||
words[i] = titleCaser.String(word)
|
||||
runes := []rune(word)
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
for j := 1; j < len(runes); j++ {
|
||||
runes[j] = unicode.ToLower(runes[j])
|
||||
}
|
||||
words[i] = string(runes)
|
||||
}
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
|
||||
+4
-4
@@ -5,14 +5,14 @@
|
||||
"scripts": {
|
||||
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
|
||||
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
|
||||
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && mkdir -p web/static/vendor/pdfjs && cp -r node_modules/@bookhoard/foliate-js/vendor/pdfjs/standard_fonts web/static/vendor/pdfjs/ && vite build",
|
||||
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && mkdir -p web/static/vendor/pdfjs && cp -r node_modules/@bookhoard/foliate-js/vendor/pdfjs/standard_fonts web/static/vendor/pdfjs/ && vite build --mode development",
|
||||
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && mkdir -p web/static/vendor/pdfjs && cp -r node_modules/@bookhoard/foliate-js/vendor/pdfjs/standard_fonts web/static/vendor/pdfjs/ && vite build --watch",
|
||||
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build",
|
||||
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --mode development",
|
||||
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --watch",
|
||||
"build": "npm run build:ts && npm run build:css:prod",
|
||||
"dev": "npm run build:ts:dev && npm run build:css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bookhoard/foliate-js": "github:john-okeefe/foliate-js#74c317d58c2cedb53811da2973d0bf9bb6a70dcb",
|
||||
"@bookhoard/foliate-js": "github:john-okeefe/foliate-js#d164d6f",
|
||||
"alpinejs": "^3.15.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
# Convert Fish config.fish to Zsh .zshrc
|
||||
|
||||
## Objective
|
||||
|
||||
Translate all functional content from `~/.config/fish/config.fish` into equivalent Zsh syntax, append it **below** the existing Forge-managed block in `~/.config/zsh/.zshrc`, and leave the existing content untouched.
|
||||
|
||||
## Source Analysis
|
||||
|
||||
### Fish config breakdown (`~/.config/fish/config.fish`, 178 lines)
|
||||
|
||||
| Lines | Category | Notes |
|
||||
|-------|----------|-------|
|
||||
| 1 | Comment (collapsed) | Multi-line comment got collapsed into one line; contains `set fish_greeting`, `set VIRTUAL_ENV_DISABLE_PROMPT`, `set -x SHELL /usr/bin/fish` |
|
||||
| 2-3 | Man pager (bat) | `set -xU` universal env vars |
|
||||
| 5-6 | Paru pager | `set -x` exported env var |
|
||||
| 8-10 | Done plugin settings | `set -U` universal vars for `done` notification plugin |
|
||||
| 12-16 | Source `~/.fish_profile` | Fish-specific profile file |
|
||||
| 18-23 | PATH: `~/.local/bin` | Already in .zshrc (line 2) |
|
||||
| 25-30 | PATH: `depot_tools` | Conditional PATH prepend |
|
||||
| 32-37 | Starship + zoxide + atuin | Interactive-only init |
|
||||
| 39-40 | find-the-command hook | Fish-specific (`ftc.fish`) |
|
||||
| 42-76 | Bang-bang + history functions | `!!` and `!$` support + history formatting |
|
||||
| 78-80 | `backup` function | Simple `cp` backup |
|
||||
| 83-92 | `copy` function | Smart `cp` with directory detection |
|
||||
| 94-102 | `cleanup` function | Remove orphaned pacman packages |
|
||||
| 104-163 | Aliases & abbreviations | Many aliases, some with Fish-specific syntax |
|
||||
| 165-168 | Fastfetch on interactive | Run fastfetch if available |
|
||||
| 170-172 | PATH: opencode + local bin | PATH additions (local bin already covered) |
|
||||
| 174-178 | Qt/KDE theming | Conditional `QT_STYLE_OVERRIDE` |
|
||||
|
||||
### Existing .zshrc breakdown (`~/.config/zsh/.zshrc`, 48 lines)
|
||||
|
||||
Lines 1-47 are Forge-managed and must remain untouched. The new content should be appended **after line 48** (end of file).
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
- [ ] **Step 1.** Append all converted content below the existing Forge block (after line 48). Do not modify any existing lines.
|
||||
|
||||
## Converted Zsh Code (to append after line 48 of `~/.config/zsh/.zshrc`)
|
||||
|
||||
```zsh
|
||||
# ============================================================
|
||||
# Converted from ~/.config/fish/config.fish
|
||||
# ============================================================
|
||||
|
||||
# --- Environment Variables ---
|
||||
|
||||
# Hide welcome message (Zsh equivalent: set empty PS1 greeting or just skip)
|
||||
VIRTUAL_ENV_DISABLE_PROMPT="1"
|
||||
export VIRTUAL_ENV_DISABLE_PROMPT
|
||||
|
||||
# Use bat for man pages
|
||||
export MANPAGER="sh -c 'col -bx | bat -l man -p'"
|
||||
export MANROFFOPT="-c"
|
||||
|
||||
# Hint to exit PKGBUILD review in Paru
|
||||
export PARU_PAGER="less -P \"Press 'q' to exit the PKGBUILD review.\""
|
||||
|
||||
# --- PATH Additions ---
|
||||
|
||||
# Add ~/.local/bin to PATH (already present via Forge, but kept for completeness)
|
||||
if [[ -d ~/.local/bin ]]; then
|
||||
case ":${PATH}:" in
|
||||
*:"$HOME/.local/bin":*) ;;
|
||||
*) export PATH="$HOME/.local/bin:$PATH" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Add depot_tools to PATH
|
||||
if [[ -d ~/Applications/depot_tools ]]; then
|
||||
case ":${PATH}:" in
|
||||
*:"$HOME/Applications/depot_tools":*) ;;
|
||||
*) export PATH="$HOME/Applications/depot_tools:$PATH" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Add opencode to PATH
|
||||
if [[ -d ~/.opencode/bin ]]; then
|
||||
case ":${PATH}:" in
|
||||
*:"$HOME/.opencode/bin":*) ;;
|
||||
*) export PATH="$HOME/.opencode/bin:$PATH" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# --- Interactive Shell Setup ---
|
||||
|
||||
if [[ -o interactive ]]; then
|
||||
# Zoxide (smart cd)
|
||||
eval "$(zoxide init zsh)"
|
||||
|
||||
# Atuin (shell history)
|
||||
eval "$(atuin init zsh)"
|
||||
|
||||
# Starship prompt
|
||||
eval "$(starship init zsh)"
|
||||
fi
|
||||
|
||||
# --- Bang-Bang Support (!!) ---
|
||||
|
||||
# Zsh has built-in bang-bang via `setopt bang_hist` (default on).
|
||||
# Enable history expansion:
|
||||
setopt bang_hist
|
||||
setopt hist_expand
|
||||
|
||||
# --- History Settings ---
|
||||
|
||||
HISTFILE=~/.config/zsh/.zsh_history
|
||||
HISTSIZE=10000
|
||||
SAVEHIST=10000
|
||||
setopt extended_history # Record timestamps (equivalent to --show-time='%F %T ')
|
||||
setopt share_history # Share history across sessions
|
||||
setopt hist_ignore_all_dups # Remove older duplicate entries
|
||||
setopt hist_ignore_space # Ignore commands starting with space
|
||||
setopt hist_save_no_dups # Don't save duplicates
|
||||
|
||||
# --- Functions ---
|
||||
|
||||
# Create a backup of a file
|
||||
backup() {
|
||||
cp "$1" "${1}.bak"
|
||||
}
|
||||
|
||||
# Smart copy: if source is a directory and exactly 2 args, copy recursively
|
||||
copy() {
|
||||
if [[ $# -eq 2 && -d "$1" ]]; then
|
||||
command cp -r "${1%/}" "$2"
|
||||
else
|
||||
command cp "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Cleanup orphaned packages
|
||||
cleanup() {
|
||||
local orphans
|
||||
orphans=$(pacman -Qdtq 2>/dev/null)
|
||||
while [[ -n "$orphans" ]]; do
|
||||
sudo pacman -R $orphans || break
|
||||
orphans=$(pacman -Qdtq 2>/dev/null)
|
||||
done
|
||||
}
|
||||
|
||||
# --- Aliases ---
|
||||
|
||||
# Replace ls with eza
|
||||
alias ls='eza -al --color=always --group-directories-first --icons'
|
||||
alias lsz='eza -al --color=always --total-size --group-directories-first --icons'
|
||||
alias la='eza -a --color=always --group-directories-first --icons'
|
||||
alias ll='eza -l --color=always --group-directories-first --icons'
|
||||
alias lt='eza -aT --color=always --group-directories-first --icons'
|
||||
alias l.='eza -ald --color=always --group-directories-first --icons .*'
|
||||
|
||||
# Replace cat with bat
|
||||
alias cat='bat --style header,snip,changes'
|
||||
|
||||
# Use paru as yay if yay is not installed
|
||||
if ! command -v yay &>/dev/null && command -v paru &>/dev/null; then
|
||||
alias yay='paru'
|
||||
fi
|
||||
|
||||
# Directory navigation
|
||||
alias ..='cd ..'
|
||||
alias ...='cd ../..'
|
||||
alias ....='cd ../../..'
|
||||
alias .....='cd ../../../..'
|
||||
alias ......='cd ../../../../..'
|
||||
|
||||
# Package management
|
||||
alias big='expac -H M "%m\t%n" | sort -h | nl'
|
||||
alias fixpacman='sudo rm /var/lib/pacman/db.lck'
|
||||
alias gitpkg='pacman -Q | grep -i "\-git" | wc -l'
|
||||
alias rmpkg='sudo pacman -Rdd'
|
||||
alias upd='/usr/bin/garuda-update'
|
||||
|
||||
# Grep replacements (ugrep)
|
||||
alias grep='ugrep --color=auto'
|
||||
alias egrep='ugrep -E --color=auto'
|
||||
alias fgrep='ugrep -F --color=auto'
|
||||
|
||||
# Tar
|
||||
alias tarnow='tar -acf '
|
||||
alias untar='tar -zxvf '
|
||||
|
||||
# Misc
|
||||
alias dir='dir --color=auto'
|
||||
alias grubup='sudo update-grub'
|
||||
alias hw='hwinfo --short'
|
||||
alias ip='ip -color'
|
||||
alias psmem='ps auxf | sort -nr -k 4'
|
||||
alias psmem10='ps auxf | sort -nr -k 4 | head -10'
|
||||
alias vdir='vdir --color=auto'
|
||||
alias wget='wget -c '
|
||||
|
||||
# Mirror management (reflector)
|
||||
alias mirror='sudo reflector -f 30 -l 30 --number 10 --verbose --save /etc/pacman.d/mirrorlist'
|
||||
alias mirrora='sudo reflector --latest 50 --number 20 --sort age --save /etc/pacman.d/mirrorlist'
|
||||
alias mirrord='sudo reflector --latest 50 --number 20 --sort delay --save /etc/pacman.d/mirrorlist'
|
||||
alias mirrors='sudo reflector --latest 50 --number 20 --sort score --save /etc/pacman.d/mirrorlist'
|
||||
|
||||
# Help for newcomers to Arch
|
||||
alias apt='man pacman'
|
||||
alias apt-get='man pacman'
|
||||
alias please='sudo'
|
||||
alias tb='nc termbin.com 9999'
|
||||
alias helpme='echo "To print basic information about a command use tldr <command>"'
|
||||
alias pacdiff='sudo -H DIFFPROG=meld pacdiff'
|
||||
|
||||
# Journalctl errors
|
||||
alias jctl='journalctl -p 3 -xb'
|
||||
|
||||
# Recent installed packages
|
||||
alias rip='expac --timefmt="%Y-%m-%d %T" "%l\t%n %v" | sort | tail -200 | nl'
|
||||
|
||||
# --- Run fastfetch if session is interactive ---
|
||||
|
||||
if [[ -o interactive ]] && command -v fastfetch &>/dev/null; then
|
||||
fastfetch --config dr460nized.jsonc
|
||||
fi
|
||||
|
||||
# --- Qt/KDE Theming ---
|
||||
|
||||
# Set for non-Plasma sessions (Hyprland, etc.)
|
||||
if [[ -z "$XDG_CURRENT_DESKTOP" ]] || [[ "$XDG_CURRENT_DESKTOP" != "KDE" ]]; then
|
||||
export QT_STYLE_OVERRIDE=kvantum
|
||||
fi
|
||||
```
|
||||
|
||||
## Items Intentionally Excluded
|
||||
|
||||
| Fish Line(s) | Item | Reason for Exclusion |
|
||||
|---|---|---|
|
||||
| 1 | `set -x SHELL /usr/bin/fish` | Fish-specific; should be `SHELL=/usr/bin/zsh` if anything, but zsh sets this automatically |
|
||||
| 1 | `set fish_greeting` | Fish-specific; Zsh has no equivalent greeting mechanism |
|
||||
| 8-10 | `__done_*` settings | These are Fish `done` plugin variables; Zsh equivalent would need a different notification plugin |
|
||||
| 12-16 | Source `~/.fish_profile` | Fish-specific profile file; if needed, convert to `~/.zprofile` or source a zsh-compatible profile |
|
||||
| 39-40 | `source /usr/share/doc/find-the-command/ftc.fish` | Fish-specific command-not-found hook; Zsh has `command-not-found-handler` or uses pkgfile |
|
||||
| 42-76 | `__history_previous_command` / `__history_previous_command_arguments` + bind | Fish-specific bang-bang plugin; Zsh has built-in `!!` and `!$` via `setopt bang_hist hist_expand` |
|
||||
|
||||
## Verification Criteria
|
||||
|
||||
- [ ] All aliases work: open a new zsh shell and run `alias` to confirm they are loaded
|
||||
- [ ] `eza` aliases produce colored output with icons
|
||||
- [ ] `bat` alias shows headers/snippets
|
||||
- [ ] `backup testfile.txt` creates `testfile.txt.bak`
|
||||
- [ ] `copy dir1 dir2` copies recursively; `copy file1 file2` copies normally
|
||||
- [ ] `cleanup` removes orphaned pacman packages without error
|
||||
- [ ] `fastfetch` runs on interactive shell launch
|
||||
- [ ] `QT_STYLE_OVERRIDE=kvantum` is set when not in KDE
|
||||
- [ ] PATH includes `~/.local/bin`, `~/Applications/depot_tools`, and `~/.opencode/bin` (when dirs exist)
|
||||
- [ ] `zoxide`, `atuin`, and `starship` initialize in interactive sessions
|
||||
- [ ] Existing Forge-managed block (lines 1-47) is completely untouched
|
||||
- [ ] History expansion works: try `!!` to repeat last command
|
||||
|
||||
## Potential Risks and Mitigations
|
||||
|
||||
1. **Alias name conflicts with existing commands**
|
||||
Mitigation: All aliases use the same names as the Fish config, which was already working. The `cat` alias overriding `cat` with `bat` is intentional but could break scripts that call `cat` and expect raw output. Use `\cat` or `command cat` when needed.
|
||||
|
||||
2. **Forge-managed block interference**
|
||||
Mitigation: The Forge block already sets up `compinit`, zsh-autosuggestions, zsh-syntax-highlighting, and a prompt theme. The converted Starship init will override the Forge prompt theme. If you want to keep the Forge prompt, remove the `eval "$(starship init zsh)"` line.
|
||||
|
||||
3. **`cleanup` function subshell variable scope**
|
||||
Mitigation: The Fish version used a while loop with `$status`. The Zsh version captures orphan list in a variable and uses the `-n` test instead, which is more reliable.
|
||||
|
||||
4. **`garuda-update` alias may not exist**
|
||||
Mitigation: The alias is a direct path reference. If the binary doesn't exist, the alias simply won't work when invoked — same behavior as Fish.
|
||||
|
||||
5. **Fastfetch config path**
|
||||
Mitigation: `fastfetch --config dr460nized.jsonc` uses a relative config name. Ensure the config file is discoverable by fastfetch (typically in `~/.config/fastfetch/`).
|
||||
|
||||
## Alternative Approaches
|
||||
|
||||
1. **Use a framework (oh-my-zsh / zinit)**: Instead of manual conversion, use a Zsh framework that provides bang-bang, aliases, and plugin management out of the box. Trade-off: adds framework dependency and complexity.
|
||||
|
||||
2. **Use `babelfish` or `fish2zsh` tools**: Automated Fish-to-Zsh converters exist but may not handle all Fish-specific constructs correctly. Trade-off: faster but less reliable for edge cases.
|
||||
|
||||
3. **Source a separate file**: Instead of appending to `.zshrc`, place converted content in `~/.config/zsh/.zshrc.fish-converted` and source it from `.zshrc`. Trade-off: cleaner separation but adds an extra file to manage.
|
||||
@@ -1,279 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Series Cover Layout Options</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #1a1b26;
|
||||
color: #c0caf5;
|
||||
padding: 40px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { margin-bottom: 10px; }
|
||||
.subtitle { color: #9aa5ce; margin-bottom: 40px; }
|
||||
|
||||
.options {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 40px;
|
||||
}
|
||||
.option {
|
||||
background: #24283b;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.option h2 { margin: 0 0 8px 0; font-size: 1.1rem; color: #7aa2f7; }
|
||||
.option .desc { color: #9aa5ce; font-size: 0.85rem; margin-bottom: 20px; }
|
||||
|
||||
/* === Common book cover placeholder === */
|
||||
.cover {
|
||||
width: 80px;
|
||||
height: 120px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
background: linear-gradient(135deg, #414868, #1a1b26);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: #565f89;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
OPTION A: Stacked Cascade (pile of books, top-left offset)
|
||||
============================================================ */
|
||||
.stacked-cascade {
|
||||
position: relative;
|
||||
width: 260px;
|
||||
height: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.stacked-cascade .cover {
|
||||
position: absolute;
|
||||
}
|
||||
.stacked-cascade .cover:nth-child(1) { top: 0; left: 0; z-index: 7; }
|
||||
.stacked-cascade .cover:nth-child(2) { top: 8px; left: 8px; z-index: 6; opacity: 0.92; }
|
||||
.stacked-cascade .cover:nth-child(3) { top: 16px; left: 16px; z-index: 5; opacity: 0.85; }
|
||||
.stacked-cascade .cover:nth-child(4) { top: 24px; left: 24px; z-index: 4; opacity: 0.78; }
|
||||
.stacked-cascade .cover:nth-child(5) { top: 32px; left: 32px; z-index: 3; opacity: 0.7; }
|
||||
.stacked-cascade .cover:nth-child(6) { top: 40px; left: 40px; z-index: 2; opacity: 0.65; }
|
||||
.stacked-cascade .cover:nth-child(7) { top: 48px; left: 48px; z-index: 1; opacity: 0.6; }
|
||||
|
||||
.stacked-cascade:hover .cover:nth-child(1) { top: 0; left: 0; }
|
||||
.stacked-cascade:hover .cover:nth-child(2) { top: 6px; left: 12px; }
|
||||
.stacked-cascade:hover .cover:nth-child(3) { top: 14px; left: 24px; }
|
||||
.stacked-cascade:hover .cover:nth-child(4) { top: 22px; left: 36px; }
|
||||
.stacked-cascade:hover .cover:nth-child(5) { top: 30px; left: 50px; }
|
||||
.stacked-cascade:hover .cover:nth-child(6) { top: 40px; left: 64px; }
|
||||
.stacked-cascade:hover .cover:nth-child(7) { top: 50px; left: 78px; }
|
||||
.stacked-cascade .cover { transition: all 0.3s ease; }
|
||||
|
||||
/* ============================================================
|
||||
OPTION B: Grid Mosaic (mini bookshelf grid)
|
||||
============================================================ */
|
||||
.grid-mosaic {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
background: #1a1b26;
|
||||
border-radius: 8px;
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
.grid-mosaic .cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
font-size: 9px;
|
||||
}
|
||||
.grid-mosaic .cover.spacer {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
OPTION C: Fanned Bottom (arc from bottom center, upward)
|
||||
============================================================ */
|
||||
.fanned-bottom {
|
||||
position: relative;
|
||||
width: 280px;
|
||||
height: 200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.fanned-bottom .cover {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
margin-left: -40px; /* half cover width */
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
.fanned-bottom .cover:nth-child(1) { z-index: 7; transform: rotate(0deg); }
|
||||
.fanned-bottom .cover:nth-child(2) { z-index: 6; transform: rotate(-12deg); opacity: 0.92; }
|
||||
.fanned-bottom .cover:nth-child(3) { z-index: 5; transform: rotate(12deg); opacity: 0.92; }
|
||||
.fanned-bottom .cover:nth-child(4) { z-index: 4; transform: rotate(-24deg); opacity: 0.82; }
|
||||
.fanned-bottom .cover:nth-child(5) { z-index: 3; transform: rotate(24deg); opacity: 0.82; }
|
||||
.fanned-bottom .cover:nth-child(6) { z-index: 2; transform: rotate(-36deg); opacity: 0.72; }
|
||||
.fanned-bottom .cover:nth-child(7) { z-index: 1; transform: rotate(36deg); opacity: 0.72; }
|
||||
|
||||
.fanned-bottom:hover .cover:nth-child(1) { transform: rotate(0deg); }
|
||||
.fanned-bottom:hover .cover:nth-child(2) { transform: rotate(-15deg); }
|
||||
.fanned-bottom:hover .cover:nth-child(3) { transform: rotate(15deg); }
|
||||
.fanned-bottom:hover .cover:nth-child(4) { transform: rotate(-30deg); }
|
||||
.fanned-bottom:hover .cover:nth-child(5) { transform: rotate(30deg); }
|
||||
.fanned-bottom:hover .cover:nth-child(6) { transform: rotate(-42deg); }
|
||||
.fanned-bottom:hover .cover:nth-child(7) { transform: rotate(42deg); }
|
||||
.fanned-bottom .cover { transition: all 0.3s ease; }
|
||||
|
||||
/* ============================================================
|
||||
OPTION D: Perspective Shelf (3D standing books)
|
||||
============================================================ */
|
||||
.perspective-shelf {
|
||||
width: 280px;
|
||||
height: 200px;
|
||||
margin: 0 auto;
|
||||
perspective: 600px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.perspective-shelf .cover {
|
||||
transform: rotateY(-15deg);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.perspective-shelf:hover .cover {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
.perspective-shelf .cover:nth-child(odd) {
|
||||
transform: rotateY(-18deg) translateZ(2px);
|
||||
}
|
||||
.perspective-shelf .cover:nth-child(even) {
|
||||
transform: rotateY(-12deg) translateZ(-1px);
|
||||
}
|
||||
.perspective-shelf:hover .cover:nth-child(odd) {
|
||||
transform: rotateY(-5deg) translateZ(4px);
|
||||
}
|
||||
.perspective-shelf:hover .cover:nth-child(even) {
|
||||
transform: rotateY(-5deg) translateZ(2px);
|
||||
}
|
||||
|
||||
/* === Demo colors for covers === */
|
||||
.c1 { background: linear-gradient(135deg, #f7768e, #c53b53) !important; color: #fff !important; }
|
||||
.c2 { background: linear-gradient(135deg, #ff9e64, #c97423) !important; color: #fff !important; }
|
||||
.c3 { background: linear-gradient(135deg, #e0af68, #b08830) !important; color: #fff !important; }
|
||||
.c4 { background: linear-gradient(135deg, #9ece6a, #6d9c3a) !important; color: #fff !important; }
|
||||
.c5 { background: linear-gradient(135deg, #73daca, #3fb0a5) !important; color: #fff !important; }
|
||||
.c6 { background: linear-gradient(135deg, #7aa2f7, #4070d4) !important; color: #fff !important; }
|
||||
.c7 { background: linear-gradient(135deg, #bb9af7, #8b6fd4) !important; color: #fff !important; }
|
||||
|
||||
.series-label {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #c0caf5;
|
||||
}
|
||||
.series-meta {
|
||||
font-size: 12px;
|
||||
color: #565f89;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.hover-hint {
|
||||
font-size: 11px;
|
||||
color: #565f89;
|
||||
margin-top: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Series Card Cover Layouts</h1>
|
||||
<p class="subtitle">4 options for displaying multi-cover composites on the /series browse page. Hover to see animation.</p>
|
||||
|
||||
<div class="options">
|
||||
<!-- Option A -->
|
||||
<div class="option">
|
||||
<h2>A. Stacked Cascade</h2>
|
||||
<p class="desc">Covers stacked diagonally with small offset, like a pile of books on a table</p>
|
||||
<div class="stacked-cascade">
|
||||
<div class="cover c1">#1</div>
|
||||
<div class="cover c2">#2</div>
|
||||
<div class="cover c3">#3</div>
|
||||
<div class="cover c4">#4</div>
|
||||
<div class="cover c5">#5</div>
|
||||
<div class="cover c6">#6</div>
|
||||
<div class="cover c7">#7</div>
|
||||
</div>
|
||||
<div class="series-label">The Expanse</div>
|
||||
<div class="series-meta">7 of 9 books</div>
|
||||
<div class="hover-hint">hover to spread</div>
|
||||
</div>
|
||||
|
||||
<!-- Option B -->
|
||||
<div class="option">
|
||||
<h2>B. Grid Mosaic</h2>
|
||||
<p class="desc">Covers tiled in a 3x3 grid, like a mini bookshelf. Clean and dense.</p>
|
||||
<div class="grid-mosaic">
|
||||
<div class="cover c1">#1</div>
|
||||
<div class="cover c2">#2</div>
|
||||
<div class="cover c3">#3</div>
|
||||
<div class="cover c4">#4</div>
|
||||
<div class="cover c5">#5</div>
|
||||
<div class="cover c6">#6</div>
|
||||
<div class="cover c7">#7</div>
|
||||
<div class="cover spacer"></div>
|
||||
<div class="cover spacer"></div>
|
||||
</div>
|
||||
<div class="series-label">The Expanse</div>
|
||||
<div class="series-meta">7 of 9 books</div>
|
||||
</div>
|
||||
|
||||
<!-- Option C -->
|
||||
<div class="option">
|
||||
<h2>C. Fanned Bottom Arc</h2>
|
||||
<p class="desc">Like a hand of cards fanned from the bottom center, spreading upward</p>
|
||||
<div class="fanned-bottom">
|
||||
<div class="cover c1">#1</div>
|
||||
<div class="cover c2">#2</div>
|
||||
<div class="cover c3">#3</div>
|
||||
<div class="cover c4">#4</div>
|
||||
<div class="cover c5">#5</div>
|
||||
<div class="cover c6">#6</div>
|
||||
<div class="cover c7">#7</div>
|
||||
</div>
|
||||
<div class="series-label">The Expanse</div>
|
||||
<div class="series-meta">7 of 9 books</div>
|
||||
<div class="hover-hint">hover to spread wider</div>
|
||||
</div>
|
||||
|
||||
<!-- Option D -->
|
||||
<div class="option">
|
||||
<h2>D. Perspective Shelf</h2>
|
||||
<p class="desc">Books standing side-by-side with CSS 3D perspective, like a real shelf viewed at angle</p>
|
||||
<div class="perspective-shelf">
|
||||
<div class="cover c1">#1</div>
|
||||
<div class="cover c2">#2</div>
|
||||
<div class="cover c3">#3</div>
|
||||
<div class="cover c4">#4</div>
|
||||
<div class="cover c5">#5</div>
|
||||
<div class="cover c6">#6</div>
|
||||
<div class="cover c7">#7</div>
|
||||
</div>
|
||||
<div class="series-label">The Expanse</div>
|
||||
<div class="series-meta">7 of 9 books</div>
|
||||
<div class="hover-hint">hover to flatten</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
# Project-attached wrapper around `make release` so you can run:
|
||||
# ./release v0.3.0 (or) ./release 0.3.0
|
||||
# instead of:
|
||||
# make release VERSION=v0.3.0
|
||||
# Lives in the repo (no machine-specific alias needed).
|
||||
set -eu
|
||||
|
||||
[ "$#" -ge 1 ] || { echo "Usage: ./release v0.3.0" >&2; exit 1; }
|
||||
|
||||
# Accept "0.3.0" or "v0.3.0"; ensure the tag starts with 'v' (the workflow
|
||||
# only triggers on v* tags).
|
||||
VERSION="v${1#v}"
|
||||
|
||||
exec make release "VERSION=${VERSION}"
|
||||
@@ -21,6 +21,28 @@ const config: Config = {
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
nav: "970px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"ui-sans-serif",
|
||||
"system-ui",
|
||||
"sans-serif",
|
||||
'"Noto Sans SC"',
|
||||
'"Noto Sans TC"',
|
||||
'"Noto Sans JP"',
|
||||
'"Noto Sans KR"',
|
||||
'"PingFang SC"',
|
||||
'"Microsoft YaHei"',
|
||||
'"Hiragino Sans"',
|
||||
'"Apple SD Gothic Neo"',
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Color Emoji",
|
||||
],
|
||||
},
|
||||
colors: {
|
||||
primary: {
|
||||
DEFAULT: "#7aa2f7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -110,11 +110,11 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(library.ID)
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 53, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -123,11 +123,11 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(library.ID)
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 54, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -136,11 +136,11 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(library.ID)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 55, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -149,11 +149,11 @@ func AdminLibrary(user User, libraries []LibraryData, users []User) templ.Compon
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs("library-folders-" + library.ID)
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-folders-" + library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 58, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
|
||||
package templates
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -73,11 +73,11 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 40, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -43,11 +43,11 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -65,11 +65,11 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -87,11 +87,11 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -109,11 +109,11 @@ func AdminSidebar(user User, currentPath string) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_sidebar.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -55,11 +55,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("user-" + user.ID)
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 37, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -114,11 +114,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("/api/auth/profile/" + user.ID)
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 70, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -127,11 +127,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("#role-result-" + user.ID)
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 72, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -160,11 +160,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("role-result-" + user.ID)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 84, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -191,11 +191,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs("/admin/users/" + user.ID + "/profile-modal")
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 94, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -214,11 +214,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("/api/auth/profile/" + user.ID)
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 113, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -227,11 +227,11 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs("#user-" + user.ID)
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 115, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -19,7 +19,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) }>
|
||||
<body x-data="bookDetail" class="theme-{ user.Theme }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) } data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) } data-conflict-id={ conflictID(book.ActiveConflict) } data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }>
|
||||
@Header(user, "/media/{ uuidToString(book.ID) }")
|
||||
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
||||
<div class="w-full px-4 py-3 flex items-center gap-4">
|
||||
@@ -67,6 +67,34 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
>
|
||||
📖 Read Now
|
||||
</button>
|
||||
<!-- Mark as Read / Unread toggle -->
|
||||
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
|
||||
<button
|
||||
@click="toggleRead(false)"
|
||||
:disabled="readSaving"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
<span x-show="!readSaving">✅ Mark as Unread</span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
} else {
|
||||
<button
|
||||
@click="toggleRead(true)"
|
||||
:disabled="readSaving"
|
||||
class="px-6 py-3 rounded-lg border"
|
||||
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);"
|
||||
>
|
||||
<span x-show="!readSaving">📖 Mark as Read</span>
|
||||
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
<!-- Sync Progress -->
|
||||
if book.ActiveConflict != nil || book.ReadingProgress != nil {
|
||||
<button
|
||||
@@ -103,13 +131,42 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
||||
</button>
|
||||
</div>
|
||||
<!-- Rating Display -->
|
||||
<div class="mb-6">
|
||||
<div class="mb-6" @mouseleave="ratingHover = 0">
|
||||
<span class="text-2xl">
|
||||
@templ.Raw(renderStars(getBookRating(book.Rating)))
|
||||
</span>
|
||||
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
||||
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
|
||||
<template x-for="i in 5" :key="i">
|
||||
<span style="position: relative; display: inline-block;">
|
||||
<span :style="starFill(i)">★</span>
|
||||
<button
|
||||
type="button"
|
||||
@click="setRating(i*2-1)"
|
||||
@mouseenter="ratingHover = i*2-1"
|
||||
:disabled="ratingSaving"
|
||||
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
|
||||
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||
></button>
|
||||
<button
|
||||
type="button"
|
||||
@click="setRating(i*2)"
|
||||
@mouseenter="ratingHover = i*2"
|
||||
:disabled="ratingSaving"
|
||||
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
|
||||
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
|
||||
></button>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
|
||||
<button
|
||||
type="button"
|
||||
x-show="userRating > 0 && !ratingSaving"
|
||||
@click="clearRating()"
|
||||
class="ml-2 text-xs underline hover:opacity-70"
|
||||
style="color: var(--text-secondary);"
|
||||
>Clear</button>
|
||||
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<!-- Community Rating Display (from metadata) -->
|
||||
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
||||
|
||||
@@ -37,15 +37,8 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
⚠️ Conflict Detected
|
||||
</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary);">
|
||||
Progress differs between devices. Review the options below and manually resolve via the Conflicts page.
|
||||
Progress differs between devices. Choose which version to keep.
|
||||
</p>
|
||||
<a
|
||||
href="/conflicts"
|
||||
class="inline-block mt-3 px-4 py-2 rounded-lg text-sm font-semibold"
|
||||
style="background-color: #f59e0b; color: white; text-decoration: none;"
|
||||
>
|
||||
Go to Conflicts Page →
|
||||
</a>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
for source, data := range book.ActiveConflict.ConflictData {
|
||||
@@ -59,21 +52,27 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
{ source }
|
||||
{ data.Source }
|
||||
</span>
|
||||
<p class="text-xs" style="color: var(--text-secondary);">
|
||||
{ FormatInTimezone(data.Timestamp, user.Timezone) }
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-3xl font-bold" style="color: var(--accent);">
|
||||
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64)) }%
|
||||
</div>
|
||||
if data.Data["percentage"] != nil {
|
||||
<div class="text-3xl font-bold" style="color: var(--accent);">
|
||||
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64) * 100) }%
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if data.Data["current_page"] != nil {
|
||||
if data.Data["page"] != nil {
|
||||
<div class="text-sm" style="color: var(--text-secondary);">
|
||||
Page: { fmt.Sprintf("%.0f", data.Data["current_page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
|
||||
if data.Data["total_pages"] != nil {
|
||||
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
|
||||
} else {
|
||||
Page: { fmt.Sprintf("%.0f", data.Data["page"].(float64)) }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if data.Data["epubcfi"] != nil {
|
||||
@@ -81,6 +80,15 @@ templ ProgressSyncModal(user User, book handlers.MediaDetail) {
|
||||
CFI: { data.Data["epubcfi"].(string) }
|
||||
</div>
|
||||
}
|
||||
<div class="mt-3">
|
||||
<button
|
||||
@click={"resolveConflict('" + book.ActiveConflict.ID + "', '" + source + "')"}
|
||||
class="px-3 py-1.5 rounded-lg text-sm font-semibold"
|
||||
style="background-color: var(--accent); color: white;"
|
||||
>
|
||||
Keep This
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -467,25 +475,25 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
</button>
|
||||
<div x-show="openSections.comic" x-transition class="p-4 space-y-3" style="background-color: var(--bg-primary);">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
||||
<select name="manga_type"
|
||||
<label for="manga_type" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Manga Type</label>
|
||||
<select id="manga_type" name="manga_type"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
|
||||
<option value="unknown" if textToString(book.MangaType) == "unknown" { selected } >Unknown</option>
|
||||
<option value="no" if textToString(book.MangaType) == "no" { selected }>No</option>
|
||||
<option value="yes" if textToString(book.MangaType) == "yes" { selected }>Yes</option>
|
||||
<option value="yes_and_right_to_left" if textToString(book.MangaType) == "yes_and_right_to_left" { selected }>Yes (Right to Left)</option>
|
||||
<option value="unknown" selected={ textToString(book.MangaType) == "unknown" }>Unknown</option>
|
||||
<option value="no" selected={ textToString(book.MangaType) == "no" }>No</option>
|
||||
<option value="yes" selected={ textToString(book.MangaType) == "yes" }>Yes</option>
|
||||
<option value="yes_and_right_to_left" selected={ textToString(book.MangaType) == "yes_and_right_to_left" }>Yes (Right to Left)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
||||
<select name="reading_direction"
|
||||
<label for="reading_direction" class="block text-sm font-medium mb-1" style="color: var(--text-secondary);">Reading Direction</label>
|
||||
<select id="reading_direction" name="reading_direction"
|
||||
class="w-full px-3 py-2 rounded-lg border text-sm"
|
||||
style="background-color: var(--bg-secondary); border-color: var(--border); color: var(--text-primary);">
|
||||
<option value="auto" if textToString(book.ReadingDirection) == "auto" { selected }>Auto</option>
|
||||
<option value="ltr" if textToString(book.ReadingDirection) == "ltr" { selected }>Left to Right</option>
|
||||
<option value="rtl" if textToString(book.ReadingDirection) == "rtl" { selected }>Right to Left</option>
|
||||
<option value="vertical" if textToString(book.ReadingDirection) == "vertical" { selected }>Vertical</option>
|
||||
<option value="auto" selected={ textToString(book.ReadingDirection) == "auto" }>Auto</option>
|
||||
<option value="ltr" selected={ textToString(book.ReadingDirection) == "ltr" }>Left to Right</option>
|
||||
<option value="rtl" selected={ textToString(book.ReadingDirection) == "rtl" }>Right to Left</option>
|
||||
<option value="vertical" selected={ textToString(book.ReadingDirection) == "vertical" }>Vertical</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+580
-547
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
+12
-12
@@ -50,22 +50,22 @@ templ BookShelf(
|
||||
class="w-full px-3 py-2 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
>
|
||||
if len(libraries) == 0 {
|
||||
<option value="">No libraries available</option>
|
||||
if len(libraries) == 0 {
|
||||
<option value="">No libraries available</option>
|
||||
} else {
|
||||
if currentLibraryID == "" {
|
||||
<option value="" selected>All Books ({ TotalMediaCount(libraries) })</option>
|
||||
} else {
|
||||
if currentLibraryID == "" {
|
||||
<option value="" selected>All Books</option>
|
||||
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
} else {
|
||||
<option value="">All Books</option>
|
||||
}
|
||||
for _, lib := range libraries {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name }</option>
|
||||
}
|
||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
}
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Search Input -->
|
||||
|
||||
+123
-71
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -54,11 +54,11 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("/api/collections/" + collection.ID)
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 16, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -67,11 +67,11 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.ID)
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 19, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -80,11 +80,11 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 25, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -106,11 +106,11 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 56, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -119,11 +119,11 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Color)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 75, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -137,11 +137,11 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 122, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -55,11 +55,11 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("/collections/" + col.ID)
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 62, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -68,11 +68,11 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(col.Color)
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 66, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -94,11 +94,11 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs("/collections/" + col.ID + "/edit-modal")
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 72, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -107,11 +107,11 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs("/api/collections/" + col.ID + "")
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 81, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -317,11 +317,11 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.CoverImagePath)
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 199, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -345,11 +345,11 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(collection.ID)
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 352, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -358,11 +358,11 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(libraryID)
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 353, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -47,11 +47,11 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID)
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 68, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -102,11 +102,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -115,11 +115,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(section.IsSystem)
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -200,11 +200,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 69, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -213,11 +213,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs("carousel-track-" + section.ID)
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -242,11 +242,11 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 97, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -297,11 +297,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -310,11 +310,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs("View " + item.Title)
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("View " + item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 114, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -328,11 +328,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(item.CoverImagePath)
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 122, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -341,11 +341,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 123, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -359,11 +359,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -377,11 +377,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 137, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -408,11 +408,11 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 141, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -473,11 +473,11 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 178, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -486,11 +486,11 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%v", section.IsSystem))
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -540,11 +540,11 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(section.ID)
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -586,11 +586,11 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -58,10 +58,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
}
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="showShelfMappings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||
<button @click={ "showShelfMappings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||
📚
|
||||
</button>
|
||||
<button @click="showDeviceSettings('{ device.ID }')" class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||
<button @click={ "showDeviceSettings('" + device.ID.String() + "')" } class="p-2 hover:opacity-80 rounded" style="color: var(--text-secondary); background-color: var(--bg-primary);">
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
@@ -175,10 +175,10 @@ templ Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []P
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button @click="approveDevice('{ reg.RegistrationID }')" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
<button @click={ "approveDevice('" + reg.RegistrationID + "')" } class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
Approve
|
||||
</button>
|
||||
<button @click="rejectDevice('{ reg.RegistrationID }')" class="btn-danger px-4 py-2 rounded-lg text-sm">
|
||||
<button @click={ "rejectDevice('" + reg.RegistrationID + "')" } class="btn-danger px-4 py-2 rounded-lg text-sm">
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
|
||||
+182
-130
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -58,11 +58,11 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -35,11 +35,11 @@ func FilterItem(id string, name string) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(id)
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 8, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+231
-3
@@ -23,7 +23,7 @@ templ Header(user User, currentPath string) {
|
||||
</svg>
|
||||
<span x-show="scanning && scanProgress > 0" x-transition class="text-xs font-normal" style="color: var(--text-secondary);" x-text="scanProgress + '%'"></span>
|
||||
</a>
|
||||
<div class="hidden md:flex items-center space-x-4">
|
||||
<div class="hidden nav:flex items-center space-x-4">
|
||||
<a href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Library
|
||||
</a>
|
||||
@@ -49,7 +49,7 @@ templ Header(user User, currentPath string) {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Center: Search Box -->
|
||||
<div class="flex-1 max-w-2xl mx-8">
|
||||
<div class="flex-1 max-w-2xl mx-8 hidden nav:block">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
@@ -65,7 +65,7 @@ templ Header(user User, currentPath string) {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Right: Theme Switcher & User Menu -->
|
||||
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="flex items-center space-x-4">
|
||||
<div x-data="{ themeDropdownOpen: false, userMenuOpen: false }" class="hidden nav:flex items-center space-x-4">
|
||||
<!-- Theme Switcher -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@@ -296,6 +296,234 @@ templ Header(user User, currentPath string) {
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@click="mobileMenuOpen = !mobileMenuOpen"
|
||||
type="button"
|
||||
class="nav:hidden p-2 rounded-lg hover:opacity-80 transition-opacity"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary);"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
x-show="mobileMenuOpen"
|
||||
@click.outside="mobileMenuOpen = false"
|
||||
x-transition:enter="transition ease-out duration-200"
|
||||
x-transition:enter-start="opacity-0 -translate-y-2"
|
||||
x-transition:enter-end="opacity-100 translate-y-0"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100 translate-y-0"
|
||||
x-transition:leave-end="opacity-0 -translate-y-2"
|
||||
class="nav:hidden border-t"
|
||||
style="border-color: var(--border); background-color: var(--bg-secondary); display: none;"
|
||||
x-data="{ mobileThemeOpen: false }"
|
||||
>
|
||||
<div class="px-4 py-3 space-y-1">
|
||||
<div class="relative mb-3">
|
||||
<input
|
||||
type="text"
|
||||
id="header-search-mobile"
|
||||
placeholder="Search your library..."
|
||||
class="w-full px-4 py-2 pl-10 border rounded-lg"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<a href="/dashboard" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Library
|
||||
</a>
|
||||
<a href="/bookshelf" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
All Books
|
||||
</a>
|
||||
<a href="/series" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Series
|
||||
</a>
|
||||
<a href="/collections" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Collections
|
||||
</a>
|
||||
<a href="/progress" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Progress
|
||||
</a>
|
||||
<a href="/devices" class="block px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Devices
|
||||
</a>
|
||||
<div class="border-t my-2" style="border-color: var(--border);"></div>
|
||||
<button
|
||||
@click="mobileThemeOpen = !mobileThemeOpen"
|
||||
type="button"
|
||||
class="flex items-center space-x-2 w-full px-3 py-2 rounded-lg text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01"></path>
|
||||
</svg>
|
||||
<span>Theme</span>
|
||||
<svg class="h-4 w-4 ml-auto transform transition-transform" :class="{ 'rotate-180': mobileThemeOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div x-show="mobileThemeOpen" x-transition class="pl-8 pr-2 py-1 space-y-1">
|
||||
<button
|
||||
@click="changeTheme('tokyo-night'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #7aa2f7;"></span>
|
||||
Tokyo Night
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('dracula'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #bd93f9;"></span>
|
||||
Dracula
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('nord'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #88c0d0;"></span>
|
||||
Nord
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('solarized-dark'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #2aa198;"></span>
|
||||
Solarized Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('monokai'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #a6e22e;"></span>
|
||||
Monokai
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('one-dark-pro'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #61dafb;"></span>
|
||||
One Dark Pro
|
||||
</button>
|
||||
<button
|
||||
@click="changeTheme('material-dark'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background-color: #80cbc4;"></span>
|
||||
Material Dark
|
||||
</button>
|
||||
<div class="border-t pt-2 mt-2" style="border-color: var(--border);">
|
||||
<p class="text-xs mb-2 px-3" style="color: var(--text-secondary)">Bookshelf Background</p>
|
||||
<button
|
||||
@click="changeWoodPaneling('none'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="none"
|
||||
>
|
||||
None
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-light'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-light"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-light.png'); background-size: cover;"></span>
|
||||
Wood Light
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-dark'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-dark"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-dark.png'); background-size: cover;"></span>
|
||||
Wood Dark
|
||||
</button>
|
||||
<button
|
||||
@click="changeWoodPaneling('wood-mahogany'); mobileThemeOpen = false"
|
||||
type="button"
|
||||
class="wood-paneling-btn w-full text-left px-3 py-2 rounded text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-primary);"
|
||||
data-wood="wood-mahogany"
|
||||
>
|
||||
<span class="inline-block w-4 h-4 rounded mr-2" style="background: url('/static/textures/wood-mahogany.png'); background-size: cover;"></span>
|
||||
Wood Mahogany
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t my-2" style="border-color: var(--border);"></div>
|
||||
if user.ID != "" {
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center space-x-2 px-3 py-2">
|
||||
<svg class="h-5 w-5" style="color: var(--text-secondary)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
|
||||
</svg>
|
||||
<span class="text-sm" style="color: var(--text-primary)">{ user.Username }</span>
|
||||
</div>
|
||||
<a href="/profile" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Profile
|
||||
</a>
|
||||
if user.Role == "admin" {
|
||||
<a href="/admin" class="block px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Admin Panel
|
||||
</a>
|
||||
}
|
||||
<button
|
||||
@click="logout()"
|
||||
type="button"
|
||||
class="block w-full text-left px-3 py-2 pl-10 rounded-lg text-sm hover:opacity-80 transition-opacity"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
} else {
|
||||
<div class="space-y-3">
|
||||
<form hx-post="/api/auth/login" hx-target="#login-result-mobile" hx-swap="innerHTML" class="space-y-3">
|
||||
<input type="hidden" name="redirect" value="{ currentPath }"/>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Email or Username</label>
|
||||
<input type="text" name="login" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm mb-1" style="color: var(--text-secondary)">Password</label>
|
||||
<input type="password" name="password" class="w-full px-3 py-2 border rounded text-sm" style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);" required/>
|
||||
</div>
|
||||
<button type="submit" class="w-full py-2 rounded text-sm" style="background-color: var(--accent); color: white;">
|
||||
Sign In
|
||||
</button>
|
||||
</form>
|
||||
<div id="login-result-mobile"></div>
|
||||
<a href="/register" class="block text-center text-sm hover:opacity-80" style="color: var(--text-secondary); text-decoration: none;">
|
||||
Don't have an account? Sign Up
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -11,14 +11,14 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
|
||||
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary);"
|
||||
>
|
||||
<option value="">All Libraries</option>
|
||||
for _, lib := range libData {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name }</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name }</option>
|
||||
}
|
||||
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
|
||||
for _, lib := range libData {
|
||||
if lib.ID == currentLibraryID {
|
||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
} else {
|
||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
if len(actions) > 0 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -29,81 +29,120 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries</option> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div class=\"sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b\" style=\"background-color: var(--bg-primary);\"><div class=\"w-full px-4 py-3 flex items-center justify-between\"><div class=\"flex items-center gap-4\"><label class=\"text-sm font-medium\" style=\"color: var(--text-secondary)\">Library:</label> <select id=\"library-select\" name=\"library_id\" class=\"px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500\" style=\"background-color: var(--bg-secondary); color: var(--text-primary);\"><option value=\"\">All Libraries (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libData))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 14, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, ")</option> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, lib := range libData {
|
||||
if lib.ID == currentLibraryID {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" selected>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<option value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\" selected>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.ID)
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 17, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</option>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, " (")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/library_switcher.templ`, Line: 19, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, ")</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</select></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</select></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(actions) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"flex items-center gap-2\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"flex items-center gap-2\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -113,12 +152,12 @@ func LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ...
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div id=\"loading-spinner\" class=\"hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50\" style=\"background-color: var(--bg-primary);\"><div class=\"animate-spin rounded-full h-12 w-12 border-b-2\" style=\"border-color: var(--accent);\"></div></div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -142,12 +181,12 @@ func DashboardActions() templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var6 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var6 == nil {
|
||||
templ_7745c5c3_Var6 = templ.NopComponent
|
||||
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var9 == nil {
|
||||
templ_7745c5c3_Var9 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<button data-action=\"open-dashboard-settings\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Customize Dashboard\">⚙️</button> <button data-action=\"reload-page\" class=\"p-2 rounded-lg hover:bg-gray-700 transition-colors\" style=\"background-color: var(--bg-secondary);\" title=\"Refresh\">🔄</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -34,11 +34,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(actionURL)
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(actionURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 5, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -47,11 +47,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 20, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -60,11 +60,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 30, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -73,11 +73,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(user.FirstName)
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.FirstName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 40, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -86,11 +86,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.LastName)
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(user.LastName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 51, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -449,11 +449,11 @@ func ProfileForm(user User, actionURL string, requireCurrentPassword bool, showR
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs("/api/auth/password/" + user.ID)
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/password/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/profile_form.templ`, Line: 199, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -14,11 +14,19 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string {
|
||||
"mangaType": metadata.MangaType,
|
||||
}
|
||||
if progress.Percentage > 0 {
|
||||
config["savedPercentage"] = progress.Percentage
|
||||
config["savedPercentage"] = progress.Percentage / 100
|
||||
}
|
||||
if progress.EpubCfi != "" {
|
||||
config["savedCfi"] = progress.EpubCfi
|
||||
}
|
||||
// Fixed-layout & comic formats: the page index is the canonical, exact
|
||||
// locator (pages are fixed images). Pass it so the reader restores by page.
|
||||
if (metadata.FormatGroup == "fixed_layout" || metadata.FormatGroup == "comic_archive") && progress.CurrentPage > 0 {
|
||||
config["savedPage"] = progress.CurrentPage
|
||||
if progress.TotalPages > 0 {
|
||||
config["savedTotalPages"] = progress.TotalPages
|
||||
}
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(config)
|
||||
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
||||
}
|
||||
|
||||
+24
-16
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -22,11 +22,19 @@ func readerInitExpr(metadata ReaderMetadata, progress ReadingProgress) string {
|
||||
"mangaType": metadata.MangaType,
|
||||
}
|
||||
if progress.Percentage > 0 {
|
||||
config["savedPercentage"] = progress.Percentage
|
||||
config["savedPercentage"] = progress.Percentage / 100
|
||||
}
|
||||
if progress.EpubCfi != "" {
|
||||
config["savedCfi"] = progress.EpubCfi
|
||||
}
|
||||
// Fixed-layout & comic formats: the page index is the canonical, exact
|
||||
// locator (pages are fixed images). Pass it so the reader restores by page.
|
||||
if (metadata.FormatGroup == "fixed_layout" || metadata.FormatGroup == "comic_archive") && progress.CurrentPage > 0 {
|
||||
config["savedPage"] = progress.CurrentPage
|
||||
if progress.TotalPages > 0 {
|
||||
config["savedTotalPages"] = progress.TotalPages
|
||||
}
|
||||
}
|
||||
jsonBytes, _ := json.Marshal(config)
|
||||
return fmt.Sprintf("initReader(%s)", string(jsonBytes))
|
||||
}
|
||||
@@ -59,7 +67,7 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 32, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 40, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -70,11 +78,11 @@ func Reader(user User, metadata ReaderMetadata, progress ReadingProgress, bookma
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(readerInitExpr(metadata, progress))
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(readerInitExpr(metadata, progress))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 41, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 49, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -162,7 +170,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + metadata.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 79, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 87, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -175,7 +183,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 82, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 90, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -190,7 +198,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%% · Page %d/%d", progress.Percentage, progress.CurrentPage, metadata.EstimatedPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 160, Col: 112}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 168, Col: 112}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -200,7 +208,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.0f%%", progress.Percentage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 162, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 170, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -211,7 +219,7 @@ func ReaderChrome(user User, metadata ReaderMetadata, progress ReadingProgress)
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d/%d", progress.CurrentPage, progress.TotalPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 165, Col: 71}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 173, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -349,11 +357,11 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.CfiPosition)
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(bookmark.CfiPosition)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 382, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 390, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -364,7 +372,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 385, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 393, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -377,7 +385,7 @@ func ReaderBookmarksPanel(bookmarks []Bookmark) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(bookmark.Position)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 387, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/reader.templ`, Line: 395, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -212,11 +212,11 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -235,11 +235,11 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(cover)
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 75, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -248,11 +248,11 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var10).String())
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -261,11 +261,11 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name)
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 77, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
package templates
|
||||
|
||||
templ Setup() {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>Setup - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<script type="module" src="/static/main.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="theme-tokyo-night min-h-screen" x-data="setupWizard" x-init="initSetup()">
|
||||
<div class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">Welcome to Bookhoard</h1>
|
||||
<p class="text-lg" style="color: var(--text-secondary)">Let's get your library set up.</p>
|
||||
</div>
|
||||
|
||||
<!-- Step indicator -->
|
||||
<div class="flex items-center justify-center mb-8 gap-2">
|
||||
<template x-for="n in 4" :key="n">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm transition-all"
|
||||
:class="currentStep >= n ? 'bg-[var(--accent)] text-[var(--bg-primary)]' : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] border border-[var(--border)]'"
|
||||
>
|
||||
<span x-text="currentStep > n ? '✓' : n"></span>
|
||||
</div>
|
||||
<div
|
||||
x-text="['Admin', 'Libraries', 'Folders', 'Scan'][n-1]"
|
||||
class="text-sm hidden sm:inline"
|
||||
:style="currentStep >= n ? 'color: var(--text-primary)' : 'color: var(--text-secondary)'"
|
||||
></div>
|
||||
<div x-show="n < 4" class="w-8 h-0.5 mx-1" :class="currentStep > n ? 'bg-[var(--accent)]' : 'bg-[var(--border)]'"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Error display -->
|
||||
<div x-show="errorMsg" class="mb-4 p-4 rounded-lg border border-red-500/50 bg-red-500/10 text-red-400 text-sm">
|
||||
<span x-text="errorMsg"></span>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEP 1: Create Admin ==================== -->
|
||||
<div x-show="currentStep === 1" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Create Administrator Account</h2>
|
||||
<p class="mb-6" style="color: var(--text-secondary)">This first account will have full admin privileges.</p>
|
||||
|
||||
<form @submit.prevent="submitAdmin()">
|
||||
<div class="mb-6 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Server URL (Base URL)</label>
|
||||
<input
|
||||
type="url"
|
||||
x-model="admin.baseUrl"
|
||||
placeholder="https://books.example.com"
|
||||
class="w-full px-3 py-2 border rounded mt-2"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
|
||||
required
|
||||
/>
|
||||
<p class="text-sm mt-2" style="color: var(--text-secondary)">The public URL of your Bookhoard instance. Used for device sync, OPDS feed, and API endpoints.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
x-model="admin.email"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="admin.username"
|
||||
id="username"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="admin.firstName"
|
||||
placeholder="Optional"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="admin.lastName"
|
||||
placeholder="Optional"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="password-requirements" class="mb-4 p-4 rounded-lg border text-sm" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<p class="font-semibold mb-2" style="color: var(--text-primary)">Password Requirements:</p>
|
||||
<ul class="space-y-1" style="color: var(--text-secondary)">
|
||||
<li id="req-length" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> At least 8 characters
|
||||
</li>
|
||||
<li id="req-upper" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One uppercase letter
|
||||
</li>
|
||||
<li id="req-lower" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One lowercase letter
|
||||
</li>
|
||||
<li id="req-number" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One number
|
||||
</li>
|
||||
<li id="req-special" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> One special character
|
||||
</li>
|
||||
<li id="req-match" class="flex items-center gap-2">
|
||||
<span class="requirement-icon">○</span> Passwords match
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="admin.password"
|
||||
id="password"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
x-model="admin.confirmPassword"
|
||||
id="confirm-password"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
id="register-btn"
|
||||
class="btn-primary w-full py-2 rounded opacity-50 cursor-not-allowed"
|
||||
:disabled="loading"
|
||||
x-text="loading ? 'Creating...' : 'Create Admin Account'"
|
||||
></button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEP 2: Create Libraries ==================== -->
|
||||
<div x-show="currentStep === 2" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Create Libraries</h2>
|
||||
<p class="mb-6" style="color: var(--text-secondary)">Add one or more media libraries. You can always add more later.</p>
|
||||
|
||||
<!-- Created libraries list -->
|
||||
<div x-show="libraries.length > 0" class="mb-6 space-y-2">
|
||||
<template x-for="(lib, idx) in libraries" :key="lib.id">
|
||||
<div class="p-3 rounded-lg border flex items-center justify-between" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-xl" x-text="lib.type === 'ebooks' ? '📚' : lib.type === 'comics' ? '📖' : '🗾'"></span>
|
||||
<div>
|
||||
<p class="font-medium" style="color: var(--text-primary)" x-text="lib.name"></p>
|
||||
<p class="text-xs capitalize" style="color: var(--text-secondary)" x-text="lib.type"></p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeLibrary(idx)"
|
||||
class="text-xs text-red-500 hover:underline"
|
||||
>Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add library form -->
|
||||
<form @submit.prevent="addLibrary()" class="space-y-4 p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library Name</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="newLibrary.name"
|
||||
placeholder="My Ebook Library"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Library Type</label>
|
||||
<select
|
||||
x-model="newLibrary.type"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
|
||||
>
|
||||
<option value="ebooks">📚 Ebooks</option>
|
||||
<option value="comics">📖 Comics</option>
|
||||
<option value="manga">🗾 Manga</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Description (optional)</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="newLibrary.description"
|
||||
placeholder="Optional"
|
||||
class="w-full px-3 py-2 border rounded"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-secondary w-full py-2 rounded"
|
||||
:disabled="loading || !newLibrary.name.trim()"
|
||||
x-text="loading ? 'Creating...' : '+ Add This Library'"
|
||||
></button>
|
||||
</form>
|
||||
|
||||
<div class="flex justify-between mt-6">
|
||||
<button type="button" @click="currentStep = 1" class="btn-secondary px-4 py-2 rounded">Back</button>
|
||||
<button type="button" @click="currentStep = 3" class="btn-primary px-6 py-2 rounded">
|
||||
<span x-text="libraries.length > 0 ? 'Next: Configure Folders' : 'Skip – Continue'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEP 3: Configure Folders ==================== -->
|
||||
<div x-show="currentStep === 3" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Configure Library Folders</h2>
|
||||
<p class="mb-6" style="color: var(--text-secondary)">Add the filesystem folders where your media files are stored.</p>
|
||||
|
||||
<div x-show="libraries.length === 0" class="text-center py-8" style="color: var(--text-secondary)">
|
||||
<p>No libraries created. You can add folders later from the admin panel.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<template x-for="lib in libraries" :key="lib.id">
|
||||
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="text-xl" x-text="lib.type === 'ebooks' ? '📚' : lib.type === 'comics' ? '📖' : '🗾'"></span>
|
||||
<h4 class="font-semibold" style="color: var(--text-primary)" x-text="lib.name"></h4>
|
||||
</div>
|
||||
|
||||
<!-- Existing folders for this library -->
|
||||
<div class="space-y-1 mb-3" x-show="lib.folders && lib.folders.length > 0">
|
||||
<template x-for="(folder, fIdx) in (lib.folders || [])" :key="folder">
|
||||
<div class="flex items-center justify-between p-2 rounded text-sm" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<span style="color: var(--text-primary)" x-text="folder"></span>
|
||||
<button type="button" @click="removeFolder(lib.id, folder, fIdx)" class="text-xs text-red-500 hover:underline">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Add folder -->
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="'/path/to/' + lib.type"
|
||||
:id="'folderInput-' + lib.id"
|
||||
class="flex-1 px-3 py-2 border rounded text-sm"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="showFolderBrowser('folderInput-' + lib.id)"
|
||||
class="btn-secondary px-3 py-2 text-xs rounded"
|
||||
>Browse</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="addFolderToLibrary(lib)"
|
||||
class="btn-primary px-3 py-2 text-xs rounded"
|
||||
>Add</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between mt-6">
|
||||
<button type="button" @click="currentStep = 2" class="btn-secondary px-4 py-2 rounded">Back</button>
|
||||
<button type="button" @click="currentStep = 4" class="btn-primary px-6 py-2 rounded">Next: Scan</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== STEP 4: Scan & Finish ==================== -->
|
||||
<div x-show="currentStep === 4" class="card p-6 rounded-lg shadow-md border" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Scan & Finish</h2>
|
||||
<p class="mb-6" style="color: var(--text-secondary)">Review your configuration and start the initial scan.</p>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="space-y-3 mb-6">
|
||||
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-secondary)">Server URL</p>
|
||||
<p style="color: var(--text-primary)" x-text="admin.baseUrl"></p>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<p class="text-sm font-medium mb-1" style="color: var(--text-secondary)">Administrator</p>
|
||||
<p style="color: var(--text-primary)" x-text="admin.username + ' (' + admin.email + ')'"></p>
|
||||
</div>
|
||||
<div class="p-4 rounded-lg border" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<p class="text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
||||
Libraries (<span x-text="libraries.length"></span>)
|
||||
</p>
|
||||
<template x-for="lib in libraries" :key="lib.id">
|
||||
<div class="flex items-start justify-between text-sm py-1">
|
||||
<div>
|
||||
<span style="color: var(--text-primary)" x-text="lib.name"></span>
|
||||
<span class="ml-2 text-xs px-2 py-0.5 rounded" style="background-color: var(--accent); color: var(--bg-primary)" x-text="lib.type"></span>
|
||||
</div>
|
||||
<div class="text-right" style="color: var(--text-secondary)">
|
||||
<span x-text="(lib.folders || []).length + ' folder(s)'"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scan progress -->
|
||||
<div x-show="scanStarted" class="mb-6 space-y-3">
|
||||
<h3 class="font-semibold" style="color: var(--text-primary)">Scan Progress</h3>
|
||||
<div id="library-progress-list" class="space-y-3">
|
||||
<template x-for="job in scanJobs" :key="job.jobId">
|
||||
<div class="p-3 rounded border" style="background-color: var(--bg-primary); border-color: var(--border)">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="font-medium" style="color: var(--text-primary)" x-text="job.libraryName"></span>
|
||||
<span class="text-sm" style="color: var(--text-secondary)" x-text="job.statusText"></span>
|
||||
</div>
|
||||
<div class="w-full rounded-full h-2" style="background-color: var(--bg-secondary)">
|
||||
<div
|
||||
class="h-2 rounded-full transition-all duration-500"
|
||||
:style="'width: ' + job.progress + '%; background-color: var(--accent);'"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div x-show="scanComplete" class="p-4 rounded-lg border border-green-500/50 bg-green-500/10 text-green-400 text-sm">
|
||||
Initial scan complete! Your libraries are ready to use.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between mt-6">
|
||||
<button type="button" @click="currentStep = 3" class="btn-secondary px-4 py-2 rounded" :disabled="loading">Back</button>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
x-show="!scanStarted"
|
||||
type="button"
|
||||
@click="startScan()"
|
||||
class="btn-primary px-6 py-2 rounded"
|
||||
:disabled="loading"
|
||||
>Start Scan</button>
|
||||
|
||||
<button
|
||||
x-show="scanStarted && !scanComplete"
|
||||
type="button"
|
||||
@click="finishSetup()"
|
||||
class="btn-secondary px-6 py-2 rounded"
|
||||
:disabled="loading"
|
||||
>Skip to Dashboard</button>
|
||||
|
||||
<button
|
||||
x-show="scanComplete"
|
||||
type="button"
|
||||
@click="finishSetup()"
|
||||
class="btn-primary px-6 py-2 rounded"
|
||||
:disabled="loading"
|
||||
x-text="loading ? 'Finishing...' : 'Go to Dashboard'"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder Browser Modal (reused from admin library) -->
|
||||
<div id="folder-browser-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center" style="background-color: rgba(0, 0, 0, 0.7)">
|
||||
<div class="card rounded-lg p-6 w-full max-w-md mx-4" style="background-color: var(--bg-secondary); border-color: var(--border)">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Browse Folders</h2>
|
||||
<button type="button" @click="hideFolderBrowser()" class="p-2 hover:opacity-80 rounded" style="color: var(--text-primary)">✕</button>
|
||||
</div>
|
||||
<div id="folder-browser-content">
|
||||
<!-- Directory listings rendered by Alpine -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
@@ -35,11 +35,11 @@ func ErrorToast(message string) templ.Component {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("showErrorToast('" + message + "')")
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showErrorToast('" + message + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/toast.templ`, Line: 5, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ type LibraryData struct {
|
||||
Name string
|
||||
Description string
|
||||
TypeName string
|
||||
MediaCount int64
|
||||
}
|
||||
|
||||
type SeriesCardData struct {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
// templ: version: v0.3.1020
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
@@ -2,9 +2,11 @@ package templates
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -29,6 +31,16 @@ func ContainsString(slice []string, item string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TotalMediaCount sums the MediaCount across the given libraries,
|
||||
// used to display the total next to the "All Libraries" option.
|
||||
func TotalMediaCount(libs []LibraryData) int64 {
|
||||
var total int64
|
||||
for _, l := range libs {
|
||||
total += l.MediaCount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func uuidToString(id pgtype.UUID) string {
|
||||
if !id.Valid {
|
||||
return ""
|
||||
@@ -146,6 +158,37 @@ func getBookRating(rating *database.MediaRatings) int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// conflictWinnerSource returns a valid source key from a conflict's
|
||||
// conflict_data map, to pass as the "winner" when resolving it. It prefers
|
||||
// "web" (since the user is acting via the web UI) and otherwise falls back to
|
||||
// the lexicographically smallest key. The chosen winner does not affect the
|
||||
// final read/unread state, which is set by a subsequent progress write; it only
|
||||
// needs to be a key present in the conflict data so the resolve endpoint
|
||||
// accepts it and arms its 10-minute suppression window.
|
||||
func conflictWinnerSource(c *handlers.ConflictDetailResponse) string {
|
||||
if c == nil || len(c.ConflictData) == 0 {
|
||||
return ""
|
||||
}
|
||||
if _, ok := c.ConflictData["web"]; ok {
|
||||
return "web"
|
||||
}
|
||||
keys := make([]string, 0, len(c.ConflictData))
|
||||
for k := range c.ConflictData {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
// conflictID returns the active conflict's ID, or "" when there is none. Used
|
||||
// to render a data-conflict-id attribute the frontend can read.
|
||||
func conflictID(c *handlers.ConflictDetailResponse) string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return c.ID
|
||||
}
|
||||
|
||||
// getAlternateSeries extracts the alternate series name from JSONB data
|
||||
func getAlternateSeries(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
|
||||
+19
-1
@@ -1,12 +1,30 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { cpSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const pdfjsAssets = () => ({
|
||||
name: "pdfjs-assets",
|
||||
closeBundle() {
|
||||
const outDir = "web/static/vendor/pdfjs";
|
||||
const srcDir =
|
||||
"node_modules/@bookhoard/foliate-js/vendor/pdfjs";
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
cpSync(join(srcDir, "standard_fonts"), join(outDir, "standard_fonts"), {
|
||||
recursive: true,
|
||||
});
|
||||
cpSync(join(srcDir, "cmaps"), join(outDir, "cmaps"), { recursive: true });
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/web/src",
|
||||
"foliate-js": "/node_modules/@bookhoard/foliate-js", // Updated path
|
||||
"foliate-js": "/node_modules/@bookhoard/foliate-js",
|
||||
},
|
||||
},
|
||||
base: "/static/",
|
||||
plugins: [pdfjsAssets()],
|
||||
build: {
|
||||
outDir: "web/static",
|
||||
emptyOutDir: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user