Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6aa958c78f | ||
|
|
3514b4dc1c | ||
|
|
ae87c0cd6a | ||
|
|
b6f507b9e5 | ||
|
|
ce3ae31ced | ||
|
|
75c1d9bb95 | ||
|
|
905218dd4b | ||
|
|
94dad5e089 | ||
|
|
2366faccce | ||
|
|
e40530824e | ||
|
|
5a6c361c11 | ||
|
|
7b1c809ae3 |
@@ -20,6 +20,9 @@ logs/
|
|||||||
|
|
||||||
# Dependencies (install fresh in container)
|
# Dependencies (install fresh in container)
|
||||||
node_modules/
|
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
|
# Environment
|
||||||
.env
|
.env
|
||||||
|
|||||||
@@ -35,5 +35,9 @@ DBPASS=your-secure-database-password-here
|
|||||||
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
# BOOKHOARD_CONVERSION_TOOL=/usr/bin/ebook-convert
|
||||||
# Conversion Cache TTL: Override default 24h
|
# Conversion Cache TTL: Override default 24h
|
||||||
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
# BOOKHOARD_CONVERSION_CACHE_TTL=48h
|
||||||
|
# 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)
|
# System timezone (fallback for server-side time operations, defaults to UTC)
|
||||||
# TZ=America/New_York
|
# TZ=America/New_York
|
||||||
@@ -56,6 +56,9 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
push: true
|
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.
|
# 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;
|
# Deployments default to "latest" via ${IMAGE_TAG:-latest} in docker-compose.yml;
|
||||||
# pin or roll back by setting IMAGE_TAG in .env.
|
# 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 files and install npm dependencies (cached unless package.json changes)
|
||||||
COPY package*.json ./
|
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
|
npm install
|
||||||
|
|
||||||
# Copy Go mod files (cached unless go.mod changes)
|
# 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:css:prod
|
||||||
RUN npm run build:ts
|
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)
|
# Build Go binary (cached unless Go files or generated code changes)
|
||||||
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
RUN --mount=type=cache,target=/root/go/pkg/mod \
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
--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
|
# Test runner stage - includes Go runtime and test dependencies
|
||||||
# This stage is ONLY used for running tests, never deployed to production
|
# This stage is ONLY used for running tests, never deployed to production
|
||||||
FROM golang:1.26-alpine AS test-runner
|
FROM golang:1.26-alpine AS test-runner
|
||||||
|
|
||||||
RUN apk --no-cache add ca-certificates curl
|
RUN apk --no-cache add ca-certificates curl poppler-utils
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy everything from builder (includes compiled binary, source code, and dependencies)
|
# Copy everything from builder (includes compiled binary, source code, and dependencies)
|
||||||
COPY --from=builder /app ./
|
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
|
# Install kepubify for conversion tests
|
||||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
||||||
&& chmod +x /usr/bin/kepubify
|
&& chmod +x /usr/bin/kepubify
|
||||||
@@ -65,7 +84,7 @@ CMD ["go", "test", "./cmd/server/tests", "-v", "-timeout", "5m", "-parallel=1",
|
|||||||
# Final stage
|
# Final stage
|
||||||
FROM alpine:latest
|
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
|
# Install kepubify for EPUB→KEPUB conversion
|
||||||
RUN wget -O /usr/bin/kepubify https://github.com/pgaskin/kepubify/releases/latest/download/kepubify-linux-64bit \
|
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
|
||||||
@@ -121,6 +121,9 @@ func main() {
|
|||||||
|
|
||||||
// Create library service
|
// Create library service
|
||||||
libraryService := services.NewLibraryService(queries)
|
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
|
// Sync Go AllowedExtensions into DB so API clients see correct extensions
|
||||||
libraryService.SyncAllowedExtensions(context.Background())
|
libraryService.SyncAllowedExtensions(context.Background())
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -252,6 +252,88 @@ func TestListPendingRegistrations(t *testing.T) {
|
|||||||
assert.NotNil(t, pending, "Pending registrations should not be nil")
|
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) {
|
func TestApproveDeviceRegistration(t *testing.T) {
|
||||||
setup := setupTestServer(t)
|
setup := setupTestServer(t)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -435,3 +436,115 @@ func TestOPDSSearchAcrossLibraries(t *testing.T) {
|
|||||||
t.Logf("❌ FAILED - OPDS returned %d", resp.StatusCode)
|
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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -544,6 +544,10 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
AnnotationService: annotationService,
|
AnnotationService: annotationService,
|
||||||
DeviceAuthMiddleware: deviceAuthMiddleware,
|
DeviceAuthMiddleware: deviceAuthMiddleware,
|
||||||
LoginTracker: loginAttemptTracker,
|
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),
|
||||||
}
|
}
|
||||||
|
|
||||||
router.RegisterRoutes(routerConfig)
|
router.RegisterRoutes(routerConfig)
|
||||||
@@ -588,9 +592,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"
|
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",
|
Email: "testuser@tests.bookhoard.internal",
|
||||||
Username: "testuser",
|
Username: "testuser",
|
||||||
PasswordHash: passwordHash,
|
PasswordHash: passwordHash,
|
||||||
@@ -598,11 +620,13 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
|||||||
LastName: pgtype.Text{String: "User", Valid: true},
|
LastName: pgtype.Text{String: "User", Valid: true},
|
||||||
Role: "admin",
|
Role: "admin",
|
||||||
})
|
})
|
||||||
require.NoError(t, err, "Failed to create admin test user")
|
require.NoError(t, cerr, "Failed to create admin test user")
|
||||||
|
adminUUID, err = uuid.FromBytes(adminUser.ID.Bytes[:])
|
||||||
adminUUID, err := uuid.FromBytes(adminUser.ID.Bytes[:])
|
|
||||||
require.NoError(t, err, "Failed to parse admin user UUID")
|
require.NoError(t, err, "Failed to parse admin user UUID")
|
||||||
|
// Default collections are created exactly once per user; the
|
||||||
|
// preserved dev admin already has its set from the first run.
|
||||||
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
createDefaultCollectionsForUser(t, queries, pgtype.UUID{Bytes: adminUUID, Valid: true})
|
||||||
|
}
|
||||||
|
|
||||||
// Create fresh regular test user
|
// Create fresh regular test user
|
||||||
regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
regularUser, err := queries.CreateUser(ctx, database.CreateUserParams{
|
||||||
|
|||||||
@@ -207,6 +207,19 @@ CREATE TABLE IF NOT EXISTS media_items (
|
|||||||
-- Summary (distinct from description - may merge with Calibre description)
|
-- Summary (distinct from description - may merge with Calibre description)
|
||||||
summary TEXT, -- Summary from ComicInfo.xml (may be merged with description from Calibre)
|
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
|
-- Chapter metadata for reader navigation and progress tracking
|
||||||
-- Caches detected chapter structure to avoid re-parsing files
|
-- Caches detected chapter structure to avoid re-parsing files
|
||||||
-- Populated by ReaderService.DetectChapters() on first read
|
-- Populated by ReaderService.DetectChapters() on first read
|
||||||
@@ -232,12 +245,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 tags_search TEXT[];
|
||||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_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 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_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);
|
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_insert ON media_items;
|
||||||
|
DROP TRIGGER IF EXISTS set_library_type_name_on_library_change ON media_items;
|
||||||
-- Drop existing function if exists
|
-- Drop existing function if exists
|
||||||
DROP FUNCTION IF EXISTS set_library_type_name();
|
DROP FUNCTION IF EXISTS set_library_type_name();
|
||||||
|
|
||||||
@@ -256,6 +282,14 @@ CREATE TRIGGER set_library_type_name_on_insert
|
|||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
EXECUTE FUNCTION set_library_type_name();
|
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
|
-- Add GIN indexes for pg_trgm fuzzy search performance
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
||||||
ON media_items USING GIN (author gin_trgm_ops);
|
ON media_items USING GIN (author gin_trgm_ops);
|
||||||
@@ -1344,8 +1378,10 @@ CREATE TABLE IF NOT EXISTS media_bookmarks (
|
|||||||
title VARCHAR(255) NOT NULL,
|
title VARCHAR(255) NOT NULL,
|
||||||
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
|
position VARCHAR(100), -- 'pdf:page:45', 'comic:page:12', 'chapter:3' for consistency
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
UNIQUE(media_item_id, user_id, title)
|
-- No UNIQUE(media_item_id, user_id, title): bookmarks are identified by
|
||||||
|
-- their location (dedup_key), titles are display labels shared verbatim
|
||||||
|
-- across clients (KOReader auto-labels repeat within a chapter).
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
CREATE INDEX IF NOT EXISTS idx_media_bookmarks_media ON media_bookmarks(media_item_id);
|
||||||
@@ -1386,6 +1422,14 @@ ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS epubcfi_location TEXT;
|
|||||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS chapter_reference INTEGER;
|
||||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted BOOLEAN DEFAULT FALSE;
|
||||||
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
-- Provenance: the client that CREATED the bookmark (unlike
|
||||||
|
-- last_modified_source, which tracks the last writer). Set once at insert.
|
||||||
|
ALTER TABLE media_bookmarks ADD COLUMN IF NOT EXISTS origin_source VARCHAR(30);
|
||||||
|
-- Identity is the dedup_key (location), not the title; drop the legacy
|
||||||
|
-- unique-title constraint so same-title bookmarks on different pages can
|
||||||
|
-- coexist (re-creating over a tombstone with a changed position also
|
||||||
|
-- relied on this). Catalog-only change, safe to re-run.
|
||||||
|
ALTER TABLE media_bookmarks DROP CONSTRAINT IF EXISTS media_bookmarks_media_item_id_user_id_title_key;
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_highlights_dedup
|
||||||
ON media_highlights (user_id, media_item_id, dedup_key)
|
ON media_highlights (user_id, media_item_id, dedup_key)
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ services:
|
|||||||
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
BOOKHOARD_CONVERSION_TOOL: ${BOOKHOARD_CONVERSION_TOOL:-/usr/bin/kepubify}
|
||||||
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
BOOKHOARD_CONVERSION_CACHE_TTL: ${BOOKHOARD_CONVERSION_CACHE_TTL:-24h}
|
||||||
|
|
||||||
|
# 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)
|
# System timezone (fallback for server-side time operations)
|
||||||
TZ: ${TZ:-UTC}
|
TZ: ${TZ:-UTC}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+40
-108
@@ -394,6 +394,11 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
## Reading Progress
|
## 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
|
### Get Reading Progress
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -405,57 +410,56 @@ Authorization: Bearer <token>
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"id": "uuid",
|
||||||
"media_item_id": "uuid",
|
"media_item_id": "uuid",
|
||||||
"user_id": "uuid",
|
"user_id": "uuid",
|
||||||
"current_page": 45,
|
"percentage": 0.045,
|
||||||
"total_pages": 200,
|
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/58/1:598)",
|
||||||
"percentage": 0.225,
|
"context_text": "from day to day, but there was none in which ...",
|
||||||
"character_offset": 15432,
|
"character_offset": 16375,
|
||||||
"epubcfi": "epubcfi(/6/4/2:15)",
|
"current_page": null,
|
||||||
"chapter": 3,
|
"total_pages": null,
|
||||||
"chapter_progress": 0.5,
|
"chapter": null,
|
||||||
"last_read_at": "2026-01-31T10:00:00Z",
|
"chapter_progress": null,
|
||||||
"format_group": "reflowable",
|
"format_group": "reflowable",
|
||||||
"viewport_y": 0.12,
|
"total_characters": 592216,
|
||||||
"zoom_level": 1.0
|
"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
|
### Update Reading Progress
|
||||||
|
|
||||||
```http
|
```http
|
||||||
PUT /api/media-items/{media_id}/progress
|
PUT /api/media-items/{media_id}/progress
|
||||||
Authorization: Bearer <token>
|
Authorization: Bearer <token>
|
||||||
Content-Type: application/json
|
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
|
```json
|
||||||
{
|
{
|
||||||
"sync_status": "success",
|
"percentage": 0.0415,
|
||||||
"progress_updated": true,
|
"context_text": "was at war with one of these Powers it was generally a",
|
||||||
"devices_notified": ["device-1", "device-2"],
|
"epubcfi": "epubcfi(/6/4!/4/8[_idContainer003]/62/1:456)"
|
||||||
"broadcast": true
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### Delete Reading Progress
|
||||||
|
|
||||||
```http
|
```http
|
||||||
@@ -1297,82 +1301,10 @@ Authorization: Bearer <device_token>
|
|||||||
|
|
||||||
## Universal Progress
|
## Universal Progress
|
||||||
|
|
||||||
### Get Universal Progress
|
Universal progress is the media-item progress — one row per (user,
|
||||||
|
media item) shared by every device. See [Reading Progress](#reading-progress)
|
||||||
```http
|
and the [Position Contract](api/progress/position-contract.md). There
|
||||||
GET /api/progress/{book_uuid}
|
are no separate `/api/progress/:id` GET/POST endpoints.
|
||||||
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": "..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Conflicts
|
## Conflicts
|
||||||
|
|
||||||
|
|||||||
@@ -117,10 +117,14 @@ See [Media Item Operations](media-items/)
|
|||||||
|
|
||||||
## Reading Progress
|
## 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
|
- GET /api/media-items/:id/progress - Get reading progress + restore handles
|
||||||
- POST /api/progress/:id - Update universal progress
|
- 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
|
- GET /api/progress/:id/history - Get progress history
|
||||||
|
|
||||||
## Notes & Highlights
|
## Notes & Highlights
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
# Get Metadata
|
# 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`
|
**Endpoint**: `GET /api/sync/koreader/metadata/:uuid`
|
||||||
**Auth**: Required (Device authentication)
|
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
|
||||||
|
|
||||||
## Path Parameters
|
## Path Parameters
|
||||||
|
|
||||||
@@ -11,41 +14,55 @@ Get metadata for a book from KOReader device.
|
|||||||
| --------- | ------------- | -------- | ----------- |
|
| --------- | ------------- | -------- | ----------- |
|
||||||
| uuid | string (UUID) | Yes | Book UUID |
|
| 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
|
### Example Request
|
||||||
|
|
||||||
```http
|
```http
|
||||||
GET /api/sync/koreader/metadata/550e8400-e29b-41d4-a716-446655440000
|
GET /api/sync/koreader/metadata/774641f9-317b-4087-8e04-53bb4392ae56
|
||||||
X-Device-ID: 550e8400-e29b-41d4-a716-446655440000
|
Authorization: Bearer {device_token}
|
||||||
X-Device-Key: device-auth-key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Response (200 OK)
|
## Response (200 OK)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||||
"title": "Book Title",
|
"title": "1984",
|
||||||
"authors": ["Author Name"],
|
"author": "George Orwell",
|
||||||
"path": "/path/to/book.epub",
|
"progress": {
|
||||||
"file_size": 1234567,
|
"percentage": 0.045,
|
||||||
"modified_at": "2026-02-08T10:00:00Z"
|
"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
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | ---------------------------- |
|
| ---- | ----------- |
|
||||||
|
| 400 | Invalid book UUID |
|
||||||
| 401 | Device authentication failed |
|
| 401 | Device authentication failed |
|
||||||
| 404 | Book or device not found |
|
| 404 | Book not found |
|
||||||
|
|||||||
@@ -1,63 +1,87 @@
|
|||||||
# Sync Progress
|
# Sync Progress
|
||||||
|
|
||||||
Sync reading progress from KOReader device.
|
Push reading progress from a KOReader device.
|
||||||
|
|
||||||
**Endpoint**: `POST /api/sync/koreader/progress`
|
**Endpoint**: `POST /api/sync/koreader/progress`
|
||||||
**Auth**: Required (Device authentication)
|
**Auth**: Required (Device authentication — `Authorization: Bearer {device_token}`)
|
||||||
|
|
||||||
## Device Authentication
|
This is the device-native tier of the [Position
|
||||||
|
Contract](../progress/position-contract.md): the KOReader payload carries
|
||||||
This endpoint requires device authentication (not user JWT). Devices authenticate using their device credentials.
|
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
|
## Request Body
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| --------- | ------------- | -------- | ------------------------- |
|
| --------- | ------ | -------- | ----------- |
|
||||||
| device_id | string (UUID) | Yes | Device UUID |
|
| `books` | array | Yes | One book object (the reference client sends a single-element array). |
|
||||||
| progress | array | Yes | Array of progress objects |
|
| `sync_mode`| string | No | `immediate` (default) or `manual`. |
|
||||||
|
|
||||||
### Progress Object
|
### Book Object
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
| ----------- | ------- | -------- | ---------------------------------- |
|
| ----- | ---- | -------- | ----------- |
|
||||||
| book | string | Yes | Book identifier (filename or UUID) |
|
| `uuid` | string (UUID) | No | Bookhoard UUID, once the device has linked the book via [Resolve Book](resolve_book.md). |
|
||||||
| percent | float | Yes | Progress percentage (0-100) |
|
| `sha256` | string | Yes | File content hash (64 hex chars) — the primary book identity. |
|
||||||
| page | integer | No | Current page number |
|
| `title` | string | No | Document title. |
|
||||||
| total_pages | integer | No | Total pages in document |
|
| `authors` | array | No | Author names. |
|
||||||
| date_read | string | No | ISO 8601 timestamp of last read |
|
| `percentage` | float | Yes | Position as a fraction of the book (0..1). |
|
||||||
| updated_at | string | Yes | ISO 8601 timestamp |
|
| `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
|
### Example Request
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/sync/koreader/progress
|
||||||
|
Authorization: Bearer {device_token}
|
||||||
|
Content-Type: application/json
|
||||||
|
```
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"device_id": "550e8400-e29b-41d4-a716-446655440000",
|
"books": [
|
||||||
"progress": [
|
|
||||||
{
|
{
|
||||||
"book": "book.epub",
|
"uuid": "774641f9-317b-4087-8e04-53bb4392ae56",
|
||||||
"percent": 75.5,
|
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||||
"page": 150,
|
"title": "1984",
|
||||||
"total_pages": 200,
|
"authors": ["George Orwell"],
|
||||||
"date_read": "2026-02-08T10:00:00Z",
|
"percentage": 0.045,
|
||||||
"updated_at": "2026-02-08T10:00:00Z"
|
"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
|
```json
|
||||||
{
|
{
|
||||||
"message": "Progress synced successfully",
|
"sync_status": "ok",
|
||||||
"synced_count": 1
|
"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
|
## Error Responses
|
||||||
|
|
||||||
| Code | Description |
|
| Code | Description |
|
||||||
| ---- | ---------------------------- |
|
| ---- | ----------- |
|
||||||
|
| 400 | Invalid request format |
|
||||||
| 401 | Device authentication failed |
|
| 401 | Device authentication failed |
|
||||||
| 400 | Invalid request data |
|
| 500 | Database error |
|
||||||
| 404 | Device not found |
|
|
||||||
|
|||||||
@@ -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 |
|
|
||||||
@@ -77,6 +77,7 @@ Complete guide to Bookhoard documentation. Find what you need quickly.
|
|||||||
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
| **Deploy Bookhoard** | [Operations Portal → Troubleshooting](operations/troubleshooting.md) |
|
||||||
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
| **Contribute code** | [Contributing Portal → Development Guide](contributing/development.md) |
|
||||||
| **Understand sync** | [User Portal → Sync Guide](user/sync-guide.md) |
|
| **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) |
|
| ...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 |
|
| ...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) |
|
| ...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 |
|
| ...resolve conflicts? | [Sync Guide](user/sync-guide.md) - Managing Conflicts |
|
||||||
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
| ...troubleshoot deployment? | [Troubleshooting Guide](operations/troubleshooting.md) |
|
||||||
| ...use the API? | [API Reference](developer/api-reference.md) |
|
| ...use the API? | [API Reference](developer/api-reference.md) |
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -36,6 +36,14 @@ Learn how to configure your e-reader devices to sync with Bookhoard:
|
|||||||
- Troubleshooting
|
- Troubleshooting
|
||||||
- Best practices
|
- 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](frontend-guide.md)** - Learn how to use the Bookhoard web interface
|
**[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 h1:13roe62tarrZ1Y1QTxE+Bzd/NKlChhRzegLVmU5Hgws=
|
||||||
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
|
github.com/ArcadiaLin/go-epub v0.1.1/go.mod h1:GY09AG6jnEbsYytkw6VeICLOt25F1GQDGXjYS7cxNhU=
|
||||||
|
github.com/a-h/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 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||||
@@ -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/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
|
||||||
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
|
||||||
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
|
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 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
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/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.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1/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.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 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
@@ -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/labstack/echo/v5 v5.1.0/go.mod h1:SyvlSdObGjRXeQfCCXW/sybkZdOOQZBmpKF0bvALaeo=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-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 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
|
||||||
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||||
|
github.com/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 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9lEc=
|
||||||
github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0=
|
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 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
|
||||||
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
|
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 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
|
||||||
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
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/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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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.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 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
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 h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
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=
|
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.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.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.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.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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/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/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 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
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 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
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 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
|
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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
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
|
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.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -185,6 +185,7 @@ type MediaBookmarks struct {
|
|||||||
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
ChapterReference pgtype.Int4 `db:"chapter_reference" json:"chapter_reference"`
|
||||||
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
Deleted pgtype.Bool `db:"deleted" json:"deleted"`
|
||||||
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
DeletedAt pgtype.Timestamptz `db:"deleted_at" json:"deleted_at"`
|
||||||
|
OriginSource pgtype.Text `db:"origin_source" json:"origin_source"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MediaHighlights struct {
|
type MediaHighlights struct {
|
||||||
@@ -305,6 +306,9 @@ type MediaItems struct {
|
|||||||
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
|
||||||
// Summary from ComicInfo.xml (may be merged with description from Calibre)
|
// Summary from ComicInfo.xml (may be merged with description from Calibre)
|
||||||
Summary pgtype.Text `db:"summary" json:"summary"`
|
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"`
|
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
|
||||||
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
|
||||||
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
TagsSearch []string `db:"tags_search" json:"tags_search"`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.30.0
|
// sqlc v1.31.1
|
||||||
|
|
||||||
package database
|
package database
|
||||||
|
|
||||||
@@ -20,6 +20,9 @@ type Querier interface {
|
|||||||
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelfParams) (KoboShelves, error)
|
||||||
// Library Folders queries
|
// Library Folders queries
|
||||||
AddLibraryFolder(ctx context.Context, arg AddLibraryFolderParams) (LibraryFolders, error)
|
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
|
// Bulk update format group for all media items
|
||||||
BulkUpdateFormatGroups(ctx context.Context) error
|
BulkUpdateFormatGroups(ctx context.Context) error
|
||||||
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error)
|
||||||
@@ -30,7 +33,14 @@ type Querier interface {
|
|||||||
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error
|
||||||
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error
|
||||||
|
// 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)
|
CountAdmins(ctx context.Context) (int64, error)
|
||||||
|
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
||||||
// Count unlinked books for a device
|
// Count unlinked books for a device
|
||||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||||
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||||
@@ -249,6 +259,12 @@ type Querier interface {
|
|||||||
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error)
|
||||||
// Per-item user-data counts, used when choosing which duplicate copy to keep
|
// Per-item user-data counts, used when choosing which duplicate copy to keep
|
||||||
GetMediaItemUsageCounts(ctx context.Context, mediaItemID pgtype.UUID) (GetMediaItemUsageCountsRow, error)
|
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)
|
GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error)
|
||||||
// ============================================
|
// ============================================
|
||||||
// ANNOTATION SYNC QUERIES (notes)
|
// ANNOTATION SYNC QUERIES (notes)
|
||||||
@@ -337,9 +353,17 @@ type Querier interface {
|
|||||||
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
|
||||||
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
|
||||||
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
|
||||||
|
// 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)
|
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
|
||||||
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
|
||||||
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, 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)
|
// List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||||
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
|
ListMediaItemsBySHA256AndLibrary(ctx context.Context, arg ListMediaItemsBySHA256AndLibraryParams) ([]MediaItems, error)
|
||||||
// List media items that have no stored SHA-256 (imported before hashing existed)
|
// List media items that have no stored SHA-256 (imported before hashing existed)
|
||||||
@@ -353,6 +377,22 @@ type Querier interface {
|
|||||||
// List unresolved unlinked books with pagination
|
// List unresolved unlinked books with pagination
|
||||||
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
|
||||||
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
ListUsers(ctx context.Context) ([]ListUsersRow, error)
|
||||||
|
// 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
|
PurgeExpiredBookmarkTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
PurgeExpiredHighlightTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
PurgeExpiredNoteTombstones(ctx context.Context, deletedAt pgtype.Timestamptz) error
|
||||||
|
|||||||
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
|
SELECT l.id, COUNT(mi.id) as media_count
|
||||||
FROM libraries l
|
FROM libraries l
|
||||||
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
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
|
WHERE COALESCE(lv.is_visible, true) = true
|
||||||
GROUP BY l.id;
|
GROUP BY l.id;
|
||||||
|
|
||||||
@@ -160,6 +162,8 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.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;
|
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
||||||
|
|
||||||
-- name: ListMediaItemsByLibrary :many
|
-- name: ListMediaItemsByLibrary :many
|
||||||
@@ -167,6 +171,16 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.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
|
WHERE mi.library_id = $1
|
||||||
ORDER BY mi.created_at DESC;
|
ORDER BY mi.created_at DESC;
|
||||||
|
|
||||||
@@ -175,7 +189,9 @@ SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN libraries l ON mi.library_id = l.id
|
JOIN libraries l ON mi.library_id = l.id
|
||||||
JOIN library_types lt ON l.library_type_id = lt.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
|
ORDER BY
|
||||||
CASE
|
CASE
|
||||||
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
||||||
@@ -282,10 +298,104 @@ UPDATE media_items SET
|
|||||||
alternate_info = $35,
|
alternate_info = $35,
|
||||||
scan_information = $36,
|
scan_information = $36,
|
||||||
summary = $37,
|
summary = $37,
|
||||||
|
metadata_overrides = $38,
|
||||||
updated_at = NOW()
|
updated_at = NOW()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
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
|
-- name: DeleteMediaItem :exec
|
||||||
DELETE FROM media_items WHERE id = $1;
|
DELETE FROM media_items WHERE id = $1;
|
||||||
|
|
||||||
@@ -434,6 +544,8 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
JOIN library_types lt ON l.library_type_id = lt.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')
|
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
|
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 (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
AND (
|
AND (
|
||||||
mi.title ILIKE sqlc.narg('search_pattern') OR
|
mi.title ILIKE sqlc.narg('search_pattern') OR
|
||||||
@@ -506,6 +618,8 @@ JOIN libraries l ON mi.library_id = l.id
|
|||||||
JOIN library_types lt ON l.library_type_id = lt.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')
|
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
|
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
|
AND (sqlc.narg('library_id')::uuid IS NULL
|
||||||
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
||||||
-- Fuzzy author filter
|
-- Fuzzy author filter
|
||||||
@@ -639,6 +753,8 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
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')
|
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
|
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 mi.library_id = sqlc.narg('library_id')
|
||||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
|
||||||
AND mi.author IS NOT NULL
|
AND mi.author IS NOT NULL
|
||||||
@@ -656,6 +772,8 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
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')
|
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
|
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 mi.library_id = sqlc.narg('library_id')
|
||||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
|
||||||
AND mi.genre IS NOT NULL
|
AND mi.genre IS NOT NULL
|
||||||
@@ -690,6 +808,8 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
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')
|
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
|
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 mi.library_id = sqlc.narg('library_id')
|
||||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
|
||||||
AND mi.series IS NOT NULL
|
AND mi.series IS NOT NULL
|
||||||
@@ -707,6 +827,8 @@ FROM media_items mi
|
|||||||
JOIN libraries l ON mi.library_id = l.id
|
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')
|
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
|
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 mi.library_id = sqlc.narg('library_id')
|
||||||
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
|
||||||
AND mi.language IS NOT NULL
|
AND mi.language IS NOT NULL
|
||||||
@@ -898,9 +1020,9 @@ INSERT INTO media_bookmarks (
|
|||||||
cfi_position, title, position, notes,
|
cfi_position, title, position, notes,
|
||||||
percentage_location, epubcfi_location, chapter_reference,
|
percentage_location, epubcfi_location, chapter_reference,
|
||||||
dedup_key, last_modified_at, last_modified_source,
|
dedup_key, last_modified_at, last_modified_source,
|
||||||
device_sync_data
|
device_sync_data, origin_source
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
||||||
) RETURNING *;
|
) RETURNING *;
|
||||||
|
|
||||||
-- name: UpdateMediaBookmarkForSync :one
|
-- name: UpdateMediaBookmarkForSync :one
|
||||||
@@ -1811,6 +1933,24 @@ SELECT * FROM media_items WHERE file_sha256 = $1;
|
|||||||
-- name: GetMediaItemBySHA256AndLibrary :one
|
-- name: GetMediaItemBySHA256AndLibrary :one
|
||||||
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
|
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)
|
-- List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
||||||
-- name: ListMediaItemsBySHA256AndLibrary :many
|
-- name: ListMediaItemsBySHA256AndLibrary :many
|
||||||
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
|
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
|
||||||
@@ -2056,6 +2196,8 @@ SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id
|
|||||||
FROM collection_items ci
|
FROM collection_items ci
|
||||||
JOIN media_items mi ON ci.media_item_id = mi.id
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
||||||
WHERE ci.collection_id = $1
|
WHERE ci.collection_id = $1
|
||||||
|
AND mi.archived_at IS NULL
|
||||||
|
AND mi.missing_scan_count = 0
|
||||||
ORDER BY ci.added_at DESC;
|
ORDER BY ci.added_at DESC;
|
||||||
|
|
||||||
-- Get collections for book
|
-- Get collections for book
|
||||||
@@ -2362,12 +2504,16 @@ INNER JOIN (
|
|||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
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
|
ORDER BY rp.last_read_at DESC
|
||||||
LIMIT sqlc.narg('limit');
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetRecentlyAddedItems :many
|
-- name: GetRecentlyAddedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
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
|
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
||||||
LIMIT sqlc.narg('limit');
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
@@ -2381,12 +2527,16 @@ INNER JOIN (
|
|||||||
ORDER BY media_item_id, last_read_at DESC
|
ORDER BY media_item_id, last_read_at DESC
|
||||||
) rp ON rp.media_item_id = mi.id
|
) rp ON rp.media_item_id = mi.id
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
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
|
ORDER BY rp.last_read_at DESC
|
||||||
LIMIT sqlc.narg('limit');
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetNotStartedItems :many
|
-- name: GetNotStartedItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
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 (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM reading_progress rp
|
SELECT 1 FROM reading_progress rp
|
||||||
WHERE rp.media_item_id = mi.id
|
WHERE rp.media_item_id = mi.id
|
||||||
@@ -2401,12 +2551,16 @@ SELECT mi.*, ci.excluded FROM media_items mi
|
|||||||
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
||||||
WHERE ci.collection_id = sqlc.narg('collection_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 (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
|
ORDER BY ci.added_at DESC
|
||||||
LIMIT sqlc.narg('limit');
|
LIMIT sqlc.narg('limit');
|
||||||
|
|
||||||
-- name: GetLibraryItems :many
|
-- name: GetLibraryItems :many
|
||||||
SELECT mi.* FROM media_items mi
|
SELECT mi.* FROM media_items mi
|
||||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
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;
|
ORDER BY mi.created_at DESC;
|
||||||
|
|
||||||
-- name: GetDistinctSeries :many
|
-- name: GetDistinctSeries :many
|
||||||
@@ -2454,7 +2608,9 @@ next_books AS (
|
|||||||
usp.last_read_at
|
usp.last_read_at
|
||||||
FROM media_items mi
|
FROM media_items mi
|
||||||
JOIN user_series_progress usp ON mi.series = usp.series
|
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)
|
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
|
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ func parseTableNames() ([]string, error) {
|
|||||||
tables := make(map[string]bool)
|
tables := make(map[string]bool)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
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)
|
matches := pattern.FindStringSubmatch(line)
|
||||||
if len(matches) > 1 {
|
if len(matches) > 1 {
|
||||||
tableName := 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
-12
@@ -6,11 +6,16 @@ import (
|
|||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
"github.com/skip2/go-qrcode"
|
"github.com/skip2/go-qrcode"
|
||||||
@@ -106,7 +111,14 @@ type PendingRegistration struct {
|
|||||||
SyncEndpoints map[string]string
|
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 {
|
func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
||||||
req := DeviceRegistrationRequest{}
|
req := DeviceRegistrationRequest{}
|
||||||
@@ -130,7 +142,9 @@ func (h *DeviceHandler) InitiateRegistration(c *echo.Context) error {
|
|||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingMu.Lock()
|
||||||
pendingRegistrations[registrationID] = registration
|
pendingRegistrations[registrationID] = registration
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
authURL := fmt.Sprintf("%s/devices/approve/%s", h.cfg.BaseURL, registrationID)
|
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"})
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request format"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingMu.Lock()
|
||||||
registration, exists := pendingRegistrations[req.RegistrationID]
|
registration, exists := pendingRegistrations[req.RegistrationID]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(registration.ExpiresAt) {
|
if time.Now().After(registration.ExpiresAt) {
|
||||||
delete(pendingRegistrations, req.RegistrationID)
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if registration.Approved {
|
if registration.Approved {
|
||||||
delete(pendingRegistrations, req.RegistrationID)
|
delete(pendingRegistrations, req.RegistrationID)
|
||||||
|
// Snapshot under the lock: the approver wrote these fields, and
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
// the row is gone from the map — no other reader/writer remains.
|
||||||
|
resp := DeviceAuthStatusResponse{
|
||||||
Status: "approved",
|
Status: "approved",
|
||||||
AuthToken: registration.AuthToken,
|
AuthToken: registration.AuthToken,
|
||||||
DeviceID: registration.DeviceID,
|
DeviceID: registration.DeviceID,
|
||||||
SyncEndpoints: registration.SyncEndpoints,
|
SyncEndpoints: registration.SyncEndpoints,
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
return c.JSON(http.StatusOK, DeviceAuthStatusResponse{
|
||||||
Status: "pending",
|
Status: "pending",
|
||||||
@@ -552,17 +574,21 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
|
|
||||||
registrationID := c.Param("registration_id")
|
registrationID := c.Param("registration_id")
|
||||||
|
|
||||||
|
pendingMu.Lock()
|
||||||
registration, exists := pendingRegistrations[registrationID]
|
registration, exists := pendingRegistrations[registrationID]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(registration.ExpiresAt) {
|
if time.Now().After(registration.ExpiresAt) {
|
||||||
delete(pendingRegistrations, registrationID)
|
delete(pendingRegistrations, registrationID)
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
return c.JSON(http.StatusGone, map[string]string{"error": "registration expired"})
|
||||||
}
|
}
|
||||||
|
|
||||||
if registration.Approved {
|
if registration.Approved {
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"message": "device already approved",
|
"message": "device already approved",
|
||||||
"device_name": registration.DeviceName,
|
"device_name": registration.DeviceName,
|
||||||
@@ -570,6 +596,7 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
"approved": true,
|
"approved": true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
authToken, err := generateDeviceToken()
|
authToken, err := generateDeviceToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -577,23 +604,87 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pgUserID := pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true}
|
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,
|
UserID: pgUserID,
|
||||||
DeviceName: registration.DeviceName,
|
DeviceName: registration.DeviceName,
|
||||||
DeviceType: registration.DeviceType,
|
DeviceType: registration.DeviceType,
|
||||||
DeviceIdentifier: registration.DeviceIdentifier,
|
DeviceIdentifier: registration.DeviceIdentifier,
|
||||||
AuthToken: authToken,
|
AuthToken: authToken,
|
||||||
SyncEnabled: syncEnabled,
|
SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
|
||||||
AutoSync: autoSync,
|
AutoSync: pgtype.Bool{Bool: true, Valid: true},
|
||||||
SyncFrequencyMinutes: syncFreq,
|
SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
|
||||||
DeviceMetadata: []byte("{}"),
|
DeviceMetadata: []byte("{}"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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{}
|
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)
|
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.UserID = userUUID
|
||||||
registration.Approved = true
|
registration.Approved = true
|
||||||
registration.AuthToken = authToken
|
registration.AuthToken = authToken
|
||||||
registration.DeviceID = device.ID.Bytes
|
registration.DeviceID = device.ID.Bytes
|
||||||
registration.SyncEndpoints = syncEndpoints
|
registration.SyncEndpoints = syncEndpoints
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"message": "device approved successfully",
|
"message": "device approved successfully",
|
||||||
@@ -625,12 +721,15 @@ func (h *DeviceHandler) ApproveDevice(c *echo.Context) error {
|
|||||||
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
func (h *DeviceHandler) RejectDevice(c *echo.Context) error {
|
||||||
registrationID := c.Param("registration_id")
|
registrationID := c.Param("registration_id")
|
||||||
|
|
||||||
|
pendingMu.Lock()
|
||||||
_, exists := pendingRegistrations[registrationID]
|
_, exists := pendingRegistrations[registrationID]
|
||||||
if !exists {
|
if !exists {
|
||||||
|
pendingMu.Unlock()
|
||||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "registration not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(pendingRegistrations, registrationID)
|
delete(pendingRegistrations, registrationID)
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]string{
|
return c.JSON(http.StatusOK, map[string]string{
|
||||||
"message": "device registration rejected",
|
"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) {
|
func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[string]interface{}, error) {
|
||||||
registrations := []map[string]interface{}{}
|
registrations := []map[string]interface{}{}
|
||||||
|
pendingMu.Lock()
|
||||||
for _, reg := range pendingRegistrations {
|
for _, reg := range pendingRegistrations {
|
||||||
if reg.UserID == (uuid.UUID{}) {
|
if reg.UserID == (uuid.UUID{}) {
|
||||||
registrations = append(registrations, map[string]interface{}{
|
registrations = append(registrations, map[string]interface{}{
|
||||||
@@ -652,6 +752,7 @@ func (h *DeviceHandler) GetPendingRegistrationsData(c *echo.Context) ([]map[stri
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pendingMu.Unlock()
|
||||||
|
|
||||||
return registrations, nil
|
return registrations, nil
|
||||||
}
|
}
|
||||||
@@ -664,6 +765,7 @@ func (h *DeviceHandler) ListPendingRegistrations(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registrations := []map[string]interface{}{}
|
registrations := []map[string]interface{}{}
|
||||||
|
pendingMu.Lock()
|
||||||
for _, reg := range pendingRegistrations {
|
for _, reg := range pendingRegistrations {
|
||||||
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
if reg.UserID == userUUID || reg.UserID == (uuid.UUID{}) {
|
||||||
registrations = append(registrations, map[string]interface{}{
|
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{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"registrations": registrations,
|
"registrations": registrations,
|
||||||
|
|||||||
+168
-105
@@ -50,33 +50,97 @@ func (h *KOReaderHandler) SetAnnotationService(svc *wsync.AnnotationService) {
|
|||||||
h.annotationSvc = svc
|
h.annotationSvc = svc
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) convertHighlightPositions(ctx context.Context, mediaItemID pgtype.UUID, pos0, pos1, contextText string) (string, string) {
|
// annotationEpub carries the per-book context every locator conversion
|
||||||
if pos0 == "" || h.libraryService == nil {
|
// needs: the media item (format gating) and the resolved EPUB path. It is
|
||||||
return "", ""
|
// resolved once per request so all annotations in a push share one
|
||||||
}
|
// converter-cache entry instead of re-resolving (and re-parsing the book)
|
||||||
|
// per annotation.
|
||||||
|
type annotationEpub struct {
|
||||||
|
mediaItem *database.MediaItems
|
||||||
|
epubPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ec annotationEpub) convertible() bool {
|
||||||
|
return ec.mediaItem != nil && ec.epubPath != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *KOReaderHandler) loadAnnotationEpub(ctx context.Context, mediaItemID pgtype.UUID) annotationEpub {
|
||||||
|
var ec annotationEpub
|
||||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return ec
|
||||||
|
}
|
||||||
|
ec.mediaItem = &mediaItem
|
||||||
|
if h.libraryService != nil {
|
||||||
|
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
|
||||||
|
ec.epubPath = epubPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ec
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertHighlightPositions resolves a device annotation's pos0/pos1
|
||||||
|
// locators to canonical CFIs through the shared facade. contextText is the
|
||||||
|
// selection's own text — a quote of the document, so the converter can
|
||||||
|
// verify structural landings against it and, when it must search, anchor a
|
||||||
|
// range end that spans block boundaries. percentage anchors the
|
||||||
|
// last-resort fallback. Only structural/exact landings are returned: a
|
||||||
|
// low-confidence conversion yields "" so an echo preserves the row's
|
||||||
|
// existing web CFIs (applyLWW coalesces empty) instead of clobbering them
|
||||||
|
// with a guess, and a new device highlight paints nowhere rather than in
|
||||||
|
// the wrong place.
|
||||||
|
func (h *KOReaderHandler) convertHighlightPositions(ec annotationEpub, pos0, pos1, contextText string, percentage float64) (string, string) {
|
||||||
|
if pos0 == "" || !ec.convertible() {
|
||||||
return "", ""
|
return "", ""
|
||||||
}
|
}
|
||||||
epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, percentage, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||||
if err != nil || epubPath == "" {
|
startCFI := webUsableCFI(startLoc)
|
||||||
return "", ""
|
endCFI := ""
|
||||||
|
// A text-search start matched the selection text itself: its extent
|
||||||
|
// is the selection's true end, even across blocks.
|
||||||
|
if startCFI != "" && startLoc.EndCFI != "" {
|
||||||
|
endCFI = startLoc.EndCFI
|
||||||
}
|
}
|
||||||
// The annotation's own text is the ideal anchor for the converter's
|
if endCFI == "" && pos1 != "" {
|
||||||
// text-search path: clients (thin, underpowered) send only raw
|
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||||
// locators, the server resolves them against the actual book.
|
endCFI = webUsableCFI(endLoc)
|
||||||
startLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos0, 0, contextText, mediaItem.FormatGroup, epubPath, "")
|
|
||||||
endLoc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, pos1, 0, "", mediaItem.FormatGroup, epubPath, "")
|
|
||||||
endCFI := endLoc.CFI
|
|
||||||
// The end conversion carries no context text, so unless it resolved
|
|
||||||
// exactly it degenerates to a percentage fallback anchored at the
|
|
||||||
// document start — useless as a range end. When the START resolved
|
|
||||||
// exactly, derive the end from it: same node, character offset
|
|
||||||
// advanced by the selection's UTF-16 length (the CFI offset unit).
|
|
||||||
if endLoc.Precision != "exact" && startLoc.Precision == "exact" && contextText != "" {
|
|
||||||
endCFI = extendCFIByLength(startLoc.CFI, contextText)
|
|
||||||
}
|
}
|
||||||
return startLoc.CFI, endCFI
|
// The end conversion carries no context text; when neither resolved
|
||||||
|
// confidently, derive the end from the start advanced by the
|
||||||
|
// selection's UTF-16 length (the CFI offset unit). Multi-node
|
||||||
|
// selections produce an out-of-range offset — harmless: resolution
|
||||||
|
// clamps or fails, and consumers fall back to the start.
|
||||||
|
if endCFI == "" && startCFI != "" && contextText != "" {
|
||||||
|
endCFI = extendCFIByLength(startCFI, contextText)
|
||||||
|
}
|
||||||
|
return startCFI, endCFI
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertBookmarkPosition resolves a device bookmark's locator to the
|
||||||
|
// canonical CFI for the web reader. Structural-only by design: bookmark
|
||||||
|
// text is a display label ("in <chapter>" auto-fill or a user note), never
|
||||||
|
// book text, so no context is supplied and only a structural/exact landing
|
||||||
|
// is trusted — lower rungs would store a confident-looking guess the web
|
||||||
|
// drawer would present as a real destination.
|
||||||
|
func (h *KOReaderHandler) convertBookmarkPosition(ec annotationEpub, position string, percentage float64) string {
|
||||||
|
if position == "" || !ec.convertible() || !wsync.IsCREXPointer(position) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, position, percentage, "", ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||||
|
return webUsableCFI(loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
// webUsableCFI keeps only high-confidence conversions: the web reader
|
||||||
|
// navigates bookmarks by CFI, so href/percentage/fallback results are
|
||||||
|
// discarded instead of stored as dead links.
|
||||||
|
func webUsableCFI(loc wsync.CanonicalLocator) string {
|
||||||
|
if loc.Precision != "structural" && loc.Precision != "exact" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(loc.CFI, "epubcfi(") || !strings.HasSuffix(loc.CFI, ")") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return loc.CFI
|
||||||
}
|
}
|
||||||
|
|
||||||
// extendCFIByLength advances a point CFI's trailing character offset by the
|
// extendCFIByLength advances a point CFI's trailing character offset by the
|
||||||
@@ -127,22 +191,19 @@ func (h *KOReaderHandler) existingHighlightColor(ctx context.Context, mediaItemI
|
|||||||
// deriveAnnotationPercentage computes a percentage for device-pushed
|
// deriveAnnotationPercentage computes a percentage for device-pushed
|
||||||
// annotations when the client didn't send one (thin clients skip their own
|
// annotations when the client didn't send one (thin clients skip their own
|
||||||
// per-annotation page lookups; arithmetic is only free on paging documents).
|
// per-annotation page lookups; arithmetic is only free on paging documents).
|
||||||
func (h *KOReaderHandler) deriveAnnotationPercentage(ctx context.Context, mediaItemID pgtype.UUID, pos0 string, page int) float64 {
|
func (h *KOReaderHandler) deriveAnnotationPercentage(ec annotationEpub, pos0 string, page int) float64 {
|
||||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
if ec.mediaItem == nil {
|
||||||
if err != nil {
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
formatGroup := wsync.FormatGroup(mediaItem.FormatGroup)
|
formatGroup := wsync.FormatGroup(ec.mediaItem.FormatGroup)
|
||||||
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
|
if formatGroup == wsync.FormatGroupFixedLayout || formatGroup == wsync.FormatGroupComicArchive {
|
||||||
if page > 0 && mediaItem.PageCount.Valid && mediaItem.PageCount.Int32 > 0 {
|
if page > 0 && ec.mediaItem.PageCount.Valid && ec.mediaItem.PageCount.Int32 > 0 {
|
||||||
return float64(page) / float64(mediaItem.PageCount.Int32)
|
return float64(page) / float64(ec.mediaItem.PageCount.Int32)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if wsync.IsCREXPointer(pos0) && h.libraryService != nil {
|
if wsync.IsCREXPointer(pos0) && ec.epubPath != "" {
|
||||||
if epubPath, err := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath); err == nil && epubPath != "" {
|
return wsync.SectionPercentageCached(ec.epubPath, pos0)
|
||||||
return wsync.NewCFIConverter(epubPath).SectionPercentage(pos0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -614,21 +675,23 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
|||||||
if h.annotationSvc == nil {
|
if h.annotationSvc == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ec := h.loadAnnotationEpub(ctx, mediaItemID)
|
||||||
|
|
||||||
for _, hl := range book.Highlights {
|
for _, hl := range book.Highlights {
|
||||||
startPos := hl.Pos0
|
startPos := hl.Pos0
|
||||||
endPos := hl.Pos1
|
endPos := hl.Pos1
|
||||||
// The highlight's own text anchors the conversion exactly.
|
|
||||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, hl.Text)
|
|
||||||
|
|
||||||
pctStart := 0.0
|
pctStart := 0.0
|
||||||
if hl.Percentage != nil {
|
if hl.Percentage != nil {
|
||||||
pctStart = *hl.Percentage
|
pctStart = *hl.Percentage
|
||||||
}
|
}
|
||||||
if pctStart == 0 {
|
if pctStart == 0 {
|
||||||
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(hl.Page))
|
pctStart = h.deriveAnnotationPercentage(ec, startPos, int(hl.Page))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The highlight's own text anchors the conversion exactly.
|
||||||
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, hl.Text, pctStart)
|
||||||
|
|
||||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
"datetime": hl.Datetime,
|
"datetime": hl.Datetime,
|
||||||
"pos0": hl.Pos0,
|
"pos0": hl.Pos0,
|
||||||
@@ -676,16 +739,17 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
|||||||
for _, note := range book.Notes {
|
for _, note := range book.Notes {
|
||||||
startPos := note.Pos0
|
startPos := note.Pos0
|
||||||
endPos := note.Pos1
|
endPos := note.Pos1
|
||||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, startPos, endPos, note.Text)
|
|
||||||
|
|
||||||
pctStart := 0.0
|
pctStart := 0.0
|
||||||
if note.Percentage != nil {
|
if note.Percentage != nil {
|
||||||
pctStart = *note.Percentage
|
pctStart = *note.Percentage
|
||||||
}
|
}
|
||||||
if pctStart == 0 {
|
if pctStart == 0 {
|
||||||
pctStart = h.deriveAnnotationPercentage(ctx, mediaItemID, startPos, int(note.Page))
|
pctStart = h.deriveAnnotationPercentage(ec, startPos, int(note.Page))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, startPos, endPos, note.Text, pctStart)
|
||||||
|
|
||||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
"datetime": note.Datetime,
|
"datetime": note.Datetime,
|
||||||
"pos0": note.Pos0,
|
"pos0": note.Pos0,
|
||||||
@@ -723,6 +787,19 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
|||||||
position = fmt.Sprintf("page:%d", bookmark.Page)
|
position = fmt.Sprintf("page:%d", bookmark.Page)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pctLoc := 0.0
|
||||||
|
if bookmark.Percentage != nil {
|
||||||
|
pctLoc = *bookmark.Percentage
|
||||||
|
}
|
||||||
|
if pctLoc == 0 {
|
||||||
|
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device-native xpointer → canonical CFI so the web drawer can
|
||||||
|
// navigate KOReader-created bookmarks (page-only positions have no
|
||||||
|
// convertible locator; the drawer's page fallback covers those).
|
||||||
|
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
|
||||||
|
|
||||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
"datetime": bookmark.Datetime,
|
"datetime": bookmark.Datetime,
|
||||||
"pos0": bookmark.Pos0,
|
"pos0": bookmark.Pos0,
|
||||||
@@ -739,8 +816,11 @@ func (h *KOReaderHandler) processBookAnnotations(ctx context.Context, deviceID,
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
Title: bookmark.Text,
|
Title: bookmark.Text,
|
||||||
Position: position,
|
Position: position,
|
||||||
|
CFIPosition: cfiPosition,
|
||||||
|
PercentageLoc: pctLoc,
|
||||||
ChapterNumber: int32(bookmark.Chapter),
|
ChapterNumber: int32(bookmark.Chapter),
|
||||||
Source: "koreader",
|
Source: "koreader",
|
||||||
|
OriginSource: "koreader",
|
||||||
DeviceSyncData: deviceData,
|
DeviceSyncData: deviceData,
|
||||||
DedupKey: dedupKey,
|
DedupKey: dedupKey,
|
||||||
})
|
})
|
||||||
@@ -783,29 +863,11 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
|||||||
if h.progressSvc != nil {
|
if h.progressSvc != nil {
|
||||||
epubcfi := book.Epubcfi
|
epubcfi := book.Epubcfi
|
||||||
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
|
if epubcfi != nil && wsync.IsCREXPointer(*epubcfi) {
|
||||||
log.Printf("Bookhoard: CRE→CFI attempting conversion for %s", *epubcfi)
|
// Same facade every annotation uses: cached converter,
|
||||||
mediaItem, err := h.db.GetMediaItem(ctx, mediaItemID)
|
// structural-first resolution, guarded fallbacks. The facade
|
||||||
if err != nil {
|
// passes non-reflowable formats through untouched.
|
||||||
log.Printf("Bookhoard: CRE→CFI failed to get media item: %v", err)
|
ec := h.loadAnnotationEpub(ctx, mediaItemID)
|
||||||
} else if mediaItem.FormatGroup == string(wsync.FormatGroupFixedLayout) ||
|
if ec.convertible() {
|
||||||
mediaItem.FormatGroup == string(wsync.FormatGroupComicArchive) {
|
|
||||||
// Image-based fixed content (fixed-layout comic EPUBs, PDF,
|
|
||||||
// comic archives) has no extractable text, so CRE→CFI conversion
|
|
||||||
// cannot succeed. The page index (page/total_pages) is the
|
|
||||||
// canonical locator. Keep the incoming xpointer for device-native
|
|
||||||
// restore; the web reader restores by page.
|
|
||||||
log.Printf("Bookhoard: CRE→CFI skipped for %s format", mediaItem.FormatGroup)
|
|
||||||
} else if h.libraryService == nil {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI libraryService is nil, skipping conversion")
|
|
||||||
} else {
|
|
||||||
epubPath, resolveErr := h.libraryService.ResolveMediaPath(ctx, mediaItem.LibraryID, mediaItem.FilePath)
|
|
||||||
if resolveErr != nil {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI failed to resolve media path: %v", resolveErr)
|
|
||||||
} else if epubPath == "" {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI resolved empty epub path for %s", mediaItem.FilePath)
|
|
||||||
} else {
|
|
||||||
log.Printf("Bookhoard: CRE→CFI resolved epub path: %s", epubPath)
|
|
||||||
converter := wsync.NewCFIConverter(epubPath)
|
|
||||||
pct := 0.0
|
pct := 0.0
|
||||||
if book.Percentage >= 0 {
|
if book.Percentage >= 0 {
|
||||||
pct = book.Percentage
|
pct = book.Percentage
|
||||||
@@ -814,22 +876,27 @@ func (h *KOReaderHandler) updateProgressForBook(c *echo.Context, deviceID pgtype
|
|||||||
if book.ContextText != nil {
|
if book.ContextText != nil {
|
||||||
contextText = *book.ContextText
|
contextText = *book.ContextText
|
||||||
}
|
}
|
||||||
result, convErr := converter.ConvertCREToStandard(*epubcfi, pct, contextText)
|
loc := wsync.ConvertToCanonical(wsync.LocatorSourceKOReader, *epubcfi, pct, contextText, ec.mediaItem.FormatGroup, ec.epubPath, "")
|
||||||
if convErr != nil {
|
switch {
|
||||||
log.Printf("Bookhoard: CRE→CFI conversion error: %v", convErr)
|
case strings.HasPrefix(loc.CFI, "epubcfi(") &&
|
||||||
} else if result != nil {
|
(loc.Precision == "structural" || loc.Precision == "exact"):
|
||||||
if result.EPUBCFI != "" {
|
converted := loc.CFI
|
||||||
convertedCFI := result.EPUBCFI
|
epubcfi = &converted
|
||||||
epubcfi = &convertedCFI
|
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to %s", loc.Precision, converted)
|
||||||
log.Printf("Bookhoard: CRE→CFI converted to epubcfi: %s", convertedCFI)
|
case loc.CFI != "" && loc.CFI != *epubcfi &&
|
||||||
} else if result.Href != "" {
|
(loc.Precision == "element" || loc.Precision == "section"):
|
||||||
convertedHref := result.Href
|
// Fragment-ID positions resolve to a section href: keep
|
||||||
epubcfi = &convertedHref
|
// serving it (legacy behavior).
|
||||||
log.Printf("Bookhoard: CRE→CFI converted to href: %s", convertedHref)
|
converted := loc.CFI
|
||||||
} else {
|
epubcfi = &converted
|
||||||
log.Printf("Bookhoard: CRE→CFI conversion: %s precision for %s", result.Precision, *epubcfi)
|
log.Printf("Bookhoard: CRE→CFI converted progress (%s) to href %s", loc.Precision, converted)
|
||||||
}
|
default:
|
||||||
}
|
// Low-confidence (percentage/fallback): store no
|
||||||
|
// canonical locator — the row's percentage restores
|
||||||
|
// approximately instead of a confidently-wrong CFI,
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1168,40 +1235,20 @@ func (h *KOReaderHandler) GetMetadata(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
|
func (h *KOReaderHandler) convertCFIToXPointer(c *echo.Context, mediaItem database.MediaItems, progress database.GetUniversalProgressRow, progressData *KOReaderProgressData) {
|
||||||
if h.libraryService == nil {
|
|
||||||
log.Printf("Bookhoard: CFI→CRE libraryService is nil, skipping reverse conversion")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
epubPath, err := h.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, mediaItem.FilePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Bookhoard: CFI→CRE failed to resolve media path: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if epubPath == "" {
|
|
||||||
log.Printf("Bookhoard: CFI→CRE resolved empty epub path for %s", mediaItem.FilePath)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
converter := wsync.NewCFIConverter(epubPath)
|
|
||||||
contextText := ""
|
contextText := ""
|
||||||
if progress.ContextText.Valid {
|
if progress.ContextText.Valid {
|
||||||
contextText = progress.ContextText.String
|
contextText = progress.ContextText.String
|
||||||
}
|
}
|
||||||
pct := progress.Percentage.Float64
|
// Same facade path annotations use on serve: structural resolution
|
||||||
|
// first, guarded text search only as fallback, cached converter. The
|
||||||
result, err := converter.ConvertStandardToCRE(progress.Epubcfi.String, pct, contextText)
|
// stored percentage anchors the reverse fallback ladder.
|
||||||
if err != nil {
|
if xp := h.reverseConvertCFI(c, mediaItem, progress.Epubcfi.String, contextText, progress.Percentage.Float64); xp != "" {
|
||||||
log.Printf("Bookhoard: CFI→CRE conversion error: %v", err)
|
progressData.KoreaderXPointer = &xp
|
||||||
return
|
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", xp)
|
||||||
}
|
|
||||||
if result != nil && result.XPointer != "" {
|
|
||||||
progressData.KoreaderXPointer = &result.XPointer
|
|
||||||
log.Printf("Bookhoard: CFI→CRE converted to XPointer: %s", result.XPointer)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string) string {
|
func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.MediaItems, epubcfi string, contextText string, percentage float64) string {
|
||||||
if h.libraryService == nil || epubcfi == "" {
|
if h.libraryService == nil || epubcfi == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -1209,7 +1256,7 @@ func (h *KOReaderHandler) reverseConvertCFI(c *echo.Context, mediaItem database.
|
|||||||
if err != nil || epubPath == "" {
|
if err != nil || epubPath == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, 0, contextText, mediaItem.FormatGroup, epubPath, "")
|
loc := wsync.ConvertFromCanonical(wsync.LocatorSourceKOReader, epubcfi, percentage, contextText, mediaItem.FormatGroup, epubPath, "")
|
||||||
if loc.Position != "" && loc.Position != epubcfi {
|
if loc.Position != "" && loc.Position != epubcfi {
|
||||||
return loc.Position
|
return loc.Position
|
||||||
}
|
}
|
||||||
@@ -1316,7 +1363,7 @@ func (h *KOReaderHandler) koreaderPos0(c *echo.Context, mediaItem database.Media
|
|||||||
cfi = strings.TrimPrefix(startPosition, "cfi:")
|
cfi = strings.TrimPrefix(startPosition, "cfi:")
|
||||||
}
|
}
|
||||||
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
|
if cfi != "" && wsync.IsStandardEPUBCFI(cfi) {
|
||||||
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText); converted != "" {
|
if converted := h.reverseConvertCFI(c, mediaItem, cfi, contextText, 0); converted != "" {
|
||||||
return converted
|
return converted
|
||||||
}
|
}
|
||||||
// Conversion failed; fall through so numeric positions still work.
|
// Conversion failed; fall through so numeric positions still work.
|
||||||
@@ -1482,6 +1529,17 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.annotationSvc != nil {
|
if h.annotationSvc != nil {
|
||||||
|
ec := h.loadAnnotationEpub(ctx, mediaItemID)
|
||||||
|
|
||||||
|
pctLoc := 0.0
|
||||||
|
if bookmark.Percentage != nil {
|
||||||
|
pctLoc = *bookmark.Percentage
|
||||||
|
}
|
||||||
|
if pctLoc == 0 {
|
||||||
|
pctLoc = h.deriveAnnotationPercentage(ec, position, int(bookmark.Page))
|
||||||
|
}
|
||||||
|
cfiPosition := h.convertBookmarkPosition(ec, position, pctLoc)
|
||||||
|
|
||||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
"datetime": bookmark.Datetime,
|
"datetime": bookmark.Datetime,
|
||||||
"pos0": bookmark.Pos0,
|
"pos0": bookmark.Pos0,
|
||||||
@@ -1493,8 +1551,11 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
UserID: pgUserID,
|
UserID: pgUserID,
|
||||||
Title: bookmark.Text,
|
Title: bookmark.Text,
|
||||||
Position: position,
|
Position: position,
|
||||||
|
CFIPosition: cfiPosition,
|
||||||
|
PercentageLoc: pctLoc,
|
||||||
ChapterNumber: int32(bookmark.Chapter),
|
ChapterNumber: int32(bookmark.Chapter),
|
||||||
Source: "koreader",
|
Source: "koreader",
|
||||||
|
OriginSource: "koreader",
|
||||||
DeviceSyncData: deviceData,
|
DeviceSyncData: deviceData,
|
||||||
})
|
})
|
||||||
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
if err == nil && result.Outcome != wsync.SaveOutcomeDeleted {
|
||||||
@@ -1588,13 +1649,15 @@ func (h *KOReaderHandler) SyncBookmarks(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if h.annotationSvc != nil {
|
if h.annotationSvc != nil {
|
||||||
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ctx, mediaItemID, highlight.Pos0, highlight.Pos1, highlight.Text)
|
ec := h.loadAnnotationEpub(ctx, mediaItemID)
|
||||||
|
|
||||||
pctStart := 0.0
|
pctStart := 0.0
|
||||||
if highlight.Percentage != nil {
|
if highlight.Percentage != nil {
|
||||||
pctStart = *highlight.Percentage
|
pctStart = *highlight.Percentage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
epubcfiStart, epubcfiEnd := h.convertHighlightPositions(ec, highlight.Pos0, highlight.Pos1, highlight.Text, pctStart)
|
||||||
|
|
||||||
deviceData, _ := json.Marshal(map[string]interface{}{
|
deviceData, _ := json.Marshal(map[string]interface{}{
|
||||||
"datetime": highlight.Datetime,
|
"datetime": highlight.Datetime,
|
||||||
"pos0": highlight.Pos0,
|
"pos0": highlight.Pos0,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
wsync "bookhoard/internal/sync"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,3 +52,53 @@ func TestKOReaderProgressRequest_DeletedAnnotationsOmitted(t *testing.T) {
|
|||||||
assert.Empty(t, req.Books[0].DeletedHighlights)
|
assert.Empty(t, req.Books[0].DeletedHighlights)
|
||||||
assert.Empty(t, req.Books[0].DeletedBookmarks)
|
assert.Empty(t, req.Books[0].DeletedBookmarks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The web drawer navigates bookmarks by CFI, so only high-confidence
|
||||||
|
// conversions may be stored: href/percentage/fallback results would
|
||||||
|
// become dead links. This is the sole gate for KOReader→web bookmark
|
||||||
|
// positions (bookmark text is a label, never book text, so the
|
||||||
|
// conversion runs structural-only with empty context).
|
||||||
|
func TestWebUsableCFI(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
loc wsync.CanonicalLocator
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "structural landing stored",
|
||||||
|
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/28/2/1:0)", Precision: "structural", Percentage: 0.52},
|
||||||
|
expected: "epubcfi(/6/52!/4/28/2/1:0)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact text-search landing stored",
|
||||||
|
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/28/2/1:0)", Precision: "exact", Percentage: 0.52},
|
||||||
|
expected: "epubcfi(/6/52!/4/28/2/1:0)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "percentage guess discarded",
|
||||||
|
loc: wsync.CanonicalLocator{CFI: "epubcfi(/6/52!/4/2/1:0)", Precision: "percentage", Percentage: 0.52},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fallback passthrough (raw xpointer) discarded",
|
||||||
|
loc: wsync.CanonicalLocator{CFI: "/body/DocFragment[2]/body/p[3]", Precision: "fallback", Percentage: 0.52},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "section href discarded (not a CFI)",
|
||||||
|
loc: wsync.CanonicalLocator{CFI: "ch10.xhtml", Precision: "section", Percentage: 0.52},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "structural but non-CFI value discarded",
|
||||||
|
loc: wsync.CanonicalLocator{CFI: "ch10.xhtml#h1", Precision: "structural", Percentage: 0.52},
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tc.expected, webUsableCFI(tc.loc))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+240
-3
@@ -152,6 +152,9 @@ type CreateMediaBookmarkRequest struct {
|
|||||||
ChapterNumber int32 `json:"chapter_number"`
|
ChapterNumber int32 `json:"chapter_number"`
|
||||||
Percentage float64 `json:"percentage"`
|
Percentage float64 `json:"percentage"`
|
||||||
ChapterReference int32 `json:"chapter_reference"`
|
ChapterReference int32 `json:"chapter_reference"`
|
||||||
|
// Origin labels the creating client for display ("android", "web").
|
||||||
|
// Optional: defaults to "web" for browser callers.
|
||||||
|
Origin string `json:"origin" validate:"omitempty,max=30"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
|
// UpdateMediaBookmarkRequest represents the request for updating a media bookmark
|
||||||
@@ -569,24 +572,33 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
|||||||
Summary: existingMedia.Summary,
|
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 {
|
if update.Updates.Title != nil {
|
||||||
updateParams.Title = *update.Updates.Title
|
updateParams.Title = *update.Updates.Title
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
|
||||||
}
|
}
|
||||||
if update.Updates.Author != nil {
|
if update.Updates.Author != nil {
|
||||||
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
|
||||||
}
|
}
|
||||||
if update.Updates.Genre != nil {
|
if update.Updates.Genre != nil {
|
||||||
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true}
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideGenre)
|
||||||
}
|
}
|
||||||
if update.Updates.Language != nil {
|
if update.Updates.Language != nil {
|
||||||
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true}
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideLanguage)
|
||||||
}
|
}
|
||||||
if len(update.Updates.Tags) > 0 {
|
if len(update.Updates.Tags) > 0 {
|
||||||
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
|
normalizedTags := utils.NormalizeTags(update.Updates.Tags)
|
||||||
updateParams.Tags = normalizedTags
|
updateParams.Tags = normalizedTags
|
||||||
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
|
tagsSearch := utils.NormalizeTagsSearch(update.Updates.Tags)
|
||||||
updateParams.TagsSearch = tagsSearch
|
updateParams.TagsSearch = tagsSearch
|
||||||
|
overrides = utils.MergeOverrides(overrides, utils.OverrideTags)
|
||||||
}
|
}
|
||||||
|
updateParams.MetadataOverrides = overrides
|
||||||
|
|
||||||
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
|
_, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -941,6 +953,37 @@ func (mh *MediaHandler) GetMediaReadingProgress(c *echo.Context) error {
|
|||||||
"last_sync_timestamp": progress.LastSyncTimestamp,
|
"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)
|
return c.JSON(http.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1237,6 +1280,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
coverPath := existing.CoverImagePath.String
|
coverPath := existing.CoverImagePath.String
|
||||||
|
coverUploaded := false
|
||||||
|
|
||||||
if req.CoverAction == "remove" {
|
if req.CoverAction == "remove" {
|
||||||
coverPath = ""
|
coverPath = ""
|
||||||
@@ -1248,6 +1292,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save cover image"})
|
||||||
}
|
}
|
||||||
coverPath = savedPath
|
coverPath = savedPath
|
||||||
|
coverUploaded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1256,7 +1301,7 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
alternateInfoBytes = []byte(req.AlternateInfo)
|
alternateInfoBytes = []byte(req.AlternateInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := mh.db.UpdateMediaItem(c.Request().Context(), database.UpdateMediaItemParams{
|
params := database.UpdateMediaItemParams{
|
||||||
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||||
@@ -1294,7 +1339,17 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
AlternateInfo: alternateInfoBytes,
|
AlternateInfo: alternateInfoBytes,
|
||||||
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
ScanInformation: pgtype.Text{String: req.ScanInformation, Valid: req.ScanInformation != ""},
|
||||||
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
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 {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
}
|
}
|
||||||
@@ -1306,6 +1361,168 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
|||||||
return c.JSON(http.StatusOK, item)
|
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)
|
// DeleteMediaItem handles DELETE /api/media-items/:id (admin only)
|
||||||
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
func (mh *MediaHandler) DeleteMediaItem(c *echo.Context) error {
|
||||||
user := MustGetAuthenticatedUser(c)
|
user := MustGetAuthenticatedUser(c)
|
||||||
@@ -1653,6 +1870,11 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
|
|||||||
ChapterReference: req.ChapterReference,
|
ChapterReference: req.ChapterReference,
|
||||||
Source: "web",
|
Source: "web",
|
||||||
ModifiedAt: time.Now(),
|
ModifiedAt: time.Now(),
|
||||||
|
// The PUT targets this exact row (from the URL): identity must
|
||||||
|
// not be re-derived from content — a device echo has usually
|
||||||
|
// rewritten the stored CFI to point shape, so the computed key
|
||||||
|
// would miss and mint a duplicate beside the edited row.
|
||||||
|
HighlightID: pgtype.UUID{Bytes: highlightUUID, Valid: true},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||||
@@ -1750,6 +1972,10 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
|
|||||||
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
|
// The sync-aware path (dedup + LWW + tombstones) is preferred; fall back
|
||||||
// to the plain query when the service isn't wired (e.g. some tests).
|
// to the plain query when the service isn't wired (e.g. some tests).
|
||||||
if mh.annotationSvc != nil {
|
if mh.annotationSvc != nil {
|
||||||
|
origin := req.Origin
|
||||||
|
if origin == "" {
|
||||||
|
origin = "web"
|
||||||
|
}
|
||||||
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
|
||||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||||
@@ -1762,6 +1988,7 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
|
|||||||
PercentageLoc: req.Percentage,
|
PercentageLoc: req.Percentage,
|
||||||
ChapterReference: req.ChapterReference,
|
ChapterReference: req.ChapterReference,
|
||||||
Source: "web",
|
Source: "web",
|
||||||
|
OriginSource: origin,
|
||||||
ModifiedAt: time.Now(),
|
ModifiedAt: time.Now(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -2175,7 +2402,17 @@ func (mh *MediaHandler) saveCoverImage(c echo.Context, mediaUUID uuid.UUID, file
|
|||||||
return "", fmt.Errorf("media item has no file path")
|
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)
|
coverFullPath, err := mh.libraryService.ResolveMediaPath(c.Request().Context(), mediaItem.LibraryID, coverRelPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import "bookhoard/internal/database"
|
|||||||
type MediaDetail struct {
|
type MediaDetail struct {
|
||||||
database.MediaItems // Embedded - ALL book fields available
|
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
|
// User-specific data
|
||||||
Rating *database.MediaRatings `json:"rating,omitempty"`
|
Rating *database.MediaRatings `json:"rating,omitempty"`
|
||||||
Collections []database.Collections `json:"collections"`
|
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.
|
// catalogMediaType is the OPDS media type for an acquisition catalog feed.
|
||||||
const catalogMediaType = "application/atom+xml;profile=opds-catalog;kind=acquisition"
|
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,
|
// addCatalogPaginationLinks adds OPDS pagination links (self, start, first,
|
||||||
// previous, next, last) and OpenSearch paging metadata (totalResults,
|
// previous, next, last) and OpenSearch paging metadata (totalResults,
|
||||||
// itemsPerPage, startIndex) to a feed based on the current page position.
|
// 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 {
|
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")
|
deviceID := c.Param("deviceId")
|
||||||
|
|
||||||
page := c.QueryParam("page")
|
page := c.QueryParam("page")
|
||||||
@@ -216,8 +320,6 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
userUUID := uuid.UUID(userID)
|
|
||||||
|
|
||||||
libraries, 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 {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
@@ -276,7 +378,23 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
feed.AddLink(searchURL, "application/opensearchdescription+xml", "search")
|
||||||
|
|
||||||
// Add entries
|
// 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()
|
bookUUID := uuid.UUID(item.ID.Bytes).String()
|
||||||
title := item.Title
|
title := item.Title
|
||||||
author := ""
|
author := ""
|
||||||
@@ -323,7 +441,7 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add collections as categories
|
// 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 {
|
if err == nil {
|
||||||
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
collectionScheme := fmt.Sprintf("%s/collections", baseURL)
|
||||||
for _, col := range collections {
|
for _, col := range collections {
|
||||||
@@ -335,6 +453,125 @@ func (h *OPDSHandler) GetDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
feed.AddEntry(entry)
|
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
|
// Generate XML
|
||||||
xmlString, err := feed.GenerateXMLString()
|
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"))
|
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)
|
return c.String(http.StatusOK, xmlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,20 +614,50 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
|
|
||||||
// Get user's visible libraries
|
// Get user's visible libraries
|
||||||
userID := device.UserID.Bytes
|
userID := device.UserID.Bytes
|
||||||
_, err = h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), pgtype.UUID{Bytes: userID, Valid: true})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
return c.XML(http.StatusInternalServerError, opds.NewErrorFeed("Failed to get libraries"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
token := h.getAuthToken(c)
|
||||||
|
|
||||||
// No query: serve the OpenSearch description document so clients can learn
|
// 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 == "" {
|
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(
|
desc := opds.NewSearchDescription(
|
||||||
"Bookhoard",
|
shortName,
|
||||||
"Search the Bookhoard library",
|
description,
|
||||||
searchURL,
|
searchURL,
|
||||||
)
|
)
|
||||||
xmlString, err := desc.GenerateXMLString()
|
xmlString, err := desc.GenerateXMLString()
|
||||||
@@ -404,6 +671,7 @@ func (h *OPDSHandler) SearchDeviceCatalog(c *echo.Context) error {
|
|||||||
// Search media items
|
// Search media items
|
||||||
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
allItems, err := h.db.SearchMediaItems(c.Request().Context(), database.SearchMediaItemsParams{
|
||||||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||||
|
LibraryID: scopedLibraryID,
|
||||||
SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true},
|
SearchPattern: pgtype.Text{String: "%" + query + "%", Valid: true},
|
||||||
Offset: pgtype.Int4{Int32: 0, Valid: true},
|
Offset: pgtype.Int4{Int32: 0, Valid: true},
|
||||||
Limit: pgtype.Int4{Int32: 1000, 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)
|
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)
|
downloadURL := appendToken(fmt.Sprintf("%s/devices/%s/download/%s", opdsBaseURL, deviceID, bookUUID), token)
|
||||||
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
entry.AddAcquisitionLink(downloadURL, resolveMimeType(item.MimeType, item.FormatMimetype))
|
||||||
|
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
|||||||
|
|
||||||
// Determine if panel detection should be enabled
|
// Determine if panel detection should be enabled
|
||||||
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
|
enablePanelDetection := shouldEnablePanelDetection(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||||
|
treatAsComic := ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup)
|
||||||
|
|
||||||
// Get reading progress
|
// Get reading progress
|
||||||
var progress database.ReadingProgress
|
var progress database.ReadingProgress
|
||||||
@@ -133,6 +134,7 @@ func (h *ReaderHandler) ShowReader(c *echo.Context) error {
|
|||||||
"progress": progress,
|
"progress": progress,
|
||||||
"bookmarks": bookmarks,
|
"bookmarks": bookmarks,
|
||||||
"enable_panel_detection": enablePanelDetection,
|
"enable_panel_detection": enablePanelDetection,
|
||||||
|
"treat_as_comic": treatAsComic,
|
||||||
"format_group": mediaItem.FormatGroup,
|
"format_group": mediaItem.FormatGroup,
|
||||||
"manga_type": mediaItem.MangaType,
|
"manga_type": mediaItem.MangaType,
|
||||||
"reading_direction": mediaItem.ReadingDirection,
|
"reading_direction": mediaItem.ReadingDirection,
|
||||||
@@ -639,6 +641,25 @@ func (h *ReaderHandler) ParseEbook(c *echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedFormats := map[string]bool{
|
||||||
|
"fixed_layout": true,
|
||||||
|
"comic_archive": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return comicLibraries[libraryType] && fixedFormats[formatGroup]
|
||||||
|
}
|
||||||
|
|
||||||
// shouldEnablePanelDetection determines if panel detection should be enabled
|
// shouldEnablePanelDetection determines if panel detection should be enabled
|
||||||
// based on library type and format group
|
// based on library type and format group
|
||||||
func shouldEnablePanelDetection(libraryType string, formatGroup string) bool {
|
func shouldEnablePanelDetection(libraryType string, formatGroup string) bool {
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"bookhoard/internal/config"
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
|
"bookhoard/internal/version"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@@ -293,6 +297,18 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
libraryID := libRes.LibraryID
|
libraryID := libRes.LibraryID
|
||||||
libData := libRes.Libraries
|
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
|
// Fetch saved filters for SSR
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
userUUID, _ := uuid.Parse(user.ID)
|
||||||
var savedFilters []database.SavedFilters
|
var savedFilters []database.SavedFilters
|
||||||
@@ -834,6 +850,59 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
return c.HTML(http.StatusOK, buf.String())
|
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 {
|
frontendProtected.GET("/admin/library", handlers.AdminMiddleware(func(c *echo.Context) error {
|
||||||
user, err := getTemplateUserWithTheme(c, cfg)
|
user, err := getTemplateUserWithTheme(c, cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -882,7 +951,8 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var buf bytes.Buffer
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1267,6 +1337,23 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true}
|
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
|
// Fetch rating
|
||||||
var rating *database.MediaRatings
|
var rating *database.MediaRatings
|
||||||
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{
|
||||||
@@ -1330,6 +1417,7 @@ func registerFrontendRoutes(cfg *Config) {
|
|||||||
// Assemble response (no field duplication!)
|
// Assemble response (no field duplication!)
|
||||||
detail := handlers.MediaDetail{
|
detail := handlers.MediaDetail{
|
||||||
MediaItems: mediaItem, // Embedded - ALL fields available
|
MediaItems: mediaItem, // Embedded - ALL fields available
|
||||||
|
FileLocation: fileLocation,
|
||||||
Rating: rating,
|
Rating: rating,
|
||||||
Collections: collections,
|
Collections: collections,
|
||||||
ReadingProgress: progress,
|
ReadingProgress: progress,
|
||||||
@@ -1368,6 +1456,7 @@ func (cfg *Config) GetHealth(c *echo.Context) error {
|
|||||||
|
|
||||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
|
"version": version.Version,
|
||||||
"database": "connected",
|
"database": "connected",
|
||||||
"scan": map[string]interface{}{
|
"scan": map[string]interface{}{
|
||||||
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
"scan_in_progress": cfg.Worker.HasActiveScans(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"bookhoard/internal/database"
|
"bookhoard/internal/database"
|
||||||
@@ -132,12 +133,30 @@ func resolveLibrary(c *echo.Context, cfg *Config, userUUID string) LibraryResolu
|
|||||||
res.Libraries = make([]templates.LibraryData, len(libraries))
|
res.Libraries = make([]templates.LibraryData, len(libraries))
|
||||||
for i, lib := range libraries {
|
for i, lib := range libraries {
|
||||||
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
|
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{
|
res.Libraries[i] = templates.LibraryData{
|
||||||
ID: libUUID.String(),
|
ID: libUUID.String(),
|
||||||
Name: lib.Name,
|
Name: lib.Name,
|
||||||
Description: getText(lib.Description),
|
Description: getText(lib.Description),
|
||||||
TypeName: lib.TypeName,
|
TypeName: lib.TypeName,
|
||||||
MediaCount: countMap[libUUID.String()],
|
MediaCount: countMap[libUUID.String()],
|
||||||
|
Offline: offline,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,11 @@ func registerMediaRoutes(cfg *Config) {
|
|||||||
// Admin-only media routes
|
// Admin-only media routes
|
||||||
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
admin.POST("/media-items", cfg.MediaHandler.CreateMediaItem)
|
||||||
admin.PUT("/media-items/:id", cfg.MediaHandler.UpdateMediaItem)
|
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.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)
|
// Shelf management (protected)
|
||||||
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
|
protected.POST("/devices/:id/shelves", cfg.MediaHandler.AddToShelf)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ func registerOPDSRoutes(cfg *Config) {
|
|||||||
opds := e.Group("/opds/devices")
|
opds := e.Group("/opds/devices")
|
||||||
opds.Use(cfg.DeviceAuthMiddleware.Authenticate)
|
opds.Use(cfg.DeviceAuthMiddleware.Authenticate)
|
||||||
opds.GET("/:deviceId/catalog", cfg.OPDSHandler.GetDeviceCatalog)
|
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/search", cfg.OPDSHandler.SearchDeviceCatalog)
|
||||||
opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation)
|
opds.GET("/:deviceId/nav", cfg.OPDSHandler.GetDeviceNavigation)
|
||||||
opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook)
|
opds.GET("/:deviceId/download/:bookId", cfg.OPDSHandler.DownloadBook)
|
||||||
|
|||||||
+13
-68
@@ -1,18 +1,15 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bookhoard/internal/database"
|
|
||||||
"bookhoard/internal/handlers"
|
"bookhoard/internal/handlers"
|
||||||
"bookhoard/internal/services"
|
"bookhoard/internal/services"
|
||||||
"bookhoard/internal/sync"
|
"bookhoard/internal/sync"
|
||||||
"bookhoard/internal/utils"
|
"bookhoard/internal/utils"
|
||||||
"bookhoard/templates"
|
"bookhoard/templates"
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/labstack/echo/v5"
|
"github.com/labstack/echo/v5"
|
||||||
)
|
)
|
||||||
@@ -69,21 +66,17 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
if !visible {
|
if !visible {
|
||||||
return renderErrorPage(c, "Access denied", "access_denied")
|
return renderErrorPage(c, "Access denied", "access_denied")
|
||||||
}
|
}
|
||||||
// Get reading progress
|
// Library type decides comic reader treatment: fixed-layout items
|
||||||
var progress database.ReadingProgress
|
// (e.g. manga PDFs) shelved in comics/manga libraries are paged
|
||||||
progress, err = cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{
|
// through like comics — bookmarks only, no highlights or search.
|
||||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
libraryWithType, err := cfg.Queries.GetLibraryWithType(c.Request().Context(), mediaItem.LibraryID)
|
||||||
UserID: uuidToPGType(userUUID),
|
if err != nil {
|
||||||
})
|
return renderErrorPage(c, "Failed to get library info", "library_error")
|
||||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
progress = database.ReadingProgress{}
|
|
||||||
}
|
}
|
||||||
// Get bookmarks
|
// Convert to template types. Reading state is deliberately NOT
|
||||||
bookmarks, _ := cfg.Queries.GetMediaBookmarks(c.Request().Context(), database.GetMediaBookmarksParams{
|
// fetched or embedded: the reader pulls position, bookmarks, and
|
||||||
MediaItemID: pgtype.UUID{Bytes: parsedUUID, Valid: true},
|
// annotations from the APIs at open time so the page can never
|
||||||
UserID: uuidToPGType(userUUID),
|
// carry (nor write back) a stale snapshot.
|
||||||
})
|
|
||||||
// Convert to template types
|
|
||||||
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
mediaUUID, _ := uuid.FromBytes(mediaItem.ID.Bytes[0:16])
|
||||||
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16])
|
libUUID, _ := uuid.FromBytes(mediaItem.LibraryID.Bytes[0:16])
|
||||||
metadata := templates.ReaderMetadata{
|
metadata := templates.ReaderMetadata{
|
||||||
@@ -103,59 +96,11 @@ func registerReaderRoutes(cfg *Config) {
|
|||||||
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
|
FileURL: utils.ResolveMediaURL(mediaItem.LibraryID, pgtype.Text{String: mediaItem.FilePath, Valid: true}),
|
||||||
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
TotalCharacters: mediaItem.TotalCharacters.Int64,
|
||||||
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
EstimatedPages: sync.EstimatedPages(mediaItem.TotalCharacters.Int64),
|
||||||
|
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||||
}
|
}
|
||||||
// Progress conversion (inline)
|
// Render template
|
||||||
progressUUID, _ := uuid.FromBytes(progress.ID.Bytes[0:16])
|
|
||||||
progressMediaUUID, _ := uuid.FromBytes(progress.MediaItemID.Bytes[0:16])
|
|
||||||
progressUserUUID, _ := uuid.FromBytes(progress.UserID.Bytes[0:16])
|
|
||||||
templateProgress := templates.ReadingProgress{
|
|
||||||
ID: progressUUID.String(),
|
|
||||||
MediaItemID: progressMediaUUID.String(),
|
|
||||||
UserID: progressUserUUID.String(),
|
|
||||||
CurrentPage: int(progress.CurrentPage.Int32),
|
|
||||||
TotalPages: int(progress.TotalPages.Int32),
|
|
||||||
Percentage: progress.Percentage.Float64 * 100,
|
|
||||||
EpubCfi: textToString(progress.Epubcfi),
|
|
||||||
LastReadAt: progress.LastReadAt.Time,
|
|
||||||
Chapter: int(progress.Chapter.Int32),
|
|
||||||
ChapterProgress: progress.ChapterProgress.Float64 * 100,
|
|
||||||
FormatGroup: mediaItem.FormatGroup,
|
|
||||||
}
|
|
||||||
// Bookmarks conversion (inline, with loop)
|
|
||||||
templateBookmarks := make([]templates.Bookmark, len(bookmarks))
|
|
||||||
for i, b := range bookmarks {
|
|
||||||
bookmarkUUID, _ := uuid.FromBytes(b.ID.Bytes[0:16])
|
|
||||||
bookmarkMediaUUID, _ := uuid.FromBytes(b.MediaItemID.Bytes[0:16])
|
|
||||||
bookmarkUserUUID, _ := uuid.FromBytes(b.UserID.Bytes[0:16])
|
|
||||||
|
|
||||||
var pageNumber *int
|
|
||||||
if b.PageNumber.Valid {
|
|
||||||
val := int(b.PageNumber.Int32)
|
|
||||||
pageNumber = &val
|
|
||||||
}
|
|
||||||
|
|
||||||
var chapterNumber *int
|
|
||||||
if b.ChapterNumber.Valid {
|
|
||||||
val := int(b.ChapterNumber.Int32)
|
|
||||||
chapterNumber = &val
|
|
||||||
}
|
|
||||||
|
|
||||||
templateBookmarks[i] = templates.Bookmark{
|
|
||||||
ID: bookmarkUUID.String(),
|
|
||||||
MediaItemID: bookmarkMediaUUID.String(),
|
|
||||||
UserID: bookmarkUserUUID.String(),
|
|
||||||
PageNumber: pageNumber,
|
|
||||||
ChapterNumber: chapterNumber,
|
|
||||||
CfiPosition: textToString(b.CfiPosition),
|
|
||||||
Title: b.Title,
|
|
||||||
Position: textToString(b.Position),
|
|
||||||
Notes: textToString(b.Notes),
|
|
||||||
CreatedAt: b.CreatedAt.Time,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 8. Render template
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Reader(user, metadata, templateProgress, templateBookmarks).Render(c.Request().Context(), &buf)
|
err = templates.Reader(user, metadata).Render(c.Request().Context(), &buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return renderErrorPage(c, "Error rendering reader", "render_error")
|
return renderErrorPage(c, "Error rendering reader", "render_error")
|
||||||
}
|
}
|
||||||
|
|||||||
+839
-227
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+104
-11
@@ -67,6 +67,13 @@ type SaveHighlightRequest struct {
|
|||||||
EndPosition string
|
EndPosition string
|
||||||
Color string
|
Color string
|
||||||
NoteText string
|
NoteText string
|
||||||
|
// HighlightID, when valid, targets that exact row (web PUTs edit by
|
||||||
|
// id): the save LWWs against it directly under its stored dedup key.
|
||||||
|
// The computed key depends on fields that legitimately change — the
|
||||||
|
// stored CFI drifts range→point shape after device echoes, and the
|
||||||
|
// user can edit the selection text — so a key-based upsert would mint
|
||||||
|
// a duplicate beside the very row being edited.
|
||||||
|
HighlightID pgtype.UUID
|
||||||
PercentageStart float64
|
PercentageStart float64
|
||||||
PercentageEnd float64
|
PercentageEnd float64
|
||||||
EpubcfiStart string
|
EpubcfiStart string
|
||||||
@@ -90,6 +97,35 @@ type SaveHighlightResult struct {
|
|||||||
|
|
||||||
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
func (s *AnnotationService) SaveHighlight(ctx context.Context, req SaveHighlightRequest) (*SaveHighlightResult, error) {
|
||||||
dedupKey := req.DedupKey
|
dedupKey := req.DedupKey
|
||||||
|
|
||||||
|
// Web edits arrive with the row id from the URL: resolve by id first
|
||||||
|
// and LWW against that row under its stored key. Content-derived keys
|
||||||
|
// are for lookups by identity (device pushes carry no row ids); an
|
||||||
|
// edit must never re-derive identity from (possibly edited) content.
|
||||||
|
if req.HighlightID.Valid {
|
||||||
|
byID, idErr := s.db.GetMediaHighlight(ctx, req.HighlightID)
|
||||||
|
if idErr != nil && !errors.Is(idErr, pgx.ErrNoRows) {
|
||||||
|
return nil, fmt.Errorf("query highlight by id: %w", idErr)
|
||||||
|
}
|
||||||
|
if idErr == nil {
|
||||||
|
if byID.UserID != req.UserID || byID.MediaItemID != req.MediaItemID {
|
||||||
|
return nil, fmt.Errorf("highlight %s belongs to another user or media item", req.HighlightID)
|
||||||
|
}
|
||||||
|
if dedupKey == "" {
|
||||||
|
dedupKey = byID.DedupKey.String
|
||||||
|
}
|
||||||
|
if byID.Deleted.Bool {
|
||||||
|
if !incomingNewerThanTombstone(req.ModifiedAt, byID.DeletedAt, byID.LastModifiedAt) {
|
||||||
|
return &SaveHighlightResult{Highlight: byID, Outcome: SaveOutcomeDeleted}, nil
|
||||||
|
}
|
||||||
|
// Newer than the tombstone: a deliberate re-create. Resurrect
|
||||||
|
// via the LWW update (which clears deleted/deleted_at).
|
||||||
|
}
|
||||||
|
return s.applyLWW(ctx, req, byID, dedupKey)
|
||||||
|
}
|
||||||
|
// No row with that id: fall through to identity-based resolution.
|
||||||
|
}
|
||||||
|
|
||||||
if dedupKey == "" {
|
if dedupKey == "" {
|
||||||
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
dedupKey = ComputeDedupKey(req.SelectionText, req.EpubcfiStart, req.StartPosition)
|
||||||
}
|
}
|
||||||
@@ -186,17 +222,38 @@ func (s *AnnotationService) applyLWW(
|
|||||||
|
|
||||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||||
|
|
||||||
|
// Web edits carry no device locators (the web reader never had a CRE
|
||||||
|
// xpointer) and may carry no CFI either: keep the stored ones so
|
||||||
|
// device-native serve-back and round-trip identity survive a web-side
|
||||||
|
// note/color edit instead of being wiped to empty.
|
||||||
|
startPosition := req.StartPosition
|
||||||
|
if startPosition == "" {
|
||||||
|
startPosition = existing.StartPosition.String
|
||||||
|
}
|
||||||
|
endPosition := req.EndPosition
|
||||||
|
if endPosition == "" {
|
||||||
|
endPosition = existing.EndPosition.String
|
||||||
|
}
|
||||||
|
epubcfiStart := req.EpubcfiStart
|
||||||
|
if epubcfiStart == "" {
|
||||||
|
epubcfiStart = existing.EpubcfiStart.String
|
||||||
|
}
|
||||||
|
epubcfiEnd := req.EpubcfiEnd
|
||||||
|
if epubcfiEnd == "" {
|
||||||
|
epubcfiEnd = existing.EpubcfiEnd.String
|
||||||
|
}
|
||||||
|
|
||||||
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
highlight, err := s.db.UpdateMediaHighlightForSync(ctx, database.UpdateMediaHighlightForSyncParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
SelectionText: req.SelectionText,
|
SelectionText: req.SelectionText,
|
||||||
StartPosition: pgText(req.StartPosition),
|
StartPosition: pgText(startPosition),
|
||||||
EndPosition: pgText(req.EndPosition),
|
EndPosition: pgText(endPosition),
|
||||||
Color: pgText(req.Color),
|
Color: pgText(req.Color),
|
||||||
NoteText: pgText(req.NoteText),
|
NoteText: pgText(req.NoteText),
|
||||||
PercentageStart: pgFloat8(req.PercentageStart),
|
PercentageStart: pgFloat8(req.PercentageStart),
|
||||||
PercentageEnd: pgFloat8(req.PercentageEnd),
|
PercentageEnd: pgFloat8(req.PercentageEnd),
|
||||||
EpubcfiStart: pgText(req.EpubcfiStart),
|
EpubcfiStart: pgText(epubcfiStart),
|
||||||
EpubcfiEnd: pgText(req.EpubcfiEnd),
|
EpubcfiEnd: pgText(epubcfiEnd),
|
||||||
ChapterReference: pgInt4(req.ChapterReference),
|
ChapterReference: pgInt4(req.ChapterReference),
|
||||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||||
@@ -222,7 +279,9 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
|
|||||||
textEq(req.Color, existing.Color) &&
|
textEq(req.Color, existing.Color) &&
|
||||||
textEq(req.NoteText, existing.NoteText) &&
|
textEq(req.NoteText, existing.NoteText) &&
|
||||||
floatEq(req.PercentageStart, existing.PercentageStart) &&
|
floatEq(req.PercentageStart, existing.PercentageStart) &&
|
||||||
floatEq(req.PercentageEnd, existing.PercentageEnd)
|
floatEq(req.PercentageEnd, existing.PercentageEnd) &&
|
||||||
|
locatorRefresher(req.EpubcfiStart, existing.EpubcfiStart) &&
|
||||||
|
locatorRefresher(req.EpubcfiEnd, existing.EpubcfiEnd)
|
||||||
return !contentSame, !contentSame
|
return !contentSame, !contentSame
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +292,22 @@ func (s *AnnotationService) compareIncoming(req SaveHighlightRequest, existing d
|
|||||||
return req.ModifiedAt.After(existingMod.Time), true
|
return req.ModifiedAt.After(existingMod.Time), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// locatorRefresher reports whether an incoming locator leaves the stored
|
||||||
|
// one untouched: either the push carried none (empty coalesces to the
|
||||||
|
// stored value in applyLWW) or it matches what is stored. A non-empty
|
||||||
|
// incoming locator that DIFFERS is a deliberate refresh: device echoes
|
||||||
|
// re-derive their canonical CFIs on every push, and an improvement (a
|
||||||
|
// converter fix re-landing a corrupted anchor, drift repair) must reach
|
||||||
|
// the row even when the annotation content is otherwise identical —
|
||||||
|
// without this, content-equal echoes resolve to "skip" and a bad stored
|
||||||
|
// locator can never heal.
|
||||||
|
func locatorRefresher(incoming string, existing pgtype.Text) bool {
|
||||||
|
if incoming == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return textEq(incoming, existing)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AnnotationService) TombstoneHighlight(
|
func (s *AnnotationService) TombstoneHighlight(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
userID, mediaItemID pgtype.UUID,
|
userID, mediaItemID pgtype.UUID,
|
||||||
@@ -590,6 +665,9 @@ type SaveBookmarkRequest struct {
|
|||||||
Source string
|
Source string
|
||||||
ModifiedAt time.Time
|
ModifiedAt time.Time
|
||||||
DeviceSyncData json.RawMessage
|
DeviceSyncData json.RawMessage
|
||||||
|
// OriginSource records the client that created the bookmark (set once
|
||||||
|
// at insert; unlike Source it is not updated by later writers).
|
||||||
|
OriginSource string
|
||||||
// DedupKey overrides the computed key for device echoes (see
|
// DedupKey overrides the computed key for device echoes (see
|
||||||
// SaveHighlightRequest).
|
// SaveHighlightRequest).
|
||||||
DedupKey string
|
DedupKey string
|
||||||
@@ -624,9 +702,9 @@ func (s *AnnotationService) SaveBookmark(ctx context.Context, req SaveBookmarkRe
|
|||||||
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
|
if !incomingNewerThanTombstone(req.ModifiedAt, existing.DeletedAt, existing.LastModifiedAt) {
|
||||||
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
return &SaveBookmarkResult{Bookmark: existing, Outcome: SaveOutcomeDeleted}, nil
|
||||||
}
|
}
|
||||||
// Newer than the tombstone: a deliberate re-create. Resurrect via the
|
// Newer than the tombstone: a deliberate re-create at the same
|
||||||
// LWW update instead of INSERT (the tombstoned row still holds the
|
// location. Resurrect via the LWW update so the row keeps its id
|
||||||
// UNIQUE(media_item_id, user_id, title) slot).
|
// and origin.
|
||||||
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
|
return s.applyBookmarkLWW(ctx, req, existing, dedupKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +734,7 @@ func (s *AnnotationService) createBookmark(ctx context.Context, req SaveBookmark
|
|||||||
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
LastModifiedAt: pgtype.Timestamptz{Time: modifiedAt, Valid: true},
|
||||||
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
LastModifiedSource: pgtype.Text{String: req.Source, Valid: req.Source != ""},
|
||||||
DeviceSyncData: deviceData,
|
DeviceSyncData: deviceData,
|
||||||
|
OriginSource: pgText(req.OriginSource),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create bookmark: %w", err)
|
return nil, fmt.Errorf("create bookmark: %w", err)
|
||||||
@@ -683,13 +762,25 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
|
|||||||
}
|
}
|
||||||
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
deviceData := mergeDeviceSyncData(existing.DeviceSyncData, req.Source, req.DeviceSyncData)
|
||||||
|
|
||||||
|
// Web saves carry no device locators: keep the stored ones so a web
|
||||||
|
// title/note edit never wipes the device-native position (mirrors the
|
||||||
|
// highlight path's coalescing).
|
||||||
|
cfiPosition := req.CFIPosition
|
||||||
|
if cfiPosition == "" {
|
||||||
|
cfiPosition = existing.CfiPosition.String
|
||||||
|
}
|
||||||
|
position := req.Position
|
||||||
|
if position == "" {
|
||||||
|
position = existing.Position.String
|
||||||
|
}
|
||||||
|
|
||||||
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
bm, err := s.db.UpdateMediaBookmarkForSync(ctx, database.UpdateMediaBookmarkForSyncParams{
|
||||||
ID: existing.ID,
|
ID: existing.ID,
|
||||||
PageNumber: pgInt4(req.PageNumber),
|
PageNumber: pgInt4(req.PageNumber),
|
||||||
ChapterNumber: pgInt4(req.ChapterNumber),
|
ChapterNumber: pgInt4(req.ChapterNumber),
|
||||||
CfiPosition: pgText(req.CFIPosition),
|
CfiPosition: pgText(cfiPosition),
|
||||||
Title: req.Title,
|
Title: req.Title,
|
||||||
Position: pgText(req.Position),
|
Position: pgText(position),
|
||||||
Notes: pgText(req.Notes),
|
Notes: pgText(req.Notes),
|
||||||
PercentageLocation: pgFloat8(req.PercentageLoc),
|
PercentageLocation: pgFloat8(req.PercentageLoc),
|
||||||
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
EpubcfiLocation: pgText(req.EpubcfiLocation),
|
||||||
@@ -714,7 +805,9 @@ func (s *AnnotationService) applyBookmarkLWW(ctx context.Context, req SaveBookma
|
|||||||
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
|
func (s *AnnotationService) compareIncomingBookmark(req SaveBookmarkRequest, existing database.MediaBookmarks) (incomingNewer bool, contentChanged bool) {
|
||||||
if req.ModifiedAt.IsZero() {
|
if req.ModifiedAt.IsZero() {
|
||||||
contentSame := strings.EqualFold(req.Title, existing.Title) &&
|
contentSame := strings.EqualFold(req.Title, existing.Title) &&
|
||||||
textEq(req.Notes, existing.Notes)
|
textEq(req.Notes, existing.Notes) &&
|
||||||
|
locatorRefresher(req.CFIPosition, existing.CfiPosition) &&
|
||||||
|
locatorRefresher(req.Position, existing.Position)
|
||||||
return !contentSame, !contentSame
|
return !contentSame, !contentSame
|
||||||
}
|
}
|
||||||
existingMod := existing.LastModifiedAt
|
existingMod := existing.LastModifiedAt
|
||||||
|
|||||||
@@ -369,3 +369,82 @@ func TestIncomingNewerThanTombstone(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Content-equal device echoes must still count as changed when they carry
|
||||||
|
// a locator that differs from the stored one: echoes re-derive canonical
|
||||||
|
// CFIs on every push, and a better conversion (or a repair of a corrupted
|
||||||
|
// anchor) has to reach the row — otherwise the skip path discards it and
|
||||||
|
// the bad locator can never heal.
|
||||||
|
func TestCompareIncomingLocatorDriftRefreshes(t *testing.T) {
|
||||||
|
svc := &AnnotationService{}
|
||||||
|
existing := database.MediaHighlights{
|
||||||
|
SelectionText: "CHAPTER IV. “What a pity it is, Elinor,”",
|
||||||
|
Color: pgtype.Text{String: "#90caf9", Valid: true},
|
||||||
|
EpubcfiStart: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/32/3:576)", Valid: true},
|
||||||
|
EpubcfiEnd: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/4/1:93)", Valid: true},
|
||||||
|
}
|
||||||
|
base := SaveHighlightRequest{
|
||||||
|
SelectionText: existing.SelectionText,
|
||||||
|
Color: "#90caf9",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("identical content and locators skip", func(t *testing.T) {
|
||||||
|
req := base
|
||||||
|
req.EpubcfiStart = existing.EpubcfiStart.String
|
||||||
|
req.EpubcfiEnd = existing.EpubcfiEnd.String
|
||||||
|
_, changed := svc.compareIncoming(req, existing)
|
||||||
|
if changed {
|
||||||
|
t.Error("identical echo must not rewrite the row")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("better start locator refreshes despite identical content", func(t *testing.T) {
|
||||||
|
req := base
|
||||||
|
req.EpubcfiStart = "epubcfi(/6/12!/4/2[x]/2/3:0)"
|
||||||
|
req.EpubcfiEnd = existing.EpubcfiEnd.String
|
||||||
|
_, changed := svc.compareIncoming(req, existing)
|
||||||
|
if !changed {
|
||||||
|
t.Error("locator drift on a content-equal echo must trigger an update")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty incoming locators leave the row alone", func(t *testing.T) {
|
||||||
|
req := base // no CFIs: applyLWW coalesces empty to stored
|
||||||
|
_, changed := svc.compareIncoming(req, existing)
|
||||||
|
if changed {
|
||||||
|
t.Error("empty locators coalesce; must not count as drift")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmarks share the echo-refresh rule for their locators.
|
||||||
|
func TestCompareIncomingBookmarkLocatorDrift(t *testing.T) {
|
||||||
|
svc := &AnnotationService{}
|
||||||
|
existing := database.MediaBookmarks{
|
||||||
|
Title: "in CHAPTER IV.",
|
||||||
|
CfiPosition: pgtype.Text{String: "epubcfi(/6/12!/4/2[x]/2/3:0)", Valid: true},
|
||||||
|
Position: pgtype.Text{String: "/body/DocFragment[6]/body/div[1]/h2[1]/text().0", Valid: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("same locators skip", func(t *testing.T) {
|
||||||
|
req := SaveBookmarkRequest{
|
||||||
|
Title: existing.Title,
|
||||||
|
CFIPosition: existing.CfiPosition.String,
|
||||||
|
Position: existing.Position.String,
|
||||||
|
}
|
||||||
|
if _, changed := svc.compareIncomingBookmark(req, existing); changed {
|
||||||
|
t.Error("identical bookmark echo must not rewrite the row")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("new CFI refreshes despite same title", func(t *testing.T) {
|
||||||
|
req := SaveBookmarkRequest{
|
||||||
|
Title: existing.Title,
|
||||||
|
CFIPosition: "epubcfi(/6/12!/4/2[x]/2/3:5)",
|
||||||
|
Position: existing.Position.String,
|
||||||
|
}
|
||||||
|
if _, changed := svc.compareIncomingBookmark(req, existing); !changed {
|
||||||
|
t.Error("bookmark locator drift must trigger an update")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+266
-46
@@ -12,6 +12,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"unicode"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"golang.org/x/net/html"
|
"golang.org/x/net/html"
|
||||||
@@ -246,6 +247,11 @@ func parseElementPart(part string) (string, int) {
|
|||||||
|
|
||||||
type ConversionResult struct {
|
type ConversionResult struct {
|
||||||
EPUBCFI string
|
EPUBCFI string
|
||||||
|
// EndEPUBCFI is set when the conversion matched a context that is a
|
||||||
|
// quote of the document (text search): the range end anchor of the
|
||||||
|
// quoted text, valid across block boundaries. Selections use it as
|
||||||
|
// the highlight end; point positions ignore it.
|
||||||
|
EndEPUBCFI string
|
||||||
Href string
|
Href string
|
||||||
Percentage float64
|
Percentage float64
|
||||||
Precision string
|
Precision string
|
||||||
@@ -425,7 +431,9 @@ func firstTextDescendant(n *html.Node) *html.Node {
|
|||||||
// textNodeAtRuneOffset walks text nodes under elem in document order and
|
// textNodeAtRuneOffset walks text nodes under elem in document order and
|
||||||
// returns the node containing the rune offset plus the local offset within
|
// returns the node containing the rune offset plus the local offset within
|
||||||
// that node. Offsets beyond the end clamp to the last node.
|
// 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 {
|
if offset < 0 {
|
||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
@@ -435,15 +443,15 @@ func textNodeAtRuneOffset(elem *html.Node, offset int) (*html.Node, int) {
|
|||||||
var walk func(*html.Node) bool
|
var walk func(*html.Node) bool
|
||||||
walk = func(node *html.Node) bool {
|
walk = func(node *html.Node) bool {
|
||||||
if node.Type == html.TextNode {
|
if node.Type == html.TextNode {
|
||||||
length := utf8.RuneCountInString(node.Data)
|
length := utf16Len(node.Data)
|
||||||
if remaining < length {
|
if remaining < length {
|
||||||
target = node
|
target = node
|
||||||
local = remaining
|
local = utf16ToRuneIndex(node.Data, remaining)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
remaining -= length
|
remaining -= length
|
||||||
target = node
|
target = node
|
||||||
local = length
|
local = utf8.RuneCountInString(node.Data)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
for child := node.FirstChild; child != nil; child = child.NextSibling {
|
||||||
@@ -501,7 +509,7 @@ func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer,
|
|||||||
var textNode *html.Node
|
var textNode *html.Node
|
||||||
var localOffset int
|
var localOffset int
|
||||||
if xp.CharOffset > 0 {
|
if xp.CharOffset > 0 {
|
||||||
textNode, localOffset = textNodeAtRuneOffset(elem, xp.CharOffset)
|
textNode, localOffset = textNodeAtUTF16Offset(elem, xp.CharOffset)
|
||||||
} else {
|
} else {
|
||||||
textNode = firstTextDescendant(elem)
|
textNode = firstTextDescendant(elem)
|
||||||
localOffset = 0
|
localOffset = 0
|
||||||
@@ -510,25 +518,24 @@ func (c *CFIConverter) convertByStructuralPath(body *html.Node, xp *CREXPointer,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text is the final verification: when the device sent usable words and
|
// Text is the final verification, in quote form: a usable context must
|
||||||
// they disagree with this structural landing, reject it and let text
|
// be a prefix of the document as read forward from this landing point.
|
||||||
// search / percentage decide rather than storing a confident-but-wrong CFI.
|
// Device captures are exactly that shape — a position's context is the
|
||||||
|
// text from the position onward (the plugin's walk-up may concatenate
|
||||||
|
// the heading with the paragraphs below), and a selection is the
|
||||||
|
// document text between its two anchors. The single-block containment
|
||||||
|
// checks stay as secondary acceptance for reverse shapes.
|
||||||
if usable, normalized := usableContextText(contextText); usable {
|
if usable, normalized := usableContextText(contextText); usable {
|
||||||
|
doc := documentTextFrom(body, textNode, localOffset, utf8.RuneCountInString(normalized)+64)
|
||||||
flat := blockFlattenedText(textNode)
|
flat := blockFlattenedText(textNode)
|
||||||
if flat != "" && !strings.Contains(flat, normalized) && !strings.Contains(normalized, flat) {
|
if !(strings.HasPrefix(doc, normalized) ||
|
||||||
// Compare a prefix too: device sends ~100 chars from the reader
|
strings.Contains(flat, normalized) ||
|
||||||
// position while the block may be longer.
|
strings.Contains(normalized, flat)) {
|
||||||
prefix := normalized
|
log.Printf("Bookhoard: structural landing disagrees with context in %s (reads %q vs ctx %q)",
|
||||||
if utf8.RuneCountInString(prefix) > 40 {
|
href, truncateForLog(doc, 80), truncateForLog(normalized, 80))
|
||||||
runes := []rune(prefix)
|
|
||||||
prefix = string(runes[:40])
|
|
||||||
}
|
|
||||||
if !strings.Contains(flat, prefix) {
|
|
||||||
log.Printf("Bookhoard: structural landing disagrees with context in %s (block %q vs ctx %q)", href, truncateForLog(flat, 80), truncateForLog(normalized, 80))
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
spineIndex := xp.FragmentIndex - 1
|
spineIndex := xp.FragmentIndex - 1
|
||||||
cfi, err := buildCFI(spineIndex, textNode, localOffset)
|
cfi, err := buildCFI(spineIndex, textNode, localOffset)
|
||||||
@@ -592,28 +599,149 @@ func (c *CFIConverter) convertFragmentID(s string, storedPercentage float64) (*C
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// docRunePos records which text node (and rune offset within it) produced
|
||||||
|
// each rune of the flattened document text.
|
||||||
|
type docRunePos struct {
|
||||||
|
node *html.Node
|
||||||
|
off int
|
||||||
|
}
|
||||||
|
|
||||||
|
// flattenDocumentForSearch renders the document's text as a
|
||||||
|
// whitespace-normalized rune stream in document order, crossing block
|
||||||
|
// boundaries: a single space separates blocks, while inline spans join
|
||||||
|
// directly (drop-cap splits like <span>C</span>onvergence read as one
|
||||||
|
// word). Every emitted rune carries its source (node, rune offset) so
|
||||||
|
// matches map back to CFIs.
|
||||||
|
func flattenDocumentForSearch(body *html.Node) ([]rune, []docRunePos) {
|
||||||
|
var runes []rune
|
||||||
|
var poss []docRunePos
|
||||||
|
pendingSpace := false
|
||||||
|
var lastBlock *html.Node
|
||||||
|
|
||||||
|
appendNode := func(n *html.Node) {
|
||||||
|
block := findBlockParent(n)
|
||||||
|
if lastBlock != nil && block != lastBlock {
|
||||||
|
pendingSpace = true
|
||||||
|
}
|
||||||
|
lastBlock = block
|
||||||
|
off := 0
|
||||||
|
for _, r := range n.Data {
|
||||||
|
if unicode.IsSpace(r) {
|
||||||
|
pendingSpace = true
|
||||||
|
} else {
|
||||||
|
if pendingSpace && len(runes) > 0 {
|
||||||
|
runes = append(runes, ' ')
|
||||||
|
poss = append(poss, docRunePos{node: n, off: off})
|
||||||
|
}
|
||||||
|
pendingSpace = false
|
||||||
|
runes = append(runes, r)
|
||||||
|
poss = append(poss, docRunePos{node: n, off: off})
|
||||||
|
}
|
||||||
|
off++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var walk func(n *html.Node)
|
||||||
|
walk = func(n *html.Node) {
|
||||||
|
if n.Type == html.TextNode {
|
||||||
|
if strings.TrimSpace(n.Data) != "" {
|
||||||
|
appendNode(n)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||||
|
walk(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(body)
|
||||||
|
return runes, poss
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentTextFrom reads the whitespace-normalized document text starting
|
||||||
|
// at rune `offset` in `start` (typically a structural landing point),
|
||||||
|
// crossing block boundaries — the document "as read" from that position.
|
||||||
|
// Capped at maxRunes.
|
||||||
|
func documentTextFrom(body *html.Node, start *html.Node, offset, maxRunes int) string {
|
||||||
|
runes, poss := flattenDocumentForSearch(body)
|
||||||
|
begin := -1
|
||||||
|
for i := range poss {
|
||||||
|
if poss[i].node == start && poss[i].off >= offset {
|
||||||
|
begin = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if begin < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// The separator space before a block's first content rune shares its
|
||||||
|
// (node, offset) — skip it so the read starts on real text.
|
||||||
|
if runes[begin] == ' ' && begin+1 < len(runes) {
|
||||||
|
begin++
|
||||||
|
}
|
||||||
|
end := len(runes)
|
||||||
|
if begin+maxRunes < end {
|
||||||
|
end = begin + maxRunes
|
||||||
|
}
|
||||||
|
return string(runes[begin:end])
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
|
func (c *CFIConverter) convertByTextSearch(body *html.Node, xp *CREXPointer, spine *spineCache, href string, storedPercentage float64, contextText string) *ConversionResult {
|
||||||
normalizedCtx := normalizeWhitespace(contextText)
|
normalizedCtx := normalizeWhitespace(contextText)
|
||||||
if normalizedCtx == "" {
|
if normalizedCtx == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
match, matchOffset := findTextInNode(body, normalizedCtx)
|
// Match against the whole document flattened in reading order — the
|
||||||
if match == nil {
|
// context may span block boundaries (a selection covering several
|
||||||
log.Printf("Bookhoard: text search no match for %q in %s", normalizedCtx, href)
|
// paragraphs, a walk-up capture joining a heading with what follows).
|
||||||
|
runes, poss := flattenDocumentForSearch(body)
|
||||||
|
text := string(runes)
|
||||||
|
|
||||||
|
words := strings.Fields(normalizedCtx)
|
||||||
|
quoted := make([]string, len(words))
|
||||||
|
for i, w := range words {
|
||||||
|
quoted[i] = regexp.QuoteMeta(w)
|
||||||
|
}
|
||||||
|
pattern := strings.Join(quoted, `\s+`)
|
||||||
|
re, err := regexp.Compile(pattern)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
loc := re.FindStringIndex(text)
|
||||||
|
if loc == nil {
|
||||||
|
log.Printf("Bookhoard: text search no match for %q in %s", truncateForLog(normalizedCtx, 60), href)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startRune := utf8.RuneCountInString(text[:loc[0]])
|
||||||
|
endRune := utf8.RuneCountInString(text[:loc[1]]) // exclusive
|
||||||
spineIndex := xp.FragmentIndex - 1
|
spineIndex := xp.FragmentIndex - 1
|
||||||
cfi, err := buildCFI(spineIndex, match, matchOffset)
|
|
||||||
if err != nil || cfi == "" {
|
startPos := poss[startRune]
|
||||||
|
startCFI, err := buildCFI(spineIndex, startPos.node, startPos.off)
|
||||||
|
if err != nil || startCFI == "" {
|
||||||
log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
|
log.Printf("Bookhoard: text search found match but buildCFI failed: %v", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", normalizedCtx, cfi)
|
// The matched context is a quote of the document: its extent gives
|
||||||
|
// selections a true range end, across block boundaries.
|
||||||
|
endCFI := ""
|
||||||
|
if endRune > startRune && endRune <= len(poss) {
|
||||||
|
endPos := poss[endRune-1]
|
||||||
|
endOff := endPos.off + 1
|
||||||
|
if nRunes := utf8.RuneCountInString(endPos.node.Data); endOff > nRunes {
|
||||||
|
endOff = nRunes
|
||||||
|
}
|
||||||
|
if ec, err := buildCFI(spineIndex, endPos.node, endOff); err == nil && ec != "" {
|
||||||
|
endCFI = ec
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Bookhoard: text search matched %q → %s (precision: exact)", truncateForLog(normalizedCtx, 60), startCFI)
|
||||||
return &ConversionResult{
|
return &ConversionResult{
|
||||||
EPUBCFI: cfi,
|
EPUBCFI: startCFI,
|
||||||
|
EndEPUBCFI: endCFI,
|
||||||
Href: href,
|
Href: href,
|
||||||
Percentage: storedPercentage,
|
Percentage: storedPercentage,
|
||||||
Precision: "exact",
|
Precision: "exact",
|
||||||
@@ -780,7 +908,10 @@ func (c *CFIConverter) convertByPercentageOffset(body *html.Node, xp *CREXPointe
|
|||||||
EPUBCFI: cfi,
|
EPUBCFI: cfi,
|
||||||
Href: href,
|
Href: href,
|
||||||
Percentage: storedPercentage,
|
Percentage: storedPercentage,
|
||||||
Precision: "exact",
|
// This is a char-count estimate, not an exact landing: say
|
||||||
|
// so. Callers gate storage on precision, and a guess must
|
||||||
|
// never masquerade as an exact anchor.
|
||||||
|
Precision: "percentage",
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -929,24 +1060,96 @@ func indexChildNodes(parent *html.Node) []indexedNode {
|
|||||||
return nodes
|
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)
|
indexed := indexChildNodes(parent)
|
||||||
for i, node := range indexed {
|
for i, node := range indexed {
|
||||||
if node.isTextChunk() {
|
if node.isTextChunk() {
|
||||||
for j, tn := range node.textChunk {
|
var sb strings.Builder
|
||||||
if tn == textNode {
|
|
||||||
chunkOffset := 0
|
chunkOffset := 0
|
||||||
for k := 0; k < j; k++ {
|
for _, tn := range node.textChunk {
|
||||||
chunkOffset += utf8.RuneCountInString(node.textChunk[k].Data)
|
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 {
|
func findElementCFIIndex(parent *html.Node, element *html.Node) int {
|
||||||
indexed := indexChildNodes(parent)
|
indexed := indexChildNodes(parent)
|
||||||
for i, node := range indexed {
|
for i, node := range indexed {
|
||||||
@@ -981,15 +1184,17 @@ func buildCFI(spineIndex int, textNode *html.Node, charOffset int) (string, erro
|
|||||||
return "", fmt.Errorf("text node has no parent")
|
return "", fmt.Errorf("text node has no parent")
|
||||||
}
|
}
|
||||||
|
|
||||||
chunkIdx, chunkOffset := findTextChunkIndex(parent, textNode)
|
chunkIdx, chunkOffset, chunkText := findTextChunk(parent, textNode)
|
||||||
if chunkIdx == -1 {
|
if chunkIdx == -1 {
|
||||||
return "", fmt.Errorf("text node not found in parent's indexed children")
|
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
|
totalOffset := chunkOffset + charOffset
|
||||||
|
|
||||||
var parts []string
|
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
|
current := parent
|
||||||
for current != nil {
|
for current != nil {
|
||||||
@@ -1116,9 +1321,16 @@ func countTextChars(n *html.Node) int {
|
|||||||
return count
|
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 {
|
func countTextCharsBefore(target *html.Node) int {
|
||||||
count := 0
|
count := 0
|
||||||
for c := target; c != nil; c = c.PrevSibling {
|
for c := target.PrevSibling; c != nil; c = c.PrevSibling {
|
||||||
count += countTextChars(c)
|
count += countTextChars(c)
|
||||||
}
|
}
|
||||||
if target.Parent != nil {
|
if target.Parent != nil {
|
||||||
@@ -1390,24 +1602,31 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
|
|||||||
entry := indexed[lastStep.Index]
|
entry := indexed[lastStep.Index]
|
||||||
|
|
||||||
if entry.isTextChunk() {
|
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 {
|
if lastStep.HasOffset {
|
||||||
textOffset = lastStep.Offset
|
u16Remaining = lastStep.Offset
|
||||||
}
|
}
|
||||||
|
|
||||||
var targetNode *html.Node
|
var targetNode *html.Node
|
||||||
remainingOffset := textOffset
|
runeIntoTarget := 0
|
||||||
for _, tn := range entry.textChunk {
|
for _, tn := range entry.textChunk {
|
||||||
textLen := utf8.RuneCountInString(tn.Data)
|
units := utf16Len(tn.Data)
|
||||||
if remainingOffset < textLen || (remainingOffset == textLen && targetNode == nil) {
|
if u16Remaining < units || (u16Remaining == units && targetNode == nil) {
|
||||||
targetNode = tn
|
targetNode = tn
|
||||||
|
runeIntoTarget = utf16ToRuneIndex(tn.Data, u16Remaining)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
remainingOffset -= textLen
|
u16Remaining -= units
|
||||||
targetNode = tn
|
targetNode = tn
|
||||||
|
runeIntoTarget = utf8.RuneCountInString(tn.Data)
|
||||||
}
|
}
|
||||||
if targetNode == nil && len(entry.textChunk) > 0 {
|
if targetNode == nil && len(entry.textChunk) > 0 {
|
||||||
targetNode = entry.textChunk[len(entry.textChunk)-1]
|
targetNode = entry.textChunk[len(entry.textChunk)-1]
|
||||||
|
runeIntoTarget = utf8.RuneCountInString(targetNode.Data)
|
||||||
}
|
}
|
||||||
|
|
||||||
parent := targetNode.Parent
|
parent := targetNode.Parent
|
||||||
@@ -1418,7 +1637,7 @@ func resolveCFIToNode(doc *html.Node, steps []cfiStep) (*html.Node, int, error)
|
|||||||
}
|
}
|
||||||
totalOffset += countTextChars(c)
|
totalOffset += countTextChars(c)
|
||||||
}
|
}
|
||||||
totalOffset += remainingOffset
|
totalOffset += runeIntoTarget
|
||||||
|
|
||||||
return targetNode, totalOffset, nil
|
return targetNode, totalOffset, nil
|
||||||
}
|
}
|
||||||
@@ -1477,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, ""))
|
xpointer := fmt.Sprintf("/body/DocFragment[%d]/body%s", fragIndex, strings.Join(parts, ""))
|
||||||
|
|
||||||
if charOffset > 0 || (node.Type == html.TextNode) {
|
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
|
return xpointer, nil
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func writeTestEPUB(t *testing.T) string {
|
|||||||
}
|
}
|
||||||
docs := []spineDoc{
|
docs := []spineDoc{
|
||||||
{"doc1.xhtml", "<body><div><p>Chapter one opening page.</p></div></body>"},
|
{"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>"},
|
{"doc3.xhtml", "<body><div><p>Chapter three contents.</p></div></body>"},
|
||||||
{"doc4.xhtml", "<body><div><p>Chapter four 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>"},
|
{"doc5.xhtml", "<body><div><p>Chapter five contents.</p></div></body>"},
|
||||||
@@ -666,3 +666,114 @@ func TestReverseIgnoresSingleCharContext(t *testing.T) {
|
|||||||
t.Errorf("single-char reverse context must fall back to percentage, got %s", reverse.Precision)
|
t.Errorf("single-char reverse context must fall back to percentage, got %s", reverse.Precision)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A position at a chapter heading sends walk-up context: the heading text
|
||||||
|
// concatenated with the paragraphs below (block walk-up in the plugin).
|
||||||
|
// The structural landing at the heading is correct and must verify — the
|
||||||
|
// context is a prefix of the document as read from the landing.
|
||||||
|
func TestWalkUpContextVerifiesAtHeading(t *testing.T) {
|
||||||
|
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||||
|
|
||||||
|
// ch10: h1[1] "Chapter 10", h1[2] "The Three C's of the New Covenant",
|
||||||
|
// then paragraphs. Position at h1[2]'s text start; the device captured
|
||||||
|
// the heading plus the following paragraph.
|
||||||
|
xp := "/body/DocFragment[2]/body/h1[2]/text().0"
|
||||||
|
ctx := "The Three C's of the New Covenant The Cleansing Life of Christ"
|
||||||
|
|
||||||
|
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("walk-up heading → %s (%s)", result.EPUBCFI, result.Precision)
|
||||||
|
if result.Precision != "structural" {
|
||||||
|
t.Fatalf("expected structural precision (quote verification), got %s (%s)", result.Precision, result.EPUBCFI)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
|
||||||
|
t.Errorf("collapsed to doc start: %s", result.EPUBCFI)
|
||||||
|
}
|
||||||
|
// The landing must be the heading, not a paragraph below it.
|
||||||
|
reverse, rerr := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
|
||||||
|
if rerr != nil {
|
||||||
|
t.Fatalf("reverse conversion error: %v", rerr)
|
||||||
|
}
|
||||||
|
if !strings.Contains(reverse.XPointer, "h1[2]") {
|
||||||
|
t.Errorf("expected landing in h1[2], got %s", reverse.XPointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A selection spanning blocks (heading tail into the next paragraph) must
|
||||||
|
// be findable by text search across block boundaries, and the matched
|
||||||
|
// quote's extent gives a true range end.
|
||||||
|
func TestCrossBlockSearchSpansBlocks(t *testing.T) {
|
||||||
|
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||||
|
|
||||||
|
// No element path → structural rung skipped, text search runs.
|
||||||
|
xp := "/body/DocFragment[2]/body"
|
||||||
|
ctx := "New Covenant The Cleansing Life of Christ"
|
||||||
|
|
||||||
|
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("cross-block search → %s … %s (%s)", result.EPUBCFI, result.EndEPUBCFI, result.Precision)
|
||||||
|
if result.Precision != "exact" {
|
||||||
|
t.Fatalf("expected exact text-search precision, got %s", result.Precision)
|
||||||
|
}
|
||||||
|
if result.EPUBCFI == "" || result.EndEPUBCFI == "" {
|
||||||
|
t.Fatalf("expected range anchors, got %q…%q", result.EPUBCFI, result.EndEPUBCFI)
|
||||||
|
}
|
||||||
|
if result.EPUBCFI == result.EndEPUBCFI {
|
||||||
|
t.Fatalf("range collapsed: %s", result.EPUBCFI)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The start lands in the heading, the end in the following paragraph.
|
||||||
|
startRev, err1 := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
|
||||||
|
endRev, err2 := c.ConvertStandardToCRE(result.EndEPUBCFI, result.Percentage, "")
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
t.Fatalf("reverse conversions failed: %v %v", err1, err2)
|
||||||
|
}
|
||||||
|
if !strings.Contains(startRev.XPointer, "h1[2]") {
|
||||||
|
t.Errorf("expected start in h1[2], got %s", startRev.XPointer)
|
||||||
|
}
|
||||||
|
if !strings.Contains(endRev.XPointer, "p[1]") {
|
||||||
|
t.Errorf("expected end in p[1] (following paragraph), got %s", endRev.XPointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The percentage rung is a char-count estimate: it must never label its
|
||||||
|
// landing "exact". Feed it an unmatchable context so the ladder falls all
|
||||||
|
// the way through.
|
||||||
|
func TestPercentageFallbackIsHonestlyLabeled(t *testing.T) {
|
||||||
|
c := NewCFIConverter(writeDropCapEPUB(t))
|
||||||
|
|
||||||
|
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
|
||||||
|
result, err := c.ConvertCREToStandard(xp, 0.52, "zzz qqq vvv uuu www")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ConvertCREToStandard error: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("unmatchable context → %s (%s)", result.EPUBCFI, result.Precision)
|
||||||
|
if result.Precision != "percentage" {
|
||||||
|
t.Errorf("percentage rung must not claim exact, got %s", result.Precision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bookmark route supplies no context (bookmark text is a display
|
||||||
|
// label, never book text), so the facade must still resolve the drop-cap
|
||||||
|
// xpointer structurally instead of collapsing to the document start.
|
||||||
|
func TestConvertToCanonical_DropCapEmptyContext(t *testing.T) {
|
||||||
|
epubPath := writeDropCapEPUB(t)
|
||||||
|
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
|
||||||
|
|
||||||
|
loc := ConvertToCanonical(LocatorSourceKOReader, xp, 0.52, "", string(FormatGroupReflowable), epubPath, "")
|
||||||
|
|
||||||
|
t.Logf("facade drop-cap (no context) → %s (%s)", loc.CFI, loc.Precision)
|
||||||
|
if loc.Precision != "structural" && loc.Precision != "exact" {
|
||||||
|
t.Fatalf("expected structural/exact precision, got %s (%s)", loc.Precision, loc.CFI)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(loc.CFI, "epubcfi(") {
|
||||||
|
t.Fatalf("expected standard epubcfi, got %s", loc.CFI)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(loc.CFI, "/4/2/1:0)") {
|
||||||
|
t.Errorf("empty-context conversion collapsed to doc start: %s", loc.CFI)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,10 @@ const (
|
|||||||
|
|
||||||
type CanonicalLocator struct {
|
type CanonicalLocator struct {
|
||||||
CFI string
|
CFI string
|
||||||
|
// EndCFI carries the matched context's range end (text-search
|
||||||
|
// conversions of selections) so callers can anchor a true highlight
|
||||||
|
// range across block boundaries.
|
||||||
|
EndCFI string
|
||||||
Precision string
|
Precision string
|
||||||
Percentage float64
|
Percentage float64
|
||||||
}
|
}
|
||||||
@@ -58,6 +62,13 @@ func cachedConverter(epubPath string) *CFIConverter {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SectionPercentageCached resolves the spine-section percentage of a CRE
|
||||||
|
// xpointer through the shared bounded converter cache, so per-annotation
|
||||||
|
// lookups parse the EPUB once per book instead of once per annotation.
|
||||||
|
func SectionPercentageCached(epubPath, xpointer string) float64 {
|
||||||
|
return cachedConverter(epubPath).SectionPercentage(xpointer)
|
||||||
|
}
|
||||||
|
|
||||||
func ConvertToCanonical(
|
func ConvertToCanonical(
|
||||||
source LocatorSource,
|
source LocatorSource,
|
||||||
devicePos string,
|
devicePos string,
|
||||||
@@ -87,7 +98,7 @@ func ConvertToCanonical(
|
|||||||
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
return CanonicalLocator{CFI: devicePos, Precision: "fallback", Percentage: percentage}
|
||||||
}
|
}
|
||||||
if result.EPUBCFI != "" {
|
if result.EPUBCFI != "" {
|
||||||
return CanonicalLocator{CFI: result.EPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
return CanonicalLocator{CFI: result.EPUBCFI, EndCFI: result.EndEPUBCFI, Precision: result.Precision, Percentage: result.Percentage}
|
||||||
}
|
}
|
||||||
if result.Href != "" {
|
if result.Href != "" {
|
||||||
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
|
return CanonicalLocator{CFI: result.Href, Precision: result.Precision, Percentage: result.Percentage}
|
||||||
|
|||||||
@@ -274,8 +274,18 @@ func EstimatedPages(totalCharacters int64) int {
|
|||||||
type ProgressService struct {
|
type ProgressService struct {
|
||||||
db *database.Queries
|
db *database.Queries
|
||||||
connManager *ConnectionManager
|
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 {
|
func NewProgressService(db *database.Queries, connManager *ConnectionManager) *ProgressService {
|
||||||
return &ProgressService{db: db, connManager: connManager}
|
return &ProgressService{db: db, connManager: connManager}
|
||||||
}
|
}
|
||||||
@@ -435,6 +445,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
|
conflictDetected := false
|
||||||
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
|
if hasExisting && existing.LastSyncSource.Valid && existing.LastSyncSource.String != req.Source {
|
||||||
if existing.LastSyncTimestamp.Valid {
|
if existing.LastSyncTimestamp.Valid {
|
||||||
|
|||||||
@@ -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"
|
||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
"dev": "npm run build:ts:dev && npm run build:css"
|
"dev": "npm run build:ts:dev && npm run build:css"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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#e16530a",
|
||||||
"alpinejs": "^3.15.8",
|
"alpinejs": "^3.15.8",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
|
|||||||
@@ -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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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: `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
|
||||||
@@ -59,7 +59,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -80,7 +80,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -93,7 +93,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -106,7 +106,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -119,7 +119,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -132,7 +132,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -194,7 +194,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -212,7 +212,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -230,7 +230,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -243,7 +243,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -261,7 +261,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -279,7 +279,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -297,7 +297,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -310,7 +310,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -323,7 +323,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -383,7 +383,7 @@ func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
templ AdminLibrary(user User, libraries []LibraryData, users []User, archivedCount int64) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -34,6 +34,28 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div id="libraries-container">
|
||||||
@LibraryList(user, libraries, users)
|
@LibraryList(user, libraries, users)
|
||||||
</div>
|
</div>
|
||||||
@@ -129,10 +151,18 @@ templ AdminLibrary(user User, libraries []LibraryData, users []User) {
|
|||||||
@Icon("close", "h-5 w-5")
|
@Icon("close", "h-5 w-5")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>?
|
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>
|
</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">
|
<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" 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">
|
<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);
|
htmx.process(content);
|
||||||
document.getElementById('folder-browser-modal').classList.remove('hidden');
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+369
-338
File diff suppressed because it is too large
Load Diff
@@ -70,7 +70,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 36, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 36, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -97,7 +97,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 45, Col: 94}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 45, Col: 94}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -124,7 +124,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 54, Col: 91}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 54, Col: 91}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -166,7 +166,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 70, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 70, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -179,7 +179,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 73, Col: 98}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 73, Col: 98}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -192,7 +192,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 96}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 74, Col: 96}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -205,7 +205,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 76, Col: 140}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 76, Col: 140}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -218,7 +218,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 144}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 77, Col: 144}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -231,7 +231,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 139}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 78, Col: 139}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -244,7 +244,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 149}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 79, Col: 149}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -262,7 +262,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 84, Col: 62}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 84, Col: 62}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -280,7 +280,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 86, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 86, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -298,7 +298,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 88, Col: 66}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 88, Col: 66}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -321,7 +321,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 95, Col: 113}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 95, Col: 113}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -334,7 +334,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 96, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package templates
|
package templates
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"bookhoard/internal/version"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
|
templ AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) {
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
@@ -111,6 +114,7 @@ templ AdminSettings(user User, systemConfig map[string]string, scanSettings Scan
|
|||||||
@ScanSettingsSection(scanSettings)
|
@ScanSettingsSection(scanSettings)
|
||||||
@TunableSettingsSection(liveGroups, false)
|
@TunableSettingsSection(liveGroups, false)
|
||||||
@TunableSettingsSection(restartGroups, true)
|
@TunableSettingsSection(restartGroups, true)
|
||||||
|
@AboutSection()
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
@@ -255,3 +259,18 @@ templ TunableSettingRow(e SettingEntry) {
|
|||||||
<span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span>
|
<span id={ "status-" + e.Key } class="text-xs w-24 text-right" style="color: var(--text-secondary)"></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AboutSection shows build metadata, rendered at the bottom of the System
|
||||||
|
// Settings page. Add future rows (disk space, uptime, Go version, …) to the
|
||||||
|
// list below.
|
||||||
|
templ AboutSection() {
|
||||||
|
<div class="mt-6 card p-6">
|
||||||
|
<div class="flex items-center gap-2 mb-4">
|
||||||
|
@Icon("info", "h-5 w-5 shrink-0")
|
||||||
|
<h3 class="text-lg font-semibold" style="color: var(--text-primary)">About</h3>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2 text-sm" style="color: var(--text-secondary);">
|
||||||
|
<p><span class="font-medium uppercase tracking-wide text-xs" style="color: var(--text-secondary)">Version:</span> { version.Version }</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ package templates
|
|||||||
import "github.com/a-h/templ"
|
import "github.com/a-h/templ"
|
||||||
import templruntime "github.com/a-h/templ/runtime"
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"bookhoard/internal/version"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component {
|
func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanSettingsData, liveGroups []SettingGroup, restartGroups []SettingGroup, errorMessage string) templ.Component {
|
||||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
@@ -67,7 +70,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 31, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 34, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -93,7 +96,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 45, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 48, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -370,7 +373,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 106, Col: 145}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 109, Col: 145}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -383,7 +386,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 107, Col: 144}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 110, Col: 144}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -396,7 +399,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 108, Col: 152}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 111, Col: 152}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -418,6 +421,10 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
templ_7745c5c3_Err = AboutSection().Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></main></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></main></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
@@ -472,7 +479,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 154, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 158, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -564,7 +571,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 194, Col: 112}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 198, Col: 112}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -622,7 +629,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 209, Col: 94}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 213, Col: 94}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -640,7 +647,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 211, Col: 74}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 215, Col: 74}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -658,7 +665,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 213, Col: 74}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 217, Col: 74}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -676,7 +683,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 219, Col: 33}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 223, Col: 33}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -689,7 +696,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 223, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 227, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -732,7 +739,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 233, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 237, Col: 20}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -750,7 +757,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 235, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 239, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -769,7 +776,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 238, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 242, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -792,7 +799,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 246, Col: 20}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 250, Col: 20}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -818,7 +825,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 255, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 259, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -832,4 +839,57 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AboutSection shows build metadata, rendered at the bottom of the System
|
||||||
|
// Settings page. Add future rows (disk space, uptime, Go version, …) to the
|
||||||
|
// list below.
|
||||||
|
func AboutSection() 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_Var22 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var22 == nil {
|
||||||
|
templ_7745c5c3_Var22 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "<div class=\"mt-6 card p-6\"><div class=\"flex items-center gap-2 mb-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = Icon("info", "h-5 w-5 shrink-0").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "<h3 class=\"text-lg font-semibold\" style=\"color: var(--text-primary)\">About</h3></div><div class=\"space-y-2 text-sm\" style=\"color: var(--text-secondary);\"><p><span class=\"font-medium uppercase tracking-wide text-xs\" style=\"color: var(--text-secondary)\">Version:</span> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 273, Col: 134}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "</p></div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var _ = templruntime.GeneratedTemplate
|
var _ = templruntime.GeneratedTemplate
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 32, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 32, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -81,7 +81,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 39, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 39, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -102,7 +102,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 46, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 46, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -123,7 +123,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 53, Col: 91}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 53, Col: 91}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 42, Col: 36}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 42, Col: 36}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -78,7 +78,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 50, Col: 97}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 50, Col: 97}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -101,7 +101,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 59, Col: 80}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 59, Col: 80}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -124,7 +124,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 77, Col: 53}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 77, Col: 53}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -137,7 +137,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 78, Col: 51}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 78, Col: 51}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -170,7 +170,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 87, Col: 47}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 87, Col: 47}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -188,7 +188,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 92, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 92, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -201,7 +201,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 98, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 98, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -240,7 +240,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 117, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 117, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -253,7 +253,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 118, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 118, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 8, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -60,7 +60,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 10, Col: 65}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -78,7 +78,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 12, Col: 120}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -96,7 +96,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 14, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -114,7 +114,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 16, Col: 88}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -127,7 +127,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 24, Col: 131}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -140,7 +140,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 28, Col: 128}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -179,7 +179,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 45, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -197,7 +197,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 47, Col: 65}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -215,7 +215,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 49, Col: 120}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -233,7 +233,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 51, Col: 69}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -251,7 +251,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 53, Col: 88}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -304,7 +304,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 70, Col: 136}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -107,10 +107,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
|
if user.Role == "admin" {
|
||||||
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
|
||||||
@Icon("edit", "h-4 w-4")
|
@Icon("edit", "h-4 w-4")
|
||||||
<span>Edit</span>
|
<span>Edit</span>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<!-- Rating -->
|
<!-- Rating -->
|
||||||
<div class="mb-5" @mouseleave="ratingHover = 0">
|
<div class="mb-5" @mouseleave="ratingHover = 0">
|
||||||
@@ -376,6 +378,12 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
if user.Role == "admin" {
|
||||||
|
<div class="md:col-span-2 lg:col-span-3">
|
||||||
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Location</p>
|
||||||
|
<p class="font-mono text-xs break-all select-all" style="color: var(--text-secondary)">{ book.FileLocation }</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
|
||||||
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
|
||||||
@@ -426,7 +434,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
|
|||||||
</div>
|
</div>
|
||||||
@ProgressSyncModal(user, book)
|
@ProgressSyncModal(user, book)
|
||||||
@NotesHighlightsModal(user, book)
|
@NotesHighlightsModal(user, book)
|
||||||
|
if user.Role == "admin" {
|
||||||
@MetadataEditorModal(book)
|
@MetadataEditorModal(book)
|
||||||
|
}
|
||||||
@ErrorToast(errorMessage)
|
@ErrorToast(errorMessage)
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -290,25 +290,6 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
<input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden"
|
<input type="file" id="cover-upload-input" accept="image/jpeg,image/png,image/webp" class="hidden"
|
||||||
@change="handleCoverUpload($event)" />
|
@change="handleCoverUpload($event)" />
|
||||||
<div class="w-64 space-y-2">
|
<div class="w-64 space-y-2">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary w-full"
|
|
||||||
@click="generateCover()"
|
|
||||||
x-show="coverGenerating"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
@Icon("refresh", "h-4 w-4")
|
|
||||||
Generating...
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary w-full"
|
|
||||||
@click="generateCover()"
|
|
||||||
x-show="!coverGenerating"
|
|
||||||
>
|
|
||||||
@Icon("refresh", "h-4 w-4")
|
|
||||||
Generate Cover
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost w-full"
|
class="btn btn-ghost w-full"
|
||||||
@@ -605,11 +586,42 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
class="input opacity-60" />
|
class="input opacity-60" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">File Path</label>
|
||||||
|
<input type="text" value={ book.FileLocation } readonly
|
||||||
|
class="input opacity-60 font-mono text-xs" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-3 p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-6 border-t flex-shrink-0" style="border-color: var(--border);">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
@click="rescanBook()"
|
||||||
|
:disabled="rescanning"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
>
|
||||||
|
<span x-show="!rescanning" class="inline-flex items-center gap-2">@Icon("sync", "h-4 w-4")<span>Rescan</span></span>
|
||||||
|
<svg x-show="rescanning" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="resetMetadata()"
|
||||||
|
:disabled="resetting"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
title="Discard all manual edits and restore scanned metadata"
|
||||||
|
>
|
||||||
|
<span x-show="!resetting" class="inline-flex items-center gap-2">@Icon("refresh", "h-4 w-4")<span>Reset to Scanned</span></span>
|
||||||
|
<svg x-show="resetting" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
<button
|
<button
|
||||||
@click="hideMetadataEditor()"
|
@click="hideMetadataEditor()"
|
||||||
class="btn btn-secondary"
|
class="btn btn-secondary"
|
||||||
@@ -626,4 +638,5 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+395
-364
File diff suppressed because it is too large
Load Diff
@@ -149,7 +149,13 @@ templ BookShelf(
|
|||||||
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
<option value="">All Books ({ TotalMediaCount(libraries) })</option>
|
||||||
}
|
}
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
|
if lib.Offline {
|
||||||
if lib.ID == currentLibraryID {
|
if lib.ID == currentLibraryID {
|
||||||
|
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
|
||||||
|
} else {
|
||||||
|
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
|
||||||
|
}
|
||||||
|
} else if lib.ID == currentLibraryID {
|
||||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
} else {
|
} else {
|
||||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
|
|||||||
+134
-44
@@ -97,7 +97,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 93, Col: 135}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -110,7 +110,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 95, Col: 25}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -173,7 +173,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 147, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 147, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -191,7 +191,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 149, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 149, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -203,6 +203,7 @@ func BookShelf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, lib := range libraries {
|
for _, lib := range libraries {
|
||||||
|
if lib.Offline {
|
||||||
if lib.ID == currentLibraryID {
|
if lib.ID == currentLibraryID {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<option value=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -211,7 +212,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -224,7 +225,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -237,13 +238,13 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 154, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ")</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, ") — storage offline</option>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -255,7 +256,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -268,7 +269,7 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -281,20 +282,109 @@ func BookShelf(
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 156, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ")</option>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, ") — storage offline</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if lib.ID == currentLibraryID {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 34}
|
||||||
|
}
|
||||||
|
_, 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, 30, "\" selected>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 56}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, " (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 159, Col: 76}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, ")</option>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<option value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 34}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " (")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 161, Col: 67}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ")</option>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</select></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Author</label> <input type=\"text\" name=\"author_filter\" placeholder=\"Filter by author\" class=\"input\" list=\"author-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchAuthorValues($el)\"> <datalist id=\"author-datalist\"></datalist></div><div class=\"relative\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Tags</label> <input type=\"text\" name=\"tags_filter\" placeholder=\"Filter by tags\" class=\"input\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchTagValues($el)\" @keydown=\"onTagFilterKeydown($event)\" @blur=\"hideTagDropdown()\"><div x-show=\"showTagDropdown\" x-transition x-cloak class=\"absolute z-50 mt-1 w-full rounded-lg shadow-lg max-h-48 overflow-y-auto\" style=\"background-color: var(--bg-primary); border: 1px solid var(--border);\"><template x-for=\"sug in tagSuggestions\" :key=\"sug.value\"><button type=\"button\" class=\"w-full text-left px-3 py-2 text-sm flex justify-between items-center\" :class=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'opacity-80' : ''\" :style=\"tagHighlightIndex === tagSuggestions.indexOf(sug) ? 'background-color: var(--bg-secondary); color: var(--text-primary);' : 'color: var(--text-primary);'\" @click=\"selectTagSuggestion(sug.value)\"><span x-text=\"sug.value\"></span> <span class=\"text-xs\" style=\"color: var(--text-secondary);\" x-text=\"sug.count + ' books'\"></span></button></template></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Series</label> <input type=\"text\" name=\"series_filter\" placeholder=\"Filter by series\" class=\"input\" list=\"series-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchSeriesValues($el)\"> <datalist id=\"series-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Language</label> <input type=\"text\" name=\"language_filter\" placeholder=\"Filter by language\" class=\"input\" list=\"language-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchLanguageValues($el)\"> <datalist id=\"language-datalist\"></datalist></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Year Range</label><div class=\"flex gap-2\"><input type=\"number\" name=\"year_min\" placeholder=\"From\" class=\"input\"> <input type=\"number\" name=\"year_max\" placeholder=\"To\" class=\"input\"></div></div><div><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Cover</label> <input type=\"button\" id=\"has-cover-tristate\" class=\"tristate-btn w-full transition-all duration-300 cursor-pointer flex items-center justify-center gap-2 px-4 py-2 rounded-lg border text-sm\" :class=\"{\n\t\t\t\t\t\t\t\t\t\t\t'state-null': hasCoverState === null,\n\t\t\t\t\t\t\t\t\t\t\t'state-true': hasCoverState === true,\n\t\t\t\t\t\t\t\t\t\t\t'state-false': hasCoverState === false\n\t\t\t\t\t\t\t\t\t\t}\" :value=\"hasCoverState === null ? '○ Any cover' : hasCoverState === true ? '✓ Has cover' : '✗ No cover'\" @click=\"cycleHasCover()\"><template x-if=\"hasCoverState !== null\"><input type=\"hidden\" name=\"has_cover\" :value=\"hasCoverState ? 'true' : 'false'\"></template></div></div><div class=\"sticky bottom-0 px-6 py-4 border-t\" style=\"background-color: var(--bg-secondary); border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);\"><button type=\"submit\" @click=\"filtersOpen = false\" class=\"btn btn-primary w-full\">Apply Filters</button></div></div></div></form><div id=\"books-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -303,53 +393,53 @@ func BookShelf(
|
|||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if errorMessage != "" {
|
if errorMessage != "" {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"col-span-full mt-2 p-4 rounded-xl border\" style=\"background-color: color-mix(in srgb, var(--status-danger) 10%, transparent); border-color: var(--status-danger);\"><p style=\"color: var(--text-primary)\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 269, Col: 59}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 275, Col: 59}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p></div>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</p></div>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div><div id=\"pagination\" class=\"mt-6 flex justify-center items-center gap-2\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset-limit))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 278, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 284, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if offset <= 0 {
|
if offset <= 0 {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " disabled")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, " disabled")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, ">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, ">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -357,43 +447,43 @@ func BookShelf(
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<span>Prev</span></button> <span class=\"px-4 py-2 text-sm\" style=\"color: var(--text-secondary);\">Page ")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 287, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span> <button type=\"button\" class=\"btn btn-secondary disabled:opacity-40\" hx-get=\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("/api/media-items/search?library_id=%s&limit=%d&offset=%d", currentLibraryID, limit, offset+limit))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 292, Col: 126}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 298, Col: 126}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" hx-target=\"#books-grid\" hx-include=\"#filter-form\"")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
if offset+limit >= count {
|
if offset+limit >= count {
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " disabled")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, " disabled")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "><span>Next</span>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "><span>Next</span>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -401,12 +491,12 @@ func BookShelf(
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</button>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</button>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</div></div><div x-show=\"showSaveModal\" @click.self=\"showSaveModal = false\" x-transition x-cloak class=\"fixed inset-0 z-[70] flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div @click.stop class=\"card rounded-2xl p-6 w-full max-w-md\"><div class=\"flex justify-between items-center mb-4\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Save Filter</h2><button @click=\"showSaveModal = false\" class=\"icon-btn\" aria-label=\"Close\">")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
@@ -414,7 +504,7 @@ func BookShelf(
|
|||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</button></div><form hx-post=\"/api/saved-filters\" hx-target=\"#saved-filters-list\" hx-swap=\"beforeend\" hx-include=\"#filter-form\" @htmx:afterRequest=\"if(event.detail.xhr.status < 400) { afterFilterSave() }\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Filter Name</label> <input type=\"text\" name=\"filter_name\" placeholder=\"My Custom Filter\" class=\"input\" required><div id=\"filter-save-error\" style=\"display: none; color: var(--status-danger); padding: 0.75rem; border-radius: 0.5rem; margin-top: 0.75rem;\"></div><input type=\"hidden\" name=\"resource_type\" value=\"media-items\"></div><div class=\"flex justify-end gap-2\"><button type=\"button\" @click=\"showSaveModal = false\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Save</button></div><input type=\"hidden\" name=\"limit\" value=\"50\"> <input type=\"hidden\" name=\"offset\" value=\"0\"></form></div></div></body></html>")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ_7745c5c3_Err
|
return templ_7745c5c3_Err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(pageTitle)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(pageTitle)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 14, Col: 21}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 14, Col: 21}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -62,7 +62,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var3 templ.SafeURL
|
var templ_7745c5c3_Var3 templ.SafeURL
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 22, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -83,7 +83,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 24, Col: 23}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -96,7 +96,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -109,7 +109,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 32}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -122,7 +122,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 33, Col: 93}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -135,7 +135,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 34, Col: 125}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -168,7 +168,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 44, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -181,7 +181,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 46, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 23, Col: 49}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -85,7 +85,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 26, Col: 57}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -98,7 +98,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 32, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -111,7 +111,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 45, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -124,7 +124,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 58, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -137,7 +137,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 74, Col: 86}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -155,7 +155,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 116, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 116, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 9, Col: 46}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 9, Col: 46}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -65,7 +65,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 176}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 176}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -78,7 +78,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -99,7 +99,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 45, Col: 67}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 45, Col: 67}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 71, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 71, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -110,7 +110,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(col.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 74, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 74, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -123,7 +123,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(col.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 77, Col: 41}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 77, Col: 41}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -136,7 +136,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("/collections/" + col.ID + "/edit-modal")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 80, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 80, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -157,7 +157,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + col.ID + "")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 89, Col: 56}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 89, Col: 56}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -178,7 +178,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(col.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 99, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 99, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -191,7 +191,7 @@ func Collection(user User, collections []CollectionData, errorMessage string) te
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(col.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 100, Col: 81}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 100, Col: 81}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -246,7 +246,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 117, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 117, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -279,7 +279,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 132, Col: 166}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 132, Col: 166}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -292,7 +292,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 134, Col: 105}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 134, Col: 105}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -305,7 +305,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 135, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 135, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -355,7 +355,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 84}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -368,7 +368,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 112}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 112}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -381,7 +381,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 179, Col: 147}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 179, Col: 147}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -404,7 +404,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var17 templ.SafeURL
|
var templ_7745c5c3_Var17 templ.SafeURL
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 194, Col: 47}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 194, Col: 47}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -417,7 +417,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 196, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 196, Col: 23}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -435,7 +435,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 201, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 201, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -453,7 +453,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var20 templ.SafeURL
|
var templ_7745c5c3_Var20 templ.SafeURL
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + book.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 206, Col: 47}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 206, Col: 47}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -471,7 +471,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 209, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 209, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -551,7 +551,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 385, Col: 26}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 385, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -564,7 +564,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 386, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 386, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -577,7 +577,7 @@ func CollectionDetail(user User, collection CollectionData, books []handlers.Boo
|
|||||||
var templ_7745c5c3_Var24 string
|
var templ_7745c5c3_Var24 string
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.IsSystem)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 387, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 387, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -620,7 +620,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var26 string
|
var templ_7745c5c3_Var26 string
|
||||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.toggleBook('" + book.MediaItemID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 397, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 397, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -638,7 +638,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -651,7 +651,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 401, Col: 54}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 401, Col: 54}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -669,7 +669,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 403, Col: 61}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 403, Col: 61}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -687,7 +687,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 406, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 406, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -700,7 +700,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("$store.bookPicker.isSelected('" + book.MediaItemID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 411, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 411, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -713,7 +713,7 @@ func BookPickerGrid(books []handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var32 string
|
var templ_7745c5c3_Var32 string
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(book.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collections.templ`, Line: 418, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collections.templ`, Line: 418, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(total)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 45, Col: 82}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 45, Col: 82}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -67,7 +67,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -80,7 +80,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 53, Col: 97}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 53, Col: 97}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -128,7 +128,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 111, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 111, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -141,7 +141,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 117, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 117, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -162,7 +162,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 125, Col: 105}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 125, Col: 105}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -175,7 +175,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 126, Col: 96}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 126, Col: 96}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -188,7 +188,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 133, Col: 70}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 133, Col: 70}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -206,7 +206,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 135, Col: 45}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 135, Col: 45}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -78,7 +78,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 40, Col: 33}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 40, Col: 33}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -117,7 +117,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 41, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 41, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -130,7 +130,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 45, Col: 130}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 45, Col: 130}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -143,7 +143,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 47, Col: 100}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 47, Col: 100}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -161,7 +161,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 49, Col: 83}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 49, Col: 83}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -184,7 +184,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var8 templ.SafeURL
|
var templ_7745c5c3_Var8 templ.SafeURL
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 55, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 55, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -210,7 +210,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 68, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -231,7 +231,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 76, Col: 39}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 76, Col: 39}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -249,7 +249,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 81, Col: 92}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 81, Col: 92}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -289,7 +289,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 95, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 95, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -339,7 +339,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 templ.SafeURL
|
var templ_7745c5c3_Var14 templ.SafeURL
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 108, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 108, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -357,7 +357,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 115, Col: 31}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 115, Col: 31}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -370,7 +370,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 116, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 116, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -388,7 +388,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 124, Col: 22}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 124, Col: 22}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -406,7 +406,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var18 string
|
var templ_7745c5c3_Var18 string
|
||||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 130, Col: 117}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 130, Col: 117}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -419,7 +419,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 17}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 131, Col: 17}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -437,7 +437,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 134, Col: 100}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 134, Col: 100}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -450,7 +450,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 135, Col: 19}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 135, Col: 19}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -473,7 +473,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 templ.SafeURL
|
var templ_7745c5c3_Var22 templ.SafeURL
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 143, Col: 40}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 143, Col: 40}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -486,7 +486,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 145, Col: 63}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 145, Col: 63}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -512,7 +512,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var24 templ.SafeURL
|
var templ_7745c5c3_Var24 templ.SafeURL
|
||||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 152, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 152, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -525,7 +525,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
|||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 154, Col: 38}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 154, Col: 38}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -593,7 +593,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 184, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 184, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -606,7 +606,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var28 string
|
var templ_7745c5c3_Var28 string
|
||||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 185, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 185, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -627,7 +627,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 190, Col: 43}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 190, Col: 43}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -640,7 +640,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var30 string
|
var templ_7745c5c3_Var30 string
|
||||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 192, Col: 84}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 192, Col: 84}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -668,7 +668,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 202, Col: 42}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 202, Col: 42}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -701,7 +701,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var32 string
|
var templ_7745c5c3_Var32 string
|
||||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 128}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 228, Col: 128}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -714,7 +714,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
|||||||
var templ_7745c5c3_Var33 string
|
var templ_7745c5c3_Var33 string
|
||||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 235, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 235, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
+16
-16
@@ -111,7 +111,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 64, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 64, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -132,7 +132,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 67, Col: 77}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 67, Col: 77}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -153,7 +153,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 102}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 72, Col: 102}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -166,7 +166,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 89}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 73, Col: 89}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -199,7 +199,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var6 string
|
var templ_7745c5c3_Var6 string
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 86, Col: 104}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 86, Col: 104}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -227,7 +227,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 94, Col: 104}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 94, Col: 104}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -255,7 +255,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 110, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 110, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -282,7 +282,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 132, Col: 38}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 132, Col: 38}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -308,7 +308,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 147, Col: 78}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 147, Col: 78}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -349,7 +349,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 168, Col: 87}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 168, Col: 87}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -362,7 +362,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var12 string
|
var templ_7745c5c3_Var12 string
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 27}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 170, Col: 27}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -375,7 +375,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 170, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -388,7 +388,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 174, Col: 72}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 174, Col: 72}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -401,7 +401,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 177, Col: 71}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 177, Col: 71}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -440,7 +440,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 242, Col: 148}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 242, Col: 148}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -453,7 +453,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 244, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 244, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
+15
-15
@@ -38,7 +38,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 11, Col: 21}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 11, Col: 21}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -60,7 +60,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var4 string
|
var templ_7745c5c3_Var4 string
|
||||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -102,7 +102,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 50, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -133,7 +133,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var6 templ.SafeURL
|
var templ_7745c5c3_Var6 templ.SafeURL
|
||||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 56, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -146,7 +146,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 57, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -159,7 +159,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var8 string
|
var templ_7745c5c3_Var8 string
|
||||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 58, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -187,7 +187,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var9 templ.SafeURL
|
var templ_7745c5c3_Var9 templ.SafeURL
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 65, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -200,7 +200,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var10 string
|
var templ_7745c5c3_Var10 string
|
||||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 66, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -213,7 +213,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 67, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -257,7 +257,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var12 templ.SafeURL
|
var templ_7745c5c3_Var12 templ.SafeURL
|
||||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 28}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -270,7 +270,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 129}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -293,7 +293,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 86, Col: 104}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -324,7 +324,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var15 templ.SafeURL
|
var templ_7745c5c3_Var15 templ.SafeURL
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 96, Col: 35}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -337,7 +337,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);")
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 98, Col: 105}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -350,7 +350,7 @@ func DocsLayout(nav Navigation, doc Document, user User, currentPath string) tem
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 100, Col: 23}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func ErrorPage(message string, errorType string) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 84, Col: 37}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 90, Col: 75}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func FilterItem(id string, name string) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(id)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 6, Col: 21}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_item.templ`, Line: 6, Col: 21}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -49,7 +49,7 @@ func FilterItem(id string, name string) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/filter_item.templ`, Line: 14, Col: 9}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `filter_item.templ`, Line: 14, Col: 9}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
+22
-22
@@ -53,7 +53,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -83,7 +83,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var5 string
|
var templ_7745c5c3_Var5 string
|
||||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var4).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -113,7 +113,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var7 string
|
var templ_7745c5c3_Var7 string
|
||||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -143,7 +143,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var9 string
|
var templ_7745c5c3_Var9 string
|
||||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var8).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -173,7 +173,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var11 string
|
var templ_7745c5c3_Var11 string
|
||||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -203,7 +203,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var13 string
|
var templ_7745c5c3_Var13 string
|
||||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var12).String())
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var12).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -260,7 +260,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var14 string
|
var templ_7745c5c3_Var14 string
|
||||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeTheme('" + opt.Name + "'); openPanel = null")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 103, Col: 68}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 103, Col: 68}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -273,7 +273,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var15 string
|
var templ_7745c5c3_Var15 string
|
||||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 105, Col: 29}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 105, Col: 29}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -286,7 +286,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var16 string
|
var templ_7745c5c3_Var16 string
|
||||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: " + opt.Color)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 108, Col: 130}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 108, Col: 130}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -299,7 +299,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var17 string
|
var templ_7745c5c3_Var17 string
|
||||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 109, Col: 50}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 109, Col: 50}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -321,7 +321,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var19 string
|
var templ_7745c5c3_Var19 string
|
||||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -352,7 +352,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var20 string
|
var templ_7745c5c3_Var20 string
|
||||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("changeWoodPaneling('" + w.Name + "')")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 120, Col: 55}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 120, Col: 55}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -365,7 +365,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var21 string
|
var templ_7745c5c3_Var21 string
|
||||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(w.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 122, Col: 26}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 122, Col: 26}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -388,7 +388,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var22 string
|
var templ_7745c5c3_Var22 string
|
||||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-image: url(/static/textures/thumb-" + w.Name + ".webp); background-size: cover;")
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 128, Col: 191}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 128, Col: 191}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -406,7 +406,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var23 string
|
var templ_7745c5c3_Var23 string
|
||||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(w.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 130, Col: 48}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 130, Col: 48}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -446,7 +446,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var25 string
|
var templ_7745c5c3_Var25 string
|
||||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var24).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -476,7 +476,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var27 string
|
var templ_7745c5c3_Var27 string
|
||||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var26).String())
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var26).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -506,7 +506,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var29 string
|
var templ_7745c5c3_Var29 string
|
||||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String())
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var28).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -536,7 +536,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var31 string
|
var templ_7745c5c3_Var31 string
|
||||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var30).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -566,7 +566,7 @@ func Header(user User, currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var33 string
|
var templ_7745c5c3_Var33 string
|
||||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var32).String())
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var32).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -646,7 +646,7 @@ func SidebarUserMenu(user User) templ.Component {
|
|||||||
var templ_7745c5c3_Var35 string
|
var templ_7745c5c3_Var35 string
|
||||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 220, Col: 58}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 220, Col: 58}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -730,7 +730,7 @@ func SidebarSignIn(currentPath string) templ.Component {
|
|||||||
var templ_7745c5c3_Var37 string
|
var templ_7745c5c3_Var37 string
|
||||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/header.templ`, Line: 268, Col: 60}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `header.templ`, Line: 268, Col: 60}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func Icon(name string, extra string) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/icons.templ`, Line: 1, Col: 0}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `icons.templ`, Line: 1, Col: 0}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func Index(loggedIn bool) templ.Component {
|
|||||||
var templ_7745c5c3_Var2 string
|
var templ_7745c5c3_Var2 string
|
||||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Name)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 30}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 24, Col: 30}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
@@ -54,7 +54,7 @@ func Index(loggedIn bool) templ.Component {
|
|||||||
var templ_7745c5c3_Var3 string
|
var templ_7745c5c3_Var3 string
|
||||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/index.templ`, Line: 24, Col: 44}
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `index.templ`, Line: 24, Col: 44}
|
||||||
}
|
}
|
||||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
if templ_7745c5c3_Err != nil {
|
if templ_7745c5c3_Err != nil {
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ templ LibrarySwitcher(libData []LibraryData, currentLibraryID string, actions ..
|
|||||||
>
|
>
|
||||||
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
|
<option value="">All Libraries ({ TotalMediaCount(libData) })</option>
|
||||||
for _, lib := range libData {
|
for _, lib := range libData {
|
||||||
|
if lib.Offline {
|
||||||
if lib.ID == currentLibraryID {
|
if lib.ID == currentLibraryID {
|
||||||
|
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
|
||||||
|
} else {
|
||||||
|
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount }) — storage offline</option>
|
||||||
|
}
|
||||||
|
} else if lib.ID == currentLibraryID {
|
||||||
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
<option value={ lib.ID } selected>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
} else {
|
} else {
|
||||||
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
<option value={ lib.ID }>{ lib.Name } ({ lib.MediaCount })</option>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user