Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
679e0f9116 | ||
|
|
9cd6356892 | ||
|
|
4f05a232fe | ||
|
|
69516ae165 | ||
|
|
f24a2d6c22 | ||
|
|
da13898046 | ||
|
|
1a2d42a244 | ||
|
|
208fc2c4d6 | ||
|
|
18969a0f01 | ||
|
|
843f35bbfc | ||
|
|
7ce73213ae | ||
|
|
31f939002f | ||
|
|
f83b36167e | ||
|
|
d5d8e3e173 | ||
|
|
44c0e5640f | ||
|
|
e7ccd7b222 | ||
|
|
b1de0da512 | ||
|
|
25d44452c0 | ||
|
|
b8db1d109c | ||
|
|
bbecd1e5ec | ||
|
|
ffe690691b | ||
|
|
0708a1f414 | ||
|
|
c8e85817aa | ||
|
|
f45adc6362 | ||
|
|
5275f2ba38 | ||
|
|
0b83db8fce | ||
|
|
4b5e193d9f | ||
|
|
b9f797789f | ||
|
|
6aaefe210a | ||
|
|
fb21d5249c | ||
|
|
b03ed9a2f2 | ||
|
|
72839921f2 | ||
|
|
25981fc762 | ||
|
|
f2629942e4 | ||
|
|
ad1ad11fb5 | ||
|
|
66dd6c8096 | ||
|
|
1205526422 | ||
|
|
29ac420876 | ||
|
|
5ffa371963 | ||
|
|
0be70f0051 | ||
|
|
f07dfbfe74 | ||
|
|
b4b1deafb8 | ||
|
|
217f411f74 | ||
|
|
4fde2a6d83 | ||
|
|
3d88f0b960 | ||
|
|
6aea6a4e2c | ||
|
|
b10bf3e8c7 | ||
|
|
8c3273a0fc | ||
|
|
aec226af1a | ||
|
|
1c1f5cf1bb | ||
|
|
9e4cc4efd0 | ||
|
|
7b0b2793a6 | ||
|
|
077afaca56 | ||
|
|
388b62b277 | ||
|
|
5da97b2e5d | ||
|
|
45e3a256e2 | ||
|
|
64ef691d64 | ||
|
|
a4dbe79d0b | ||
|
|
1002cbca93 | ||
|
|
65f6bdf075 | ||
|
|
4faa217822 | ||
|
|
5cdfe5a8dd | ||
|
|
1cd8557b58 | ||
|
|
b4c956aed4 | ||
|
|
7caa46c2da | ||
|
|
5329bf35a9 | ||
|
|
80e5d5b0d1 | ||
|
|
b3a429d7e1 | ||
|
|
d8179bb066 | ||
|
|
960305dfa9 | ||
|
|
16bbeec5a3 | ||
|
|
39f9c39360 | ||
|
|
011b6d14e4 | ||
|
|
d4661e7f04 | ||
|
|
27a93ba2ea | ||
|
|
7afad5dcde | ||
|
|
929a862e74 | ||
|
|
0e55dafb1e | ||
|
|
cc8084a713 | ||
|
|
3ab294bf81 | ||
|
|
e944f11415 | ||
|
|
708598687e | ||
|
|
aac7c72900 | ||
|
|
fe5ab9e5f8 | ||
|
|
249b1dfe93 | ||
|
|
17216e9cc7 | ||
|
|
841db91a29 | ||
|
|
1eb9c92d6a | ||
|
|
1fa8ee3a59 | ||
|
|
72d167005f | ||
|
|
bf2c2825ac | ||
|
|
cd119a74da | ||
|
|
14445a7c3f | ||
|
|
e6aceae0da | ||
|
|
44b98f3fc3 | ||
|
|
61681aac23 | ||
|
|
7a9a66fcc5 | ||
|
|
2df3ecbf36 | ||
|
|
9da193e718 | ||
|
|
7f64b92b9d | ||
|
|
b9645752f5 | ||
|
|
9c8337d0a8 | ||
|
|
da1f689263 | ||
|
|
298330a3a8 | ||
|
|
0614795ca2 |
No files matched your search
@@ -20,6 +20,9 @@ logs/
|
||||
|
||||
# Dependencies (install fresh in container)
|
||||
node_modules/
|
||||
# Local-only lockfile: a stale one pins old foliate-js commits into builds
|
||||
# (npm reuses name@version without re-resolving the git pin)
|
||||
package-lock.json
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
@@ -35,5 +35,9 @@ DBPASS=your-secure-database-password-here
|
||||
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
||||
# Conversion Cache TTL: Override default 24h
|
||||
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
||||
# Archive Retention: days to keep archived items (files missing from disk for
|
||||
# 2+ scans) before they are purged, deleting their reading history with them.
|
||||
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||
# ARCHIVE_RETENTION_DAYS=90
|
||||
# System timezone (fallback for server-side time operations, defaults to UTC)
|
||||
# TZ=America/New_York
|
||||
@@ -56,6 +56,9 @@ jobs:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
# Bake the version into the binary (Admin → Settings → About).
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.TAG }}
|
||||
# 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.
|
||||
|
||||
+23
-4
@@ -15,7 +15,10 @@ RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
|
||||
# Copy package files and install npm dependencies (cached unless package.json changes)
|
||||
COPY package*.json ./
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
# npm cache mount renamed from /root/.npm: the old volume held a stale
|
||||
# foliate-js tarball (name@version reuse) that --no-cache and builder prune
|
||||
# could not evict; a new mount id guarantees a clean fetch.
|
||||
RUN --mount=type=cache,target=/root/.npm-cache2 \
|
||||
npm install
|
||||
|
||||
# Copy Go mod files (cached unless go.mod changes)
|
||||
@@ -34,22 +37,38 @@ RUN cd templates && templ generate
|
||||
RUN npm run build:css:prod
|
||||
RUN npm run build:ts
|
||||
|
||||
# App version baked into the binary (shown in Admin → Settings → About).
|
||||
# Declared after `COPY . .` so a version bump only invalidates this layer.
|
||||
# The release workflow passes the git tag, e.g. APP_VERSION=v1.5.
|
||||
ARG APP_VERSION=dev
|
||||
|
||||
# Build Go binary (cached unless Go files or generated code changes)
|
||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo -o main ./cmd/server
|
||||
CGO_ENABLED=0 GOOS=linux go build -installsuffix cgo \
|
||||
-ldflags "-X bookhoard/internal/version.Version=${APP_VERSION}" \
|
||||
-o main ./cmd/server
|
||||
|
||||
# Materialize the Go module cache into an image layer so the test-runner
|
||||
# stage can restore it. GOMODCACHE is /go/pkg/mod; cache mounts elsewhere are
|
||||
# ignored by the go tool, and without this copy `go test` in the tests
|
||||
# container re-downloads every dependency on each invocation.
|
||||
RUN cp -r /go/pkg/mod /app/go-module-cache
|
||||
|
||||
# Test runner stage - includes Go runtime and test dependencies
|
||||
# This stage is ONLY used for running tests, never deployed to production
|
||||
FROM golang:1.26-alpine AS test-runner
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy everything from builder (includes compiled binary, source code, and dependencies)
|
||||
COPY --from=builder /app ./
|
||||
|
||||
# Restore the materialized Go module cache so `go test` works offline.
|
||||
RUN mkdir -p /go/pkg/mod && cp -r /app/go-module-cache/. /go/pkg/mod/ && rm -rf /app/go-module-cache
|
||||
|
||||
# Install kepubify for conversion tests
|
||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
||||
&& chmod +x /usr/bin/kepubify
|
||||
@@ -65,7 +84,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk --no-cache add ca-certificates curl
|
||||
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||
|
||||
# Install kepubify for EPUB→KEPUB conversion
|
||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
info:
|
||||
name: Rescan Media Item
|
||||
type: http
|
||||
seq: 9
|
||||
|
||||
http:
|
||||
method: POST
|
||||
url: "{{base_url}}/api/media-items/{{media_item_id}}/rescan"
|
||||
headers:
|
||||
- name: ""
|
||||
value: application/json
|
||||
auth: inherit
|
||||
|
||||
runtime:
|
||||
scripts:
|
||||
- type: tests
|
||||
code: |-
|
||||
test_rescan_media_item_success(status, headers, body) {
|
||||
if (status !== 200) {
|
||||
throw new Error("Expected status 200, got " + status);
|
||||
|
||||
settings:
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
followRedirects: true
|
||||
maxRedirects: 5
|
||||
|
||||
docs: |-
|
||||
## Rescan Media Item
|
||||
|
||||
Re-extracts metadata and cover art for a single media item from the file on disk.
|
||||
|
||||
**Method:** POST
|
||||
|
||||
**Endpoint:** /api/media-items/{id}/rescan
|
||||
@@ -27,7 +27,7 @@ docs: |-
|
||||
- `id` (string): Media item ID
|
||||
|
||||
**Request Body:**
|
||||
- `content` (string): Note content (required, 1-10000 chars)
|
||||
- `content` (string): Note content (required, 1-100000 chars)
|
||||
- `position` (string): Optional position reference (max 100 chars)
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -28,7 +28,7 @@ docs: |-
|
||||
- `noteId` (string): Note ID
|
||||
|
||||
**Request Body:**
|
||||
- `content` (string): Updated note content (required, 1-10000 chars)
|
||||
- `content` (string): Updated note content (required, 1-100000 chars)
|
||||
- `position` (string): Updated position reference (optional, max 100 chars)
|
||||
|
||||
**Response:**
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"bookhoard/internal/router"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/sync"
|
||||
"bookhoard/templates"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
@@ -56,6 +57,20 @@ func main() {
|
||||
if err := registry.Load(ctx); err != nil {
|
||||
log.Printf("⚠️ Could not load system settings (using defaults): %v", err)
|
||||
}
|
||||
|
||||
// Public signup surfaces follow the registration_enabled flag, with the
|
||||
// first-user exception: an unconfigured server (zero admins) keeps its
|
||||
// signup links and registration route reachable for bootstrapping.
|
||||
templates.SetSignupVisibility(func() bool {
|
||||
if registry.RegistrationEnabled() {
|
||||
return true
|
||||
}
|
||||
count, err := queries.CountAdmins(context.Background())
|
||||
if err != nil {
|
||||
return true // fail open, matching setupstatus
|
||||
}
|
||||
return count == 0
|
||||
})
|
||||
// Wire the registry into the package-level password validator so live
|
||||
// rule changes apply to the echo struct-tag validator and ValidatePassword.
|
||||
middleware.SetDefaultPasswordSettings(registry)
|
||||
@@ -121,6 +136,9 @@ func main() {
|
||||
|
||||
// Create library service
|
||||
libraryService := services.NewLibraryService(queries)
|
||||
// The progress service verifies submitted anchors against the book
|
||||
// itself — it needs to resolve library-relative file paths.
|
||||
progressService.SetMediaPathResolver(libraryService)
|
||||
|
||||
// Sync Go AllowedExtensions into DB so API clients see correct extensions
|
||||
libraryService.SyncAllowedExtensions(context.Background())
|
||||
|
||||
@@ -140,7 +140,6 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
body := readBody(resp)
|
||||
|
||||
// Verify reading direction badge is displayed
|
||||
assert.Contains(t, body, "📖", "Should display reading direction icon")
|
||||
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
|
||||
})
|
||||
|
||||
@@ -263,7 +262,8 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
|
||||
assert.Contains(t, body, "📚", "Should display story arc icon")
|
||||
// The redesigned badge renders the arc NAME in a text badge
|
||||
// (no 📚 icon) — assert the name.
|
||||
assert.Contains(t, body, "The Dark Phoenix Saga", "Should display story arc name")
|
||||
})
|
||||
|
||||
@@ -508,7 +508,8 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
body := readBody(resp)
|
||||
|
||||
// Verify all manga-specific fields are displayed
|
||||
// The redesigned badge (07578e0) renders the direction text chip
|
||||
// without the 📖 emoji — assert the chip text.
|
||||
assert.Contains(t, body, "RTL", "Should display RTL reading direction")
|
||||
assert.Contains(t, body, "Teen", "Should display age rating")
|
||||
assert.Contains(t, body, "8.5 / 10", "Should display community rating")
|
||||
@@ -619,9 +620,11 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
assert.Contains(t, body, "Everyone", "Should display age rating")
|
||||
assert.Contains(t, body, "goodreads.com", "Should display web URL")
|
||||
|
||||
// Verify comic-specific fields are NOT displayed
|
||||
assert.NotContains(t, body, "Manga Type", "Should not display manga type for ebook")
|
||||
assert.NotContains(t, body, "Scan Info", "Should not display scan info for ebook")
|
||||
// Verify comic-specific fields are NOT displayed. The page body
|
||||
// always contains the admin edit modal's "Manga Type"/"Scan Info"
|
||||
// LABELS — assert the metadata VALUES (what the display block
|
||||
// renders) are absent instead.
|
||||
assert.NotContains(t, body, "yes and right to left", "Should not display manga type value for ebook")
|
||||
assert.NotContains(t, body, "B&W", "Should not display B&W badge for ebook")
|
||||
})
|
||||
|
||||
@@ -682,9 +685,11 @@ func TestComicMetadataDisplay_ComicDetailPage(t *testing.T) {
|
||||
|
||||
// Page should still render successfully
|
||||
assert.Contains(t, body, "Minimal Comic", "Should display title")
|
||||
// Use "Community Rating:" with colon to avoid matching the HTML comment
|
||||
// The admin edit modal always carries "Manga Type" as a form
|
||||
// LABEL — the display block would render the capitalized VALUE,
|
||||
// which is absent here.
|
||||
assert.NotContains(t, body, "Community Rating:", "Should not show community rating when not set")
|
||||
assert.NotContains(t, body, "Manga Type", "Should not show manga type when unknown")
|
||||
assert.NotContains(t, body, "yes and right to left", "Should not show a manga type value when unknown")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,639 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Cross-library SHA move detection + archived-twin merge integration tests.
|
||||
// These cover the scenario where identical book content moves between
|
||||
// libraries of the same type: the row must be repointed (history follows)
|
||||
// instead of duplicated, deliberate copies stay independent, type rules are
|
||||
// enforced, and archived twins can be merged into their active copy.
|
||||
|
||||
const (
|
||||
moveTestAdminEmail = "testuser@tests.bookhoard.internal"
|
||||
moveTestFileName = "Test Book.epub"
|
||||
)
|
||||
|
||||
// createEbooksTestLibrary creates a library of the given type via the API and
|
||||
// swaps its default folder for a fresh temp directory the test controls.
|
||||
func createTypedTestLibrary(t *testing.T, setup *TestServerSetup, name, libType string) (pgtype.UUID, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
libBody, _ := json.Marshal(map[string]interface{}{"name": name, "type": libType})
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/libraries", bytes.NewBuffer(libBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "library creation should succeed")
|
||||
|
||||
var libResp map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&libResp))
|
||||
libID, err := uuid.Parse(libResp["id"].(string))
|
||||
require.NoError(t, err)
|
||||
pgLib := pgtype.UUID{Bytes: libID, Valid: true}
|
||||
|
||||
folders, err := setup.DB.GetLibraryFolders(ctx, pgLib)
|
||||
require.NoError(t, err)
|
||||
for _, f := range folders {
|
||||
_, err = setup.DB.DeleteLibraryFolder(ctx, database.DeleteLibraryFolderParams{
|
||||
LibraryID: pgLib,
|
||||
FolderPath: f.FolderPath,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
_, err = setup.DB.AddLibraryFolder(ctx, database.AddLibraryFolderParams{
|
||||
LibraryID: pgLib,
|
||||
FolderPath: tmpDir,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return pgLib, tmpDir
|
||||
}
|
||||
|
||||
// deleteLibraries removes libraries (cascading to media items, progress,
|
||||
// annotations, and processing issues) so reruns never see leftovers from a
|
||||
// previous run — stale rows with identical content hashes would otherwise be
|
||||
// picked up as move candidates. Call via defer AFTER the setup.Close defer so
|
||||
// it executes while the pool is still open (defers run LIFO).
|
||||
func deleteLibraries(t *testing.T, setup *TestServerSetup, libs ...pgtype.UUID) {
|
||||
t.Helper()
|
||||
for _, lib := range libs {
|
||||
_ = setup.DB.DeleteLibrary(context.Background(), lib)
|
||||
}
|
||||
}
|
||||
|
||||
// scanTestFolder runs a synchronous scan over one folder.
|
||||
func scanTestFolder(t *testing.T, setup *TestServerSetup, adminID pgtype.UUID, folder string) {
|
||||
t.Helper()
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
defer func() { _ = scanner.Close() }()
|
||||
scanner.SetAdminID(adminID)
|
||||
require.NoError(t, scanner.SetFolders([]string{folder}, false))
|
||||
require.NoError(t, scanner.ScanFolders(context.Background()))
|
||||
}
|
||||
|
||||
// waitForCond polls cond until true or the timeout elapses.
|
||||
func waitForCond(t *testing.T, timeout time.Duration, msg string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("condition not met within %v: %s", timeout, msg)
|
||||
}
|
||||
|
||||
func moveTestAdminID(t *testing.T, setup *TestServerSetup) (pgtype.UUID, pgtype.UUID) {
|
||||
t.Helper()
|
||||
admin, err := setup.DB.GetUserByEmail(context.Background(), moveTestAdminEmail)
|
||||
require.NoError(t, err)
|
||||
pgAdmin := pgtype.UUID{Bytes: admin.ID.Bytes, Valid: true}
|
||||
return pgAdmin, pgAdmin
|
||||
}
|
||||
|
||||
// writeTestEbook places an identical dummy epub under folder/Author/.
|
||||
func writeTestEbook(t *testing.T, folder string, content []byte) string {
|
||||
t.Helper()
|
||||
authorDir := filepath.Join(folder, "Author")
|
||||
require.NoError(t, os.MkdirAll(authorDir, 0755))
|
||||
bookPath := filepath.Join(authorDir, moveTestFileName)
|
||||
require.NoError(t, os.WriteFile(bookPath, content, 0644))
|
||||
return bookPath
|
||||
}
|
||||
|
||||
// TestCrossLibraryMovePreservesHistory is the core scenario: a book moves
|
||||
// from library A to library B (same type). The existing row must be repointed
|
||||
// to library B keeping its ID so progress and annotations survive, instead of
|
||||
// creating a duplicate while the old row goes missing.
|
||||
func TestCrossLibraryMovePreservesHistory(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Move Src Library", "ebooks")
|
||||
libB, folderB := createTypedTestLibrary(t, setup, "Move Dst Library", "ebooks")
|
||||
defer deleteLibraries(t, setup, libA, libB)
|
||||
|
||||
content := []byte("cross library move test epub content")
|
||||
writeTestEbook(t, folderA, content)
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
|
||||
var original database.ListMediaItemsByLibraryRow
|
||||
waitForCond(t, 5*time.Second, "book should be imported into library A", func() bool {
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
if err != nil || len(books) != 1 {
|
||||
return false
|
||||
}
|
||||
original = books[0]
|
||||
return true
|
||||
})
|
||||
|
||||
// Give the book real user data at its original location.
|
||||
_, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
|
||||
MediaItemID: original.ID,
|
||||
UserID: adminPgID,
|
||||
CurrentPage: pgtype.Int4{Int32: 12, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: 300, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||
MediaItemID: original.ID,
|
||||
UserID: adminPgID,
|
||||
SelectionText: "a line worth keeping",
|
||||
Color: pgtype.Text{String: "#ffff00", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Move the file: same bytes, new library folder, old copy deleted.
|
||||
writeTestEbook(t, folderB, content)
|
||||
require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName)))
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
|
||||
var moved database.ListMediaItemsByLibraryRow
|
||||
waitForCond(t, 5*time.Second, "book should be repointed into library B", func() bool {
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libB)
|
||||
if err != nil || len(books) != 1 {
|
||||
return false
|
||||
}
|
||||
moved = books[0]
|
||||
return true
|
||||
})
|
||||
|
||||
assert.Equal(t, original.ID, moved.ID, "row ID must be preserved so history follows the book")
|
||||
assert.Equal(t, libB, moved.LibraryID, "row must now belong to library B")
|
||||
assert.Equal(t, filepath.ToSlash(filepath.Join("Author", moveTestFileName)), moved.FilePath, "path must be repointed relative to library B")
|
||||
assert.False(t, moved.ArchivedAt.Valid, "moved row must not be archived")
|
||||
assert.Equal(t, int32(0), moved.MissingScanCount, "moved row must not be marked missing")
|
||||
assert.True(t, moved.LibraryTypeName.Valid && moved.LibraryTypeName.String == "ebooks", "library_type_name must stay truthful after library change")
|
||||
|
||||
// History followed the row.
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: original.ID, UserID: adminPgID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(12), progress.CurrentPage.Int32, "reading progress must survive the move")
|
||||
|
||||
highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: original.ID, UserID: adminPgID})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, highlights, 1, "annotation must survive the move")
|
||||
|
||||
// Library A no longer holds the row: no duplicate, no archived ghost.
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, booksA, "library A must not keep a duplicate row after the move")
|
||||
}
|
||||
|
||||
// TestCrossLibraryCopyStaysIndependent verifies that placing identical
|
||||
// content in two libraries on purpose creates two independent rows: the
|
||||
// second import is NOT blocked, and neither row is repointed or archived.
|
||||
func TestCrossLibraryCopyStaysIndependent(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Copy Lib A", "ebooks")
|
||||
libB, folderB := createTypedTestLibrary(t, setup, "Copy Lib B", "ebooks")
|
||||
defer deleteLibraries(t, setup, libA, libB)
|
||||
|
||||
content := []byte("deliberate duplicate content")
|
||||
writeTestEbook(t, folderA, content)
|
||||
writeTestEbook(t, folderB, content)
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
|
||||
var itemA, itemB database.ListMediaItemsByLibraryRow
|
||||
waitForCond(t, 5*time.Second, "both libraries should hold their own copy", func() bool {
|
||||
booksA, errA := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
booksB, errB := setup.DB.ListMediaItemsByLibrary(ctx, libB)
|
||||
if errA != nil || errB != nil || len(booksA) != 1 || len(booksB) != 1 {
|
||||
return false
|
||||
}
|
||||
itemA, itemB = booksA[0], booksB[0]
|
||||
return true
|
||||
})
|
||||
|
||||
assert.NotEqual(t, itemA.ID, itemB.ID, "deliberate copies must stay independent rows")
|
||||
assert.False(t, itemA.ArchivedAt.Valid && itemB.ArchivedAt.Valid, "no copy should be archived")
|
||||
|
||||
// History on one copy does not leak into the other.
|
||||
_, err := setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
|
||||
MediaItemID: itemA.ID,
|
||||
UserID: adminPgID,
|
||||
CurrentPage: pgtype.Int4{Int32: 7, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: 100, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: itemB.ID, UserID: adminPgID})
|
||||
assert.Error(t, err, "copy B must not inherit copy A's progress")
|
||||
}
|
||||
|
||||
// TestCrossLibraryMoveRejectedForTypeMismatch verifies the type guard: the
|
||||
// cross-library lookup only matches same-type libraries, so a reflowable EPUB
|
||||
// registered in a manga library whose file moves to ANOTHER manga library is
|
||||
// detected as a move but must be rejected by the type rules (manga libraries
|
||||
// only accept fixed-layout/image content). The move is rejected, a processing
|
||||
// issue is recorded, and the file stays untracked rather than being
|
||||
// force-imported.
|
||||
func TestCrossLibraryMoveRejectedForTypeMismatch(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, _ := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Type Src Manga", "manga")
|
||||
libM, folderM := createTypedTestLibrary(t, setup, "Type Dst Manga", "manga")
|
||||
defer deleteLibraries(t, setup, libA, libM)
|
||||
|
||||
content := []byte("reflowable epub that manga libraries must not adopt via move")
|
||||
writeTestEbook(t, folderA, content)
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
|
||||
var original database.ListMediaItemsByLibraryRow
|
||||
waitForCond(t, 5*time.Second, "book should be imported into the first manga library", func() bool {
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
if err != nil || len(books) != 1 {
|
||||
return false
|
||||
}
|
||||
original = books[0]
|
||||
return true
|
||||
})
|
||||
require.Equal(t, "reflowable", original.FormatGroup, "dummy epub must scan as reflowable for this test")
|
||||
|
||||
// Move into the second manga library: same hash, same type, but the
|
||||
// reflowable format violates manga library rules.
|
||||
writeTestEbook(t, folderM, content)
|
||||
require.NoError(t, os.Remove(filepath.Join(folderA, "Author", moveTestFileName)))
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderM)
|
||||
|
||||
// The second manga library must not adopt the item.
|
||||
booksM, err := setup.DB.ListMediaItemsByLibrary(ctx, libM)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, booksM, "reflowable epub must not be repointed between manga libraries")
|
||||
|
||||
// The original row is untouched in the first manga library.
|
||||
after, err := setup.DB.GetMediaItem(ctx, original.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, libA, after.LibraryID, "original row must stay in its library")
|
||||
|
||||
// A processing issue explains the rejection.
|
||||
waitForCond(t, 5*time.Second, "processing issue should be recorded", func() bool {
|
||||
issues, err := setup.DB.ListProcessingIssuesByLibrary(ctx, libM)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if issue.IssueType == "library_type_mismatch" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// TestListHiddenMediaItemsMatchesActiveTwin verifies the archived-page query:
|
||||
// an archived row with an active same-hash twin reports it in match_* columns;
|
||||
// an archived row without a twin reports no match.
|
||||
func TestListHiddenMediaItemsMatchesActiveTwin(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, _ := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Twin Lib A", "ebooks")
|
||||
libB, folderB := createTypedTestLibrary(t, setup, "Twin Lib B", "ebooks")
|
||||
libC, folderC := createTypedTestLibrary(t, setup, "Twin Lib C", "ebooks")
|
||||
defer deleteLibraries(t, setup, libA, libB, libC)
|
||||
|
||||
twinContent := []byte("twin content shared by two libraries")
|
||||
writeTestEbook(t, folderA, twinContent)
|
||||
writeTestEbook(t, folderB, twinContent)
|
||||
lonelyContent := []byte("unique content with no twin anywhere")
|
||||
writeTestEbook(t, folderC, lonelyContent)
|
||||
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
scanTestFolder(t, setup, adminID, folderC)
|
||||
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
twin := booksA[0]
|
||||
|
||||
booksC, err := setup.DB.ListMediaItemsByLibrary(ctx, libC)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksC, 1)
|
||||
lonely := booksC[0]
|
||||
|
||||
// Archive both: the A row now has an active twin in B, the C row has none.
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, twin.ID))
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, lonely.ID))
|
||||
|
||||
rows, err := setup.DB.ListHiddenMediaItems(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
foundTwin, foundLonely := false, false
|
||||
for _, row := range rows {
|
||||
if row.ID == twin.ID {
|
||||
foundTwin = true
|
||||
assert.True(t, row.MatchID.Valid, "twin row must report its active match")
|
||||
if row.MatchID.Valid {
|
||||
assert.Equal(t, findTwinTargetID(t, setup, libB), uuid.UUID(row.MatchID.Bytes).String())
|
||||
}
|
||||
assert.Equal(t, "Test Book", row.MatchTitle)
|
||||
}
|
||||
if row.ID == lonely.ID {
|
||||
foundLonely = true
|
||||
assert.False(t, row.MatchID.Valid, "row without a twin must report no match")
|
||||
}
|
||||
}
|
||||
require.True(t, foundTwin, "twin row should be listed")
|
||||
require.True(t, foundLonely, "lonely row should be listed")
|
||||
}
|
||||
|
||||
// findTwinTargetID returns the single active item in the given library.
|
||||
func findTwinTargetID(t *testing.T, setup *TestServerSetup, libID pgtype.UUID) string {
|
||||
t.Helper()
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(context.Background(), libID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, books, 1)
|
||||
return uuid.UUID(books[0].ID.Bytes).String()
|
||||
}
|
||||
|
||||
// mergeRequest issues POST /api/media-items/{source}/merge.
|
||||
func mergeRequest(t *testing.T, setup *TestServerSetup, token, sourceID string, body map[string]interface{}) *http.Response {
|
||||
t.Helper()
|
||||
payload, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+sourceID+"/merge", bytes.NewBuffer(payload))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp
|
||||
}
|
||||
|
||||
// archiveAndIsolate imports one book into the library and archives it,
|
||||
// simulating a file that was copied elsewhere then deleted from here.
|
||||
func archiveAndIsolate(t *testing.T, setup *TestServerSetup, adminID, adminPgID pgtype.UUID, libID pgtype.UUID, folder, content string) database.ListMediaItemsByLibraryRow {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
writeTestEbook(t, folder, []byte(content))
|
||||
scanTestFolder(t, setup, adminID, folder)
|
||||
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, books, 1)
|
||||
item := books[0]
|
||||
|
||||
// Reading history that must survive the merge.
|
||||
_, err = setup.DB.UpdateReadingProgress(ctx, database.UpdateReadingProgressParams{
|
||||
MediaItemID: item.ID,
|
||||
UserID: adminPgID,
|
||||
CurrentPage: pgtype.Int4{Int32: 42, Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: 200, Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DB.CreateMediaHighlight(ctx, database.CreateMediaHighlightParams{
|
||||
MediaItemID: item.ID,
|
||||
UserID: adminPgID,
|
||||
SelectionText: "annotation on the old copy",
|
||||
Color: pgtype.Text{String: "#00ff00", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, item.ID))
|
||||
return item
|
||||
}
|
||||
|
||||
// TestMergeArchivedItemIntoActiveTwin covers the copy-then-delete-later
|
||||
// workflow: the active copy exists in another library, the archived twin
|
||||
// holds the real reading history. Merging moves progress + annotations onto
|
||||
// the active row and removes the archived row.
|
||||
func TestMergeArchivedItemIntoActiveTwin(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||
libOld, folderOld := createTypedTestLibrary(t, setup, "Merge Old Lib", "ebooks")
|
||||
libNew, folderNew := createTypedTestLibrary(t, setup, "Merge New Lib", "ebooks")
|
||||
defer deleteLibraries(t, setup, libOld, libNew)
|
||||
|
||||
content := "merge workflow epub content"
|
||||
|
||||
// Old library: book + history, then archived (file "deleted").
|
||||
oldItem := archiveAndIsolate(t, setup, adminID, adminPgID, libOld, folderOld, content)
|
||||
|
||||
// New library: the surviving active copy, no history of its own.
|
||||
writeTestEbook(t, folderNew, []byte(content))
|
||||
scanTestFolder(t, setup, adminID, folderNew)
|
||||
booksNew, err := setup.DB.ListMediaItemsByLibrary(ctx, libNew)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksNew, 1)
|
||||
active := booksNew[0]
|
||||
|
||||
oldID := uuid.UUID(oldItem.ID.Bytes).String()
|
||||
activeID := uuid.UUID(active.ID.Bytes).String()
|
||||
resp := mergeRequest(t, setup, setup.Token, oldID, map[string]interface{}{"target_id": activeID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "merge should succeed")
|
||||
|
||||
var mergeResp map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&mergeResp))
|
||||
|
||||
// Archived row is gone.
|
||||
_, err = setup.DB.GetMediaItem(ctx, oldItem.ID)
|
||||
assert.Error(t, err, "archived row must be removed after merge")
|
||||
|
||||
// History landed on the active row.
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{MediaItemID: active.ID, UserID: adminPgID})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(42), progress.CurrentPage.Int32, "progress must merge into the active copy")
|
||||
|
||||
highlights, err := setup.DB.GetMediaHighlights(ctx, database.GetMediaHighlightsParams{MediaItemID: active.ID, UserID: adminPgID})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, highlights, 1, "annotations must merge into the active copy")
|
||||
|
||||
booksOld, err := setup.DB.ListMediaItemsByLibrary(ctx, libOld)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, booksOld, "old library must be empty after merge")
|
||||
}
|
||||
|
||||
func TestMergeArchivedItemRejections(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, adminPgID := moveTestAdminID(t, setup)
|
||||
libA, folderA := createTypedTestLibrary(t, setup, "Reject Lib A", "ebooks")
|
||||
libB, folderB := createTypedTestLibrary(t, setup, "Reject Lib B", "ebooks")
|
||||
defer deleteLibraries(t, setup, libA, libB)
|
||||
|
||||
t.Run("requires admin", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("admin check content"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
itemID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
resp := mergeRequest(t, setup, setup.RegularToken, itemID, map[string]interface{}{"target_id": itemID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects active source", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("active source content"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
writeTestEbook(t, folderB, []byte("active source content"))
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
targetID := findTwinTargetID(t, setup, libB)
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "source is not archived/missing")
|
||||
})
|
||||
|
||||
t.Run("rejects hash mismatch", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("hash mismatch content A"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
source := booksA[0]
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, source.ID))
|
||||
sourceID := uuid.UUID(source.ID.Bytes).String()
|
||||
|
||||
writeTestEbook(t, folderB, []byte("hash mismatch content B - different bytes"))
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
targetID := findTwinTargetID(t, setup, libB)
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "different content must not merge")
|
||||
|
||||
// Both rows still exist.
|
||||
_, err = setup.DB.GetMediaItem(ctx, source.ID)
|
||||
assert.NoError(t, err, "source must survive a rejected merge")
|
||||
})
|
||||
|
||||
t.Run("requires target_id", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("missing target content"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
|
||||
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects self merge", func(t *testing.T) {
|
||||
writeTestEbook(t, folderA, []byte("self merge content"))
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
|
||||
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": sourceID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("rejects archived target", func(t *testing.T) {
|
||||
content := []byte("archived target content")
|
||||
writeTestEbook(t, folderA, content)
|
||||
scanTestFolder(t, setup, adminID, folderA)
|
||||
booksA, err := setup.DB.ListMediaItemsByLibrary(ctx, libA)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksA, 1)
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksA[0].ID))
|
||||
sourceID := uuid.UUID(booksA[0].ID.Bytes).String()
|
||||
|
||||
writeTestEbook(t, folderB, content)
|
||||
scanTestFolder(t, setup, adminID, folderB)
|
||||
booksB, err := setup.DB.ListMediaItemsByLibrary(ctx, libB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, booksB, 1)
|
||||
require.NoError(t, setup.DB.ArchiveMediaItem(ctx, booksB[0].ID))
|
||||
targetID := uuid.UUID(booksB[0].ID.Bytes).String()
|
||||
|
||||
resp := mergeRequest(t, setup, setup.Token, sourceID, map[string]interface{}{"target_id": targetID})
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "target must be active")
|
||||
})
|
||||
|
||||
_ = adminPgID
|
||||
}
|
||||
|
||||
// TestLibraryTypeNameTriggerOnLibraryChange proves the schema trigger keeps
|
||||
// media_items.library_type_name in sync when a row is repointed to a library
|
||||
// of a different type (the INSERT-only trigger previously left it stale).
|
||||
func TestLibraryTypeNameTriggerOnLibraryChange(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
adminID, _ := moveTestAdminID(t, setup)
|
||||
libE, folderE := createTypedTestLibrary(t, setup, "Trigger Ebooks", "ebooks")
|
||||
libC, _ := createTypedTestLibrary(t, setup, "Trigger Comics", "comics")
|
||||
defer deleteLibraries(t, setup, libE, libC)
|
||||
|
||||
writeTestEbook(t, folderE, []byte("trigger check content"))
|
||||
scanTestFolder(t, setup, adminID, folderE)
|
||||
|
||||
books, err := setup.DB.ListMediaItemsByLibrary(ctx, libE)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, books, 1)
|
||||
item := books[0]
|
||||
require.True(t, item.LibraryTypeName.Valid && item.LibraryTypeName.String == "ebooks")
|
||||
|
||||
require.NoError(t, setup.DB.MoveMediaItemToLibrary(ctx, database.MoveMediaItemToLibraryParams{
|
||||
ID: item.ID,
|
||||
LibraryID: libC,
|
||||
FilePath: "Author/Test Book.epub",
|
||||
FileSize: pgtype.Int8{Int64: 27, Valid: true},
|
||||
}))
|
||||
|
||||
after, err := setup.DB.GetMediaItem(ctx, item.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, libC, after.LibraryID)
|
||||
assert.True(t, after.LibraryTypeName.Valid && after.LibraryTypeName.String == "comics",
|
||||
"trigger must refresh library_type_name when library_id changes")
|
||||
}
|
||||
@@ -69,15 +69,16 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
assert.IsType(s.T(), float64(0), section["priority"], "priority should be number")
|
||||
}
|
||||
|
||||
// Verify all system collections exist
|
||||
assert.Contains(s.T(), sectionMap, "continue-reading")
|
||||
assert.Contains(s.T(), sectionMap, "recently-added")
|
||||
assert.Contains(s.T(), sectionMap, "recently-read")
|
||||
assert.Contains(s.T(), sectionMap, "not-started")
|
||||
assert.Contains(s.T(), sectionMap, "continue-series")
|
||||
// Verify all system collections exist (names are the section ids;
|
||||
// the canonical seed names are Title-case).
|
||||
assert.Contains(s.T(), sectionMap, "Continue Reading")
|
||||
assert.Contains(s.T(), sectionMap, "Recently Added")
|
||||
assert.Contains(s.T(), sectionMap, "Recently Read")
|
||||
assert.Contains(s.T(), sectionMap, "Not Started")
|
||||
assert.Contains(s.T(), sectionMap, "Continue Series")
|
||||
|
||||
// Verify continue-reading is a system collection
|
||||
continueReading := sectionMap["continue-reading"]
|
||||
continueReading := sectionMap["Continue Reading"]
|
||||
assert.True(s.T(), continueReading["is_system"].(bool), "continue-reading should be system collection")
|
||||
assert.Equal(s.T(), "📖", continueReading["icon"], "icon should match")
|
||||
}
|
||||
@@ -85,6 +86,8 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_EndToEndFlow() {
|
||||
func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
|
||||
token := s.setup.Token
|
||||
|
||||
// No library_id = the All Libraries view: the endpoint aggregates
|
||||
// sections across every library (200), it does not 400.
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/sections", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
@@ -95,7 +98,14 @@ func (s *DashboardIntegrationTestSuite) TestGetSections_MissingLibraryID() {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var response map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(s.T(), err)
|
||||
sections, ok := response["sections"].([]interface{})
|
||||
require.True(s.T(), ok, "sections should be an array")
|
||||
assert.NotEmpty(s.T(), sections, "All Libraries view should aggregate sections")
|
||||
}
|
||||
|
||||
func (s *DashboardIntegrationTestSuite) TestGetSections_InvalidLibraryID() {
|
||||
@@ -258,6 +268,263 @@ func (s *DashboardIntegrationTestSuite) TestRestoreSystemCollection_ValidNames()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_GlobalScope() {
|
||||
token := s.setup.Token
|
||||
|
||||
// Absent library_id (JSON null after omit) = the global All Libraries
|
||||
// scope: stored in user_dashboard_preferences_global, one row per user.
|
||||
reqBody := map[string]interface{}{
|
||||
"hidden_collections": []string{"not-started"},
|
||||
"collection_order": []string{"continue-reading", "recently-added"},
|
||||
"items_per_section": 15,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Second global save with different values: must UPDATE the same row
|
||||
// (the old ON CONFLICT (user_id, library_id) never fired for NULLs and
|
||||
// would have minted a duplicate per save).
|
||||
reqBody["items_per_section"] = 25
|
||||
body, _ = json.Marshal(reqBody)
|
||||
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Reading prefs without library_id returns the global row (not some
|
||||
// arbitrary library's).
|
||||
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var prefs map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&prefs)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), float64(25), prefs["items_per_section"], "global row should carry the second save")
|
||||
}
|
||||
|
||||
func (s *DashboardIntegrationTestSuite) TestUpdatePreferences_EmptyLibraryIDRejected() {
|
||||
token := s.setup.Token
|
||||
|
||||
// Empty string is a LOST value (a client bug), never a scope — must
|
||||
// stay a loud 400 rather than silently writing the global scope.
|
||||
reqBody := map[string]interface{}{
|
||||
"library_id": "",
|
||||
"hidden_collections": []string{},
|
||||
"collection_order": []string{},
|
||||
"items_per_section": 20,
|
||||
}
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *DashboardIntegrationTestSuite) TestGetPreferences_GlobalAndPerLibraryIndependent() {
|
||||
token := s.setup.Token
|
||||
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Prefs Scope Test Library", false)
|
||||
|
||||
// Global scope: 15.
|
||||
globalBody, _ := json.Marshal(map[string]interface{}{
|
||||
"hidden_collections": []string{},
|
||||
"collection_order": []string{},
|
||||
"items_per_section": 15,
|
||||
})
|
||||
req, _ := http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(globalBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Per-library scope: 30. Must not bleed into global or vice versa.
|
||||
libBody, _ := json.Marshal(map[string]interface{}{
|
||||
"library_id": libraryID,
|
||||
"hidden_collections": []string{},
|
||||
"collection_order": []string{},
|
||||
"items_per_section": 30,
|
||||
})
|
||||
req, _ = http.NewRequest("PUT", s.setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(libBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
resp.Body.Close()
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Per-library read.
|
||||
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences?library_id="+libraryID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
var libPrefs map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&libPrefs)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), float64(30), libPrefs["items_per_section"], "per-library scope should read its own row")
|
||||
|
||||
// Global read.
|
||||
req, _ = http.NewRequest("GET", s.setup.Server.URL+"/api/dashboard/preferences", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err = client.Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
var globalPrefs map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&globalPrefs)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), float64(15), globalPrefs["items_per_section"], "global scope should stay independent")
|
||||
}
|
||||
|
||||
// TestDeleteSystemCollection_TombstonesAndRestores pins the system-
|
||||
// collection permanence contract: DELETE hides the section everywhere
|
||||
// (tombstone) and restore brings back the EXACT SAME row (id stable),
|
||||
// never a fresh one.
|
||||
func newHTTPClient() *http.Client { return &http.Client{} }
|
||||
func (s *DashboardIntegrationTestSuite) TestDeleteSystemCollection_TombstonesAndRestores() {
|
||||
token := s.setup.Token
|
||||
libraryID := createTestLibraryWithFolder(s.T(), s.setup.Server, token, "Test Library", false)
|
||||
|
||||
collectionID := func(sectionID string) string {
|
||||
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := newHTTPClient().Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||
require.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
var response map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
|
||||
for _, sec := range response["sections"].([]interface{}) {
|
||||
section := sec.(map[string]interface{})
|
||||
if section["id"] == sectionID {
|
||||
return section["collection_id"].(string)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
hasSection := func(sectionID string) bool {
|
||||
url := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", s.setup.Server.URL, libraryID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := newHTTPClient().Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body)
|
||||
var response map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(resp.Body).Decode(&response))
|
||||
for _, sec := range response["sections"].([]interface{}) {
|
||||
if sec.(map[string]interface{})["id"] == sectionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
idBefore := collectionID("Continue Reading")
|
||||
require.NotEmpty(s.T(), idBefore, "Continue Reading should exist")
|
||||
|
||||
// DELETE a system collection = tombstone (204, behaves like a delete).
|
||||
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+idBefore, nil)
|
||||
delReq.Header.Set("Authorization", "Bearer "+token)
|
||||
delResp, err := newHTTPClient().Do(delReq)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
|
||||
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
|
||||
|
||||
// Invisible on the dashboard.
|
||||
assert.False(s.T(), hasSection("Continue Reading"), "tombstoned section must vanish")
|
||||
|
||||
// Restore brings back the EXACT SAME row.
|
||||
restoreBody, _ := json.Marshal(map[string]string{"collection_name": "Continue Reading"})
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/dashboard/restore-system-collection", bytes.NewBuffer(restoreBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
restoreResp, err := newHTTPClient().Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(restoreResp.Body)
|
||||
require.Equal(s.T(), http.StatusOK, restoreResp.StatusCode)
|
||||
|
||||
idAfter := collectionID("Continue Reading")
|
||||
assert.Equal(s.T(), idBefore, idAfter, "restore must resurrect the SAME row id")
|
||||
assert.True(s.T(), hasSection("Continue Reading"))
|
||||
}
|
||||
|
||||
// TestDeleteUserCollection_HardDelete pins the user-collection contract:
|
||||
// user collections hard-delete exactly as before (no tombstone, no restore).
|
||||
func (s *DashboardIntegrationTestSuite) TestDeleteUserCollection_HardDelete() {
|
||||
token := s.setup.Token
|
||||
|
||||
createBody, _ := json.Marshal(map[string]interface{}{"name": "Test User Shelf", "type": "ebooks"})
|
||||
req, _ := http.NewRequest("POST", s.setup.Server.URL+"/api/collections", bytes.NewBuffer(createBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
createResp, err := newHTTPClient().Do(req)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(createResp.Body)
|
||||
require.Equal(s.T(), http.StatusCreated, createResp.StatusCode)
|
||||
|
||||
var created map[string]interface{}
|
||||
require.NoError(s.T(), json.NewDecoder(createResp.Body).Decode(&created))
|
||||
collectionID := created["id"].(string)
|
||||
|
||||
delReq, _ := http.NewRequest("DELETE", s.setup.Server.URL+"/api/collections/"+collectionID, nil)
|
||||
delReq.Header.Set("Authorization", "Bearer "+token)
|
||||
delResp, err := newHTTPClient().Do(delReq)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(delResp.Body)
|
||||
assert.Equal(s.T(), http.StatusNoContent, delResp.StatusCode)
|
||||
|
||||
listReq, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/collections", nil)
|
||||
listReq.Header.Set("Authorization", "Bearer "+token)
|
||||
listResp, err := newHTTPClient().Do(listReq)
|
||||
require.NoError(s.T(), err)
|
||||
defer func(Body io.ReadCloser) { _ = Body.Close() }(listResp.Body)
|
||||
var list struct {
|
||||
Collections []map[string]interface{} `json:"collections"`
|
||||
}
|
||||
require.NoError(s.T(), json.NewDecoder(listResp.Body).Decode(&list))
|
||||
for _, c := range list.Collections {
|
||||
assert.NotEqual(s.T(), collectionID, c["id"], "hard-deleted user collection must be gone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardIntegrationTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(DashboardIntegrationTestSuite))
|
||||
}
|
||||
@@ -252,6 +252,88 @@ func TestListPendingRegistrations(t *testing.T) {
|
||||
assert.NotNil(t, pending, "Pending registrations should not be nil")
|
||||
}
|
||||
|
||||
// Regression test for the "approve returns 500 after app reinstall" bug:
|
||||
// an app reinstall that preserves data (e.g. Android Studio installDebug
|
||||
// over an existing install) re-registers with the SAME device_identifier,
|
||||
// and the blind INSERT in ApproveDevice hit the UNIQUE(device_identifier)
|
||||
// constraint. Re-approval must be idempotent: same row (id unchanged),
|
||||
// rotated token, old token invalidated.
|
||||
func TestApproveDeviceReapprovalRotatesToken(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
|
||||
identifier := fmt.Sprintf("repro-reinstall-%s", uuid.New().String())
|
||||
|
||||
registerAndApprove := func() string {
|
||||
regRequest := map[string]interface{}{
|
||||
"device_name": "Reinstall Device",
|
||||
"device_type": "mobile",
|
||||
"device_identifier": identifier,
|
||||
}
|
||||
regBody, _ := json.Marshal(regRequest)
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/devices/register", bytes.NewReader(regBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusCreated, rec.Code, "Should initiate registration")
|
||||
|
||||
var regResponse map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), ®Response)
|
||||
registrationID, ok := regResponse["registration_id"].(string)
|
||||
require.True(t, ok, "Should have registration_id")
|
||||
|
||||
req = httptest.NewRequest("GET", fmt.Sprintf("/api/devices/approve/%s", registrationID), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
rec = httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code,
|
||||
"Approve must succeed even when the identifier already exists (was the reinstall 500)")
|
||||
|
||||
// The status response is single-use and carries the credentials.
|
||||
statusBody, _ := json.Marshal(map[string]string{"registration_id": registrationID})
|
||||
req = httptest.NewRequest("POST", "/api/devices/register/status", bytes.NewReader(statusBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec = httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var statusResponse map[string]interface{}
|
||||
json.Unmarshal(rec.Body.Bytes(), &statusResponse)
|
||||
require.Equal(t, "approved", statusResponse["status"])
|
||||
token, ok := statusResponse["auth_token"].(string)
|
||||
require.True(t, ok, "Should have auth_token")
|
||||
require.NotEmpty(t, token)
|
||||
return token
|
||||
}
|
||||
|
||||
token1 := registerAndApprove()
|
||||
token2 := registerAndApprove()
|
||||
|
||||
assert.NotEqual(t, token1, token2, "Re-approval must rotate the auth token (fresh install = fresh credentials)")
|
||||
|
||||
devices, err := setup.DB.ListDevicesByUser(context.Background(),
|
||||
pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
rows := 0
|
||||
for _, d := range devices {
|
||||
if d.DeviceIdentifier == identifier {
|
||||
rows++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, rows, "Re-approval must reuse the existing devices row, not duplicate it")
|
||||
|
||||
// The rotated-in token must be the live one.
|
||||
var live *database.Devices
|
||||
for i := range devices {
|
||||
if devices[i].DeviceIdentifier == identifier {
|
||||
live = &devices[i]
|
||||
}
|
||||
}
|
||||
require.NotNil(t, live)
|
||||
assert.Equal(t, token2, live.AuthToken, "The devices row must carry the newly rotated token")
|
||||
}
|
||||
|
||||
func TestApproveDeviceRegistration(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
@@ -430,7 +512,7 @@ func TestRegenerateDeviceToken_Forbidden(t *testing.T) {
|
||||
defer setup.Server.Close()
|
||||
|
||||
// Create device for user 1
|
||||
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-device")
|
||||
device1 := setup.CreateDevice(t, "User1 Device", "koreader", "user1-test-device")
|
||||
|
||||
// Create a second user with different credentials
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
|
||||
t.Run("POST /api/saved-filters creates filter", func(t *testing.T) {
|
||||
reqBody := map[string]interface{}{
|
||||
"name": "My Sci-Fi Books",
|
||||
"name": "My Sci-Fi Test Books",
|
||||
"resource_type": "media-items",
|
||||
"filters": map[string]string{
|
||||
"genre_filter": "Science Fiction",
|
||||
@@ -71,7 +71,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
var filter map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&filter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "My Sci-Fi Books", filter["name"])
|
||||
assert.Equal(t, "My Sci-Fi Test Books", filter["name"])
|
||||
assert.Equal(t, "media-items", filter["resource_type"])
|
||||
assert.NotEmpty(t, filter["id"])
|
||||
// Filters field will be JSON object (JSONB as raw bytes)
|
||||
@@ -113,7 +113,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
t.Run("PUT /api/saved-filters/:id updates filter", func(t *testing.T) {
|
||||
// Create filter first
|
||||
createReq := map[string]interface{}{
|
||||
"name": "Original Name",
|
||||
"name": "Original Test Name",
|
||||
"resource_type": "media-items",
|
||||
"filters": map[string]string{"search": "original"},
|
||||
}
|
||||
@@ -134,11 +134,13 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
|
||||
// Update filter
|
||||
updateReq := map[string]interface{}{
|
||||
"name": "Updated Name",
|
||||
"name": "Updated Test Name",
|
||||
"resource_type": "media-items",
|
||||
"filters": map[string]string{"search": "updated"},
|
||||
}
|
||||
@@ -159,7 +161,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
var updatedFilter map[string]interface{}
|
||||
err = json.NewDecoder(updateResp.Body).Decode(&updatedFilter)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Updated Name", updatedFilter["name"])
|
||||
assert.Equal(t, "Updated Test Name", updatedFilter["name"])
|
||||
})
|
||||
|
||||
t.Run("DELETE /api/saved-filters/:id deletes filter", func(t *testing.T) {
|
||||
@@ -184,7 +186,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
|
||||
// Delete filter
|
||||
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
@@ -223,7 +227,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
|
||||
// Admin user tries to delete regular user's filter
|
||||
deleteHTTP, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
@@ -260,7 +266,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
|
||||
// Now retrieve the filter by ID
|
||||
getReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
@@ -324,7 +332,7 @@ func TestSavedFilters(t *testing.T) {
|
||||
t.Run("GET /api/saved-filters/:id from different user returns 404", func(t *testing.T) {
|
||||
// Create filter with admin user (setup.Token)
|
||||
reqBody := map[string]interface{}{
|
||||
"name": "Admin Filter",
|
||||
"name": "Admin Test Filter",
|
||||
"resource_type": "media-items",
|
||||
"filters": map[string]string{"search": "test"},
|
||||
}
|
||||
@@ -340,7 +348,9 @@ func TestSavedFilters(t *testing.T) {
|
||||
var createdFilter map[string]interface{}
|
||||
err = json.NewDecoder(createResp.Body).Decode(&createdFilter)
|
||||
require.NoError(t, err)
|
||||
filterID := createdFilter["id"].(string)
|
||||
filterIDRaw, ok := createdFilter["id"]
|
||||
require.True(t, ok, "create filter response should carry id: %v", createdFilter)
|
||||
filterID := filterIDRaw.(string)
|
||||
// Try to access with regular user (setup.RegularToken)
|
||||
httpReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/saved-filters/"+filterID, nil)
|
||||
httpReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -28,7 +30,21 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("GET /api/libraries/browse - admin can browse directories", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/browse?path=/tmp", nil)
|
||||
// Self-created fixture dir: the container's /tmp may be empty, and
|
||||
// the endpoint lists DIRECTORIES only — seed two so the listing is
|
||||
// deterministic regardless of environment.
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"alpha", "beta"} {
|
||||
if err := os.Mkdir(filepath.Join(root, name), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// A decoy file must NOT appear in the directory listing.
|
||||
if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/libraries/browse?path="+root, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
@@ -41,8 +57,8 @@ func TestBrowseLibraryFoldersEndpoint(t *testing.T) {
|
||||
}
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &data)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "/tmp", data.CurrentPath)
|
||||
assert.NotEmpty(t, data.Directories)
|
||||
assert.Equal(t, filepath.Clean(root), data.CurrentPath)
|
||||
assert.ElementsMatch(t, []string{"alpha", "beta"}, data.Directories)
|
||||
})
|
||||
|
||||
t.Run("GET /api/libraries/browse - blocks path traversal attempts", func(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -151,11 +152,15 @@ func TestMediaItemISBNNormalization(t *testing.T) {
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Unique file_path per case: CreateMediaItem upserts ON
|
||||
// CONFLICT (library_id, file_path), so a constant path would
|
||||
// return the FIRST row's ISBN for every later case.
|
||||
slug := strings.ReplaceAll(strings.ToLower(tc.name), " ", "-")
|
||||
payload := map[string]interface{}{
|
||||
"title": fmt.Sprintf("Test Book %s", tc.name),
|
||||
"isbn": tc.input,
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": "/test/" + slug + ".epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -203,7 +208,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
payload := map[string]interface{}{
|
||||
"title": "Book without ISBN",
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": "/test/book-without-isbn.epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -228,7 +233,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
"title": "Multi-Hyphen ISBN",
|
||||
"isbn": "978-0-306-40615-7",
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": "/test/multi-hyphen-isbn.epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -258,7 +263,7 @@ func TestMediaItemISBNEdgeCases(t *testing.T) {
|
||||
"title": "Trailing Hyphen ISBN",
|
||||
"isbn": "978-0-596-00965-2",
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": "/test/trailing-hyphen-isbn.epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -292,13 +297,15 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
libID := createTestLibrary(t, setup.Server, token, "test-pagination-lib")
|
||||
addFolderToLibrary(t, setup, libID, "/app/uploads")
|
||||
|
||||
// Create some test media items
|
||||
// Create some test media items. Unique file_path per item: the create
|
||||
// endpoint upserts ON CONFLICT (library_id, file_path), so a constant
|
||||
// path would collapse all five creates onto one row.
|
||||
for i := 1; i <= 5; i++ {
|
||||
payload := map[string]interface{}{
|
||||
"title": fmt.Sprintf("Book %d", i),
|
||||
"isbn": fmt.Sprintf("978030640615%d", i),
|
||||
"library_id": libID,
|
||||
"file_path": "/test/path.epub",
|
||||
"file_path": fmt.Sprintf("/test/book-%d.epub", i),
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
@@ -408,6 +415,62 @@ func TestMediaItemsPagination(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMediaItemCreateDuplicatePath pins the create endpoint's duplicate
|
||||
// behavior: a POST with a (library_id, file_path) that already exists
|
||||
// answers 200 OK with the EXISTING item and duplicate=true — never a
|
||||
// 201 with the old row pretending to be new, and nothing overwritten.
|
||||
func TestMediaItemCreateDuplicatePath(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
token := setup.Token
|
||||
libID := createTestLibrary(t, setup.Server, token, "test-dup-create-lib")
|
||||
addFolderToLibrary(t, setup, libID, "/app/uploads")
|
||||
|
||||
createPayload := func(title string) []byte {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"title": title,
|
||||
"author": "First Author",
|
||||
"isbn": "9780306406157",
|
||||
"library_id": libID,
|
||||
"file_path": "/test/dup-target.epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
})
|
||||
return body
|
||||
}
|
||||
|
||||
post := func(body []byte) (*http.Response, []byte) {
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp, raw
|
||||
}
|
||||
|
||||
resp, raw := post(createPayload("Original Title"))
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, "first create should be 201")
|
||||
var created map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(raw, &created))
|
||||
originalID := created["id"].(string)
|
||||
|
||||
resp, raw = post(createPayload("Different Title"))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "duplicate create should be 200, not a 201 lie")
|
||||
|
||||
var dup map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(raw, &dup))
|
||||
assert.Equal(t, true, dup["duplicate"], "response must flag the duplicate")
|
||||
assert.Equal(t, originalID, dup["item"].(map[string]interface{})["id"].(string),
|
||||
"duplicate response carries the existing item")
|
||||
assert.Equal(t, "Original Title", dup["item"].(map[string]interface{})["title"],
|
||||
"the second create's payload must NOT overwrite the existing row")
|
||||
}
|
||||
|
||||
// TestMediaItemLibraryRequirement tests that media items require a library
|
||||
func TestMediaItemLibraryRequirement(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestMediaNoteLengthLimits verifies the note content validator cap
|
||||
// (internal/handlers/media.go): content up to 100,000 chars is accepted,
|
||||
// longer content is rejected with 400.
|
||||
func TestMediaNoteLengthLimits(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
token := setup.Token
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
|
||||
postNote := func(content string) int {
|
||||
payload := map[string]interface{}{
|
||||
"content": content,
|
||||
"position": "page:1",
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/media-items/"+mediaItemID+"/notes", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
t.Run("50k-char note accepted", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("a", 50000)))
|
||||
})
|
||||
|
||||
t.Run("100k-char note accepted at boundary", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusCreated, postNote(strings.Repeat("b", 100000)))
|
||||
})
|
||||
|
||||
t.Run("over-limit note rejected", func(t *testing.T) {
|
||||
assert.Equal(t, http.StatusBadRequest, postNote(strings.Repeat("c", 100001)))
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -435,3 +436,115 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
|
||||
t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
|
||||
// catalog is a navigation feed with an "All Books" entry plus one folder per
|
||||
// visible library, the scoped catalog only lists that library's books, and
|
||||
// the library-scoped search parameter restricts results to that library.
|
||||
// TestOPDSLibraryFolders covers the library-folder navigation feed: the root
|
||||
// catalog is a navigation feed with an "All Books" entry plus one folder per
|
||||
// visible library, the scoped catalog only lists that library's books, and
|
||||
// the library-scoped search parameter restricts results to that library.
|
||||
// NOTE: library names deliberately avoid the word "test" — setupTestServer's
|
||||
// setup-time cleanup deletes every library whose name contains it (and
|
||||
// setupDeviceTest re-runs that cleanup mid-test).
|
||||
func TestOPDSLibraryFolders(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
client := &http.Client{}
|
||||
|
||||
// Two libraries, one distinct book each.
|
||||
lib1Resp := createLibrary(t, client, setup, "OPDS Folder Lib Alpha")
|
||||
lib2Resp := createLibrary(t, client, setup, "OPDS Folder Lib Beta")
|
||||
lib1ID := lib1Resp["id"].(string)
|
||||
lib2ID := lib2Resp["id"].(string)
|
||||
// Real directories: the folder API validates that the path exists on the
|
||||
// host running the server (container path /app/uploads won't exist when
|
||||
// tests run on the host).
|
||||
addFolderToLibrary(t, setup, lib1ID, t.TempDir())
|
||||
addFolderToLibrary(t, setup, lib2ID, t.TempDir())
|
||||
createTestMediaItemIDInLibrary(t, client, setup, lib1ID, "OPDS Folder Book 1")
|
||||
createTestMediaItemIDInLibrary(t, client, setup, lib2ID, "OPDS Folder Book 2")
|
||||
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
device := deviceSetup.CreateDevice(t, "OPDS Folders Device", "koreader", "opds-folders-"+uuid.NewString())
|
||||
base := setup.Server.URL + "/opds/devices/" + device.ID.String()
|
||||
|
||||
// Explicit cleanup: these names don't contain "test", so the harness
|
||||
// cleanup skips them; remove them ourselves. Registered as a defer AFTER
|
||||
// the setup.Close defer so LIFO ordering runs it while the pool is open.
|
||||
defer deleteLibraries(t, setup,
|
||||
pgtype.UUID{Bytes: uuid.MustParse(lib1ID), Valid: true},
|
||||
pgtype.UUID{Bytes: uuid.MustParse(lib2ID), Valid: true})
|
||||
|
||||
getFeed := func(t *testing.T, url string) (int, string, string) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+device.AuthToken)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
return resp.StatusCode, contentType, string(bodyBytes)
|
||||
}
|
||||
|
||||
t.Run("root catalog is a navigation feed with All Books and library folders", func(t *testing.T) {
|
||||
status, contentType, body := getFeed(t, base+"/catalog")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, contentType, "kind=navigation")
|
||||
assert.Contains(t, body, "All Books")
|
||||
assert.Contains(t, body, "OPDS Folder Lib Alpha")
|
||||
assert.Contains(t, body, "OPDS Folder Lib Beta")
|
||||
assert.Contains(t, body, "/library/"+lib1ID+"/catalog")
|
||||
assert.Contains(t, body, "/library/"+lib2ID+"/catalog")
|
||||
// The nav feed itself must not inline book entries.
|
||||
assert.NotContains(t, body, "OPDS Folder Book 1")
|
||||
})
|
||||
|
||||
t.Run("all=1 keeps the flat cross-library catalog", func(t *testing.T) {
|
||||
status, contentType, body := getFeed(t, base+"/catalog?all=1")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, contentType, "kind=acquisition")
|
||||
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||
assert.Contains(t, body, "OPDS Folder Book 2")
|
||||
})
|
||||
|
||||
t.Run("scoped catalog lists only its own library's books", func(t *testing.T) {
|
||||
status, contentType, body := getFeed(t, base+"/library/"+lib1ID+"/catalog")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, contentType, "kind=acquisition")
|
||||
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||
assert.NotContains(t, body, "OPDS Folder Book 2")
|
||||
// Scoped search discovery: template pins the library.
|
||||
assert.Contains(t, body, "library_id="+lib1ID)
|
||||
})
|
||||
|
||||
t.Run("scoped catalog rejects libraries outside device visibility", func(t *testing.T) {
|
||||
status, _, _ := getFeed(t, base+"/library/11111111-2222-3333-4444-555555555555/catalog")
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
})
|
||||
|
||||
t.Run("scoped search restricts results to the library", func(t *testing.T) {
|
||||
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder&library_id="+lib2ID)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, body, "OPDS Folder Book 2")
|
||||
assert.NotContains(t, body, "OPDS Folder Book 1")
|
||||
})
|
||||
|
||||
t.Run("scoped search rejects inaccessible library", func(t *testing.T) {
|
||||
status, _, _ := getFeed(t, base+"/search?q=OPDS&library_id=11111111-2222-3333-4444-555555555555")
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
})
|
||||
|
||||
t.Run("global search labels entries with their library", func(t *testing.T) {
|
||||
status, _, body := getFeed(t, base+"/search?q=OPDS+Folder")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, body, "OPDS Folder Book 1")
|
||||
assert.Contains(t, body, "OPDS Folder Book 2")
|
||||
assert.Contains(t, body, "OPDS Folder Lib Alpha")
|
||||
assert.Contains(t, body, "OPDS Folder Lib Beta")
|
||||
})
|
||||
}
|
||||
@@ -16,8 +16,7 @@ func TestProcessingIssuesListAuthentication(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Auth Library", "manga")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Auth Library", "manga")
|
||||
|
||||
t.Run("GET /api/libraries/:id/issues/list - No authentication", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
||||
@@ -55,8 +54,7 @@ func TestProcessingIssuesListResponseStructure(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Structure Library", "comics")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Structure Library", "comics")
|
||||
|
||||
t.Run("Returns JSON array for empty library", func(t *testing.T) {
|
||||
token := setup.Token
|
||||
@@ -163,8 +161,7 @@ func TestProcessingIssueStatsAuthentication(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Auth Library", "manga")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Auth Library", "manga")
|
||||
|
||||
t.Run("GET /api/libraries/:id/issues/stats - No authentication", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
|
||||
@@ -202,8 +199,7 @@ func TestProcessingIssueStatsResponseStructure(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Stats Structure Library", "comics")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Stats Structure Library", "comics")
|
||||
|
||||
t.Run("Returns correct structure for empty library", func(t *testing.T) {
|
||||
token := setup.Token
|
||||
@@ -343,11 +339,9 @@ func TestProcessingIssuesCrossLibraryIsolation(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create two different libraries with different types
|
||||
deviceSetup1 := setupDeviceTest(t)
|
||||
library1ID := deviceSetup1.CreateLibrary(t, "Isolation Test Library 1", "manga")
|
||||
library1ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 1", "manga")
|
||||
|
||||
deviceSetup2 := setupDeviceTest(t)
|
||||
library2ID := deviceSetup2.CreateLibrary(t, "Isolation Test Library 2", "comics")
|
||||
library2ID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Isolation Test Library 2", "comics")
|
||||
|
||||
token := setup.Token
|
||||
|
||||
@@ -431,8 +425,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
|
||||
|
||||
for _, lt := range libraryTypes {
|
||||
t.Run(lt.name+" - list endpoint works", func(t *testing.T) {
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
@@ -448,8 +441,7 @@ func TestProcessingIssuesDifferentLibraryTypes(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run(lt.name+" - stats endpoint works", func(t *testing.T) {
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test "+lt.name, lt.libraryType)
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test "+lt.name, lt.libraryType)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/stats", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
@@ -474,8 +466,7 @@ func TestProcessingIssuesInvalidTokens(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
// Create a test library
|
||||
deviceSetup := setupDeviceTest(t)
|
||||
libraryID := deviceSetup.CreateLibrary(t, "Test Token Library", "ebooks")
|
||||
libraryID := createLibraryDirect(t, setup.DB, getTestUserID(t, setup.DB), "Test Token Library", "ebooks")
|
||||
|
||||
t.Run("List endpoint rejects invalid token", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libraryID+"/issues/list", nil)
|
||||
|
||||
@@ -656,3 +656,180 @@ func TestProgressWeb_EdgeCases(t *testing.T) {
|
||||
assert.InDelta(t, 1.5, progress.ZoomLevel.Float64, 0.01)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeletedUserTokenRejected pins the ghost-session guard: a
|
||||
// signature-valid JWT for a user that has been DELETED must stop
|
||||
// authenticating (401 user_not_found), not serve a phantom session.
|
||||
// Before the guard, deleted users kept full API access until token
|
||||
// expiry (observed live on hardware 2026-10-09).
|
||||
func TestDeletedUserTokenRejected(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a dedicated user, then log in as it to get a fresh token.
|
||||
createResp := doReq(t, "POST", setup.Server.URL+"/api/auth/register", map[string]interface{}{
|
||||
"email": "ghost-test@tests.bookhoard.internal",
|
||||
"username": "ghosttest",
|
||||
"password": "Ghost@Pass123!",
|
||||
}, setup.Token)
|
||||
defer createResp.Body.Close()
|
||||
if createResp.StatusCode != http.StatusCreated {
|
||||
t.Skipf("registration unavailable (status %d)", createResp.StatusCode)
|
||||
}
|
||||
|
||||
loginResp := doReq(t, "POST", setup.Server.URL+"/api/auth/login", map[string]interface{}{
|
||||
"login": "ghost-test@tests.bookhoard.internal",
|
||||
"password": "Ghost@Pass123!",
|
||||
}, "")
|
||||
defer loginResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, loginResp.StatusCode)
|
||||
var loginBody struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
|
||||
require.NotEmpty(t, loginBody.AccessToken)
|
||||
|
||||
ghostToken := loginBody.AccessToken
|
||||
|
||||
// Sanity: the token works while the user exists (user-scoped endpoint
|
||||
// a freshly-registered regular user can access).
|
||||
okResp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
|
||||
defer okResp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, okResp.StatusCode, "token must work while the user exists")
|
||||
|
||||
// Delete the user directly via the DB for determinism (same query the
|
||||
// suite's own cleanup uses).
|
||||
ghostID := ""
|
||||
users, err := setup.DB.ListUsers(ctx)
|
||||
require.NoError(t, err)
|
||||
for _, u := range users {
|
||||
if u.Email == "ghost-test@tests.bookhoard.internal" {
|
||||
ghostID = uuid.UUID(u.ID.Bytes).String()
|
||||
require.NoError(t, setup.DB.DeleteUser(ctx, u.ID))
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, ghostID, "ghost user row must exist before deletion")
|
||||
|
||||
// The same token must now be rejected with 401 user_not_found.
|
||||
resp := doReq(t, "GET", setup.Server.URL+"/api/collections", nil, ghostToken)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, "ghost token must be rejected")
|
||||
|
||||
var errBody map[string]string
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&errBody))
|
||||
assert.Contains(t, errBody["message"], "user_not_found")
|
||||
}
|
||||
|
||||
// TestProgressWeb_LocatorCoherence pins the locator coherence rule: a
|
||||
// percentage-only write that DECLARES no locator (ClearLocator) must clear
|
||||
// any inherited stale anchor instead of re-pairing the new percentage with
|
||||
// the previous writer's CFI/context — the incoherent row shape that fed the
|
||||
// client-side far-anchor restore floor. Request-carried fields always win.
|
||||
func TestProgressWeb_LocatorCoherence(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
ctx := context.Background()
|
||||
userID := getTestUserID(t, setup.DB)
|
||||
pgUserID := pgtype.UUID{Bytes: userID, Valid: true}
|
||||
|
||||
seedAnchor := func(t *testing.T, mediaItemID string) uuid.UUID {
|
||||
mediaUUID, _ := uuid.Parse(mediaItemID)
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "web",
|
||||
Percentage: pFloat64(0.3),
|
||||
Epubcfi: pStr("epubcfi(/6/4/2:first)"),
|
||||
ContextText: pStr("some anchor context sentence"),
|
||||
DeviceType: "web",
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, progress.Epubcfi.Valid, "seed: anchor stored")
|
||||
return mediaUUID
|
||||
}
|
||||
|
||||
t.Run("percentage-only kobo write clears the inherited anchor", func(t *testing.T) {
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID := seedAnchor(t, mediaItemID)
|
||||
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "kobo",
|
||||
Percentage: pFloat64(0.6),
|
||||
DeviceType: "kobo",
|
||||
ClearLocator: true,
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 0.6, progress.Percentage.Float64, 0.001, "percentage updated")
|
||||
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared, not re-paired")
|
||||
assert.False(t, progress.ContextText.Valid, "stale context cleared")
|
||||
assert.Equal(t, "kobo", progress.LastSyncSource.String)
|
||||
})
|
||||
|
||||
t.Run("request-carried context survives the clear", func(t *testing.T) {
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID := seedAnchor(t, mediaItemID)
|
||||
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "koreader",
|
||||
Percentage: pFloat64(0.7),
|
||||
ContextText: pStr("a brand new context sentence"),
|
||||
DeviceType: "koreader",
|
||||
ClearLocator: true,
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 0.7, progress.Percentage.Float64, 0.001)
|
||||
assert.True(t, progress.ContextText.Valid, "request-carried context kept")
|
||||
assert.Equal(t, "a brand new context sentence", progress.ContextText.String)
|
||||
// The verify gate is inert in the test harness (no media path
|
||||
// resolver wired), so no anchor can be minted here; the rule
|
||||
// under test is that the STALE one is gone.
|
||||
assert.False(t, progress.Epubcfi.Valid, "stale anchor cleared")
|
||||
})
|
||||
|
||||
t.Run("HTTP merge semantics untouched: key-absent PUT preserves fields", func(t *testing.T) {
|
||||
mediaItemID := createTestMediaItemID(t, setup)
|
||||
mediaUUID := seedAnchor(t, mediaItemID)
|
||||
|
||||
// Service-level save WITHOUT ClearLocator mimics the HTTP merge
|
||||
// path (field absent = inherit): the anchor must survive.
|
||||
_, err := setup.ProgressService.SaveProgress(ctx, wsync.SaveProgressRequest{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
Source: "queue",
|
||||
Percentage: pFloat64(0.4),
|
||||
Broadcast: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
progress, err := setup.DB.GetReadingProgress(ctx, database.GetReadingProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
UserID: pgUserID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, progress.Epubcfi.Valid, "merge path preserves the anchor")
|
||||
assert.Equal(t, "epubcfi(/6/4/2:first)", progress.Epubcfi.String)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSchemaInitializationIsIdempotent replays the application's production
|
||||
// schema initializer twice against the same database. schema.sql runs on
|
||||
// EVERY app startup, so it must apply cleanly to a database where a previous
|
||||
// startup already applied it. A regression here crash-loops the production
|
||||
// container on its second boot (e.g. SQLSTATE 2BP01: DROP FUNCTION refused
|
||||
// while a trigger created by the previous run still references it).
|
||||
//
|
||||
// Uses a dedicated pool: production Initialize holds the advisory lock on one
|
||||
// connection while executing the schema on another, but the shared test pool
|
||||
// is capped at MaxConns=1 and would deadlock.
|
||||
func TestSchemaInitializationIsIdempotent(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping schema re-initialization in -short mode")
|
||||
}
|
||||
|
||||
// Bring up the standard test environment (seeds users, starts server).
|
||||
setup := setupTestServer(t)
|
||||
defer func() { _ = setup.Close() }()
|
||||
|
||||
// Dedicated pool with default connection limits for the initializer.
|
||||
poolCfg, err := pgxpool.ParseConfig(config.LoadConfig().DatabaseURL())
|
||||
require.NoError(t, err, "failed to parse database URL")
|
||||
pool, err := pgxpool.NewWithConfig(context.Background(), poolCfg)
|
||||
require.NoError(t, err, "failed to connect for schema initialization test")
|
||||
defer func() { pool.Close() }()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// First boot: applies the schema to the (possibly fresh) database.
|
||||
if err := database.Initialize(ctx, pool); err != nil {
|
||||
t.Fatalf("first schema initialization failed: %v", err)
|
||||
}
|
||||
|
||||
// Second boot against the now-initialized database: this is exactly
|
||||
// where non-idempotent statements (bare CREATE TRIGGER, DROP FUNCTION
|
||||
// with dependent objects, etc.) blow up.
|
||||
if err := database.Initialize(ctx, pool); err != nil {
|
||||
t.Fatalf("second schema initialization failed: %v — schema.sql must stay idempotent across restarts", err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -34,15 +35,19 @@ func TestUnifiedSearch(t *testing.T) {
|
||||
_ = folderResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode)
|
||||
|
||||
// Helper to create book with fields
|
||||
// Helper to create book with fields. Unique file_path per book: the
|
||||
// create endpoint answers 200/duplicate for a repeated
|
||||
// (library_id, file_path), and the old shared constant made the 2nd/3rd
|
||||
// creates no-ops anyway.
|
||||
createBook := func(title, author, genre string, year int, hasCover bool) {
|
||||
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||
bookReq := map[string]interface{}{
|
||||
"library_id": libraryID,
|
||||
"title": title,
|
||||
"author": author,
|
||||
"genre": genre,
|
||||
"copyright_year": year,
|
||||
"file_path": "/tmp/test.epub",
|
||||
"file_path": "/tmp/" + slug + ".epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ func (s *SeriesIntegrationTestSuite) TearDownSuite() {
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
|
||||
// Ground truth: library_id is OPTIONAL on /api/series by design —
|
||||
// GetDistinctSeries matches (narg IS NULL OR library_id = narg), so a
|
||||
// missing library_id aggregates series across all libraries. The
|
||||
// former "library_id required → 400" expectation asserted validation
|
||||
// the shipped query never had.
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
@@ -42,12 +47,13 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_RequiresLibraryID() {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "library_id required", body["error"])
|
||||
assert.Contains(s.T(), body, "series", "response carries the series array")
|
||||
assert.Contains(s.T(), body, "total")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeries_InvalidLibraryID() {
|
||||
@@ -159,7 +165,14 @@ func (s *SeriesIntegrationTestSuite) TestGetSeries_LimitClampedTo100() {
|
||||
assert.Equal(s.T(), float64(100), limit, "limit should be clamped to 100")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresLibraryID() {
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NameOnlyContract() {
|
||||
// Ground truth: /api/series/books requires only `name` — the app
|
||||
// client sends name-only (BookhoardApi.kt) and the web SSR page too
|
||||
// (frontend.go). The query is intentionally library-agnostic (it
|
||||
// matches the series name across libraries; library_id, if sent, is
|
||||
// ignored — nothing validates it). The former "library_id required /
|
||||
// bad uuid → 400" expectations asserted validation that never
|
||||
// shipped.
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?name=Test+Series", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
@@ -170,12 +183,14 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresLibraryID() {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]string
|
||||
var body map[string]interface{}
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
require.NoError(s.T(), err)
|
||||
assert.Equal(s.T(), "library_id required", body["error"])
|
||||
assert.Equal(s.T(), "Test Series", body["name"])
|
||||
assert.Contains(s.T(), body, "books", "response carries the books array")
|
||||
assert.Contains(s.T(), body, "total")
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
|
||||
@@ -199,6 +214,8 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_RequiresName() {
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
|
||||
// Same name-only contract: an unparseable library_id is ignored, not
|
||||
// validated (nothing in the shipped pipeline ever reads it here).
|
||||
req, _ := http.NewRequest("GET", s.setup.Server.URL+"/api/series/books?library_id=bad-uuid&name=Test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||
|
||||
@@ -209,7 +226,7 @@ func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_InvalidLibraryID() {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
|
||||
assert.Equal(s.T(), http.StatusBadRequest, resp.StatusCode)
|
||||
assert.Equal(s.T(), http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
func (s *SeriesIntegrationTestSuite) TestGetSeriesBooks_NonexistentSeries() {
|
||||
@@ -349,11 +366,17 @@ func (s *SeriesIntegrationTestSuite) TestGetSections_IncludesContinueSeries() {
|
||||
sectionIDs[section["id"].(string)] = true
|
||||
}
|
||||
|
||||
assert.Contains(s.T(), sectionIDs, "continue-series", "dashboard should include continue-series section")
|
||||
assert.Contains(s.T(), sectionIDs, "continue-reading")
|
||||
assert.Contains(s.T(), sectionIDs, "recently-added")
|
||||
assert.Contains(s.T(), sectionIDs, "recently-read")
|
||||
assert.Contains(s.T(), sectionIDs, "not-started")
|
||||
// Ground truth: the /sections contract keys a section's identity on
|
||||
// the collection NAME — BuildSections sets ID = CollectionName, and
|
||||
// the web/app reorder + hidden-collection preferences persist those
|
||||
// same strings (data-collection-id). The kebab "query_type"
|
||||
// ("continue-series" etc.) is the STORAGE key for system collections
|
||||
// and is not exposed on this endpoint.
|
||||
assert.Contains(s.T(), sectionIDs, "Continue Series", "dashboard should include Continue Series")
|
||||
assert.Contains(s.T(), sectionIDs, "Continue Reading")
|
||||
assert.Contains(s.T(), sectionIDs, "Recently Added")
|
||||
assert.Contains(s.T(), sectionIDs, "Recently Read")
|
||||
assert.Contains(s.T(), sectionIDs, "Not Started")
|
||||
}
|
||||
|
||||
func TestSeriesIntegrationTestSuite(t *testing.T) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -34,14 +35,18 @@ func TestTagsFilter(t *testing.T) {
|
||||
_ = folderResp.Body.Close()
|
||||
require.Equal(t, http.StatusCreated, folderResp.StatusCode, "Should add folder to library")
|
||||
|
||||
// Helper to create book with tags
|
||||
// Helper to create book with tags. The file_path must be UNIQUE per
|
||||
// book: CreateMediaItem upserts ON CONFLICT (library_id, file_path),
|
||||
// so a constant path collapses every create onto the first row and
|
||||
// the later books' tags never land.
|
||||
createBook := func(title, author string, tags []string) {
|
||||
slug := strings.ReplaceAll(strings.ToLower(title), " ", "-")
|
||||
bookReq := map[string]interface{}{
|
||||
"library_id": libraryID,
|
||||
"title": title,
|
||||
"author": author,
|
||||
"tags": tags,
|
||||
"file_path": "/tmp/test.epub",
|
||||
"file_path": "/tmp/" + slug + ".epub",
|
||||
"file_size": 1024,
|
||||
"mime_type": "application/epub+zip",
|
||||
}
|
||||
|
||||
@@ -109,6 +109,18 @@ func createTestLibraryWithFolder(t *testing.T, ts *httptest.Server, token, name
|
||||
require.NoError(t, err)
|
||||
libraryID := libResponse["id"].(string)
|
||||
|
||||
// Every library a test creates must not outlive that test — delete
|
||||
// it on cleanup so runs leave no residue in the dev database.
|
||||
t.Cleanup(func() {
|
||||
delHTTP, _ := http.NewRequest("DELETE", fmt.Sprintf("%s/api/libraries/%s", ts.URL, libraryID), nil)
|
||||
delHTTP.Header.Set("Authorization", "Bearer "+token)
|
||||
delResp, err := client.Do(delHTTP)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, delResp.Body)
|
||||
_ = delResp.Body.Close()
|
||||
}
|
||||
})
|
||||
|
||||
if withFolder {
|
||||
folderReq := map[string]interface{}{
|
||||
"folder_path": "/app/uploads",
|
||||
|
||||
@@ -275,6 +275,10 @@ func uuidToPGType(u uuid.UUID) pgtype.UUID {
|
||||
// createDefaultCollectionsForUser creates the 4 default system collections for a user
|
||||
func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID pgtype.UUID) {
|
||||
ctx := context.Background()
|
||||
// Canonical Title-case names — the same list the registration seed
|
||||
// (CreateDefaultCollectionsForUser) and RestoreSystemCollection use.
|
||||
// A stale kebab-case copy lived here once and duplicated every
|
||||
// system collection in the shared dev DB.
|
||||
defaultCollections := []struct {
|
||||
Name string
|
||||
Description string
|
||||
@@ -283,14 +287,34 @@ func createDefaultCollectionsForUser(t *testing.T, db *database.Queries, userID
|
||||
QueryType string
|
||||
Priority int32
|
||||
}{
|
||||
{"continue-reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
|
||||
{"recently-added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||
{"recently-read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||
{"not-started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||
{"continue-series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||||
{"Continue Reading", "Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", "continue-reading", 1},
|
||||
{"Recently Added", "Newly added items to this library", "🆕", "#9ece6a", "recently-added", 2},
|
||||
{"Recently Read", "Books you've finished (progress >= 1)", "✅", "#e0af68", "recently-read", 3},
|
||||
{"Not Started", "Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", "not-started", 4},
|
||||
{"Continue Series", "Next book in series you're reading", "📚", "#bb9af7", "continue-series", 5},
|
||||
}
|
||||
|
||||
for _, col := range defaultCollections {
|
||||
// Idempotent by the STABLE identity (user + query_type): existing
|
||||
// rows (renamed, tombstoned) heal to canonical metadata in place;
|
||||
// insert only when the query type has no row. A blind insert here
|
||||
// hit UNIQUE(user_id, name) on every preserved-admin rerun.
|
||||
existing, gerr := db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
||||
UserID: userID,
|
||||
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
|
||||
})
|
||||
if gerr == nil {
|
||||
_, uerr := db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
||||
ID: existing.ID,
|
||||
Name: col.Name,
|
||||
Description: pgtype.Text{String: col.Description, Valid: true},
|
||||
Icon: pgtype.Text{String: col.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: col.Color, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
|
||||
})
|
||||
require.NoError(t, uerr, "Should restore default collection: "+col.Name)
|
||||
continue
|
||||
}
|
||||
_, err := db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
UserID: userID,
|
||||
Name: col.Name,
|
||||
@@ -544,6 +568,14 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
AnnotationService: annotationService,
|
||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||
LoginTracker: loginAttemptTracker,
|
||||
// RegisterRoutes dereferences Settings for the auth rate limit;
|
||||
// the main binary wires database.NewSettingsRegistry(queries) —
|
||||
// the test harness must too or route registration nil-panics.
|
||||
Settings: database.NewSettingsRegistry(queries),
|
||||
// main.go:246 passes the shared LibraryService here; omitting it
|
||||
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
||||
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
||||
LibraryService: libraryService,
|
||||
}
|
||||
|
||||
router.RegisterRoutes(routerConfig)
|
||||
@@ -588,9 +620,27 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
}
|
||||
}
|
||||
|
||||
// Create fresh admin test user
|
||||
// The admin test user doubles as the shared dev admin and is
|
||||
// deliberately PRESERVED by the cleanup above — reuse it when it
|
||||
// already exists instead of re-creating (a blind INSERT hits
|
||||
// users_email_key on every run after the first).
|
||||
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
||||
adminUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
||||
var adminUUID uuid.UUID
|
||||
if existing, gerr := queries.GetUserByEmail(ctx, "testuser@tests.bookhoard.internal"); gerr == nil {
|
||||
adminUUID, err = uuid.FromBytes(existing.ID.Bytes[:])
|
||||
require.NoError(t, err, "Failed to parse admin user UUID")
|
||||
// Reset the password to the known test constant: other tests (e.g.
|
||||
// password-change tests) mutate the shared admin's password, which
|
||||
// would otherwise lock every later test out with login 401s until
|
||||
// the database is wiped.
|
||||
if updErr := queries.UpdatePassword(ctx, database.UpdatePasswordParams{
|
||||
ID: existing.ID,
|
||||
PasswordHash: passwordHash,
|
||||
}); updErr != nil {
|
||||
require.NoError(t, updErr, "Failed to reset shared admin test password")
|
||||
}
|
||||
} else {
|
||||
adminUser, cerr := queries.CreateUser(ctx, database.CreateUserParams{
|
||||
Email: "testuser@tests.bookhoard.internal",
|
||||
Username: "testuser",
|
||||
PasswordHash: passwordHash,
|
||||
@@ -598,12 +648,46 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
LastName: pgtype.Text{String: "User", Valid: true},
|
||||
Role: "admin",
|
||||
})
|
||||
require.NoError(t, err, "Failed to create admin test user")
|
||||
|
||||
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
|
||||
require.NoError(t, cerr, "Failed to create admin test user")
|
||||
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
|
||||
require.NoError(t, err, "Failed to parse admin user UUID")
|
||||
}
|
||||
// The canonical system-collection set is ensured on EVERY run: the
|
||||
// helper is idempotent by (user, query_type) and heals tombstoned or
|
||||
// renamed rows, so suite runs can't accumulate naming-drift copies.
|
||||
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
|
||||
// Delete test-created COLLECTIONS (names containing "test"): without
|
||||
// this, every suite run leaves its collections behind and the next
|
||||
// run's creates die on UNIQUE(user_id, name) — the "collections
|
||||
// 500-panic" rot. System collections are skipped (permanent rows).
|
||||
// Same convention as the library pass: names containing "test".
|
||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
if cerr == nil {
|
||||
for _, coll := range adminColls {
|
||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||
queries.DeleteCollection(ctx, coll.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Same passes for test DEVICES (identifier/name containing "test")
|
||||
// and test SAVED FILTERS (name containing "test"): both accumulated
|
||||
// across runs and poisoned later runs (duplicate identifiers, 409s,
|
||||
// non-empty "initially empty" assertions).
|
||||
_, derr := dbPool.Exec(ctx,
|
||||
"DELETE FROM devices WHERE user_id = $1 AND (device_identifier ILIKE '%test%' OR device_name ILIKE '%test%')",
|
||||
adminUUID)
|
||||
if derr != nil {
|
||||
t.Logf("device cleanup failed: %v", derr)
|
||||
}
|
||||
_, ferr := dbPool.Exec(ctx,
|
||||
"DELETE FROM saved_filters WHERE user_id = $1 AND name ILIKE '%test%'",
|
||||
adminUUID)
|
||||
if ferr != nil {
|
||||
t.Logf("saved-filter cleanup failed: %v", ferr)
|
||||
}
|
||||
|
||||
// Create fresh regular test user
|
||||
regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
||||
Email: "testregularuser@tests.bookhoard.internal",
|
||||
@@ -652,6 +736,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
}
|
||||
}
|
||||
|
||||
// Same pass for test-created COLLECTIONS (the "collections
|
||||
// 500-panic" rot: they lingered forever and poisoned later runs).
|
||||
adminColls, cerr := queries.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||
if cerr == nil {
|
||||
for _, coll := range adminColls {
|
||||
if !coll.IsSystemCollection.Bool && strings.Contains(strings.ToLower(coll.Name), "test") {
|
||||
queries.DeleteCollection(ctx, coll.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := setup.Close(); err != nil {
|
||||
t.Errorf("Failed to cleanup test server: %v", err)
|
||||
}
|
||||
@@ -699,6 +794,26 @@ func getTestUserID(t *testing.T, db *database.Queries) uuid.UUID {
|
||||
return userUUID
|
||||
}
|
||||
|
||||
// createLibraryDirect inserts a library owned by the given user WITHOUT a
|
||||
// nested test-server setup: a second setupTestServer bootstraps away the
|
||||
// first setup's users, and the ghost-session guard rightly rejects the
|
||||
// outer test's tokens from then on.
|
||||
func createLibraryDirect(t *testing.T, db *database.Queries, ownerID uuid.UUID, name, libraryType string) string {
|
||||
ctx := context.Background()
|
||||
libraryTypeRow, err := db.GetLibraryTypeByName(ctx, libraryType)
|
||||
require.NoError(t, err, "Should find library type")
|
||||
library, err := db.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: name,
|
||||
Description: pgtype.Text{String: "Test library description", Valid: true},
|
||||
LibraryTypeID: libraryTypeRow.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(ownerID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err, "Should create library")
|
||||
libraryUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||
require.NoError(t, err, "Should parse library ID")
|
||||
return libraryUUID.String()
|
||||
}
|
||||
|
||||
func getRegularUserID(t *testing.T, db *database.Queries) uuid.UUID {
|
||||
ctx := context.Background()
|
||||
user, err := db.GetUserByEmail(ctx, "testregularuser@tests.bookhoard.internal")
|
||||
|
||||
@@ -68,6 +68,7 @@ INSERT INTO system_settings (setting_key, setting_value, description, setting_ty
|
||||
('password_require_lower', 'true', 'Require at least one lowercase letter (a-z)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||
('password_require_number', 'true', 'Require at least one number (0-9)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||
('password_require_special', 'true', 'Require at least one special character', 'bool', NULL, NULL, FALSE, 'security'),
|
||||
('registration_enabled', 'true', 'Allow users to create their own accounts (admins can always create accounts)', 'bool', NULL, NULL, FALSE, 'security'),
|
||||
-- security / auth (restart required)
|
||||
('auth_rate_limit_per_min', '10', 'Global auth API rate limit (requests per minute)', 'int', '1', '10000', TRUE, 'security'),
|
||||
('login_max_attempts', '5', 'Failed login attempts before lockout', 'int', '1', '100', TRUE, 'security'),
|
||||
@@ -207,6 +208,19 @@ CREATE TABLE IF NOT EXISTS media_items (
|
||||
-- Summary (distinct from description - may merge with Calibre description)
|
||||
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||
|
||||
-- Per-field user-override tracking. Column names listed here (e.g.
|
||||
-- 'description', 'tags', 'cover_image_path') are user-customized and MUST
|
||||
-- NOT be overwritten by library scans or per-book rescans; only the
|
||||
-- reset-to-scanned-defaults action clears them.
|
||||
metadata_overrides TEXT[] NOT NULL DEFAULT '{}',
|
||||
|
||||
-- Archive lifecycle: a file missing from disk for two consecutive scans
|
||||
-- gets archived_at set (hidden from all browsing, reading history kept).
|
||||
-- If the file returns, the row is un-archived. Archived rows are purged
|
||||
-- after ARCHIVE_RETENTION_DAYS (0 = manual purge only).
|
||||
missing_scan_count INT NOT NULL DEFAULT 0,
|
||||
archived_at TIMESTAMPTZ,
|
||||
|
||||
-- Chapter metadata for reader navigation and progress tracking
|
||||
-- Caches detected chapter structure to avoid re-parsing files
|
||||
-- Populated by ReaderService.DetectChapters() on first read
|
||||
@@ -232,12 +246,25 @@ CREATE INDEX IF NOT EXISTS idx_media_items_contributors_gin ON media_items USING
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS tags_search TEXT[];
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||
|
||||
-- Per-field user-override tracking (idempotent backfill for existing
|
||||
-- installs; the column is also declared in the media_items table
|
||||
-- definition above for fresh databases)
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS metadata_overrides TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Archive lifecycle (idempotent backfill for existing installs)
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS missing_scan_count INT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_archived ON media_items (archived_at) WHERE archived_at IS NOT NULL;
|
||||
|
||||
-- Create GIN indexes for fast search field searches
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_tags_search ON media_items USING GIN (tags_search);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_contributors_search ON media_items USING GIN (contributors_search);
|
||||
|
||||
-- Drop existing trigger if exists
|
||||
-- Drop existing triggers if exists. Both must be dropped BEFORE the function:
|
||||
-- dropping a function that triggers still reference fails (SQLSTATE 2BP01),
|
||||
-- which would abort the schema transaction on every startup after the first.
|
||||
DROP TRIGGER IF EXISTS set_library_type_name_on_insert ON media_items;
|
||||
DROP TRIGGER IF EXISTS set_library_type_name_on_library_change ON media_items;
|
||||
-- Drop existing function if exists
|
||||
DROP FUNCTION IF EXISTS set_library_type_name();
|
||||
|
||||
@@ -256,6 +283,14 @@ CREATE TRIGGER set_library_type_name_on_insert
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_library_type_name();
|
||||
|
||||
-- Keep library_type_name truthful when an item is repointed to another
|
||||
-- library (cross-library move detection reuses the same function; the row
|
||||
-- keeps its ID while its library changes).
|
||||
CREATE TRIGGER set_library_type_name_on_library_change
|
||||
BEFORE UPDATE OF library_id ON media_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_library_type_name();
|
||||
|
||||
-- Add GIN indexes for pg_trgm fuzzy search performance
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
||||
ON media_items USING GIN (author gin_trgm_ops);
|
||||
@@ -1100,9 +1135,15 @@ CREATE TABLE IF NOT EXISTS collections (
|
||||
priority INT DEFAULT 100,
|
||||
is_system_collection BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
deleted_at TIMESTAMP WITH TIME ZONE,
|
||||
UNIQUE(user_id, name)
|
||||
);
|
||||
|
||||
-- System collections (the 5 dashboard sections) are permanent: "delete"
|
||||
-- tombstones the row (deleted_at) and restore un-tombstones it in place —
|
||||
-- the row id (and everything referencing it) never churn.
|
||||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id);
|
||||
|
||||
-- Index for dashboard queries
|
||||
@@ -1125,6 +1166,20 @@ CREATE TABLE IF NOT EXISTS user_dashboard_preferences (
|
||||
-- Index for fast lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
|
||||
|
||||
-- Global (all-libraries) dashboard preferences: exactly one row per user.
|
||||
-- Backs the dashboard's "All Libraries" view (library_id NULL convention
|
||||
-- rejected: the upsert's ON CONFLICT cannot dedupe NULLs and intent would
|
||||
-- be invisible — a dedicated table makes the scope structural). System
|
||||
-- and user collections are user-scoped, so collection_order/
|
||||
-- hidden_collections IDs are library-independent and valid in this scope.
|
||||
CREATE TABLE IF NOT EXISTS user_dashboard_preferences_global (
|
||||
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
hidden_collections TEXT[] DEFAULT '{}',
|
||||
collection_order TEXT[] DEFAULT '{}',
|
||||
items_per_section INT DEFAULT 20,
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Create collection_items table (which books belong to each collection)
|
||||
CREATE TABLE IF NOT EXISTS collection_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
+8
-1
@@ -15,7 +15,8 @@ services:
|
||||
# Make other volumes as needed
|
||||
- ./uploads:/app/uploads
|
||||
ports:
|
||||
- "${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
- "127.0.0.1:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
- "[::1]:${DB_PORT:-5432}:${DB_PORT:-5432}"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
@@ -61,6 +62,12 @@ services:
|
||||
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||
|
||||
# Library Maintenance
|
||||
# Days an archived item (file missing from disk for 2+ scans) is kept,
|
||||
# with its reading history, before library scans purge it for good.
|
||||
# Set 0 to keep archived items until purged manually on the library admin page.
|
||||
ARCHIVE_RETENTION_DAYS: ${ARCHIVE_RETENTION_DAYS:-90}
|
||||
|
||||
# System timezone (fallback for server-side time operations)
|
||||
TZ: ${TZ:-UTC}
|
||||
ports:
|
||||
|
||||
+40
-108
@@ -394,6 +394,11 @@ Authorization: Bearer <token>
|
||||
|
||||
## Reading Progress
|
||||
|
||||
Full field reference: [api/progress/](api/progress/) — and read the
|
||||
[Position Contract](api/progress/position-contract.md) (verification,
|
||||
healing, the OPF spine numbering hazard, offset currencies) before
|
||||
writing a client.
|
||||
|
||||
### Get Reading Progress
|
||||
|
||||
```http
|
||||
@@ -405,57 +410,56 @@ Authorization: Bearer <token>
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"media_item_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"current_page": 45,
|
||||
"total_pages": 200,
|
||||
"percentage": 0.225,
|
||||
"character_offset": 15432,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"chapter": 3,
|
||||
"chapter_progress": 0.5,
|
||||
"last_read_at": "2026-01-31T10:00:00Z",
|
||||
"percentage": 0.045,
|
||||
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
|
||||
"context_text": "from day to day, but there was none in which ...",
|
||||
"character_offset": 16375,
|
||||
"current_page": null,
|
||||
"total_pages": null,
|
||||
"chapter": null,
|
||||
"chapter_progress": null,
|
||||
"format_group": "reflowable",
|
||||
"viewport_y": 0.12,
|
||||
"zoom_level": 1.0
|
||||
"total_characters": 592216,
|
||||
"chapter_count": 1,
|
||||
"last_read_at": "2026-09-26T14:52:34Z",
|
||||
"last_sync_source": "koreader",
|
||||
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
|
||||
"anchor_href": "1984.xhtml",
|
||||
"char_offset": 598
|
||||
}
|
||||
```
|
||||
|
||||
`css_selector`, `anchor_href` and `char_offset` are server-derived
|
||||
restore handles, served for convertible reflowable books with a
|
||||
resolvable anchor. `char_offset` is UTF-16 code units within the anchor
|
||||
block's text; `character_offset` is a book-wide rune count. Resolve the
|
||||
document by `anchor_href`, never by the CFI's spine step (OPF numbering
|
||||
includes `linear="no"` items).
|
||||
|
||||
### Update Reading Progress
|
||||
|
||||
```http
|
||||
PUT /api/media-items/{media_id}/progress
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"source": "web",
|
||||
"location": {
|
||||
"percentage": 0.45678,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"character": 15432,
|
||||
"chapter": 3,
|
||||
"page": 89,
|
||||
"total_pages": 200
|
||||
},
|
||||
"device_metadata": {
|
||||
"device_type": "web",
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
|
||||
```json
|
||||
{
|
||||
"sync_status": "success",
|
||||
"progress_updated": true,
|
||||
"devices_notified": ["device-1", "device-2"],
|
||||
"broadcast": true
|
||||
"percentage": 0.0415,
|
||||
"context_text": "was at war with one of these Powers it was generally a",
|
||||
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
|
||||
}
|
||||
```
|
||||
|
||||
All fields are optional. The server verifies the submission against the
|
||||
book and heals it on mismatch; the response is the stored row after
|
||||
verification. A `percentage` below 0.005 is ignored while stored
|
||||
progress exceeds 0.01 (first-page anti-clobber).
|
||||
|
||||
### Delete Reading Progress
|
||||
|
||||
```http
|
||||
@@ -1297,82 +1301,10 @@ Authorization: Bearer <device_token>
|
||||
|
||||
## Universal Progress
|
||||
|
||||
### Get Universal Progress
|
||||
|
||||
```http
|
||||
GET /api/progress/{book_uuid}
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**Response** (200):
|
||||
|
||||
```json
|
||||
{
|
||||
"book_id": "book-uuid",
|
||||
"format_group": "reflowable",
|
||||
"universal_progress": 0.45678,
|
||||
"location_references": {
|
||||
"percentage": 0.45678,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"character": 15432,
|
||||
"chapter": 3,
|
||||
"chapter_progress": 0.234,
|
||||
"viewport_y": 0.12
|
||||
},
|
||||
"device_progress": {
|
||||
"koreader": {
|
||||
"percentage": 0.45678,
|
||||
"last_sync": "2026-01-30T20:00:00Z"
|
||||
},
|
||||
"kobo": {
|
||||
"percentage": 45.6,
|
||||
"last_sync": "2026-01-30T19:55:00Z"
|
||||
},
|
||||
"web": {
|
||||
"display_page": 89,
|
||||
"total_pages": 200,
|
||||
"last_sync": "2026-01-30T20:05:00Z"
|
||||
}
|
||||
},
|
||||
"annotations": {
|
||||
"highlights": [...],
|
||||
"notes": [...],
|
||||
"bookmarks": [...]
|
||||
},
|
||||
"conflicts": [
|
||||
{
|
||||
"id": "conflict-uuid",
|
||||
"type": "progress",
|
||||
"resolved": false,
|
||||
"sources": ["koreader", "kobo"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Update Universal Progress
|
||||
|
||||
```http
|
||||
POST /api/progress/{book_uuid}
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"source": "web|koreader|kobo|mobile",
|
||||
"location": {
|
||||
"percentage": 0.45678,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"character": 15432,
|
||||
"chapter": 3,
|
||||
"page": 89,
|
||||
"total_pages": 200
|
||||
},
|
||||
"device_metadata": {
|
||||
"device_type": "web",
|
||||
"user_agent": "..."
|
||||
}
|
||||
}
|
||||
```
|
||||
Universal progress is the media-item progress — one row per (user,
|
||||
media item) shared by every device. See [Reading Progress](#reading-progress)
|
||||
and the [Position Contract](api/progress/position-contract.md). There
|
||||
are no separate `/api/progress/:id` GET/POST endpoints.
|
||||
|
||||
## Conflicts
|
||||
|
||||
|
||||
@@ -117,10 +117,14 @@ See [Media Item Operations](media-items/)
|
||||
|
||||
## Reading Progress
|
||||
|
||||
See [Progress Tracking](progress/)
|
||||
See [Progress Tracking](progress/) — in particular the
|
||||
[Position Contract](progress/position-contract.md) (verification,
|
||||
healing, the OPF spine numbering hazard, and offset currencies) before
|
||||
writing a client.
|
||||
|
||||
- GET /api/progress/:id - Get universal progress
|
||||
- POST /api/progress/:id - Update universal progress
|
||||
- GET /api/media-items/:id/progress - Get reading progress + restore handles
|
||||
- PUT /api/media-items/:id/progress - Submit reading progress (verified/healed server-side)
|
||||
- DELETE /api/media-items/:id/progress - Delete reading progress
|
||||
- GET /api/progress/:id/history - Get progress history
|
||||
|
||||
## Notes & Highlights
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Get Metadata
|
||||
|
||||
Get metadata for a book from KOReader device.
|
||||
Get a book's stored progress and annotations for a KOReader device —
|
||||
the pull half of the device sync. The reference client calls this after
|
||||
linking a book via [Resolve Book](resolve_book.md) and navigates to the
|
||||
returned position.
|
||||
|
||||
**Endpoint**: `GET /api/sync/koreader/metadata/:uuid`
|
||||
**Auth**: Required (Device authentication)
|
||||
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
|
||||
|
||||
## Path Parameters
|
||||
|
||||
@@ -11,41 +14,55 @@ Get metadata for a book from KOReader device.
|
||||
| --------- | ------------- | -------- | ----------- |
|
||||
| uuid | string (UUID) | Yes | Book UUID |
|
||||
|
||||
## Device Authentication
|
||||
|
||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ------------------------- |
|
||||
| X-Device-ID | string | Yes | Device UUID |
|
||||
| X-Device-Key | string | Yes | Device authentication key |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/sync/koreader/metadata/550e8400-e29b-41d4-a716-446655440000
|
||||
X-Device-ID: 550e8400-e29b-41d4-a716-446655440000
|
||||
X-Device-Key: device-auth-key
|
||||
GET /api/sync/koreader/metadata/774641f9-317b-4087-8e04-53bb4392ae56
|
||||
Authorization: Bearer {device_token}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"title": "Book Title",
|
||||
"authors": ["Author Name"],
|
||||
"path": "/path/to/book.epub",
|
||||
"file_size": 1234567,
|
||||
"modified_at": "2026-02-08T10:00:00Z"
|
||||
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||
"title": "1984",
|
||||
"author": "George Orwell",
|
||||
"progress": {
|
||||
"percentage": 0.045,
|
||||
"koreader_xpointer": "/body/DocFragment[1]/body/p[29]/text().598",
|
||||
"chapter": null,
|
||||
"chapter_progress": null,
|
||||
"page": null,
|
||||
"total_pages": null
|
||||
},
|
||||
"annotations": {
|
||||
"highlights": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Progress Object
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `percentage` | float | Stored position as a book fraction. |
|
||||
| `koreader_xpointer` | string | The stored canonical position converted back to a CRE xpointer (UTF-16 `text().N` offset). **The device should navigate to this.** Reflowable books only. |
|
||||
| `epubcfi` | string | The stored canonical CFI, when the conversion to a CRE xpointer is unavailable. Fallback after `koreader_xpointer`. |
|
||||
| `character` | int | Book-wide rune offset (internal currency). |
|
||||
| `chapter`, `chapter_progress` | int, float | Chapter position when known. |
|
||||
| `page`, `total_pages` | int | Fixed-layout page position — the canonical locator for image-based books (CFI/xpointer are omitted for them). |
|
||||
|
||||
`progress` is `null` when the book has no stored progress.
|
||||
|
||||
The `annotations` object carries device-format highlights/bookmarks/notes
|
||||
synced from other clients; its presence depends on annotation sync being
|
||||
enabled.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ---------------------------- |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid book UUID |
|
||||
| 401 | Device authentication failed |
|
||||
| 404 | Book or device not found |
|
||||
| 404 | Book not found |
|
||||
@@ -1,63 +1,87 @@
|
||||
# Sync Progress
|
||||
|
||||
Sync reading progress from KOReader device.
|
||||
Push reading progress from a KOReader device.
|
||||
|
||||
**Endpoint**: `POST /api/sync/koreader/progress`
|
||||
**Auth**: Required (Device authentication)
|
||||
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
|
||||
|
||||
## Device Authentication
|
||||
|
||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
||||
This is the device-native tier of the [Position
|
||||
Contract](../progress/position-contract.md): the KOReader payload carries
|
||||
a CRE xpointer and the server converts it to the canonical standard CFI,
|
||||
verifies it against the submitted `context_text`, and heals it on
|
||||
mismatch — exactly like every other client.
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | ------------------------- |
|
||||
| device_id | string (UUID) | Yes | Device UUID |
|
||||
| progress | array | Yes | Array of progress objects |
|
||||
| --------- | ------ | -------- | ----------- |
|
||||
| `books` | array | Yes | One book object (the reference client sends a single-element array). |
|
||||
| `sync_mode`| string | No | `immediate` (default) or `manual`. |
|
||||
|
||||
### Progress Object
|
||||
### Book Object
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ----------- | ------- | -------- | ---------------------------------- |
|
||||
| book | string | Yes | Book identifier (filename or UUID) |
|
||||
| percent | float | Yes | Progress percentage (0-100) |
|
||||
| page | integer | No | Current page number |
|
||||
| total_pages | integer | No | Total pages in document |
|
||||
| date_read | string | No | ISO 8601 timestamp of last read |
|
||||
| updated_at | string | Yes | ISO 8601 timestamp |
|
||||
| ----- | ---- | -------- | ----------- |
|
||||
| `uuid` | string (UUID) | No | Bookhoard UUID, once the device has linked the book via [Resolve Book](resolve_book.md). |
|
||||
| `sha256` | string | Yes | File content hash (64 hex chars) — the primary book identity. |
|
||||
| `title` | string | No | Document title. |
|
||||
| `authors` | array | No | Author names. |
|
||||
| `percentage` | float | Yes | Position as a fraction of the book (0..1). |
|
||||
| `context_text` | string | No | Up to 100 whitespace-normalized chars from the current position — enables the server's verification/healing. Strongly recommended. |
|
||||
| `page` | int | No | Current page (fixed-layout books). |
|
||||
| `total_pages` | int | No | Page count (fixed-layout books). |
|
||||
| `epubcfi` | string | Reflowable only | **A CRE xpointer** (`/body/DocFragment[N]/body/...`), not a CFI — the field name is historical. Fixed-layout books must omit it and carry their position in `page`/`total_pages`. |
|
||||
| `file_path` | string | No | Device-local file path (informational). |
|
||||
| `device_info` | object | No | `{ koreader_version, device_model }`. |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
POST /api/sync/koreader/progress
|
||||
Authorization: Bearer {device_token}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"progress": [
|
||||
"books": [
|
||||
{
|
||||
"book": "book.epub",
|
||||
"percent": 75.5,
|
||||
"page": 150,
|
||||
"total_pages": 200,
|
||||
"date_read": "2026-02-08T10:00:00Z",
|
||||
"updated_at": "2026-02-08T10:00:00Z"
|
||||
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"title": "1984",
|
||||
"authors": ["George Orwell"],
|
||||
"percentage": 0.045,
|
||||
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim",
|
||||
"page": 14,
|
||||
"total_pages": 311,
|
||||
"epubcfi": "/body/DocFragment[1]/body/p[29]/text().598",
|
||||
"device_info": {
|
||||
"koreader_version": "v2026.07.1",
|
||||
"device_model": "emulator"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"sync_mode": "immediate"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
## Response (202 Accepted)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Progress synced successfully",
|
||||
"synced_count": 1
|
||||
"sync_status": "ok",
|
||||
"books_synced": 1,
|
||||
"timestamp": "2026-09-26T21:25:09Z"
|
||||
}
|
||||
```
|
||||
|
||||
Per-book results and any detected sync conflicts are carried in
|
||||
`book_results` and `conflicts` when present.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ---------------------------- |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid request format |
|
||||
| 401 | Device authentication failed |
|
||||
| 400 | Invalid request data |
|
||||
| 404 | Device not found |
|
||||
| 500 | Database error |
|
||||
@@ -0,0 +1,35 @@
|
||||
# Delete Media Item Progress
|
||||
|
||||
Delete the stored reading progress for a media item.
|
||||
|
||||
**Endpoint**: `DELETE /api/media-items/:id/progress`
|
||||
**Auth**: Required (Bearer token)
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | ---------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
DELETE /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "reading progress deleted"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid media item id |
|
||||
| 401 | Invalid or expired token |
|
||||
| 500 | Database error |
|
||||
@@ -1,36 +0,0 @@
|
||||
# Delete Reading Progress
|
||||
|
||||
Delete reading progress for a media item.
|
||||
|
||||
**Endpoint**: `DELETE /api/media-items/{media_id}/progress`
|
||||
**Auth**: Required
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | --------------- |
|
||||
| media_id | string | Yes | Media item UUID |
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ------------ |
|
||||
| Authorization | string | Yes | Bearer token |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
DELETE /api/media-items/uuid/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (204 No Content)
|
||||
|
||||
Progress deleted successfully.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -0,0 +1,98 @@
|
||||
# Get Media Item Progress
|
||||
|
||||
Get the stored reading progress for a media item, plus the
|
||||
server-derived restore handles for reflowable books.
|
||||
|
||||
**Endpoint**: `GET /api/media-items/:id/progress`
|
||||
**Auth**: Required (Bearer token)
|
||||
|
||||
See [Position Contract](position-contract.md) for the semantics of every
|
||||
field — what is verified, what the currencies are, and how clients
|
||||
should restore.
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | --------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
```
|
||||
|
||||
## Response (200 OK) — no progress stored
|
||||
|
||||
When the item has no progress row, an empty fixed-layout-style stub is
|
||||
returned (not 404):
|
||||
|
||||
```json
|
||||
{
|
||||
"current_page": 0,
|
||||
"total_pages": null
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK) — progress stored
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "e8239659-0ed6-42ae-a946-8440d7655b42",
|
||||
"media_item_id": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||
"user_id": "1b64992e-3408-4d84-9e03-e2dc4950e1dd",
|
||||
"current_page": null,
|
||||
"total_pages": null,
|
||||
"last_read_at": "2026-09-26T14:52:34.806446Z",
|
||||
"percentage": 0.045,
|
||||
"character_offset": 16375,
|
||||
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
|
||||
"chapter": null,
|
||||
"chapter_progress": null,
|
||||
"format_group": "reflowable",
|
||||
"total_characters": 592216,
|
||||
"chapter_count": 1,
|
||||
"last_sync_device": "web",
|
||||
"last_sync_source": "koreader",
|
||||
"last_sync_timestamp": "2026-09-26T14:52:34.806446Z",
|
||||
"css_selector": "body>div:nth-child(4)>p:nth-child(29)",
|
||||
"anchor_href": "1984.xhtml",
|
||||
"char_offset": 598,
|
||||
"context_text": "from day to day, but there was none in which Goldstein was not the principal figure. He was the prim"
|
||||
}
|
||||
```
|
||||
|
||||
### Field Reference
|
||||
|
||||
Base fields (always present when a row exists):
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `percentage` | float | Position as a fraction of the whole book (0..1). |
|
||||
| `epubcfi` | string | The stored canonical standard CFI. **Spine steps index the OPF spine as written, including `linear="no"` items — do not resolve them against a readium reading order.** See the [Position Contract](position-contract.md#spine-numbering-hazard--read-this-before-parsing-a-stored-cfi). |
|
||||
| `context_text` | string | The stored verification context (≤100 whitespace-normalized chars from the anchor). |
|
||||
| `character_offset` | int | Book-wide rune offset. Internal currency — consistent with `total_characters`. |
|
||||
| `current_page`, `total_pages` | int | Fixed-layout page position (null for reflowable). |
|
||||
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction, when known. |
|
||||
| `format_group` | string | `reflowable`, `fixed_layout`, `comic_archive`, … Gates the restore handles. |
|
||||
| `total_characters`, `chapter_count` | int | Book metrics, for client-side fraction math. |
|
||||
| `last_sync_device`, `last_sync_source`, `last_sync_timestamp` | — | Which client last wrote the row. |
|
||||
|
||||
Restore handles (conditional — served only for convertible reflowable
|
||||
books whose stored anchor re-resolves at GET time):
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `anchor_href` | string | The spine document containing the anchor (e.g. `1984.xhtml`). **Resolve your resource by this**, never by the CFI's spine step. |
|
||||
| `css_selector` | string | Body-relative chain to the anchor's block element. |
|
||||
| `char_offset` | int | Anchor offset within the block's concatenated text, in UTF-16 code units. |
|
||||
| `epubcfi` | string | Re-served (possibly healed) canonical CFI — present whenever re-verification produced one. |
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid media item id |
|
||||
| 401 | Invalid or expired token |
|
||||
| 500 | Database error |
|
||||
@@ -1,52 +0,0 @@
|
||||
# Get Reading Progress
|
||||
|
||||
Retrieve reading progress for a specific media item.
|
||||
|
||||
**Endpoint**: `GET /api/media-items/{media_id}/progress`
|
||||
**Auth**: Required
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | --------------- |
|
||||
| media_id | string | Yes | Media item UUID |
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ------------ |
|
||||
| Authorization | string | Yes | Bearer token |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/media-items/uuid/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"media_item_id": "uuid",
|
||||
"user_id": "uuid",
|
||||
"current_page": 45,
|
||||
"total_pages": 200,
|
||||
"percentage": 0.225,
|
||||
"character_offset": 15432,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"chapter": 3,
|
||||
"chapter_progress": 0.5,
|
||||
"last_read_at": "2026-01-31T10:00:00Z",
|
||||
"format_group": "reflowable",
|
||||
"viewport_y": 0.12,
|
||||
"zoom_level": 1.0
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -1,50 +0,0 @@
|
||||
# Get Universal Progress
|
||||
|
||||
Get universal (device-agnostic) reading progress for a media item.
|
||||
|
||||
**Endpoint**: `GET /api/progress/:id`
|
||||
**Auth**: Required
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | --------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Type | Required | Description |
|
||||
| ------------- | ------ | -------- | ------------ |
|
||||
| Authorization | string | Yes | Bearer token |
|
||||
|
||||
### Example Request
|
||||
|
||||
```http
|
||||
GET /api/progress/550e8400-e29b-41d4-a716-446655440000
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"media_item_id": "uuid",
|
||||
"percentage": 75.5,
|
||||
"position": 1234,
|
||||
"page": 150,
|
||||
"total_pages": 200,
|
||||
"finished": false,
|
||||
"updated_at": "2026-02-08T10:00:00Z",
|
||||
"device": {
|
||||
"id": "device-uuid",
|
||||
"name": "My Kobo"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -0,0 +1,135 @@
|
||||
# Position Contract
|
||||
|
||||
How reading positions are represented, submitted, verified, and restored
|
||||
across clients. This is the contract every client speaks — readium-based
|
||||
apps, web (foliate), KOReader, and Kobo devices alike.
|
||||
|
||||
**The server is the position authority.** Every submission is
|
||||
independently verified against the book itself before it is stored; a
|
||||
client whose locator math is wrong cannot poison the stored position.
|
||||
In exchange, the server hands back handles clients can apply directly,
|
||||
so they never have to parse or trust CFIs themselves.
|
||||
|
||||
## The three-tier submission
|
||||
|
||||
Clients submit what their renderer can reliably observe. All three tiers
|
||||
are accepted on `PUT /api/media-items/:id/progress` (and on the KOReader
|
||||
device endpoints):
|
||||
|
||||
| Tier | Field | Currency | Notes |
|
||||
| ------ | -------------- | ----------------------------------------------------- | ----- |
|
||||
| 1 | `percentage` | Float 0..1 of the whole book | Universal. The only field every client can supply. |
|
||||
| 2 | `context_text` | Up to 100 chars, whitespace-normalized, starting at the anchor | The verification anchor. The server extracts the text at the submitted structural anchor and compares. |
|
||||
| 3 | `epubcfi` | Standard wrapped CFI, terminals in UTF-16 code units | Optional structural anchor. KOReader submits a CRE xpointer and the server converts it. |
|
||||
|
||||
Tier 2 is what makes the system self-correcting: percentages alone
|
||||
cannot distinguish "the reader's locator is right" from "the reader
|
||||
silently reported wherever it is currently scrolled".
|
||||
|
||||
## Verification and healing (ingest side)
|
||||
|
||||
On every progress save for a convertible reflowable book that carries a
|
||||
`context_text`, the server:
|
||||
|
||||
1. Parses the submitted `epubcfi` (if any) and resolves it against the
|
||||
book's own XHTML.
|
||||
2. Extracts the text at the resolved anchor and cross-checks it with the
|
||||
submitted `context_text`.
|
||||
3. On a mismatch — or an unresolvable anchor, or no anchor at all —
|
||||
heals the position by text search, using the submitted `percentage`
|
||||
to disambiguate repeated phrases. The healed CFI and a recomputed
|
||||
percentage are stored in place of the submitted ones.
|
||||
4. Refreshes the book-wide `character_offset` column from the verified
|
||||
anchor on every verified save, so it never goes stale behind the
|
||||
anchor.
|
||||
|
||||
If verification fails outright (book file unreadable, context not found
|
||||
and percentage cannot disambiguate), the error is logged and the
|
||||
submission is stored as-is — verification never rejects a save, it only
|
||||
corrects.
|
||||
|
||||
## The restore handles
|
||||
|
||||
`GET /api/media-items/:id/progress` serves, alongside the raw stored
|
||||
fields, the server-derived handles for reflowable books with a
|
||||
resolvable anchor:
|
||||
|
||||
| Field | Currency | Meaning |
|
||||
| ------------- | ----------------------- | ------- |
|
||||
| `anchor_href` | — | The spine document the anchor lives in (e.g. `1984.xhtml`). **This is how a client finds the right resource.** |
|
||||
| `css_selector`| — | Body-relative `tag:nth-child(k)` chain of the anchor's block element (e.g. `body>div:nth-child(4)>p:nth-child(29)`). |
|
||||
| `char_offset` | UTF-16 code units | Offset of the anchor within the concatenated text of that block. |
|
||||
| `epubcfi` | UTF-16 terminals | The stored canonical CFI — re-served healed if the GET-time re-verification improved it. |
|
||||
| `context_text`| — | The stored verification context (≤100 normalized chars from the anchor). |
|
||||
|
||||
**Recommended restore sequence for a client:**
|
||||
|
||||
1. Open the book and jump to the stored `percentage` (coarse floor).
|
||||
2. Resolve `anchor_href` against your own spine (an ends-with match on
|
||||
document hrefs) and open that document if you are not already there.
|
||||
3. Query `css_selector` in that document, walk its text nodes counting
|
||||
UTF-16 units to `char_offset`, and scroll that position into view.
|
||||
4. If the anchor cannot be measured (renderer-specific laziness), the
|
||||
percentage floor stands.
|
||||
|
||||
## SPINE NUMBERING HAZARD — read this before parsing a stored CFI
|
||||
|
||||
**`epubcfi` spine steps index the OPF spine AS WRITTEN, including
|
||||
`linear="no"` items.** Several rendering engines (notably readium)
|
||||
number their reading order EXCLUDING `linear="no"` items. When a book's
|
||||
cover (or any other item) is `linear="no"`, the two numberings differ by
|
||||
a constant offset from that item onward — a client resolving a stored
|
||||
CFI's spine step against its own numbering lands in the WRONG DOCUMENT.
|
||||
|
||||
This is not hypothetical: "1984" epubs commonly have a `linear="no"`
|
||||
cover, which makes readium spine 0 = OPF spine 1. The server heals such
|
||||
numbering mismatches at ingest (that is what the context check is for),
|
||||
but the durable rule for client authors is:
|
||||
|
||||
> **Never resolve a stored CFI's spine step yourself.** Resolve the
|
||||
> document by `anchor_href`, then land with `css_selector` +
|
||||
> `char_offset`. Treat the CFI as opaque server currency.
|
||||
|
||||
## Offset currencies
|
||||
|
||||
Two counting systems are in play, and they are deliberately kept apart:
|
||||
|
||||
| Quantity | Currency | Why |
|
||||
| -------- | -------- | --- |
|
||||
| CFI terminal offsets (`…/1:456`) | UTF-16 code units | The EPUB CFI spec, and what every client observes (JavaScript `.length`). |
|
||||
| `char_offset` (block-relative handle) | UTF-16 code units | Same reason — clients walk DOM text with JS semantics. |
|
||||
| KOReader CRE `text().N` offsets | UTF-16 code units | crengine is UCS-16 internally. |
|
||||
| `character_offset` (book-wide column) | Unicode runes | Internal, consistent with `total_characters` and the percentage derivations. |
|
||||
|
||||
For all-BMP text the two currencies are identical. They diverge on
|
||||
astral-plane characters (emoji, rare CJK ideographs): one rune, two
|
||||
UTF-16 units. The server converts at every wire boundary; internal
|
||||
arithmetic never crosses.
|
||||
|
||||
## `context_text` rules
|
||||
|
||||
- Starts at the anchor position (it may begin mid-word).
|
||||
- Whitespace-normalized (all runs of whitespace collapse to single
|
||||
spaces).
|
||||
- At most 100 characters.
|
||||
- Comparison is containment-based (client and server suffixes of the
|
||||
same block verify in either direction); contexts shorter than 12
|
||||
chars never match.
|
||||
|
||||
## Engine-specific notes
|
||||
|
||||
- **readium-based clients** (the Android app): submit
|
||||
percentage + `context_text` + a CFI generated from the laid-out
|
||||
WebView. Their locally-generated CFI spine steps use readium
|
||||
numbering — the server heals the difference; nothing to do.
|
||||
- **Web (foliate)**: submit all three tiers; foliate CFIs are the same
|
||||
currency the server stores.
|
||||
- **KOReader**: submits a CRE xpointer in the `epubcfi` field of the
|
||||
device payload (historical field name; it is a CRE xpointer, not a
|
||||
CFI). The server converts CRE → canonical at ingest and canonical →
|
||||
CRE on pull (`koreader_xpointer` in the metadata response).
|
||||
- **Kobo**: submits kepub CFI locators, converted server-side the same
|
||||
way.
|
||||
- **Fixed-layout content** (PDF/CBZ): the page index is the canonical
|
||||
locator; CFI/xpointer are meaningless and neither submitted nor
|
||||
served.
|
||||
@@ -0,0 +1,91 @@
|
||||
# Update Media Item Progress
|
||||
|
||||
Submit reading progress for a media item. This is the position-authority
|
||||
ingest point: the server independently verifies the submission against
|
||||
the book and heals it when the client's projection is wrong.
|
||||
|
||||
**Endpoint**: `PUT /api/media-items/:id/progress`
|
||||
**Auth**: Required (Bearer token)
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
See the [Position Contract](position-contract.md) for the three-tier
|
||||
submission model, the verification/healing semantics, and the offset
|
||||
currencies.
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | ---------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
## Request Body
|
||||
|
||||
All fields are optional; submit what your renderer can observe.
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----- | ---- | ----------- |
|
||||
| `percentage` | float | Position as a fraction of the whole book (0..1). Tier 1 — the universal field. |
|
||||
| `context_text` | string | Up to 100 whitespace-normalized chars starting at the anchor. Tier 2 — enables verification and healing. |
|
||||
| `epubcfi` | string | Standard wrapped CFI (UTF-16 terminals). Tier 3 — the structural anchor. Note: the server re-derives the stored canonical CFI; a client's own spine numbering is healed if it disagrees with the OPF spine. |
|
||||
| `character_offset` | int | Book-wide rune offset. Accepted but recomputed server-side from the verified anchor on every verified save. |
|
||||
| `current_page`, `total_pages` | int | Fixed-layout position. For fixed-layout formats the page index is the canonical locator. |
|
||||
| `chapter`, `chapter_progress` | int, float | Chapter index and within-chapter fraction. |
|
||||
| `reading_mode` | string | `paged` or `scrolled` (fixed-layout reader state). |
|
||||
| `zoom_level`, `scroll_position_x`, `scroll_position_y` | float | Fixed-layout viewport state. |
|
||||
|
||||
### Example Request (reflowable, all three tiers)
|
||||
|
||||
```http
|
||||
PUT /api/media-items/774641f9-317b-4087-8e04-53bb4392ae56/progress
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"percentage": 0.0415,
|
||||
"context_text": "was at war with one of these Powers it was generally at peace with the other. But what was strange w",
|
||||
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
|
||||
}
|
||||
```
|
||||
|
||||
### Example Request (fixed-layout)
|
||||
|
||||
```json
|
||||
{
|
||||
"percentage": 0.15,
|
||||
"current_page": 30,
|
||||
"total_pages": 194,
|
||||
"reading_mode": "paged"
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- The submission is verified against the book's XHTML when
|
||||
`context_text` is present and the book is convertible reflowable: the
|
||||
CFI is resolved, the text at the anchor is compared with
|
||||
`context_text`, and mismatches heal by text search (percentage
|
||||
disambiguates repeats). See [Position Contract](position-contract.md).
|
||||
- **Anti-clobber guard**: a submission with `percentage < 0.005` is
|
||||
ignored with `{"status": "ignored"}` when the stored row already holds
|
||||
a percentage above `0.01` — re-opening a book at its first page does
|
||||
not wipe real progress.
|
||||
- The response is the stored row after verification, including the
|
||||
healed `epubcfi` and the refreshed `character_offset` when
|
||||
verification ran.
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
The saved progress row (same shape as
|
||||
[GET](get_media_progress.md), minus the GET-time handles). The
|
||||
`epubcfi` and `percentage` in the response are the server-verified
|
||||
values, which may differ from the submitted ones when healing occurred.
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 400 | Invalid request data |
|
||||
| 401 | Invalid or expired token |
|
||||
| 500 | Database error |
|
||||
@@ -1,68 +0,0 @@
|
||||
# Update Reading Progress
|
||||
|
||||
Update reading progress for a media item. This will sync across all devices via WebSocket.
|
||||
|
||||
**Endpoint**: `PUT /api/media-items/{media_id}/progress`
|
||||
**Auth**: Required
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------ | -------- | --------------- |
|
||||
| media_id | string | Yes | Media item UUID |
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --------------------------- | ------- | -------- | ------------------------------------------------- |
|
||||
| source | string | Yes | Progress source (e.g., "web", "koreader", "kobo") |
|
||||
| location | object | Yes | Location information |
|
||||
| location.percentage | float | No | Progress percentage (0-1) |
|
||||
| location.epubcfi | string | No | EPUB CFI location |
|
||||
| location.character | integer | No | Character offset |
|
||||
| location.chapter | integer | No | Chapter number |
|
||||
| location.page | integer | No | Current page |
|
||||
| location.total_pages | integer | No | Total pages |
|
||||
| device_metadata | object | No | Device metadata |
|
||||
| device_metadata.device_type | string | No | Device type |
|
||||
| device_metadata.user_agent | string | No | User agent string |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "web",
|
||||
"location": {
|
||||
"percentage": 0.45678,
|
||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
||||
"character": 15432,
|
||||
"chapter": 3,
|
||||
"page": 89,
|
||||
"total_pages": 200
|
||||
},
|
||||
"device_metadata": {
|
||||
"device_type": "web",
|
||||
"user_agent": "Mozilla/5.0..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"sync_status": "success",
|
||||
"progress_updated": true,
|
||||
"devices_notified": ["device-1", "device-2"],
|
||||
"broadcast": true
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 400 | Invalid location data |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -1,56 +0,0 @@
|
||||
# Update Universal Progress
|
||||
|
||||
Update universal reading progress for a media item.
|
||||
|
||||
**Endpoint**: `POST /api/progress/:id`
|
||||
**Auth**: Required
|
||||
**Content-Type**: `application/json`
|
||||
|
||||
## Path Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --------- | ------------- | -------- | --------------- |
|
||||
| id | string (UUID) | Yes | Media item UUID |
|
||||
|
||||
## Request Body
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------- | ------------- | -------- | ------------------------------------------- |
|
||||
| percentage | float | No | Progress percentage (0-100) |
|
||||
| position | integer | No | Current position in bytes |
|
||||
| page | integer | No | Current page number |
|
||||
| finished | boolean | No | Whether the book is finished |
|
||||
| device_id | string (UUID) | No | Device UUID (optional, for tracking source) |
|
||||
|
||||
### Example Request
|
||||
|
||||
```json
|
||||
{
|
||||
"percentage": 75.5,
|
||||
"position": 1234,
|
||||
"page": 150,
|
||||
"finished": false,
|
||||
"device_id": "device-uuid"
|
||||
}
|
||||
```
|
||||
|
||||
## Response (200 OK)
|
||||
|
||||
```json
|
||||
{
|
||||
"media_item_id": "uuid",
|
||||
"percentage": 75.5,
|
||||
"position": 1234,
|
||||
"page": 150,
|
||||
"finished": false,
|
||||
"updated_at": "2026-02-08T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ------------------------ |
|
||||
| 400 | Invalid request data |
|
||||
| 401 | Invalid or expired token |
|
||||
| 404 | Media item not found |
|
||||
@@ -30,10 +30,15 @@ The Bookhoard scanner provides comprehensive library management for ebooks, comi
|
||||
| -------- | ------------------------ |
|
||||
| EPUB | `.epub` |
|
||||
| PDF | `.pdf` |
|
||||
| Kindle | `.mobi`, `.azw`, `.azw3` |
|
||||
| Kindle | `.mobi` |
|
||||
| Text | `.txt`, `.rtf` |
|
||||
| Document | `.doc`, `.docx` |
|
||||
| Other | `.lit`, `.fb2`, `.pdb` |
|
||||
| Document | `.docx` |
|
||||
| Other | `.fb2`, `.pdb` |
|
||||
|
||||
DOC (Word 97-2003 binary) and LIT (Microsoft Reader) are deliberately
|
||||
unsupported: no credible JavaScript parser exists for either, and
|
||||
compromising on fidelity is against the project's format goals. Files
|
||||
with these extensions are not scanned.
|
||||
|
||||
### Comics
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Initiate a one-time scan of a library for ebooks, manga, or comics.
|
||||
|
||||
The scanner automatically detects and processes files based on the library type:
|
||||
|
||||
**Ebooks:** .epub, .pdf, .mobi, .azw, .azw3, .txt, .rtf, .doc, .docx, .lit, .fb2, .pdb
|
||||
**Ebooks:** .epub, .pdf, .mobi, .txt, .rtf, .docx, .fb2, .pdb
|
||||
|
||||
**Comics:** .cbz, .cbr, .cb7, .cbt, .pdf
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
||||
| **Read in the browser** | [User Portal → Web Reader Guide](user/reader-guide.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -90,6 +91,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
||||
| ...set up KOReader? | [KOReader Setup Guide](user/devices/koreader-setup.md) |
|
||||
| ...use a Kobo? | [Kobo Setup Guide](user/devices/kobo-setup.md) - native sync coming soon; KOReader works today |
|
||||
| ...understand sync? | [Sync Guide](user/sync-guide.md) |
|
||||
| ...read in the browser? | [Web Reader Guide](user/reader-guide.md) - reading, highlights, notes |
|
||||
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||
| ...use the API? | [API Reference](developer/api-reference.md) |
|
||||
|
||||
@@ -124,7 +124,7 @@ The following Calibre metadata is **not** imported (by design):
|
||||
|
||||
Bookhoard imports Calibre metadata for **all file types**:
|
||||
|
||||
- 📚 **Ebooks**: EPUB, MOBI, AZW3, PDF, TXT, etc.
|
||||
- 📚 **Ebooks**: EPUB, MOBI, PDF, TXT, etc.
|
||||
- 🎧 **Audiobooks**: MP3, M4B, M4A, etc.
|
||||
- 📰 **Comics**: CBZ, CBR, CB7, CBT, etc.
|
||||
- 📄 **Documents**: PDF, DOCX, etc.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Android App Setup Guide
|
||||
|
||||
The Bookhoard Android app is the native mobile client: browse your libraries, read EPUBs, PDFs, comics and manga, and sync progress, highlights, bookmarks and notes with the server.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- ✅ A Bookhoard instance running and reachable from your phone's network
|
||||
- ✅ The Bookhoard APK installed on your phone (Android 8.0+ / API 26+)
|
||||
- ✅ Your phone connected to the same network as the server (for a self-hosted LAN setup)
|
||||
|
||||
## Installing the app
|
||||
|
||||
The app is distributed as a sideloaded APK:
|
||||
|
||||
1. Copy the APK to your phone (USB, or any file-sync you trust)
|
||||
2. Tap the APK to install — approve the "install unknown apps" prompt for the app you installed from (file manager, browser, etc.)
|
||||
3. Upgrades install straight over the existing app and keep your data (login, downloads, reading state)
|
||||
|
||||
## First run
|
||||
|
||||
1. **Server URL** — enter your server's address. For a self-hosted LAN setup that is `http://<desktop-LAN-IP>:8765` (plain HTTP is expected here and fully supported; check the server machine's firewall allows port 8765 from your LAN)
|
||||
2. **Log in** with your Bookhoard account
|
||||
3. **Device registration** happens automatically — the app registers itself as a synced device so progress and annotations sync under your account
|
||||
|
||||
## Android 16+: the local-network permission
|
||||
|
||||
On Android 16 and newer, apps need explicit permission to talk to devices on your local network (and to non-HTTPS local addresses in general). **If the permission is missing, the app's logins to a LAN server time out with no visible cause** — the phone silently drops the traffic.
|
||||
|
||||
- The app **asks for the permission by itself** during setup, as soon as you enter a local server address — grant it when prompted
|
||||
- If it was denied (or you missed the prompt): **Settings → Apps → Bookhoard → Permissions → "Access local network devices" → Allow**, then try again
|
||||
- Servers reached over the public internet (HTTPS) are not affected by this permission
|
||||
|
||||
## Troubleshooting login failures
|
||||
|
||||
The login screen prints the underlying error after "Could not reach server: …" — read it to narrow the cause:
|
||||
|
||||
| Error | Meaning | What to check |
|
||||
|---|---|---|
|
||||
| `SocketTimeoutException` | The phone sent nothing that reached the server | On Android 16+ this is most often the **local-network permission** (above). Otherwise: wrong IP, phone on a different network/VLAN, or server down |
|
||||
| `ConnectException` (connection refused/blocked) | The phone reached the machine but nothing answered | Server container down, or a firewall rejecting port 8765 |
|
||||
| `UnknownHostException` | The hostname didn't resolve | Typo in the server URL, or a DNS/name issue (raw IPs avoid this) |
|
||||
|
||||
To verify the server is reachable from the phone at all, open the same URL in the phone's browser — the browser is not subject to the per-app local-network permission, so if the browser works but the app times out on Android 16+, it is the permission.
|
||||
@@ -0,0 +1,81 @@
|
||||
# Web Reader Guide
|
||||
|
||||
Bookhoard's built-in web reader works in any modern browser — desktop or
|
||||
mobile — and keeps your position, highlights, bookmarks, and notes in sync
|
||||
with the Android app and KOReader.
|
||||
|
||||
## Opening a Book
|
||||
|
||||
From your library or book page, click **Read**. The reader opens in an
|
||||
immersive view with the book's cover, title, and chapter in the top bar
|
||||
(EPUB) or page number (PDF/comics).
|
||||
|
||||
## Navigation
|
||||
|
||||
### On a phone or tablet (touch)
|
||||
|
||||
| Gesture | Action |
|
||||
|---|---|
|
||||
| **Swipe** left / right | Turn the page |
|
||||
| **Tap** once | Toggle the reader menu (headers, settings, drawers) |
|
||||
| **Tap** in a corner zone | With the menu open: jump back / forward by chapter |
|
||||
|
||||
Paging is swipe-only by design: taps are reserved for the menu so a page
|
||||
turn can never fire while you're trying to select text.
|
||||
|
||||
### On desktop
|
||||
|
||||
- Scroll wheel, trackpad, arrow keys, or **Page Up / Page Down** to page
|
||||
- Click the progress bar (bottom, with the menu open) to jump
|
||||
- Contents drawer for the table of contents; Pages grid for PDF/comics
|
||||
|
||||
## Text Selection & Highlights
|
||||
|
||||
### Selecting text (touch)
|
||||
|
||||
1. **Press and hold** a word (~half a second) until it highlights
|
||||
2. **Drag** to extend the selection word by word — it stays on the
|
||||
visible page
|
||||
3. **Drag the round handles** (EPUB) to fine-tune the start and end
|
||||
points
|
||||
4. Lift your finger and pause — the highlight menu appears **below** the
|
||||
selection, underneath the browser's own copy menu
|
||||
|
||||
The browser's address bar hides while a selection is active to give the
|
||||
page room; it returns as soon as the selection is cleared.
|
||||
|
||||
### Creating a highlight or note
|
||||
|
||||
With the selection menu open:
|
||||
|
||||
- **Color dots** — tap one to highlight immediately in that color
|
||||
- **Pencil** — opens a note; write it and tap **Highlight with note**
|
||||
to save the highlight and its note together
|
||||
- **Copy** — copies the selected text (works even over plain HTTP on
|
||||
your LAN)
|
||||
- While the note editor is open, you can still change the color — the
|
||||
popover stays put until you save or cancel
|
||||
|
||||
### Editing or removing a highlight
|
||||
|
||||
Tap anywhere on a painted highlight to reopen it in the menu. From there
|
||||
you can change its color, edit the note (pencil → **Save note**), or
|
||||
delete it (trash icon). Tapping anywhere else closes the menu.
|
||||
|
||||
### PDFs and comics
|
||||
|
||||
Text selection works the same way on PDFs. Highlights are stored as
|
||||
page-anchored rectangles and sync to the Android app. Comics/manga
|
||||
libraries disable annotation (bookmarks still work).
|
||||
|
||||
## Notes & Bookmarks
|
||||
|
||||
The **annotations drawer** (right side, from the open menu) lists your
|
||||
highlights, standalone notes, and bookmarks for the book — tap any entry
|
||||
to jump to it.
|
||||
|
||||
## Where Reading Position Is Stored
|
||||
|
||||
Position, highlights, bookmarks, and notes sync automatically through the
|
||||
server (see the [Sync Guide](sync-guide.md)). Close the tab whenever you
|
||||
like — the reader resumes exactly where you left off on any device.
|
||||
@@ -6,6 +6,12 @@ Welcome to the Bookhoard user documentation. This section contains guides for us
|
||||
|
||||
Learn how to configure your e-reader devices to sync with Bookhoard:
|
||||
|
||||
- **[Android App Guide](devices/android-app.md)** - The native Android client
|
||||
- Installing and upgrading the APK
|
||||
- Connecting to a self-hosted server over LAN
|
||||
- The Android 16+ local-network permission
|
||||
- Troubleshooting login failures
|
||||
|
||||
- **[KOReader Setup Guide](devices/koreader-setup.md)** - Complete guide for KOReader
|
||||
- Installation on Kindle/Kobo/PocketBook
|
||||
- Plugin setup with server-side device approval
|
||||
@@ -36,6 +42,14 @@ Learn how to configure your e-reader devices to sync with Bookhoard:
|
||||
- Troubleshooting
|
||||
- Best practices
|
||||
|
||||
## 📖 Reading
|
||||
|
||||
- **[Web Reader Guide](reader-guide.md)** - Reading and annotating in the browser
|
||||
- Touch navigation (swipe to page, tap for menu)
|
||||
- Selecting text: long-press, drag, handles
|
||||
- Highlights, notes, copy, and editing
|
||||
- PDFs and comics
|
||||
|
||||
## 🎨 Frontend Guide
|
||||
|
||||
**[Frontend Guide](frontend-guide.md)** - Learn how to use the Bookhoard web interface
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
github.com/ArcadiaLin/go-epub v0.1.1 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws=
|
||||
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo=
|
||||
github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ=
|
||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
@@ -12,8 +14,13 @@ github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU
|
||||
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -22,6 +29,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8Yc
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
|
||||
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
@@ -72,16 +81,23 @@ github.com/labstack/echo/v5 v5.1.0 h1:MvIRydoN+p9cx/zq8Lff6YXqUW2ZaEsOMISzEGSMrB
|
||||
github.com/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A=
|
||||
github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM=
|
||||
github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
|
||||
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e h1:aoZm08cpOy4WuID//EZDgcC4zIxODThtZNPirFr42+A=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -91,6 +107,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
|
||||
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
@@ -98,6 +116,8 @@ github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -118,16 +138,22 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
@@ -84,3 +84,14 @@ func getEnvInt(key string, defaultValue int) int {
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// ArchiveRetentionDays returns how many days a media item stays archived
|
||||
// (file missing from disk for two consecutive scans) before library scans
|
||||
// purge it for good. Reading progress, notes, and highlights survive the
|
||||
// archive window and are restored if the file returns; the purge deletes
|
||||
// them along with the row.
|
||||
// Configure via ARCHIVE_RETENTION_DAYS (default 90); 0 keeps archived items
|
||||
// until an admin purges them manually from the library admin page.
|
||||
func ArchiveRetentionDays() int {
|
||||
return getEnvInt("ARCHIVE_RETENTION_DAYS", 90)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
@@ -31,6 +31,7 @@ type Collections struct {
|
||||
Priority pgtype.Int4 `db:"priority" json:"priority"`
|
||||
IsSystemCollection pgtype.Bool `db:"is_system_collection" json:"is_system_collection"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||
}
|
||||
|
||||
type DeviceCatalogs struct {
|
||||
@@ -306,6 +307,9 @@ type MediaItems struct {
|
||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||
// Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
||||
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
|
||||
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
|
||||
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
|
||||
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||
@@ -535,6 +539,14 @@ type UserDashboardPreferences struct {
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type UserDashboardPreferencesGlobal struct {
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
HiddenCollections []string `db:"hidden_collections" json:"hidden_collections"`
|
||||
CollectionOrder []string `db:"collection_order" json:"collection_order"`
|
||||
ItemsPerSection pgtype.Int4 `db:"items_per_section" json:"items_per_section"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type Users struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// sqlc v1.31.1
|
||||
|
||||
package database
|
||||
|
||||
@@ -20,6 +20,9 @@ type Querier interface {
|
||||
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
||||
// Library Folders queries
|
||||
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
||||
// Second consecutive missing scan: hide the item from all browsing while
|
||||
// preserving reading history in case the file returns.
|
||||
ArchiveMediaItem(ctx context.Context, id pgtype.UUID) error
|
||||
// Bulk update format group for all media items
|
||||
BulkUpdateFormatGroups(ctx context.Context) error
|
||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
||||
@@ -27,10 +30,18 @@ type Querier interface {
|
||||
// Cleanup expired OPDS tokens
|
||||
CleanupExpiredOpdsTokens(ctx context.Context) error
|
||||
CleanupExpiredRefreshTokens(ctx context.Context, dollar_1 float64) error
|
||||
ClearCollectionItems(ctx context.Context, collectionID pgtype.UUID) error
|
||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||
// File is back on disk (by path or content hash): restore visibility and
|
||||
// reset the missing-scan counter. No-op for items that were never archived.
|
||||
ClearMediaItemArchive(ctx context.Context, id pgtype.UUID) error
|
||||
// Reset an item to scanned defaults: clears per-field user overrides so the
|
||||
// next rescan can freely overwrite user-customized metadata.
|
||||
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
CountArchivedMediaItems(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)
|
||||
@@ -69,6 +80,11 @@ type Querier interface {
|
||||
// MEDIA ITEM FORMATS QUERIES
|
||||
// Create media item format
|
||||
CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error)
|
||||
// API create path: race-safe create-or-detect. On a duplicate
|
||||
// (library_id, file_path) this inserts NOTHING and returns no row
|
||||
// (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||
// and a duplicate flag instead of pretending it created something.
|
||||
CreateMediaItemIfNew(ctx context.Context, arg CreateMediaItemIfNewParams) (MediaItems, error)
|
||||
// Media Notes queries
|
||||
CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error)
|
||||
CreateMediaNoteFull(ctx context.Context, arg CreateMediaNoteFullParams) (MediaNotes, error)
|
||||
@@ -164,6 +180,10 @@ type Querier interface {
|
||||
// CAROUSEL-STYLE DASHBOARD
|
||||
// ============================================
|
||||
// Dashboard preferences queries
|
||||
// The handler routes by scope: a Valid library_id targets the per-library
|
||||
// table below; an absent one targets user_dashboard_preferences_global
|
||||
// (the "All Libraries" view). Never pass NULL here — the loose NULL match
|
||||
// was removed because it returned an arbitrary library's row.
|
||||
GetDashboardPreferences(ctx context.Context, arg GetDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error)
|
||||
GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error)
|
||||
@@ -190,6 +210,8 @@ type Querier interface {
|
||||
GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error)
|
||||
GetFirstAdmin(ctx context.Context) (pgtype.UUID, error)
|
||||
GetFirstAdminExclude(ctx context.Context, id pgtype.UUID) (GetFirstAdminExcludeRow, error)
|
||||
// Global (all-libraries) scope: exactly one row per user.
|
||||
GetGlobalDashboardPreferences(ctx context.Context, userID pgtype.UUID) (UserDashboardPreferencesGlobal, error)
|
||||
GetHashConflict(ctx context.Context, id pgtype.UUID) (HashConflicts, error)
|
||||
GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error)
|
||||
GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error)
|
||||
@@ -233,6 +255,7 @@ type Querier interface {
|
||||
// ============================================
|
||||
GetMediaItemByFilePathForSync(ctx context.Context, filePath string) (MediaItems, error)
|
||||
GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error)
|
||||
GetMediaItemByLibraryAndPath(ctx context.Context, arg GetMediaItemByLibraryAndPathParams) (MediaItems, error)
|
||||
// Get media item by OPF identifier
|
||||
GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier pgtype.Text) (MediaItems, error)
|
||||
// Get media item by OPF UUID
|
||||
@@ -249,6 +272,12 @@ type Querier interface {
|
||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||
// Per-item user-data counts, used when choosing which duplicate copy to keep
|
||||
GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error)
|
||||
// Cross-library move detection: identical content registered in a different
|
||||
// library of the same type (ebooks stay with ebooks, comics with comics).
|
||||
// Candidates may be active copies (deliberate duplicates - leave alone) or
|
||||
// hidden/archived rows whose file vanished (move candidates - the scanner
|
||||
// verifies disk state before repointing).
|
||||
GetMediaItemsBySHA256AndSameLibraryType(ctx context.Context, arg GetMediaItemsBySHA256AndSameLibraryTypeParams) ([]GetMediaItemsBySHA256AndSameLibraryTypeRow, error)
|
||||
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
||||
// ============================================
|
||||
// ANNOTATION SYNC QUERIES (notes)
|
||||
@@ -283,6 +312,12 @@ type Querier interface {
|
||||
GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error)
|
||||
GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error)
|
||||
GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error)
|
||||
// System collections are permanent: identity is (user_id, query_type).
|
||||
// Restore/reset operate IN PLACE (the row id — and everything referencing
|
||||
// it: shelf mappings, prefs order, links — never churns). Deleted sections
|
||||
// are TOMBSTONED (deleted_at) and stay invisible until restored.
|
||||
// Unfiltered by design: restore/seed must resolve tombstoned rows too.
|
||||
GetSystemCollectionByQueryType(ctx context.Context, arg GetSystemCollectionByQueryTypeParams) (Collections, error)
|
||||
// Dashboard collections queries
|
||||
GetSystemCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
|
||||
// SYSTEM CONFIG QUERIES
|
||||
@@ -337,9 +372,17 @@ type Querier interface {
|
||||
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
||||
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||
// Items hidden from libraries: either missing from disk (waiting on the
|
||||
// second scan that archives them) or already archived and pending purge.
|
||||
// Feeds the admin archived-items page. match_* columns identify an active
|
||||
// item with identical content (same SHA-256) so the UI can offer merging the
|
||||
// archived row's reading data into its active twin instead of waiting for a
|
||||
// file that will never return.
|
||||
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
|
||||
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
|
||||
ListMediaItemsByLibraryIncludingArchived(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryIncludingArchivedRow, error)
|
||||
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
|
||||
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||
@@ -353,6 +396,22 @@ type Querier interface {
|
||||
// List unresolved unlinked books with pagination
|
||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||
// First consecutive scan that cannot find the file on disk.
|
||||
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
|
||||
// Content reappeared at a new location while the old path vanished: treat as
|
||||
// a move. Repoints the row (and refreshes file-level fields) so reading
|
||||
// history follows the book; archive/missing state is cleared separately.
|
||||
MoveMediaItemFilePath(ctx context.Context, arg MoveMediaItemFilePathParams) error
|
||||
// Cross-library move: identical content reappeared in a different library of
|
||||
// the same type while the old file vanished. Repoints the row so reading
|
||||
// history, annotations and collections follow the book; archive/missing
|
||||
// state is cleared. library_type_name is refreshed by trigger.
|
||||
MoveMediaItemToLibrary(ctx context.Context, arg MoveMediaItemToLibraryParams) error
|
||||
// Manual bulk purge from the library admin page.
|
||||
PurgeAllArchivedMediaItems(ctx context.Context) ([]PurgeAllArchivedMediaItemsRow, error)
|
||||
// Retention sweep at library-scan time: hard-delete archived items older
|
||||
// than the cutoff. Cascades remove reading history with the row.
|
||||
PurgeExpiredArchivedMediaItems(ctx context.Context, archivedAt pgtype.Timestamptz) ([]PurgeExpiredArchivedMediaItemsRow, 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
|
||||
@@ -379,6 +438,9 @@ type Querier interface {
|
||||
RestoreMediaBookmarkByID(ctx context.Context, arg RestoreMediaBookmarkByIDParams) (int64, error)
|
||||
RestoreMediaHighlightByID(ctx context.Context, arg RestoreMediaHighlightByIDParams) (int64, error)
|
||||
RestoreMediaNoteByID(ctx context.Context, arg RestoreMediaNoteByIDParams) (int64, error)
|
||||
// Full reset, in place: canonical metadata, back on the dashboard,
|
||||
// un-tombstoned. Item assignments are cleared by the caller ("full").
|
||||
RestoreSystemCollectionRow(ctx context.Context, arg RestoreSystemCollectionRowParams) (Collections, error)
|
||||
RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error
|
||||
RevokeDevice(ctx context.Context, id pgtype.UUID) error
|
||||
// Revoke OPDS token
|
||||
@@ -403,6 +465,7 @@ type Querier interface {
|
||||
TombstoneMediaHighlightByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
|
||||
TombstoneMediaNoteByID(ctx context.Context, id pgtype.UUID) error
|
||||
TombstoneSystemCollection(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)
|
||||
@@ -463,6 +526,7 @@ type Querier interface {
|
||||
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||
|
||||
+1386
-84
File diff suppressed because it is too large.
Load diff
@@ -141,7 +141,9 @@ ORDER BY l.created_at ASC;
|
||||
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
|
||||
LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.missing_scan_count = 0
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
GROUP BY l.id;
|
||||
|
||||
@@ -152,6 +154,19 @@ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $
|
||||
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: CreateMediaItemIfNew :one
|
||||
-- API create path: race-safe create-or-detect. On a duplicate
|
||||
-- (library_id, file_path) this inserts NOTHING and returns no row
|
||||
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
||||
-- and a duplicate flag instead of pretending it created something.
|
||||
INSERT INTO media_items (library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, library_type_name)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44)
|
||||
ON CONFLICT (library_id, file_path) DO NOTHING
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetMediaItemByLibraryAndPath :one
|
||||
SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2;
|
||||
|
||||
-- name: GetMediaItem :one
|
||||
SELECT * FROM media_items WHERE id = $1;
|
||||
|
||||
@@ -160,6 +175,8 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
||||
|
||||
-- name: ListMediaItemsByLibrary :many
|
||||
@@ -167,6 +184,16 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.library_id = $1
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
-- name: ListMediaItemsByLibraryIncludingArchived :many
|
||||
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.library_id = $1
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
@@ -175,7 +202,9 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
WHERE mi.library_id = sqlc.narg('library_id')
|
||||
WHERE mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
||||
@@ -282,10 +311,104 @@ UPDATE media_items SET
|
||||
alternate_info = $35,
|
||||
scan_information = $36,
|
||||
summary = $37,
|
||||
metadata_overrides = $38,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearMediaItemMetadataOverrides :exec
|
||||
-- Reset an item to scanned defaults: clears per-field user overrides so the
|
||||
-- next rescan can freely overwrite user-customized metadata.
|
||||
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: MarkMediaItemMissing :exec
|
||||
-- First consecutive scan that cannot find the file on disk.
|
||||
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: MoveMediaItemFilePath :exec
|
||||
-- Content reappeared at a new location while the old path vanished: treat as
|
||||
-- a move. Repoints the row (and refreshes file-level fields) so reading
|
||||
-- history follows the book; archive/missing state is cleared separately.
|
||||
UPDATE media_items SET file_path = $2, file_size = $3, missing_scan_count = 0,
|
||||
archived_at = NULL, updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ArchiveMediaItem :exec
|
||||
-- Second consecutive missing scan: hide the item from all browsing while
|
||||
-- preserving reading history in case the file returns.
|
||||
UPDATE media_items SET archived_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: MoveMediaItemToLibrary :exec
|
||||
-- Cross-library move: identical content reappeared in a different library of
|
||||
-- the same type while the old file vanished. Repoints the row so reading
|
||||
-- history, annotations and collections follow the book; archive/missing
|
||||
-- state is cleared. library_type_name is refreshed by trigger.
|
||||
UPDATE media_items
|
||||
SET library_id = $2,
|
||||
file_path = $3,
|
||||
file_size = $4,
|
||||
archived_at = NULL,
|
||||
missing_scan_count = 0,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ClearMediaItemArchive :exec
|
||||
-- File is back on disk (by path or content hash): restore visibility and
|
||||
-- reset the missing-scan counter. No-op for items that were never archived.
|
||||
UPDATE media_items SET archived_at = NULL, missing_scan_count = 0, updated_at = NOW()
|
||||
WHERE id = $1 AND (archived_at IS NOT NULL OR missing_scan_count > 0);
|
||||
|
||||
-- name: PurgeExpiredArchivedMediaItems :many
|
||||
-- Retention sweep at library-scan time: hard-delete archived items older
|
||||
-- than the cutoff. Cascades remove reading history with the row.
|
||||
DELETE FROM media_items
|
||||
WHERE archived_at IS NOT NULL AND archived_at < $1
|
||||
RETURNING id, title;
|
||||
|
||||
-- name: PurgeAllArchivedMediaItems :many
|
||||
-- Manual bulk purge from the library admin page.
|
||||
DELETE FROM media_items
|
||||
WHERE archived_at IS NOT NULL
|
||||
RETURNING id, title;
|
||||
|
||||
-- name: CountArchivedMediaItems :one
|
||||
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL;
|
||||
|
||||
-- name: ListHiddenMediaItems :many
|
||||
-- Items hidden from libraries: either missing from disk (waiting on the
|
||||
-- second scan that archives them) or already archived and pending purge.
|
||||
-- Feeds the admin archived-items page. match_* columns identify an active
|
||||
-- item with identical content (same SHA-256) so the UI can offer merging the
|
||||
-- archived row's reading data into its active twin instead of waiting for a
|
||||
-- file that will never return.
|
||||
SELECT mi.*, l.name as library_name, lt.name as library_type_name,
|
||||
twin.id AS match_id, COALESCE(twin.title, '') AS match_title,
|
||||
twin_library.name AS match_library_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT t.id, t.title, t.library_id
|
||||
FROM media_items t
|
||||
JOIN libraries tl ON t.library_id = tl.id
|
||||
JOIN library_types tlt ON tl.library_type_id = tlt.id
|
||||
WHERE t.file_sha256 = mi.file_sha256
|
||||
AND t.id <> mi.id
|
||||
AND t.archived_at IS NULL
|
||||
AND t.missing_scan_count = 0
|
||||
ORDER BY (tlt.id = lt.id) DESC,
|
||||
(tl.id = l.id) DESC,
|
||||
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = t.id) DESC,
|
||||
t.created_at ASC
|
||||
LIMIT 1
|
||||
) twin ON true
|
||||
LEFT JOIN libraries twin_library ON twin_library.id = twin.library_id
|
||||
WHERE mi.archived_at IS NOT NULL OR mi.missing_scan_count > 0
|
||||
ORDER BY mi.archived_at NULLS LAST, mi.updated_at DESC;
|
||||
|
||||
-- name: DeleteMediaItem :exec
|
||||
DELETE FROM media_items WHERE id = $1;
|
||||
|
||||
@@ -434,6 +557,8 @@ JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (
|
||||
mi.title ILIKE sqlc.narg('search_pattern') OR
|
||||
@@ -506,6 +631,8 @@ JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL
|
||||
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
-- Fuzzy author filter
|
||||
@@ -514,6 +641,8 @@ WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
||||
-- Fuzzy genre filter
|
||||
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
||||
-- Fuzzy publisher filter
|
||||
AND (sqlc.narg('publisher_filter') = '' OR word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')) > 0.3)
|
||||
-- Fuzzy language filter
|
||||
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
||||
-- Tags filter (NEW - fuzzy match against tags array)
|
||||
@@ -588,6 +717,7 @@ ORDER BY
|
||||
ELSE 0 END,
|
||||
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
|
||||
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')),
|
||||
word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')),
|
||||
(SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag))
|
||||
FROM unnest(mi.tags_search) AS tag)
|
||||
)
|
||||
@@ -639,6 +769,8 @@ FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
|
||||
AND mi.author IS NOT NULL
|
||||
@@ -656,6 +788,8 @@ FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
|
||||
AND mi.genre IS NOT NULL
|
||||
@@ -690,6 +824,8 @@ FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
|
||||
AND mi.series IS NOT NULL
|
||||
@@ -707,6 +843,8 @@ FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND mi.library_id = sqlc.narg('library_id')
|
||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
|
||||
AND mi.language IS NOT NULL
|
||||
@@ -1811,6 +1949,24 @@ SELECT * FROM media_items WHERE file_sha256 = $1;
|
||||
-- name: GetMediaItemBySHA256AndLibrary :one
|
||||
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
|
||||
|
||||
-- name: GetMediaItemsBySHA256AndSameLibraryType :many
|
||||
-- Cross-library move detection: identical content registered in a different
|
||||
-- library of the same type (ebooks stay with ebooks, comics with comics).
|
||||
-- Candidates may be active copies (deliberate duplicates - leave alone) or
|
||||
-- hidden/archived rows whose file vanished (move candidates - the scanner
|
||||
-- verifies disk state before repointing).
|
||||
SELECT mi.*, lt.name AS library_type_name
|
||||
FROM media_items mi
|
||||
JOIN libraries l ON mi.library_id = l.id
|
||||
JOIN library_types lt ON l.library_type_id = lt.id
|
||||
JOIN libraries target_lib ON target_lib.id = sqlc.arg('library_id')
|
||||
WHERE mi.file_sha256 = sqlc.arg('file_sha256')
|
||||
AND mi.library_id <> sqlc.arg('library_id')
|
||||
AND lt.id = target_lib.library_type_id
|
||||
ORDER BY (mi.archived_at IS NOT NULL) DESC,
|
||||
mi.missing_scan_count DESC,
|
||||
mi.created_at ASC;
|
||||
|
||||
-- List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||
-- name: ListMediaItemsBySHA256AndLibrary :many
|
||||
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
|
||||
@@ -2008,7 +2164,7 @@ SELECT * FROM collections WHERE id = $1;
|
||||
|
||||
-- Get collections by user
|
||||
-- name: GetCollectionsByUser :many
|
||||
SELECT * FROM collections WHERE user_id = $1 ORDER BY created_at DESC;
|
||||
SELECT * FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC;
|
||||
|
||||
-- Get collection with book count
|
||||
-- name: GetCollectionWithBookCount :one
|
||||
@@ -2056,6 +2212,8 @@ SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id
|
||||
FROM collection_items ci
|
||||
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = $1
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY ci.added_at DESC;
|
||||
|
||||
-- Get collections for book
|
||||
@@ -2063,7 +2221,8 @@ ORDER BY ci.added_at DESC;
|
||||
SELECT c.*
|
||||
FROM collections c
|
||||
JOIN collection_items ci ON c.id = ci.collection_id
|
||||
WHERE ci.media_item_id = $1;
|
||||
WHERE ci.media_item_id = $1
|
||||
AND c.deleted_at IS NULL;
|
||||
|
||||
-- Check if book is in collection
|
||||
-- name: IsBookInCollection :one
|
||||
@@ -2290,9 +2449,13 @@ LIMIT $1 OFFSET $2;
|
||||
-- ============================================
|
||||
|
||||
-- Dashboard preferences queries
|
||||
-- The handler routes by scope: a Valid library_id targets the per-library
|
||||
-- table below; an absent one targets user_dashboard_preferences_global
|
||||
-- (the "All Libraries" view). Never pass NULL here — the loose NULL match
|
||||
-- was removed because it returned an arbitrary library's row.
|
||||
-- name: GetDashboardPreferences :one
|
||||
SELECT * FROM user_dashboard_preferences
|
||||
WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid);
|
||||
WHERE user_id = sqlc.narg('user_id') AND library_id = sqlc.narg('library_id')::uuid;
|
||||
|
||||
-- name: UpsertDashboardPreferences :one
|
||||
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
||||
@@ -2314,12 +2477,29 @@ SET hidden_collections = $2,
|
||||
WHERE user_id = $1 AND library_id = $5
|
||||
RETURNING *;
|
||||
|
||||
-- Global (all-libraries) scope: exactly one row per user.
|
||||
-- name: GetGlobalDashboardPreferences :one
|
||||
SELECT user_id, hidden_collections, collection_order, items_per_section, updated_at FROM user_dashboard_preferences_global
|
||||
WHERE user_id = $1;
|
||||
|
||||
-- name: UpsertGlobalDashboardPreferences :one
|
||||
INSERT INTO user_dashboard_preferences_global (user_id, hidden_collections, collection_order, items_per_section)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE SET
|
||||
hidden_collections = EXCLUDED.hidden_collections,
|
||||
collection_order = EXCLUDED.collection_order,
|
||||
items_per_section = EXCLUDED.items_per_section,
|
||||
updated_at = NOW()
|
||||
RETURNING user_id, hidden_collections, collection_order, items_per_section, updated_at;
|
||||
|
||||
-- Dashboard collections queries
|
||||
-- name: GetSystemCollectionsForDashboard :many
|
||||
SELECT * FROM collections
|
||||
WHERE user_id = $1
|
||||
AND is_system_collection = true
|
||||
AND show_on_dashboard = true
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY priority ASC;
|
||||
|
||||
-- name: GetUserCollectionsForDashboard :many
|
||||
@@ -2327,6 +2507,7 @@ SELECT c.* FROM collections c
|
||||
WHERE c.user_id = $1
|
||||
AND c.show_on_dashboard = true
|
||||
AND c.is_system_collection = false
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY priority ASC;
|
||||
|
||||
-- name: DeleteUserSystemCollection :exec
|
||||
@@ -2335,6 +2516,38 @@ WHERE user_id = $1
|
||||
AND name = $2
|
||||
AND is_system_collection = true;
|
||||
|
||||
-- System collections are permanent: identity is (user_id, query_type).
|
||||
-- Restore/reset operate IN PLACE (the row id — and everything referencing
|
||||
-- it: shelf mappings, prefs order, links — never churns). Deleted sections
|
||||
-- are TOMBSTONED (deleted_at) and stay invisible until restored.
|
||||
|
||||
-- name: GetSystemCollectionByQueryType :one
|
||||
-- Unfiltered by design: restore/seed must resolve tombstoned rows too.
|
||||
SELECT * FROM collections
|
||||
WHERE user_id = $1
|
||||
AND query_type = $2
|
||||
AND is_system_collection = true;
|
||||
|
||||
-- name: TombstoneSystemCollection :exec
|
||||
UPDATE collections SET deleted_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: RestoreSystemCollectionRow :one
|
||||
-- Full reset, in place: canonical metadata, back on the dashboard,
|
||||
-- un-tombstoned. Item assignments are cleared by the caller ("full").
|
||||
UPDATE collections
|
||||
SET name = $2,
|
||||
description = $3,
|
||||
icon = $4,
|
||||
color = $5,
|
||||
priority = $6,
|
||||
show_on_dashboard = true,
|
||||
deleted_at = NULL
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: ClearCollectionItems :exec
|
||||
DELETE FROM collection_items WHERE collection_id = $1;
|
||||
|
||||
-- name: CreateSystemCollection :one
|
||||
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
||||
@@ -2362,12 +2575,16 @@ INNER JOIN (
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) rp ON rp.media_item_id = mi.id
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY rp.last_read_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetRecentlyAddedItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
@@ -2381,12 +2598,16 @@ INNER JOIN (
|
||||
ORDER BY media_item_id, last_read_at DESC
|
||||
) rp ON rp.media_item_id = mi.id
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY rp.last_read_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetNotStartedItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM reading_progress rp
|
||||
WHERE rp.media_item_id = mi.id
|
||||
@@ -2401,12 +2622,16 @@ SELECT mi.*, ci.excluded FROM media_items mi
|
||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||
WHERE ci.collection_id = sqlc.narg('collection_id')
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY ci.added_at DESC
|
||||
LIMIT sqlc.narg('limit');
|
||||
|
||||
-- name: GetLibraryItems :many
|
||||
SELECT mi.* FROM media_items mi
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
ORDER BY mi.created_at DESC;
|
||||
|
||||
-- name: GetDistinctSeries :many
|
||||
@@ -2454,7 +2679,9 @@ next_books AS (
|
||||
usp.last_read_at
|
||||
FROM media_items mi
|
||||
JOIN user_series_progress usp ON mi.series = usp.series
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
WHERE mi.archived_at IS NULL
|
||||
AND mi.missing_scan_count = 0
|
||||
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
||||
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
||||
)
|
||||
|
||||
@@ -77,6 +77,12 @@ func parseTableNames() ([]string, error) {
|
||||
tables := make(map[string]bool)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// Skip SQL comments: a doc line like "-- ... declared in CREATE
|
||||
// TABLE above" would otherwise register a phantom table and fail
|
||||
// startup verification.
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "--") {
|
||||
continue
|
||||
}
|
||||
matches := pattern.FindStringSubmatch(line)
|
||||
if len(matches) > 1 {
|
||||
tableName := matches[1]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseTableNamesIgnoresComments guards against phantom tables parsed out
|
||||
// of SQL comments (e.g. "-- ... declared in CREATE TABLE above" once
|
||||
// registered a table named "above" and crashed startup verification).
|
||||
func TestParseTableNamesIgnoresComments(t *testing.T) {
|
||||
tables, err := parseTableNames()
|
||||
if err != nil {
|
||||
t.Fatalf("parseTableNames() error: %v", err)
|
||||
}
|
||||
if len(tables) == 0 {
|
||||
t.Fatal("parseTableNames() returned no tables")
|
||||
}
|
||||
|
||||
for _, name := range tables {
|
||||
// Every parsed table must correspond to a real CREATE TABLE statement
|
||||
// at the start of a (non-comment) line.
|
||||
stmt := regexp.MustCompile(`(?m)^CREATE TABLE (?:IF NOT EXISTS )?(?:\w+\.)?` + regexp.QuoteMeta(name) + `\s`)
|
||||
if !stmt.MatchString(SchemaFile) {
|
||||
t.Errorf("parseTableNames() returned phantom table %q with no matching CREATE TABLE statement", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ var SettingDefaults = []SettingDefault{
|
||||
{Key: "password_require_lower", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one lowercase letter (a-z)"},
|
||||
{Key: "password_require_number", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one number (0-9)"},
|
||||
{Key: "password_require_special", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Password Quality", Description: "Require at least one special character"},
|
||||
{Key: "registration_enabled", Value: "true", Type: SettingTypeBool, Category: "security", Group: "Public Registration", Description: "Allow users to create their own accounts (admins can always create accounts)"},
|
||||
{Key: "opds_default_page_size", Value: "50", Type: SettingTypeInt, Min: "1", Max: "500", Category: "api", Group: "OPDS Catalog", Description: "Default OPDS page size"},
|
||||
{Key: "opds_max_page_size", Value: "200", Type: SettingTypeInt, Min: "1", Max: "1000", Category: "api", Group: "OPDS Catalog", Description: "Maximum OPDS page size"},
|
||||
{Key: "device_rate_sync_per_min", Value: "60", Type: SettingTypeInt, Min: "1", Max: "10000", Category: "api", Group: "Device Rate Limits", Description: "Device sync requests per minute"},
|
||||
@@ -188,6 +189,12 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
||||
// AutoScanEnabled reports whether auto-scanning is on.
|
||||
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||
|
||||
// RegistrationEnabled reports whether public signup is allowed. Admin user
|
||||
// creation is never affected — only self-service registration rides this
|
||||
// flag; handlers additionally grant the first-user exception (see
|
||||
// handlers/auth.go Register).
|
||||
func (r *SettingsRegistry) RegistrationEnabled() bool { return r.getBool("registration_enabled") }
|
||||
|
||||
// DefaultTimezone returns the configured default timezone name.
|
||||
func (r *SettingsRegistry) DefaultTimezone() string { return r.raw("default_timezone") }
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ type UserProfile struct {
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Role string `json:"role"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
@@ -173,6 +174,21 @@ func (h *AuthHandler) Register(c *echo.Context) error {
|
||||
req.Role = strings.ToLower(req.Role)
|
||||
}
|
||||
|
||||
// Public registration can be disabled by admins. The giveaway exception
|
||||
// is an unconfigured server: with zero admins the first-user-becomes-
|
||||
// admin bootstrap path must stay reachable (mirrors setupRedirect-
|
||||
// Middleware's "users exist" reasoning). Admin account creation via the
|
||||
// admin API never rides this route, so it is unaffected regardless.
|
||||
if h.settings != nil && !h.settings.RegistrationEnabled() {
|
||||
count, countErr := h.db.CountAdmins(c.Request().Context())
|
||||
if countErr != nil || count > 0 {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusForbidden, `<div class="text-red-500">Registration is disabled on this server</div>`)
|
||||
}
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "registration is disabled on this server"})
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := h.db.GetUserByEmail(c.Request().Context(), req.Email); err == nil {
|
||||
if c.Request().Header.Get("HX-Request") == "true" {
|
||||
return c.HTML(http.StatusConflict, `<div class="text-red-500">Email already exists</div>`)
|
||||
@@ -485,7 +501,14 @@ window.location.href = '%s';
|
||||
|
||||
// GetProfile handles GET /api/auth/profile
|
||||
func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
ctxUser := MustGetAuthenticatedUser(c)
|
||||
// The context user is a JWT-claims stub (id/email/username/role) —
|
||||
// it carries no theme. Read the full row: clients (the Android app)
|
||||
// sync their app-chrome theme from this field.
|
||||
user, err := h.db.GetUser(c.Request().Context(), ctxUser.ID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load profile"})
|
||||
}
|
||||
|
||||
firstName := ""
|
||||
if user.FirstName.Valid {
|
||||
@@ -502,6 +525,7 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Role: user.Role,
|
||||
Theme: user.Theme.String,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1067,7 +1091,27 @@ func (h *AuthHandler) CreateDefaultCollectionsForUser(ctx context.Context, userI
|
||||
}
|
||||
|
||||
for _, col := range defaultCollections {
|
||||
_, err := h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
// Idempotent by the STABLE identity (user + query_type): a
|
||||
// preserved or renamed row heals to canonical metadata instead of
|
||||
// duplicating (the naming-drift bug). Insert only when absent.
|
||||
existing, err := h.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
||||
UserID: userID,
|
||||
QueryType: pgtype.Text{String: col.QueryType, Valid: true},
|
||||
})
|
||||
if err == nil {
|
||||
if _, uerr := h.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
||||
ID: existing.ID,
|
||||
Name: col.Name,
|
||||
Description: pgtype.Text{String: col.Description, Valid: true},
|
||||
Icon: pgtype.Text{String: col.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: col.Color, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: col.Priority, Valid: true},
|
||||
}); uerr != nil {
|
||||
return uerr
|
||||
}
|
||||
continue
|
||||
}
|
||||
_, err = h.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
UserID: userID,
|
||||
Name: col.Name,
|
||||
Description: pgtype.Text{String: col.Description, Valid: true},
|
||||
|
||||
@@ -111,6 +111,9 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
req.ViewSettings,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrCollectionNameTaken) {
|
||||
return c.JSON(http.StatusConflict, map[string]string{"error": "a collection with this name already exists"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,9 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
|
||||
userUUID := uuid.UUID(user.ID.Bytes)
|
||||
|
||||
var req struct {
|
||||
LibraryID string `json:"library_id"`
|
||||
// nil (JSON null/absent) = the global All Libraries scope;
|
||||
// empty string is a LOST value, not a scope — rejected loudly.
|
||||
LibraryID *string `json:"library_id"`
|
||||
HiddenCollections []string `json:"hidden_collections"`
|
||||
CollectionOrder []string `json:"collection_order"`
|
||||
ItemsPerSection int `json:"items_per_section"`
|
||||
@@ -85,10 +87,17 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
||||
}
|
||||
|
||||
libUUID, err := uuid.Parse(req.LibraryID)
|
||||
var libUUID pgtype.UUID
|
||||
if req.LibraryID != nil {
|
||||
if *req.LibraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
parsed, err := uuid.Parse(*req.LibraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
// Sanitize preferences (remove duplicates)
|
||||
cleanHidden, cleanOrder := h.dashboardService.SanitizeDashboardPreferences(req.HiddenCollections, req.CollectionOrder)
|
||||
@@ -97,7 +106,7 @@ func (h *DashboardHandler) UpdatePreferences(c *echo.Context) error {
|
||||
|
||||
prefs, err := h.dashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
LibraryID: libUUID,
|
||||
HiddenCollections: req.HiddenCollections,
|
||||
CollectionOrder: req.CollectionOrder,
|
||||
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
|
||||
|
||||
+115
-12
@@ -6,11 +6,16 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
"github.com/skip2/go-qrcode"
|
||||
@@ -106,7 +111,14 @@ type PendingRegistration struct {
|
||||
SyncEndpoints map[string]string
|
||||
}
|
||||
|
||||
var pendingRegistrations = make(map[string]*PendingRegistration)
|
||||
// pendingRegistrations holds in-flight (unapproved) device registrations.
|
||||
// HTTP handlers touch it from multiple goroutines — every access must hold
|
||||
// pendingMu (Go maps are not safe for concurrent use; a racing write is a
|
||||
// runtime fatal, not an error).
|
||||
var (
|
||||
pendingRegistrations = make(map[string]*PendingRegistration)
|
||||
pendingMu sync.Mutex
|
||||
)
|
||||
|
||||
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||
req := DeviceRegistrationRequest{}
|
||||
@@ -130,7 +142,9 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
pendingMu.Lock()
|
||||
pendingRegistrations[registrationID] = registration
|
||||
pendingMu.Unlock()
|
||||
|
||||
authURL := fmt.Sprintf("%s/devices/approve/%s", h.cfg.BaseURL, registrationID)
|
||||
|
||||
@@ -167,26 +181,34 @@ func (h *DeviceHandler) CheckRegistrationStatus(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||
}
|
||||
|
||||
pendingMu.Lock()
|
||||
registration, exists := pendingRegistrations[req.RegistrationID]
|
||||
if !exists {
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||
}
|
||||
|
||||
if time.Now().After(registration.ExpiresAt) {
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||
}
|
||||
|
||||
if registration.Approved {
|
||||
delete(pendingRegistrations, req.RegistrationID)
|
||||
|
||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||
// Snapshot under the lock: the approver wrote these fields, and
|
||||
// the row is gone from the map — no other reader/writer remains.
|
||||
resp := DeviceAuthStatusResponse{
|
||||
Status: "approved",
|
||||
AuthToken: registration.AuthToken,
|
||||
DeviceID: registration.DeviceID,
|
||||
SyncEndpoints: registration.SyncEndpoints,
|
||||
})
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||
Status: "pending",
|
||||
@@ -552,17 +574,21 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
|
||||
registrationID := c.Param("registration_id")
|
||||
|
||||
pendingMu.Lock()
|
||||
registration, exists := pendingRegistrations[registrationID]
|
||||
if !exists {
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||
}
|
||||
|
||||
if time.Now().After(registration.ExpiresAt) {
|
||||
delete(pendingRegistrations, registrationID)
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||
}
|
||||
|
||||
if registration.Approved {
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device already approved",
|
||||
"device_name": registration.DeviceName,
|
||||
@@ -570,6 +596,7 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
authToken, err := generateDeviceToken()
|
||||
if err != nil {
|
||||
@@ -577,23 +604,87 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
}
|
||||
|
||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
||||
syncEnabled := pgtype.Bool{Bool: true, Valid: true}
|
||||
autoSync := pgtype.Bool{Bool: true, Valid: true}
|
||||
syncFreq := pgtype.Int4{Int32: 5, Valid: true}
|
||||
|
||||
device, err := h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||
// Re-approval: app reinstalls that preserve data (e.g. Android Studio
|
||||
// installDebug over an existing install) re-register with the SAME
|
||||
// device_identifier, but the devices row from the previous install
|
||||
// still exists — device_identifier is UNIQUE, so a blind INSERT fails
|
||||
// with a unique violation (the historical "approve returns 500 after
|
||||
// reinstall" bug). Look the device up first and rotate its token
|
||||
// instead; a fresh install MUST get fresh credentials, so the old
|
||||
// token is invalidated either way.
|
||||
rotateExisting := func(existing database.Devices) (database.Devices, bool, error) {
|
||||
// The identifier is globally unique; a row owned by another user
|
||||
// means cross-account identifier reuse — refuse it.
|
||||
if !existing.UserID.Valid || existing.UserID.Bytes != pgUserID.Bytes {
|
||||
return existing, false, nil
|
||||
}
|
||||
updated, err := h.db.UpdateDeviceAuthToken(c.Request().Context(),
|
||||
database.UpdateDeviceAuthTokenParams{
|
||||
ID: pgtype.UUID{Bytes: existing.ID.Bytes, Valid: true},
|
||||
AuthToken: authToken,
|
||||
})
|
||||
return updated, true, err
|
||||
}
|
||||
|
||||
existing, err := h.db.GetDeviceByIdentifier(c.Request().Context(),
|
||||
registration.DeviceIdentifier)
|
||||
var device database.Devices
|
||||
switch {
|
||||
case err == nil:
|
||||
// Known device: rotate the token, keep the row (id unchanged —
|
||||
// highlights/bookmarks/progress anchored to it stay valid).
|
||||
var ok bool
|
||||
device, ok, err = rotateExisting(existing)
|
||||
if err != nil {
|
||||
slog.Error("device re-approval failed",
|
||||
"registration_id", registrationID, "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to approve device"})
|
||||
}
|
||||
if !ok {
|
||||
return c.JSON(http.StatusConflict,
|
||||
map[string]string{"error": "device identifier already registered to another user"})
|
||||
}
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
device, err = h.db.CreateDevice(c.Request().Context(), database.CreateDeviceParams{
|
||||
UserID: pgUserID,
|
||||
DeviceName: registration.DeviceName,
|
||||
DeviceType: registration.DeviceType,
|
||||
DeviceIdentifier: registration.DeviceIdentifier,
|
||||
AuthToken: authToken,
|
||||
SyncEnabled: syncEnabled,
|
||||
AutoSync: autoSync,
|
||||
SyncFrequencyMinutes: syncFreq,
|
||||
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||
DeviceMetadata: []byte("{}"),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create device"})
|
||||
// Concurrent approve racing us onto the unique index: fall
|
||||
// through to the rotate path for the winner's row.
|
||||
var pgErr *pgconn.PgError
|
||||
if asErr := errors.As(err, &pgErr); asErr && pgErr.Code == "23505" {
|
||||
if winner, lerr := h.db.GetDeviceByIdentifier(
|
||||
c.Request().Context(), registration.DeviceIdentifier); lerr == nil {
|
||||
var ok bool
|
||||
device, ok, err = rotateExisting(winner)
|
||||
if err == nil && !ok {
|
||||
return c.JSON(http.StatusConflict,
|
||||
map[string]string{"error": "device identifier already registered to another user"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("device creation failed",
|
||||
"registration_id", registrationID, "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to create device"})
|
||||
}
|
||||
default:
|
||||
slog.Error("device lookup failed",
|
||||
"registration_id", registrationID, "error", err)
|
||||
return c.JSON(http.StatusInternalServerError,
|
||||
map[string]string{"error": "failed to look up device"})
|
||||
}
|
||||
|
||||
syncEndpoints := map[string]string{}
|
||||
@@ -607,11 +698,16 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
syncEndpoints["library"] = fmt.Sprintf("%s/api/sync/kobo/library", h.cfg.BaseURL)
|
||||
}
|
||||
|
||||
// Publish the credentials under the map lock: the status poller
|
||||
// snapshots these fields only after Approved flips, so the token can
|
||||
// never be read half-written.
|
||||
pendingMu.Lock()
|
||||
registration.UserID = userUUID
|
||||
registration.Approved = true
|
||||
registration.AuthToken = authToken
|
||||
registration.DeviceID = device.ID.Bytes
|
||||
registration.SyncEndpoints = syncEndpoints
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"message": "device approved successfully",
|
||||
@@ -625,12 +721,15 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
||||
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
||||
registrationID := c.Param("registration_id")
|
||||
|
||||
pendingMu.Lock()
|
||||
_, exists := pendingRegistrations[registrationID]
|
||||
if !exists {
|
||||
pendingMu.Unlock()
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||
}
|
||||
|
||||
delete(pendingRegistrations, registrationID)
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
"message": "device registration rejected",
|
||||
@@ -639,6 +738,7 @@ func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
||||
|
||||
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||
registrations := []map[string]interface{}{}
|
||||
pendingMu.Lock()
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.UserID == (uuid.UUID{}) {
|
||||
registrations = append(registrations, map[string]interface{}{
|
||||
@@ -652,6 +752,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
||||
})
|
||||
}
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return registrations, nil
|
||||
}
|
||||
@@ -664,6 +765,7 @@ func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
||||
}
|
||||
|
||||
registrations := []map[string]interface{}{}
|
||||
pendingMu.Lock()
|
||||
for _, reg := range pendingRegistrations {
|
||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||
registrations = append(registrations, map[string]interface{}{
|
||||
@@ -677,6 +779,7 @@ func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
}
|
||||
pendingMu.Unlock()
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"registrations": registrations,
|
||||
|
||||
@@ -458,6 +458,7 @@ func (h *KoboHandler) Markup(c *echo.Context) error {
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
ClearLocator: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
@@ -806,6 +807,7 @@ func (h *KoboHandler) AnalyticsGettests(c *echo.Context) error {
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: true,
|
||||
ClearLocator: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
@@ -868,6 +870,7 @@ func (h *KoboHandler) SyncFromServer(c *echo.Context) error {
|
||||
DeviceType: "kobo",
|
||||
DeviceName: device.DeviceName,
|
||||
Broadcast: false,
|
||||
ClearLocator: true,
|
||||
})
|
||||
} else {
|
||||
_, err = h.db.UpdateUniversalProgress(c.Request().Context(), database.UpdateUniversalProgressParams{
|
||||
|
||||
@@ -862,6 +862,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
|
||||
if h.progressSvc != nil {
|
||||
epubcfi := book.Epubcfi
|
||||
// No position pointer from the device, or a conversion that
|
||||
// declined below: the write is percentage-only by intent. The
|
||||
// service then clears any inherited stale anchor instead of
|
||||
// re-pairing it with the new percentage.
|
||||
clearLocator := epubcfi == nil
|
||||
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
|
||||
// Same facade every annotation uses: cached converter,
|
||||
// structural-first resolution, guarded fallbacks. The facade
|
||||
@@ -897,6 +902,7 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
// and the device keeps its own native position.
|
||||
log.Printf("Bookhoard: CRE→CFI conversion low-confidence (%s) for %s; storing percentage only", loc.Precision, *epubcfi)
|
||||
epubcfi = nil
|
||||
clearLocator = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -916,6 +922,7 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
||||
DeviceType: "koreader",
|
||||
DeviceName: deviceModel,
|
||||
Broadcast: true,
|
||||
ClearLocator: clearLocator,
|
||||
}
|
||||
|
||||
_, err := h.progressSvc.SaveProgress(ctx, saveReq)
|
||||
|
||||
+254
-11
@@ -102,13 +102,13 @@ type UpdateMediaItemRequest struct {
|
||||
|
||||
// CreateMediaNoteRequest represents the request for creating a media note
|
||||
type CreateMediaNoteRequest struct {
|
||||
Content string `json:"content" validate:"required,min=1,max=10000"`
|
||||
Content string `json:"content" validate:"required,min=1,max=100000"`
|
||||
Position string `json:"position"`
|
||||
}
|
||||
|
||||
// UpdateMediaNoteRequest represents the request for updating a media note
|
||||
type UpdateMediaNoteRequest struct {
|
||||
Content string `json:"content" validate:"required,min=1,max=10000"`
|
||||
Content string `json:"content" validate:"required,min=1,max=100000"`
|
||||
Position string `json:"position"`
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ type CreateMediaHighlightRequest struct {
|
||||
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteText string `json:"note_text" validate:"max=10000"`
|
||||
NoteText string `json:"note_text" validate:"max=100000"`
|
||||
NoteID string `json:"note_id"`
|
||||
PercentageStart float64 `json:"percentage_start"`
|
||||
PercentageEnd float64 `json:"percentage_end"`
|
||||
@@ -135,7 +135,7 @@ type UpdateMediaHighlightRequest struct {
|
||||
EpubcfiStart string `json:"epubcfi_start" validate:"max=2000"`
|
||||
EpubcfiEnd string `json:"epubcfi_end" validate:"max=2000"`
|
||||
Color string `json:"color" validate:"omitempty,len=7"`
|
||||
NoteText string `json:"note_text" validate:"max=10000"`
|
||||
NoteText string `json:"note_text" validate:"max=100000"`
|
||||
NoteID string `json:"note_id"`
|
||||
PercentageStart float64 `json:"percentage_start"`
|
||||
PercentageEnd float64 `json:"percentage_end"`
|
||||
@@ -146,7 +146,7 @@ type UpdateMediaHighlightRequest struct {
|
||||
type CreateMediaBookmarkRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||
Position string `json:"position" validate:"max=100"`
|
||||
Notes string `json:"notes" validate:"max=10000"`
|
||||
Notes string `json:"notes" validate:"max=100000"`
|
||||
CfiPosition string `json:"cfi_position" validate:"max=255"`
|
||||
PageNumber int32 `json:"page_number"`
|
||||
ChapterNumber int32 `json:"chapter_number"`
|
||||
@@ -160,7 +160,7 @@ type CreateMediaBookmarkRequest struct {
|
||||
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
|
||||
type UpdateMediaBookmarkRequest struct {
|
||||
Title string `json:"title" validate:"required,min=1,max=255"`
|
||||
Notes string `json:"notes" validate:"max=10000"`
|
||||
Notes string `json:"notes" validate:"max=100000"`
|
||||
Position string `json:"position" validate:"max=100"`
|
||||
}
|
||||
|
||||
@@ -572,24 +572,33 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
||||
Summary: existingMedia.Summary,
|
||||
}
|
||||
|
||||
// Bulk edits are user customizations: keep existing overrides and mark
|
||||
// each applied update as overridden so scans preserve it.
|
||||
overrides := existingMedia.MetadataOverrides
|
||||
if update.Updates.Title != nil {
|
||||
updateParams.Title = *update.Updates.Title
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
|
||||
}
|
||||
if update.Updates.Author != nil {
|
||||
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
|
||||
}
|
||||
if update.Updates.Genre != nil {
|
||||
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideGenre)
|
||||
}
|
||||
if update.Updates.Language != nil {
|
||||
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage)
|
||||
}
|
||||
if len(update.Updates.Tags) > 0 {
|
||||
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
|
||||
updateParams.Tags = normalizedTags
|
||||
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
|
||||
updateParams.TagsSearch = tagsSearch
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideTags)
|
||||
}
|
||||
updateParams.MetadataOverrides = overrides
|
||||
|
||||
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
|
||||
if err != nil {
|
||||
@@ -944,6 +953,37 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
||||
"last_sync_timestamp": progress.LastSyncTimestamp,
|
||||
}
|
||||
|
||||
// Position authority: re-verify the stored anchor and derive the
|
||||
// client-facing handles (cssSelector + anchor document href) server-
|
||||
// side, so clients scroll to an address instead of parsing CFIs.
|
||||
if progress.Epubcfi.Valid && wsync.IsConvertibleFormat(progress.FormatGroup) && mh.libraryService != nil {
|
||||
contextText := ""
|
||||
if progress.ContextText.Valid {
|
||||
contextText = progress.ContextText.String
|
||||
}
|
||||
pct := 0.0
|
||||
if progress.Percentage.Valid {
|
||||
pct = progress.Percentage.Float64
|
||||
}
|
||||
if mediaItem, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err == nil {
|
||||
if path, perr := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
|
||||
if finalCFI, sel, href, charOff, _, _, _, verr := wsync.VerifyProgressAnchor(path, progress.Epubcfi.String, contextText, pct); verr == nil && sel != "" {
|
||||
resp["css_selector"] = sel
|
||||
resp["anchor_href"] = href
|
||||
if charOff != nil {
|
||||
resp["char_offset"] = *charOff
|
||||
}
|
||||
if finalCFI != "" {
|
||||
resp["epubcfi"] = finalCFI
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if progress.ContextText.Valid {
|
||||
resp["context_text"] = progress.ContextText.String
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
@@ -1154,7 +1194,10 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
mediaItemID, err := mh.db.CreateMediaItem(c.Request().Context(), database.CreateMediaItemParams{
|
||||
// Race-safe create-or-detect: on a duplicate (library_id, file_path)
|
||||
// the insert writes nothing and returns no row — answer with the
|
||||
// EXISTING item, a duplicate flag, and 200 OK (not a 201 lie).
|
||||
created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{
|
||||
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
@@ -1175,11 +1218,25 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
ContributorsSearch: contributorsSearch,
|
||||
AddedByAdminID: user.ID,
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
existing, gerr := mh.db.GetMediaItemByLibraryAndPath(c.Request().Context(), database.GetMediaItemByLibraryAndPathParams{
|
||||
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
||||
FilePath: req.FilePath,
|
||||
})
|
||||
if gerr != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": gerr.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"duplicate": true,
|
||||
"message": "A media item already exists for this file_path in this library",
|
||||
"item": existing,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaItemID.ID.Bytes, Valid: true})
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: created.ID.Bytes, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1240,6 +1297,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
}
|
||||
|
||||
coverPath := existing.CoverImagePath.String
|
||||
coverUploaded := false
|
||||
|
||||
if req.CoverAction == "remove" {
|
||||
coverPath = ""
|
||||
@@ -1251,6 +1309,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
||||
}
|
||||
coverPath = savedPath
|
||||
coverUploaded = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1259,7 +1318,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
alternateInfoBytes = []byte(req.AlternateInfo)
|
||||
}
|
||||
|
||||
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
|
||||
params := database.UpdateMediaItemParams{
|
||||
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
@@ -1297,7 +1356,17 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
AlternateInfo: alternateInfoBytes,
|
||||
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
||||
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
||||
})
|
||||
}
|
||||
|
||||
// Fields the user actually changed become overrides so future scans keep
|
||||
// the custom values. An explicit cover upload/removal always overrides.
|
||||
overrides := utils.DetectMetadataOverrides(params, existing)
|
||||
if req.CoverAction == "remove" || coverUploaded {
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideCoverImagePath)
|
||||
}
|
||||
params.MetadataOverrides = overrides
|
||||
|
||||
item, err := mh.db.UpdateMediaItem(c.Request().Context(), params)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
@@ -1309,6 +1378,168 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// UnarchiveMediaItem handles POST /api/media-items/:id/unarchive (admin
|
||||
// only). Manually restores a hidden/archived item to library visibility
|
||||
// without waiting for its file to return. If the file is still gone, the
|
||||
// next scan hides it again.
|
||||
func (mh *MediaHandler) UnarchiveMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
mediaID := c.Param("id")
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
if err := mh.db.ClearMediaItemArchive(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// MergeArchivedMediaItem handles POST /api/media-items/:id/merge (admin
|
||||
// only). Merges an archived/missing item's reading data (progress,
|
||||
// annotations, highlights, notes, bookmarks, ratings, collections, kobo
|
||||
// shelves) into an active item with identical content (same SHA-256), then
|
||||
// deletes the archived row. Covers the copy-then-delete-later workflow: the
|
||||
// active copy exists, the archived twin holds the real reading history.
|
||||
// Per-user collisions keep the active copy's data, mirroring hash-conflict
|
||||
// resolution.
|
||||
func (mh *MediaHandler) MergeArchivedMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
sourceUUID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TargetID string `json:"target_id"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil || req.TargetID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target_id is required"})
|
||||
}
|
||||
targetUUID, err := uuid.Parse(req.TargetID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid target_id"})
|
||||
}
|
||||
if sourceUUID == targetUUID {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "cannot merge an item into itself"})
|
||||
}
|
||||
|
||||
ctx := c.Request().Context()
|
||||
source, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "archived item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !source.ArchivedAt.Valid && source.MissingScanCount == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "source item is not archived or missing"})
|
||||
}
|
||||
|
||||
target, err := mh.db.GetMediaItem(ctx, pgtype.UUID{Bytes: targetUUID, Valid: true})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "target item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if target.ArchivedAt.Valid || target.MissingScanCount > 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "target item is not active"})
|
||||
}
|
||||
|
||||
if !source.FileSha256.Valid || source.FileSha256.String == "" ||
|
||||
source.FileSha256.String != target.FileSha256.String {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "items do not share the same content hash"})
|
||||
}
|
||||
|
||||
if err := mh.db.ReparentMediaItemChildren(ctx, database.ReparentMediaItemChildrenParams{
|
||||
Column1: pgtype.UUID{Bytes: targetUUID, Valid: true},
|
||||
Column2: pgtype.UUID{Bytes: sourceUUID, Valid: true},
|
||||
}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to merge reading data: " + err.Error()})
|
||||
}
|
||||
if err := mh.db.DeleteMediaItem(ctx, pgtype.UUID{Bytes: sourceUUID, Valid: true}); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to remove archived item: " + err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]string{
|
||||
"message": "Merged reading progress, annotations, and collections into the active item",
|
||||
})
|
||||
}
|
||||
|
||||
// PurgeArchivedMediaItems handles POST /api/media-items/purge-archived
|
||||
// (admin only). Hard-deletes every archived item (files missing from disk for
|
||||
// 2+ scans) together with its reading history. The archive retention window
|
||||
// eventually does the same automatically; this is the manual bulk escape hatch.
|
||||
func (mh *MediaHandler) PurgeArchivedMediaItems(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
purged, err := mh.db.PurgeAllArchivedMediaItems(c.Request().Context())
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"purged": len(purged),
|
||||
})
|
||||
}
|
||||
|
||||
// RescanMediaItem handles POST /api/media-items/:id/rescan (admin only)
|
||||
func (mh *MediaHandler) RescanMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
if user.Role != "admin" {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "admin access required"})
|
||||
}
|
||||
|
||||
mediaID := c.Param("id")
|
||||
mediaUUID, err := uuid.Parse(mediaID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id"})
|
||||
}
|
||||
|
||||
if _, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "media item not found"})
|
||||
}
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
scanner := services.NewMediaScanner(mh.db)
|
||||
defer scanner.Close()
|
||||
|
||||
// reset_overrides=true discards user customizations first, returning the
|
||||
// item to pure scanned defaults (the "Reset to Scanned" action).
|
||||
resetOverrides := c.QueryParam("reset_overrides") == "true"
|
||||
|
||||
if err := scanner.RescanMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true}, resetOverrides); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, item)
|
||||
}
|
||||
|
||||
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
||||
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
@@ -1919,6 +2150,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
authorFilter := c.QueryParam("author_filter")
|
||||
seriesFilter := c.QueryParam("series_filter")
|
||||
genreFilter := c.QueryParam("genre_filter")
|
||||
publisherFilter := c.QueryParam("publisher_filter")
|
||||
tagsFilter := c.QueryParam("tags_filter")
|
||||
languageFilter := c.QueryParam("language_filter")
|
||||
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
||||
@@ -1944,6 +2176,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
AuthorFilter: authorFilter,
|
||||
SeriesFilter: seriesFilter,
|
||||
GenreFilter: genreFilter,
|
||||
PublisherFilter: publisherFilter,
|
||||
TagsFilter: tagsFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
@@ -2188,7 +2421,17 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
|
||||
return "", fmt.Errorf("media item has no file path")
|
||||
}
|
||||
|
||||
coverRelPath := relativeFilePath + ".cover.jpg"
|
||||
// Custom covers live at a dedicated sidecar path (distinct from the
|
||||
// scanner-generated {file}.cover.jpg) so scans can never overwrite a
|
||||
// user-uploaded cover and the metadata_overrides set can protect it.
|
||||
ext := ".jpg"
|
||||
switch contentType {
|
||||
case "image/png":
|
||||
ext = ".png"
|
||||
case "image/webp":
|
||||
ext = ".webp"
|
||||
}
|
||||
coverRelPath := relativeFilePath + ".custom_cover" + ext
|
||||
|
||||
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,10 @@ import "bookhoard/internal/database"
|
||||
type MediaDetail struct {
|
||||
database.MediaItems // Embedded - ALL book fields available
|
||||
|
||||
// Absolute on-disk location (library folder + relative path), resolved by
|
||||
// the page handler so the UI can show where the file lives.
|
||||
FileLocation string `json:"file_location"`
|
||||
|
||||
// User-specific data
|
||||
Rating *database.MediaRatings `json:"rating,omitempty"`
|
||||
Collections []database.Collections `json:"collections"`
|
||||
|
||||
+288
-11
@@ -90,6 +90,9 @@ func appendToken(url, token string) string {
|
||||
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
|
||||
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
||||
|
||||
// navFeedMediaType is the OPDS media type for a navigation catalog feed.
|
||||
const navFeedMediaType = "application/atom+xml;profile=opds-catalog;kind=navigation"
|
||||
|
||||
// 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.
|
||||
@@ -172,8 +175,109 @@ func formatLabelFromPath(path string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// GetDeviceCatalog returns the OPDS catalog feed for a device
|
||||
// GetDeviceCatalog serves the OPDS root feed for a device.
|
||||
//
|
||||
// By default it returns a navigation feed: an "All Books" entry (the flat
|
||||
// cross-library catalog) followed by one folder entry per visible library.
|
||||
// OPDS clients render navigation entries as folders, so each library's
|
||||
// books — and its library-scoped search — live under their own node instead
|
||||
// of interleaving duplicate copies of the same book in one flat list.
|
||||
// Passing ?all=1 serves the flat cross-library acquisition feed directly,
|
||||
// preserving the pre-folders behavior.
|
||||
func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
if c.QueryParam("all") == "1" {
|
||||
return h.serveAllBooksCatalog(c)
|
||||
}
|
||||
|
||||
deviceID := c.Param("deviceId")
|
||||
|
||||
// Get base URLs
|
||||
_, opdsBaseURL, err := h.getBaseURLs(c)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
||||
}
|
||||
|
||||
// Parse device ID
|
||||
deviceUUID, err := uuid.Parse(deviceID)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
||||
}
|
||||
|
||||
// Verify device exists
|
||||
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
||||
}
|
||||
|
||||
// Get user's visible libraries
|
||||
userID := device.UserID.Bytes
|
||||
libraries, 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)
|
||||
|
||||
feed := opds.NewFeed(
|
||||
fmt.Sprintf("urn:uuid:%s", deviceID),
|
||||
"Bookhoard Library",
|
||||
)
|
||||
|
||||
// OpenSearch discovery: the root search stays global (all libraries).
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||
|
||||
updated := time.Now().UTC().Format("2006-01-02T15:04:05Z")
|
||||
|
||||
// "All Books" first: the flat cross-library catalog (?all=1).
|
||||
allBooks := opds.NewEntry(
|
||||
fmt.Sprintf("urn:uuid:%s-all-books", deviceID),
|
||||
"All Books",
|
||||
"",
|
||||
updated,
|
||||
)
|
||||
allBooks.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/catalog?all=1", opdsBaseURL, deviceID), token), catalogMediaType, "subsection")
|
||||
allBooks.SetSummary("Browse every book across all your libraries")
|
||||
feed.AddEntry(allBooks)
|
||||
|
||||
// One folder per visible library, with live book counts.
|
||||
counts := make(map[pgtype.UUID]int64)
|
||||
if countRows, cerr := h.db.GetVisibleLibraryMediaCounts(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true}); cerr == nil {
|
||||
for _, row := range countRows {
|
||||
counts[row.ID] = row.MediaCount
|
||||
}
|
||||
}
|
||||
|
||||
for _, lib := range libraries {
|
||||
libUUID := uuid.UUID(lib.ID.Bytes).String()
|
||||
title := lib.Name
|
||||
if n, ok := counts[lib.ID]; ok {
|
||||
title = fmt.Sprintf("%s (%d)", lib.Name, n)
|
||||
}
|
||||
entry := opds.NewEntry(
|
||||
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
|
||||
title,
|
||||
"",
|
||||
updated,
|
||||
)
|
||||
entry.AddLink(appendToken(fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID), token), catalogMediaType, "subsection")
|
||||
entry.SetSummary("Browse books in the " + lib.Name + " library")
|
||||
feed.AddEntry(entry)
|
||||
}
|
||||
|
||||
// Generate XML
|
||||
xmlString, err := feed.GenerateXMLString()
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", navFeedMediaType)
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
// serveAllBooksCatalog is the original flat cross-library acquisition feed:
|
||||
// every book from every visible library, paginated as one list.
|
||||
func (h *OPDSHandler) serveAllBooksCatalog(c *echo.Context) error {
|
||||
deviceID := c.Param("deviceId")
|
||||
|
||||
page := c.QueryParam("page")
|
||||
@@ -216,8 +320,6 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
// Get user's visible libraries
|
||||
userID := device.UserID.Bytes
|
||||
userUUID := uuid.UUID(userID)
|
||||
|
||||
libraries, 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"))
|
||||
@@ -276,7 +378,23 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||
|
||||
// Add entries
|
||||
for _, item := range allItems {
|
||||
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, allItems, baseURL, opdsBaseURL, token)
|
||||
|
||||
// Generate XML
|
||||
xmlString, err := feed.GenerateXMLString()
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", catalogMediaType)
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
// addAcquisitionEntries fills an acquisition feed with book entries for the
|
||||
// given items. Shared body of the all-books and per-library catalogs.
|
||||
func (h *OPDSHandler) addAcquisitionEntries(ctx context.Context, feed *opds.Feed, device database.Devices, deviceID string, items []database.ListMediaItemsByLibraryRow, baseURL, opdsBaseURL, token string) {
|
||||
userUUID := uuid.UUID(device.UserID.Bytes)
|
||||
for _, item := range items {
|
||||
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
||||
title := item.Title
|
||||
author := ""
|
||||
@@ -323,7 +441,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// Add collections as categories
|
||||
collections, err := h.db.GetCollectionsForBook(c.Request().Context(), pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
|
||||
collections, err := h.db.GetCollectionsForBook(ctx, pgtype.UUID{Bytes: item.ID.Bytes, Valid: true})
|
||||
if err == nil {
|
||||
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
||||
for _, col := range collections {
|
||||
@@ -335,6 +453,125 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
|
||||
feed.AddEntry(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// GetLibraryCatalog serves an acquisition feed scoped to one of the device
|
||||
// owner's visible libraries. Its search link is library-scoped: OPDS clients
|
||||
// only substitute {searchTerms}, so opening search from inside this folder
|
||||
// searches within this library only.
|
||||
func (h *OPDSHandler) GetLibraryCatalog(c *echo.Context) error {
|
||||
deviceID := c.Param("deviceId")
|
||||
libraryID := c.Param("libraryId")
|
||||
|
||||
page := c.QueryParam("page")
|
||||
perPage := c.QueryParam("per_page")
|
||||
includeFormat := c.QueryParam("include_format")
|
||||
|
||||
// Parse pagination parameters
|
||||
pageNum := 1
|
||||
if page != "" {
|
||||
if num, err := strconv.Atoi(page); err == nil && num > 0 {
|
||||
pageNum = num
|
||||
}
|
||||
}
|
||||
|
||||
perPageNum := h.opdsDefaultPageSize()
|
||||
maxPerPage := h.opdsMaxPageSize()
|
||||
if perPage != "" {
|
||||
if num, err := strconv.Atoi(perPage); err == nil && num > 0 && num <= maxPerPage {
|
||||
perPageNum = num
|
||||
}
|
||||
}
|
||||
|
||||
// Get base URLs
|
||||
baseURL, opdsBaseURL, err := h.getBaseURLs(c)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get config"))
|
||||
}
|
||||
|
||||
// Parse IDs
|
||||
deviceUUID, err := uuid.Parse(deviceID)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid device ID"))
|
||||
}
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library ID"))
|
||||
}
|
||||
|
||||
// Verify device exists
|
||||
device, err := h.db.GetDevice(c.Request().Context(), pgtype.UUID{Bytes: deviceUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.XML(http.StatusNotFound, opds.NewErrorFeed("Device not found"))
|
||||
}
|
||||
|
||||
// The library must be visible to the device owner.
|
||||
userID := device.UserID.Bytes
|
||||
libraries, 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"))
|
||||
}
|
||||
var library *database.GetUserVisibleLibrariesRow
|
||||
for i := range libraries {
|
||||
if libraries[i].ID.Bytes == libUUID {
|
||||
library = &libraries[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if library == nil {
|
||||
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
|
||||
}
|
||||
|
||||
// Get media items
|
||||
items, err := h.db.ListMediaItemsByLibrary(c.Request().Context(), library.ID)
|
||||
if err != nil {
|
||||
items = []database.ListMediaItemsByLibraryRow{}
|
||||
}
|
||||
|
||||
// Filter by format if specified
|
||||
if includeFormat != "" {
|
||||
filteredItems := []database.ListMediaItemsByLibraryRow{}
|
||||
for _, item := range items {
|
||||
if strings.EqualFold(item.FormatGroup, includeFormat) {
|
||||
filteredItems = append(filteredItems, item)
|
||||
}
|
||||
}
|
||||
items = filteredItems
|
||||
}
|
||||
|
||||
// Pagination
|
||||
totalItems := len(items)
|
||||
startIdx := (pageNum - 1) * perPageNum
|
||||
endIdx := startIdx + perPageNum
|
||||
|
||||
if startIdx >= totalItems {
|
||||
items = []database.ListMediaItemsByLibraryRow{}
|
||||
} else if endIdx > totalItems {
|
||||
items = items[startIdx:]
|
||||
} else {
|
||||
items = items[startIdx:endIdx]
|
||||
}
|
||||
|
||||
// Create OPDS feed
|
||||
feed := opds.NewFeed(
|
||||
fmt.Sprintf("urn:uuid:%s-library-%s", deviceID, libUUID),
|
||||
library.Name,
|
||||
)
|
||||
|
||||
token := h.getAuthToken(c)
|
||||
catalogBase := fmt.Sprintf("%s/devices/%s/library/%s/catalog", opdsBaseURL, deviceID, libUUID)
|
||||
addCatalogPaginationLinks(feed, catalogBase, pageNum, perPageNum, totalItems, token)
|
||||
|
||||
// Library-scoped search discovery: the template pins this library.
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}&library_id=%s", opdsBaseURL, deviceID, libUUID), token)
|
||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||
|
||||
// Up-link back to the root navigation feed.
|
||||
rootURL := appendToken(fmt.Sprintf("%s/devices/%s/catalog", opdsBaseURL, deviceID), token)
|
||||
feed.AddLink(rootURL, navFeedMediaType, "up")
|
||||
|
||||
// Add entries
|
||||
h.addAcquisitionEntries(c.Request().Context(), feed, device, deviceID, items, baseURL, opdsBaseURL, token)
|
||||
|
||||
// Generate XML
|
||||
xmlString, err := feed.GenerateXMLString()
|
||||
@@ -342,7 +579,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to generate feed"))
|
||||
}
|
||||
|
||||
c.Response().Header().Set("Content-Type", "application/atom+xml;profile=opds-catalog;kind=acquisition")
|
||||
c.Response().Header().Set("Content-Type", catalogMediaType)
|
||||
return c.String(http.StatusOK, xmlString)
|
||||
}
|
||||
|
||||
@@ -377,20 +614,50 @@ 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})
|
||||
libraries, 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"))
|
||||
}
|
||||
|
||||
// Optional library scope: library folder feeds advertise their own search
|
||||
// link with this parameter, so opening search from inside a library
|
||||
// folder searches within that library only.
|
||||
var scopedLibraryID pgtype.UUID
|
||||
scopedLibraryName := ""
|
||||
if libParam := c.QueryParam("library_id"); libParam != "" {
|
||||
parsed, perr := uuid.Parse(libParam)
|
||||
if perr != nil {
|
||||
return c.XML(http.StatusBadRequest, opds.NewErrorFeed("Invalid library ID"))
|
||||
}
|
||||
for i := range libraries {
|
||||
if libraries[i].ID.Bytes == parsed {
|
||||
scopedLibraryID = libraries[i].ID
|
||||
scopedLibraryName = libraries[i].Name
|
||||
break
|
||||
}
|
||||
}
|
||||
if !scopedLibraryID.Valid {
|
||||
return c.XML(http.StatusForbidden, opds.NewErrorFeed("Library not accessible"))
|
||||
}
|
||||
}
|
||||
|
||||
token := h.getAuthToken(c)
|
||||
|
||||
// No query: serve the OpenSearch description document so clients can learn
|
||||
// the search template (contains the {searchTerms} placeholder).
|
||||
// the search URL template (contains the {searchTerms} placeholder).
|
||||
if query == "" {
|
||||
searchURL := appendToken(fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID), token)
|
||||
template := fmt.Sprintf("%s/devices/%s/search?q={searchTerms}", opdsBaseURL, deviceID)
|
||||
shortName := "Bookhoard"
|
||||
description := "Search the Bookhoard library"
|
||||
if scopedLibraryID.Valid {
|
||||
template += "&library_id=" + scopedLibraryID.String()
|
||||
shortName = "Bookhoard: " + scopedLibraryName
|
||||
description = "Search the " + scopedLibraryName + " library"
|
||||
}
|
||||
searchURL := appendToken(template, token)
|
||||
desc := opds.NewSearchDescription(
|
||||
"Bookhoard",
|
||||
"Search the Bookhoard library",
|
||||
shortName,
|
||||
description,
|
||||
searchURL,
|
||||
)
|
||||
xmlString, err := desc.GenerateXMLString()
|
||||
@@ -404,6 +671,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
// Search media items
|
||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: scopedLibraryID,
|
||||
SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true},
|
||||
Offset: pgtype.Int4{Int32: 0, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: 1000, Valid: true},
|
||||
@@ -451,6 +719,15 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
||||
entry.SetSummary(item.Description.String)
|
||||
}
|
||||
|
||||
// Global (unscoped) search: label which library each copy lives in so
|
||||
// duplicate copies of the same book are distinguishable.
|
||||
if !scopedLibraryID.Valid && item.LibraryName != "" {
|
||||
entry.AddCategory(baseURL+"/libraries", item.LibraryName)
|
||||
if !item.Description.Valid || item.Description.String == "" {
|
||||
entry.SetSummary("Library: " + item.LibraryName)
|
||||
}
|
||||
}
|
||||
|
||||
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||
|
||||
|
||||
@@ -97,121 +97,6 @@ func (h *Handler) GetUniversalProgress(c *echo.Context) error {
|
||||
}
|
||||
|
||||
// UpdateUniversalProgress updates progress with automatic conversion
|
||||
func (h *Handler) UpdateUniversalProgress(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
mediaItemID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid media item ID")
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Source string `json:"source"`
|
||||
Location struct {
|
||||
Percentage *float64 `json:"percentage"`
|
||||
Page *int `json:"page"`
|
||||
TotalPages *int `json:"total_pages"`
|
||||
Epubcfi *string `json:"epubcfi"`
|
||||
Character *int64 `json:"character"`
|
||||
Chapter *int `json:"chapter"`
|
||||
ViewportY *float64 `json:"viewport_y"`
|
||||
} `json:"location"`
|
||||
DeviceMetadata struct {
|
||||
DeviceType string `json:"device_type"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
} `json:"device_metadata"`
|
||||
}
|
||||
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
|
||||
}
|
||||
|
||||
mediaItem, err := h.db.GetMediaItem(context.Background(), pgtype.UUID{Bytes: mediaItemID, Valid: true})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "media item not found")
|
||||
}
|
||||
|
||||
percentage := 0.0
|
||||
if req.Location.Percentage != nil {
|
||||
percentage = *req.Location.Percentage
|
||||
} else if req.Location.Page != nil && req.Location.TotalPages != nil {
|
||||
percentage = wsync.PageToPercentage(*req.Location.Page, *req.Location.TotalPages)
|
||||
}
|
||||
|
||||
currentPage := 0
|
||||
totalPages := 0
|
||||
if req.Location.Page != nil {
|
||||
currentPage = *req.Location.Page
|
||||
}
|
||||
if req.Location.TotalPages != nil {
|
||||
totalPages = *req.Location.TotalPages
|
||||
}
|
||||
if mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
||||
totalPages = int(mediaItem.PageCount.Int32)
|
||||
}
|
||||
|
||||
var epubcfi pgtype.Text
|
||||
if req.Location.Epubcfi != nil {
|
||||
epubcfi = pgtype.Text{String: *req.Location.Epubcfi, Valid: true}
|
||||
}
|
||||
|
||||
var chapter pgtype.Int4
|
||||
if req.Location.Chapter != nil {
|
||||
chapter = pgtype.Int4{Int32: int32(*req.Location.Chapter), Valid: true}
|
||||
}
|
||||
|
||||
var viewportY pgtype.Float8
|
||||
if req.Location.ViewportY != nil {
|
||||
viewportY = pgtype.Float8{Float64: *req.Location.ViewportY, Valid: true}
|
||||
}
|
||||
|
||||
var lastSyncDevice, lastSyncSource pgtype.Text
|
||||
if req.DeviceMetadata.DeviceType != "" {
|
||||
lastSyncDevice = pgtype.Text{String: req.DeviceMetadata.DeviceType, Valid: true}
|
||||
lastSyncSource = pgtype.Text{String: req.Source, Valid: true}
|
||||
}
|
||||
|
||||
updated, err := h.db.UpdateUniversalProgress(context.Background(), database.UpdateUniversalProgressParams{
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
||||
UserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||
Percentage: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
Epubcfi: epubcfi,
|
||||
Chapter: chapter,
|
||||
ChapterProgress: pgtype.Float8{Float64: percentage, Valid: true},
|
||||
ViewportY: viewportY,
|
||||
LastSyncDevice: lastSyncDevice,
|
||||
LastSyncSource: lastSyncSource,
|
||||
CurrentPage: pgtype.Int4{Int32: int32(currentPage), Valid: true},
|
||||
TotalPages: pgtype.Int4{Int32: int32(totalPages), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to update progress")
|
||||
}
|
||||
|
||||
// Broadcast progress update to all connected WebSocket clients
|
||||
deviceName := "Web"
|
||||
deviceType := "web"
|
||||
if req.DeviceMetadata.DeviceType != "" {
|
||||
deviceType = req.DeviceMetadata.DeviceType
|
||||
deviceName = req.DeviceMetadata.DeviceType
|
||||
}
|
||||
|
||||
h.connManager.BroadcastProgressUpdate(mediaItemID, percentage, wsync.SourceDevice{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Name: deviceName,
|
||||
Type: deviceType,
|
||||
})
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"sync_status": "success",
|
||||
"progress_updated": true,
|
||||
"percentage": percentage,
|
||||
"current_page": currentPage,
|
||||
"total_pages": totalPages,
|
||||
"last_read_at": updated.LastReadAt.Time,
|
||||
})
|
||||
}
|
||||
|
||||
// GetProgressHistory retrieves reading session history
|
||||
func (h *Handler) GetProgressHistory(c *echo.Context) error {
|
||||
user := MustGetAuthenticatedUser(c)
|
||||
|
||||
+15
-45
@@ -102,6 +102,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
||||
|
||||
// Determine if panel detection should be enabled
|
||||
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||
treatAsComic := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||
|
||||
// Get reading progress
|
||||
var progress database.ReadingProgress
|
||||
@@ -133,6 +134,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
||||
"progress": progress,
|
||||
"bookmarks": bookmarks,
|
||||
"enable_panel_detection": enablePanelDetection,
|
||||
"treat_as_comic": treatAsComic,
|
||||
"format_group": mediaItem.FormatGroup,
|
||||
"manga_type": mediaItem.MangaType,
|
||||
"reading_direction": mediaItem.ReadingDirection,
|
||||
@@ -588,55 +590,23 @@ func (h *ReaderHandler) generatePDFThumbnail(fullPath string, page int, width, h
|
||||
return []byte{}, nil
|
||||
}
|
||||
|
||||
func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
|
||||
mediaItemID := c.Param("mediaItemId")
|
||||
parsedUUID, err := uuid.Parse(mediaItemID)
|
||||
if err != nil {
|
||||
return c.JSON(400, map[string]string{"error": "Invalid media item ID"})
|
||||
// ShouldTreatAsComic reports whether a fixed-layout item that lives in a
|
||||
// comics or manga library should get comic reader treatment: bookmarks
|
||||
// only — no text-selection highlights, no annotations, no in-book search.
|
||||
// Comic archives already behave this way; this flag extends it to fixed-
|
||||
// layout PDFs shelved in comic libraries (scanned or official manga PDFs).
|
||||
func ShouldTreatAsComic(libraryType string, formatGroup string) bool {
|
||||
comicLibraries := map[string]bool{
|
||||
"manga": true,
|
||||
"comics": true,
|
||||
}
|
||||
|
||||
// Fetch media item
|
||||
mediaItem, err := h.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: parsedUUID, Valid: true})
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return c.JSON(404, map[string]string{"error": "Media item not found"})
|
||||
}
|
||||
return c.JSON(500, map[string]string{"error": "Failed to fetch media item"})
|
||||
fixedFormats := map[string]bool{
|
||||
"fixed_layout": true,
|
||||
"comic_archive": true,
|
||||
}
|
||||
|
||||
// Check if format requires server-side parsing
|
||||
requiresServerParsing := false
|
||||
formatName := ""
|
||||
switch mediaItem.MimeType.String {
|
||||
case "application/x-mobipocket-ebook":
|
||||
requiresServerParsing = true
|
||||
formatName = "MOBI"
|
||||
case "application/vnd.amazon.mobi8-ebook":
|
||||
requiresServerParsing = true
|
||||
formatName = "AZW3/KF8"
|
||||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
|
||||
requiresServerParsing = true
|
||||
formatName = "DOCX"
|
||||
case "application/rtf":
|
||||
requiresServerParsing = true
|
||||
formatName = "RTF"
|
||||
}
|
||||
|
||||
if requiresServerParsing {
|
||||
// Phase 1: Return 501 for server-side formats
|
||||
return c.JSON(501, map[string]interface{}{
|
||||
"error": fmt.Sprintf("Server-side %s parsing is not yet implemented", formatName),
|
||||
"message": fmt.Sprintf("%s format support is planned for Phase 2.5", formatName),
|
||||
"suggestion": "Please convert your ebook to EPUB format for now",
|
||||
"format": formatName,
|
||||
"phase": "2.5",
|
||||
})
|
||||
}
|
||||
|
||||
// Client-side format - should not call this endpoint
|
||||
return c.JSON(400, map[string]string{
|
||||
"error": "This format should be parsed client-side, not on the server",
|
||||
})
|
||||
return comicLibraries[libraryType] && fixedFormats[formatGroup]
|
||||
}
|
||||
|
||||
// shouldEnablePanelDetection determines if panel detection should be enabled
|
||||
|
||||
+104
-5
@@ -7,13 +7,17 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/config"
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/internal/version"
|
||||
"bookhoard/templates"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -58,7 +62,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
var buf bytes.Buffer
|
||||
sessionExpired := c.QueryParam("session") == "expired"
|
||||
deleted := c.QueryParam("deleted") == "true"
|
||||
err := templates.Login(sessionExpired, deleted).Render(c.Request().Context(), &buf)
|
||||
err := templates.Login(sessionExpired, deleted, templates.SignupVisible()).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -66,6 +70,13 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
})
|
||||
|
||||
e.GET("/register", func(c *echo.Context) error {
|
||||
// Registration can be disabled by admins. The first-user exception
|
||||
// keeps the page reachable on an unconfigured server (zero admins) —
|
||||
// see templates.SignupVisible for the shared reasoning. Redirect
|
||||
// (not 404) so stale links and bookmarks land somewhere sensible.
|
||||
if !templates.SignupVisible() {
|
||||
return c.Redirect(http.StatusFound, "/login")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := templates.Register().Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
@@ -97,7 +108,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
loggedIn = err == nil && token.Valid
|
||||
}
|
||||
|
||||
err = templates.Index(loggedIn).Render(c.Request().Context(), &buf)
|
||||
err = templates.Index(loggedIn, templates.SignupVisible()).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,7 +120,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// ============================================================================
|
||||
|
||||
// Protected frontend routes (no /api prefix)
|
||||
frontendProtected := e.Group("", jwtMiddleware, ensureUserExistsMiddleware(cfg))
|
||||
frontendProtected := e.Group("", jwtMiddleware)
|
||||
|
||||
// Series browse page
|
||||
frontendProtected.GET("/series", func(c *echo.Context) error {
|
||||
@@ -293,6 +304,18 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
libraryID := libRes.LibraryID
|
||||
libData := libRes.Libraries
|
||||
|
||||
// Steam Deck behavior: an offline library (all folders missing, e.g.
|
||||
// an unmounted external drive) shows an empty shelf with a notice
|
||||
// instead of querying items. Nothing is marked or purged.
|
||||
if !libRes.IsAll {
|
||||
for _, l := range libData {
|
||||
if l.ID == libraryID && l.Offline {
|
||||
errorMsg = l.Name + " is currently unavailable - its storage is not connected. Your books are safe and will return when the drive is reattached."
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch saved filters for SSR
|
||||
userUUID, _ := uuid.Parse(user.ID)
|
||||
var savedFilters []database.SavedFilters
|
||||
@@ -834,6 +857,59 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
frontendProtected.GET("/admin/archived", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
|
||||
rows, err := cfg.Queries.ListHiddenMediaItems(c.Request().Context())
|
||||
if err != nil {
|
||||
log.Printf("ListHiddenMediaItems failed: %v", err)
|
||||
rows = []database.ListHiddenMediaItemsRow{}
|
||||
}
|
||||
|
||||
retentionDays := config.ArchiveRetentionDays()
|
||||
items := make([]templates.ArchivedItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
itemUUID, _ := uuid.FromBytes(row.ID.Bytes[0:16])
|
||||
item := templates.ArchivedItem{
|
||||
ID: itemUUID.String(),
|
||||
Title: row.Title,
|
||||
Author: getText(row.Author),
|
||||
LibraryName: row.LibraryName,
|
||||
FilePath: row.FilePath,
|
||||
MissingScans: row.MissingScanCount,
|
||||
}
|
||||
if row.MatchID.Valid {
|
||||
item.MatchID = uuid.UUID(row.MatchID.Bytes).String()
|
||||
item.MatchTitle = row.MatchTitle
|
||||
item.MatchLibraryName = getText(row.MatchLibraryName)
|
||||
}
|
||||
if row.ArchivedAt.Valid {
|
||||
archived := row.ArchivedAt.Time
|
||||
item.ArchivedAt = &archived
|
||||
switch {
|
||||
case retentionDays <= 0:
|
||||
item.Status = "Archived - kept until manually purged (retention disabled)"
|
||||
default:
|
||||
purge := archived.AddDate(0, 0, retentionDays)
|
||||
item.PurgeAt = &purge
|
||||
item.Status = "Archived - permanently deleted " + purge.Format("Jan 2, 2006")
|
||||
}
|
||||
} else {
|
||||
item.Status = fmt.Sprintf("Missing from disk - archives on the next scan (%d/2)", row.MissingScanCount)
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := templates.AdminArchived(user, items, retentionDays).Render(c.Request().Context(), &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
}))
|
||||
|
||||
frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
@@ -882,7 +958,8 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.AdminLibrary(user, libData, userData).Render(c.Request().Context(), &buf)
|
||||
archivedCount, _ := cfg.Queries.CountArchivedMediaItems(c.Request().Context())
|
||||
err = templates.AdminLibrary(user, libData, userData, archivedCount).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1267,6 +1344,23 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
|
||||
}
|
||||
|
||||
// Resolve the on-disk location (library folder + relative path) so the
|
||||
// detail page can show where the file lives, even for sparse metadata.
|
||||
// Admin-only: everyday users never receive the absolute path.
|
||||
fileLocation := ""
|
||||
if user.Role == "admin" {
|
||||
fileLocation = mediaItem.FilePath
|
||||
if folders, ferr := cfg.Queries.GetLibraryFolders(c.Request().Context(), mediaItem.LibraryID); ferr == nil {
|
||||
for _, folder := range folders {
|
||||
candidate := filepath.Join(folder.FolderPath, mediaItem.FilePath)
|
||||
if _, serr := os.Stat(candidate); serr == nil {
|
||||
fileLocation = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch rating
|
||||
var rating *database.MediaRatings
|
||||
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
||||
@@ -1330,6 +1424,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
// Assemble response (no field duplication!)
|
||||
detail := handlers.MediaDetail{
|
||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||
FileLocation: fileLocation,
|
||||
Rating: rating,
|
||||
Collections: collections,
|
||||
ReadingProgress: progress,
|
||||
@@ -1341,7 +1436,10 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
|
||||
// Render template
|
||||
var buf bytes.Buffer
|
||||
err = templates.BookDetail(user, detail, "").Render(c.Request().Context(), &buf)
|
||||
// The deleted-annotations modal states the retention in plain days
|
||||
// (the tunable's user-facing value), never as a settings name.
|
||||
tombstoneDays := cfg.Settings.TombstoneTTL() / (24 * time.Hour)
|
||||
err = templates.BookDetail(user, detail, "", int(tombstoneDays)).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1368,6 +1466,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"version": version.Version,
|
||||
"database": "connected",
|
||||
"scan": map[string]interface{}{
|
||||
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
@@ -132,12 +133,30 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
||||
res.Libraries = make([]templates.LibraryData, len(libraries))
|
||||
for i, lib := range libraries {
|
||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
// Steam Deck SD-card behavior: a library whose folders are all
|
||||
// missing (unmounted drive) renders offline - hidden contents, no
|
||||
// marking, no purging - and returns when the storage does.
|
||||
offline := false
|
||||
folders, folderErr := cfg.Queries.GetLibraryFolders(c.Request().Context(), lib.ID)
|
||||
if folderErr != nil || len(folders) == 0 {
|
||||
offline = true
|
||||
} else {
|
||||
anyFolderExists := false
|
||||
for _, folder := range folders {
|
||||
if _, statErr := os.Stat(folder.FolderPath); statErr == nil {
|
||||
anyFolderExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
offline = !anyFolderExists
|
||||
}
|
||||
res.Libraries[i] = templates.LibraryData{
|
||||
ID: libUUID.String(),
|
||||
Name: lib.Name,
|
||||
Description: getText(lib.Description),
|
||||
TypeName: lib.TypeName,
|
||||
MediaCount: countMap[libUUID.String()],
|
||||
Offline: offline,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,11 @@ func registerMediaRoutes(cfg *Config) {
|
||||
// Admin-only media routes
|
||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
||||
admin.POST("/media-items/:id/rescan", cfg.MediaHandler.RescanMediaItem)
|
||||
admin.DELETE("/media-items/:id", cfg.MediaHandler.DeleteMediaItem)
|
||||
admin.POST("/media-items/purge-archived", cfg.MediaHandler.PurgeArchivedMediaItems)
|
||||
admin.POST("/media-items/:id/unarchive", cfg.MediaHandler.UnarchiveMediaItem)
|
||||
admin.POST("/media-items/:id/merge", cfg.MediaHandler.MergeArchivedMediaItem)
|
||||
|
||||
// Shelf management (protected)
|
||||
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
|
||||
|
||||
@@ -19,6 +19,7 @@ func registerOPDSRoutes(cfg *Config) {
|
||||
opds := e.Group("/opds/devices")
|
||||
opds.Use(cfg.DeviceAuthMiddleware.Authenticate)
|
||||
opds.GET("/:deviceId/catalog", cfg.OPDSHandler.GetDeviceCatalog)
|
||||
opds.GET("/:deviceId/library/:libraryId/catalog", cfg.OPDSHandler.GetLibraryCatalog)
|
||||
opds.GET("/:deviceId/search", cfg.OPDSHandler.SearchDeviceCatalog)
|
||||
opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation)
|
||||
opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook)
|
||||
|
||||
@@ -66,6 +66,13 @@ func registerReaderRoutes(cfg *Config) {
|
||||
if !visible {
|
||||
return renderErrorPage(c, "Access denied", "access_denied")
|
||||
}
|
||||
// Library type decides comic reader treatment: fixed-layout items
|
||||
// (e.g. manga PDFs) shelved in comics/manga libraries are paged
|
||||
// through like comics — bookmarks only, no highlights or search.
|
||||
libraryWithType, err := cfg.Queries.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Failed to get library info", "library_error")
|
||||
}
|
||||
// Convert to template types. Reading state is deliberately NOT
|
||||
// fetched or embedded: the reader pulls position, bookmarks, and
|
||||
// annotations from the APIs at open time so the page can never
|
||||
@@ -89,6 +96,7 @@ func registerReaderRoutes(cfg *Config) {
|
||||
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
|
||||
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
||||
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||
}
|
||||
// Render template
|
||||
var buf bytes.Buffer
|
||||
|
||||
+32
-37
@@ -113,6 +113,32 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "invalid user ID in token")
|
||||
}
|
||||
|
||||
// Ghost-session guard: a signature-valid token for a user that
|
||||
// no longer exists must not authenticate (deleted users kept
|
||||
// full access until token expiry before this check). A
|
||||
// primary-key lookup per request — instant revocation, no TTL
|
||||
// semantics; the guard also covers the SSR groups that used to
|
||||
// chain ensureUserExistsMiddleware.
|
||||
if !userExists(cfg, userUUID) {
|
||||
log.Printf("Ghost session rejected: user %s no longer exists", userIDStr)
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "token",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-24 * time.Hour),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
if wantsHTML(c.Request().Header) {
|
||||
return c.Redirect(http.StatusFound, "/login?session=invalid")
|
||||
}
|
||||
// Non-nil error is REQUIRED: returning c.JSON's nil would
|
||||
// let the chain continue into handlers whose c.Get("user")
|
||||
// assertion panics on the never-set user.
|
||||
return echo.NewHTTPError(http.StatusUnauthorized,
|
||||
"user_not_found: this account no longer exists; please log in again")
|
||||
}
|
||||
|
||||
c.Set("user", database.Users{
|
||||
ID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
Email: claims["user_email"].(string),
|
||||
@@ -138,43 +164,12 @@ func createJWTMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
})
|
||||
}
|
||||
|
||||
// ensureUserExistsMiddleware checks if the authenticated user still exists in the database
|
||||
func ensureUserExistsMiddleware(cfg *Config) echo.MiddlewareFunc {
|
||||
return func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c *echo.Context) error {
|
||||
userIDStr, ok := c.Get("user_id").(string)
|
||||
if !ok {
|
||||
return next(c)
|
||||
}
|
||||
|
||||
userUUID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
log.Printf("Invalid UUID in user existence check: %v", err)
|
||||
return next(c)
|
||||
}
|
||||
|
||||
// Check if user exists in database
|
||||
_, err = cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
|
||||
if err != nil {
|
||||
log.Printf("User not found in database: %s", userIDStr)
|
||||
|
||||
// Clear invalid cookie
|
||||
c.SetCookie(&http.Cookie{
|
||||
Name: "token",
|
||||
Value: "",
|
||||
Expires: time.Now().Add(-24 * time.Hour),
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
// Redirect to login with session=invalid
|
||||
return c.Redirect(http.StatusFound, "/login?session=invalid")
|
||||
}
|
||||
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
// userExists checks the users table for the token's claimed user. Runs on
|
||||
// every authenticated request: a primary-key lookup, cheap at this scale,
|
||||
// and it makes deletion revocable the moment it happens.
|
||||
func userExists(cfg *Config, id uuid.UUID) bool {
|
||||
_, err := cfg.Queries.GetUser(context.Background(), uuidToPGType(id))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// wantsHTML determines if the request expects HTML response
|
||||
|
||||
@@ -4,13 +4,19 @@ import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// ErrCollectionNameTaken: the user already has a collection with this name.
|
||||
var ErrCollectionNameTaken = errors.New("collection name already exists")
|
||||
|
||||
// Rule represents an auto-assign rule for collections
|
||||
type Rule struct {
|
||||
ID string `json:"id"`
|
||||
@@ -72,7 +78,13 @@ func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UU
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return database.Collections{}, fmt.Errorf("failed to create collection: %v", err)
|
||||
// Duplicate name (UNIQUE(user_id, name)) is a client error, not a
|
||||
// server failure: surface it as 409 so callers can recover.
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return database.Collections{}, ErrCollectionNameTaken
|
||||
}
|
||||
return database.Collections{}, fmt.Errorf("failed to create collection: %w", err)
|
||||
}
|
||||
|
||||
return collection, nil
|
||||
@@ -160,9 +172,24 @@ func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID u
|
||||
|
||||
// DeleteCollection deletes a collection
|
||||
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
|
||||
err := s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
// System collections (the 5 dashboard sections) are PERMANENT: a
|
||||
// delete tombstones the row (invisible everywhere, restorable with
|
||||
// the same id) instead of destroying it and every reference to it.
|
||||
row, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete collection: %v", err)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil // already gone: keep delete idempotent
|
||||
}
|
||||
return fmt.Errorf("failed to fetch collection: %w", err)
|
||||
}
|
||||
|
||||
if row.IsSystemCollection.Bool {
|
||||
return s.db.TombstoneSystemCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
}
|
||||
|
||||
err = s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete collection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
@@ -376,6 +378,22 @@ func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll data
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) {
|
||||
// Absent library = the global (All Libraries) scope: a separate
|
||||
// one-row-per-user table, so an accidental per-library write can never
|
||||
// masquerade as global.
|
||||
if !libraryID.Valid {
|
||||
g, err := s.db.GetGlobalDashboardPreferences(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
||||
if err != nil {
|
||||
return database.UserDashboardPreferences{}, err
|
||||
}
|
||||
return database.UserDashboardPreferences{
|
||||
UserID: g.UserID,
|
||||
HiddenCollections: g.HiddenCollections,
|
||||
CollectionOrder: g.CollectionOrder,
|
||||
ItemsPerSection: g.ItemsPerSection,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
LibraryID: libraryID,
|
||||
@@ -383,6 +401,24 @@ func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID u
|
||||
}
|
||||
|
||||
func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) {
|
||||
if !params.LibraryID.Valid {
|
||||
g, err := s.db.UpsertGlobalDashboardPreferences(ctx, database.UpsertGlobalDashboardPreferencesParams{
|
||||
UserID: params.UserID,
|
||||
HiddenCollections: params.HiddenCollections,
|
||||
CollectionOrder: params.CollectionOrder,
|
||||
ItemsPerSection: params.ItemsPerSection,
|
||||
})
|
||||
if err != nil {
|
||||
return database.UserDashboardPreferences{}, err
|
||||
}
|
||||
return database.UserDashboardPreferences{
|
||||
UserID: g.UserID,
|
||||
HiddenCollections: g.HiddenCollections,
|
||||
CollectionOrder: g.CollectionOrder,
|
||||
ItemsPerSection: g.ItemsPerSection,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
return s.db.UpsertDashboardPreferences(ctx, params)
|
||||
}
|
||||
|
||||
@@ -430,26 +466,23 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
|
||||
return fmt.Errorf("unknown collection: %s", collectionName)
|
||||
}
|
||||
|
||||
if resetType == "keep_books" {
|
||||
return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{
|
||||
// System collections are PERMANENT rows identified by the stable
|
||||
// (user_id, query_type) pair — restore never deletes or re-creates,
|
||||
// so the row id (and every reference to it: device shelf mappings,
|
||||
// dashboard order/hidden prefs, /collections/{id} links) never churns.
|
||||
// Resolution includes tombstoned rows: restoring a deleted section
|
||||
// un-deletes the exact same row.
|
||||
row, err := s.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: collectionName,
|
||||
Description: pgtype.Text{String: meta.Description, Valid: true},
|
||||
Icon: pgtype.Text{String: meta.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: meta.Color, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
||||
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: collectionName,
|
||||
})
|
||||
if err != nil {
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Provision: the only path that mints a system-collection row.
|
||||
_, cerr := s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{
|
||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
Name: collectionName,
|
||||
Description: pgtype.Text{String: meta.Description, Valid: true},
|
||||
@@ -459,5 +492,24 @@ func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID u
|
||||
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
||||
})
|
||||
return cerr
|
||||
}
|
||||
|
||||
// "full": clear item assignments (a fresh section has none) and reset
|
||||
// everything, IN PLACE. "keep_books": metadata-only, in place. Both
|
||||
// un-tombstone, so a deleted section comes back as the same row.
|
||||
if resetType != "keep_books" {
|
||||
if err := s.db.ClearCollectionItems(ctx, row.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err = s.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
||||
ID: row.ID,
|
||||
Name: collectionName,
|
||||
Description: pgtype.Text{String: meta.Description, Valid: true},
|
||||
Icon: pgtype.Text{String: meta.Icon, Valid: true},
|
||||
Color: pgtype.Text{String: meta.Color, Valid: true},
|
||||
Priority: pgtype.Int4{Int32: meta.Priority, Valid: true},
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const (
|
||||
)
|
||||
|
||||
var AllowedExtensions = map[string][]string{
|
||||
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".doc", ".docx", ".lit", ".fb2", ".pdb"},
|
||||
LibraryTypeEbooks: {".epub", ".pdf", ".mobi", ".azw", ".azw3", ".txt", ".rtf", ".docx", ".fb2", ".pdb"},
|
||||
LibraryTypeComics: {".cbz", ".cbr", ".cb7", ".cbt", ".epub", ".pdf"},
|
||||
LibraryTypeManga: {".cbz", ".cbr", ".epub", ".pdf", ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".avif", ".tiff", ".tif"},
|
||||
}
|
||||
@@ -59,9 +59,7 @@ var MimeTypes = map[string]string{
|
||||
".azw3": "application/vnd.amazon.ebook",
|
||||
".txt": "text/plain",
|
||||
".rtf": "application/rtf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".lit": "application/x-msreader",
|
||||
".fb2": "application/x-fictionbook+xml",
|
||||
".pdb": "application/vnd.palm",
|
||||
}
|
||||
@@ -301,6 +299,13 @@ func (s *LibraryService) SyncAllowedExtensions(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (s *LibraryService) ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
||||
// Typed-nil interface guard: a nil *LibraryService held in an
|
||||
// interface passes `!= nil` checks at call sites; degrade instead
|
||||
// of panicking (kobo markup 500s were exactly this path).
|
||||
if s == nil || s.db == nil {
|
||||
return "", fmt.Errorf("library service not configured")
|
||||
}
|
||||
|
||||
// Get library folders for this library
|
||||
folders, err := s.db.GetLibraryFolders(ctx, libraryID)
|
||||
if err != nil || len(folders) == 0 {
|
||||
|
||||
+893
-251
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,410 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// createTestJPEGBytes returns the bytes of a minimal valid JPEG.
|
||||
func createTestJPEGBytes() string {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||||
if err := jpeg.Encode(&buf, img, nil); err != nil {
|
||||
return ""
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// createPragmaticStyleEPUB builds an EPUB modeled on Pragmatic Bookshelf
|
||||
// output: the dc namespace declared on the <metadata> element (not on
|
||||
// <package>), a scheme-less ISBN identifier, an OPF-declared cover, and a
|
||||
// deliberately malformed chapter body. The malformed chapter is the
|
||||
// regression trigger: the previous go-epub-based extractor failed the whole
|
||||
// book when any chapter was unparseable and wrote blank metadata.
|
||||
func createPragmaticStyleEPUB(epubPath string) error {
|
||||
file, err := os.Create(epubPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(file)
|
||||
defer zipWriter.Close()
|
||||
|
||||
mimetypeW, err := zipWriter.CreateHeader(&zip.FileHeader{
|
||||
Name: "mimetype",
|
||||
Method: zip.Store,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mimetypeW.Write([]byte("application/epub+zip"))
|
||||
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
|
||||
// dc namespace declared on <metadata>; identifiers carry no scheme attr
|
||||
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="PubID">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:language>en</dc:language>
|
||||
<dc:title>A Common-Sense Guide</dc:title>
|
||||
<dc:creator>Jay Wengrow</dc:creator>
|
||||
<dc:publisher>The Pragmatic Bookshelf, LLC</dc:publisher>
|
||||
<dc:description>Content that makes you a better programmer.</dc:description>
|
||||
<dc:subject>Programming</dc:subject>
|
||||
<dc:identifier id="PubID">978-1-68050-722-8</dc:identifier>
|
||||
<meta name="cover" content="cover-image"/>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="cover-image" href="images/cover.jpg" media-type="image/jpeg"/>
|
||||
<item id="ch1" href="ch1.xhtml" media-type="application/xhtml+xml"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="ch1"/></spine>
|
||||
</package>`,
|
||||
// Malformed on purpose: unclosed tags
|
||||
"OEBPS/ch1.xhtml": `<html><body><p>unclosed paragraph`,
|
||||
"OEBPS/images/cover.jpg": createTestJPEGBytes(),
|
||||
}
|
||||
|
||||
for name, content := range files {
|
||||
w, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write([]byte(content)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return zipWriter.Close()
|
||||
}
|
||||
|
||||
// TestExtractEPUBMetadataBrokenChapter guards the regression where one
|
||||
// unparseable chapter made the extractor return nothing at all: metadata must
|
||||
// come from the OPF regardless of chapter-body damage.
|
||||
func TestExtractEPUBMetadataBrokenChapter(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
epubPath := filepath.Join(tmpDir, "book.epub")
|
||||
if err := createPragmaticStyleEPUB(epubPath); err != nil {
|
||||
t.Fatalf("failed to create test EPUB: %v", err)
|
||||
}
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
metadata, err := s.extractEPUBMetadata(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBMetadata() error: %v", err)
|
||||
}
|
||||
if metadata.Title != "A Common-Sense Guide" {
|
||||
t.Errorf("Title = %q, want %q", metadata.Title, "A Common-Sense Guide")
|
||||
}
|
||||
if metadata.Author != "Jay Wengrow" {
|
||||
t.Errorf("Author = %q, want %q", metadata.Author, "Jay Wengrow")
|
||||
}
|
||||
if metadata.Publisher != "The Pragmatic Bookshelf, LLC" {
|
||||
t.Errorf("Publisher = %q, want %q", metadata.Publisher, "The Pragmatic Bookshelf, LLC")
|
||||
}
|
||||
if metadata.Description == "" {
|
||||
t.Error("Description missing")
|
||||
}
|
||||
if metadata.Language != "en" {
|
||||
t.Errorf("Language = %q, want %q", metadata.Language, "en")
|
||||
}
|
||||
// Scheme-less identifier that normalizes to a valid ISBN must be picked up
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("ISBN missing (scheme-less dc:identifier fallback failed)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOPFContentCalibreSeries(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
|
||||
<dc:title>Test Book</dc:title>
|
||||
<dc:creator>Some Author</dc:creator>
|
||||
<dc:date>2020-03-15</dc:date>
|
||||
<dc:subject>Fiction</dc:subject>
|
||||
<dc:subject>Classic</dc:subject>
|
||||
<dc:identifier opf:scheme="ISBN">978-3-16-148410-0</dc:identifier>
|
||||
<meta name="calibre:series" content="Great Series"/>
|
||||
<meta name="calibre:series_index" content="2.5"/>
|
||||
</metadata>
|
||||
</package>`
|
||||
metadata, err := parseOPFContent([]byte(opf))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 2 {
|
||||
t.Errorf("Series = %q/%d, want Great Series/2", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("schemed ISBN not extracted")
|
||||
}
|
||||
wantDate := time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
if !metadata.PublishDate.Equal(wantDate) {
|
||||
t.Errorf("PublishDate = %v, want %v", metadata.PublishDate, wantDate)
|
||||
}
|
||||
if len(metadata.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want 2 subjects", metadata.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractAudiobookshelfSidecar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
validate func(t *testing.T, m *MediaMetadata)
|
||||
}{
|
||||
{
|
||||
name: "full sidecar",
|
||||
json: `{
|
||||
"title": "An Book",
|
||||
"authors": ["Author One", "Author Two"],
|
||||
"series": [{"series": "The Series", "sequence": "4.5"}],
|
||||
"genres": ["Fantasy"],
|
||||
"tags": ["tag1"],
|
||||
"publishedYear": 2019,
|
||||
"publisher": "ACME Books",
|
||||
"description": "A very good book.",
|
||||
"isbn": "978-3-16-148410-0",
|
||||
"asin": "B08XYZ",
|
||||
"language": "en"
|
||||
}`,
|
||||
validate: func(t *testing.T, m *MediaMetadata) {
|
||||
if m.Title != "An Book" || m.Author != "Author One" {
|
||||
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||
}
|
||||
if m.Series != "The Series" || m.SeriesNumber != 4 {
|
||||
t.Errorf("Series = %q/%d, want The Series/4", m.Series, m.SeriesNumber)
|
||||
}
|
||||
if len(m.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
||||
}
|
||||
if m.PublishDate.Year() != 2019 {
|
||||
t.Errorf("PublishDate year = %d, want 2019", m.PublishDate.Year())
|
||||
}
|
||||
if m.Publisher != "ACME Books" || m.Description != "A very good book." {
|
||||
t.Errorf("Publisher/Description = %q/%q", m.Publisher, m.Description)
|
||||
}
|
||||
if m.ISBN == "" || m.ASIN != "B08XYZ" || m.Language != "en" {
|
||||
t.Errorf("ISBN/ASIN/Language = %q/%q/%q", m.ISBN, m.ASIN, m.Language)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sparse sidecar (real-world Audiobookshelf export)",
|
||||
json: `{"title": "Sparse (1234)", "authors": ["X"], "tags": [], "description": null}`,
|
||||
validate: func(t *testing.T, m *MediaMetadata) {
|
||||
if m.Title != "Sparse (1234)" || m.Author != "X" {
|
||||
t.Errorf("Title/Author = %q/%q", m.Title, m.Author)
|
||||
}
|
||||
if m.Description != "" || m.Tags != nil {
|
||||
t.Error("null/empty sidecar fields must stay unset")
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(tt.json), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub"))
|
||||
if m == nil {
|
||||
t.Fatal("extractAudiobookshelfSidecar() = nil, want metadata")
|
||||
}
|
||||
tt.validate(t, m)
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("no sidecar returns nil", func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if m := extractAudiobookshelfSidecar(filepath.Join(dir, "book.epub")); m != nil {
|
||||
t.Errorf("extractAudiobookshelfSidecar() = %v, want nil", m)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createTestPDFWithImage builds a minimal one-page PDF embedding a JPEG
|
||||
// XObject, so pdfcpu-based cover extraction has an image to find.
|
||||
func createTestPDFWithImage() []byte {
|
||||
jpegData := []byte(createTestJPEGBytes())
|
||||
content := "q 100 0 0 100 0 0 cm /Im0 Do Q"
|
||||
|
||||
var buf bytes.Buffer
|
||||
offsets := []int{0} // object numbers are 1-based
|
||||
buf.WriteString("%PDF-1.4\n")
|
||||
|
||||
writeObj := func(n int, body func(w *bytes.Buffer)) {
|
||||
offsets = append(offsets, buf.Len())
|
||||
fmt.Fprintf(&buf, "%d 0 obj\n", n)
|
||||
body(&buf)
|
||||
buf.WriteString("endobj\n")
|
||||
}
|
||||
|
||||
writeObj(1, func(w *bytes.Buffer) { w.WriteString("<< /Type /Catalog /Pages 2 0 R >>\n") })
|
||||
writeObj(2, func(w *bytes.Buffer) { w.WriteString("<< /Type /Pages /Kids [3 0 R] /Count 1 >>\n") })
|
||||
writeObj(3, func(w *bytes.Buffer) {
|
||||
w.WriteString("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 100 100] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>\n")
|
||||
})
|
||||
writeObj(4, func(w *bytes.Buffer) {
|
||||
fmt.Fprintf(w, "<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", len(jpegData))
|
||||
w.Write(jpegData)
|
||||
w.WriteString("\nendstream\n")
|
||||
})
|
||||
writeObj(5, func(w *bytes.Buffer) {
|
||||
fmt.Fprintf(w, "<< /Length %d >>\nstream\n%s\nendstream\n", len(content), content)
|
||||
})
|
||||
writeObj(6, func(w *bytes.Buffer) {
|
||||
// Info dictionary deliberately richer than the sidecars in gap-fill
|
||||
// tests: gap-fill must use these, never overwrite with them.
|
||||
w.WriteString("<< /Title (Embedded Title) /Author (Embedded Author) /Subject (Embedded Subject) /Producer (Embedded Producer) /Keywords (embedded-kw) >>\n")
|
||||
})
|
||||
|
||||
xrefStart := buf.Len()
|
||||
fmt.Fprintf(&buf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
|
||||
for _, off := range offsets[1:] {
|
||||
fmt.Fprintf(&buf, "%010d 00000 n \n", off)
|
||||
}
|
||||
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root 1 0 R /Info 6 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xrefStart)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestSidecarCoverFallback guards the regression where a metadata.json
|
||||
// sidecar (no cover.jpg next to the book) made extractMetadata return an
|
||||
// empty CoverPath for PDFs - mergeMetadata has no PDF/EPUB cover logic, so a
|
||||
// full rescan wiped cover_image_path for sidecar-managed books.
|
||||
func TestSidecarCoverFallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pdfPath := filepath.Join(dir, "book.pdf")
|
||||
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(`{"title":"Sidecar Book","authors":["A"]}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
s.folders = []string{dir} // SetFolders requires a live DB; tests only need path relativization
|
||||
metadata, err := s.extractMetadata(pdfPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractMetadata() error: %v", err)
|
||||
}
|
||||
if metadata.Title != "Sidecar Book" {
|
||||
t.Errorf("Title = %q, want sidecar title", metadata.Title)
|
||||
}
|
||||
if metadata.CoverPath == "" {
|
||||
t.Fatal("CoverPath empty - sidecar branch skipped embedded cover extraction")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, metadata.CoverPath)); err != nil {
|
||||
t.Errorf("extracted cover not on disk: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeMetadataEPUBGapFill verifies that a sparse sidecar (metadata.opf
|
||||
// with only a title) gets its blanks filled from the book's own embedded OPF
|
||||
// while sidecar-set fields are never overwritten.
|
||||
func TestMergeMetadataEPUBGapFill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
epubPath := filepath.Join(dir, "gappy.epub")
|
||||
f, err := os.Create(epubPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zipWriter := zip.NewWriter(f)
|
||||
mimetype, _ := zipWriter.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||
mimetype.Write([]byte("application/epub+zip"))
|
||||
epubFiles := map[string]string{
|
||||
"META-INF/container.xml": `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`,
|
||||
"OEBPS/content.opf": `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="u">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Embedded Title</dc:title>
|
||||
<dc:creator>Embedded Author</dc:creator>
|
||||
<dc:description>Embedded description from the book.</dc:description>
|
||||
<dc:publisher>Embedded Publisher</dc:publisher>
|
||||
<dc:language>en</dc:language>
|
||||
<dc:date>2021-06-01</dc:date>
|
||||
</metadata>
|
||||
<manifest/>
|
||||
<spine/>
|
||||
</package>`,
|
||||
}
|
||||
for name, content := range epubFiles {
|
||||
w, err := zipWriter.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Write([]byte(content))
|
||||
}
|
||||
if err := zipWriter.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f.Close()
|
||||
|
||||
sparse := &MediaMetadata{Title: "Sidecar Title", Author: "Sidecar Author"}
|
||||
s := NewMediaScanner(nil)
|
||||
merged, err := s.mergeMetadata(epubPath, sparse)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeMetadata() error: %v", err)
|
||||
}
|
||||
if merged.Title != "Sidecar Title" || merged.Author != "Sidecar Author" {
|
||||
t.Errorf("sidecar values overwritten: title=%q author=%q", merged.Title, merged.Author)
|
||||
}
|
||||
if merged.Description != "Embedded description from the book." {
|
||||
t.Errorf("Description = %q, want embedded fill", merged.Description)
|
||||
}
|
||||
if merged.Publisher != "Embedded Publisher" {
|
||||
t.Errorf("Publisher = %q, want embedded fill", merged.Publisher)
|
||||
}
|
||||
if merged.Language != "en" {
|
||||
t.Errorf("Language = %q, want embedded fill", merged.Language)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeMetadataPDFGapFill verifies the same for PDFs: a sparse sidecar
|
||||
// keeps its title while author/description/publisher/tags/pagecount fill in
|
||||
// from the embedded Info dictionary.
|
||||
func TestMergeMetadataPDFGapFill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
pdfPath := filepath.Join(dir, "gappy.pdf")
|
||||
if err := os.WriteFile(pdfPath, createTestPDFWithImage(), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sparse := &MediaMetadata{Title: "Kept Title"}
|
||||
s := NewMediaScanner(nil)
|
||||
merged, err := s.mergeMetadata(pdfPath, sparse)
|
||||
if err != nil {
|
||||
t.Fatalf("mergeMetadata() error: %v", err)
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
got string
|
||||
want string
|
||||
}{
|
||||
{"Title (sidecar wins)", merged.Title, "Kept Title"},
|
||||
{"Author", merged.Author, "Embedded Author"},
|
||||
{"Description", merged.Description, "Embedded Subject"},
|
||||
{"Publisher", merged.Publisher, "Embedded Producer"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s = %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
if len(merged.Tags) == 0 {
|
||||
t.Error("Tags empty, want keywords from Info dict")
|
||||
}
|
||||
if merged.PageCount != 1 {
|
||||
t.Errorf("PageCount = %d, want 1 from Info dict", merged.PageCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func mustUUID(t *testing.T, raw string) pgtype.UUID {
|
||||
t.Helper()
|
||||
parsed, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid test uuid %q: %v", raw, err)
|
||||
}
|
||||
return pgtype.UUID{Bytes: parsed, Valid: true}
|
||||
}
|
||||
|
||||
// alwaysExists simulates every candidate's file still being on disk:
|
||||
// deliberate copies across libraries must never be repointed.
|
||||
func TestSelectMoveCandidateAllFilesExist(t *testing.T) {
|
||||
candidates := []moveCandidate{
|
||||
{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "a/book.epub"},
|
||||
{ID: mustUUID(t, "00000000-0000-0000-0000-000000000002"), FilePath: "b/book.epub"},
|
||||
}
|
||||
|
||||
if _, ok := selectMoveCandidate(candidates, func(moveCandidate) bool { return true }); ok {
|
||||
t.Fatal("expected no move candidate when every file still exists")
|
||||
}
|
||||
}
|
||||
|
||||
// Input arrives pre-ordered (archived first, then most missing scans, then
|
||||
// oldest), so the first missing candidate must win.
|
||||
func TestSelectMoveCandidatePicksFirstMissing(t *testing.T) {
|
||||
onDisk := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "a/book.epub"}
|
||||
gone := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000002"), FilePath: "b/book.epub"}
|
||||
candidates := []moveCandidate{onDisk, gone}
|
||||
|
||||
got, ok := selectMoveCandidate(candidates, func(c moveCandidate) bool {
|
||||
return c.ID == onDisk.ID
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("expected a move candidate")
|
||||
}
|
||||
if got.ID != gone.ID {
|
||||
t.Fatalf("expected candidate %v, got %v", gone.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMoveCandidateOnlyMissingQualifies(t *testing.T) {
|
||||
gone := moveCandidate{ID: mustUUID(t, "00000000-0000-0000-0000-000000000001"), FilePath: "old/book.epub"}
|
||||
candidates := []moveCandidate{gone}
|
||||
|
||||
got, ok := selectMoveCandidate(candidates, func(moveCandidate) bool { return false })
|
||||
if !ok {
|
||||
t.Fatal("expected the missing candidate to qualify")
|
||||
}
|
||||
if got.ID != gone.ID {
|
||||
t.Fatalf("expected candidate %v, got %v", gone.ID, got.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectMoveCandidateEmptyList(t *testing.T) {
|
||||
if _, ok := selectMoveCandidate(nil, func(moveCandidate) bool { return false }); ok {
|
||||
t.Fatal("expected no candidate for an empty list")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Calibre-modeled OPF parsing. The scanner previously scraped OPF content
|
||||
// with attribute-order-sensitive regexes; real books serialize attributes in
|
||||
// any order (e.g. Pragmatic/Pattinson EPUBs put id before properties and
|
||||
// content before name), which silently defeated cover detection. Everything
|
||||
// here is parsed with encoding/xml so attribute order and namespace prefix
|
||||
// choices are irrelevant.
|
||||
|
||||
type opfDCValue struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type opfIdentifier struct {
|
||||
Scheme string `xml:"http://www.idpf.org/2007/opf scheme,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type opfMeta struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
Content string `xml:"content,attr"`
|
||||
Property string `xml:"property,attr"`
|
||||
Refines string `xml:"refines,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type opfItem struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
MediaType string `xml:"media-type,attr"`
|
||||
Properties string `xml:"properties,attr"`
|
||||
}
|
||||
|
||||
// opfDocument is a structured view of an OPF package document.
|
||||
type opfDocument struct {
|
||||
Metadata struct {
|
||||
Titles []opfDCValue `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
Creators []string `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
Subjects []string `xml:"http://purl.org/dc/elements/1.1/ subject"`
|
||||
Descriptions []string `xml:"http://purl.org/dc/elements/1.1/ description"`
|
||||
Publishers []string `xml:"http://purl.org/dc/elements/1.1/ publisher"`
|
||||
Dates []string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
Languages []string `xml:"http://purl.org/dc/elements/1.1/ language"`
|
||||
Identifiers []opfIdentifier `xml:"http://purl.org/dc/elements/1.1/ identifier"`
|
||||
Contributors []string `xml:"http://purl.org/dc/elements/1.1/ contributor"`
|
||||
Metas []opfMeta `xml:"meta"`
|
||||
} `xml:"metadata"`
|
||||
Manifest struct {
|
||||
Items []opfItem `xml:"item"`
|
||||
} `xml:"manifest"`
|
||||
Spine struct {
|
||||
PageProgressionDirection string `xml:"page-progression-direction,attr"`
|
||||
Itemrefs []struct {
|
||||
IDRef string `xml:"idref,attr"`
|
||||
} `xml:"itemref"`
|
||||
} `xml:"spine"`
|
||||
Guide struct {
|
||||
References []struct {
|
||||
Type string `xml:"type,attr"`
|
||||
Href string `xml:"href,attr"`
|
||||
} `xml:"reference"`
|
||||
} `xml:"guide"`
|
||||
}
|
||||
|
||||
func parseOPFXML(content []byte) (*opfDocument, error) {
|
||||
var doc opfDocument
|
||||
if err := xml.Unmarshal(content, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// refinesFor maps an element id to its EPUB3 refining metas
|
||||
// (those whose refines attribute starts with '#').
|
||||
func (d *opfDocument) refinesFor(id string) []opfMeta {
|
||||
var out []opfMeta
|
||||
if id == "" {
|
||||
return out
|
||||
}
|
||||
for _, m := range d.Metadata.Metas {
|
||||
if strings.HasPrefix(m.Refines, "#") && m.Refines[1:] == id {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// refinesProperty returns the value of the first refining meta carrying the
|
||||
// given property (e.g. "title-type", "collection-type", "group-position").
|
||||
func refinesProperty(metas []opfMeta, property string) (string, bool) {
|
||||
for _, m := range metas {
|
||||
if strings.EqualFold(m.Property, property) {
|
||||
if v := strings.TrimSpace(m.Value); v != "" {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// selectTitle ports Calibre's read_title: prefer the dc:title refined as
|
||||
// title-type "main"; fall back to the first non-empty title. A distinct
|
||||
// subtitle (title-type containing "subtitle"/"sub-title") is joined onto the
|
||||
// main title with ": ", exactly as Calibre stores it.
|
||||
func (d *opfDocument) selectTitle() string {
|
||||
var first, main, subtitle string
|
||||
for _, t := range d.Metadata.Titles {
|
||||
v := strings.TrimSpace(t.Value)
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if first == "" {
|
||||
first = v
|
||||
}
|
||||
tt, ok := refinesProperty(d.refinesFor(t.ID), "title-type")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(tt) {
|
||||
case "main":
|
||||
if main == "" {
|
||||
main = v
|
||||
}
|
||||
default:
|
||||
l := strings.ToLower(tt)
|
||||
if strings.Contains(l, "subtitle") || strings.Contains(l, "sub-title") {
|
||||
if subtitle == "" {
|
||||
subtitle = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
title := main
|
||||
if title == "" {
|
||||
title = first
|
||||
}
|
||||
if subtitle != "" && subtitle != title {
|
||||
title = title + ": " + subtitle
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// readSeries ports Calibre's read_series: EPUB3 belongs-to-collection (with a
|
||||
// collection-type=series refine and group-position index) first, then the
|
||||
// classic calibre:series / calibre:series_index metas.
|
||||
func (d *opfDocument) readSeries() (series string, index float64) {
|
||||
for _, m := range d.Metadata.Metas {
|
||||
if !strings.EqualFold(m.Property, "belongs-to-collection") {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(m.Value)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
refines := d.refinesFor(m.ID)
|
||||
if ct, ok := refinesProperty(refines, "collection-type"); !ok || !strings.EqualFold(ct, "series") {
|
||||
continue
|
||||
}
|
||||
if gp, ok := refinesProperty(refines, "group-position"); ok {
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(gp), 64); err == nil {
|
||||
index = v
|
||||
}
|
||||
}
|
||||
return name, index
|
||||
}
|
||||
for _, m := range d.Metadata.Metas {
|
||||
switch m.Name {
|
||||
case "calibre:series":
|
||||
series = m.Content
|
||||
case "calibre:series_index":
|
||||
if v, err := strconv.ParseFloat(strings.TrimSpace(m.Content), 64); err == nil {
|
||||
index = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return series, index
|
||||
}
|
||||
|
||||
// pageProgressionDirection returns the OPF spine's reading direction as
|
||||
// "rtl" or "ltr", or "" when the file declares none (callers treat that as
|
||||
// unknown, not as left-to-right). EPUB2/3 declare this on <spine>; it is
|
||||
// what foliate reads client-side, and the DB column feeds clients (and the
|
||||
// web reader's fixed-layout override) that need it up front.
|
||||
func (d *opfDocument) pageProgressionDirection() string {
|
||||
switch strings.ToLower(strings.TrimSpace(d.Spine.PageProgressionDirection)) {
|
||||
case "rtl", "right-to-left":
|
||||
return "rtl"
|
||||
case "ltr", "left-to-right", "default":
|
||||
return "ltr"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// itemByID returns manifest items with id, href and media-type, keyed by id.
|
||||
func (d *opfDocument) itemByID() map[string]opfItem {
|
||||
m := make(map[string]opfItem, len(d.Manifest.Items))
|
||||
for _, it := range d.Manifest.Items {
|
||||
if it.ID != "" && it.Href != "" && it.MediaType != "" {
|
||||
m[it.ID] = it
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// firstSpineItem returns the manifest item for the first spine idref.
|
||||
func (d *opfDocument) firstSpineItem() (opfItem, bool) {
|
||||
if len(d.Spine.Itemrefs) == 0 {
|
||||
return opfItem{}, false
|
||||
}
|
||||
item, ok := d.itemByID()[d.Spine.Itemrefs[0].IDRef]
|
||||
return item, ok
|
||||
}
|
||||
|
||||
// isRasterMedia reports whether a manifest media-type is an image but not an
|
||||
// (X)HTML document - Calibre's guard against cover *pages* masquerading as
|
||||
// cover images.
|
||||
func isRasterMedia(mediaType string) bool {
|
||||
mt := strings.ToLower(strings.TrimSpace(mediaType))
|
||||
if mt == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(mt, "xml") || strings.Contains(mt, "html") {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(mt, "image/")
|
||||
}
|
||||
|
||||
// findRasterCoverInOPF ports Calibre's read_raster_cover resolution order:
|
||||
// 1. manifest item with properties containing "cover-image"
|
||||
// 2. <meta name="cover" content="ID"> resolved through the manifest
|
||||
// 3. the first spine item being a raster image itself (store manga)
|
||||
//
|
||||
// Returns the OPF-relative href of the cover image, or "".
|
||||
func (d *opfDocument) findRasterCoverInOPF() string {
|
||||
// 1. properties="cover-image" (space-separated property list)
|
||||
for _, it := range d.Manifest.Items {
|
||||
for _, prop := range strings.Fields(it.Properties) {
|
||||
if strings.EqualFold(prop, "cover-image") && isRasterMedia(it.MediaType) {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. meta name="cover" content=<manifest image id>
|
||||
byID := d.itemByID()
|
||||
for _, m := range d.Metadata.Metas {
|
||||
if !strings.EqualFold(m.Name, "cover") {
|
||||
continue
|
||||
}
|
||||
if it, ok := byID[strings.TrimSpace(m.Content)]; ok && isRasterMedia(it.MediaType) {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
|
||||
// 3. first spine item is itself an image (jpeg/webp/png per Calibre)
|
||||
if it, ok := d.firstSpineItem(); ok {
|
||||
mt := strings.ToLower(it.MediaType)
|
||||
if mt == "image/jpeg" || mt == "image/webp" || mt == "image/png" {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// coverPageHref returns the OPF-relative href of the cover *page* document to
|
||||
// mine for an embedded image: the guide's type="cover" reference when
|
||||
// present, otherwise the first spine item (Calibre renders the latter).
|
||||
func (d *opfDocument) coverPageHref() string {
|
||||
for _, ref := range d.Guide.References {
|
||||
if strings.EqualFold(ref.Type, "cover") && ref.Href != "" {
|
||||
return ref.Href
|
||||
}
|
||||
}
|
||||
if it, ok := d.firstSpineItem(); ok {
|
||||
if it.Href != "" && !isRasterMedia(it.MediaType) {
|
||||
return it.Href
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isISBNLike reports whether a bare identifier value is shaped like an ISBN
|
||||
// (digits, optional hyphens/spaces, optional trailing X; 10 or 13
|
||||
// significant characters). Guards the scheme-less dc:identifier fallback
|
||||
// against URLs and UUIDs sharing the same slot.
|
||||
func isISBNLike(v string) bool {
|
||||
digits := 0
|
||||
for i, r := range v {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
digits++
|
||||
case r == '-' || r == ' ':
|
||||
// separator
|
||||
case (r == 'X' || r == 'x') && i == len(v)-1:
|
||||
digits++ // ISBN-10 check character
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return digits == 10 || digits == 13
|
||||
}
|
||||
|
||||
// findImageReferenceInPage extracts the first raster image reference from a
|
||||
// cover (X)HTML page: <img src="..."> or SVG <image xlink:href="...">.
|
||||
// Token-based parsing keeps it tolerant of mixed namespaces and fragments.
|
||||
// Returns the reference relative to the page document, or "".
|
||||
func findImageReferenceInPage(pageContent []byte) string {
|
||||
decoder := xml.NewDecoder(bytes.NewReader(pageContent))
|
||||
for {
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
start, ok := tok.(xml.StartElement)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(start.Name.Local) {
|
||||
case "img":
|
||||
for _, a := range start.Attr {
|
||||
if strings.EqualFold(a.Name.Local, "src") && strings.TrimSpace(a.Value) != "" {
|
||||
return strings.TrimSpace(a.Value)
|
||||
}
|
||||
}
|
||||
case "image":
|
||||
for _, a := range start.Attr {
|
||||
if strings.EqualFold(a.Name.Local, "href") && strings.TrimSpace(a.Value) != "" {
|
||||
return strings.TrimSpace(a.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// helper to build an EPUB zip from a file map for cover tests
|
||||
func writeEPUB(t *testing.T, path string, files map[string]string) {
|
||||
t.Helper()
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
w := zip.NewWriter(f)
|
||||
mimetype, err := w.CreateHeader(&zip.FileHeader{Name: "mimetype", Method: zip.Store})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mimetype.Write([]byte("application/epub+zip"))
|
||||
for name, content := range files {
|
||||
fw, err := w.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(content)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
const containerXML = `<?xml version="1.0"?><container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/package.opf" media-type="application/oebps-package+xml"/></rootfiles></container>`
|
||||
|
||||
const tinyJPEG = "\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
|
||||
|
||||
// TestFindCoverInOPFAttributeOrder guards the regression where attribute
|
||||
// order defeated regex scraping: this OPF mirrors Grand Central's "3 Days to
|
||||
// Live" serialization (href before id, content before name on the meta tag).
|
||||
func TestFindCoverInOPFAttributeOrder(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>3 Days to Live</dc:title>
|
||||
<meta content="cover-image" name="cover"/>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item href="images/9781538752760.jpg" id="cover-image" media-type="image/jpeg" properties="cover-image"/>
|
||||
</manifest>
|
||||
<spine/>
|
||||
</package>`
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": opf,
|
||||
"OEBPS/images/9781538752760.jpg": tinyJPEG,
|
||||
}
|
||||
epubPath := filepath.Join(t.TempDir(), "book.epub")
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
coverPath, err := s.extractEPUBCover(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||
}
|
||||
if coverPath == "" {
|
||||
t.Fatal("cover not extracted - attribute order still defeats resolution")
|
||||
}
|
||||
if _, err := os.Stat(coverPath); err != nil {
|
||||
t.Fatalf("cover file not written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindCoverInOPFCoverPage covers books that declare no raster cover at
|
||||
// all: the classic EPUB2/Adobe structure where cover.xhtml wraps the image
|
||||
// (here via SVG), reachable through the guide reference or first spine item.
|
||||
func TestFindCoverInOPFCoverPage(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Old Adobe Book</dc:title>
|
||||
</metadata>
|
||||
<manifest>
|
||||
<item id="coverpage" href="text/cover.xhtml" media-type="application/xhtml+xml"/>
|
||||
<item id="coverimg" href="art/cover-wrap.jpg" media-type="image/jpeg"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="coverpage"/></spine>
|
||||
<guide><reference type="cover" href="text/cover.xhtml"/></guide>
|
||||
</package>`
|
||||
coverPage := `<?xml version="1.0"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<body>
|
||||
<div><svg xmlns="http://www.w3.org/2000/svg"><image xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="../art/cover-wrap.jpg"/></svg></div>
|
||||
</body>
|
||||
</html>`
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": opf,
|
||||
"OEBPS/text/cover.xhtml": coverPage,
|
||||
"OEBPS/art/cover-wrap.jpg": tinyJPEG,
|
||||
}
|
||||
epubPath := filepath.Join(t.TempDir(), "adobe.epub")
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
coverPath, err := s.extractEPUBCover(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||
}
|
||||
if coverPath == "" {
|
||||
t.Fatal("cover-page fallback failed to find SVG-wrapped image")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFindCoverInOPFImageFirstSpine covers store manga whose first spine
|
||||
// item is a raster image itself (Calibre's third resolution step).
|
||||
func TestFindCoverInOPFImageFirstSpine(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Manga Vol 1</dc:title></metadata>
|
||||
<manifest>
|
||||
<item id="p1" href="pages/0001.jpg" media-type="image/jpeg"/>
|
||||
</manifest>
|
||||
<spine><itemref idref="p1"/></spine>
|
||||
</package>`
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": opf,
|
||||
"OEBPS/pages/0001.jpg": tinyJPEG,
|
||||
}
|
||||
epubPath := filepath.Join(t.TempDir(), "manga.epub")
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
coverPath, err := s.extractEPUBCover(epubPath)
|
||||
if err != nil {
|
||||
t.Fatalf("extractEPUBCover() error: %v", err)
|
||||
}
|
||||
if coverPath == "" {
|
||||
t.Fatal("image-first spine cover not detected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOPFContentTitleTypeAndSeries covers EPUB3 refines-based title
|
||||
// selection (main + subtitle joined Calibre-style) and belongs-to-collection
|
||||
// series with collection-type and group-position refines.
|
||||
func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="pub-id">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title id="t1">The Main Title</dc:title>
|
||||
<dc:title id="t2">The Subtitle</dc:title>
|
||||
<meta refines="#t1" property="title-type">main</meta>
|
||||
<meta refines="#t2" property="title-type">subtitle</meta>
|
||||
<dc:subject>Programming</dc:subject>
|
||||
<dc:subject>Algorithms</dc:subject>
|
||||
<dc:identifier>urn:isbn:978-3-16-148410-0</dc:identifier>
|
||||
<meta id="coll1" property="belongs-to-collection">Great Series</meta>
|
||||
<meta refines="#coll1" property="collection-type">series</meta>
|
||||
<meta refines="#coll1" property="group-position">4.5</meta>
|
||||
</metadata>
|
||||
</package>`
|
||||
metadata, err := parseOPFContent([]byte(opf))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if want := "The Main Title: The Subtitle"; metadata.Title != want {
|
||||
t.Errorf("Title = %q, want %q", metadata.Title, want)
|
||||
}
|
||||
if metadata.Series != "Great Series" || metadata.SeriesNumber != 4 {
|
||||
t.Errorf("Series = %q/%d, want Great Series/4", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("urn:isbn: identifier not extracted")
|
||||
}
|
||||
if metadata.Genre != "Programming" {
|
||||
t.Errorf("Genre = %q, want first subject %q", metadata.Genre, "Programming")
|
||||
}
|
||||
if len(metadata.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want both subjects", metadata.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOPFContentSubjectsWithCommas verifies subject headings keep their
|
||||
// embedded commas as single tags (Library of Congress style headings).
|
||||
func TestParseOPFContentSubjectsWithCommas(t *testing.T) {
|
||||
opf := `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>A Study in Scarlet</dc:title>
|
||||
<dc:subject>Holmes, Sherlock (Fictitious character) -- Fiction</dc:subject>
|
||||
</metadata>
|
||||
</package>`
|
||||
metadata, err := parseOPFContent([]byte(opf))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if len(metadata.Tags) != 1 {
|
||||
t.Errorf("Tags = %v, want exactly 1 unsplit subject heading", metadata.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveOPFPath checks URL decoding and posix normalization of
|
||||
// OPF-relative hrefs.
|
||||
func TestResolveOPFPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
opfPath, href, want string
|
||||
}{
|
||||
{"OEBPS/package.opf", "images/cover.jpg", "OEBPS/images/cover.jpg"},
|
||||
{"package.opf", "cover.jpg", "cover.jpg"},
|
||||
{"OEBPS/package.opf", "../cover.jpg", "cover.jpg"},
|
||||
{"OEBPS/package.opf", "my%20covers/a%20cover.jpg", "OEBPS/my covers/a cover.jpg"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := resolveOPFPath(tt.opfPath, tt.href); got != tt.want {
|
||||
t.Errorf("resolveOPFPath(%q, %q) = %q, want %q", tt.opfPath, tt.href, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseOPFContentPageProgressionDirection verifies the EPUB spine's
|
||||
// page-progression-direction feeds ReadingDirection, and that an undeclared
|
||||
// direction stays empty rather than forcing left-to-right.
|
||||
func TestParseOPFContentPageProgressionDirection(t *testing.T) {
|
||||
makeOPF := func(spineAttrs string) string {
|
||||
return `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Dir Test</dc:title></metadata>
|
||||
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
|
||||
<spine` + spineAttrs + `><itemref idref="p1"/></spine>
|
||||
</package>`
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
spineAttrs string
|
||||
want string
|
||||
}{
|
||||
{"rtl declared lowercase", ` page-progression-direction="rtl"`, "rtl"},
|
||||
{"rtl declared uppercase", ` page-progression-direction="RTL"`, "rtl"},
|
||||
{"ltr declared", ` page-progression-direction="ltr"`, "ltr"},
|
||||
{"undeclared stays empty", ``, ""},
|
||||
{"unknown value stays empty", ` page-progression-direction="sideways"`, ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
metadata, err := parseOPFContent([]byte(makeOPF(tt.spineAttrs)))
|
||||
if err != nil {
|
||||
t.Fatalf("parseOPFContent() error: %v", err)
|
||||
}
|
||||
if metadata.ReadingDirection != tt.want {
|
||||
t.Errorf("ReadingDirection = %q, want %q", metadata.ReadingDirection, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeMetadataReadingDirectionGapFill verifies the sidecar wins when it
|
||||
// declares a direction, while an embedded-only direction fills the blank.
|
||||
func TestMergeMetadataReadingDirectionGapFill(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
epubPath := filepath.Join(dir, "dir.epub")
|
||||
files := map[string]string{
|
||||
"META-INF/container.xml": containerXML,
|
||||
"OEBPS/package.opf": `<?xml version="1.0"?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>RTL Book</dc:title></metadata>
|
||||
<manifest><item id="p1" href="p1.xhtml" media-type="application/xhtml+xml"/></manifest>
|
||||
<spine page-progression-direction="rtl"><itemref idref="p1"/></spine>
|
||||
</package>`,
|
||||
"OEBPS/p1.xhtml": `<html><body><p>hi</p></body></html>`,
|
||||
}
|
||||
writeEPUB(t, epubPath, files)
|
||||
|
||||
s := NewMediaScanner(nil)
|
||||
|
||||
// Sidecar blank -> embedded rtl fills it
|
||||
merged, err := s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar"})
|
||||
if err != nil {
|
||||
t.Fatalf("mergeMetadata() error: %v", err)
|
||||
}
|
||||
if merged.ReadingDirection != "rtl" {
|
||||
t.Errorf("ReadingDirection = %q, want embedded rtl fill", merged.ReadingDirection)
|
||||
}
|
||||
|
||||
// Sidecar ltr wins over embedded rtl
|
||||
merged, err = s.mergeMetadata(epubPath, &MediaMetadata{Title: "Sidecar", ReadingDirection: "ltr"})
|
||||
if err != nil {
|
||||
t.Fatalf("mergeMetadata() error: %v", err)
|
||||
}
|
||||
if merged.ReadingDirection != "ltr" {
|
||||
t.Errorf("ReadingDirection = %q, want sidecar ltr preserved", merged.ReadingDirection)
|
||||
}
|
||||
}
|
||||
@@ -119,3 +119,36 @@ func TestProcessDirtyDirectories_CollectsReadyDirs(t *testing.T) {
|
||||
scanner.dirtyDirsMu.RUnlock()
|
||||
assert.Equal(t, 0, count, "Ready directory should be removed from dirty list")
|
||||
}
|
||||
|
||||
func TestClassifyFormatGroup(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ext string
|
||||
epubFixed bool
|
||||
group string
|
||||
reflow bool
|
||||
fixed bool
|
||||
}{
|
||||
{"EPUB reflowable", ".epub", false, "reflowable", true, false},
|
||||
{"EPUB fixed-layout", ".epub", true, "fixed_layout", false, true},
|
||||
{"MOBI", ".mobi", false, "reflowable", true, false},
|
||||
{"FB2", ".fb2", false, "reflowable", true, false},
|
||||
{"TXT", ".txt", false, "reflowable", true, false},
|
||||
{"DOCX (was unknown before the fix)", ".docx", false, "reflowable", true, false},
|
||||
{"RTF", ".rtf", false, "reflowable", true, false},
|
||||
{"PDB (PalmDOC)", ".pdb", false, "reflowable", true, false},
|
||||
{"PDF", ".pdf", false, "fixed_layout", false, true},
|
||||
{"DJVU", ".djvu", false, "fixed_layout", false, true},
|
||||
{"CBZ", ".cbz", false, "comic_archive", false, true},
|
||||
{"unrecognized extension", ".xyz", false, "unknown", false, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
group, reflow, fixed := classifyFormatGroup(tt.ext, tt.epubFixed)
|
||||
if group != tt.group || reflow != tt.reflow || fixed != tt.fixed {
|
||||
t.Errorf("classifyFormatGroup(%q, %v) = (%q, %v, %v), want (%q, %v, %v)",
|
||||
tt.ext, tt.epubFixed, group, reflow, fixed, tt.group, tt.reflow, tt.fixed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ type SearchParams struct {
|
||||
AuthorFilter string
|
||||
SeriesFilter string
|
||||
GenreFilter string
|
||||
PublisherFilter string
|
||||
TagsFilter string
|
||||
LanguageFilter string
|
||||
YearMin int
|
||||
@@ -73,6 +74,7 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
|
||||
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
|
||||
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
|
||||
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
|
||||
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
|
||||
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
|
||||
+115
-24
@@ -431,7 +431,9 @@ func firstTextDescendant(n *html.Node) *html.Node {
|
||||
// textNodeAtRuneOffset walks text nodes under elem in document order and
|
||||
// returns the node containing the rune offset plus the local offset within
|
||||
// that node. Offsets beyond the end clamp to the last node.
|
||||
func textNodeAtRuneOffset(elem *html.Node, offset int) (*html.Node, int) {
|
||||
// textNodeAtUTF16Offset resolves a crengine text().N offset — UTF-16 code
|
||||
// units — to (text node, rune offset) within elem's text.
|
||||
func textNodeAtUTF16Offset(elem *html.Node, offset int) (*html.Node, int) {
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
@@ -441,15 +443,15 @@ func textNodeAtRuneOffset(elem *html.Node, offset int) (*html.Node, int) {
|
||||
var walk func(*html.Node) bool
|
||||
walk = func(node *html.Node) bool {
|
||||
if node.Type == html.TextNode {
|
||||
length := utf8.RuneCountInString(node.Data)
|
||||
length := utf16Len(node.Data)
|
||||
if remaining < length {
|
||||
target = node
|
||||
local = remaining
|
||||
local = utf16ToRuneIndex(node.Data, remaining)
|
||||
return true
|
||||
}
|
||||
remaining -= length
|
||||
target = node
|
||||
local = length
|
||||
local = utf8.RuneCountInString(node.Data)
|
||||
return false
|
||||
}
|
||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||
@@ -507,7 +509,7 @@ func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer,
|
||||
var textNode *html.Node
|
||||
var localOffset int
|
||||
if xp.CharOffset > 0 {
|
||||
textNode, localOffset = textNodeAtRuneOffset(elem, xp.CharOffset)
|
||||
textNode, localOffset = textNodeAtUTF16Offset(elem, xp.CharOffset)
|
||||
} else {
|
||||
textNode = firstTextDescendant(elem)
|
||||
localOffset = 0
|
||||
@@ -1058,24 +1060,96 @@ func indexChildNodes(parent *html.Node) []indexedNode {
|
||||
return nodes
|
||||
}
|
||||
|
||||
func findTextChunkIndex(parent *html.Node, textNode *html.Node) (int, int) {
|
||||
// Character-offset currency policy: the EPUB CFI spec and crengine both
|
||||
// count UTF-16 code units (JavaScript `.length` semantics — what foliate,
|
||||
// readium, KOReader and Kobo clients all observe), so every offset that
|
||||
// CROSSES the wire — CFI terminals, CRE text() offsets, the served
|
||||
// char_offset handle — is UTF-16. Internal arithmetic (book-level
|
||||
// character_offset, percentage fractions) stays rune-based, consistent
|
||||
// with TotalCharacters. These helpers convert at the boundaries; for
|
||||
// all-BMP text the two currencies are identical, so ASCII books are
|
||||
// unaffected.
|
||||
|
||||
// utf16Len returns the UTF-16 code-unit length of s.
|
||||
func utf16Len(s string) int {
|
||||
n := 0
|
||||
for _, r := range s {
|
||||
if r >= 0x10000 {
|
||||
n += 2
|
||||
} else {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// utf16ToRuneIndex converts a UTF-16 code-unit offset within s to a rune
|
||||
// index (clamped to len(runes)).
|
||||
func utf16ToRuneIndex(s string, u16 int) int {
|
||||
if u16 <= 0 {
|
||||
return 0
|
||||
}
|
||||
units := 0
|
||||
i := 0
|
||||
for _, r := range s {
|
||||
if units >= u16 {
|
||||
return i
|
||||
}
|
||||
if r >= 0x10000 {
|
||||
units += 2
|
||||
} else {
|
||||
units++
|
||||
}
|
||||
i++
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// runeToUTF16Index converts a rune index within s to a UTF-16 code-unit
|
||||
// offset (clamped to the string's unit length).
|
||||
func runeToUTF16Index(s string, runeIdx int) int {
|
||||
if runeIdx <= 0 {
|
||||
return 0
|
||||
}
|
||||
units := 0
|
||||
i := 0
|
||||
for _, r := range s {
|
||||
if i >= runeIdx {
|
||||
return units
|
||||
}
|
||||
if r >= 0x10000 {
|
||||
units += 2
|
||||
} else {
|
||||
units++
|
||||
}
|
||||
i++
|
||||
}
|
||||
return units
|
||||
}
|
||||
|
||||
// findTextChunk locates the indexed text chunk containing textNode and
|
||||
// returns its chunk index, the rune offset of the node within the chunk,
|
||||
// and the chunk text up to and including the node (for UTF-16 conversion
|
||||
// of chunk-relative offsets).
|
||||
func findTextChunk(parent *html.Node, textNode *html.Node) (int, int, string) {
|
||||
indexed := indexChildNodes(parent)
|
||||
for i, node := range indexed {
|
||||
if node.isTextChunk() {
|
||||
for j, tn := range node.textChunk {
|
||||
if tn == textNode {
|
||||
var sb strings.Builder
|
||||
chunkOffset := 0
|
||||
for k := 0; k < j; k++ {
|
||||
chunkOffset += utf8.RuneCountInString(node.textChunk[k].Data)
|
||||
for _, tn := range node.textChunk {
|
||||
if tn == textNode {
|
||||
return i, chunkOffset, sb.String() + textNode.Data
|
||||
}
|
||||
return i, chunkOffset
|
||||
chunkOffset += utf8.RuneCountInString(tn.Data)
|
||||
sb.WriteString(tn.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1, 0
|
||||
return -1, 0, ""
|
||||
}
|
||||
|
||||
|
||||
func findElementCFIIndex(parent *html.Node, element *html.Node) int {
|
||||
indexed := indexChildNodes(parent)
|
||||
for i, node := range indexed {
|
||||
@@ -1110,15 +1184,17 @@ func buildCFI(spineIndex int, textNode *html.Node, charOffset int) (string, erro
|
||||
return "", fmt.Errorf("text node has no parent")
|
||||
}
|
||||
|
||||
chunkIdx, chunkOffset := findTextChunkIndex(parent, textNode)
|
||||
chunkIdx, chunkOffset, chunkText := findTextChunk(parent, textNode)
|
||||
if chunkIdx == -1 {
|
||||
return "", fmt.Errorf("text node not found in parent's indexed children")
|
||||
}
|
||||
|
||||
// The CFI terminal offset is UTF-16 code units (spec currency); the
|
||||
// internal charOffset is runes. Convert over the chunk text.
|
||||
totalOffset := chunkOffset + charOffset
|
||||
|
||||
var parts []string
|
||||
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, totalOffset))
|
||||
parts = append(parts, fmt.Sprintf("/%d:%d", chunkIdx, runeToUTF16Index(chunkText, totalOffset)))
|
||||
|
||||
current := parent
|
||||
for current != nil {
|
||||
@@ -1245,9 +1321,16 @@ func countTextChars(n *html.Node) int {
|
||||
return count
|
||||
}
|
||||
|
||||
// countTextCharsBefore counts the text characters preceding target within
|
||||
// its document: preceding siblings at each ancestor level, strictly
|
||||
// EXCLUDING target's own subtree (callers add the in-node offset
|
||||
// separately). Starting the walk at target itself — or recursing after
|
||||
// counting the current node's subtree — would re-count the accumulated
|
||||
// document once per ancestor level and multiply the result by the node's
|
||||
// depth.
|
||||
func countTextCharsBefore(target *html.Node) int {
|
||||
count := 0
|
||||
for c := target; c != nil; c = c.PrevSibling {
|
||||
for c := target.PrevSibling; c != nil; c = c.PrevSibling {
|
||||
count += countTextChars(c)
|
||||
}
|
||||
if target.Parent != nil {
|
||||
@@ -1519,24 +1602,31 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
|
||||
entry := indexed[lastStep.Index]
|
||||
|
||||
if entry.isTextChunk() {
|
||||
textOffset := 0
|
||||
// The CFI terminal offset arrives in UTF-16 code units (spec
|
||||
// currency — foliate/readium/KOReader/Kobo all emit UTF-16).
|
||||
// Walk the chunk in UTF-16 units, then convert the hit position
|
||||
// to the internal rune offset.
|
||||
u16Remaining := 0
|
||||
if lastStep.HasOffset {
|
||||
textOffset = lastStep.Offset
|
||||
u16Remaining = lastStep.Offset
|
||||
}
|
||||
|
||||
var targetNode *html.Node
|
||||
remainingOffset := textOffset
|
||||
runeIntoTarget := 0
|
||||
for _, tn := range entry.textChunk {
|
||||
textLen := utf8.RuneCountInString(tn.Data)
|
||||
if remainingOffset < textLen || (remainingOffset == textLen && targetNode == nil) {
|
||||
units := utf16Len(tn.Data)
|
||||
if u16Remaining < units || (u16Remaining == units && targetNode == nil) {
|
||||
targetNode = tn
|
||||
runeIntoTarget = utf16ToRuneIndex(tn.Data, u16Remaining)
|
||||
break
|
||||
}
|
||||
remainingOffset -= textLen
|
||||
u16Remaining -= units
|
||||
targetNode = tn
|
||||
runeIntoTarget = utf8.RuneCountInString(tn.Data)
|
||||
}
|
||||
if targetNode == nil && len(entry.textChunk) > 0 {
|
||||
targetNode = entry.textChunk[len(entry.textChunk)-1]
|
||||
runeIntoTarget = utf8.RuneCountInString(targetNode.Data)
|
||||
}
|
||||
|
||||
parent := targetNode.Parent
|
||||
@@ -1547,7 +1637,7 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
|
||||
}
|
||||
totalOffset += countTextChars(c)
|
||||
}
|
||||
totalOffset += remainingOffset
|
||||
totalOffset += runeIntoTarget
|
||||
|
||||
return targetNode, totalOffset, nil
|
||||
}
|
||||
@@ -1606,7 +1696,8 @@ func buildCREXPointer(spineIndex int, node *html.Node, charOffset int) (string,
|
||||
xpointer := fmt.Sprintf("/body/DocFragment[%d]/body%s", fragIndex, strings.Join(parts, ""))
|
||||
|
||||
if charOffset > 0 || (node.Type == html.TextNode) {
|
||||
xpointer += fmt.Sprintf("/text().%d", charOffset)
|
||||
// crengine counts UTF-16 code units; charOffset is internal runes.
|
||||
xpointer += fmt.Sprintf("/text().%d", runeToUTF16Index(node.Data, charOffset))
|
||||
}
|
||||
|
||||
return xpointer, nil
|
||||
|
||||
@@ -133,7 +133,7 @@ func writeTestEPUB(t *testing.T) string {
|
||||
}
|
||||
docs := []spineDoc{
|
||||
{"doc1.xhtml", "<body><div><p>Chapter one opening page.</p></div></body>"},
|
||||
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p></div></body>"},
|
||||
{"doc2.xhtml", "<body><div><p>The family of Dashwood had long been settled in Sussex.</p><p>Their estate was large, and their residence was at Norland Park.</p><p>The family crest shows a globe \U0001F30D and a rocket \U0001F680 flying onward.</p></div></body>"},
|
||||
{"doc3.xhtml", "<body><div><p>Chapter three contents.</p></div></body>"},
|
||||
{"doc4.xhtml", "<body><div><p>Chapter four contents.</p></div></body>"},
|
||||
{"doc5.xhtml", "<body><div><p>Chapter five contents.</p></div></body>"},
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
package sync
|
||||
|
||||
// Ingest-side position authority for reading progress.
|
||||
//
|
||||
// Clients submit (percentage, context_text, epubcfi). The epubcfi is a
|
||||
// standard wrapped CFI — epubcfi(/6/N!/…) — resolvable against the same
|
||||
// document this package parses, so every submission is INDEPENDENTLY
|
||||
// verified: the text at the resolved anchor is extracted and compared
|
||||
// with the submitted context. A mismatch (or an unresolvable anchor)
|
||||
// heals the position by text search, with the submitted percentage
|
||||
// disambiguating repeated phrases, instead of trusting a client-side
|
||||
// projection. This keeps buggy clients from poisoning stored positions:
|
||||
// a resolver that silently returns "wherever I'm currently scrolled"
|
||||
// fails the context check and gets healed to the true location.
|
||||
//
|
||||
// The anchor's block element is also derived as a cssSelector plus a
|
||||
// block-relative character offset and served back — the readium-native
|
||||
// handle clients scroll to, so they never have to parse or trust CFIs
|
||||
// themselves.
|
||||
//
|
||||
// Note: readium-based clients number their readingOrder excluding
|
||||
// linear="no" spine items, while this package's spine index follows the
|
||||
// OPF spine as written. The spine index is therefore internal-only;
|
||||
// client-facing responses carry the anchor document's href instead.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// IsConvertibleFormat reports whether a format group uses standard CFIs
|
||||
// as its structural locator currency (i.e. whether CFI verification and
|
||||
// cssSelector derivation apply to it).
|
||||
func IsConvertibleFormat(formatGroup string) bool {
|
||||
return isConvertible(string(formatGroup))
|
||||
}
|
||||
|
||||
func truncateRunes(s string, n int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= n {
|
||||
return s
|
||||
}
|
||||
return string(r[:n])
|
||||
}
|
||||
|
||||
// anchorBlock returns the nearest non-inline (block-level) ancestor of a
|
||||
// resolved text/element node — the server-side equivalent of the
|
||||
// readers' computed-style block walk.
|
||||
func anchorBlock(node *html.Node) *html.Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
if node.Type == html.ElementNode && !isInlineFormatting(node) {
|
||||
return node
|
||||
}
|
||||
return findBlockParent(node)
|
||||
}
|
||||
|
||||
// blockContextText extracts the normalized text from (node, runeOff) to
|
||||
// the end of the anchor block — the same excerpt rule the readers use for
|
||||
// context_text, ≤100 chars.
|
||||
func blockContextText(block, node *html.Node, runeOff int) string {
|
||||
segments := collectInlineText(block)
|
||||
var sb strings.Builder
|
||||
started := false
|
||||
for _, seg := range segments {
|
||||
if !started && seg.node == node {
|
||||
started = true
|
||||
runes := []rune(string(seg.runes))
|
||||
if runeOff < len(runes) {
|
||||
sb.WriteString(string(runes[runeOff:]))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if started {
|
||||
sb.WriteString(string(seg.runes))
|
||||
}
|
||||
}
|
||||
return truncateRunes(normalizeWhitespace(sb.String()), 100)
|
||||
}
|
||||
|
||||
// contextMatches reports whether a server-extracted context and a client-
|
||||
// submitted context describe the same anchor. Both are suffixes of the
|
||||
// same block text when the anchors share a block, so containment in
|
||||
// either direction verifies; empty or very short contexts never match.
|
||||
func contextMatches(serverCtx, submitted string) bool {
|
||||
s := truncateRunes(normalizeWhitespace(submitted), 100)
|
||||
t := truncateRunes(normalizeWhitespace(serverCtx), 100)
|
||||
if s == "" || t == "" {
|
||||
return false
|
||||
}
|
||||
short, long := s, t
|
||||
if len([]rune(short)) > len([]rune(long)) {
|
||||
short, long = long, short
|
||||
}
|
||||
if len([]rune(short)) < 12 {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(long, short)
|
||||
}
|
||||
|
||||
// cssSelectorFor mirrors the readers' selOf: a body-relative
|
||||
// tag:nth-child(k) chain (k = 1-based position among element siblings).
|
||||
func cssSelectorFor(block *html.Node) string {
|
||||
var segs []string
|
||||
n := block
|
||||
for n != nil && n.Type == html.ElementNode && n.Data != "body" {
|
||||
k := 1
|
||||
sib := n.Parent.FirstChild
|
||||
for sib != nil && sib != n {
|
||||
if sib.Type == html.ElementNode {
|
||||
k++
|
||||
}
|
||||
sib = sib.NextSibling
|
||||
}
|
||||
segs = append([]string{n.Data + ":nth-child(" + fmt.Sprintf("%d", k) + ")"}, segs...)
|
||||
n = n.Parent
|
||||
}
|
||||
return "body>" + strings.Join(segs, ">")
|
||||
}
|
||||
|
||||
// blockCharOffset computes the offset of (node, runeOff) within the
|
||||
// concatenated text of its block, in UTF-16 code units — the client-side
|
||||
// scroll-target currency (JavaScript .length semantics).
|
||||
func blockCharOffset(block, node *html.Node, runeOff int) int {
|
||||
segments := collectInlineText(block)
|
||||
var sb strings.Builder
|
||||
for _, seg := range segments {
|
||||
if seg.node == node {
|
||||
runes := seg.runes
|
||||
if runeOff < len(runes) {
|
||||
runes = runes[:runeOff]
|
||||
}
|
||||
sb.WriteString(string(runes))
|
||||
return runeToUTF16Index(sb.String(), utf8.RuneCountInString(sb.String()))
|
||||
}
|
||||
sb.WriteString(string(seg.runes))
|
||||
}
|
||||
return utf16Len(sb.String())
|
||||
}
|
||||
|
||||
// bookCharOffset computes the book-wide rune offset of (node, runeOff) —
|
||||
// the reading_progress.character_offset column's currency, consistent with
|
||||
// TotalCharacters and the percentage derivations.
|
||||
func bookCharOffset(conv *CFIConverter, spineIndex int, node *html.Node, runeOff int) int {
|
||||
spine, err := conv.loadSpine()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
before := 0
|
||||
for i := 0; i < spineIndex && i < len(spine.items); i++ {
|
||||
doc, _, derr := conv.getContentDoc(i + 1)
|
||||
if derr != nil {
|
||||
continue
|
||||
}
|
||||
if b := findBody(doc); b != nil {
|
||||
before += countTextChars(b)
|
||||
}
|
||||
}
|
||||
return before + countTextCharsBefore(node) + runeOff
|
||||
}
|
||||
|
||||
// ProgressAnchor is the full server-computed apply handle for a stored
|
||||
// standard CFI: the anchor block's cssSelector, the character offset
|
||||
// within that block's text, and the spine document's href.
|
||||
type ProgressAnchor struct {
|
||||
CSSSelector string
|
||||
CharOffset int
|
||||
Href string
|
||||
HealedCFI string
|
||||
Healed bool
|
||||
HealedPct *float64
|
||||
}
|
||||
|
||||
// VerifyProgressAnchor resolves a client-submitted standard CFI against
|
||||
// the EPUB, cross-checks the submitted context text, and heals the anchor
|
||||
// by text search on any mismatch. charOffset is the anchor's block-
|
||||
// relative UTF-16 offset (the served char_offset handle); bookOffset is
|
||||
// the anchor's book-wide rune offset (the character_offset column's
|
||||
// currency) — callers refresh the column from it on every verified save
|
||||
// so it never goes stale behind the anchor.
|
||||
func VerifyProgressAnchor(epubPath, epubcfi, contextText string, percentage float64) (finalCFI string, cssSelector string, anchorHref string, charOffset *int, bookOffset *int, healedPct *float64, healed bool, err error) {
|
||||
finalCFI = epubcfi
|
||||
anchorHref = ""
|
||||
|
||||
spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
|
||||
if err != nil {
|
||||
cfi, sel, href, off, book, pct, healedFlag, herr := healFromContext(epubPath, contextText, percentage)
|
||||
return cfi, sel, href, off, book, pct, healedFlag, herr
|
||||
}
|
||||
conv := cachedConverter(epubPath)
|
||||
doc, docHref, err := conv.getContentDoc(spineIndex + 1)
|
||||
if err != nil {
|
||||
return healFromContext(epubPath, contextText, percentage)
|
||||
}
|
||||
node, runeOff, rerr := resolveCFIToNode(doc, localSteps)
|
||||
if rerr != nil {
|
||||
return healFromContext(epubPath, contextText, percentage)
|
||||
}
|
||||
anchorHref = docHref
|
||||
|
||||
block := anchorBlock(node)
|
||||
serverCtx := blockContextText(block, node, runeOff)
|
||||
if contextMatches(serverCtx, contextText) {
|
||||
off := blockCharOffset(block, node, runeOff)
|
||||
book := bookCharOffset(conv, spineIndex, node, runeOff)
|
||||
return finalCFI, cssSelectorFor(block), anchorHref, &off, &book, nil, false, nil
|
||||
}
|
||||
|
||||
// Mismatch: heal by text search.
|
||||
hCFI, hPct, hSel, hHref, hBook, herr := healAnchorByText(epubPath, contextText, percentage, spineIndex)
|
||||
if herr != nil {
|
||||
return finalCFI, "", hHref, nil, nil, nil, false, fmt.Errorf("context mismatch (server %q vs client %q) and heal failed: %w",
|
||||
truncateRunes(serverCtx, 40), truncateRunes(contextText, 40), herr)
|
||||
}
|
||||
hOff := blockCharOffsetFor(epubPath, hCFI)
|
||||
return hCFI, hSel, hHref, &hOff, &hBook, &hPct, true, nil
|
||||
}
|
||||
|
||||
func healFromContext(epubPath, contextText string, percentage float64) (string, string, string, *int, *int, *float64, bool, error) {
|
||||
cfi, healedPct, sel, href, book, err := healAnchorByText(epubPath, contextText, percentage, -1)
|
||||
if err != nil {
|
||||
return "", "", "", nil, nil, nil, false, err
|
||||
}
|
||||
off := blockCharOffsetFor(epubPath, cfi)
|
||||
return cfi, sel, href, &off, &book, &healedPct, true, nil
|
||||
}
|
||||
|
||||
func healAnchorByText(epubPath, contextText string, percentage float64, spineIndex int) (string, float64, string, string, int, error) {
|
||||
if epubPath == "" {
|
||||
return "", 0, "", "", 0, fmt.Errorf("no epub available for text anchoring")
|
||||
}
|
||||
conv := cachedConverter(epubPath)
|
||||
spine, err := conv.loadSpine()
|
||||
if err != nil {
|
||||
return "", 0, "", "", 0, err
|
||||
}
|
||||
needle := truncateRunes(normalizeWhitespace(contextText), 40)
|
||||
if len([]rune(needle)) < 12 {
|
||||
return "", 0, "", "", 0, fmt.Errorf("context too short to anchor")
|
||||
}
|
||||
|
||||
type match struct {
|
||||
spine int
|
||||
node *html.Node
|
||||
off int
|
||||
}
|
||||
var matches []match
|
||||
totalChars := 0
|
||||
charsBefore := make([]int, len(spine.items))
|
||||
for i := range spine.items {
|
||||
doc, _, derr := conv.getContentDoc(i + 1)
|
||||
if derr != nil {
|
||||
continue
|
||||
}
|
||||
body := findBody(doc)
|
||||
if body == nil {
|
||||
continue
|
||||
}
|
||||
charsBefore[i] = totalChars
|
||||
totalChars += countTextChars(body)
|
||||
if n, runeOff := findTextInNode(body, needle); n != nil {
|
||||
matches = append(matches, match{spine: i, node: n, off: runeOff})
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 || totalChars <= 0 {
|
||||
return "", 0, "", "", 0, fmt.Errorf("context not found in book")
|
||||
}
|
||||
|
||||
best := matches[0]
|
||||
bestDist := -1.0
|
||||
for _, m := range matches {
|
||||
frac := (float64(charsBefore[m.spine]) + float64(countTextCharsBefore(m.node)+m.off)) / float64(totalChars)
|
||||
d := frac - percentage
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if bestDist < 0 || d < bestDist {
|
||||
best = m
|
||||
bestDist = d
|
||||
}
|
||||
}
|
||||
|
||||
bookOff := charsBefore[best.spine] + countTextCharsBefore(best.node) + best.off
|
||||
healedPct := float64(bookOff) / float64(totalChars)
|
||||
cfi, err := buildCFI(best.spine, best.node, best.off)
|
||||
if err != nil {
|
||||
return "", 0, "", "", 0, err
|
||||
}
|
||||
sel := ""
|
||||
if block := anchorBlock(best.node); block != nil {
|
||||
sel = cssSelectorFor(block)
|
||||
}
|
||||
return cfi, healedPct, sel, spine.items[best.spine].href, bookOff, nil
|
||||
}
|
||||
|
||||
func blockCharOffsetFor(epubPath, cfi string) int {
|
||||
n, off, _, herr := cfiTextAtAnchorInternal(epubPath, cfi)
|
||||
if herr != nil {
|
||||
return 0
|
||||
}
|
||||
block := anchorBlock(n)
|
||||
if block == nil {
|
||||
return 0
|
||||
}
|
||||
return blockCharOffset(block, n, off)
|
||||
}
|
||||
|
||||
// cfiTextAtAnchorInternal resolves a stored standard CFI to its anchor
|
||||
// text node, rune offset, and containing document.
|
||||
func cfiTextAtAnchorInternal(epubPath, cfi string) (*html.Node, int, *html.Node, error) {
|
||||
spineIndex, localSteps, err := parseEPUBCFI(cfi)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
conv := cachedConverter(epubPath)
|
||||
doc, _, err := conv.getContentDoc(spineIndex + 1)
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
node, off, err := resolveCFIToNode(doc, localSteps)
|
||||
return node, off, doc, err
|
||||
}
|
||||
|
||||
// ProgressCSSSelector derives the anchor block's cssSelector from a stored
|
||||
// standard CFI.
|
||||
func ProgressCSSSelector(epubPath, epubcfi string) (string, error) {
|
||||
spineIndex, localSteps, err := parseEPUBCFI(epubcfi)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conv := cachedConverter(epubPath)
|
||||
doc, _, err := conv.getContentDoc(spineIndex + 1)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
node, _, err := resolveCFIToNode(doc, localSteps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block := anchorBlock(node)
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("no block ancestor for anchor")
|
||||
}
|
||||
return cssSelectorFor(block), nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManualRealBookAnchor(t *testing.T) {
|
||||
path := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (1269)/1984 - George Orwell.epub"
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Skip("real book not present on this machine")
|
||||
}
|
||||
cfi := "epubcfi(/6/2!/4[x1984]/8[_idContainer003]/62/1:456)"
|
||||
ctx := "was at war with one of these Powers it was generally at peace with the other"
|
||||
finalCFI, sel, href, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, cfi, ctx, 0.0415)
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyProgressAnchor error: %v", err)
|
||||
}
|
||||
t.Logf("final=%q\n healed=%v healedPct=%v\n selector=%q href=%q charOff=%v", finalCFI, healed, healedPct, sel, href, charOff)
|
||||
sel2, err2 := ProgressCSSSelector(path, cfi)
|
||||
t.Logf("ProgressCSSSelector: %q err=%v", sel2, err2)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The fixture (writeTestEPUB in cfi_converter_test.go): 6 spine docs.
|
||||
// doc2 = spine index 1 with two paragraphs:
|
||||
// p1: "The family of Dashwood had long been settled in Sussex."
|
||||
// p2: "Their estate was large, and their residence was at Norland Park."
|
||||
// Hand-derived local paths (html→body /4, body→div /4, div→p /4|/6,
|
||||
// p→text chunk /1):
|
||||
const (
|
||||
dashwoodCFI = "epubcfi(/6/4!/4/2/2/1:0)"
|
||||
dashwoodText = "The family of Dashwood had long been settled in Sussex."
|
||||
estateCFI = "epubcfi(/6/4!/4/2/4/1:0)"
|
||||
estateText = "Their estate was large, and their residence was at Norland Park."
|
||||
dashwoodSelect = "body>div:nth-child(1)>p:nth-child(1)"
|
||||
estateSelect = "body>div:nth-child(1)>p:nth-child(2)"
|
||||
)
|
||||
|
||||
func TestVerifyProgressAnchorAcceptsExact(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
finalCFI, sel, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, dashwoodText, 0.3)
|
||||
_ = charOff
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if healed {
|
||||
t.Errorf("exact anchor should not heal")
|
||||
}
|
||||
if healedPct != nil {
|
||||
t.Errorf("exact anchor should not carry a healed percentage")
|
||||
}
|
||||
if finalCFI != dashwoodCFI {
|
||||
t.Errorf("finalCFI = %q, want unchanged %q", finalCFI, dashwoodCFI)
|
||||
}
|
||||
if sel != dashwoodSelect {
|
||||
t.Errorf("cssSelector = %q, want %q", sel, dashwoodSelect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProgressAnchorHealsMismatch(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
// Anchored at p1 but the context is p2's text: the classic
|
||||
// client-projection bug — the server must heal to the true location.
|
||||
finalCFI, _, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, estateText, 0.3)
|
||||
_ = charOff
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if !healed {
|
||||
t.Fatalf("expected healing, got none (cfi=%q)", finalCFI)
|
||||
}
|
||||
if finalCFI != estateCFI {
|
||||
t.Errorf("healed CFI = %q, want %q", finalCFI, estateCFI)
|
||||
}
|
||||
if healedPct == nil || *healedPct <= 0 {
|
||||
t.Errorf("healed percentage not recomputed: %v", healedPct)
|
||||
}
|
||||
|
||||
// Healing must converge: re-verifying the healed anchor is a no-op.
|
||||
finalCFI2, _, _, _, _, healedPct2, healed2, err := VerifyProgressAnchor(path, finalCFI, estateText, 0.3)
|
||||
if err != nil {
|
||||
t.Fatalf("re-verify error: %v", err)
|
||||
}
|
||||
if healed2 {
|
||||
t.Errorf("healed anchor should be stable, got healed again to %q (pct %v)", finalCFI2, healedPct2)
|
||||
}
|
||||
if finalCFI2 != finalCFI {
|
||||
t.Errorf("re-verify CFI = %q, want %q", finalCFI2, finalCFI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProgressAnchorHealsUnresolvable(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
// Element index 99 is out of range in doc2 — the anchor cannot resolve.
|
||||
bogus := "epubcfi(/6/4!/4/2/99/1:0)"
|
||||
finalCFI, _, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, bogus, dashwoodText, 0.05)
|
||||
_ = charOff
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if !healed {
|
||||
t.Fatal("unresolvable anchor should heal from context")
|
||||
}
|
||||
if finalCFI != dashwoodCFI {
|
||||
t.Errorf("healed CFI = %q, want the Dashwood anchor %q", finalCFI, dashwoodCFI)
|
||||
}
|
||||
if healedPct == nil {
|
||||
t.Errorf("healed percentage not recomputed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyProgressAnchorDumbClient(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
// No CFI at all — a client that only knows percentage + context is
|
||||
// fully supported: the server anchors structurally from the text.
|
||||
finalCFI, sel, _, charOff, _, healedPct, healed, err := VerifyProgressAnchor(path, "", estateText, 0.3)
|
||||
_ = charOff
|
||||
if err != nil {
|
||||
t.Fatalf("verify error: %v", err)
|
||||
}
|
||||
if !healed {
|
||||
t.Fatal("context-only submission should count as anchored-by-heal")
|
||||
}
|
||||
if finalCFI != estateCFI {
|
||||
t.Errorf("anchored CFI = %q, want %q", finalCFI, estateCFI)
|
||||
}
|
||||
if sel != estateSelect {
|
||||
t.Errorf("cssSelector = %q, want %q", sel, estateSelect)
|
||||
}
|
||||
if healedPct == nil {
|
||||
t.Errorf("percentage not recomputed for context-only anchor")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextMatches(t *testing.T) {
|
||||
block := "The family of Dashwood had long been settled in Sussex."
|
||||
cases := []struct {
|
||||
name string
|
||||
serverCtx string
|
||||
submitted string
|
||||
want bool
|
||||
}{
|
||||
{"exact", block, block, true},
|
||||
{"suffix of block", block, "settled in Sussex.", true},
|
||||
{"block is suffix", "settled in Sussex.", block, true},
|
||||
{"mid-block substring", block, "Dashwood had long been", true},
|
||||
{"different text", block, "completely unrelated words here", false},
|
||||
{"too short", block, "the", false},
|
||||
{"empty submitted", block, "", false},
|
||||
{"empty server", "", "some long enough context text", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := contextMatches(tc.serverCtx, tc.submitted); got != tc.want {
|
||||
t.Errorf("contextMatches(%q, %q) = %v, want %v", tc.serverCtx, tc.submitted, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStandardCFIRange(t *testing.T) {
|
||||
// Web progress CFIs are range CFIs; parse must resolve to the start arm.
|
||||
spineIndex, localSteps, err := parseEPUBCFI("epubcfi(/6/4!/4/4:0,/4/4:53)")
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if spineIndex != 1 {
|
||||
t.Errorf("spineIndex = %d, want 1", spineIndex)
|
||||
}
|
||||
if len(localSteps) != 4 {
|
||||
t.Fatalf("steps = %d, want 4 (parent + start arm)", len(localSteps))
|
||||
}
|
||||
last := localSteps[len(localSteps)-1]
|
||||
if last.Index != 4 || !last.HasOffset || last.Offset != 53 {
|
||||
t.Errorf("start-arm step = %+v, want /4:0", last)
|
||||
}
|
||||
}
|
||||
|
||||
// The emoji in fixture doc2's third paragraph are astral plane runes:
|
||||
// one rune, two UTF-16 code units. Every wire offset (CFI terminals,
|
||||
// the served char_offset) must therefore be UTF-16, while the book-wide
|
||||
// character_offset column stays rune-based.
|
||||
//
|
||||
// Case 1: the context starts at rune 31 of the paragraph text ("The
|
||||
// family crest shows a globe " = 31 BMP runes), so its UTF-16 offset is
|
||||
// also 31 — the currencies agree.
|
||||
//
|
||||
// Case 2: the context starts at rune 33 ("and a rocket ..."), with the
|
||||
// astral 🌍 (rune 31, units 31-32) BEFORE the offset — the UTF-16 offset
|
||||
// is 34, one more than the rune offset. That +1 is the whole point of
|
||||
// the boundary conversion.
|
||||
//
|
||||
// Local path: p3 of doc2's div = /4/2/6, text chunk /1.
|
||||
func TestAstralOffsetCurrency(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
cases := []struct {
|
||||
name string
|
||||
context string
|
||||
wantTerm int
|
||||
}{
|
||||
{"before any emoji", "🌍 and a rocket 🚀 flying onward.", 31},
|
||||
{"after one emoji", "and a rocket 🚀 flying onward.", 34},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
wantCFI := fmt.Sprintf("epubcfi(/6/4!/4/2/6/1:%d)", tc.wantTerm)
|
||||
|
||||
finalCFI, sel, _, charOff, bookOff, _, healed, err := VerifyProgressAnchor(path, "", tc.context, 0.3)
|
||||
if err != nil {
|
||||
t.Fatalf("heal error: %v", err)
|
||||
}
|
||||
if !healed {
|
||||
t.Fatalf("expected heal from context-only submission")
|
||||
}
|
||||
if finalCFI != wantCFI {
|
||||
t.Errorf("healed CFI = %q, want %q (UTF-16 terminal)", finalCFI, wantCFI)
|
||||
}
|
||||
if sel != "body>div:nth-child(1)>p:nth-child(3)" {
|
||||
t.Errorf("cssSelector = %q", sel)
|
||||
}
|
||||
if charOff == nil || *charOff != tc.wantTerm {
|
||||
t.Errorf("block char_offset = %v, want %d UTF-16 units", charOff, tc.wantTerm)
|
||||
}
|
||||
if bookOff == nil || *bookOff <= 0 {
|
||||
t.Errorf("book offset = %v, want a positive book-wide rune offset", bookOff)
|
||||
}
|
||||
|
||||
// Round trip: the healed CFI must verify exactly, with the
|
||||
// same UTF-16 block handle and a stable book offset.
|
||||
finalCFI2, _, _, charOff2, bookOff2, healedPct2, healed2, err := VerifyProgressAnchor(path, finalCFI, tc.context, 0.3)
|
||||
if err != nil {
|
||||
t.Fatalf("re-verify error: %v", err)
|
||||
}
|
||||
if healed2 || healedPct2 != nil {
|
||||
t.Errorf("exact round trip should not heal (healed=%v)", healed2)
|
||||
}
|
||||
if finalCFI2 != wantCFI {
|
||||
t.Errorf("re-verified CFI = %q, want %q", finalCFI2, wantCFI)
|
||||
}
|
||||
if charOff2 == nil || *charOff2 != tc.wantTerm {
|
||||
t.Errorf("re-verified block char_offset = %v, want %d", charOff2, tc.wantTerm)
|
||||
}
|
||||
if bookOff2 == nil || bookOff == nil || *bookOff2 != *bookOff {
|
||||
t.Errorf("book offset unstable: %v vs %v", bookOff2, bookOff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The book-wide offset must order anchors the way the book orders them:
|
||||
// a later paragraph in the same document has a strictly larger
|
||||
// character_offset.
|
||||
func TestBookOffsetOrdering(t *testing.T) {
|
||||
path := writeTestEPUB(t)
|
||||
_, _, _, _, book1, _, _, err1 := VerifyProgressAnchor(path, dashwoodCFI, dashwoodText, 0.3)
|
||||
_, _, _, _, book2, _, _, err2 := VerifyProgressAnchor(path, estateCFI, estateText, 0.3)
|
||||
if err1 != nil || err2 != nil {
|
||||
t.Fatalf("verify errors: %v %v", err1, err2)
|
||||
}
|
||||
if book1 == nil || book2 == nil {
|
||||
t.Fatalf("book offsets missing: %v %v", book1, book2)
|
||||
}
|
||||
if *book2 <= *book1 {
|
||||
t.Errorf("estate offset %d should exceed dashwood offset %d", *book2, *book1)
|
||||
}
|
||||
}
|
||||
@@ -30,9 +30,7 @@ var mimeTypes = map[string]string{
|
||||
".fb2": "application/x-fictionbook+xml",
|
||||
".txt": "text/plain",
|
||||
".rtf": "application/rtf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".lit": "application/x-ms-reader",
|
||||
".pdb": "application/vnd.palm",
|
||||
".prc": "application/vnd.palm",
|
||||
}
|
||||
@@ -46,9 +44,7 @@ var ReflowableFormats = map[string]bool{
|
||||
".fb2": true,
|
||||
".txt": true,
|
||||
".rtf": true,
|
||||
".doc": true,
|
||||
".docx": true,
|
||||
".lit": true,
|
||||
".pdb": true,
|
||||
".prc": true,
|
||||
}
|
||||
|
||||
@@ -274,8 +274,18 @@ func EstimatedPages(totalCharacters int64) int {
|
||||
type ProgressService struct {
|
||||
db *database.Queries
|
||||
connManager *ConnectionManager
|
||||
mediaPaths MediaPathResolver
|
||||
}
|
||||
|
||||
// MediaPathResolver resolves a media item's library-relative file path to
|
||||
// an absolute path, so the progress service can verify submitted anchors
|
||||
// against the book itself.
|
||||
type MediaPathResolver interface {
|
||||
ResolveMediaPath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error)
|
||||
}
|
||||
|
||||
func (s *ProgressService) SetMediaPathResolver(r MediaPathResolver) { s.mediaPaths = r }
|
||||
|
||||
func NewProgressService(db *database.Queries, connManager *ConnectionManager) *ProgressService {
|
||||
return &ProgressService{db: db, connManager: connManager}
|
||||
}
|
||||
@@ -305,6 +315,16 @@ type SaveProgressRequest struct {
|
||||
DeviceType string
|
||||
DeviceName string
|
||||
Broadcast bool
|
||||
|
||||
// ClearLocator declares that this write INTENDS to carry no position
|
||||
// locator: percentage-only writers (devices whose converter declined,
|
||||
// Kobo markup percentage syncs, queue entries without CFI). Without it,
|
||||
// seeding below would re-pair the new percentage with the PREVIOUS
|
||||
// writer's stale CFI/context — the incoherent row shape that feeds the
|
||||
// client-side far-anchor restore floor. Locater-less rows are honest:
|
||||
// percentage restores are approximate, and the next anchor-bearing
|
||||
// write re-establishes the pair. Request-supplied fields always win.
|
||||
ClearLocator bool
|
||||
}
|
||||
|
||||
func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequest) (database.ReadingProgress, error) {
|
||||
@@ -397,6 +417,22 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
params.ReadingMode = pgtype.Text{String: *req.ReadingMode, Valid: true}
|
||||
}
|
||||
|
||||
// Locator coherence: a write that declares no locator must not
|
||||
// silently re-pair its new percentage with the previous writer's
|
||||
// stale CFI/context (inherited by the seeding above). Clear them so
|
||||
// the row is an honest percentage-only position. Request-carried
|
||||
// fields always win — and a request-carried context survives so the
|
||||
// verify/heal pass below can mint a fresh anchor from it.
|
||||
if req.ClearLocator && req.Epubcfi == nil {
|
||||
params.Epubcfi = pgtype.Text{}
|
||||
if req.ContextText == nil {
|
||||
params.ContextText = pgtype.Text{}
|
||||
}
|
||||
if req.CharacterOffset == nil {
|
||||
params.CharacterOffset = pgtype.Int8{}
|
||||
}
|
||||
}
|
||||
|
||||
params.LastSyncDevice = pgtype.Text{String: req.Source, Valid: true}
|
||||
params.LastSyncSource = pgtype.Text{String: req.Source, Valid: true}
|
||||
|
||||
@@ -435,6 +471,43 @@ func (s *ProgressService) SaveProgress(ctx context.Context, req SaveProgressRequ
|
||||
}
|
||||
}
|
||||
|
||||
// Canonical position authority: resolve the submitted CFI against the
|
||||
// book itself, cross-check the submitted context text, and heal the
|
||||
// anchor by text search on any mismatch. Clients submit what their
|
||||
// renderer can reliably observe (percentage + visible text); the
|
||||
// server owns the structural math and keeps buggy clients from
|
||||
// poisoning stored positions.
|
||||
if !isFixed && s.mediaPaths != nil && params.ContextText.Valid &&
|
||||
isConvertible(string(formatGroup)) && formatGroup != "" {
|
||||
if path, perr := s.mediaPaths.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); perr == nil && path != "" {
|
||||
submittedCFI := ""
|
||||
if params.Epubcfi.Valid {
|
||||
submittedCFI = params.Epubcfi.String
|
||||
}
|
||||
submittedPct := 0.0
|
||||
if params.Percentage.Valid {
|
||||
submittedPct = params.Percentage.Float64
|
||||
}
|
||||
if finalCFI, _, _, _, bookOff, healedPct, healed, verr := VerifyProgressAnchor(path, submittedCFI, params.ContextText.String, submittedPct); verr != nil {
|
||||
log.Printf("Bookhoard: progress anchor verify failed for %s: %v", req.MediaItemID.String(), verr)
|
||||
} else {
|
||||
if healed || (!params.Epubcfi.Valid && finalCFI != "") {
|
||||
params.Epubcfi = pgtype.Text{String: finalCFI, Valid: true}
|
||||
if healedPct != nil {
|
||||
params.Percentage = pgtype.Float8{Float64: *healedPct, Valid: true}
|
||||
}
|
||||
}
|
||||
// The verified book-wide offset refreshes the column on
|
||||
// EVERY verified save — not only heals — so it never goes
|
||||
// stale behind the anchor (and a block-relative offset is
|
||||
// never written into the book-level column).
|
||||
if bookOff != nil {
|
||||
params.CharacterOffset = pgtype.Int8{Int64: int64(*bookOff), Valid: true}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
conflictDetected := false
|
||||
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
|
||||
if existing.LastSyncTimestamp.Valid {
|
||||
|
||||
@@ -443,6 +443,10 @@ func (p *SyncQueueProcessor) syncProgress(ctx context.Context, userID pgtype.UUI
|
||||
if v, ok := syncData["epubcfi"].(string); ok {
|
||||
req.Epubcfi = &v
|
||||
}
|
||||
// Entries without a CFI are percentage-only by intent: declare it
|
||||
// so the service clears any inherited stale anchor instead of
|
||||
// re-pairing it with this percentage.
|
||||
req.ClearLocator = req.Epubcfi == nil
|
||||
if v, ok := syncData["context_text"].(string); ok {
|
||||
req.ContextText = &v
|
||||
}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// Per-field user-override tracking for media item metadata.
|
||||
//
|
||||
// metadata_overrides is a TEXT[] column on media_items holding the names of
|
||||
// columns the user has customized via the metadata editor. Library scans and
|
||||
// per-book rescans MUST preserve those columns (applyMetadataOverrides);
|
||||
// only the reset-to-scanned-defaults action clears the set.
|
||||
|
||||
const (
|
||||
OverrideTitle = "title"
|
||||
OverrideAuthor = "author"
|
||||
OverrideISBN = "isbn"
|
||||
OverrideDescription = "description"
|
||||
OverrideCoverImagePath = "cover_image_path"
|
||||
OverrideSeries = "series"
|
||||
OverrideSeriesNumber = "series_number"
|
||||
OverrideTags = "tags"
|
||||
OverrideAsin = "asin"
|
||||
OverrideDatePublished = "date_published"
|
||||
OverridePublisher = "publisher"
|
||||
OverrideContributors = "contributors"
|
||||
OverrideLanguage = "language"
|
||||
OverrideEdition = "edition"
|
||||
OverridePageCount = "page_count"
|
||||
OverrideGenre = "genre"
|
||||
OverrideCopyrightYear = "copyright_year"
|
||||
OverrideGoodreadsID = "goodreads_id"
|
||||
OverrideOpenlibraryID = "openlibrary_id"
|
||||
OverrideGoogleBooksID = "google_books_id"
|
||||
OverrideMangaType = "manga_type"
|
||||
OverrideReadingDirection = "reading_direction"
|
||||
OverrideSeriesCount = "series_count"
|
||||
OverrideVolume = "volume"
|
||||
OverrideImprint = "imprint"
|
||||
OverrideAgeRating = "age_rating"
|
||||
OverrideWebURL = "web_url"
|
||||
OverrideMetadataNotes = "metadata_notes"
|
||||
OverrideCommunityRating = "community_rating"
|
||||
OverrideStoryArc = "story_arc"
|
||||
OverrideIsBlackAndWhite = "is_black_and_white"
|
||||
OverrideAlternateInfo = "alternate_info"
|
||||
OverrideScanInformation = "scan_information"
|
||||
OverrideSummary = "summary"
|
||||
)
|
||||
|
||||
// hasOverride reports whether key is in the override set.
|
||||
func hasOverride(overrides []string, key string) bool {
|
||||
for _, k := range overrides {
|
||||
if k == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MergeOverrides unions existing and added, preserving order and dropping
|
||||
// duplicates. Returns a non-nil slice so it always satisfies NOT NULL columns.
|
||||
func MergeOverrides(existing []string, added ...string) []string {
|
||||
seen := make(map[string]bool, len(existing)+len(added))
|
||||
merged := make([]string, 0, len(existing)+len(added))
|
||||
for _, k := range existing {
|
||||
if k != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
merged = append(merged, k)
|
||||
}
|
||||
}
|
||||
for _, k := range added {
|
||||
if k != "" && !seen[k] {
|
||||
seen[k] = true
|
||||
merged = append(merged, k)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Normalizers turn nullable column values into comparable strings so that
|
||||
// "unset" (invalid/zero) forms compare equal regardless of which side they
|
||||
// come from.
|
||||
|
||||
func normText(t pgtype.Text) string {
|
||||
if !t.Valid {
|
||||
return ""
|
||||
}
|
||||
return t.String
|
||||
}
|
||||
|
||||
func normInt(i pgtype.Int4) string {
|
||||
if !i.Valid {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(int64(i.Int32), 10)
|
||||
}
|
||||
|
||||
func normFloat(f pgtype.Float8) string {
|
||||
if !f.Valid {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(f.Float64, 'g', -1, 64)
|
||||
}
|
||||
|
||||
func normBool(b pgtype.Bool) string {
|
||||
if !b.Valid {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatBool(b.Bool)
|
||||
}
|
||||
|
||||
func normDate(d pgtype.Date) string {
|
||||
if !d.Valid {
|
||||
return ""
|
||||
}
|
||||
return d.Time.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func normStringSlice(s []string) string {
|
||||
if len(s) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(s, "\x1f")
|
||||
}
|
||||
|
||||
func normBytes(b []byte) string {
|
||||
if len(b) == 0 {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// detectMetadataOverridesDiff returns the keys whose value in params differs
|
||||
// from the existing row. Used by the metadata editor save path to grow the
|
||||
// override set with exactly the fields the user changed.
|
||||
func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
|
||||
var changed []string
|
||||
add := func(key string, differs bool) {
|
||||
if differs {
|
||||
changed = append(changed, key)
|
||||
}
|
||||
}
|
||||
|
||||
add(OverrideTitle, params.Title != existing.Title)
|
||||
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
|
||||
add(OverrideISBN, normText(params.Isbn) != normText(existing.Isbn))
|
||||
add(OverrideDescription, normText(params.Description) != normText(existing.Description))
|
||||
add(OverrideSeries, normText(params.Series) != normText(existing.Series))
|
||||
add(OverrideSeriesNumber, normInt(params.SeriesNumber) != normInt(existing.SeriesNumber))
|
||||
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
|
||||
add(OverrideAsin, normText(params.Asin) != normText(existing.Asin))
|
||||
add(OverrideDatePublished, normDate(params.DatePublished) != normDate(existing.DatePublished))
|
||||
add(OverridePublisher, normText(params.Publisher) != normText(existing.Publisher))
|
||||
add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
|
||||
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
|
||||
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
|
||||
add(OverridePageCount, normInt(params.PageCount) != normInt(existing.PageCount))
|
||||
add(OverrideGenre, normText(params.Genre) != normText(existing.Genre))
|
||||
add(OverrideCopyrightYear, normInt(params.CopyrightYear) != normInt(existing.CopyrightYear))
|
||||
add(OverrideGoodreadsID, normText(params.GoodreadsID) != normText(existing.GoodreadsID))
|
||||
add(OverrideOpenlibraryID, normText(params.OpenlibraryID) != normText(existing.OpenlibraryID))
|
||||
add(OverrideGoogleBooksID, normText(params.GoogleBooksID) != normText(existing.GoogleBooksID))
|
||||
add(OverrideMangaType, normText(params.MangaType) != normText(existing.MangaType))
|
||||
add(OverrideReadingDirection, normText(params.ReadingDirection) != normText(existing.ReadingDirection))
|
||||
add(OverrideSeriesCount, normInt(params.SeriesCount) != normInt(existing.SeriesCount))
|
||||
add(OverrideVolume, normInt(params.Volume) != normInt(existing.Volume))
|
||||
add(OverrideImprint, normText(params.Imprint) != normText(existing.Imprint))
|
||||
add(OverrideAgeRating, normText(params.AgeRating) != normText(existing.AgeRating))
|
||||
add(OverrideWebURL, normText(params.WebUrl) != normText(existing.WebUrl))
|
||||
add(OverrideMetadataNotes, normText(params.MetadataNotes) != normText(existing.MetadataNotes))
|
||||
add(OverrideCommunityRating, normFloat(params.CommunityRating) != normFloat(existing.CommunityRating))
|
||||
add(OverrideStoryArc, normText(params.StoryArc) != normText(existing.StoryArc))
|
||||
add(OverrideIsBlackAndWhite, normBool(params.IsBlackAndWhite) != normBool(existing.IsBlackAndWhite))
|
||||
add(OverrideAlternateInfo, normBytes(params.AlternateInfo) != normBytes(existing.AlternateInfo))
|
||||
add(OverrideScanInformation, normText(params.ScanInformation) != normText(existing.ScanInformation))
|
||||
add(OverrideSummary, normText(params.Summary) != normText(existing.Summary))
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// DetectMetadataOverrides returns the union of the existing override set and
|
||||
// any fields whose incoming (user-submitted) values differ from the stored
|
||||
// row. Overrides accumulate: a field stays protected until an explicit reset,
|
||||
// even if a later save reverts the value.
|
||||
func DetectMetadataOverrides(params database.UpdateMediaItemParams, existing database.MediaItems) []string {
|
||||
return MergeOverrides(existing.MetadataOverrides, detectMetadataOverridesDiff(params, existing)...)
|
||||
}
|
||||
|
||||
// ApplyMetadataOverrides restores every overridden field in params from the
|
||||
// existing row so scanner updates cannot clobber user customizations. Derived
|
||||
// search columns are restored together with their base column.
|
||||
func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing database.MediaItems) {
|
||||
o := existing.MetadataOverrides
|
||||
|
||||
if hasOverride(o, OverrideTitle) {
|
||||
params.Title = existing.Title
|
||||
}
|
||||
if hasOverride(o, OverrideAuthor) {
|
||||
params.Author = existing.Author
|
||||
}
|
||||
if hasOverride(o, OverrideISBN) {
|
||||
params.Isbn = existing.Isbn
|
||||
}
|
||||
if hasOverride(o, OverrideDescription) {
|
||||
params.Description = existing.Description
|
||||
}
|
||||
if hasOverride(o, OverrideCoverImagePath) {
|
||||
params.CoverImagePath = existing.CoverImagePath
|
||||
}
|
||||
if hasOverride(o, OverrideSeries) {
|
||||
params.Series = existing.Series
|
||||
}
|
||||
if hasOverride(o, OverrideSeriesNumber) {
|
||||
params.SeriesNumber = existing.SeriesNumber
|
||||
}
|
||||
if hasOverride(o, OverrideTags) {
|
||||
params.Tags = existing.Tags
|
||||
params.TagsSearch = existing.TagsSearch
|
||||
}
|
||||
if hasOverride(o, OverrideAsin) {
|
||||
params.Asin = existing.Asin
|
||||
}
|
||||
if hasOverride(o, OverrideDatePublished) {
|
||||
params.DatePublished = existing.DatePublished
|
||||
}
|
||||
if hasOverride(o, OverridePublisher) {
|
||||
params.Publisher = existing.Publisher
|
||||
}
|
||||
if hasOverride(o, OverrideContributors) {
|
||||
params.Contributors = existing.Contributors
|
||||
params.ContributorsSearch = existing.ContributorsSearch
|
||||
}
|
||||
if hasOverride(o, OverrideLanguage) {
|
||||
params.Language = existing.Language
|
||||
}
|
||||
if hasOverride(o, OverrideEdition) {
|
||||
params.Edition = existing.Edition
|
||||
}
|
||||
if hasOverride(o, OverridePageCount) {
|
||||
params.PageCount = existing.PageCount
|
||||
}
|
||||
if hasOverride(o, OverrideGenre) {
|
||||
params.Genre = existing.Genre
|
||||
}
|
||||
if hasOverride(o, OverrideCopyrightYear) {
|
||||
params.CopyrightYear = existing.CopyrightYear
|
||||
}
|
||||
if hasOverride(o, OverrideGoodreadsID) {
|
||||
params.GoodreadsID = existing.GoodreadsID
|
||||
}
|
||||
if hasOverride(o, OverrideOpenlibraryID) {
|
||||
params.OpenlibraryID = existing.OpenlibraryID
|
||||
}
|
||||
if hasOverride(o, OverrideGoogleBooksID) {
|
||||
params.GoogleBooksID = existing.GoogleBooksID
|
||||
}
|
||||
if hasOverride(o, OverrideMangaType) {
|
||||
params.MangaType = existing.MangaType
|
||||
}
|
||||
if hasOverride(o, OverrideReadingDirection) {
|
||||
params.ReadingDirection = existing.ReadingDirection
|
||||
}
|
||||
if hasOverride(o, OverrideSeriesCount) {
|
||||
params.SeriesCount = existing.SeriesCount
|
||||
}
|
||||
if hasOverride(o, OverrideVolume) {
|
||||
params.Volume = existing.Volume
|
||||
}
|
||||
if hasOverride(o, OverrideImprint) {
|
||||
params.Imprint = existing.Imprint
|
||||
}
|
||||
if hasOverride(o, OverrideAgeRating) {
|
||||
params.AgeRating = existing.AgeRating
|
||||
}
|
||||
if hasOverride(o, OverrideWebURL) {
|
||||
params.WebUrl = existing.WebUrl
|
||||
}
|
||||
if hasOverride(o, OverrideMetadataNotes) {
|
||||
params.MetadataNotes = existing.MetadataNotes
|
||||
}
|
||||
if hasOverride(o, OverrideCommunityRating) {
|
||||
params.CommunityRating = existing.CommunityRating
|
||||
}
|
||||
if hasOverride(o, OverrideStoryArc) {
|
||||
params.StoryArc = existing.StoryArc
|
||||
}
|
||||
if hasOverride(o, OverrideIsBlackAndWhite) {
|
||||
params.IsBlackAndWhite = existing.IsBlackAndWhite
|
||||
}
|
||||
if hasOverride(o, OverrideAlternateInfo) {
|
||||
params.AlternateInfo = existing.AlternateInfo
|
||||
}
|
||||
if hasOverride(o, OverrideScanInformation) {
|
||||
params.ScanInformation = existing.ScanInformation
|
||||
}
|
||||
if hasOverride(o, OverrideSummary) {
|
||||
params.Summary = existing.Summary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func TestMergeOverrides(t *testing.T) {
|
||||
got := MergeOverrides([]string{"title", "tags"}, "tags", "description", "")
|
||||
want := []string{"title", "tags", "description"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("MergeOverrides() = %v, want %v", got, want)
|
||||
}
|
||||
if MergeOverrides(nil) == nil {
|
||||
t.Error("MergeOverrides(nil) must return non-nil slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMetadataOverrides(t *testing.T) {
|
||||
existing := database.MediaItems{
|
||||
Title: "Scanned Title",
|
||||
Description: pgtype.Text{String: "Scanned description", Valid: true},
|
||||
Tags: []string{"foo", "bar"},
|
||||
}
|
||||
params := database.UpdateMediaItemParams{
|
||||
// unchanged
|
||||
Title: "Scanned Title",
|
||||
// changed
|
||||
Description: pgtype.Text{String: "My custom description", Valid: true},
|
||||
Tags: []string{"foo", "bar"},
|
||||
}
|
||||
|
||||
got := DetectMetadataOverrides(params, existing)
|
||||
if !reflect.DeepEqual(got, []string{"description"}) {
|
||||
t.Errorf("DetectMetadataOverrides() = %v, want [description]", got)
|
||||
}
|
||||
|
||||
// Overrides accumulate: an existing override survives a later save.
|
||||
existing.MetadataOverrides = []string{"publisher"}
|
||||
got = DetectMetadataOverrides(params, existing)
|
||||
want := []string{"publisher", "description"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("DetectMetadataOverrides() accumulate = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// An untouched save detects nothing new.
|
||||
noop := database.UpdateMediaItemParams{
|
||||
Title: existing.Title,
|
||||
Description: existing.Description,
|
||||
Tags: existing.Tags,
|
||||
}
|
||||
got = DetectMetadataOverrides(noop, existing)
|
||||
if !reflect.DeepEqual(got, []string{"publisher"}) {
|
||||
t.Errorf("DetectMetadataOverrides() noop = %v, want [publisher]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectMetadataOverridesUnsetFormsEqual(t *testing.T) {
|
||||
// Zero-value params must not look "changed" against NULL-ish columns.
|
||||
existing := database.MediaItems{
|
||||
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
||||
SeriesNumber: pgtype.Int4{Int32: 5, Valid: true},
|
||||
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
||||
}
|
||||
params := database.UpdateMediaItemParams{
|
||||
PageCount: pgtype.Int4{Int32: 0, Valid: false},
|
||||
SeriesNumber: pgtype.Int4{Int32: 0, Valid: false}, // cleared by user -> changed
|
||||
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
||||
}
|
||||
got := DetectMetadataOverrides(params, existing)
|
||||
if !reflect.DeepEqual(got, []string{"series_number"}) {
|
||||
t.Errorf("DetectMetadataOverrides() = %v, want [series_number]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMetadataOverrides(t *testing.T) {
|
||||
existing := database.MediaItems{
|
||||
Title: "Custom Title",
|
||||
Description: pgtype.Text{String: "Custom desc", Valid: true},
|
||||
Tags: []string{"mine"},
|
||||
TagsSearch: []string{"mine"},
|
||||
CoverImagePath: pgtype.Text{String: "books/x.epub.custom_cover.jpg", Valid: true},
|
||||
}
|
||||
params := database.UpdateMediaItemParams{
|
||||
Title: "Scanned Title",
|
||||
Description: pgtype.Text{String: "Scanned desc", Valid: true},
|
||||
Tags: []string{"scanned"},
|
||||
TagsSearch: []string{"scanned"},
|
||||
CoverImagePath: pgtype.Text{String: "books/x.epub.cover.jpg", Valid: true},
|
||||
}
|
||||
|
||||
// Only title and tags overridden; scanned description and cover win.
|
||||
existing.MetadataOverrides = []string{"title", "tags"}
|
||||
ApplyMetadataOverrides(¶ms, existing)
|
||||
|
||||
if params.Title != "Custom Title" {
|
||||
t.Errorf("Title = %q, want %q", params.Title, "Custom Title")
|
||||
}
|
||||
if !reflect.DeepEqual(params.Tags, []string{"mine"}) || !reflect.DeepEqual(params.TagsSearch, []string{"mine"}) {
|
||||
t.Error("Tags/TagsSearch must be restored together")
|
||||
}
|
||||
if params.Description.String != "Scanned desc" {
|
||||
t.Errorf("Description = %q, want scanned value", params.Description.String)
|
||||
}
|
||||
if params.CoverImagePath.String != "books/x.epub.cover.jpg" {
|
||||
t.Errorf("CoverImagePath = %q, want scanned value", params.CoverImagePath.String)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package version
|
||||
|
||||
// Version is the app version. It defaults to "dev" for local builds and is
|
||||
// overridden at build time in release images via:
|
||||
//
|
||||
// go build -ldflags "-X bookhoard/internal/version.Version=v1.5"
|
||||
//
|
||||
// The release workflow passes the git tag (e.g. v1.5) as the APP_VERSION
|
||||
// Docker build arg, which the Dockerfile forwards to this flag.
|
||||
var Version = "dev"
|
||||
+10
-2
@@ -12,16 +12,24 @@
|
||||
"dev": "npm run build:ts:dev && npm run build:css"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bookhoard/foliate-js": "git+https://github.com/john-okeefe/foliate-js.git#e448d36",
|
||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#b7597d6",
|
||||
"alpinejs": "^3.15.8",
|
||||
"chart.js": "^4.5.1",
|
||||
"dompurify": "^3.4.16",
|
||||
"fflate": "^0.8.3",
|
||||
"highlight.js": "^11.11.1",
|
||||
"htmx.org": "^2.0.8",
|
||||
"lunr": "^2.3.9"
|
||||
"lunr": "^2.3.9",
|
||||
"markdown-it": "^15.0.2",
|
||||
"markdown-it-footnote": "^4.0.0",
|
||||
"turndown": "^7.2.4",
|
||||
"turndown-plugin-gfm": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package templates
|
||||
|
||||
templ AdminArchived(user User, items []ArchivedItem, retentionDays int) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<title>Archived Items - Bookhoard</title>
|
||||
<script src="/static/htmx.min.js"></script>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
</head>
|
||||
<body class="theme-{ user.Theme }">
|
||||
@Header(user, "/admin/archived")
|
||||
<main class="p-8">
|
||||
<div class="max-w-5xl">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<div class="flex items-center gap-3 mb-1">
|
||||
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
||||
@Icon("library", "h-5 w-5")
|
||||
</span>
|
||||
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Archived Items</h1>
|
||||
</div>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
Books hidden because their files are missing from disk. Reading history is
|
||||
kept in case a file returns; moves are detected automatically.
|
||||
if retentionDays > 0 {
|
||||
Archived items are permanently deleted after { retentionDays } days
|
||||
(ARCHIVE_RETENTION_DAYS).
|
||||
} else {
|
||||
Archived items are kept until manually purged (retention disabled).
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
if len(items) > 0 {
|
||||
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Purge All Archived
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
if len(items) == 0 {
|
||||
<div class="card p-8 text-center">
|
||||
<p style="color: var(--text-secondary)">Nothing is archived or missing. All clear.</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b text-left" style="border-color: var(--border); color: var(--text-secondary);">
|
||||
<th class="px-4 py-3 font-semibold">Book</th>
|
||||
<th class="px-4 py-3 font-semibold">Library</th>
|
||||
<th class="px-4 py-3 font-semibold">Status</th>
|
||||
<th class="px-4 py-3 font-semibold text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
for _, item := range items {
|
||||
<tr class="border-b" style="border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);">
|
||||
<td class="px-4 py-3">
|
||||
<p class="font-medium" style="color: var(--text-primary)">{ item.Title }</p>
|
||||
if item.Author != "" {
|
||||
<p style="color: var(--text-secondary)">{ item.Author }</p>
|
||||
}
|
||||
<p class="font-mono text-xs break-all" style="color: var(--text-secondary)">{ item.FilePath }</p>
|
||||
</td>
|
||||
<td class="px-4 py-3" style="color: var(--text-primary)">{ item.LibraryName }</td>
|
||||
<td class="px-4 py-3" style="color: var(--text-secondary)">{ item.Status }</td>
|
||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||
if item.MatchID != "" {
|
||||
<button
|
||||
type="button"
|
||||
data-merge-source={ item.ID }
|
||||
data-merge-target={ item.MatchID }
|
||||
class="btn btn-ghost text-xs"
|
||||
title={ "Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry" }
|
||||
>
|
||||
@Icon("copy", "h-3.5 w-3.5")
|
||||
Merge
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
data-unarchive={ item.ID }
|
||||
class="btn btn-ghost text-xs"
|
||||
title="Restore to libraries now; if the file is still gone the next scan hides it again"
|
||||
>
|
||||
@Icon("refresh", "h-3.5 w-3.5")
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-delete-archived={ item.ID }
|
||||
class="btn btn-ghost text-xs"
|
||||
title="Delete this item and its reading history permanently"
|
||||
>
|
||||
@Icon("trash", "h-3.5 w-3.5")
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// 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.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><meta charset=\"UTF-8\"><title>Archived Items - Bookhoard</title><script src=\"/static/htmx.min.js\"></script><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"></head><body class=\"theme-{ user.Theme }\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Header(user, "/admin/archived").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<main class=\"p-8\"><div class=\"max-w-5xl\"><div class=\"mb-8\"><div class=\"flex items-center justify-between gap-4 flex-wrap\"><div><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("library", "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, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Archived Items</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Books hidden because their files are missing from disk. Reading history is kept in case a file returns; moves are detected automatically. ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if retentionDays > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "Archived items are permanently deleted after ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 30, Col: 70}
|
||||
}
|
||||
_, 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, " days (ARCHIVE_RETENTION_DAYS).")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "Archived items are kept until manually purged (retention disabled).")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(items) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<button type=\"button\" onclick=\"purgeArchivedItems()\" class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "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, 9, "Purge All Archived</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<div class=\"card p-8 text-center\"><p style=\"color: var(--text-secondary)\">Nothing is archived or missing. All clear.</p></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<div class=\"card overflow-hidden\"><table class=\"w-full text-sm\"><thead><tr class=\"border-b text-left\" style=\"border-color: var(--border); color: var(--text-secondary);\"><th class=\"px-4 py-3 font-semibold\">Book</th><th class=\"px-4 py-3 font-semibold\">Library</th><th class=\"px-4 py-3 font-semibold\">Status</th><th class=\"px-4 py-3 font-semibold text-right\">Actions</th></tr></thead> <tbody>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, item := range items {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<tr class=\"border-b\" style=\"border-color: color-mix(in srgb, var(--text-primary) 5%, transparent);\"><td class=\"px-4 py-3\"><p class=\"font-medium\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 65, Col: 82}
|
||||
}
|
||||
_, 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, 14, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.Author != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<p style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 67, Col: 66}
|
||||
}
|
||||
_, 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, 16, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<p class=\"font-mono text-xs break-all\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 69, Col: 103}
|
||||
}
|
||||
_, 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, 18, "</p></td><td class=\"px-4 py-3\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 71, Col: 86}
|
||||
}
|
||||
_, 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, 19, "</td><td class=\"px-4 py-3\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 83}
|
||||
}
|
||||
_, 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, 20, "</td><td class=\"px-4 py-3 text-right whitespace-nowrap\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if item.MatchID != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<button type=\"button\" data-merge-source=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 77, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-merge-target=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 78, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" class=\"btn btn-ghost text-xs\" title=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("Merge this archived copy's reading data into the active copy \"" + item.MatchTitle + "\" (" + item.MatchLibraryName + ") and remove the archived entry")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 80, Col: 174}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("copy", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "Merge</button> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<button type=\"button\" data-unarchive=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 88, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" class=\"btn btn-ghost text-xs\" title=\"Restore to libraries now; if the file is still gone the next scan hides it again\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("refresh", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "Restore</button> <button type=\"button\" data-delete-archived=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 97, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" class=\"btn btn-ghost text-xs\" title=\"Delete this item and its reading history permanently\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("trash", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "Delete</button></td></tr>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</tbody></table></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div></main></body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,6 +1,6 @@
|
||||
package templates
|
||||
|
||||
templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -34,6 +34,28 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
if archivedCount > 0 {
|
||||
<div class="card p-4 mb-6 flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<p class="font-semibold" style="color: var(--text-primary)">
|
||||
<a href="/admin/archived" class="hover:underline" style="color: var(--text-primary); text-decoration: none;">
|
||||
Archived items: { archivedCount }
|
||||
</a>
|
||||
</p>
|
||||
<p class="text-sm" style="color: var(--text-secondary)">
|
||||
Files missing from disk for two consecutive scans. Reading history is kept
|
||||
unless purged; items return automatically if their files come back.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/admin/archived" class="btn btn-secondary">View Archived</a>
|
||||
<button type="button" onclick="purgeArchivedItems()" class="btn btn-secondary">
|
||||
@Icon("trash", "h-4 w-4")
|
||||
Purge Archived Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div id="libraries-container">
|
||||
@LibraryList(user, libraries, users)
|
||||
</div>
|
||||
@@ -129,10 +151,18 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
@Icon("close", "h-5 w-5")
|
||||
</button>
|
||||
</div>
|
||||
<p class="mb-6" style="color: var(--text-primary)">
|
||||
<p class="mb-3" style="color: var(--text-primary)">
|
||||
Are you sure you want to delete <strong id="delete-library-name"></strong>?
|
||||
This will remove the library and all its folder mappings. Media files will not be deleted.
|
||||
</p>
|
||||
<ul class="mb-6 space-y-2 text-sm list-disc list-inside" style="color: var(--text-primary)">
|
||||
<li>
|
||||
<strong>Permanently removed, no archive or undo:</strong>
|
||||
the library, its folder mappings, and all book records — with their reading progress, highlights, notes, bookmarks, and ratings.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Not touched:</strong> media files on disk.
|
||||
</li>
|
||||
</ul>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="document.getElementById('delete-library-modal').classList.add('hidden')" class="btn btn-secondary">Cancel</button>
|
||||
<button type="button" id="delete-library-confirm" class="btn btn-danger">
|
||||
@@ -179,6 +209,15 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
||||
htmx.process(content);
|
||||
document.getElementById('folder-browser-modal').classList.remove('hidden');
|
||||
}
|
||||
// Close the delete-library modal after a successful delete: the
|
||||
// htmx swap already removes the deleted row from the libraries
|
||||
// list, but the modal lives outside the swapped container and
|
||||
// would otherwise stay open.
|
||||
document.getElementById('delete-library-confirm').addEventListener('htmx:afterRequest', function(evt) {
|
||||
if (evt.detail.successful) {
|
||||
document.getElementById('delete-library-modal').classList.add('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+369
-338
File diff suppressed because it is too large.
Load diff
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user