diff --git a/.env.example b/.env.example index cdb581b..d4721b2 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..c11c520 --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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 diff --git a/Makefile b/Makefile index 54e6a0b..c986827 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -11,6 +11,10 @@ endif # 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:" @@ -40,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: @@ -48,9 +55,9 @@ test: # Run integration tests in containers (matches production environment) test-integration: @echo "Building test containers..." - $(CONTAINER_RUNTIME) compose --profile tests build + $(COMPOSE) --profile tests build @echo "Starting application containers..." - $(CONTAINER_RUNTIME) compose up -d db app + $(COMPOSE) up -d db app @echo "Waiting for services to be healthy..." @until $(CONTAINER_RUNTIME) exec bookhoard_db pg_isready -U postgres > /dev/null 2>&1; do \ echo " Database not ready yet..."; \ @@ -64,7 +71,7 @@ test-integration: echo " ✓ Application is ready" @echo "" @echo "Running integration tests in container..." - $(CONTAINER_RUNTIME) 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." @@ -75,67 +82,67 @@ test-all: test test-integration # Rebuild app container only (preserve DB, with cache) rebuild-app: @echo "Rebuilding app container (database stays running)..." - $(CONTAINER_RUNTIME) 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)..." - $(CONTAINER_RUNTIME) compose build --no-cache app - $(CONTAINER_RUNTIME) 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)..." - $(CONTAINER_RUNTIME) 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)..." - $(CONTAINER_RUNTIME) compose build --no-cache - $(CONTAINER_RUNTIME) 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)..." - $(CONTAINER_RUNTIME) compose down -v - $(CONTAINER_RUNTIME) compose build --no-cache - $(CONTAINER_RUNTIME) 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: - $(CONTAINER_RUNTIME) compose down -v + $(COMPOSE) down -v # Quick start (if already built) up: - $(CONTAINER_RUNTIME) compose up -d + $(COMPOSE) up -d # Stop all containers (alias for clean) down: - $(CONTAINER_RUNTIME) compose down + $(COMPOSE) down # Restart app container (preserves database) restart: @echo "Restarting app container (database stays running)..." - $(CONTAINER_RUNTIME) compose restart app + $(COMPOSE) restart app @echo "✓ App container restarted" # Show container status ps: - $(CONTAINER_RUNTIME) compose ps + $(COMPOSE) ps # Show container logs logs: - $(CONTAINER_RUNTIME) 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 $(CONTAINER_RUNTIME) 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 $(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 @@ -144,7 +151,7 @@ test-env-up: # Stop test environment test-env-down: - $(CONTAINER_RUNTIME) compose down -v + $(COMPOSE) down -v # Verify project guidelines compliance verify-guidelines: @@ -154,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." diff --git a/README.md b/README.md index 0cf835f..08caec9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bruno/NewDB.sh b/bruno/NewDB.sh index 03cc26f..18dd9ec 100755 --- a/bruno/NewDB.sh +++ b/bruno/NewDB.sh @@ -1,3 +1,3 @@ #!/bin/bash -bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml" +bru run --env Bookhoard --delay 500 "NewDevDBSetup/RegisterUser.yml" "NewDevDBSetup/SetBaseUrl.yml" "NewDevDBSetup/CreateEbookLibrary.yml" "NewDevDBSetup/CreateComicLibrary.yml" "NewDevDBSetup/CreateMangaLibrary.yml" "NewDevDBSetup/AddEbookLibraryFolder.yml" "NewDevDBSetup/AddComicLibraryFolder.yml" "NewDevDBSetup/AddMangaLibraryFolder.yml" "NewDevDBSetup/ScanAllLibraries.yml" diff --git a/bruno/NewDevDBSetup/SetBaseUrl.yml b/bruno/NewDevDBSetup/SetBaseUrl.yml new file mode 100644 index 0000000..27ba77d --- /dev/null +++ b/bruno/NewDevDBSetup/SetBaseUrl.yml @@ -0,0 +1,38 @@ +info: + name: SetBaseUrl + type: http + seq: 3 + +http: + method: PUT + url: '{{base_url}}/api/system/config' + auth: inherit + body: + type: json + jsonBody: |- + { + "base_url": "http://localhost:8765" + } + headers: + - key: Authorization + value: Bearer {{token}} + - key: Content-Type + value: application/json + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 + +docs: |- + ## Set Base URL + + Configures the server's base_url during initial dev database setup. + + Must be run after RegisterUser (which provides the auth token) and before + any library/device creation (which require setup to be complete). + + **Method:** PUT + **Endpoint:** /api/system/config + **Auth:** Bearer token (from RegisterUser) diff --git a/bruno/media-items/Update Media Rating.yml b/bruno/media-items/Update Media Rating.yml index 179fca8..765a149 100644 --- a/bruno/media-items/Update Media Rating.yml +++ b/bruno/media-items/Update Media Rating.yml @@ -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 diff --git a/bruno/opencollection.yml b/bruno/opencollection.yml index 1318a1c..40b11a6 100644 --- a/bruno/opencollection.yml +++ b/bruno/opencollection.yml @@ -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 diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..e23533c --- /dev/null +++ b/cliff.toml @@ -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" diff --git a/cmd/server/main.go b/cmd/server/main.go index ea041a3..b470e10 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -14,6 +14,8 @@ import ( "log" "time" + _ "time/tzdata" + "github.com/go-playground/validator/v10" "github.com/jackc/pgx/v5/pgxpool" "github.com/labstack/echo/v5" @@ -48,6 +50,35 @@ func main() { } log.Println("✅ Database schema initialized and verified, starting server...") + // Seed base_url from env var if not already configured. Uses conditional + // UPDATE so admin-set values are never overwritten on restart. + if cfg.BaseURL != "" { + _, err = dbPool.Exec(ctx, ` + INSERT INTO system_config (key, value) + VALUES ('base_url', $1) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value + WHERE system_config.value = '' + `, cfg.BaseURL) + if err != nil { + log.Printf("⚠️ Could not seed base_url: %v", err) + } else { + // Also seed derived URLs + for key, suffix := range map[string]string{ + "opds_base_url": "/opds", + "api_base_url": "/api", + } { + _, _ = dbPool.Exec(ctx, ` + INSERT INTO system_config (key, value) + VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value + WHERE system_config.value = '' + `, key, cfg.BaseURL+suffix) + } + } + } + // Create login attempt tracker: 5 failed attempts = 15 minute lockout loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute) @@ -63,9 +94,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 +114,7 @@ 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) @@ -95,6 +131,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) @@ -162,6 +199,7 @@ func main() { ConnManager: connManager, QueueProcessor: queueProcessor, ProgressService: progressService, + AnnotationService: annotationService, DeviceAuthMiddleware: deviceAuthMiddleware, JobsHandler: jobsHandler, LoginTracker: loginAttemptTracker, diff --git a/cmd/server/tests/calibre_integration_test.go b/cmd/server/tests/calibre_integration_test.go index 9c06ccd..7107948 100644 --- a/cmd/server/tests/calibre_integration_test.go +++ b/cmd/server/tests/calibre_integration_test.go @@ -90,7 +90,7 @@ func TestCalibreLibraryScan(t *testing.T) { // Create scanner and configure it scanner := services.NewMediaScanner(setup.DB) scanner.SetAdminID(adminID) - err = scanner.SetFolders([]string{tmpDir}) + err = scanner.SetFolders([]string{tmpDir}, false) require.NoError(t, err, "Failed to set scanner folders") // Scan library @@ -167,7 +167,7 @@ func TestCalibreLibraryScanWithoutSidecar(t *testing.T) { // Create scanner and configure it scanner := services.NewMediaScanner(setup.DB) scanner.SetAdminID(adminID) - err = scanner.SetFolders([]string{tmpDir}) + err = scanner.SetFolders([]string{tmpDir}, false) require.NoError(t, err, "Failed to set scanner folders") // Scan library diff --git a/cmd/server/tests/test_helpers_test.go b/cmd/server/tests/test_helpers_test.go index a79906d..d7a86ab 100644 --- a/cmd/server/tests/test_helpers_test.go +++ b/cmd/server/tests/test_helpers_test.go @@ -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, } diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 25eee9f..0629cf6 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -49,8 +49,7 @@ CREATE TABLE IF NOT EXISTS system_settings ( INSERT INTO system_settings (setting_key, setting_value, description) VALUES ('scan_poll_interval_seconds', '60', 'How often to scan all libraries in minutes'), ('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide'), -('default_timezone', 'UTC', 'System default timezone'), -('setup_complete', 'false', 'Whether the initial setup wizard has been completed') +('default_timezone', 'UTC', 'System default timezone') ON CONFLICT (setting_key) DO NOTHING; -- Create refresh_tokens table @@ -1150,12 +1149,14 @@ CREATE TABLE IF NOT EXISTS system_config ( updated_by UUID REFERENCES users(id) ); --- Pre-seeded values -INSERT INTO system_config (key, value) VALUES -('base_url', 'https://bookhoard.example.com'), -('opds_base_url', 'https://bookhoard.example.com/opds'), -('api_base_url', 'https://bookhoard.example.com/api') -ON CONFLICT (key) DO NOTHING; +-- One-time cleanup: clear the old placeholder seed so the startup logic +-- can re-seed from the BASE_URL env var (or the setup wizard can set it). +UPDATE system_config SET value = '' +WHERE key = 'base_url' AND value = 'https://bookhoard.example.com'; +UPDATE system_config SET value = '' +WHERE key = 'opds_base_url' AND value = 'https://bookhoard.example.com/opds'; +UPDATE system_config SET value = '' +WHERE key = 'api_base_url' AND value = 'https://bookhoard.example.com/api'; -- Create opds_tokens table (device-specific OPDS access tokens) CREATE TABLE IF NOT EXISTS opds_tokens ( @@ -1313,3 +1314,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; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..9d69c8a --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,57 @@ +# Development override — merged on top of docker-compose.yml (the base/prod file). +# Activated by all `make` targets via: +# COMPOSE = 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 diff --git a/docker-compose.yml b/docker-compose.yml index befdfb3..987f90f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: "3.8" - services: # PostgreSQL Database db: @@ -9,14 +7,15 @@ services: POSTGRES_DB: bookhoard POSTGRES_USER: postgres POSTGRES_PASSWORD: ${DBPASS} - COOKIE_SECURE: false # make true in production with HTTPS + # PGPORT makes Postgres listen on DB_PORT (kept in sync with the host mapping + app's DATABASE_PORT) + PGPORT: ${DB_PORT:-5432} volumes: - postgres_data:/var/lib/postgresql/data - ./database/schema:/docker-entrypoint-initdb.d # Make other volumes as needed - ./uploads:/app/uploads ports: - - "5432:5432" + - "${DB_PORT:-5432}:${DB_PORT:-5432}" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres"] interval: 30s @@ -27,27 +26,30 @@ services: - .env # Bookhoard Application + # In production this image is pulled from the Gitea container registry. + # Override IMAGE_TAG in .env to pin or rollback a specific version (defaults to "latest"). app: - build: - context: . - dockerfile: ./Dockerfile + image: git.linuxhg.com/bookhoard/bookhoard:${IMAGE_TAG:-latest} container_name: bookhoard + restart: unless-stopped environment: # Database Configuration DATABASE_HOST: db - DATABASE_PORT: 5432 + DATABASE_PORT: ${DB_PORT:-5432} DATABASE_USER: postgres DATABASE_PASSWORD: ${DBPASS} DATABASE_NAME: bookhoard # Application Configuration JWT_SECRET: ${JWT_SECRET} - SERVER_PORT: 8765 + SERVER_PORT: ${SERVER_PORT:-8765} # IMPORTANT: Device sync requires full URL with protocol # Local: http://localhost:8765 # Local network: http://192.168.1.X:8765 # Domain: https://bookhoard.example.com - BASE_URL: http://localhost:${SERVER_PORT} + BASE_URL: ${BASE_URL:-http://localhost:8765} + # Mark session cookies Secure; set true behind a TLS-terminating reverse proxy (Caddy/nginx/traefik) + COOKIE_SECURE: ${COOKIE_SECURE:-false} # Rate Limiting Configuration TEST_MODE: ${TEST_MODE:-false} @@ -62,7 +64,7 @@ services: # System timezone (fallback for server-side time operations) TZ: ${TZ:-UTC} ports: - - "8765:8765" + - "${SERVER_PORT:-8765}:${SERVER_PORT:-8765}" depends_on: db: condition: service_healthy @@ -70,55 +72,12 @@ services: - ./uploads:/app/uploads - bookhoard_conversion_cache:/app/cache/kepub healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:8765/health || exit 1"] + test: ["CMD-SHELL", "curl -f http://localhost:${SERVER_PORT:-8765}/health || exit 1"] interval: 30s timeout: 5s retries: 3 start_period: 10s - # Integration Tests - runs against containerized app and db - tests: - build: - context: . - dockerfile: ./Dockerfile - target: test-runner - container_name: bookhoard_tests - environment: - # Database Configuration - DATABASE_HOST: db - DATABASE_PORT: 5432 - DATABASE_USER: postgres - DATABASE_PASSWORD: ${DBPASS} - DATABASE_NAME: bookhoard - COOKIE_SECURE: false - - # Application Configuration - JWT_SECRET: ${JWT_SECRET} - SERVER_PORT: 8765 - - # Test Configuration - TEST_MODE: "true" - RATE_LIMIT_ENABLED: "false" - REQUESTS_PER_MINUTE: 1000 - - # Conversion Service Configuration - BOOKHOARD_CONVERSION_CACHE_DIR: /app/cache/kepub - BOOKHOARD_CONVERSION_TOOL: /usr/bin/kepubify - BOOKHOARD_CONVERSION_CACHE_TTL: 24h - - # Test upload path (inside container) - TEST_UPLOAD_PATH: /app/uploads - depends_on: - db: - condition: service_healthy - app: - condition: service_healthy - volumes: - - ./uploads:/app/uploads - - bookhoard_conversion_cache:/app/cache/kepub - profiles: - - tests - # Named Volumes volumes: postgres_data: diff --git a/docs/developer/api-reference.md b/docs/developer/api-reference.md index 046cd43..916bf11 100644 --- a/docs/developer/api-reference.md +++ b/docs/developer/api-reference.md @@ -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 + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/"> urn:uuid:device-id Bookhoard Library 2026-02-01T12:00:00Z - - - + + + + + + + + + 1814 + 50 + 51 urn:uuid:bookhoard-uuid-123 - The Hobbit - J.R.R. Tolkien + The Hobbit + J.R.R. Tolkien 2026-02-01T10:00:00Z + + Bookhoard + Search the Bookhoard library + UTF-8 + UTF-8 + + +``` + +When called **with** a `q` parameter, **Response** (200 - OPDS 1.2 XML with +search results, including `opensearch:totalResults`). ### List Available Formats diff --git a/go.mod b/go.mod index d3a18fb..d4701b1 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/yuin/goldmark v1.8.2 github.com/yuin/goldmark-highlighting v0.0.0-20220208100518-594be1970594 golang.org/x/crypto v0.50.0 + golang.org/x/net v0.53.0 golang.org/x/text v0.36.0 ) @@ -57,7 +58,6 @@ require ( github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/xyproto/randomstring v1.2.0 // indirect golang.org/x/image v0.39.0 // indirect - golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/internal/config/config.go b/internal/config/config.go index a68adf9..137334f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,30 +45,19 @@ func (c *Config) DatabaseURL() string { c.DatabaseUser, c.DatabasePassword, c.DatabaseHost, c.DatabasePort, c.DatabaseName) } -// GetBaseURL returns the base URL from system configuration database with fallback to config/env var -func GetBaseURL(ctx context.Context, db interface{}) string { - // Try to get from database first - type SystemConfigQuerier interface { - GetSystemConfig(ctx context.Context, key string) (SystemConfigRow, error) - } +// SystemConfigGetter returns the value for a system config key, or an error. +type SystemConfigGetter func(ctx context.Context, key string) (string, error) - if querier, ok := db.(SystemConfigQuerier); ok { - config, err := querier.GetSystemConfig(ctx, "base_url") - if err == nil && config.Value != "" { - return config.Value - } +// GetBaseURL returns the base URL from system configuration database, or empty +// string if not set. The getter abstraction avoids importing the database package. +func GetBaseURL(ctx context.Context, getter SystemConfigGetter) string { + val, err := getter(ctx, "base_url") + if err == nil && val != "" { + return val } - - // Fallback: return empty string - caller should use their own fallback return "" } -// SystemConfigRow represents a system configuration row -type SystemConfigRow struct { - Key string - Value string -} - func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value diff --git a/internal/database/db.go b/internal/database/db.go index 486aa36..bdf4241 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package database diff --git a/internal/database/models.go b/internal/database/models.go index a08dece..fa7d6a0 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package database @@ -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 { diff --git a/internal/database/querier.go b/internal/database/querier.go index 1d7b71d..5d8fbc2 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 package database @@ -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,7 @@ 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 @@ -303,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 @@ -333,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) @@ -356,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 @@ -374,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) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 03b44ea..91961c5 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.30.0 // source: queries.sql package database @@ -226,6 +226,17 @@ func (q *Queries) ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfBy return err } +const CountAdmins = `-- name: CountAdmins :one +SELECT COUNT(*) FROM users WHERE role = 'admin' +` + +func (q *Queries) CountAdmins(ctx context.Context) (int64, error) { + row := q.db.QueryRow(ctx, CountAdmins) + var count int64 + err := row.Scan(&count) + return count, err +} + const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one SELECT COUNT(*) as count FROM unlinked_books @@ -251,6 +262,44 @@ func (q *Queries) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int return count, err } +const CreateAutoResolvedSyncConflict = `-- 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 id, media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_by, resolved_at, created_at +` + +type CreateAutoResolvedSyncConflictParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + ConflictType string `db:"conflict_type" json:"conflict_type"` + ConflictData []byte `db:"conflict_data" json:"conflict_data"` + ResolutionData []byte `db:"resolution_data" json:"resolution_data"` +} + +func (q *Queries) CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error) { + row := q.db.QueryRow(ctx, CreateAutoResolvedSyncConflict, + arg.MediaItemID, + arg.UserID, + arg.ConflictType, + arg.ConflictData, + arg.ResolutionData, + ) + var i SyncConflicts + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.ConflictType, + &i.ConflictData, + &i.ResolutionStatus, + &i.ResolutionData, + &i.ResolvedBy, + &i.ResolvedAt, + &i.CreatedAt, + ) + return i, err +} + const CreateCollection = `-- name: CreateCollection :one INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings) @@ -555,7 +604,7 @@ func (q *Queries) CreateLibrary(ctx context.Context, arg CreateLibraryParams) (L const CreateMediaBookmark = `-- 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) -RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at +RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at ` type CreateMediaBookmarkParams struct { @@ -592,6 +641,88 @@ func (q *Queries) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookma &i.Position, &i.Notes, &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const CreateMediaBookmarkFull = `-- 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 id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at +` + +type CreateMediaBookmarkFullParams struct { + 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"` + 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"` + 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"` +} + +func (q *Queries) CreateMediaBookmarkFull(ctx context.Context, arg CreateMediaBookmarkFullParams) (MediaBookmarks, error) { + row := q.db.QueryRow(ctx, CreateMediaBookmarkFull, + arg.MediaItemID, + arg.UserID, + arg.PageNumber, + arg.ChapterNumber, + arg.CfiPosition, + arg.Title, + arg.Position, + arg.Notes, + arg.PercentageLocation, + arg.EpubcfiLocation, + arg.ChapterReference, + arg.DedupKey, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaBookmarks + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.PageNumber, + &i.ChapterNumber, + &i.CfiPosition, + &i.Title, + &i.Position, + &i.Notes, + &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -599,7 +730,7 @@ func (q *Queries) CreateMediaBookmark(ctx context.Context, arg CreateMediaBookma const CreateMediaHighlight = `-- name: CreateMediaHighlight :one INSERT INTO media_highlights (media_item_id, user_id, selection_text, start_position, end_position, color, note_id) VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data +RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at ` type CreateMediaHighlightParams struct { @@ -646,6 +777,97 @@ func (q *Queries) CreateMediaHighlight(ctx context.Context, arg CreateMediaHighl &i.ParagraphEnd, &i.PanelNumber, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const CreateMediaHighlightFull = `-- 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 id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at +` + +type CreateMediaHighlightFullParams struct { + 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"` + NoteText pgtype.Text `db:"note_text" json:"note_text"` + PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"` + PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_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"` + 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"` +} + +func (q *Queries) CreateMediaHighlightFull(ctx context.Context, arg CreateMediaHighlightFullParams) (MediaHighlights, error) { + row := q.db.QueryRow(ctx, CreateMediaHighlightFull, + arg.MediaItemID, + arg.UserID, + arg.SelectionText, + arg.StartPosition, + arg.EndPosition, + arg.Color, + arg.NoteText, + arg.PercentageStart, + arg.PercentageEnd, + arg.EpubcfiStart, + arg.EpubcfiEnd, + arg.ChapterReference, + arg.DedupKey, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaHighlights + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.SelectionText, + &i.StartPosition, + &i.EndPosition, + &i.Color, + &i.NoteID, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageStart, + &i.PercentageEnd, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiStart, + &i.EpubcfiEnd, + &i.ChapterReference, + &i.ParagraphStart, + &i.ParagraphEnd, + &i.PanelNumber, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -865,7 +1087,7 @@ func (q *Queries) CreateMediaItemFormat(ctx context.Context, arg CreateMediaItem const CreateMediaNote = `-- name: CreateMediaNote :one INSERT INTO media_notes (media_item_id, user_id, content, position) VALUES ($1, $2, $3, $4) -RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data +RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at ` type CreateMediaNoteParams struct { @@ -899,6 +1121,82 @@ func (q *Queries) CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams &i.ChapterReference, &i.ParagraphReference, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const CreateMediaNoteFull = `-- 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 id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at +` + +type CreateMediaNoteFullParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Content string `db:"content" json:"content"` + Position pgtype.Text `db:"position" json:"position"` + PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"` + CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"` + CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"` + EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"` + 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"` +} + +func (q *Queries) CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error) { + row := q.db.QueryRow(ctx, CreateMediaNoteFull, + arg.MediaItemID, + arg.UserID, + arg.Content, + arg.Position, + arg.PercentageLocation, + arg.CharacterStart, + arg.CharacterEnd, + arg.EpubcfiLocation, + arg.ChapterReference, + arg.ParagraphReference, + arg.DedupKey, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaNotes + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.Content, + &i.Position, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageLocation, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.ParagraphReference, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -1792,6 +2090,114 @@ func (q *Queries) GenerateKoboEntitlementId(ctx context.Context) (interface{}, e return entitlement_id, err } +const GetActiveAnnotationsForBook = `-- 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 +` + +type GetActiveAnnotationsForBookParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` +} + +type GetActiveAnnotationsForBookRow struct { + ID pgtype.UUID `db:"id" json:"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"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + AnnotationType string `db:"annotation_type" json:"annotation_type"` + PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"` + PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_end"` + EpubcfiStart pgtype.Text `db:"epubcfi_start" json:"epubcfi_start"` + EpubcfiEnd pgtype.Text `db:"epubcfi_end" json:"epubcfi_end"` + NoteText pgtype.Text `db:"note_text" json:"note_text"` + 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"` +} + +// ============================================ +// ANNOTATION SERVE QUERIES +// ============================================ +func (q *Queries) GetActiveAnnotationsForBook(ctx context.Context, arg GetActiveAnnotationsForBookParams) ([]GetActiveAnnotationsForBookRow, error) { + rows, err := q.db.Query(ctx, GetActiveAnnotationsForBook, arg.MediaItemID, arg.UserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetActiveAnnotationsForBookRow{} + for rows.Next() { + var i GetActiveAnnotationsForBookRow + if err := rows.Scan( + &i.ID, + &i.SelectionText, + &i.StartPosition, + &i.EndPosition, + &i.Color, + &i.CreatedAt, + &i.UpdatedAt, + &i.AnnotationType, + &i.PercentageStart, + &i.PercentageEnd, + &i.EpubcfiStart, + &i.EpubcfiEnd, + &i.NoteText, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetAllSystemConfig = `-- name: GetAllSystemConfig :many SELECT key, value, updated_at, updated_by FROM system_config ORDER BY key ` @@ -1867,7 +2273,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, @@ -1883,7 +2289,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 ` @@ -4001,9 +4407,84 @@ func (q *Queries) GetLibraryWithType(ctx context.Context, id pgtype.UUID) (GetLi return i, err } +const GetMediaBookmark = `-- name: GetMediaBookmark :one +SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks WHERE id = $1 +` + +func (q *Queries) GetMediaBookmark(ctx context.Context, id pgtype.UUID) (MediaBookmarks, error) { + row := q.db.QueryRow(ctx, GetMediaBookmark, id) + var i MediaBookmarks + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.PageNumber, + &i.ChapterNumber, + &i.CfiPosition, + &i.Title, + &i.Position, + &i.Notes, + &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const GetMediaBookmarkByDedupKey = `-- name: GetMediaBookmarkByDedupKey :one + +SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at 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 +` + +type GetMediaBookmarkByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +// ============================================ +// ANNOTATION SYNC QUERIES (bookmarks) +// ============================================ +func (q *Queries) GetMediaBookmarkByDedupKey(ctx context.Context, arg GetMediaBookmarkByDedupKeyParams) (MediaBookmarks, error) { + row := q.db.QueryRow(ctx, GetMediaBookmarkByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + var i MediaBookmarks + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.PageNumber, + &i.ChapterNumber, + &i.CfiPosition, + &i.Title, + &i.Position, + &i.Notes, + &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + const GetMediaBookmarks = `-- name: GetMediaBookmarks :many -SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at FROM media_bookmarks -WHERE media_item_id = $1 AND user_id = $2 +SELECT id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at FROM media_bookmarks +WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC ` @@ -4032,6 +4513,15 @@ func (q *Queries) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksPa &i.Position, &i.Notes, &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, ); err != nil { return nil, err } @@ -4044,7 +4534,7 @@ func (q *Queries) GetMediaBookmarks(ctx context.Context, arg GetMediaBookmarksPa } const GetMediaHighlight = `-- name: GetMediaHighlight :one -SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data FROM media_highlights WHERE id = $1 +SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at FROM media_highlights WHERE id = $1 ` func (q *Queries) GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaHighlights, error) { @@ -4072,12 +4562,70 @@ func (q *Queries) GetMediaHighlight(ctx context.Context, id pgtype.UUID) (MediaH &i.ParagraphEnd, &i.PanelNumber, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const GetMediaHighlightByDedupKey = `-- name: GetMediaHighlightByDedupKey :one + +SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at 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 +` + +type GetMediaHighlightByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +// ============================================ +// ANNOTATION SYNC QUERIES (highlights) +// ============================================ +func (q *Queries) GetMediaHighlightByDedupKey(ctx context.Context, arg GetMediaHighlightByDedupKeyParams) (MediaHighlights, error) { + row := q.db.QueryRow(ctx, GetMediaHighlightByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + var i MediaHighlights + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.SelectionText, + &i.StartPosition, + &i.EndPosition, + &i.Color, + &i.NoteID, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageStart, + &i.PercentageEnd, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiStart, + &i.EpubcfiEnd, + &i.ChapterReference, + &i.ParagraphStart, + &i.ParagraphEnd, + &i.PanelNumber, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, ) return i, err } const GetMediaHighlights = `-- name: GetMediaHighlights :many -SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC +SELECT id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC ` type GetMediaHighlightsParams struct { @@ -4116,6 +4664,12 @@ func (q *Queries) GetMediaHighlights(ctx context.Context, arg GetMediaHighlights &i.ParagraphEnd, &i.PanelNumber, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, ); err != nil { return nil, err } @@ -4808,7 +5362,7 @@ func (q *Queries) GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UU } const GetMediaNote = `-- name: GetMediaNote :one -SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data FROM media_notes WHERE id = $1 +SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes WHERE id = $1 ` func (q *Queries) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error) { @@ -4829,12 +5383,61 @@ func (q *Queries) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, &i.ChapterReference, &i.ParagraphReference, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const GetMediaNoteByDedupKey = `-- name: GetMediaNoteByDedupKey :one + +SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at 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 +` + +type GetMediaNoteByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +// ============================================ +// ANNOTATION SYNC QUERIES (notes) +// ============================================ +func (q *Queries) GetMediaNoteByDedupKey(ctx context.Context, arg GetMediaNoteByDedupKeyParams) (MediaNotes, error) { + row := q.db.QueryRow(ctx, GetMediaNoteByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + var i MediaNotes + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.Content, + &i.Position, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageLocation, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.ParagraphReference, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, ) return i, err } const GetMediaNotes = `-- name: GetMediaNotes :many -SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data FROM media_notes WHERE media_item_id = $1 AND user_id = $2 ORDER BY created_at DESC +SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC ` type GetMediaNotesParams struct { @@ -4866,6 +5469,11 @@ func (q *Queries) GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([ &i.ChapterReference, &i.ParagraphReference, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, ); err != nil { return nil, err } @@ -5990,6 +6598,76 @@ func (q *Queries) GetSystemTimezone(ctx context.Context) (string, error) { return setting_value, err } +const GetTombstonedAnnotationsForBook = `-- 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 +` + +type GetTombstonedAnnotationsForBookParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` +} + +type GetTombstonedAnnotationsForBookRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` + AnnotationType string `db:"annotation_type" json:"annotation_type"` + DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` + DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"` +} + +func (q *Queries) GetTombstonedAnnotationsForBook(ctx context.Context, arg GetTombstonedAnnotationsForBookParams) ([]GetTombstonedAnnotationsForBookRow, error) { + rows, err := q.db.Query(ctx, GetTombstonedAnnotationsForBook, arg.MediaItemID, arg.UserID, arg.DeletedAt) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetTombstonedAnnotationsForBookRow{} + for rows.Next() { + var i GetTombstonedAnnotationsForBookRow + if err := rows.Scan( + &i.ID, + &i.DedupKey, + &i.AnnotationType, + &i.DeviceSyncData, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetUniversalProgress = `-- name: GetUniversalProgress :one SELECT rp.id, @@ -6815,6 +7493,40 @@ func (q *Queries) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUI return items, nil } +const GetVisibleLibraryMediaCounts = `-- 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 +` + +type GetVisibleLibraryMediaCountsRow struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaCount int64 `db:"media_count" json:"media_count"` +} + +func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) { + rows, err := q.db.Query(ctx, GetVisibleLibraryMediaCounts, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetVisibleLibraryMediaCountsRow{} + for rows.Next() { + var i GetVisibleLibraryMediaCountsRow + if err := rows.Scan(&i.ID, &i.MediaCount); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one SELECT EXISTS( SELECT 1 FROM sync_conflicts @@ -8185,6 +8897,33 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { return items, nil } +const PurgeExpiredBookmarkTombstones = `-- name: PurgeExpiredBookmarkTombstones :exec +DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1 +` + +func (q *Queries) PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, PurgeExpiredBookmarkTombstones, deletedAt) + return err +} + +const PurgeExpiredHighlightTombstones = `-- name: PurgeExpiredHighlightTombstones :exec +DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1 +` + +func (q *Queries) PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, PurgeExpiredHighlightTombstones, deletedAt) + return err +} + +const PurgeExpiredNoteTombstones = `-- name: PurgeExpiredNoteTombstones :exec +DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1 +` + +func (q *Queries) PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error { + _, err := q.db.Exec(ctx, PurgeExpiredNoteTombstones, deletedAt) + return err +} + const QueryMediaItemsByIdentifiers = `-- name: QueryMediaItemsByIdentifiers :many SELECT mi.id, @@ -9410,6 +10149,102 @@ func (q *Queries) SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibrary return err } +const TombstoneMediaBookmarkByDedupKey = `-- 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 +` + +type TombstoneMediaBookmarkByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +func (q *Queries) TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error { + _, err := q.db.Exec(ctx, TombstoneMediaBookmarkByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + return err +} + +const TombstoneMediaBookmarkByID = `-- name: TombstoneMediaBookmarkByID :exec +UPDATE media_bookmarks SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE id = $1 +` + +func (q *Queries) TombstoneMediaBookmarkByID(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, TombstoneMediaBookmarkByID, id) + return err +} + +const TombstoneMediaHighlightByDedupKey = `-- 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 +` + +type TombstoneMediaHighlightByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +func (q *Queries) TombstoneMediaHighlightByDedupKey(ctx context.Context, arg TombstoneMediaHighlightByDedupKeyParams) error { + _, err := q.db.Exec(ctx, TombstoneMediaHighlightByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + return err +} + +const TombstoneMediaHighlightByID = `-- name: TombstoneMediaHighlightByID :exec +UPDATE media_highlights SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE id = $1 +` + +func (q *Queries) TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, TombstoneMediaHighlightByID, id) + return err +} + +const TombstoneMediaNoteByDedupKey = `-- 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 +` + +type TombstoneMediaNoteByDedupKeyParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DedupKey pgtype.Text `db:"dedup_key" json:"dedup_key"` +} + +func (q *Queries) TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error { + _, err := q.db.Exec(ctx, TombstoneMediaNoteByDedupKey, arg.UserID, arg.MediaItemID, arg.DedupKey) + return err +} + +const TombstoneMediaNoteByID = `-- name: TombstoneMediaNoteByID :exec +UPDATE media_notes SET + deleted = TRUE, + deleted_at = NOW(), + last_modified_at = NOW() +WHERE id = $1 +` + +func (q *Queries) TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, TombstoneMediaNoteByID, id) + return err +} + const UpdateCollection = `-- name: UpdateCollection :one UPDATE collections SET @@ -9931,7 +10766,7 @@ SET position = $4, updated_at = NOW() WHERE id = $1 AND user_id = $5 -RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at +RETURNING id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at ` type UpdateMediaBookmarkParams struct { @@ -9962,6 +10797,91 @@ func (q *Queries) UpdateMediaBookmark(ctx context.Context, arg UpdateMediaBookma &i.Position, &i.Notes, &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const UpdateMediaBookmarkForSync = `-- 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 id, media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes, created_at, dedup_key, last_modified_at, last_modified_source, device_sync_data, percentage_location, epubcfi_location, chapter_reference, deleted, deleted_at +` + +type UpdateMediaBookmarkForSyncParams struct { + ID pgtype.UUID `db:"id" json:"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"` + 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"` + 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"` +} + +func (q *Queries) UpdateMediaBookmarkForSync(ctx context.Context, arg UpdateMediaBookmarkForSyncParams) (MediaBookmarks, error) { + row := q.db.QueryRow(ctx, UpdateMediaBookmarkForSync, + arg.ID, + arg.PageNumber, + arg.ChapterNumber, + arg.CfiPosition, + arg.Title, + arg.Position, + arg.Notes, + arg.PercentageLocation, + arg.EpubcfiLocation, + arg.ChapterReference, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaBookmarks + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.PageNumber, + &i.ChapterNumber, + &i.CfiPosition, + &i.Title, + &i.Position, + &i.Notes, + &i.CreatedAt, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.DeviceSyncData, + &i.PercentageLocation, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -9975,7 +10895,7 @@ UPDATE media_highlights SET note_id = $6, updated_at = NOW() WHERE id = $1 -RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data +RETURNING id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at ` type UpdateMediaHighlightParams struct { @@ -10019,6 +10939,99 @@ func (q *Queries) UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighl &i.ParagraphEnd, &i.PanelNumber, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const UpdateMediaHighlightForSync = `-- 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 id, media_item_id, user_id, selection_text, start_position, end_position, color, note_id, created_at, updated_at, percentage_start, percentage_end, character_start, character_end, epubcfi_start, epubcfi_end, chapter_reference, paragraph_start, paragraph_end, panel_number, device_sync_data, dedup_key, last_modified_at, last_modified_source, note_text, deleted, deleted_at +` + +type UpdateMediaHighlightForSyncParams struct { + ID pgtype.UUID `db:"id" json:"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"` + NoteText pgtype.Text `db:"note_text" json:"note_text"` + PercentageStart pgtype.Float8 `db:"percentage_start" json:"percentage_start"` + PercentageEnd pgtype.Float8 `db:"percentage_end" json:"percentage_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"` + 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"` +} + +func (q *Queries) UpdateMediaHighlightForSync(ctx context.Context, arg UpdateMediaHighlightForSyncParams) (MediaHighlights, error) { + row := q.db.QueryRow(ctx, UpdateMediaHighlightForSync, + arg.ID, + arg.SelectionText, + arg.StartPosition, + arg.EndPosition, + arg.Color, + arg.NoteText, + arg.PercentageStart, + arg.PercentageEnd, + arg.EpubcfiStart, + arg.EpubcfiEnd, + arg.ChapterReference, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaHighlights + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.SelectionText, + &i.StartPosition, + &i.EndPosition, + &i.Color, + &i.NoteID, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageStart, + &i.PercentageEnd, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiStart, + &i.EpubcfiEnd, + &i.ChapterReference, + &i.ParagraphStart, + &i.ParagraphEnd, + &i.PanelNumber, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.NoteText, + &i.Deleted, + &i.DeletedAt, ) return i, err } @@ -10580,7 +11593,7 @@ UPDATE media_notes SET position = $3, updated_at = NOW() WHERE id = $1 -RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data +RETURNING id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at ` type UpdateMediaNoteParams struct { @@ -10607,6 +11620,84 @@ func (q *Queries) UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams &i.ChapterReference, &i.ParagraphReference, &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, + ) + return i, err +} + +const UpdateMediaNoteForSync = `-- 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 id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data, dedup_key, last_modified_at, last_modified_source, deleted, deleted_at +` + +type UpdateMediaNoteForSyncParams struct { + ID pgtype.UUID `db:"id" json:"id"` + Content string `db:"content" json:"content"` + Position pgtype.Text `db:"position" json:"position"` + PercentageLocation pgtype.Float8 `db:"percentage_location" json:"percentage_location"` + CharacterStart pgtype.Int4 `db:"character_start" json:"character_start"` + CharacterEnd pgtype.Int4 `db:"character_end" json:"character_end"` + EpubcfiLocation pgtype.Text `db:"epubcfi_location" json:"epubcfi_location"` + ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"` + ParagraphReference pgtype.Int4 `db:"paragraph_reference" json:"paragraph_reference"` + 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"` +} + +func (q *Queries) UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error) { + row := q.db.QueryRow(ctx, UpdateMediaNoteForSync, + arg.ID, + arg.Content, + arg.Position, + arg.PercentageLocation, + arg.CharacterStart, + arg.CharacterEnd, + arg.EpubcfiLocation, + arg.ChapterReference, + arg.ParagraphReference, + arg.LastModifiedAt, + arg.LastModifiedSource, + arg.DeviceSyncData, + ) + var i MediaNotes + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.UserID, + &i.Content, + &i.Position, + &i.CreatedAt, + &i.UpdatedAt, + &i.PercentageLocation, + &i.CharacterStart, + &i.CharacterEnd, + &i.EpubcfiLocation, + &i.ChapterReference, + &i.ParagraphReference, + &i.DeviceSyncData, + &i.DedupKey, + &i.LastModifiedAt, + &i.LastModifiedSource, + &i.Deleted, + &i.DeletedAt, ) return i, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index a9fd9ea..eb1220a 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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) @@ -1123,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; @@ -1222,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, @@ -1238,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 @@ -2109,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) diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go index e847377..8710290 100644 --- a/internal/handlers/auth.go +++ b/internal/handlers/auth.go @@ -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, `
Failed to create default collections
`) @@ -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 { diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index ae548ef..a0fb939 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -73,6 +73,7 @@ type BookInfo struct { Title string `json:"title"` Author string `json:"author"` CoverImagePath string `json:"cover_image_path"` + HasConflict bool `json:"has_conflict"` } type SectionData struct { diff --git a/internal/handlers/conflicts.go b/internal/handlers/conflicts.go index 2d6095f..6b6023d 100644 --- a/internal/handlers/conflicts.go +++ b/internal/handlers/conflicts.go @@ -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") } @@ -258,6 +258,12 @@ func (h *ConflictHandler) ResolveConflict(c *echo.Context) error { } } + 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, @@ -356,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 { diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index e6fcc47..c163d33 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -4,6 +4,7 @@ import ( "bookhoard/internal/database" "bookhoard/internal/services" "bookhoard/internal/utils" + "context" "log" "net/http" "strconv" @@ -64,6 +65,7 @@ func (h *DashboardHandler) GetSections(c *echo.Context) error { } sectionData := BuildSections(sections, libraryID) + sectionData = MarkActiveConflictsSections(c.Request().Context(), h.db, user.ID, sectionData) return c.JSON(http.StatusOK, map[string]interface{}{"sections": sectionData}) } @@ -188,6 +190,59 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID string return result } +// activeConflictSet returns the set of media item IDs (as strings) that have an +// active (unresolved) progress sync conflict for the given user. A single query +// is issued; resolved conflicts are filtered out in memory. +func activeConflictSet(ctx context.Context, db *database.Queries, userID pgtype.UUID) map[string]bool { + conflicts, err := db.ListSyncConflictsByUser(ctx, userID) + if err != nil { + return nil + } + set := make(map[string]bool, len(conflicts)) + for _, c := range conflicts { + if c.ResolutionStatus.String == "unresolved" { + set[uuid.UUID(c.MediaItemID.Bytes).String()] = true + } + } + return set +} + +// MarkActiveConflicts stamps HasConflict on each book whose media item has an +// active progress sync conflict for the user. It performs a single query +// regardless of how many books are passed. +func MarkActiveConflicts(ctx context.Context, db *database.Queries, userID pgtype.UUID, books []BookInfo) []BookInfo { + if len(books) == 0 { + return books + } + set := activeConflictSet(ctx, db, userID) + for i := range books { + if set[books[i].MediaItemID] { + books[i].HasConflict = true + } + } + return books +} + +// MarkActiveConflictsSections is the section-aware variant of MarkActiveConflicts, +// used by the dashboard which renders books grouped into sections. +func MarkActiveConflictsSections(ctx context.Context, db *database.Queries, userID pgtype.UUID, sections []SectionData) []SectionData { + if len(sections) == 0 { + return sections + } + set := activeConflictSet(ctx, db, userID) + if len(set) == 0 { + return sections + } + for s := range sections { + for i := range sections[s].Items { + if set[sections[s].Items[i].MediaItemID] { + sections[s].Items[i].HasConflict = true + } + } + } + return sections +} + func getViewAllURL(collectionID string, libraryID string) string { if collectionID != "" { if libraryID != "" { diff --git a/internal/handlers/kobo.go b/internal/handlers/kobo.go index 3f44442..d36efd4 100644 --- a/internal/handlers/kobo.go +++ b/internal/handlers/kobo.go @@ -3,6 +3,7 @@ package handlers import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" + "encoding/json" "fmt" "log" "net/http" @@ -19,6 +20,7 @@ type KoboHandler struct { db *database.Queries connManager *wsync.ConnectionManager progressSvc *wsync.ProgressService + annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver } @@ -30,6 +32,10 @@ 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 } @@ -245,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 { @@ -311,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, }) @@ -403,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) @@ -412,6 +426,7 @@ 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 @@ -465,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 != "" { @@ -564,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 @@ -606,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++ + } } } } @@ -756,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++ + } } } diff --git a/internal/handlers/koreader.go b/internal/handlers/koreader.go index 895ebea..a28b1d0 100644 --- a/internal/handlers/koreader.go +++ b/internal/handlers/koreader.go @@ -4,6 +4,7 @@ import ( "bookhoard/internal/database" wsync "bookhoard/internal/sync" "context" + "encoding/json" "fmt" "log" "net/http" @@ -19,6 +20,7 @@ type KOReaderHandler struct { connManager *wsync.ConnectionManager queue *wsync.SyncQueueProcessor progressSvc *wsync.ProgressService + annotationSvc *wsync.AnnotationService libraryService LibraryPathResolver } @@ -34,6 +36,27 @@ 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 } @@ -154,9 +177,11 @@ type KOReaderProgressData struct { } 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 { @@ -396,6 +421,7 @@ func (h *KOReaderHandler) handleCheckpointSync(c *echo.Context, device database. 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(), @@ -441,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() @@ -522,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{ @@ -558,6 +684,8 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype }, ) + h.processBookAnnotations(ctx, deviceID, userID, mediaItemID, book) + return nil } @@ -657,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, }) @@ -670,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, @@ -686,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) @@ -737,6 +927,21 @@ func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem databa } } +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 @@ -776,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, }) @@ -876,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++ + } } } @@ -906,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++ + } } } @@ -941,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++ + } } } diff --git a/internal/handlers/media.go b/internal/handlers/media.go index 7f4b007..6618b09 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -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 { @@ -1388,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) @@ -1456,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()}) } @@ -1525,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}, @@ -1613,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()}) } @@ -1735,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) } diff --git a/internal/handlers/opds.go b/internal/handlers/opds.go index a847274..69287d6 100644 --- a/internal/handlers/opds.go +++ b/internal/handlers/opds.go @@ -38,15 +38,15 @@ func NewOPDSHandler(db *database.Queries, libraryService *services.LibraryServic } } -// Helper function to get base URL from system config +// Helper function to get base URL from system config with request-derived fallback func (h *OPDSHandler) getBaseURLs(c *echo.Context) (string, string, error) { - baseURL, err := h.db.GetSystemConfig(c.Request().Context(), "base_url") - if err != nil { - return "", "", fmt.Errorf("failed to get base_url from config: %w", err) + var dbBaseURL string + if config, err := h.db.GetSystemConfig(c.Request().Context(), "base_url"); err == nil { + dbBaseURL = config.Value } - - opdsBaseURL := baseURL.Value + "/opds" - return baseURL.Value, opdsBaseURL, nil + baseURL := deriveBaseURL(c, dbBaseURL) + opdsBaseURL := baseURL + "/opds" + return baseURL, opdsBaseURL, nil } func (h *OPDSHandler) getAuthToken(c *echo.Context) string { @@ -67,6 +67,42 @@ func appendToken(url, token string) string { 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 { @@ -206,14 +242,17 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error { "Bookhoard Library", ) - // Add feed links + // Feed links, including OPDS pagination links (first/previous/next/last) and + // OpenSearch paging metadata (totalResults/itemsPerPage/startIndex). token := h.getAuthToken(c) - catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token) - feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") - feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") + catalogBase := fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID) + addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token) + // 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/atom+xml;profile=opds-catalog;kind=acquisition", "search") + feed.AddLink(searchURL, "application/opensearchdescription+xml", "search") // Add entries for _, item := range allItems { @@ -286,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 { @@ -316,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}, @@ -340,12 +398,14 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error { ) // Add feed links - token := h.getAuthToken(c) catalogURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token) - feed.AddLink(catalogURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "start") + feed.AddLink(catalogURL, catalogMediaType, "start") searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q=%s", opdsBaseURL, deviceID, query), token) - feed.AddLink(searchURL, "application/atom+xml;profile=opds-catalog;kind=acquisition", "self") + feed.AddLink(searchURL, catalogMediaType, "self") + + // OpenSearch paging metadata (search results are a single page) + feed.SetPagination(len(allItems), len(allItems), 1) // Add entries (same as catalog) userUUID := uuid.UUID(userID) diff --git a/internal/handlers/opds_test.go b/internal/handlers/opds_test.go new file mode 100644 index 0000000..4fd3e30 --- /dev/null +++ b/internal/handlers/opds_test.go @@ -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") +} diff --git a/internal/handlers/progress.go b/internal/handlers/progress.go index 0af5ea9..f0de199 100644 --- a/internal/handlers/progress.go +++ b/internal/handlers/progress.go @@ -374,6 +374,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro deviceName = progress.LastSyncDevice.String } + lastUpdated := "" + if progress.LastReadAt.Valid { + lastUpdated = progress.LastReadAt.Time.Format("01-02-2006 03:04 PM") + } + progressList = append(progressList, ProgressWithMedia{ MediaItemID: progress.MediaItemID.Bytes, Title: mediaItem.Title, @@ -386,6 +391,11 @@ func (h *Handler) GetAllProgressData(c *echo.Context) ([]ProgressWithMedia, erro Epubcfi: epubcfi, LastSyncDevice: deviceName, ProgressPercentage: progress.Percentage.Float64 * 100, + EpubCFI: epubcfi, + LastUpdated: lastUpdated, + DeviceIcon: getDeviceIcon(deviceName), + DeviceName: deviceName, + DeviceType: deviceName, FormatGroup: mediaItem.FormatGroup, EstimatedPages: wsync.EstimatedPages(mediaItem.TotalCharacters.Int64), }) diff --git a/internal/handlers/scanner.go b/internal/handlers/scanner.go index 70e6a74..684269b 100644 --- a/internal/handlers/scanner.go +++ b/internal/handlers/scanner.go @@ -128,8 +128,8 @@ func (h *Handler) StartScanner(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid user id"}) } - // Set the folder paths - if err := h.scanner.SetFolders(req.FolderPaths); err != nil { + // Set the folder paths (watch=true: this long-lived scanner reads events) + if err := h.scanner.SetFolders(req.FolderPaths, true); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid folder paths: " + err.Error()}) } @@ -202,7 +202,7 @@ func (h *Handler) StartWatchModeForLibrary(ctx context.Context, libraryID pgtype } scanner := services.NewMediaScanner(h.db) - if err := scanner.SetFolders(folderPaths); err != nil { + if err := scanner.SetFolders(folderPaths, true); err != nil { return fmt.Errorf("failed to set scanner folders: %v", err) } diff --git a/internal/handlers/sidecar.go b/internal/handlers/sidecar.go index 678397e..bf36b06 100644 --- a/internal/handlers/sidecar.go +++ b/internal/handlers/sidecar.go @@ -3,6 +3,7 @@ package handlers import ( "bookhoard/internal/config" "bookhoard/internal/database" + "bookhoard/internal/setupstatus" "encoding/json" "fmt" "net/http" @@ -81,15 +82,13 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error { userID := device.UserID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} - // Get base URL and compute paths - baseURL, _ := h.db.GetSystemConfig(ctx, "base_url") - if baseURL.Value == "" { - baseURL.Value = h.cfg.BaseURL - } + // Get base URL and compute paths (with request-derived fallback) + dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url") + baseURL := deriveBaseURL(c, dbBaseURL.Value) // Generate URLs - opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String()) - syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value) + opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String()) + syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL) // Get user's visible libraries with media items mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) @@ -176,8 +175,8 @@ func (h *SidecarHandler) GetSidecarConfig(c *echo.Context) error { Bookhoard: SidecarBookhoardConfig{ OPDSCatalog: opdsCatalogURL, SyncAPI: syncAPIURL, - OPDSBaseURL: baseURL.Value + "/opds", - APIBaseURL: baseURL.Value + "/api", + OPDSBaseURL: baseURL + "/opds", + APIBaseURL: baseURL + "/api", DeviceID: deviceID.String(), DeviceToken: device.AuthToken, }, @@ -219,15 +218,13 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error { userID := device.UserID.Bytes pgUserID := pgtype.UUID{Bytes: userID, Valid: true} - // Get base URL and compute paths - baseURL, _ := h.db.GetSystemConfig(ctx, "base_url") - if baseURL.Value == "" { - baseURL.Value = h.cfg.BaseURL - } + // Get base URL and compute paths (with request-derived fallback) + dbBaseURL, _ := h.db.GetSystemConfig(ctx, "base_url") + baseURL := deriveBaseURL(c, dbBaseURL.Value) // Generate URLs - opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL.Value, deviceID.String()) - syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL.Value) + opdsCatalogURL := fmt.Sprintf("%s/opds/devices/%s/catalog", baseURL, deviceID.String()) + syncAPIURL := fmt.Sprintf("%s/api/sync/kobo", baseURL) // Get user's visible libraries with media items mediaItems, err := h.db.GetUserMediaItemsForSync(ctx, pgUserID) @@ -309,8 +306,8 @@ func (h *SidecarHandler) DownloadSidecarConfig(c *echo.Context) error { Bookhoard: SidecarBookhoardConfig{ OPDSCatalog: opdsCatalogURL, SyncAPI: syncAPIURL, - OPDSBaseURL: baseURL.Value + "/opds", - APIBaseURL: baseURL.Value + "/api", + OPDSBaseURL: baseURL + "/opds", + APIBaseURL: baseURL + "/api", DeviceID: deviceID.String(), DeviceToken: device.AuthToken, }, @@ -434,6 +431,10 @@ func (h *SidecarHandler) UpdateSystemConfiguration(c *echo.Context) error { }) } } + + // Invalidate setup status cache so the middleware picks up the new + // base_url immediately (setup is not complete until base_url is set). + setupstatus.Invalidate() } // Check for HTMX request diff --git a/internal/handlers/system_settings.go b/internal/handlers/system_settings.go index fd3209f..7e3c1eb 100644 --- a/internal/handlers/system_settings.go +++ b/internal/handlers/system_settings.go @@ -95,32 +95,6 @@ func (h *SystemSettingsHandler) UpdateScanSettings(c *echo.Context) error { }) } -func (h *SystemSettingsHandler) SetSetupComplete(c *echo.Context) error { - err := h.db.UpdateSystemSetting(c.Request().Context(), database.UpdateSystemSettingParams{ - SettingKey: "setup_complete", - SettingValue: "true", - }) - if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - return c.JSON(http.StatusOK, map[string]string{"message": "setup complete"}) -} - -func (h *SystemSettingsHandler) GetSetupStatus(c *echo.Context) error { - val, err := h.db.GetSystemSetting(c.Request().Context(), "setup_complete") - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - return c.JSON(http.StatusOK, map[string]bool{"setup_complete": false}) - } - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } - complete, err := strconv.ParseBool(val) - if err != nil { - complete = false - } - return c.JSON(http.StatusOK, map[string]bool{"setup_complete": complete}) -} - func (h *SystemSettingsHandler) GetScanSettings(c *echo.Context) error { scanFrequencySetting, err := h.db.GetSystemSetting(c.Request().Context(), "scan_poll_interval_seconds") if err != nil { diff --git a/internal/handlers/url.go b/internal/handlers/url.go new file mode 100644 index 0000000..eaaa21d --- /dev/null +++ b/internal/handlers/url.go @@ -0,0 +1,36 @@ +package handlers + +import ( + "strings" + + "github.com/labstack/echo/v5" +) + +// deriveBaseURL returns the base URL to use for constructing self-referential +// links (OPDS feeds, sidecar config, etc.). It prefers the database-configured +// base_url when available, and falls back to deriving the URL from the incoming +// HTTP request (Host header + scheme), which is always reachable by the client. +// +// Proxy header support: X-Forwarded-Proto and X-Forwarded-Host are respected so +// that deployments behind TLS-terminating reverse proxies advertise the correct +// external URL. +func deriveBaseURL(c *echo.Context, dbBaseURL string) string { + if dbBaseURL != "" { + return strings.TrimRight(dbBaseURL, "/") + } + + scheme := "http" + if c.Request().TLS != nil { + scheme = "https" + } + if proto := c.Request().Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } + + host := c.Request().Host + if forwarded := c.Request().Header.Get("X-Forwarded-Host"); forwarded != "" { + host = forwarded + } + + return scheme + "://" + host +} diff --git a/internal/opds/feed.go b/internal/opds/feed.go index 44cb16d..a893e2d 100644 --- a/internal/opds/feed.go +++ b/internal/opds/feed.go @@ -9,15 +9,19 @@ 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 { @@ -64,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{ @@ -178,6 +194,62 @@ func (f *Feed) GenerateXMLString() (string, error) { return xml.Header + string(output), nil } +// OpenSearchUrl is a single 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( diff --git a/internal/opds/feed_test.go b/internal/opds/feed_test.go index c26185d..991d87e 100644 --- a/internal/opds/feed_test.go +++ b/internal/opds/feed_test.go @@ -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) { `Test Feed`, ``, `urn:uuid:book-id`, - `Test Book`, - `Test Author`, + `Test Book`, + `Test Author`, `book-uuid-123`, @@ -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/"`, + `1814`, + `50`, + `51`, + `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{ + ``, + `Bookhoard`, + `= len(substr) && indexOf(s, substr) >= 0 } diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 1dd0b1e..1ee0774 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -9,7 +9,6 @@ import ( "strconv" "time" - "bookhoard/internal/config" "bookhoard/internal/database" "bookhoard/internal/handlers" "bookhoard/internal/services" @@ -215,6 +214,9 @@ func registerFrontendRoutes(cfg *Config) { bookInfoList = []handlers.BookInfo{} } + seriesUserUUID, _ := uuid.Parse(user.ID) + bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: seriesUserUUID, Valid: true}, bookInfoList) + var buf bytes.Buffer err = templates.BrowseDetail(user, "📚", "Series", seriesName, seriesName, "/series", "All Series", "📚", "This series doesn't have any books yet", bookInfoList, errorMsg).Render(c.Request().Context(), &buf) if err != nil { @@ -267,6 +269,9 @@ func registerFrontendRoutes(cfg *Config) { bookInfoList = []handlers.BookInfo{} } + tagUserUUID, _ := uuid.Parse(user.ID) + bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: tagUserUUID, Valid: true}, bookInfoList) + var buf bytes.Buffer err = templates.BrowseDetail(user, "🏷️", "Tag", tagName, tagName, "/bookshelf", "Bookshelf", "🏷️", "No books found with this tag", bookInfoList, errorMsg).Render(c.Request().Context(), &buf) if err != nil { @@ -354,6 +359,7 @@ func registerFrontendRoutes(cfg *Config) { } var buf bytes.Buffer + bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, pgtype.UUID{Bytes: userUUID, Valid: true}, bookInfoList) err = templates.BookShelf(user, libData, libraryID, errorMsg, savedFilters, bookInfoList, limit, offset, totalCount).Render(c.Request().Context(), &buf) if err != nil { return err @@ -408,7 +414,8 @@ func registerFrontendRoutes(cfg *Config) { // Get only visible sections for the dashboard display visibleSections := cfg.DashboardService.FilterHiddenCollections(allSections, prefs.HiddenCollections) - sectionData := handlers.BuildSections(visibleSections, libraryID) + userPgID := pgtype.UUID{Bytes: userUUID, Valid: true} + sectionData := handlers.MarkActiveConflictsSections(c.Request().Context(), cfg.Queries, userPgID, handlers.BuildSections(visibleSections, libraryID)) allSectionsData := handlers.BuildSections(allSections, libraryID) var buf bytes.Buffer @@ -623,6 +630,7 @@ func registerFrontendRoutes(cfg *Config) { Description: collection.Description.String, Color: collection.Color.String, Icon: collection.Icon.String, + IsSystem: collection.IsSystemCollection.Bool, } var buf bytes.Buffer @@ -725,10 +733,7 @@ func registerFrontendRoutes(cfg *Config) { } // Get base URL from database config with fallback to config/env var - baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries) - if baseURL == "" { - baseURL = cfg.Cfg.BaseURL - } + baseURL := cfg.getBaseURL(c.Request().Context()) var buf bytes.Buffer err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf) @@ -986,10 +991,7 @@ func registerFrontendRoutes(cfg *Config) { } // Fetch current system configuration - just base_url - baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries) - if baseURL == "" { - baseURL = cfg.Cfg.BaseURL - } + baseURL := cfg.getBaseURL(c.Request().Context()) systemConfig := map[string]string{ "base_url": baseURL, @@ -1077,10 +1079,7 @@ func registerFrontendRoutes(cfg *Config) { } // Get base URL from database config with fallback to config/env var - baseURL := config.GetBaseURL(c.Request().Context(), cfg.Queries) - if baseURL == "" { - baseURL = cfg.Cfg.BaseURL - } + baseURL := cfg.getBaseURL(c.Request().Context()) var buf bytes.Buffer err = templates.Devices(user, devices, pendingList, errorMsg, baseURL).Render(c.Request().Context(), &buf) diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 2d3ff3a..88441bf 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -118,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]) @@ -126,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()], } } diff --git a/internal/router/router.go b/internal/router/router.go index 8f25e62..4c956ab 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -62,6 +62,7 @@ type Config struct { ConnManager *sync.ConnectionManager QueueProcessor *sync.SyncQueueProcessor ProgressService *sync.ProgressService + AnnotationService *sync.AnnotationService DeviceAuthMiddleware *middleware.DeviceAuthMiddleware LoginTracker *ratelimit.LoginAttemptTracker ScannerHandler *handlers.Handler @@ -71,6 +72,24 @@ type Config struct { LibraryService *services.LibraryService } +// getBaseURL returns the configured base URL from the database, falling back to +// the env var / config default. Uses a closure to adapt the database query to +// config.SystemConfigGetter. +func (cfg *Config) getBaseURL(ctx context.Context) string { + getter := func(ctx context.Context, key string) (string, error) { + row, err := cfg.Queries.GetSystemConfig(ctx, key) + if err != nil { + return "", err + } + return row.Value, nil + } + baseURL := config.GetBaseURL(ctx, getter) + if baseURL == "" { + baseURL = cfg.Cfg.BaseURL + } + return baseURL +} + // createJWTMiddleware creates a JWT middleware with proper user context setup func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc { return echojwt.WithConfig(echojwt.Config{ diff --git a/internal/router/search.go b/internal/router/search.go index 8602df5..d3cd95e 100644 --- a/internal/router/search.go +++ b/internal/router/search.go @@ -112,9 +112,15 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error { CoverImagePath: utils.ResolveMediaURL(pgtype.UUID{Bytes: bookLibUUID, Valid: true}, book.CoverImagePath), } } - // Render using BooksGrid template + // Stamp active conflict flags so cards route the play action correctly + bookInfoList = handlers.MarkActiveConflicts(c.Request().Context(), cfg.Queries, user.ID, bookInfoList) + // Render using BooksGrid template (or BookPickerGrid for collection picker) var buf bytes.Buffer - err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf) + if c.QueryParam("show_checkbox") == "true" { + err = templates.BookPickerGrid(bookInfoList).Render(c.Request().Context(), &buf) + } else { + err = templates.BooksGrid(bookInfoList, limit, offset, totalCount, libraryID).Render(c.Request().Context(), &buf) + } if err != nil { log.Printf("Template render error: %v", err) return c.HTML(http.StatusInternalServerError, `
Render error
`) diff --git a/internal/router/setup.go b/internal/router/setup.go index d8e69a2..cd9f71a 100644 --- a/internal/router/setup.go +++ b/internal/router/setup.go @@ -3,65 +3,51 @@ package router import ( "bytes" "context" - "errors" "log" "net/http" - "strconv" "strings" - "sync" - "time" + "bookhoard/internal/setupstatus" "bookhoard/templates" - "github.com/jackc/pgx/v5" "github.com/labstack/echo/v5" ) -var ( - setupCacheMu sync.RWMutex - setupCacheComplete bool = true - setupCacheExpiry time.Time - setupCacheTTL = 10 * time.Second -) - func isSetupComplete(cfg *Config) bool { - setupCacheMu.RLock() - if time.Now().Before(setupCacheExpiry) { - complete := setupCacheComplete - setupCacheMu.RUnlock() - return complete - } - setupCacheMu.RUnlock() - - val, err := cfg.Queries.GetSystemSetting(context.Background(), "setup_complete") - if err != nil { - if errors.Is(err, pgx.ErrNoRows) { - setupCacheMu.Lock() - setupCacheComplete = false - setupCacheExpiry = time.Now().Add(setupCacheTTL) - setupCacheMu.Unlock() - return false + getter := func(ctx context.Context) (string, error) { + row, err := cfg.Queries.GetSystemConfig(ctx, "base_url") + if err != nil { + return "", err } - return true + return row.Value, nil } - - complete, err := strconv.ParseBool(val) - if err != nil { - complete = false - } - - setupCacheMu.Lock() - setupCacheComplete = complete - setupCacheExpiry = time.Now().Add(setupCacheTTL) - setupCacheMu.Unlock() - return complete + return setupstatus.IsSetupComplete(context.Background(), cfg.Queries, getter) } -func invalidateSetupCache() { - setupCacheMu.Lock() - setupCacheComplete = true - setupCacheExpiry = time.Time{} - setupCacheMu.Unlock() +// setupAllowedAPIRoutes lists API endpoints that remain accessible before +// initial setup is complete so the server can be configured via API. +var setupAllowedAPIRoutes = []string{ + "/api/auth/register", + "/api/auth/login", + "/api/system/config", +} + +// isAllowedDuringSetup reports whether a request path should bypass the setup +// gate. This includes the setup page itself, static assets, health checks, and +// the minimal set of API routes needed to perform initial configuration. +func isAllowedDuringSetup(path string) bool { + if path == "/setup" || path == "/setup/" { + return true + } + if strings.HasPrefix(path, "/static/") || path == "/health" || path == "/favicon.ico" { + return true + } + for _, route := range setupAllowedAPIRoutes { + if path == route || strings.HasPrefix(path, route+"/") { + return true + } + } + return false } func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc { @@ -69,19 +55,16 @@ func setupRedirectMiddleware(cfg *Config) echo.MiddlewareFunc { 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" { + if isAllowedDuringSetup(path) { return next(c) } if !isSetupComplete(cfg) { + if strings.HasPrefix(path, "/api/") { + return c.JSON(http.StatusServiceUnavailable, map[string]string{ + "error": "Server setup is not complete. Configure an admin account and base_url via the setup wizard or API.", + }) + } return c.Redirect(http.StatusFound, "/setup") } @@ -104,15 +87,4 @@ func registerSetupRoutes(cfg *Config) { } return c.HTML(http.StatusOK, buf.String()) }) - - jwtMiddleware := createJWTMiddleware(cfg) - protected := e.Group("/api/setup", jwtMiddleware) - protected.PUT("/complete", func(c *echo.Context) error { - err := cfg.SystemSettingsHandler.SetSetupComplete(c) - if err != nil { - return err - } - invalidateSetupCache() - return nil - }) } diff --git a/internal/router/sync.go b/internal/router/sync.go index 4731fa6..406e453 100644 --- a/internal/router/sync.go +++ b/internal/router/sync.go @@ -33,6 +33,7 @@ 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)) diff --git a/internal/services/media_scanner.go b/internal/services/media_scanner.go index 8f584c6..0a4d07c 100644 --- a/internal/services/media_scanner.go +++ b/internal/services/media_scanner.go @@ -146,16 +146,18 @@ type CalibreOPFMetadata struct { Timestamp *time.Time } -// NewMediaScanner creates a new media scanner instance +// NewMediaScanner creates a new media scanner instance. +// +// The fsnotify watcher is NOT created here. It is created lazily inside +// SetFolders only when watch=true (the long-lived watch-mode scanner). +// Ephemeral one-off scan jobs pass watch=false, so they never allocate a +// watcher (and thus can never panic on EMFILE/ENOSPC). This fixes the +// fd/inotify-watch leak where every scan job created a watcher that was +// never closed. func NewMediaScanner(db *database.Queries) *MediaScanner { - watcher, err := fsnotify.NewWatcher() - if err != nil { - panic(fmt.Sprintf("Failed to create file watcher: %v", err)) - } - return &MediaScanner{ db: db, - watcher: watcher, + watcher: nil, settingsCache: NewSettingsCache(30 * time.Second), dirtyDirs: make(map[string]time.Time), fileStability: make(map[string]*atomic.Bool), @@ -238,24 +240,34 @@ func (s *MediaScanner) GetStats() (int, int, int) { return s.totalFiles, s.newItems, s.errors } -func (s *MediaScanner) SetFolders(folders []string) error { +// SetFolders configures the scanner's folders and (optionally) sets up an +// fsnotify watcher over the full directory tree. +// +// watch should be true only for the single long-lived watch-mode scanner that +// actually consumes watcher.Events. Ephemeral scan jobs must pass false so no +// watcher (and thus no fd/inotify watches) is allocated — the watcher is never +// read by scan jobs and previously leaked one watcher per job. +func (s *MediaScanner) SetFolders(folders []string, watch bool) error { s.folders = folders - // Remove old watch if exists + // Always close any previously-owned watcher so reconfiguration doesn't leak. if s.watcher != nil { - if s.watcher != nil { - if err := s.watcher.Close(); err != nil { - fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err) - } + if err := s.watcher.Close(); err != nil { + fmt.Printf("Warning: failed to close old watcher during folder reconfiguration: %v\n", err) } + s.watcher = nil } - // Create new watcher - watcher, err := fsnotify.NewWatcher() - if err != nil { - return fmt.Errorf("failed to create watcher: %v", err) + // Create + populate a fresh watcher only when the caller intends to read events. + if watch { + watcher, err := fsnotify.NewWatcher() + if err != nil { + // Return an error instead of panicking so a failed watcher can't + // take down the whole process. + return fmt.Errorf("failed to create watcher: %w", err) + } + s.watcher = watcher } - s.watcher = watcher // Build cache of allowed extensions per folder // Uses Go AllowedExtensions map as source of truth (not DB) @@ -286,31 +298,36 @@ func (s *MediaScanner) SetFolders(folders []string) error { } } - // Add all folders and their subdirectories to the watcher (like Audiobookshelf) - watchCount := 0 - for _, folder := range folders { - if err := s.watcher.Add(folder); err != nil { - fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err) - } else { - watchCount++ - } - filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } - if !d.IsDir() || path == folder { - return nil - } - if err := s.watcher.Add(path); err != nil { - fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err) + // Add all folders and their subdirectories to the watcher (like Audiobookshelf). + // Only when watching; scan jobs (watch=false) skip this entirely. + if s.watcher != nil { + watchCount := 0 + for _, folder := range folders { + if err := s.watcher.Add(folder); err != nil { + fmt.Printf("[WATCHER] Warning: failed to watch root folder %s: %v\n", folder, err) } else { watchCount++ } - return nil - }) - } + filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() || path == folder { + return nil + } + if err := s.watcher.Add(path); err != nil { + fmt.Printf("[WATCHER] Warning: failed to watch subdirectory %s: %v\n", path, err) + } else { + watchCount++ + } + return nil + }) + } - fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders)) + fmt.Printf("[WATCHER] Now watching %d directories across %d root folders\n", watchCount, len(folders)) + } else { + fmt.Printf("[SCANNER] Configured %d root folders (watch mode disabled, no inotify watcher)\n", len(folders)) + } return nil } @@ -417,8 +434,10 @@ func (s *MediaScanner) ScanFolders(ctx context.Context) error { } if d.IsDir() { - if err := s.watcher.Add(path); err != nil { - fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err) + if s.watcher != nil { + if err := s.watcher.Add(path); err != nil { + fmt.Printf("Warning: failed to watch subdirectory %s: %v\n", path, err) + } } return nil } @@ -2601,6 +2620,13 @@ func (s *MediaScanner) WatchChanges(ctx context.Context) error { go s.startBackupScan(ctx) go func() { + // The event loop only runs if a real watcher was set up (watch=true). + // If watching with no watcher (e.g. inotify unavailable through a Docker + // bind mount), polling via startBackupScan above still handles detection. + if s.watcher == nil { + fmt.Printf("[WATCHER] No inotify watcher configured; relying on periodic polling for change detection\n") + return + } fmt.Printf("[WATCHER] Event loop started for %d folders\n", len(s.folders)) for { select { diff --git a/internal/services/worker.go b/internal/services/worker.go index da7c0d4..8f509d2 100644 --- a/internal/services/worker.go +++ b/internal/services/worker.go @@ -205,7 +205,23 @@ func (w *Worker) worker() { return } - w.processJob(job) + // Recover from any panic inside a job so a single failing job can + // never crash the whole worker goroutine (and thus the process). + func() { + defer func() { + if r := recover(); r != nil { + fmt.Printf("[WORKER] panic in job %s (%s): %v\n", job.ID, job.Type, r) + w.mu.Lock() + w.results[job.ID] = &JobResult{ + JobID: job.ID, + Status: JobStatusFailed, + Error: fmt.Sprintf("panic: %v", r), + } + w.mu.Unlock() + } + }() + w.processJob(job) + }() case <-w.ctx.Done(): return @@ -348,6 +364,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) { } scanner := NewMediaScanner(db) + defer scanner.Close() scanner.job = job job.ProgressCallback = func(progress float64, filesScanned, newItems, errors int) { @@ -377,7 +394,7 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) { } } - if err := scanner.SetFolders(folders); err != nil { + if err := scanner.SetFolders(folders, false); err != nil { return nil, err } @@ -521,7 +538,8 @@ func (w *Worker) processSetFoldersJob(job *Job) (interface{}, error) { // Create scanner and configure folders scanner := NewMediaScanner(db) - if err := scanner.SetFolders(folders); err != nil { + defer scanner.Close() + if err := scanner.SetFolders(folders, false); err != nil { return nil, fmt.Errorf("failed to set folders: %w", err) } @@ -900,6 +918,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) { // Create temporary scanner instance for this job scanner := NewMediaScanner(db) + defer scanner.Close() scanner.job = job // Find which library owns this directory (prefix match for subdirectories) ctx := context.Background() @@ -918,7 +937,7 @@ func (w *Worker) processDirectoryScanJob(job *Job) (interface{}, error) { folderPaths = append(folderPaths, f.FolderPath) } // Configure scanner with folders - if err := scanner.SetFolders(folderPaths); err != nil { + if err := scanner.SetFolders(folderPaths, false); err != nil { return nil, fmt.Errorf("failed to set folders: %w", err) } // Now scan the directory diff --git a/internal/setupstatus/status.go b/internal/setupstatus/status.go new file mode 100644 index 0000000..e91b803 --- /dev/null +++ b/internal/setupstatus/status.go @@ -0,0 +1,79 @@ +// Package setupstatus reports whether the application's initial setup has been +// completed. Setup is considered complete when at least one admin user exists +// AND a non-empty base_url has been configured, regardless of how those were +// 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) +} + +// BaseURLGetter returns the configured base_url value from the database, or an +// error if it cannot be read. Defined as a function type (not an interface) so +// it can be satisfied by a closure wrapping *database.Queries.GetSystemConfig +// without importing the database package. +type BaseURLGetter func(ctx context.Context) (string, 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 AND base_url is configured. 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, baseURLGetter BaseURLGetter) bool { + cacheMu.RLock() + if time.Now().Before(cacheExpiry) { + complete := cacheComplete + cacheMu.RUnlock() + return complete + } + cacheMu.RUnlock() + + complete := true + + count, err := q.CountAdmins(ctx) + if err == nil { + complete = count > 0 + } + + if complete && baseURLGetter != nil { + baseURL, err := baseURLGetter(ctx) + if err == nil { + complete = baseURL != "" + } + } + + 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) or +// the base_url configuration. +func Invalidate() { + cacheMu.Lock() + cacheComplete = true + cacheExpiry = time.Time{} + cacheMu.Unlock() +} diff --git a/internal/sync/annotations.go b/internal/sync/annotations.go new file mode 100644 index 0000000..d047479 --- /dev/null +++ b/internal/sync/annotations.go @@ -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 +} diff --git a/internal/sync/annotations_test.go b/internal/sync/annotations_test.go new file mode 100644 index 0000000..bf0adde --- /dev/null +++ b/internal/sync/annotations_test.go @@ -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}, + } +} diff --git a/internal/sync/locators.go b/internal/sync/locators.go new file mode 100644 index 0000000..2a800ea --- /dev/null +++ b/internal/sync/locators.go @@ -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} + } +} diff --git a/internal/sync/queue.go b/internal/sync/queue.go index e4ad5d9..b7822e0 100644 --- a/internal/sync/queue.go +++ b/internal/sync/queue.go @@ -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 { @@ -85,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) @@ -113,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, @@ -308,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) } @@ -407,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) { diff --git a/release b/release new file mode 100755 index 0000000..3aac254 --- /dev/null +++ b/release @@ -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}" diff --git a/tailwind.config.ts b/tailwind.config.ts index f00ad4f..ac89013 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -44,25 +44,43 @@ const config: Config = { ], }, colors: { - primary: { - DEFAULT: "#7aa2f7", - 50: "#f0f9ff", - 100: "#e0f2fe", - 200: "#bae6fd", - 300: "#7dd3fc", - 400: "#38bdf8", - 500: "#0ea5e9", - 600: "#0284c7", - 700: "#0369a1", - 800: "#075985", - 900: "#0c4a6e", + // Semantic surface tokens (page bg, cards, raised layers) + surface: { + DEFAULT: "var(--bg-primary)", + raised: "var(--bg-secondary)", + hover: "var(--surface-hover)", + overlay: "var(--surface-overlay)", }, - "bg-primary": "var(--bg-primary)", - "bg-secondary": "var(--bg-secondary)", - "text-primary": "var(--text-primary)", - "text-secondary": "var(--text-secondary)", - accent: "var(--accent)", - border: "var(--border)", + // Text tokens + content: { + DEFAULT: "var(--text-primary)", + muted: "var(--text-secondary)", + }, + // Accent / brand + brand: { + DEFAULT: "var(--accent)", + muted: "var(--accent-muted)", + }, + // Borders / hairlines + line: { + DEFAULT: "var(--border)", + strong: "var(--border-strong)", + }, + // Status colors (theme-aware via vars, fall back to fixed) + success: "var(--status-success)", + warning: "var(--status-warning)", + danger: "var(--status-danger)", + info: "var(--status-info)", + }, + borderRadius: { + xl: "0.875rem", + "2xl": "1.25rem", + }, + boxShadow: { + card: "var(--shadow-card)", + "card-hover": "var(--shadow-card-hover)", + pop: "var(--shadow-pop)", + bar: "var(--shadow-bar)", }, backgroundImage: { "wood-light": "url('/static/textures/wood-light.png')", diff --git a/templates/admin.templ b/templates/admin.templ index a5d1ad9..fb1d8a7 100644 --- a/templates/admin.templ +++ b/templates/admin.templ @@ -11,60 +11,79 @@ templ Admin(user User) { @Header(user, "/admin") -
+
@AdminSidebar(user, "/admin")
-

Dashboard

-

Overview of your Bookhoard library and settings

+
+ + @Icon("grid", "h-5 w-5") + +

Dashboard

+
+

Overview of your Bookhoard library and settings

-
-
-
-
📖
+
+
+
+ + @Icon("library", "h-5 w-5") +

Library

-

Manage your ebook collection

+

Manage your ebook collection

- View Library + + @Icon("arrow-right", "h-4 w-4") + View Library +
-
-
-
👁️
+
+
+ + @Icon("sync", "h-5 w-5") +

Scan Watch Status

-

Auto-detecting new files

+

Auto-detecting new files

- + Watching 0 libraries
-
-

Quick Actions

+
+

Quick Actions

- - -
Manage Libraries and Folders
-
Add or remove libraries and scan directories
+
+ + @Icon("library", "h-5 w-5") + Manage Libraries + + Add or remove libraries and scan directories
-

Create Library

Browse Folders

Delete Library

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/admin_processing_issues.templ b/templates/admin_processing_issues.templ index a017738..9c58178 100644 --- a/templates/admin_processing_issues.templ +++ b/templates/admin_processing_issues.templ @@ -1,4 +1,3 @@ - package templates templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssueData, stats IssueStats) { @@ -12,83 +11,105 @@ templ AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssu @Header(user, "/admin/libraries/"+libraryID)
-
-
-
-

Processing Issues

-

Items that couldn't be processed in this library

+
+
+
+
+
+ + @Icon("alert", "h-5 w-5") + +

Processing Issues

+
+

Items that couldn't be processed in this library

+
+ + @Icon("arrow-left", "h-4 w-4") + Back to Library +
- - ← Back to Library -
-
- if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { - -
- if stats.ErrorCount > 0 { -
-

Errors

-

{ stats.ErrorCount }

-
- } - if stats.WarningCount > 0 { -
-

Warnings

-

{ stats.WarningCount }

-
- } - if stats.InfoCount > 0 { -
-

Info

-

{ stats.InfoCount }

-
- } -
- } - if len(issues) == 0 { -
-

No processing issues found for this library.

-
- } else { - -
- for _, issue := range issues { -
-
-
-

{ issue.Title }

-

{ issue.IssueDescription }

-
-

Type: { issue.IssueType }

-

Format: { issue.FormatGroup }

-

File: { issue.FilePath }

-

Library: { issue.LibraryTypeName }

+ if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { + +
+ if stats.ErrorCount > 0 { +
+
+ @Icon("x-circle", "h-5 w-5") +

Errors

+
+

{ stats.ErrorCount }

+
+ } + if stats.WarningCount > 0 { +
+
+ @Icon("alert", "h-5 w-5") +

Warnings

+
+

{ stats.WarningCount }

+
+ } + if stats.InfoCount > 0 { +
+
+ @Icon("info", "h-5 w-5") +

Info

+
+

{ stats.InfoCount }

+
+ } +
+ } + if len(issues) == 0 { +
+ + @Icon("check-circle", "h-6 w-6") + +

No processing issues found for this library.

+
+ } else { + +
+ for _, issue := range issues { +
+
+
+

{ issue.Title }

+

{ issue.IssueDescription }

+
+

Type: { issue.IssueType }

+

Format: { issue.FormatGroup }

+

File: { issue.FilePath }

+

Library: { issue.LibraryTypeName }

+
+
+
+ if issue.Severity == "error" { + { issue.Severity } + } else if issue.Severity == "warning" { + { issue.Severity } + } else { + { issue.Severity } + }
-
- - { issue.Severity } - +
+ if issue.Severity == "warning" || issue.Severity == "info" { + + }
-
- if issue.Severity == "warning" || issue.Severity == "info" { - - } -
-
- } -
- } + } +
+ } +
diff --git a/templates/admin_processing_issues_templ.go b/templates/admin_processing_issues_templ.go index 56cac26..0b56c9a 100644 --- a/templates/admin_processing_issues_templ.go +++ b/templates/admin_processing_issues_templ.go @@ -1,7 +1,6 @@ // Code generated by templ - DO NOT EDIT. // templ: version: v0.3.1020 - package templates //lint:file-ignore SA4006 This context is only used if a nested component is present. @@ -38,200 +37,302 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Processing Issues

Items that couldn't be processed in this library

← Back to Library
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

Processing Issues

Items that couldn't be processed in this library

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("arrow-left", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Back to Library
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if stats.ErrorCount > 0 || stats.WarningCount > 0 || stats.InfoCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if stats.ErrorCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "

Errors

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("x-circle", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

Errors

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 32, Col: 56} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 41, Col: 92} } _, 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, 5, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if stats.WarningCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Warnings

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("alert", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Warnings

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 38, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 50, Col: 94} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if stats.InfoCount > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

Info

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("info", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

Info

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 44, Col: 55} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 59, Col: 91} } _, 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, 9, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } if len(issues) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

No processing issues found for this library.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("check-circle", "h-6 w-6").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "

No processing issues found for this library.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } else { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, issue := range issues { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 60, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 98} } _, 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, 14, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 61, Col: 64} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 96} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

Type: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "

Type: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 63, Col: 54} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 81, Col: 140} } _, 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, 16, "

Format: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "

Format: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 64, Col: 58} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 82, Col: 144} } _, 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, 17, "

File: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "

File: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 65, Col: 53} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 83, Col: 139} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "

Library: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "

Library: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 66, Col: 63} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 149} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var11 string - templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 27} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if issue.Severity == "warning" || issue.Severity == "info" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "") + if issue.Severity == "error" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else if issue.Severity == "warning" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 91, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 93, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if issue.Severity == "warning" || issue.Severity == "info" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/admin_settings.templ b/templates/admin_settings.templ index feacf7d..d0e2a1e 100644 --- a/templates/admin_settings.templ +++ b/templates/admin_settings.templ @@ -11,55 +11,67 @@ templ AdminSettings(user User, systemConfig map[string]string, errorMessage stri @Header(user, "/admin/settings") -
+
@AdminSidebar(user, "/admin/settings")
-
- - ← Back to Dashboard + -

System Settings

-

Configure your Bookhoard instance

+
+ + @Icon("settings", "h-5 w-5") + +

System Settings

+
+

Configure your Bookhoard instance

if errorMessage != "" { -
- { errorMessage } +
+ @Icon("alert", "h-5 w-5 shrink-0 mt-0.5") + { errorMessage }
}
-
-

Base URL

+
+
+ @Icon("globe", "h-5 w-5 shrink-0") +

Base URL

+
- + -

The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.

+

The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.

-
-
-

System Defaults

+
+
+ @Icon("clock", "h-5 w-5 shrink-0") +

System Defaults

+
- + -

Default timezone for users who haven't set their own.

+

Default timezone for users who haven't set their own.

-
-

URL Paths

+
+
+ @Icon("external", "h-5 w-5 shrink-0") +

URL Paths

+
-

OPDS: { systemConfig["base_url"] }/opds

-

API: { systemConfig["base_url"] }/api

-

Device Sync: { systemConfig["base_url"] }/api/sync

+

OPDS: { systemConfig["base_url"] }/opds

+

API: { systemConfig["base_url"] }/api

+

Device Sync: { systemConfig["base_url"] }/api/sync

diff --git a/templates/admin_settings_templ.go b/templates/admin_settings_templ.go index 40b8b8f..ec93dcc 100644 --- a/templates/admin_settings_templ.go +++ b/templates/admin_settings_templ.go @@ -37,7 +37,7 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -45,322 +45,378 @@ func AdminSettings(user User, systemConfig map[string]string, errorMessage strin if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "

System Settings

Configure your Bookhoard instance

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("settings", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "

System Settings

Configure your Bookhoard instance

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if errorMessage != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("alert", "h-5 w-5 shrink-0 mt-0.5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 29, Col: 22} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 36, Col: 28} } _, 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, 5, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Base URL

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("globe", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

Base URL

The public URL of your Bookhoard instance (e.g., https://books.example.com). Used for device sync, OPDS, and API endpoints.

System Defaults

Default timezone for users who haven't set their own.

URL Paths

OPDS: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, ">Japan/Korea (UTC+9)

Default timezone for users who haven't set their own.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("external", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "

URL Paths

OPDS: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 96, Col: 60} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 145} } _, 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, 56, "/opds

API: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "/opds

API: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 97, Col: 59} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 112, Col: 144} } _, 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, 57, "/api

Device Sync: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "/api

Device Sync: ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"]) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 98, Col: 67} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 113, Col: 152} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "/api/sync

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "/api/sync

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/admin_sidebar.templ b/templates/admin_sidebar.templ index fff21e9..f213594 100644 --- a/templates/admin_sidebar.templ +++ b/templates/admin_sidebar.templ @@ -1,23 +1,32 @@ package templates templ AdminSidebar(user User, currentPath string) { -
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/templates/analytics.templ b/templates/analytics.templ index 8cf3411..105049c 100644 --- a/templates/analytics.templ +++ b/templates/analytics.templ @@ -12,78 +12,75 @@ templ Analytics(user User) { @Header(user, "/analytics") -
+
-

📊 Reading Analytics

-

Track your reading habits and device usage

+
+ + @Icon("chart", "h-5 w-5") + +

Reading Analytics

+
+

Track your reading habits and device usage

- -
-
+
+
- +
- +
-
- +
+
- -
-
-

Books Read

-

-

+
+
+

Books Read

+

-

-
-

Pages Read

-

-

+
+

Pages Read

+

-

-
-

Reading Time

-

-

+
+

Reading Time

+

-

-
-

Completion Rate

-

-

+
+

Completion Rate

+

-

-
- -
-

Daily Reading Minutes

+
+

Daily Reading Minutes

- -
-

Device Usage

+
+

Device Usage

- -
-

Most Read Books

+
+

Most Read Books