Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d0ae2bcc4 | ||
|
|
f8de17723a | ||
|
|
4769e832c4 | ||
|
|
03c3e151dc | ||
|
|
3576a4415a | ||
|
|
3325b64463 | ||
|
|
a9430f3c06 | ||
|
|
96fd2aad42 | ||
|
|
1f30602c23 | ||
|
|
18bed79d3a | ||
|
|
fe7eab91e8 | ||
|
|
295c4b0664 | ||
|
|
db96ea91cd | ||
|
|
408a972665 | ||
|
|
6508abc00f | ||
|
|
3ed852515b | ||
|
|
f62b46fb15 | ||
|
|
35d8ba32e5 | ||
|
|
80bfc1a77b | ||
|
|
970beee4b8 | ||
|
|
fa5b952c54 | ||
|
|
ff17d14694 | ||
|
|
807899b137 | ||
|
|
6791d611f0 | ||
|
|
e5315f1b94 | ||
|
|
86c4940426 | ||
|
|
d7d1495753 | ||
|
|
77cd268f4e | ||
|
|
8aeecaa0ef | ||
|
|
dd4cf1235c | ||
|
|
a42c6d4f75 | ||
|
|
9b5defcb31 | ||
|
|
19b9c7c9b8 | ||
|
|
91b0c45dd4 | ||
|
|
43987262a6 | ||
|
|
68701d6b94 | ||
|
|
ab713e2feb | ||
|
|
9c006a9a2f | ||
|
|
4c03116ec7 | ||
|
|
c747f0a76c | ||
|
|
6f98ca4c22 | ||
|
|
04b36f0563 | ||
|
|
4ee24e7152 | ||
|
|
ebb7f4a5b0 | ||
|
|
f52bb6f073 | ||
|
|
6152137156 | ||
|
|
2c5ad014c0 | ||
|
|
5245f292ca | ||
|
|
6750e9e0dc | ||
|
|
693053a9f5 | ||
|
|
bbedbfb13f | ||
|
|
7e586df48a | ||
|
|
7537063cb7 | ||
|
|
f94e7472b4 | ||
|
|
1dbb0ce406 | ||
|
|
7e4c97f639 | ||
|
|
646477afad |
No files matched your search
@@ -71,6 +71,16 @@ were name-only across ALL libraries. Fixed; do not regress.
|
||||
|
||||
## Tests
|
||||
|
||||
- **THE DOCTRINE — tests are never run against a real/production
|
||||
server.** The dev DB is the test environment, and the invariant every
|
||||
test must uphold is: **leave the testuser experience alone** — its
|
||||
settings, scans, and reading data must be exactly as the suite found
|
||||
them. The helpers exist to enforce this; if a test needs protection
|
||||
the helpers don't provide, EXPAND THE HELPERS rather than hand-rolling
|
||||
(2026-10-21: `setSystemSettingRestored` — snapshot-and-restore for
|
||||
system_settings — exists because a hardcoded ev restore could clobber
|
||||
a deliberate dev bump, and the live dev server obeys that setting on
|
||||
its next poll).
|
||||
- Use `setupTestServer()` from `cmd/server/tests/test_helpers_test.go`
|
||||
(see the Conventions section below for the pool/cleanup rules).
|
||||
- Integration tests run against the SHARED dev database: never write
|
||||
@@ -113,6 +123,23 @@ were name-only across ALL libraries. Fixed; do not regress.
|
||||
tested with synthetic ffprobeReport values. Recipes:
|
||||
docs/contributing/development.md → Media fixtures.
|
||||
|
||||
## Web (TypeScript) tests
|
||||
|
||||
- `npm run test` (vp test) runs the vitest suite: colocated `web/src/*.test.ts`
|
||||
files, jsdom environment (configured in vite.config.ts `test`). Do NOT switch
|
||||
to happy-dom — DOMPurify's sanitizer output is mangled under it, which makes
|
||||
the markdown security suite test nothing (verified empirically; jsdom is the
|
||||
documented fallback for exactly this reason).
|
||||
- Static gates: `npx tsc --noEmit`, `npx vp check web/src` (format + lint),
|
||||
`npx vp fmt web/src` to apply formatting.
|
||||
- Tests are never bundled (build entries are main.ts/reader.ts only). When
|
||||
touching build config, re-prove it: back-to-back `npm run build:ts` runs must
|
||||
produce byte-identical hashes, and no test-only strings may appear in
|
||||
web/static.
|
||||
- markdown.test.ts pins the notes XSS defenses (FORBID_TAGS, scheme checks,
|
||||
parser-level html escaping). Loosening any assertion there is a security
|
||||
decision, not cleanup.
|
||||
|
||||
## Timezones
|
||||
|
||||
- The server owns display conversion: clients send instants (RFC3339),
|
||||
@@ -161,14 +188,19 @@ of a server bug: stop and flag the user.
|
||||
the cache stale until the next API-path write — never "preview"
|
||||
rule changes via psql and expect them applied (2026-10-14: a live
|
||||
curl test flip looked ignored until the API path was used).
|
||||
3. **User collections are not dashboard sections**: nothing sets
|
||||
collection show_on_dashboard for user rows, and GetDashboardSections
|
||||
skips empty user sections. Collection detail (JSON + SSR) must NOT
|
||||
route user rows through the dashboard-section path — a 7ce7321
|
||||
regression did exactly that (static detail without library_id
|
||||
returned zero books until 0a7fbd6). Only real system query types
|
||||
(continue-reading etc.) route there; `query_type='filter'` is the
|
||||
schema DEFAULT on every user row, not a query type.
|
||||
3. **User collections are not dashboard query types** (amended 2026-10-19,
|
||||
A2c): user collections ARE dashboard sections by default now —
|
||||
GetUserCollectionsForDashboard no longer gates on show_on_dashboard
|
||||
(which remains system-section provisioning state; hiding is
|
||||
dashboard-prefs `hidden_collections` territory). GetDashboardSections
|
||||
still skips genuinely empty user sections, but hide_finished
|
||||
collections emit a Complete section instead of vanishing. Collection
|
||||
detail (JSON + SSR) must still NOT route user rows through the
|
||||
dashboard-section path — a 7ce7321 regression did exactly that
|
||||
(static detail without library_id returned zero books until 0a7fbd6).
|
||||
Only real system query types (continue-reading etc.) route there;
|
||||
`query_type='filter'` is the schema DEFAULT on every user row, not a
|
||||
query type.
|
||||
4. **End-of-day arithmetic**: anchor the END bound to the START of the
|
||||
day, never to NOW(). `now.AddDate(0,0,1) − 1ns` carries this
|
||||
instant's clock time into tomorrow — a range "ending today"
|
||||
@@ -201,3 +233,25 @@ of a server bug: stop and flag the user.
|
||||
test pools use `max_conns=1`; never create a `pgxpool` per subtest
|
||||
(connection exhaustion); use `t.Cleanup()`-based helpers; table-driven
|
||||
subtests over duplicate functions.
|
||||
7. **Scanner re-extraction is write-after-success** — a failed/empty
|
||||
extraction must NEVER clear scan-derived columns. `updateMediaItem`
|
||||
(generic full-row path) preserves `existing.AudioMetadata` when the
|
||||
new extraction carries no audio payload, and `updateAudioBookItem`
|
||||
refuses empty payloads over populated rows (both guarded 2026-10-21
|
||||
after an ev-gated re-extract NULLed every multi-file audiobook's
|
||||
audio_metadata — books displayed as 1 second long and lost their
|
||||
track manifests). New scan-owned column writes need the same guard.
|
||||
8. **templ double-escapes style attribute values** — the codegen wraps
|
||||
style exprs in a sanitizer AND `templ.EscapeString`, so ANY quote
|
||||
character (even inside `templ.SafeCSS`) reaches the DOM as literal
|
||||
`'` text and the CSS never parses (2026-10-21: every book-detail
|
||||
hero backdrop broken + Firefox fetched `/media/&` → 500 spam).
|
||||
Emit quote-free CSS: unquoted `url(...)` tokens via `SafeCSS` +
|
||||
`cssURL()` in book_detail.templ.
|
||||
9. **`vp test` runs the vite config** — plugins with build hooks fire
|
||||
in the test context too. `cleanStaleChunks` guards on
|
||||
`produced.size === 0` precisely because a bare sweep deleted every
|
||||
hashed chunk in `web/static` when run under vitest (2026-10-21:
|
||||
`npm run test` gutted the build; every page 404'd its imports).
|
||||
Any future outDir-mutating plugin needs the same "am I a build?"
|
||||
guard.
|
||||
+1
-1
@@ -165,7 +165,7 @@ func main() {
|
||||
dashboardHandler := handlers.NewDashboardHandler(queries)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
filtersHandler := handlers.NewFiltersHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
jobsHandler := handlers.NewJobsHandler(queries, worker)
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
package main
|
||||
|
||||
// Playback speed preferences (player plan §8): a per-user PER-BOOK
|
||||
// speed plus a user-level default, resolved at the chapters endpoint
|
||||
// (per-book row → users.default_audio_speed → 1.0). All speed writes
|
||||
// ride the fresh per-test regular user — the shared admin account is
|
||||
// never touched (AGENTS doctrine). Fixture books live in test-named
|
||||
// libraries that cascade away with the library delete.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// writeTestTrackTone renders a short sine mp3 at a GIVEN frequency so
|
||||
// multiple fixture books never collide on the scanner's content dedup.
|
||||
// Falls back to dummy bytes (ingest is ffprobe-optional) like
|
||||
// writeTestTrack.
|
||||
func writeTestTrackTone(t *testing.T, path string, hz int) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
|
||||
"-i", fmt.Sprintf("sine=frequency=%d:duration=0.3", hz), "-codec:a", "libmp3lame", "-b:a", "32k", path)
|
||||
if out, err := cmd.CombinedOutput(); err == nil {
|
||||
return
|
||||
} else {
|
||||
t.Logf("ffmpeg track render failed (%s), falling back to dummy bytes: %s", err, out)
|
||||
}
|
||||
}
|
||||
require.NoError(t, os.WriteFile(path, []byte(fmt.Sprintf("ID3 dummy audio %d", hz)), 0o644))
|
||||
}
|
||||
|
||||
func TestAudioPlaybackSpeed(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Speed %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
writeTestTrack(t, filepath.Join(folder, "Speed Book", "01 one.mp3"))
|
||||
// 552 Hz (not the shared helper's 440) so the scanner's sha-256
|
||||
// dedup doesn't skip the second book as a byte-duplicate.
|
||||
writeTestTrackTone(t, filepath.Join(folder, "Speed Book Two", "01 one.mp3"), 552)
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
// Grouping depends on track count (single-track folders ingest as
|
||||
// single-file items whose path IS the file; multi-track folders get
|
||||
// the folder path) — find by the library-relative path CONTAINING
|
||||
// the book folder so the test holds either way.
|
||||
var bookPath, book2Path string
|
||||
for _, item := range listLibraryAudioItems(t, setup, libID) {
|
||||
p := filepath.ToSlash(item.FilePath)
|
||||
t.Logf("ingested item path: %s", p)
|
||||
if strings.Contains(p, "Speed Book Two") && book2Path == "" {
|
||||
book2Path = p
|
||||
} else if strings.Contains(p, "Speed Book/") && bookPath == "" {
|
||||
bookPath = p
|
||||
}
|
||||
}
|
||||
require.NotEmpty(t, bookPath, "Speed Book item must exist")
|
||||
require.NotEmpty(t, book2Path, "Speed Book Two item must exist")
|
||||
book, found := findAudioItemByPath(t, setup, libID, bookPath)
|
||||
require.True(t, found)
|
||||
bookID := uuid.UUID(book.ID.Bytes)
|
||||
book2, found := findAudioItemByPath(t, setup, libID, book2Path)
|
||||
require.True(t, found)
|
||||
book2ID := uuid.UUID(book2.ID.Bytes)
|
||||
|
||||
client := &http.Client{}
|
||||
token := setup.RegularToken
|
||||
authed := func(method, url, body string) (*http.Response, func()) {
|
||||
var req *http.Request
|
||||
if body == "" {
|
||||
req, _ = http.NewRequest(method, url, nil)
|
||||
} else {
|
||||
req, _ = http.NewRequest(method, url, strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
return resp, func() { _ = resp.Body.Close() }
|
||||
}
|
||||
getSpeeds := func(itemID uuid.UUID) (playback, def float64) {
|
||||
resp, closer := authed("GET", setup.Server.URL+"/api/media-items/"+itemID.String()+"/chapters", "")
|
||||
defer closer()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var data struct {
|
||||
PlaybackSpeed float64 `json:"playback_speed"`
|
||||
DefaultPlaybackSpeed float64 `json:"default_playback_speed"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&data))
|
||||
return data.PlaybackSpeed, data.DefaultPlaybackSpeed
|
||||
}
|
||||
|
||||
t.Run("unset resolves to 1x", func(t *testing.T) {
|
||||
playback, def := getSpeeds(bookID)
|
||||
require.Equal(t, 1.0, playback)
|
||||
require.Equal(t, 1.0, def)
|
||||
})
|
||||
|
||||
t.Run("put per-book speed upserts and resolves", func(t *testing.T) {
|
||||
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", `{"speed":1.5}`)
|
||||
defer closer()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var saved struct {
|
||||
PlaybackSpeed float64 `json:"playback_speed"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&saved))
|
||||
require.Equal(t, 1.5, saved.PlaybackSpeed)
|
||||
|
||||
playback, def := getSpeeds(bookID)
|
||||
require.Equal(t, 1.5, playback)
|
||||
require.Equal(t, 1.0, def) // user default untouched
|
||||
})
|
||||
|
||||
t.Run("out of range speed is rejected", func(t *testing.T) {
|
||||
for _, body := range []string{`{"speed":99}`, `{"speed":0}`, `{"speed":0.1}`} {
|
||||
resp, closer := authed("PUT", setup.Server.URL+"/api/media-items/"+bookID.String()+"/audio-speed", body)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
closer()
|
||||
}
|
||||
// The stored speed is untouched by the rejects.
|
||||
playback, _ := getSpeeds(bookID)
|
||||
require.Equal(t, 1.5, playback)
|
||||
})
|
||||
|
||||
t.Run("profile default applies to books without their own speed", func(t *testing.T) {
|
||||
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":1.25}`)
|
||||
defer closer()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
// Per-book row wins over the default on book one...
|
||||
playback, def := getSpeeds(bookID)
|
||||
require.Equal(t, 1.5, playback)
|
||||
require.Equal(t, 1.25, def)
|
||||
// ...book two has no row and picks the default up.
|
||||
playback2, _ := getSpeeds(book2ID)
|
||||
require.Equal(t, 1.25, playback2)
|
||||
|
||||
// GET /profile carries it for the profile form.
|
||||
resp2, closer2 := authed("GET", setup.Server.URL+"/api/auth/profile", "")
|
||||
defer closer2()
|
||||
var profile struct {
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp2.Body).Decode(&profile))
|
||||
require.Equal(t, 1.25, profile.DefaultAudioSpeed)
|
||||
})
|
||||
|
||||
t.Run("updating the default re-satisfies unset books", func(t *testing.T) {
|
||||
resp, closer := authed("PUT", setup.Server.URL+"/api/auth/profile", `{"default_audio_speed":0.75}`)
|
||||
defer closer()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
playback2, _ := getSpeeds(book2ID)
|
||||
require.Equal(t, 0.75, playback2)
|
||||
playback1, _ := getSpeeds(bookID)
|
||||
require.Equal(t, 1.5, playback1) // per-book pin survives
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,24 @@ func writeTestTrack(t *testing.T, path string) {
|
||||
require.NoError(t, os.WriteFile(path, []byte("ID3 dummy audio"), 0o644))
|
||||
}
|
||||
|
||||
// writeTestTrackM4B is writeTestTrack for the m4b (AAC/ipod muxer)
|
||||
// container — the audiobook format whose rescan route is under guard.
|
||||
// ffmpeg needs -f ipod for the .m4b extension; falls back to dummy bytes.
|
||||
func writeTestTrackM4B(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
|
||||
if ffmpeg, err := exec.LookPath("ffmpeg"); err == nil {
|
||||
cmd := exec.Command(ffmpeg, "-y", "-v", "error", "-f", "lavfi",
|
||||
"-i", "sine=frequency=440:duration=0.3", "-codec:a", "aac", "-b:a", "32k", "-f", "ipod", path)
|
||||
if out, err := cmd.CombinedOutput(); err == nil {
|
||||
return
|
||||
} else {
|
||||
t.Logf("ffmpeg m4b render failed (%s), falling back to dummy bytes: %s", err, out)
|
||||
}
|
||||
}
|
||||
require.NoError(t, os.WriteFile(path, []byte("dummy m4b audio"), 0o644))
|
||||
}
|
||||
|
||||
// audioTestLibrary builds an audiobooks-type library with a fresh temp
|
||||
// folder, returning its UUID and the folder path.
|
||||
func audioTestLibrary(t *testing.T, setup *TestServerSetup, name string) (pgtype.UUID, string) {
|
||||
@@ -569,3 +587,213 @@ func TestRescanKeepsFilenameTitle(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Plain Book", rescanned.Title, "rescan must not blank the filename-derived title")
|
||||
}
|
||||
|
||||
// TestAudiobookFolderSidecarEditDetected is the Phase 6 headline: a
|
||||
// size-stable folder book is changed by EDITING its metadata.json —
|
||||
// the track-count/size fingerprint never saw it, not even on force
|
||||
// rescan. Pins the ladder end to end: seeding at create, pure-skip
|
||||
// steady state, same-size sidecar edit consumed (title + collection
|
||||
// sync), and the refreshed manifest.
|
||||
func TestAudiobookFolderSidecarEditDetected(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Sidecar %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
bookDir := filepath.Join(folder, "Sidecar Author", "Sidecar Dir Name")
|
||||
writeTestTrack(t, filepath.Join(bookDir, "01 first.mp3"))
|
||||
writeTestTrack(t, filepath.Join(bookDir, "02 second.mp3"))
|
||||
sidecar := filepath.Join(bookDir, "metadata.json")
|
||||
// Sidecar title deliberately differs from the directory name so the
|
||||
// assertion proves the SIDECAR won.
|
||||
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Title"}`), 0o644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Sidecar Author/Sidecar Dir Name"))
|
||||
require.True(t, found)
|
||||
require.Equal(t, "Sidecar Real Title", book.Title, "sidecar title wins at create")
|
||||
require.NotEqual(t, book.UpdatedAt.Time, time.Time{}, "sanity")
|
||||
|
||||
// Manifest seeded at create (the multifile create tail).
|
||||
state, err := setup.DB.GetMediaScanState(context.Background(), book.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, state.Manifest, "folder create must seed the sidecar manifest")
|
||||
seededManifest := state.Manifest
|
||||
|
||||
// Steady state: the manifest ladder runs and skips — pure skip.
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
afterSkip := findItemUpdated(t, setup, libID, book.FilePath)
|
||||
require.Equal(t, book.UpdatedAt.Time, afterSkip, "unchanged rescan with seeded manifest is a pure skip")
|
||||
|
||||
// Same-size sidecar edit — the size-only blind spot.
|
||||
sameSize := []byte(`{"title":"Sidecar Real Titlz"}`)
|
||||
require.Len(t, sameSize, len(`{"title":"Sidecar Real Title"}`), "fixture must be a same-size edit")
|
||||
require.NoError(t, os.WriteFile(sidecar, sameSize, 0o644))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
edited := findAudioItemByPathRow(t, setup, libID, book.FilePath)
|
||||
require.Equal(t, "Sidecar Real Titlz", edited.Title, "same-size sidecar edit is detected and consumed")
|
||||
|
||||
state, err = setup.DB.GetMediaScanState(context.Background(), edited.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, state.Manifest)
|
||||
require.NotEqual(t, string(seededManifest), string(state.Manifest), "change path stores a fresh full manifest")
|
||||
|
||||
// Playlist sync on the change path (plan Correction 2): a reading-
|
||||
// order entry added to an EXISTING book creates its collection, and
|
||||
// a further rescan does not duplicate it.
|
||||
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Sidecar Real Titlz","series":["Sidecar Saga (Reading Order) #1"]}`), 0o644))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
var collCount int
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
||||
).Scan(&collCount))
|
||||
require.Equal(t, 1, collCount, "reading-order sidecar edit creates the collection for an existing book")
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT COUNT(*) FROM collections WHERE name = 'Sidecar Saga (Reading Order)' AND user_id = $1`, admin,
|
||||
).Scan(&collCount))
|
||||
require.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
||||
}
|
||||
|
||||
// TestAudiobookTrackOnlyChangeKeepsEditorial pins the other half of the
|
||||
// contract: a TRACK change (sidecars untouched) keeps the existing
|
||||
// scan-derived-only update — editorial columns stay as the sidecar
|
||||
// wrote them, and no editorial apply fires.
|
||||
func TestAudiobookTrackOnlyChangeKeepsEditorial(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio TrackOnly %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
bookDir := filepath.Join(folder, "Track Only Author", "Track Only Book")
|
||||
writeTestTrack(t, filepath.Join(bookDir, "01 first.mp3"))
|
||||
writeTestTrack(t, filepath.Join(bookDir, "02 second.mp3"))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.json"), []byte(`{"title":"Editorial Anchor"}`), 0o644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Track Only Author/Track Only Book"))
|
||||
require.True(t, found)
|
||||
require.Equal(t, "Editorial Anchor", book.Title)
|
||||
|
||||
// A third track arrives; sidecars untouched → track-count change →
|
||||
// payload update only.
|
||||
writeTestTrack(t, filepath.Join(bookDir, "03 third.mp3"))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
updated := findAudioItemByPathRow(t, setup, libID, book.FilePath)
|
||||
layout, tracks, _, _ := decodeAudioPayload(t, updated)
|
||||
require.Equal(t, "folder", layout)
|
||||
require.Equal(t, 3, tracks, "track change takes the payload update")
|
||||
require.Equal(t, "Editorial Anchor", updated.Title, "track-only change must not touch editorial columns")
|
||||
}
|
||||
|
||||
// TestAudiobookZipSidecarEditAndRestore covers the zip twin: sibling
|
||||
// metadata.json edits are detected, and the fast path lifts archive
|
||||
// state like the folder path always did (Phase 6 parity fix).
|
||||
func TestAudiobookZipSidecarEditAndRestore(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio ZipSidecar %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
dir := t.TempDir()
|
||||
writeTestTrack(t, filepath.Join(dir, "a.mp3"))
|
||||
writeTestTrack(t, filepath.Join(dir, "b.mp3"))
|
||||
zipPath := filepath.Join(folder, "Zip Sidecar Book.zip")
|
||||
zipFiles(t, zipPath, map[string]string{
|
||||
"01 first.mp3": filepath.Join(dir, "a.mp3"),
|
||||
"02 second.mp3": filepath.Join(dir, "b.mp3"),
|
||||
})
|
||||
sidecar := filepath.Join(folder, "metadata.json")
|
||||
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Zip Sidecar Title"}`), 0o644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("Zip Sidecar Book.zip"))
|
||||
require.True(t, found)
|
||||
require.Equal(t, "Zip Sidecar Title", book.Title, "sibling sidecar feeds the zip book at create")
|
||||
|
||||
state, err := setup.DB.GetMediaScanState(context.Background(), book.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, state.Manifest, "zip create must seed the sidecar manifest")
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
require.Equal(t, book.UpdatedAt.Time, findItemUpdated(t, setup, libID, book.FilePath), "unchanged zip rescan is a pure skip")
|
||||
|
||||
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Zip Sidecar Titlz"}`), 0o644))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
edited := findAudioItemByPathRow(t, setup, libID, book.FilePath)
|
||||
require.Equal(t, "Zip Sidecar Titlz", edited.Title, "zip sidecar edit is detected and consumed")
|
||||
|
||||
// Archive the row the way the missing-scan does; the file is back,
|
||||
// so the next scan must lift the archive (parity with folders).
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`UPDATE media_items SET archived_at = NOW() WHERE id = $1 RETURNING id`, edited.ID,
|
||||
).Scan(new(pgtype.UUID)))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
restored := findAudioItemByPathRow(t, setup, libID, book.FilePath)
|
||||
require.False(t, restored.ArchivedAt.Valid, "zip fast path must lift archive state when the file is back")
|
||||
require.Equal(t, "Zip Sidecar Titlz", restored.Title)
|
||||
}
|
||||
|
||||
// TestAudiobookSidecarRemovalFallsBackToFilename is the wipe-guard's
|
||||
// testable sibling (faceted plan Phase 6 audit find): a single-file
|
||||
// book whose sidecar is REMOVED must re-derive metadata through the
|
||||
// create-equivalent chain (tags → filename), never blank the title.
|
||||
// The pre-fix change path passed a non-nil empty metadata, which
|
||||
// skipped the filename guard and the tag re-derivation.
|
||||
func TestAudiobookSidecarRemovalFallsBackToFilename(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Removal %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
trackPath := filepath.Join(folder, "Fallback Book.mp3")
|
||||
writeTestTrack(t, trackPath)
|
||||
sidecar := filepath.Join(folder, "metadata.json")
|
||||
require.NoError(t, os.WriteFile(sidecar, []byte(`{"title":"Soon Gone Title"}`), 0o644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
book, found := findAudioItemByPath(t, setup, libID, "Fallback Book.mp3")
|
||||
require.True(t, found)
|
||||
require.Equal(t, "Soon Gone Title", book.Title, "sidecar title wins at create")
|
||||
|
||||
// The sidecar vanishes: every sidecar gone → change → re-extract
|
||||
// finds nothing → title falls back to the filename, not blank.
|
||||
require.NoError(t, os.Remove(sidecar))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
after := findAudioItemByPathRow(t, setup, libID, book.FilePath)
|
||||
require.Equal(t, "Fallback Book", after.Title, "sidecar removal re-derives through the create-equivalent chain")
|
||||
}
|
||||
|
||||
// findItemUpdated reads a media item's updated_at by file_path.
|
||||
func findItemUpdated(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) time.Time {
|
||||
t.Helper()
|
||||
for _, item := range listLibraryAudioItems(t, setup, libID) {
|
||||
if item.FilePath == filePath {
|
||||
return item.UpdatedAt.Time
|
||||
}
|
||||
}
|
||||
t.Fatalf("item %q not found in library", filePath)
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// findAudioItemByPathRow is findAudioItemByPath with a hard failure.
|
||||
func findAudioItemByPathRow(t *testing.T, setup *TestServerSetup, libID pgtype.UUID, filePath string) database.ListMediaItemsByLibraryIncludingArchivedRow {
|
||||
t.Helper()
|
||||
item, found := findAudioItemByPath(t, setup, libID, filePath)
|
||||
require.True(t, found, "item %q must exist", filePath)
|
||||
return item
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func TestCalibreLibraryScan(t *testing.T) {
|
||||
require.Equal(t, "Test Book", book.Title, "Title should match sidecar")
|
||||
require.Equal(t, "Test Author", book.Author.String, "Author should match sidecar")
|
||||
require.Equal(t, "Test Series", book.Series.String, "Series should match sidecar")
|
||||
require.Equal(t, int32(1), book.SeriesNumber.Int32, "Series number should match sidecar")
|
||||
require.Equal(t, float64(1), book.SeriesNumber.Float64, "Series number should match sidecar")
|
||||
require.Equal(t, "Test Publisher", book.Publisher.String, "Publisher should match sidecar")
|
||||
require.Len(t, book.Tags, 2, "Should have 2 tags from sidecar")
|
||||
require.Contains(t, book.Tags, "Fantasy", "Should have Fantasy tag")
|
||||
|
||||
@@ -382,7 +382,7 @@ func createPreviewTestMediaItems(t *testing.T, db *database.Queries, libraryID s
|
||||
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: "Preview Test Book " + string(rune('A'+i)),
|
||||
Author: pgtype.Text{String: "Test Author " + string(rune('A'+i)), Valid: true},
|
||||
Authors: []string{"Test Author " + string(rune('A'+i))},
|
||||
Genre: pgtype.Text{String: genre, Valid: true},
|
||||
FilePath: "/tmp/test" + string(rune('A'+i)) + ".epub",
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
|
||||
@@ -111,7 +111,7 @@ func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uui
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: title,
|
||||
Author: pgtype.Text{String: "Smart Rule Author", Valid: true},
|
||||
Authors: []string{"Smart Rule Author"},
|
||||
Genre: pgtype.Text{String: genre, Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
|
||||
@@ -123,7 +123,7 @@ func createContractFixture(t *testing.T, setup *TestServerSetup) contractFixture
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: itemTitle,
|
||||
Author: pgtype.Text{String: "Test Contract Author", Valid: true},
|
||||
Authors: []string{"Test Contract Author"},
|
||||
Series: pgtype.Text{String: seriesName, Valid: true},
|
||||
Genre: pgtype.Text{String: "Science Fiction", Valid: true},
|
||||
FilePath: "/tmp/contract-test-item.epub",
|
||||
@@ -490,10 +490,12 @@ func TestContractPin_CollectionsJSON(t *testing.T) {
|
||||
fx := createContractFixture(t, setup)
|
||||
|
||||
// The envelope every detail branch shares.
|
||||
// A2c (2026-10-19): + up_next (the collection's reading-queue strip
|
||||
// payload; docs/contract_pins.md changelog).
|
||||
envelopeKeys := []string{
|
||||
"id", "user_id", "name", "description", "color", "icon",
|
||||
"auto_assign_rules", "view_settings", "books", "book_count",
|
||||
"is_smart", "created_at",
|
||||
"is_smart", "created_at", "up_next",
|
||||
}
|
||||
|
||||
t.Run("user collection with library_id (badged branch)", func(t *testing.T) {
|
||||
@@ -660,6 +662,11 @@ func TestContractPin_SearchJSON(t *testing.T) {
|
||||
// EXPLAIN plan-identical) and the search endpoint serializes the
|
||||
// row directly, so they emit here.
|
||||
"abridged", "audio_metadata",
|
||||
// A2b/A2c (2026-10-19): + subtitle, explicit, authors,
|
||||
// authors_search, publishers, publishers_search, narrators_search
|
||||
// (docs/contract_pins.md changelog).
|
||||
"subtitle", "explicit", "authors", "authors_search",
|
||||
"publishers", "publishers_search", "narrators_search",
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/media-items/search?q=%s&library_id=%s", setup.Server.URL, url.QueryEscape(fx.itemTitle), fx.libraryID)
|
||||
@@ -693,6 +700,8 @@ func TestContractPin_DashboardSections(t *testing.T) {
|
||||
sectionKeys := []string{
|
||||
"id", "collection_id", "is_system", "title", "description",
|
||||
"icon", "items", "view_all_url", "priority",
|
||||
// A2c (2026-10-19): + is_playlist, complete (docs/contract_pins.md).
|
||||
"is_playlist", "complete",
|
||||
}
|
||||
sections := getArray(t, body, "sections")
|
||||
require.NotEmpty(t, sections)
|
||||
@@ -711,10 +720,14 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
|
||||
fx := createContractFixture(t, setup)
|
||||
|
||||
// The sqlc row key sets behind BOTH branches.
|
||||
// A2b (2026-10-19): + subtitle, explicit, authors, authors_search,
|
||||
// publishers, publishers_search (docs/contract_pins.md changelog).
|
||||
rawRowKeys := []string{
|
||||
"id", "library_id", "title", "author", "isbn", "description",
|
||||
"id", "library_id", "title", "subtitle", "author", "isbn", "description",
|
||||
"file_path", "file_size", "mime_type", "cover_image_path",
|
||||
"series", "series_number", "tags", "narrators", "abridged",
|
||||
"explicit", "authors", "authors_search", "publishers",
|
||||
"publishers_search",
|
||||
"asin", "date_published", "publisher", "contributors", "language",
|
||||
"edition", "page_count", "genre", "copyright_year", "goodreads_id",
|
||||
"openlibrary_id", "google_books_id", "added_by_admin_id", "created_at",
|
||||
@@ -731,10 +744,10 @@ func TestContractPin_MediaListBothBranches(t *testing.T) {
|
||||
"library_type_name_2",
|
||||
}
|
||||
resolvedKeys := []string{
|
||||
"id", "library_id", "title", "author", "isbn", "description",
|
||||
"id", "library_id", "title", "subtitle", "author", "authors", "isbn", "description",
|
||||
"file_path", "file_size", "mime_type", "cover_image_path",
|
||||
"series", "series_number", "tags", "asin", "date_published",
|
||||
"publisher", "contributors", "language", "edition", "page_count",
|
||||
"publisher", "publishers", "explicit", "contributors", "language", "edition", "page_count",
|
||||
"genre", "created_at", "updated_at", "format_group",
|
||||
"manga_type", "reading_direction",
|
||||
"series_count", "volume", "imprint", "age_rating", "web_url",
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package main
|
||||
|
||||
// Extractor-version (ev) mechanism, faceted plan Phase 10: parser fixes
|
||||
// (7e4c97f6, genre model v2) must reach rows ingested under an older
|
||||
// parser without a force rescan. The manifest's ev stamp is the
|
||||
// per-item provenance; scan_extractor_version is the current parser's
|
||||
// claim; a mismatch re-extracts on a later scan, bounded per scan.
|
||||
//
|
||||
// State machine pinned here, driven through REAL scans over a temp
|
||||
// library (the corruption probe makes re-extraction observable: the
|
||||
// only writer that restores the sidecar values is a re-extraction):
|
||||
//
|
||||
// create → stamped ev=1 (current parser, truthful)
|
||||
// equal → no re-extract (corruption persists)
|
||||
// wiped → backfill stores ev UNSET, never stamps, never re-extracts
|
||||
// bump → re-extract ONCE (corruption healed, ev=2)
|
||||
// equal → stable (second corruption persists)
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// storedManifestEv reads the "ev" stamp from an item's stored manifest
|
||||
// (0 = absent — the backfill's v1-or-unknown marker).
|
||||
func storedManifestEv(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID) int {
|
||||
t.Helper()
|
||||
row, err := setup.DB.GetMediaScanState(context.Background(), itemID)
|
||||
require.NoError(t, err, "scan-state row must exist — every book needs an ev home")
|
||||
var m map[string]any
|
||||
require.NoError(t, json.Unmarshal(row.Manifest, &m))
|
||||
ev, _ := m["ev"].(float64)
|
||||
return int(ev)
|
||||
}
|
||||
|
||||
// corruptItemColumn scribbles a scan-owned column on a TEST-CREATED item
|
||||
// so a later re-extraction is observable (the sidecar rewrites it back).
|
||||
// The row is test data cascade-deleted with the library — never real rows.
|
||||
func corruptItemColumn(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, column, value string) {
|
||||
t.Helper()
|
||||
tag, err := setup.DBPool.Exec(context.Background(),
|
||||
fmt.Sprintf("UPDATE media_items SET %s = $1 WHERE id = $2", column), value, itemID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), tag.RowsAffected(), "corruption must hit exactly the test item")
|
||||
}
|
||||
|
||||
|
||||
func setExtractorVersion(t *testing.T, setup *TestServerSetup, v int) {
|
||||
t.Helper()
|
||||
// Restore-to-prior (not a hardcoded value): the shared dev DB's
|
||||
// setting must survive the suite exactly as the test found it — if a
|
||||
// parser-fix bump was in flight, a hardcoded 1 would silently cancel
|
||||
// it and the live server would stop re-extracting.
|
||||
setSystemSettingRestored(t, setup, "scan_extractor_version", strconv.Itoa(v))
|
||||
}
|
||||
|
||||
func TestExtractorVersionReextract(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
pinExtractorVersionOne(t, setup)
|
||||
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test EV Library %d", time.Now().UnixNano()), "ebooks")
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// metadata.opf sidecar: the re-extraction's provenance source.
|
||||
bookDir := filepath.Join(folder, "EV Probe Author", "EV Probe Book")
|
||||
require.NoError(t, os.MkdirAll(bookDir, 0o755))
|
||||
opf := `<?xml version='1.0' encoding='utf-8'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>EV Probe Book</dc:title>
|
||||
<dc:creator>EV Probe Author</dc:creator>
|
||||
</metadata>
|
||||
</package>`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.opf"), []byte(opf), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "EV Probe Book.epub"), []byte("dummy epub content"), 0o644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
item, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
|
||||
require.True(t, found, "sidecar book must import")
|
||||
|
||||
// Create stamps the current parser version (truthful).
|
||||
require.Equal(t, 1, storedManifestEv(t, setup, item.ID), "create must stamp the default ev=1")
|
||||
|
||||
// Equality → skip: corruption persists (nothing re-extracts at ev=1).
|
||||
// NOTE: `author` is trigger-owned (sync_author_publisher_display
|
||||
// rewrites it from the authors array on every UPDATE) — corrupt a
|
||||
// trigger-free column the re-extraction rewrites: title.
|
||||
corruptItemColumn(t, setup, item.ID, "title", "ZZZ corrupted")
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
|
||||
require.True(t, found)
|
||||
require.Equal(t, "ZZZ corrupted", item.Title, "equal ev must NOT re-extract (corruption persists)")
|
||||
require.Equal(t, 1, storedManifestEv(t, setup, item.ID))
|
||||
|
||||
// Wipe the scan state (test-owned row) to simulate a pre-release
|
||||
// book: backfill re-fingerprints WITHOUT re-extraction and WITHOUT
|
||||
// stamping — the row never claims a version it can't prove.
|
||||
_, err := setup.DBPool.Exec(ctx, "DELETE FROM media_scan_state WHERE media_item_id = $1", item.ID)
|
||||
require.NoError(t, err)
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
require.Equal(t, 0, storedManifestEv(t, setup, item.ID), "backfill must store ev UNSET (v1-or-unknown)")
|
||||
item, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
|
||||
require.Equal(t, "ZZZ corrupted", item.Title, "backfill must NOT re-extract")
|
||||
|
||||
// Bump via the SETTINGS API; the absent ev rides v1 → mismatch →
|
||||
// re-extract once: the corruption heals and the fresh manifest
|
||||
// carries ev=2.
|
||||
setExtractorVersion(t, setup, 2)
|
||||
// Prior-value restore rides inside setExtractorVersion — never a
|
||||
// hardcoded 1, so a deliberate dev bump survives the suite.
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
item, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
|
||||
require.True(t, found)
|
||||
require.Equal(t, "EV Probe Book", item.Title, "ev mismatch must re-extract (sidecar value restored)")
|
||||
require.Equal(t, 2, storedManifestEv(t, setup, item.ID), "post-extraction store stamps the new ev")
|
||||
|
||||
// Then stable: the second corruption persists at ev=2 == setting 2.
|
||||
corruptItemColumn(t, setup, item.ID, "title", "ZZZ corrupted again")
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
item, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Probe Author/EV Probe Book/EV Probe Book.epub"))
|
||||
require.Equal(t, "ZZZ corrupted again", item.Title, "equal ev must NOT re-extract again (stable)")
|
||||
require.Equal(t, 2, storedManifestEv(t, setup, item.ID))
|
||||
}
|
||||
|
||||
func TestExtractorVersionMultifile(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
pinExtractorVersionOne(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test EV Audio %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// A canonical folder book, sidecar-less: the Phase 6 seed gives it
|
||||
// an entries-less manifest — now also its ev home.
|
||||
writeTestTrack(t, filepath.Join(folder, "EV Audio Author", "EV Audio Title", "01 First.mp3"))
|
||||
writeTestTrack(t, filepath.Join(folder, "EV Audio Author", "EV Audio Title", "02 Second.mp3"))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
book, found := findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
|
||||
require.True(t, found, "folder book must import")
|
||||
require.Equal(t, "EV Audio Title", book.Title)
|
||||
require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "multifile create must stamp the default ev=1")
|
||||
|
||||
// Equality → skip: the multifile ladder's unchanged gate stays shut.
|
||||
corruptItemColumn(t, setup, book.ID, "title", "ZZZ corrupted")
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
book, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
|
||||
require.Equal(t, "ZZZ corrupted", book.Title, "equal ev must NOT re-apply (corruption persists)")
|
||||
require.Equal(t, 1, storedManifestEv(t, setup, book.ID))
|
||||
|
||||
// Bump → the ev gate fires through multifileSidecarChanged → the
|
||||
// editorial apply re-derives the title from the folder basename.
|
||||
setExtractorVersion(t, setup, 2)
|
||||
// Prior-value restore rides inside setExtractorVersion — never a
|
||||
// hardcoded 1, so a deliberate dev bump survives the suite.
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
book, found = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
|
||||
require.True(t, found)
|
||||
require.Equal(t, "EV Audio Title", book.Title, "ev mismatch must re-extract the folder book")
|
||||
require.Equal(t, 2, storedManifestEv(t, setup, book.ID), "change-path store stamps the new ev")
|
||||
|
||||
// Then stable.
|
||||
corruptItemColumn(t, setup, book.ID, "title", "ZZZ corrupted again")
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
book, _ = findAudioItemByPath(t, setup, libID, filepath.ToSlash("EV Audio Author/EV Audio Title"))
|
||||
require.Equal(t, "ZZZ corrupted again", book.Title, "equal ev must NOT re-apply again (stable)")
|
||||
require.Equal(t, 2, storedManifestEv(t, setup, book.ID))
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
package main
|
||||
|
||||
// Facet registry Batch A integration tests (faceted-navigation plan,
|
||||
// Part 1 Phase 1): the nine new SearchMediaItemsUnified narg blocks —
|
||||
// scalar-fuzzy (story_arc, imprint, edition), exact enums (age_rating,
|
||||
// format_group, manga_type, reading_direction), and presence-bound
|
||||
// bools (explicit, abridged) — filter correctly against a real DB.
|
||||
// The registry↔SQL drift guard (unit level) lives in
|
||||
// internal/services/facet_registry_test.go.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// facetSearch runs the unified search endpoint with arbitrary query
|
||||
// params and returns (status, decoded array-or-object).
|
||||
func facetSearch(t *testing.T, setup *TestServerSetup, params url.Values) (int, []map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+params.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// The endpoint's no-results contract: 404 + error envelope.
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var rows []map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
|
||||
return resp.StatusCode, rows
|
||||
}
|
||||
|
||||
func facetTitles(t *testing.T, rows []map[string]interface{}) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for _, r := range rows {
|
||||
title, _ := r["title"].(string)
|
||||
out = append(out, title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFacetRegistryParamsFilter(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
libName := fmt.Sprintf("Test Facet Params Library %d", time.Now().UnixNano())
|
||||
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: libName,
|
||||
Description: pgtype.Text{String: "facet params test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
|
||||
|
||||
createItem := func(title string, mutate func(*database.CreateMediaItemParams)) {
|
||||
t.Helper()
|
||||
p := database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: title,
|
||||
Authors: []string{"Facet Param Author"},
|
||||
FilePath: fmt.Sprintf("/tmp/facet-params/%s-%d.epub", title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
}
|
||||
if mutate != nil {
|
||||
mutate(&p)
|
||||
}
|
||||
_, err := setup.DB.CreateMediaItem(ctx, p)
|
||||
require.NoError(t, err, "create %s", title)
|
||||
}
|
||||
|
||||
createItem("Facet Alpha", func(p *database.CreateMediaItemParams) {
|
||||
p.StoryArc = pgtype.Text{String: "civil war", Valid: true}
|
||||
p.Imprint = pgtype.Text{String: "Vertigo", Valid: true}
|
||||
p.Edition = pgtype.Text{String: "First Edition", Valid: true}
|
||||
p.AgeRating = pgtype.Text{String: "Teen", Valid: true}
|
||||
p.MangaType = pgtype.Text{String: "yes", Valid: true}
|
||||
p.Explicit = true
|
||||
})
|
||||
createItem("Facet Beta", func(p *database.CreateMediaItemParams) {
|
||||
p.StoryArc = pgtype.Text{String: "infinity gauntlet", Valid: true}
|
||||
p.AgeRating = pgtype.Text{String: "Mature", Valid: true}
|
||||
p.ReadingDirection = pgtype.Text{String: "rtl", Valid: true}
|
||||
p.Abridged = true
|
||||
})
|
||||
|
||||
base := url.Values{"library_id": []string{libUUID.String()}}
|
||||
titles := func(params url.Values) []string {
|
||||
status, rows := facetSearch(t, setup, mergeParams(base, params))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
return facetTitles(t, rows)
|
||||
}
|
||||
|
||||
// Unfiltered: both items, the additive params are default-neutral.
|
||||
got := titles(url.Values{})
|
||||
assert.Len(t, got, 2, "unfiltered search returns both items")
|
||||
|
||||
// Scalar-fuzzy: single value...
|
||||
got = titles(url.Values{"story_arc_filter": {"civil war"}})
|
||||
assert.Equal(t, []string{"Facet Alpha"}, got, "story_arc scalar-fuzzy")
|
||||
got = titles(url.Values{"imprint_filter": {"Vertigo"}})
|
||||
assert.Equal(t, []string{"Facet Alpha"}, got, "imprint scalar-fuzzy")
|
||||
got = titles(url.Values{"edition_filter": {"First Edition"}})
|
||||
assert.Equal(t, []string{"Facet Alpha"}, got, "edition scalar-fuzzy")
|
||||
// ...and comma = OR-within (multi-select).
|
||||
got = titles(url.Values{"story_arc_filter": {"civil war,infinity gauntlet"}})
|
||||
assert.Len(t, got, 2, "comma is OR-within the facet")
|
||||
|
||||
// Exact enums: single + multi.
|
||||
got = titles(url.Values{"age_rating_filter": {"Teen"}})
|
||||
assert.Equal(t, []string{"Facet Alpha"}, got, "age_rating exact")
|
||||
got = titles(url.Values{"age_rating_filter": {"Teen,Mature"}})
|
||||
assert.Len(t, got, 2, "age_rating multi-select")
|
||||
got = titles(url.Values{"format_group_filter": {"reflowable"}})
|
||||
assert.Len(t, got, 2, "format_group exact (create default)")
|
||||
status, _ := facetSearch(t, setup, mergeParams(base, url.Values{"format_group_filter": {"audiobook"}}))
|
||||
assert.Equal(t, http.StatusNotFound, status, "wrong format_group matches nothing")
|
||||
got = titles(url.Values{"reading_direction_filter": {"rtl"}})
|
||||
assert.Equal(t, []string{"Facet Beta"}, got, "reading_direction exact")
|
||||
got = titles(url.Values{"manga_type_filter": {"yes"}})
|
||||
assert.Equal(t, []string{"Facet Alpha"}, got, "manga_type exact (works cross-type by data)")
|
||||
|
||||
// Presence-bound bools: absent = no filter, value = exact.
|
||||
got = titles(url.Values{"explicit_filter": {"true"}})
|
||||
assert.Equal(t, []string{"Facet Alpha"}, got, "explicit=true")
|
||||
got = titles(url.Values{"explicit_filter": {"false"}})
|
||||
assert.Equal(t, []string{"Facet Beta"}, got, "explicit=false")
|
||||
got = titles(url.Values{"abridged_filter": {"true"}})
|
||||
assert.Equal(t, []string{"Facet Beta"}, got, "abridged=true")
|
||||
|
||||
// AND-across-facets: story_arc + age_rating intersect to Alpha.
|
||||
got = titles(url.Values{"story_arc_filter": {"civil war"}, "age_rating_filter": {"Teen"}})
|
||||
assert.Equal(t, []string{"Facet Alpha"}, got, "AND-across-facets")
|
||||
|
||||
// Language UX (Batch B): a display name normalizes through the
|
||||
// ingest map — "English" matches the row stored as "en".
|
||||
createItem("Facet Gamma", func(p *database.CreateMediaItemParams) {
|
||||
p.Language = pgtype.Text{String: "en", Valid: true}
|
||||
})
|
||||
got = titles(url.Values{"language_filter": {"English"}})
|
||||
assert.Equal(t, []string{"Facet Gamma"}, got, "display name matches ISO-stored rows")
|
||||
got = titles(url.Values{"language_filter": {"en"}})
|
||||
assert.Equal(t, []string{"Facet Gamma"}, got, "raw code still matches")
|
||||
}
|
||||
|
||||
// TestFacetsEndpoint covers GET /api/media-items/facets (Batch C): the
|
||||
// registry-driven value+count listing, language display names, and the
|
||||
// family-lens gates (hidden library → 403 on BOTH the facets and search
|
||||
// endpoints; bool/range facets → 400 because they render as toggles).
|
||||
func TestFacetsEndpoint(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
libName := fmt.Sprintf("Test Facet Listing Library %d", time.Now().UnixNano())
|
||||
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: libName,
|
||||
Description: pgtype.Text{String: "facet listing test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
|
||||
|
||||
// Two items share the genre; one carries the language + narrator.
|
||||
for _, title := range []string{"Facet List One", "Facet List Two"} {
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: title,
|
||||
Authors: []string{"Facet Listing Author"},
|
||||
Genre: pgtype.Text{String: "fantasy", Valid: true},
|
||||
Language: pgtype.Text{String: "en", Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/facet-listing/%s-%d.epub", title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: "Facet List Three",
|
||||
Authors: []string{"Facet Listing Author"},
|
||||
Genre: pgtype.Text{String: "horror", Valid: true},
|
||||
Narrators: []string{"Facet Listing Narrator"},
|
||||
FilePath: fmt.Sprintf("/tmp/facet-listing/three-%d.epub", time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
getFacets := func(params url.Values) (int, map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?"+params.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode >= 400 {
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
var body map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
return resp.StatusCode, body
|
||||
}
|
||||
|
||||
// Counts correct + ordered by count desc.
|
||||
status, body := getFacets(url.Values{
|
||||
"facet": {"genre"},
|
||||
"library_id": {libUUID.String()},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Equal(t, "genre", body["facet"])
|
||||
values, _ := body["values"].([]interface{})
|
||||
require.Len(t, values, 2)
|
||||
first, _ := values[0].(map[string]interface{})
|
||||
second, _ := values[1].(map[string]interface{})
|
||||
assert.Equal(t, "fantasy", first["value"])
|
||||
assert.Equal(t, float64(2), first["count"], "two items share the genre")
|
||||
assert.Equal(t, "horror", second["value"])
|
||||
assert.Equal(t, float64(1), second["count"])
|
||||
|
||||
// Language values carry display names (server owns display).
|
||||
status, body = getFacets(url.Values{
|
||||
"facet": {"language"},
|
||||
"library_id": {libUUID.String()},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
values, _ = body["values"].([]interface{})
|
||||
require.Len(t, values, 1)
|
||||
lang, _ := values[0].(map[string]interface{})
|
||||
assert.Equal(t, "en", lang["value"], "the raw code rides the param")
|
||||
assert.Equal(t, "English", lang["display"], "the name is for display")
|
||||
|
||||
// Bool facets render as toggles, not lists → 400.
|
||||
status, _ = getFacets(url.Values{
|
||||
"facet": {"explicit"},
|
||||
"library_id": {libUUID.String()},
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// Unknown facet → 400.
|
||||
status, _ = getFacets(url.Values{
|
||||
"facet": {"woodcut"},
|
||||
"library_id": {libUUID.String()},
|
||||
})
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// Family-lens: a library explicitly hidden from the regular user is
|
||||
// a 403 on the facets endpoint AND on the search endpoint (an
|
||||
// explicit library_id outside the visible set is never silently
|
||||
// empty — AGENTS.md → Library visibility).
|
||||
_, err = setup.DB.SetLibraryVisibility(ctx, database.SetLibraryVisibilityParams{
|
||||
UserID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
LibraryID: library.ID,
|
||||
IsVisible: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
status, _ = getFacets(url.Values{
|
||||
"facet": {"genre"},
|
||||
"library_id": {libUUID.String()},
|
||||
})
|
||||
assert.Equal(t, http.StatusForbidden, status, "hidden library → 403")
|
||||
|
||||
searchReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?library_id="+libUUID.String(), nil)
|
||||
searchReq.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
searchResp, err := http.DefaultClient.Do(searchReq)
|
||||
require.NoError(t, err)
|
||||
_, _ = io.Copy(io.Discard, searchResp.Body)
|
||||
_ = searchResp.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, searchResp.StatusCode, "search with hidden library_id → 403")
|
||||
}
|
||||
|
||||
func mergeParams(base, extra url.Values) url.Values {
|
||||
out := url.Values{}
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// facetFixtureBooks is the under-filter counts fixture: two fantasy
|
||||
// books (Pinecrest / Quartzmill) and one history book (Pinecrest).
|
||||
// Publisher names are trigram-disjoint on purpose — the scalar-fuzzy
|
||||
// filter is word_similarity, and near-identical names ("Ace Books" vs
|
||||
// "Baen Books") cross-match at 0.3+ (session gotcha).
|
||||
func facetFixtureBooks(t *testing.T, setup *TestServerSetup, libraryID pgtype.UUID, prefix string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
books := []struct {
|
||||
title string
|
||||
genre string
|
||||
publisher string
|
||||
}{
|
||||
{prefix + " Alpha", "fantasy", "Pinecrest"},
|
||||
{prefix + " Beta", "fantasy", "Quartzmill"},
|
||||
{prefix + " Gamma", "history", "Pinecrest"},
|
||||
}
|
||||
for _, b := range books {
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: b.title,
|
||||
Authors: []string{prefix + " Author"},
|
||||
Genre: pgtype.Text{String: b.genre, Valid: true},
|
||||
Publishers: []string{b.publisher},
|
||||
Language: pgtype.Text{String: "en", Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/facet-counts/%s-%d.epub", b.title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
|
||||
// facetCountValue is one decoded facet value+count.
|
||||
type facetCountValue struct {
|
||||
Value string `json:"value"`
|
||||
Count float64 `json:"count"`
|
||||
}
|
||||
|
||||
// facetCountsGet runs the all-facets endpoint (no facet param) and
|
||||
// decodes {"facets": {param: [{value, count}, …]}}.
|
||||
func facetCountsGet(t *testing.T, setup *TestServerSetup, params url.Values) (int, map[string][]facetCountValue) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?"+params.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode >= 400 {
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
var body struct {
|
||||
Facets map[string][]facetCountValue `json:"facets"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
return resp.StatusCode, body.Facets
|
||||
}
|
||||
|
||||
// requireFacetCount asserts one value's count within a facet.
|
||||
func requireFacetCount(t *testing.T, facet []facetCountValue, value string, want float64) {
|
||||
t.Helper()
|
||||
for _, v := range facet {
|
||||
if v.Value == value {
|
||||
require.Equal(t, want, v.Count, "facet value %q count", value)
|
||||
return
|
||||
}
|
||||
}
|
||||
require.Failf(t, "facet value missing", "%q not in facet", value)
|
||||
}
|
||||
|
||||
// TestFacetCountsUnderFilters (faceted plan Phase 7): the all-facets
|
||||
// shape computes each facet's counts UNDER the other active filters —
|
||||
// a facet's own block is omitted from its arm (classic faceted
|
||||
// semantics: the genre list stays complete while genre filters the
|
||||
// rest), multi-select is OR-within, and the family lens holds on the
|
||||
// new shape too.
|
||||
func TestFacetCountsUnderFilters(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
libName := fmt.Sprintf("Test Facet Counts %d", time.Now().UnixNano())
|
||||
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: libName,
|
||||
Description: pgtype.Text{String: "facet under-filter counts test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
|
||||
|
||||
hiddenLib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: fmt.Sprintf("Test Facet Counts Hidden %d", time.Now().UnixNano()),
|
||||
Description: pgtype.Text{String: "hidden for the regular user", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
hiddenUUID, err := uuid.FromBytes(hiddenLib.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, hiddenLib.ID) })
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
||||
hiddenLib.ID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
facetFixtureBooks(t, setup, library.ID, "Counts")
|
||||
// A book in the HIDDEN library: distinctive values that must never
|
||||
// surface for the regular user.
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: hiddenLib.ID,
|
||||
Title: "Counts Hidden Book",
|
||||
Authors: []string{"Counts Secret Author"},
|
||||
Genre: pgtype.Text{String: "cryptobotany", Valid: true},
|
||||
Publishers: []string{"Shadowpress"},
|
||||
FilePath: fmt.Sprintf("/tmp/facet-counts/hidden-%d.epub", time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// No filters: the v1 global-per-library counts.
|
||||
status, counts := facetCountsGet(t, setup, url.Values{"library_id": {libUUID.String()}})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
requireFacetCount(t, counts["genre"], "fantasy", 2)
|
||||
requireFacetCount(t, counts["genre"], "history", 1)
|
||||
requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
|
||||
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
|
||||
|
||||
// Under genre=fantasy: the genre facet STILL lists everything with
|
||||
// unfiltered counts (its own block is omitted from its arm), while
|
||||
// the publisher facet counts only within fantasy.
|
||||
status, counts = facetCountsGet(t, setup, url.Values{
|
||||
"library_id": {libUUID.String()},
|
||||
"genre_filter": {"fantasy"},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
requireFacetCount(t, counts["genre"], "fantasy", 2)
|
||||
requireFacetCount(t, counts["genre"], "history", 1)
|
||||
requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
|
||||
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
|
||||
|
||||
// OR-within: both genres → Pinecrest regains Alpha (fantasy) and
|
||||
// Gamma (history).
|
||||
status, counts = facetCountsGet(t, setup, url.Values{
|
||||
"library_id": {libUUID.String()},
|
||||
"genre_filter": {"fantasy,history"},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
requireFacetCount(t, counts["publisher"], "Pinecrest", 2)
|
||||
requireFacetCount(t, counts["publisher"], "Quartzmill", 1)
|
||||
|
||||
// AND-across: genre=history + publisher=Pinecrest → the genre facet
|
||||
// is unfiltered by ITSELF (its own block is omitted) but still
|
||||
// counts under the active publisher filter: both Pinecrest books
|
||||
// (Alpha fantasy, Gamma history) surface as 1 each, Beta (Quartzmill)
|
||||
// is filtered away. The publisher facet sees only history books, so
|
||||
// Pinecrest counts Gamma alone.
|
||||
status, counts = facetCountsGet(t, setup, url.Values{
|
||||
"library_id": {libUUID.String()},
|
||||
"genre_filter": {"history"},
|
||||
"publisher_filter": {"Pinecrest"},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
requireFacetCount(t, counts["genre"], "fantasy", 1)
|
||||
requireFacetCount(t, counts["publisher"], "Pinecrest", 1)
|
||||
|
||||
// All-Libraries view (no library_id): the visible library's values
|
||||
// are present, the hidden library's are absent everywhere.
|
||||
status, counts = facetCountsGet(t, setup, url.Values{})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
requireFacetCount(t, counts["genre"], "fantasy", 2)
|
||||
for _, v := range counts["genre"] {
|
||||
require.NotEqual(t, "cryptobotany", v.Value, "hidden-library genre must be absent")
|
||||
}
|
||||
for _, v := range counts["publisher"] {
|
||||
require.NotEqual(t, "Shadowpress", v.Value, "hidden-library publisher must be absent")
|
||||
}
|
||||
|
||||
// Family lens: an explicit HIDDEN library_id on the new shape is a
|
||||
// 403 (AGENTS standing rule — never a silent pass).
|
||||
status, _ = facetCountsGet(t, setup, url.Values{"library_id": {hiddenUUID.String()}})
|
||||
require.Equal(t, http.StatusForbidden, status)
|
||||
|
||||
// The pinned single-facet shape is untouched by the extension.
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/facets?facet=genre&library_id="+libUUID.String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var single struct {
|
||||
Facet string `json:"facet"`
|
||||
Values []facetCountValue `json:"values"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&single))
|
||||
require.Equal(t, "genre", single.Facet)
|
||||
requireFacetCount(t, single.Values, "fantasy", 2)
|
||||
requireFacetCount(t, single.Values, "history", 1)
|
||||
}
|
||||
|
||||
// TestFieldValuesAllLibraries pins the All-Books suggestion path
|
||||
// (filter-plan Phase 8): the field-values autocomplete treats
|
||||
// library_id as OPTIONAL — absent aggregates every library VISIBLE to
|
||||
// the caller (the per-user family lens is enforced inside the query),
|
||||
// and an explicit out-of-set library_id is a 403. This is the
|
||||
// regression net for the hard "library_id is required" 400 that broke
|
||||
// typing in the filter drawer on the All-Books view.
|
||||
func TestFieldValuesAllLibraries(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
|
||||
makeLib := func(name string) database.Libraries {
|
||||
t.Helper()
|
||||
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: fmt.Sprintf("Test Field Values %s %d", name, time.Now().UnixNano()),
|
||||
Description: pgtype.Text{String: "field values all-libraries test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
|
||||
return lib
|
||||
}
|
||||
visibleLib := makeLib("Visible")
|
||||
hiddenLib := makeLib("Hidden")
|
||||
hiddenUUID, err := uuid.FromBytes(hiddenLib.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
|
||||
// One book per library with IDENTICAL distinctive values — the
|
||||
// All-Books answer must aggregate across both libraries, and every
|
||||
// value is trigram-disjoint from any real data ("Verdana…" nonsense).
|
||||
for _, lib := range []struct {
|
||||
id pgtype.UUID
|
||||
name string
|
||||
}{{visibleLib.ID, "Visible"}, {hiddenLib.ID, "Hidden"}} {
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: lib.id,
|
||||
Title: fmt.Sprintf("Field Values %s Book", lib.name),
|
||||
Authors: []string{"Verdana Scribe"},
|
||||
Genre: pgtype.Text{String: "verdanafantasy", Valid: true},
|
||||
Series: pgtype.Text{String: "Verdana Saga", Valid: true},
|
||||
Language: pgtype.Text{String: "verdanish", Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/field-values/%s-%d.epub", lib.name, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
fieldValuesGet := func(params url.Values) (int, []map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+params.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
var body struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
return resp.StatusCode, body.Results
|
||||
}
|
||||
findValue := func(results []map[string]interface{}, value string) (found bool, count float64) {
|
||||
t.Helper()
|
||||
for _, r := range results {
|
||||
if r["value"] == value {
|
||||
found = true
|
||||
count, _ = r["count"].(float64)
|
||||
}
|
||||
}
|
||||
return found, count
|
||||
}
|
||||
|
||||
// Phase A — NO library_id (the All-Books view): 200, and the same
|
||||
// author surfaces ONCE with count 2 — values aggregate across every
|
||||
// visible library. The old bug answered 400 here.
|
||||
status, results := fieldValuesGet(url.Values{"author": {"Verdana"}})
|
||||
require.Equal(t, http.StatusOK, status, "no library_id must be 200, not the old hard 400")
|
||||
found, count := findValue(results, "Verdana Scribe")
|
||||
require.True(t, found, "author value present on the All-Books path")
|
||||
require.Equal(t, float64(2), count, "value aggregated across both visible libraries")
|
||||
for field, value := range map[string]string{
|
||||
"genre": "verdanafantasy", "series": "Verdana Saga", "language": "verdanish",
|
||||
} {
|
||||
status, results = fieldValuesGet(url.Values{field: {value}})
|
||||
require.Equal(t, http.StatusOK, status, "%s autocomplete with no library_id", field)
|
||||
found, count = findValue(results, value)
|
||||
require.True(t, found, "%s value present on the All-Books path", field)
|
||||
require.Equal(t, float64(2), count, "%s aggregated across both libraries", field)
|
||||
}
|
||||
|
||||
// Phase B — family lens on the same All-Books path: hiding one
|
||||
// library from this user halves the counts without any library_id
|
||||
// in the request at all.
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
||||
hiddenLib.ID, userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
status, results = fieldValuesGet(url.Values{"author": {"Verdana"}})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
found, count = findValue(results, "Verdana Scribe")
|
||||
require.True(t, found)
|
||||
require.Equal(t, float64(1), count, "hidden library's books leave the All-Books suggestions")
|
||||
|
||||
// Phase C — an explicit out-of-set library_id is a 403, never a
|
||||
// silent pass (AGENTS family-lens standing rule).
|
||||
status, _ = fieldValuesGet(url.Values{"author": {"Verdana"}, "library_id": {hiddenUUID.String()}})
|
||||
require.Equal(t, http.StatusForbidden, status)
|
||||
status, _ = fieldValuesGet(url.Values{"author": {"Verdana"}, "library_id": {uuid.New().String()}})
|
||||
require.Equal(t, http.StatusForbidden, status)
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
package main
|
||||
|
||||
// Filter rule builder integration tests (faceted plan Phase 9): the
|
||||
// composed engines behind the qf wire param — tree semantics (AND/OR/
|
||||
// negation/nesting), every user-relative field class (the "my data"
|
||||
// weld), the note-text substring pin, family lens, suggestion-under-
|
||||
// rules exactness, qf validation, and qf-over-flat precedence.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// qfSearch runs the search endpoint with arbitrary params (qf trees go
|
||||
// in the qf param as JSON) and returns (status, decoded rows).
|
||||
func qfSearch(t *testing.T, setup *TestServerSetup, token string, params url.Values) (int, []map[string]interface{}) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+params.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&rows))
|
||||
return resp.StatusCode, rows
|
||||
}
|
||||
|
||||
// qfRule is a compact rule builder for tests.
|
||||
func qfRule(field, op string, values ...string) map[string]interface{} {
|
||||
return map[string]interface{}{"field": field, "op": op, "values": values}
|
||||
}
|
||||
|
||||
func qfAll(children ...map[string]interface{}) string {
|
||||
return qfGroup("all", children...)
|
||||
}
|
||||
|
||||
func qfAny(children ...map[string]interface{}) string {
|
||||
return qfGroup("any", children...)
|
||||
}
|
||||
|
||||
func qfGroup(mode string, children ...map[string]interface{}) string {
|
||||
b, _ := json.Marshal(map[string]interface{}{"mode": mode, "children": children})
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func qfTitles(t *testing.T, rows []map[string]interface{}) []string {
|
||||
t.Helper()
|
||||
out := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
title, _ := r["title"].(string)
|
||||
out = append(out, title)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// builderFixtureLib builds one test library + four books with
|
||||
// trigram-disjoint values. Returns the library UUID and the book titles.
|
||||
func builderFixtureLib(t *testing.T, setup *TestServerSetup, userID uuid.UUID, name string) (uuid.UUID, pgtype.UUID) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: fmt.Sprintf("Test Builder %s %d", name, time.Now().UnixNano()),
|
||||
Description: pgtype.Text{String: "filter builder test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, library.ID) })
|
||||
|
||||
year := pgtype.Int4{Int32: 1999, Valid: true}
|
||||
datePublished := pgtype.Date{Time: time.Date(1999, 3, 1, 0, 0, 0, 0, time.UTC), Valid: true}
|
||||
books := []database.CreateMediaItemParams{
|
||||
{
|
||||
LibraryID: library.ID, Title: name + " Tide",
|
||||
Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "fantasy", Valid: true},
|
||||
Tags: []string{"space", "epic"}, PageCount: pgtype.Int4{Int32: 300, Valid: true},
|
||||
Explicit: true, Isbn: pgtype.Text{String: "1111111111111", Valid: true},
|
||||
Series: pgtype.Text{String: "Voss Saga", Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 1, Valid: true},
|
||||
Language: pgtype.Text{String: "en", Valid: true},
|
||||
DatePublished: datePublished, CopyrightYear: year,
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-tide-%d.epub", name, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
},
|
||||
{
|
||||
LibraryID: library.ID, Title: name + " Stone",
|
||||
Authors: []string{"Amara Voss"}, Genre: pgtype.Text{String: "history", Valid: true},
|
||||
Tags: []string{"space"}, PageCount: pgtype.Int4{Int32: 500, Valid: true},
|
||||
Series: pgtype.Text{String: "Voss Saga", Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 2, Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-stone-%d.epub", name, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 2048, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
},
|
||||
{
|
||||
LibraryID: library.ID, Title: name + " Fen",
|
||||
Authors: []string{"Benjamin Quill"}, Genre: pgtype.Text{String: "fantasy", Valid: true},
|
||||
Tags: []string{"epic"}, PageCount: pgtype.Int4{Int32: 200, Valid: true},
|
||||
Abridged: true,
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-fen-%d.epub", name, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 512, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
},
|
||||
{
|
||||
LibraryID: library.ID, Title: name + " Wick",
|
||||
Authors: []string{"Coraline Dar"}, Genre: pgtype.Text{String: "history", Valid: true},
|
||||
PageCount: pgtype.Int4{Int32: 700, Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-wick-%d.epub", name, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 4096, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
},
|
||||
}
|
||||
for i := range books {
|
||||
if books[i].Title == "" {
|
||||
books[i].Title = fmt.Sprintf("%s Book %d", name, i)
|
||||
}
|
||||
// created_at binds explicitly — a zero pgtype would insert NULL
|
||||
// and the date fields (added_at within N days) would never match.
|
||||
books[i].CreatedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
|
||||
books[i].ImportedAt = pgtype.Timestamptz{Time: time.Now().UTC(), Valid: true}
|
||||
// Ingest populates the *_search columns (normalized lowercase);
|
||||
// direct-create fixtures must mirror that or the *_search-side
|
||||
// match clauses (tags via tags_search) see nothing.
|
||||
if len(books[i].Authors) > 0 && books[i].AuthorsSearch == nil {
|
||||
lowered := make([]string, len(books[i].Authors))
|
||||
for j, a := range books[i].Authors {
|
||||
lowered[j] = strings.ToLower(a)
|
||||
}
|
||||
books[i].AuthorsSearch = lowered
|
||||
}
|
||||
if len(books[i].Tags) > 0 && books[i].TagsSearch == nil {
|
||||
lowered := make([]string, len(books[i].Tags))
|
||||
for j, tag := range books[i].Tags {
|
||||
lowered[j] = strings.ToLower(tag)
|
||||
}
|
||||
books[i].TagsSearch = lowered
|
||||
}
|
||||
_, err := setup.DB.CreateMediaItem(ctx, books[i])
|
||||
require.NoError(t, err, "create %s", books[i].Title)
|
||||
}
|
||||
return libUUID, library.ID
|
||||
}
|
||||
|
||||
// secondBuilderUser creates a second test user (the "other reader" for
|
||||
// the my-data weld tests) and returns (userID bytes, JWT).
|
||||
func secondBuilderUser(t *testing.T, setup *TestServerSetup) (uuid.UUID, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
suffix := time.Now().UnixNano()
|
||||
second, err := setup.DB.CreateUser(ctx, database.CreateUserParams{
|
||||
Email: fmt.Sprintf("testbuilder%d@tests.bookhoard.internal", suffix),
|
||||
Username: fmt.Sprintf("testbuilder%d", suffix),
|
||||
PasswordHash: "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq",
|
||||
FirstName: pgtype.Text{String: "Second", Valid: true},
|
||||
LastName: pgtype.Text{String: "Reader", Valid: true},
|
||||
Role: "user",
|
||||
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteUser(ctx, second.ID) })
|
||||
token := loginWithCredentials(t, setup.Server, second.Email, "Test@Pass123!")
|
||||
secondUUID, err := uuid.FromBytes(second.ID.Bytes[:])
|
||||
require.NoError(t, err)
|
||||
return secondUUID, token
|
||||
}
|
||||
|
||||
func TestFilterComposedSemantics(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Sem")
|
||||
|
||||
titles := func(qf string) []string {
|
||||
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||
status, rows := qfSearch(t, setup, setup.RegularToken, p)
|
||||
// 404 is the endpoint's pinned no-results contract — an empty set.
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||
return qfTitles(t, rows)
|
||||
}
|
||||
|
||||
// Plain rule: author contains (fuzzy, per-element).
|
||||
got := titles(qfAll(qfRule("author", "contains", "Voss")))
|
||||
assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "author contains")
|
||||
|
||||
// Negation as operator: doesn't contain.
|
||||
got = titles(qfAll(qfRule("author", "not_contains", "Voss")))
|
||||
assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "author not_contains")
|
||||
|
||||
// ALL group = AND.
|
||||
got = titles(qfAll(
|
||||
qfRule("author", "contains", "Voss"),
|
||||
qfRule("genre", "contains", "fantasy"),
|
||||
))
|
||||
assert.Equal(t, []string{"Sem Tide"}, got, "ALL group")
|
||||
|
||||
// ANY group = OR.
|
||||
got = titles(qfAny(
|
||||
qfRule("genre", "is", "history"),
|
||||
qfRule("abridged", "is_true"),
|
||||
))
|
||||
assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "ANY group")
|
||||
|
||||
// Nesting: group ANDed into its parent.
|
||||
got = titles(qfAll(
|
||||
qfRule("author", "contains", "Voss"),
|
||||
map[string]interface{}{"mode": "any", "children": []map[string]interface{}{
|
||||
qfRule("genre", "is", "history"),
|
||||
qfRule("page_count", "at_least", "400"),
|
||||
}},
|
||||
))
|
||||
assert.Equal(t, []string{"Sem Stone"}, got, "nested ANY inside ALL")
|
||||
|
||||
// Negated subgroup inside ALL: author Voss AND NOT(genre fantasy).
|
||||
got = titles(qfAll(
|
||||
qfRule("author", "contains", "Voss"),
|
||||
map[string]interface{}{"mode": "any", "children": []map[string]interface{}{
|
||||
qfRule("genre", "not_contains", "fantasy"),
|
||||
qfRule("title", "contains", "zzz-no-match"),
|
||||
}},
|
||||
))
|
||||
assert.Equal(t, []string{"Sem Stone"}, got, "negation inside nested group")
|
||||
|
||||
// Array exact + negation.
|
||||
got = titles(qfAll(qfRule("tags", "is", "epic")))
|
||||
assert.ElementsMatch(t, []string{"Sem Tide", "Sem Fen"}, got, "tags is")
|
||||
got = titles(qfAll(qfRule("tags", "is_not", "epic")))
|
||||
assert.ElementsMatch(t, []string{"Sem Stone", "Sem Wick"}, got, "tags isn't")
|
||||
|
||||
// Numbers: between / at_most.
|
||||
got = titles(qfAll(qfRule("page_count", "between", "250", "550")))
|
||||
assert.ElementsMatch(t, []string{"Sem Tide", "Sem Stone"}, got, "page_count between")
|
||||
got = titles(qfAll(qfRule("page_count", "at_most", "250")))
|
||||
assert.ElementsMatch(t, []string{"Sem Fen"}, got, "page_count at_most")
|
||||
|
||||
// Empty operators.
|
||||
got = titles(qfAll(qfRule("series", "empty")))
|
||||
assert.ElementsMatch(t, []string{"Sem Fen", "Sem Wick"}, got, "series is empty")
|
||||
got = titles(qfAll(qfRule("isbn", "missing")))
|
||||
assert.ElementsMatch(t, []string{"Sem Stone", "Sem Fen", "Sem Wick"}, got, "isbn missing")
|
||||
|
||||
// Enums exact + isn't.
|
||||
got = titles(qfAll(qfRule("age_rating", "is", "Teen")))
|
||||
assert.Empty(t, got, "age_rating is (none set)")
|
||||
got = titles(qfAll(qfRule("mime_type", "is", "application/epub+zip")))
|
||||
assert.Len(t, got, 4, "mime_type is")
|
||||
|
||||
// Series full membership: the alternate_info series matches too.
|
||||
ctx := context.Background()
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
`UPDATE media_items SET alternate_info = '{"alternate_series":"Quill Chronicles"}'::jsonb
|
||||
WHERE title = $1`, "Sem Fen")
|
||||
require.NoError(t, err)
|
||||
got = titles(qfAll(qfRule("series", "contains", "Quill Chron")))
|
||||
assert.Equal(t, []string{"Sem Fen"}, got, "series matches alternate_info membership")
|
||||
got = titles(qfAll(qfRule("series", "is", "Quill Chronicles")))
|
||||
assert.Equal(t, []string{"Sem Fen"}, got, "series exact matches alternate_info membership")
|
||||
|
||||
// Dates: within the last N days (all fixtures created now).
|
||||
got = titles(qfAll(qfRule("added_at", "within_days", "7")))
|
||||
assert.Len(t, got, 4, "added_at within the last 7 days")
|
||||
|
||||
// Identifiers: is / isn't (isn't includes books without one).
|
||||
got = titles(qfAll(qfRule("isbn", "is", "1111111111111")))
|
||||
assert.Equal(t, []string{"Sem Tide"}, got, "isbn is")
|
||||
got = titles(qfAll(qfRule("isbn", "is_not", "1111111111111")))
|
||||
assert.Len(t, got, 3, "isbn isn't includes books without an ISBN")
|
||||
|
||||
// Presence.
|
||||
got = titles(qfAll(qfRule("cover", "missing")))
|
||||
assert.Len(t, got, 4, "cover missing")
|
||||
|
||||
// Published year (flat year-range semantics: date_published falls
|
||||
// back to copyright_year).
|
||||
got = titles(qfAll(qfRule("published_year", "at_least", "1990")))
|
||||
assert.Equal(t, []string{"Sem Tide"}, got, "published_year at_least")
|
||||
}
|
||||
|
||||
func TestFilterUserRelativeFields(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userA := getRegularUserID(t, setup.DB)
|
||||
userBUUID, tokenB := secondBuilderUser(t, setup)
|
||||
ctx := context.Background()
|
||||
|
||||
libUUID, _ := builderFixtureLib(t, setup, userA, "Mine")
|
||||
|
||||
// Resolve the fixture media item ids by title.
|
||||
itemID := func(title string) pgtype.UUID {
|
||||
var id pgtype.UUID
|
||||
err := setup.DBPool.QueryRow(ctx,
|
||||
`SELECT id FROM media_items WHERE title = $1`, title).Scan(&id)
|
||||
require.NoError(t, err, "fixture item %s", title)
|
||||
return id
|
||||
}
|
||||
tideID := itemID("Mine Tide")
|
||||
stoneID := itemID("Mine Stone")
|
||||
fenID := itemID("Mine Fen")
|
||||
wickID := itemID("Mine Wick")
|
||||
|
||||
toPg := func(id uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: id, Valid: true} }
|
||||
aID := toPg(userA)
|
||||
bID := toPg(userBUUID)
|
||||
|
||||
// Reader A: reading Tide (50%), finished Stone, rated Tide 8,
|
||||
// reviewed Fen, noted on Tide ("the catharsis scene"), highlighted
|
||||
// Stone, time on Wick (2h), collection holding Tide.
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,0.5, now()), ($3,$4,1.0, now())`,
|
||||
tideID, aID, stoneID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at) VALUES ($1,$2,1.0, now())`,
|
||||
tideID, bID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,8)`, tideID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_ratings (media_item_id, user_id, rating) VALUES ($1,$2,3)`, tideID, bID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_reviews (media_item_id, user_id, review) VALUES ($1,$2,'splendid')`, fenID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_notes (media_item_id, user_id, content) VALUES ($1,$2,'remember the catharsis scene')`, tideID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO media_highlights (media_item_id, user_id, selection_text) VALUES ($1,$2,'the dark forest road')`, stoneID, aID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO reading_history (user_id, media_item_id, time_spent_seconds, pages_read) VALUES ($1,$2,7200,120), ($3,$4,600,30)`,
|
||||
aID, wickID, aID, tideID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var collID pgtype.UUID
|
||||
err = setup.DBPool.QueryRow(ctx,
|
||||
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||
aID, fmt.Sprintf("Test Builder Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1,$2)`, collID, tideID)
|
||||
require.NoError(t, err)
|
||||
|
||||
titlesFor := func(token string, qf string) []string {
|
||||
p := url.Values{"library_id": {libUUID.String()}, "qf": {qf}}
|
||||
status, rows := qfSearch(t, setup, token, p)
|
||||
// 404 = the pinned no-results contract (an empty set).
|
||||
require.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||
return qfTitles(t, rows)
|
||||
}
|
||||
|
||||
// Status: the "my data" weld — same tree, different readers.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "reading"))), "A: Tide is in progress")
|
||||
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("status", "is", "reading"))), "B: nothing in progress")
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(tokenB, qfAll(qfRule("status", "is", "finished"))), "B finished Tide; A did not")
|
||||
assert.ElementsMatch(t, []string{"Mine Fen", "Mine Wick"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("status", "is", "unread"))), "A unread")
|
||||
|
||||
// Progress percent.
|
||||
assert.ElementsMatch(t, []string{"Mine Stone"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("progress_percent", "at_least", "60"))))
|
||||
|
||||
// Last read.
|
||||
assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("last_read", "within_days", "7"))), 2)
|
||||
|
||||
// My rating: two worlds.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("my_rating", "at_least", "5"))))
|
||||
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("my_rating", "at_least", "5"))), "B rated 3")
|
||||
assert.ElementsMatch(t, []string{"Mine Fen", "Mine Stone", "Mine Wick"},
|
||||
titlesFor(tokenB, qfAll(qfRule("my_rating", "unrated"))), "B rated only Tide")
|
||||
|
||||
// Review presence.
|
||||
assert.ElementsMatch(t, []string{"Mine Fen"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("review", "has"))))
|
||||
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("review", "has"))))
|
||||
|
||||
// Notes presence + recency.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("notes", "has"))))
|
||||
|
||||
// Note text: substring pin + the weld. A's note says "catharsis":
|
||||
// searching "catharsis" matches, "Carthage" does NOT (substring,
|
||||
// not fuzzy), and B — no notes — sees nothing either way.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "catharsis"))))
|
||||
assert.Empty(t, titlesFor(setup.RegularToken, qfAll(qfRule("note_text", "contains", "Carthage"))),
|
||||
"substring semantics: catharsis ≠ Carthage")
|
||||
assert.Empty(t, titlesFor(tokenB, qfAll(qfRule("note_text", "contains", "catharsis"))),
|
||||
"my data: B's notes are the only notes in B's world")
|
||||
|
||||
// Highlight text.
|
||||
assert.ElementsMatch(t, []string{"Mine Stone"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("highlight_text", "contains", "dark forest"))))
|
||||
|
||||
// Time spent (minutes) + pages read.
|
||||
assert.ElementsMatch(t, []string{"Mine Wick"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("time_spent", "at_least", "60"))))
|
||||
assert.ElementsMatch(t, []string{"Mine Wick"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("pages_read", "at_least", "100"))))
|
||||
|
||||
// Collections: in / in none.
|
||||
assert.ElementsMatch(t, []string{"Mine Tide"},
|
||||
titlesFor(setup.RegularToken, qfAll(qfRule("collection", "in_any"))))
|
||||
assert.Len(t, titlesFor(tokenB, qfAll(qfRule("collection", "in_none"))), 4,
|
||||
"B is in no collections")
|
||||
|
||||
// Library field (holding structure): the fixture library holds all
|
||||
// four books.
|
||||
assert.Len(t, titlesFor(setup.RegularToken, qfAll(qfRule("library", "is", libUUID.String()))), 4)
|
||||
}
|
||||
|
||||
func TestFilterSuggestionsUnderRules(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Sug")
|
||||
|
||||
// Add a second distinct author+genre pair (trigram-disjoint).
|
||||
ctx := context.Background()
|
||||
_, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
Title: "Sug Dune",
|
||||
Authors: []string{"Zephyrine Hale"},
|
||||
Genre: pgtype.Text{String: "spacewestern", Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/builder/sug-dune-%d.epub", time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 128, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
suggest := func(field, typed, qf string) (int, []map[string]interface{}) {
|
||||
p := url.Values{"library_id": {libUUID.String()}, field: {typed}, "limit": {"20"}}
|
||||
if qf != "" {
|
||||
p.Set("qf", qf)
|
||||
}
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+p.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, nil
|
||||
}
|
||||
var body struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
return resp.StatusCode, body.Results
|
||||
}
|
||||
valuesOf := func(results []map[string]interface{}) []string {
|
||||
out := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
v, _ := r["value"].(string)
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Typed genre menu: both genres surface for their prefixes.
|
||||
status, results := suggest("genre", "fan", "")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "fantasy")
|
||||
|
||||
// The two-worlds example: author contains Voss → the genre menu is
|
||||
// HER genres only; author DOESN'T contain → her-only genres absent.
|
||||
status, results = suggest("genre", "fan", qfAll(qfRule("author", "contains", "Voss")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "fantasy")
|
||||
|
||||
// NOTE: "fan" + not_contains STILL lists fantasy — Quill's Fen is
|
||||
// fantasy too; the genre is not author-exclusive. The clean
|
||||
// two-worlds demo is spacewestern (only Hale's book), below.
|
||||
|
||||
// ...and the OTHER world's genre appears only when Voss is excluded.
|
||||
status, results = suggest("genre", "space", qfAll(qfRule("author", "not_contains", "Voss")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "spacewestern", "the other world's genre surfaces under negation")
|
||||
status, results = suggest("genre", "space", qfAll(qfRule("author", "contains", "Voss")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.NotContains(t, valuesOf(results), "spacewestern", "zero-match values never offered")
|
||||
|
||||
// Suggestions are symmetric: the genre rule constrains the AUTHOR
|
||||
// menu just the same.
|
||||
status, results = suggest("author", "zeph", qfAll(qfRule("genre", "is", "spacewestern")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "Zephyrine Hale")
|
||||
status, results = suggest("author", "amara", qfAll(qfRule("genre", "is", "spacewestern")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.NotContains(t, valuesOf(results), "Amara Voss", "zero-match values never offered")
|
||||
|
||||
// BROWSE (the user's click-the-box case): a blank typed query lists
|
||||
// the field's top values UNDER the other rules — "assign an author,
|
||||
// click publisher, see their publishers" without typing anything.
|
||||
// The wire sends a single space as the sentinel.
|
||||
status, results = suggest("genre", " ", qfAll(qfRule("author", "contains", "Quill")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "fantasy", "browse shows the field's values under the rules")
|
||||
assert.NotContains(t, valuesOf(results), "spacewestern", "zero-match values never offered")
|
||||
assert.NotContains(t, valuesOf(results), "history", "values outside the rules never offered")
|
||||
|
||||
// Browse with NO other rules in place: the blank query still lists
|
||||
// the field's values (the static similarity path would answer a
|
||||
// single space with nothing). Regression for the empty-browse bug.
|
||||
status, results = suggest("genre", " ", "")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "fantasy", "browse with no rules lists the field's values")
|
||||
status, results = suggest("author", " ", "")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "Amara Voss", "author browse with no rules")
|
||||
|
||||
// Library field menu: visibility-scoped, id values + name display
|
||||
// (this test's fixture libraries are named "Test Builder Sug …").
|
||||
status, results = suggest("library", "Test Builder", "")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
found := false
|
||||
for _, r := range results {
|
||||
if r["value"] == libUUID.String() {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "the visible library is offered")
|
||||
|
||||
// BROWSE the library field UNDER rules (the blank-typed click): the
|
||||
// id-source query must not choke on the unused typed parameter and
|
||||
// must list exactly the library holding the surviving books.
|
||||
status, results = suggest("library", " ", qfAll(qfRule("author", "contains", "Voss")))
|
||||
require.Equal(t, http.StatusOK, status, "library browse under rules (blank typed)")
|
||||
require.Len(t, results, 1, "exactly the fixture library survives the rules")
|
||||
assert.Equal(t, libUUID.String(), results[0]["value"])
|
||||
assert.InDelta(t, float64(2), results[0]["count"], 0.001, "both Voss books live there")
|
||||
|
||||
// Series browse (blank typed, under rules): full-membership source.
|
||||
status, results = suggest("series", " ", qfAll(qfRule("author", "contains", "Voss")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Contains(t, valuesOf(results), "Voss Saga")
|
||||
|
||||
// Added-by browse (blank typed): books without added_by → an empty
|
||||
// menu, but NO error (the parameter-discipline regression net).
|
||||
status, results = suggest("added_by", " ", "")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
|
||||
// Collection browse (blank typed): the container sources ride the
|
||||
// same id-source query — a collection holding a surviving book is
|
||||
// offered with the surviving count; zero-match containers never are.
|
||||
userA := getRegularUserID(t, setup.DB)
|
||||
var collID pgtype.UUID
|
||||
err = setup.DBPool.QueryRow(ctx,
|
||||
`INSERT INTO collections (user_id, name) VALUES ($1, $2) RETURNING id`,
|
||||
pgtype.UUID{Bytes: [16]byte(userA), Valid: true},
|
||||
fmt.Sprintf("Test Builder Sug Coll %d", time.Now().UnixNano())).Scan(&collID)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteCollection(ctx, collID) })
|
||||
var tideID pgtype.UUID
|
||||
err = setup.DBPool.QueryRow(ctx, `SELECT id FROM media_items WHERE title = $1`, "Sug Tide").Scan(&tideID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO collection_items (collection_id, media_item_id) VALUES ($1, $2)`, collID, tideID)
|
||||
require.NoError(t, err)
|
||||
|
||||
status, results = suggest("collection", " ", "")
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.True(t, func() bool {
|
||||
for _, r := range results {
|
||||
if r["value"] == collID.String() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}(), "the collection holding a book is offered on browse")
|
||||
|
||||
status, results = suggest("collection", " ", qfAll(qfRule("genre", "is", "spacewestern")))
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.Empty(t, results, "zero-match containers never offered")
|
||||
}
|
||||
|
||||
func TestFilterFamilyLens(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
ctx := context.Background()
|
||||
libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks")
|
||||
require.NoError(t, err)
|
||||
|
||||
makeLib := func(name string) (uuid.UUID, pgtype.UUID) {
|
||||
lib, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{
|
||||
Name: fmt.Sprintf("Test Lens %s %d", name, time.Now().UnixNano()),
|
||||
Description: pgtype.Text{String: "family lens test library", Valid: true},
|
||||
LibraryTypeID: libType.ID,
|
||||
CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
id, err := uuid.FromBytes(lib.ID.Bytes[0:16])
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = setup.DB.DeleteLibrary(ctx, lib.ID) })
|
||||
return id, lib.ID
|
||||
}
|
||||
_, visibleID := makeLib("Visible")
|
||||
hiddenUUID, hiddenID := makeLib("Hidden")
|
||||
|
||||
// One book in each, same author so the All-Books menu would
|
||||
// aggregate both if the lens failed.
|
||||
for _, lib := range []struct {
|
||||
id pgtype.UUID
|
||||
title string
|
||||
}{{visibleID, "Lens Vis Book"}, {hiddenID, "Lens Hid Book"}} {
|
||||
_, err = setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: lib.id, Title: lib.title,
|
||||
Authors: []string{"Lens Secret Author"},
|
||||
FilePath: fmt.Sprintf("/tmp/builder/%s-%d.epub", lib.title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: 64, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
||||
hiddenID, pgtype.UUID{Bytes: [16]byte(userID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
search := func(params url.Values) (int, []map[string]interface{}) {
|
||||
return qfSearch(t, setup, setup.RegularToken, params)
|
||||
}
|
||||
|
||||
// The composed search never sees the hidden library's books —
|
||||
// even when a rule names it explicitly.
|
||||
qfHide := qfAll(qfRule("library", "is", hiddenUUID.String()))
|
||||
status, rows := search(url.Values{"qf": {qfHide}})
|
||||
// 200-with-empty or the 404 no-results contract — either way: nothing.
|
||||
assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status)
|
||||
assert.Empty(t, rows, "a rule naming a hidden library matches nothing")
|
||||
|
||||
// Explicit out-of-set scope + a tree: 403 (never silent).
|
||||
status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "contains", "Lens"))}, "library_id": {hiddenUUID.String()}})
|
||||
assert.Equal(t, http.StatusForbidden, status)
|
||||
|
||||
// The library suggestion menu never offers hidden libraries (a
|
||||
// real typed prefix — empty typed values skip the autocomplete
|
||||
// branch entirely).
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/search?"+url.Values{
|
||||
"library": {"Test Lens"}, "limit": {"50"},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var body struct {
|
||||
Results []map[string]interface{} `json:"results"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
for _, r := range body.Results {
|
||||
assert.NotEqual(t, hiddenUUID.String(), r["value"], "hidden library never offered")
|
||||
}
|
||||
|
||||
// filter-count: out-of-set scope is a 403 too.
|
||||
req, _ = http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?qf="+url.QueryEscape(qfAll(qfRule("author", "contains", "Lens")))+"&library_id="+hiddenUUID.String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestFilterQFValidationAndPrecedence(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Val")
|
||||
|
||||
search := func(params url.Values) (int, []map[string]interface{}) {
|
||||
return qfSearch(t, setup, setup.RegularToken, params)
|
||||
}
|
||||
|
||||
// Unknown field → 400.
|
||||
status, _ := search(url.Values{"qf": {qfAll(qfRule("not_a_field", "is", "x"))}})
|
||||
assert.Equal(t, http.StatusBadRequest, status, "unknown field is a 400")
|
||||
|
||||
// Unknown operator → 400.
|
||||
status, _ = search(url.Values{"qf": {qfAll(qfRule("author", "explodes", "x"))}})
|
||||
assert.Equal(t, http.StatusBadRequest, status, "unknown operator is a 400")
|
||||
|
||||
// Operator not allowed on the field → 400.
|
||||
status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "contains", "3"))}})
|
||||
assert.Equal(t, http.StatusBadRequest, status, "operator not allowed on field")
|
||||
|
||||
// between with one value → 400.
|
||||
status, _ = search(url.Values{"qf": {qfAll(qfRule("page_count", "between", "3"))}})
|
||||
assert.Equal(t, http.StatusBadRequest, status, "between needs two values")
|
||||
|
||||
// Malformed JSON → 400.
|
||||
status, _ = search(url.Values{"qf": {"{not json"}})
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
|
||||
// SQL injection in a VALUE is just a value (bound parameter): no
|
||||
// error, no match.
|
||||
inj := qfAll(qfRule("author", "contains", "x' OR 1=1 --"))
|
||||
status, rows := search(url.Values{"qf": {inj}})
|
||||
assert.Contains(t, []int{http.StatusOK, http.StatusNotFound}, status,
|
||||
"injection attempt is a bound value, not SQL")
|
||||
assert.Empty(t, rows)
|
||||
|
||||
// Precedence: a URL carrying BOTH qf and flat params — qf wins.
|
||||
p := url.Values{
|
||||
"library_id": {libUUID.String()},
|
||||
"qf": {qfAll(qfRule("author", "contains", "Voss"))},
|
||||
"author_filter": {"Coraline Dar"},
|
||||
}
|
||||
status, rows = search(p)
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
assert.ElementsMatch(t, []string{"Val Tide", "Val Stone"}, qfTitles(t, rows),
|
||||
"qf wins over flat on a hand-crafted URL")
|
||||
|
||||
// Live count endpoint: rules + scope, a true COUNT.
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items/filter-count?"+url.Values{
|
||||
"qf": {qfAll(qfRule("author", "contains", "Voss"))}, "library_id": {libUUID.String()},
|
||||
}.Encode(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
var body struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&body))
|
||||
assert.Equal(t, 2, body.Count, "live count is the true match count")
|
||||
|
||||
// Empty group → 400 (parse validation).
|
||||
status, _ = search(url.Values{"qf": {`{"mode":"all","children":[]}`}})
|
||||
assert.Equal(t, http.StatusBadRequest, status)
|
||||
}
|
||||
|
||||
func TestFilterFlatEquivalence(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
userID := getRegularUserID(t, setup.DB)
|
||||
libUUID, _ := builderFixtureLib(t, setup, userID, "Eqv")
|
||||
scope := url.Values{"library_id": {libUUID.String()}}
|
||||
|
||||
// The tree and its flat deep link return the SAME rows (the smart
|
||||
// serialization promise: flat links keep their semantics).
|
||||
statusFlat, flatRows := qfSearch(t, setup, setup.RegularToken,
|
||||
mergeParams(scope, url.Values{"author_filter": {"Voss"}}))
|
||||
require.Equal(t, http.StatusOK, statusFlat)
|
||||
statusTree, treeRows := qfSearch(t, setup, setup.RegularToken,
|
||||
mergeParams(scope, url.Values{"qf": {qfAll(qfRule("author", "contains", "Voss"))}}))
|
||||
require.Equal(t, http.StatusOK, statusTree)
|
||||
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows),
|
||||
"tree and flat link return identical rows")
|
||||
|
||||
// Enum: exact flat vs tree is (both may 404-no-results — the empty
|
||||
// set on both sides is still equivalence).
|
||||
statusFlat, flatRows = qfSearch(t, setup, setup.RegularToken,
|
||||
mergeParams(scope, url.Values{"age_rating_filter": {"Teen"}}))
|
||||
statusTree, treeRows = qfSearch(t, setup, setup.RegularToken,
|
||||
mergeParams(scope, url.Values{"qf": {qfAll(qfRule("age_rating", "is", "Teen"))}}))
|
||||
require.Equal(t, statusFlat, statusTree, "same result status")
|
||||
assert.ElementsMatch(t, qfTitles(t, flatRows), qfTitles(t, treeRows))
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func (s *OwnershipAndAdminTestSuite) createItemViaDB(withFolder bool) (itemID, l
|
||||
item, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: "Ownership Suite Item",
|
||||
Author: pgtype.Text{String: "Suite Author", Valid: true},
|
||||
Authors: []string{"Suite Author"},
|
||||
FilePath: relPath,
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
@@ -183,8 +183,10 @@ func (s *OwnershipAndAdminTestSuite) TestHighlightDelete_CrossUserNoOp() {
|
||||
// create accounts even with self-service registration disabled, regular
|
||||
// users cannot, and duplicates conflict.
|
||||
func (s *OwnershipAndAdminTestSuite) TestAdminCreateUser() {
|
||||
suffix := fmt.Sprintf("%d", s.setup.DBPool.Config().MaxConns) // stable-ish uniquifier
|
||||
unique := fmt.Sprintf("admincreated%s", suffix)
|
||||
// Real unique suffix: MaxConns is forced to 1 by the harness, so the old
|
||||
// "stable-ish" uniquifier produced the same user every run and only
|
||||
// survived on the setup-time cleanup sweep of the NEXT run.
|
||||
unique := fmt.Sprintf("admincreated-%s", uuid.New().String()[:8])
|
||||
|
||||
// Regular user token → 403 (AdminMiddleware).
|
||||
payload, _ := json.Marshal(map[string]interface{}{
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package main
|
||||
|
||||
// Phase 13 (faceted plan): force rescan = original-scan semantics, and
|
||||
// the manually_edited filter.
|
||||
//
|
||||
// Force rescan contract: re-review every field on every book from all
|
||||
// sources (sidecars → folder structure → tags → fallbacks) on multi-
|
||||
// file books too — the pre-Phase-13 skip is gone — with
|
||||
// metadata_overrides as the only protection. A force pass also stamps
|
||||
// the fresh manifest with the current parser version (success-only)
|
||||
// and closes the book's open ledger issues.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/services"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// pinExtractorVersionOne pins the SHARED dev DB's extractor version to
|
||||
// the default 1 for the test's duration. Prior interrupted runs can
|
||||
// leave it bumped (an early test failure aborts before its restore
|
||||
// cleanup registers), and every stamp assertion in these tests assumes
|
||||
// the default. Restore-to-prior rides inside setExtractorVersion: the
|
||||
// suite leaves the setting exactly as it found it.
|
||||
func pinExtractorVersionOne(t *testing.T, setup *TestServerSetup) {
|
||||
t.Helper()
|
||||
setExtractorVersion(t, setup, 1)
|
||||
}
|
||||
|
||||
// forceScanTestFolder runs a FORCE scan over one folder.
|
||||
func forceScanTestFolder(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))
|
||||
scanner.SetForce(true)
|
||||
require.NoError(t, scanner.ScanFolders(context.Background()))
|
||||
}
|
||||
|
||||
func TestForceRescanMultifileSidecarApply(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
pinExtractorVersionOne(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Force Rescan %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
writeTestTrack(t, filepath.Join(folder, "Force Author", "Force Title", "01 First.mp3"))
|
||||
writeTestTrack(t, filepath.Join(folder, "Force Author", "Force Title", "02 Second.mp3"))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
rel := filepath.ToSlash("Force Author/Force Title")
|
||||
book, found := findAudioItemByPath(t, setup, libID, rel)
|
||||
require.True(t, found, "folder book must import")
|
||||
require.Equal(t, "Force Title", book.Title)
|
||||
|
||||
// Corrupt, then FORCE: the pre-Phase-13 skip is gone — the full
|
||||
// chain re-derives the title from the folder basename.
|
||||
corruptItemColumn(t, setup, book.ID, "title", "ZZZ")
|
||||
forceScanTestFolder(t, setup, admin, folder)
|
||||
book, found = findAudioItemByPath(t, setup, libID, rel)
|
||||
require.True(t, found)
|
||||
require.Equal(t, "Force Title", book.Title, "force rescan must re-apply folder-book metadata")
|
||||
require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "force rescan stamps the current parser version")
|
||||
|
||||
// metadata_overrides is the ONLY protection: a recorded override
|
||||
// survives the force pass untouched.
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
`UPDATE media_items SET metadata_overrides = ARRAY['title'], title = 'Manual Title' WHERE id = $1`,
|
||||
book.ID)
|
||||
require.NoError(t, err)
|
||||
forceScanTestFolder(t, setup, admin, folder)
|
||||
book, _ = findAudioItemByPath(t, setup, libID, rel)
|
||||
require.Equal(t, "Manual Title", book.Title, "metadata_overrides protect manual edits under force rescan")
|
||||
}
|
||||
|
||||
func TestForceRescanSingleFileSidecarReapply(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
pinExtractorVersionOne(t, setup)
|
||||
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Force Single %d", time.Now().UnixNano()), "ebooks")
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
bookDir := filepath.Join(folder, "Force Probe Author", "Force Probe Book")
|
||||
require.NoError(t, os.MkdirAll(bookDir, 0o755))
|
||||
opf := `<?xml version='1.0' encoding='utf-8'?>
|
||||
<package xmlns="http://www.idpf.org/2007/opf" version="2.0">
|
||||
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<dc:title>Force Probe Book</dc:title>
|
||||
<dc:creator>Force Probe Author</dc:creator>
|
||||
</metadata>
|
||||
</package>`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "metadata.opf"), []byte(opf), 0o644))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "Force Probe Book.epub"), []byte("dummy epub content"), 0o644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
rel := filepath.ToSlash("Force Probe Author/Force Probe Book/Force Probe Book.epub")
|
||||
book, found := findAudioItemByPath(t, setup, libID, rel)
|
||||
require.True(t, found)
|
||||
|
||||
// Single-file force rescan already ran the full chain — pin it, and
|
||||
// pin the NEW part: the manifest comes out stamped.
|
||||
corruptItemColumn(t, setup, book.ID, "title", "ZZZ")
|
||||
forceScanTestFolder(t, setup, admin, folder)
|
||||
book, found = findAudioItemByPath(t, setup, libID, rel)
|
||||
require.True(t, found)
|
||||
require.Equal(t, "Force Probe Book", book.Title, "force rescan re-derives sidecar metadata (single-file)")
|
||||
require.Equal(t, 1, storedManifestEv(t, setup, book.ID), "force rescan stamps the manifest")
|
||||
}
|
||||
|
||||
func TestManuallyEditedFilter(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
libID, _ := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Manual Edits %d", time.Now().UnixNano()), "ebooks")
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
createBook := func(path string) pgtype.UUID {
|
||||
created, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: libID,
|
||||
Title: path,
|
||||
Authors: []string{},
|
||||
FilePath: path,
|
||||
FileSize: pgtype.Int8{Int64: 64, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return created.ID
|
||||
}
|
||||
editedID := createBook("edited-book.epub")
|
||||
cleanID := createBook("clean-book.epub")
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
`UPDATE media_items SET metadata_overrides = ARRAY['title'] WHERE id = $1`,
|
||||
editedID)
|
||||
require.NoError(t, err)
|
||||
|
||||
qfIDs := func(op string) []string {
|
||||
status, rows := qfSearch(t, setup, setup.Token, map[string][]string{
|
||||
"qf": {qfAll(qfRule("manually_edited", op))},
|
||||
"library_id": {libID.String()},
|
||||
})
|
||||
require.Equal(t, http.StatusOK, status)
|
||||
ids := make([]string, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r["id"].(string))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
trueIDs := qfIDs("is_true")
|
||||
require.Len(t, trueIDs, 1)
|
||||
assert.Equal(t, editedID.String(), trueIDs[0], "is_true matches only manually-edited books")
|
||||
|
||||
falseIDs := qfIDs("is_false")
|
||||
require.Len(t, falseIDs, 1)
|
||||
assert.Equal(t, cleanID.String(), falseIDs[0], "is_false matches only untouched books")
|
||||
|
||||
// The deep-linkable JSON shape round-trips through the qf parser
|
||||
// (arity-none rule: no values on the wire).
|
||||
var parsed struct {
|
||||
Mode string `json:"mode"`
|
||||
Children []struct {
|
||||
Field string `json:"field"`
|
||||
} `json:"children"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(qfAll(qfRule("manually_edited", "is_true"))), &parsed))
|
||||
require.Len(t, parsed.Children, 1)
|
||||
assert.Equal(t, "manually_edited", parsed.Children[0].Field)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A2c end-to-end: an ABS sidecar whose series array mixes a real series and
|
||||
// an ordering construct must (a) land the real series on the item, (b)
|
||||
// auto-create a curated playlist collection for the ordering construct with
|
||||
// provenance + position, (c) survive a rescan without duplicates, (d)
|
||||
// support owner-only reorder, and (e) filter finished books for Up Next /
|
||||
// hide_finished with a completion marker when everything is read.
|
||||
func TestPlaylistCollectionsEndToEnd(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
adminID, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Playlist %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// The user's exact Witcher shape: real series + reading-order construct.
|
||||
itemDir := filepath.Join(folder, "Tag Author", "The Tagged Album")
|
||||
fixtureAudio(t, "tagged.mp3", filepath.Join(itemDir, "tagged.mp3"))
|
||||
sidecar := `{
|
||||
"title": "The Tagged Album",
|
||||
"authors": ["Tag Author"],
|
||||
"series": ["The Tagged Saga #2", "The Tagged Saga (Reading Order) #7"]
|
||||
}`
|
||||
require.NoError(t, os.WriteFile(filepath.Join(itemDir, "metadata.json"), []byte(sidecar), 0644))
|
||||
|
||||
scanTestFolder(t, setup, adminID, folder)
|
||||
|
||||
// (a) The item carries the REAL series only; the reading-order entry
|
||||
// never touches the series columns.
|
||||
var series string
|
||||
var seriesNum float64
|
||||
var foundPath string
|
||||
err := setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT series, series_number, file_path FROM media_items WHERE title = 'The Tagged Album'`,
|
||||
).Scan(&series, &seriesNum, &foundPath)
|
||||
if err != nil {
|
||||
var paths []string
|
||||
rows, qerr := setup.DBPool.Query(context.Background(),
|
||||
`SELECT file_path FROM media_items WHERE library_id = $1`, libID)
|
||||
if qerr == nil {
|
||||
for rows.Next() {
|
||||
var p string
|
||||
_ = rows.Scan(&p)
|
||||
paths = append(paths, p)
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
t.Fatalf("item not found by title (paths in library: %v): %v", paths, err)
|
||||
}
|
||||
require.Equal(t, "The Tagged Saga", series)
|
||||
require.Equal(t, 2.0, seriesNum)
|
||||
require.NotEmpty(t, foundPath)
|
||||
|
||||
// (b) The playlist collection exists with provenance, dashboard pin,
|
||||
// and the item at reading position 7.
|
||||
adminUUIDStr := adminID.String()
|
||||
var collID uuid.UUID
|
||||
var collName, collDesc string
|
||||
var showOnDashboard bool
|
||||
var viewSettingsRaw []byte
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT id, name, description, show_on_dashboard, view_settings
|
||||
FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||
adminID).Scan(&collID, &collName, &collDesc, &showOnDashboard, &viewSettingsRaw))
|
||||
require.True(t, showOnDashboard, "playlists are dashboard-eligible by design")
|
||||
require.Contains(t, string(viewSettingsRaw), "imported_from_series")
|
||||
require.Contains(t, string(viewSettingsRaw), "show_up_next")
|
||||
require.Contains(t, collDesc, "safe to edit or delete")
|
||||
_ = adminUUIDStr
|
||||
|
||||
var position *int32
|
||||
var itemCount int
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT ci.position, COUNT(*) OVER () FROM collection_items ci
|
||||
WHERE ci.collection_id = $1`, collID).Scan(&position, &itemCount))
|
||||
require.Equal(t, 1, itemCount)
|
||||
require.NotNil(t, position)
|
||||
require.Equal(t, int32(7), *position)
|
||||
_ = adminUUIDStr
|
||||
|
||||
// (c) Rescan: no duplicate collection, no duplicate membership.
|
||||
scanTestFolder(t, setup, adminID, folder)
|
||||
var collectionCount, membershipCount int
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT COUNT(*) FROM collections WHERE user_id = $1 AND name = 'The Tagged Saga (Reading Order)'`,
|
||||
adminID).Scan(&collectionCount))
|
||||
require.Equal(t, 1, collectionCount)
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT COUNT(*) FROM collection_items WHERE collection_id = $1`, collID).Scan(&membershipCount))
|
||||
require.Equal(t, 1, membershipCount)
|
||||
|
||||
// (d) Owner-only reorder: positions become 1..n; a non-owner gets 403.
|
||||
var itemID uuid.UUID
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT id FROM media_items WHERE title = 'The Tagged Album'`).Scan(&itemID))
|
||||
|
||||
reorderURL := fmt.Sprintf("%s/api/collections/%s/items/order", setup.Server.URL, collID.String())
|
||||
body, _ := json.Marshal(map[string][]string{"item_ids": {itemID.String()}})
|
||||
req, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body))
|
||||
req.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
_ = resp.Body.Close()
|
||||
|
||||
regularToken := setup.RegularToken
|
||||
req2, _ := http.NewRequest("PUT", reorderURL, bytes.NewBuffer(body))
|
||||
req2.Header.Set("Authorization", "Bearer "+regularToken)
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusForbidden, resp2.StatusCode)
|
||||
_ = resp2.Body.Close()
|
||||
|
||||
// (e) hide_finished + Up Next + completion marker.
|
||||
// Flip the collection's flag on, mark the single item finished for the
|
||||
// admin, and the dashboard section becomes Complete with no items.
|
||||
_, err = setup.DBPool.Exec(context.Background(),
|
||||
`UPDATE collections SET view_settings = view_settings || '{"hide_finished": true}'::jsonb WHERE id = $1`, collID)
|
||||
require.NoError(t, err)
|
||||
_, err = setup.DBPool.Exec(context.Background(),
|
||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
|
||||
VALUES ($1, $2, 1.0, NOW()) ON CONFLICT (media_item_id, user_id) DO UPDATE SET percentage = 1.0`,
|
||||
itemID, adminID)
|
||||
require.NoError(t, err)
|
||||
|
||||
sectionsURL := fmt.Sprintf("%s/api/dashboard/sections?library_id=%s", setup.Server.URL, libID.String())
|
||||
req3, _ := http.NewRequest("GET", sectionsURL, nil)
|
||||
req3.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp3, err := http.DefaultClient.Do(req3)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp3.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp3.StatusCode)
|
||||
|
||||
var payload struct {
|
||||
Sections []struct {
|
||||
CollectionID string `json:"collection_id"`
|
||||
Complete bool `json:"complete"`
|
||||
IsPlaylist bool `json:"is_playlist"`
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"sections"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp3.Body).Decode(&payload))
|
||||
|
||||
var found bool
|
||||
for _, s := range payload.Sections {
|
||||
if s.CollectionID == collID.String() {
|
||||
found = true
|
||||
if !s.Complete {
|
||||
b, _ := json.Marshal(s)
|
||||
t.Fatalf("section found but not complete: %s", b)
|
||||
}
|
||||
require.True(t, s.IsPlaylist)
|
||||
require.Empty(t, s.Items, "finished items must not show under hide_finished")
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
b, _ := json.Marshal(payload.Sections)
|
||||
t.Fatalf("playlist section missing; sections = %s", b)
|
||||
}
|
||||
require.True(t, found, "the playlist section must appear on the owner's dashboard")
|
||||
|
||||
// Up Next on the collection detail: the finished queue is empty.
|
||||
detailURL := fmt.Sprintf("%s/api/collections/%s", setup.Server.URL, collID.String())
|
||||
req4, _ := http.NewRequest("GET", detailURL, nil)
|
||||
req4.Header.Set("Authorization", "Bearer "+setup.Token)
|
||||
resp4, err := http.DefaultClient.Do(req4)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp4.Body.Close() }()
|
||||
var detail struct {
|
||||
UpNext []map[string]any `json:"up_next"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp4.Body).Decode(&detail))
|
||||
require.Empty(t, detail.UpNext, "nothing is up next once everything is finished")
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -119,8 +129,8 @@ func TestProcessingIssuesListInputValidation(t *testing.T) {
|
||||
{
|
||||
name: "Nonexistent library UUID",
|
||||
libraryID: "00000000-0000-0000-0000-000000000000",
|
||||
expectedStatus: http.StatusOK,
|
||||
description: "Should return empty array for valid but nonexistent UUID",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
description: "Family lens: an id outside the visible set is a 403, never a silent pass (nonexistent is trivially out-of-set)",
|
||||
},
|
||||
{
|
||||
name: "Special characters in UUID",
|
||||
@@ -293,8 +303,8 @@ func TestProcessingIssueStatsInputValidation(t *testing.T) {
|
||||
{
|
||||
name: "Nonexistent library UUID",
|
||||
libraryID: "00000000-0000-0000-0000-000000000000",
|
||||
expectedStatus: http.StatusOK,
|
||||
description: "Should return zero counts for valid but nonexistent UUID",
|
||||
expectedStatus: http.StatusForbidden,
|
||||
description: "Family lens: an id outside the visible set is a 403, never a silent pass (nonexistent is trivially out-of-set)",
|
||||
},
|
||||
{
|
||||
name: "Special characters in UUID",
|
||||
@@ -513,3 +523,166 @@ func TestProcessingIssuesInvalidTokens(t *testing.T) {
|
||||
assert.Equal(t, http.StatusUnauthorized, rec.Code)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Phase 11 (faceted plan): the honest ledger — row-less import failures,
|
||||
// truthful upserts, self-healing resolution, family lens.
|
||||
// ============================================================================
|
||||
|
||||
// createTestAdminOnce creates a fresh admin user (never the shared
|
||||
// testuser) with unique credentials and registers its cleanup.
|
||||
func createTestAdminOnce(t *testing.T, db *database.Queries, ts *httptest.Server) (UserTestData, string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
uniqueID := uuid.New().String()[:8]
|
||||
email := fmt.Sprintf("issuesadmin-%s@tests.bookhoard.internal", uniqueID)
|
||||
username := fmt.Sprintf("issuesadmin-%s", uniqueID)
|
||||
|
||||
passwordHash := "$2a$10$JjAtK7PPa1WexQC3AUGe8OXLeuseZ/haN1Mz7emMo6CfOvMiTVXWq"
|
||||
newUser, err := db.CreateUser(ctx, database.CreateUserParams{
|
||||
Email: email,
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
FirstName: pgtype.Text{String: "Issues", Valid: true},
|
||||
LastName: pgtype.Text{String: "Admin", Valid: true},
|
||||
Role: "admin",
|
||||
Timezone: pgtype.Text{String: "UTC", Valid: true},
|
||||
})
|
||||
require.NoError(t, err, "Should create admin test user")
|
||||
|
||||
userUUID, err := uuid.FromBytes(newUser.ID.Bytes[0:16])
|
||||
require.NoError(t, err, "Should parse admin user UUID")
|
||||
|
||||
t.Cleanup(func() {
|
||||
db.DeleteUser(context.Background(), pgtype.UUID{Bytes: [16]byte(userUUID), Valid: true})
|
||||
})
|
||||
|
||||
user := UserTestData{ID: userUUID, Email: email, Username: username, Password: "Test@Pass123!"}
|
||||
token := loginWithCredentials(t, ts, email, "Test@Pass123!")
|
||||
return user, token
|
||||
}
|
||||
|
||||
func TestProcessingIssuesLedgerImportFailures(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Issues Ledger %d", time.Now().UnixNano()), "ebooks")
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// A book whose library-relative path exceeds media_items.file_path's
|
||||
// VARCHAR(500): CreateMediaItem fails deterministically, and the
|
||||
// relative path itself cannot ever import — the classic row-less
|
||||
// ledger case. Filename and dir components stay within filesystem
|
||||
// limits; only the total path overflows.
|
||||
longRel := ""
|
||||
for i := 0; i < 9; i++ {
|
||||
longRel = filepath.Join(longRel, strings.Repeat("d", 60))
|
||||
}
|
||||
longRel = filepath.Join(longRel, "deepbook.epub")
|
||||
require.Greater(t, len(longRel), 500, "relative path must exceed the column limit")
|
||||
deepDir := filepath.Join(folder, filepath.Dir(longRel))
|
||||
require.NoError(t, os.MkdirAll(deepDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(folder, longRel), []byte("dummy epub content"), 0o644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
// Fail → the row-less issue appears, keyed by (library, path).
|
||||
open := func() []database.ListProcessingIssuesByLibraryRow {
|
||||
rows, err := setup.DB.ListProcessingIssuesByLibrary(ctx, libID)
|
||||
require.NoError(t, err)
|
||||
return rows
|
||||
}
|
||||
issues := open()
|
||||
require.Len(t, issues, 1, "the failed import must appear in the ledger")
|
||||
issue := issues[0]
|
||||
require.Equal(t, "import_failed", issue.IssueType)
|
||||
require.Equal(t, "error", issue.Severity)
|
||||
require.False(t, issue.MediaItemID.Valid, "row-less: no media_items row exists")
|
||||
require.Equal(t, filepath.ToSlash(longRel), issue.FilePath, "row-less key is the library-relative path")
|
||||
require.Empty(t, issue.Title, "no item row → no title")
|
||||
|
||||
// The JSON list surfaces the row-less shape: empty item id, title
|
||||
// fallen back to the path (pins the LEFT JOIN — an inner join would
|
||||
// silently drop the ledger's newest kind).
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libID.String()+"/issues/list", 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)
|
||||
var listed []map[string]interface{}
|
||||
require.NoError(t, json.NewDecoder(rec.Body).Decode(&listed))
|
||||
require.Len(t, listed, 1)
|
||||
assert.Equal(t, "", listed[0]["media_item_id"], "row-less issues carry no item id")
|
||||
assert.Equal(t, filepath.ToSlash(longRel), listed[0]["title"], "title falls back to the file path")
|
||||
assert.Equal(t, "import_failed", listed[0]["issue_type"])
|
||||
|
||||
// Repeat failure → the SAME row updates (no dups).
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
issues = open()
|
||||
require.Len(t, issues, 1, "repeat failures upsert, never duplicate")
|
||||
require.Equal(t, issue.ID, issues[0].ID)
|
||||
|
||||
// Success resolves by (library, path): a normal book imports at a
|
||||
// path that has an open row-less issue — seeded here to model the
|
||||
// transient failure the long path can't have (its path IS the
|
||||
// failure).
|
||||
goodRel := "Author/ledgerbook.epub"
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
|
||||
VALUES (NULL, $1, 'import_failed', 'seeded transient failure', 'error', $2)`,
|
||||
libID, goodRel)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(folder, "Author"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(folder, "Author", "ledgerbook.epub"), []byte("dummy epub content"), 0o644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
var resolved int
|
||||
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||
`SELECT count(*) FROM processing_issues WHERE library_id = $1 AND file_path = $2 AND resolved AND resolved_at IS NOT NULL`,
|
||||
libID, goodRel).Scan(&resolved))
|
||||
require.Equal(t, 1, resolved, "the file's successful import must resolve its open issue")
|
||||
require.Len(t, open(), 1, "only the long-path issue stays open")
|
||||
}
|
||||
|
||||
func TestProcessingIssuesFamilyLens(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
libID, _ := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Issues Lens %d", time.Now().UnixNano()), "ebooks")
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// A fresh admin (never the shared testuser) + a hidden-library row:
|
||||
// default-ALLOW visibility with one explicit hide.
|
||||
lensAdmin, lensToken := createTestAdminOnce(t, setup.DB, setup.Server)
|
||||
_, err := setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO library_visibility (library_id, user_id, is_visible) VALUES ($1, $2, false)`,
|
||||
libID, pgtype.UUID{Bytes: [16]byte(lensAdmin.ID), Valid: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
// An open issue in that library (row-less, no item to cascade).
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
|
||||
VALUES (NULL, $1, 'import_failed', 'lens probe', 'error', 'hidden/book.epub')`,
|
||||
libID)
|
||||
require.NoError(t, err)
|
||||
|
||||
get := func(token, suffix string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest("GET", "/api/libraries/"+libID.String()+"/issues/"+suffix, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
rec := httptest.NewRecorder()
|
||||
setup.Server.Config.Handler.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Hidden from the fresh admin → 403 on both endpoints (never silent).
|
||||
assert.Equal(t, http.StatusForbidden, get(lensToken, "list").Code, "hidden library list is a 403")
|
||||
assert.Equal(t, http.StatusForbidden, get(lensToken, "stats").Code, "hidden library stats are a 403")
|
||||
|
||||
// Visible to the shared admin (no hide row) → the rows show.
|
||||
assert.Equal(t, http.StatusOK, get(setup.Token, "list").Code)
|
||||
assert.Equal(t, http.StatusOK, get(setup.Token, "stats").Code)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func createTestQueueItem(t *testing.T, db *database.Queries, userID uuid.UUID) (
|
||||
mediaItem, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: library.ID,
|
||||
Title: "Test Book for Queue",
|
||||
Author: pgtype.Text{String: "Test Author", Valid: true},
|
||||
Authors: []string{"Test Author"},
|
||||
FilePath: "/tmp/test.epub",
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
// Bulk scan-state tests (faceted plan Part 2 Phase 3): the scanner's
|
||||
// one-query-per-library bulk load replaces the per-file
|
||||
// GetMediaItemByFilePath flood. Behavioral equivalence asserted at the
|
||||
// container level: unchanged rescans skip (updated_at stable), items
|
||||
// created OUTSIDE the scanner (API create path) are seen by the bulk
|
||||
// load, and size changes still take the update path.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestScanStateBulkLoad(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Scan State %d", time.Now().UnixNano()), "ebooks")
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// Two scanner-created fixture books.
|
||||
epubA := filepath.Join(folder, "Author A", "State Book A", "book.epub")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(epubA), 0755))
|
||||
require.NoError(t, os.WriteFile(epubA, []byte("state book A content"), 0644))
|
||||
epubB := filepath.Join(folder, "Author B", "State Book B", "book.epub")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(epubB), 0755))
|
||||
require.NoError(t, os.WriteFile(epubB, []byte("state book B content"), 0644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
items := listLibraryAudioItems(t, setup, libID)
|
||||
require.Len(t, items, 2, "both books ingested")
|
||||
var updatedA time.Time
|
||||
for _, item := range items {
|
||||
switch item.Title {
|
||||
case "State Book A":
|
||||
updatedA = item.UpdatedAt.Time
|
||||
case "State Book B":
|
||||
default:
|
||||
t.Errorf("unexpected item %q", item.Title)
|
||||
}
|
||||
}
|
||||
require.False(t, updatedA.IsZero())
|
||||
|
||||
// Unchanged rescan: the skip path must not touch the rows.
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
items = listLibraryAudioItems(t, setup, libID)
|
||||
require.Len(t, items, 2, "no duplicates on rescan")
|
||||
for _, item := range items {
|
||||
if item.Title == "State Book A" {
|
||||
assert.Equal(t, updatedA, item.UpdatedAt.Time, "unchanged rescan leaves updated_at stable")
|
||||
}
|
||||
}
|
||||
|
||||
// A row created by the API create path (NOT by a scan) must be seen
|
||||
// by the bulk load: the rescan skips it instead of re-creating. The
|
||||
// file must really exist (the archive pass missing-checks rows
|
||||
// whose files are absent).
|
||||
apiFile := filepath.Join(folder, "Author C", "API Made Book", "book.epub")
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(apiFile), 0755))
|
||||
require.NoError(t, os.WriteFile(apiFile, []byte("api made book content"), 0644))
|
||||
apiStat, err := os.Stat(apiFile)
|
||||
require.NoError(t, err)
|
||||
created, err := setup.DB.CreateMediaItem(context.Background(), database.CreateMediaItemParams{
|
||||
LibraryID: libID,
|
||||
Title: "API Made Book",
|
||||
Authors: []string{"API Author"},
|
||||
FilePath: "Author C/API Made Book/book.epub",
|
||||
FileSize: pgtype.Int8{Int64: apiStat.Size(), Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Stamp the file's real sha the way the scanner does — otherwise the
|
||||
// skip path's one-time hash self-heal fires and bumps updated_at.
|
||||
// The identifiers UPDATE also bumps updated_at, so read the row
|
||||
// AFTER the stamp for the baseline.
|
||||
apiSha := sha256.Sum256([]byte("api made book content"))
|
||||
stamped, err := setup.DB.UpdateMediaItemIdentifiers(context.Background(), database.UpdateMediaItemIdentifiersParams{
|
||||
ID: created.ID,
|
||||
FileSha256: pgtype.Text{String: hex.EncodeToString(apiSha[:]), Valid: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
apiUpdated := stamped.UpdatedAt.Time
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
items = listLibraryAudioItems(t, setup, libID)
|
||||
require.Len(t, items, 3, "API-created item survives the rescan as-is")
|
||||
for _, item := range items {
|
||||
if item.Title == "API Made Book" {
|
||||
assert.Equal(t, apiUpdated, item.UpdatedAt.Time, "bulk-loaded state sees scanner-external rows; skip path leaves them alone")
|
||||
}
|
||||
}
|
||||
|
||||
// Size change: the update path still fires (with a fresh full-row
|
||||
// read + hash recompute, then a write-through of the new size).
|
||||
require.NoError(t, os.WriteFile(epubA, []byte("state book A content — now longer"), 0644))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
items = listLibraryAudioItems(t, setup, libID)
|
||||
require.Len(t, items, 3, "size change updates in place, never duplicates")
|
||||
for _, item := range items {
|
||||
if item.Title == "State Book A" {
|
||||
assert.NotEqual(t, updatedA, item.UpdatedAt.Time, "size change takes the update path")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScanManifestLifecycle exercises the manifest-gated change ladder
|
||||
// end to end (faceted plan Part 2 Phase 4): seeding at create, repoint
|
||||
// reconciliation (mtime drift, identical content → no re-extraction),
|
||||
// same-size sidecar edits (the size-only detection blind spot),
|
||||
// playlist sync on the change path, and the no-leak guard.
|
||||
func TestScanManifestLifecycle(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := createTypedTestLibrary(t, setup, fmt.Sprintf("Test Manifest Lifecycle %d", time.Now().UnixNano()), "ebooks")
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
// The book ships WITH an ABS sidecar: the create path seeds the
|
||||
// manifest so the next scan compares instead of backfilling.
|
||||
bookDir := filepath.Join(folder, "Manifest Author", "Manifest Book")
|
||||
require.NoError(t, os.MkdirAll(bookDir, 0755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(bookDir, "book.epub"), []byte("manifest book content"), 0644))
|
||||
opfPath := filepath.Join(bookDir, "metadata.json")
|
||||
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifest Book"}`), 0644))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
|
||||
var itemID pgtype.UUID
|
||||
var itemIDStr, updatedStr string
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT id::text, updated_at::text FROM media_items WHERE title = 'Manifest Book' AND library_id = $1`,
|
||||
libID,
|
||||
).Scan(&itemIDStr, &updatedStr))
|
||||
require.NoError(t, itemID.Scan(itemIDStr))
|
||||
|
||||
// Seeded manifest exists.
|
||||
seeded, err := setup.DB.GetMediaScanState(context.Background(), itemID)
|
||||
require.NoError(t, err, "create path must seed the manifest")
|
||||
require.NotEmpty(t, seeded.Manifest)
|
||||
|
||||
// Unchanged rescan: skip path, nothing moves.
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
var updated2 string
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT updated_at::text FROM media_items WHERE id = $1`, itemID).Scan(&updated2))
|
||||
assert.Equal(t, updatedStr, updated2, "unchanged rescan is a pure skip")
|
||||
|
||||
// Repoint: mtime drift with identical content → reconciled, ZERO
|
||||
// re-extraction; the stored stat pair refreshes.
|
||||
past := time.Now().Add(-2 * time.Hour)
|
||||
require.NoError(t, os.Chtimes(opfPath, past, past))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
var updated3 string
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT updated_at::text FROM media_items WHERE id = $1`, itemID).Scan(&updated3))
|
||||
assert.Equal(t, updatedStr, updated3, "repoint reconciliation must not re-extract")
|
||||
reconciled, err := setup.DB.GetMediaScanState(context.Background(), itemID)
|
||||
require.NoError(t, err)
|
||||
var storedManifest struct {
|
||||
Entries []struct {
|
||||
Name string `json:"n"`
|
||||
MTime int64 `json:"m"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(reconciled.Manifest, &storedManifest))
|
||||
var sawRefreshed bool
|
||||
for _, e := range storedManifest.Entries {
|
||||
if e.Name == "metadata.json" && e.MTime == past.UnixNano() {
|
||||
sawRefreshed = true
|
||||
}
|
||||
}
|
||||
assert.True(t, sawRefreshed, "repoint refreshes the stored stat pair")
|
||||
|
||||
// Same-size sidecar edit — the size-only detection blind spot: the
|
||||
// stat pair moves, the hash differs → re-extract consumes the edit.
|
||||
sameLen := []byte(`{"title":"Manifesu Book"}`)
|
||||
require.Len(t, sameLen, len(`{"title":"Manifest Book"}`), "fixture must be a same-size edit")
|
||||
require.NoError(t, os.WriteFile(opfPath, sameLen, 0644))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
var title4 string
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT title FROM media_items WHERE id = $1`, itemID).Scan(&title4))
|
||||
assert.Equal(t, "Manifesu Book", title4, "same-size sidecar edit is detected and consumed")
|
||||
|
||||
// Playlist sync on the change path (Correction 2): the sidecar gains
|
||||
// a reading-order series → the collection is created for an
|
||||
// EXISTING book, and a rescan does not duplicate it.
|
||||
require.NoError(t, os.WriteFile(opfPath, []byte(`{"title":"Manifesu Book","series":["Lifecycle Saga (Reading Order) #1"]}`), 0644))
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
var collCount int
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
||||
).Scan(&collCount))
|
||||
assert.Equal(t, 1, collCount, "reading-order sidecar added to an existing book creates its collection")
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
require.NoError(t, setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT COUNT(*) FROM collections WHERE name = 'Lifecycle Saga (Reading Order)' AND user_id = $1`, admin,
|
||||
).Scan(&collCount))
|
||||
assert.Equal(t, 1, collCount, "rescan is idempotent — no duplicate collection")
|
||||
|
||||
// No-leak guard (plan Part 2 test 7): the library-scoped list emits
|
||||
// raw rows — scanner state must never ride along (structural by
|
||||
// design; this assertion keeps it honest).
|
||||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/media-items?library_id="+libID.String(), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, string(body), `"manifest":`, "scanner state must not leak into media list JSON")
|
||||
assert.NotContains(t, string(body), `"sidecar`, "scanner state must not leak into media list JSON")
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookhoard/internal/services"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRescanMediaItemPreservesAudioMetadata pins the 2026-10-21 incident:
|
||||
// a per-item rescan of a multi-file audiobook routes book-level metadata
|
||||
// (sidecars/tags — AudioMeta nil) through the generic row update, whose
|
||||
// unguarded write NULLed audio_metadata. A NULL payload drops every
|
||||
// track/chapter and the duration, so the book displays as 1 second long
|
||||
// and the audio reader loses its track manifest. Re-extraction must be
|
||||
// write-after-success: no fresh audio payload from the audio chain means
|
||||
// the existing column survives byte-for-byte.
|
||||
func TestRescanMediaItemPreservesAudioMetadata(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Audio Guard %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "01 One.mp3"))
|
||||
writeTestTrack(t, filepath.Join(folder, "Guard Author", "Guard Book", "02 Two.mp3"))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
rel := filepath.ToSlash("Guard Author/Guard Book")
|
||||
book, found := findAudioItemByPath(t, setup, libID, rel)
|
||||
require.True(t, found, "folder book must import")
|
||||
|
||||
audioBytes := func(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var raw []byte
|
||||
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
|
||||
return raw
|
||||
}
|
||||
|
||||
before := audioBytes(t)
|
||||
require.NotNil(t, before, "initial scan must populate audio_metadata")
|
||||
var payload services.AudioMetadataPayload
|
||||
require.NoError(t, json.Unmarshal(before, &payload), "payload must be valid JSON")
|
||||
require.Greater(t, payload.DurationSeconds, 0.0, "initial scan must carry a duration")
|
||||
require.Len(t, payload.Tracks, 2, "initial scan must carry the track manifest")
|
||||
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
defer func() { _ = scanner.Close() }()
|
||||
scanner.SetAdminID(admin)
|
||||
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false), "per-item rescan must succeed")
|
||||
|
||||
after := audioBytes(t)
|
||||
require.NotNil(t, after, "rescan must never NULL audio_metadata (2026-10-21 incident)")
|
||||
var afterPayload services.AudioMetadataPayload
|
||||
require.NoError(t, json.Unmarshal(after, &afterPayload), "surviving payload must stay valid JSON")
|
||||
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the rescan")
|
||||
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the rescan")
|
||||
}
|
||||
|
||||
// TestMetadataEditorSavePreservesAudioMetadata pins the editor-side half
|
||||
// of the incident: PUT /api/media-items/:id (the metadata editor) writes
|
||||
// the FULL row, and audio_metadata has no editor field — an unguarded
|
||||
// save NULLed every audiobook's payload on a plain metadata edit.
|
||||
func TestMetadataEditorSavePreservesAudioMetadata(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test Editor Guard %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "01 One.mp3"))
|
||||
writeTestTrack(t, filepath.Join(folder, "Editor Author", "Editor Book", "02 Two.mp3"))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
rel := filepath.ToSlash("Editor Author/Editor Book")
|
||||
book, found := findAudioItemByPath(t, setup, libID, rel)
|
||||
require.True(t, found, "folder book must import")
|
||||
|
||||
audioBytes := func(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var raw []byte
|
||||
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
|
||||
return raw
|
||||
}
|
||||
before := audioBytes(t)
|
||||
require.NotNil(t, before, "initial scan must populate audio_metadata")
|
||||
|
||||
// A plain metadata edit through the editor endpoint (admin token —
|
||||
// the editor is admin-only), no audio fields involved.
|
||||
editorSave(t, setup, book.ID, map[string]interface{}{
|
||||
"title": "Editor Book Renamed",
|
||||
"cover_action": "keep",
|
||||
})
|
||||
|
||||
after := audioBytes(t)
|
||||
require.NotNil(t, after, "metadata edit must never NULL audio_metadata")
|
||||
var afterPayload services.AudioMetadataPayload
|
||||
require.NoError(t, json.Unmarshal(after, &afterPayload))
|
||||
require.Greater(t, afterPayload.DurationSeconds, 0.0, "duration must survive the edit")
|
||||
require.Len(t, afterPayload.Tracks, 2, "track manifest must survive the edit")
|
||||
|
||||
renamed, found := findAudioItemByPath(t, setup, libID, rel)
|
||||
require.True(t, found)
|
||||
require.Equal(t, "Editor Book Renamed", renamed.Title, "the edit itself must still apply")
|
||||
}
|
||||
|
||||
// TestRescanMediaItemPreservesAudioMetadataSingleFileM4B is the incident
|
||||
// class verbatim: the failing books were SINGLE-FILE m4b audiobooks
|
||||
// (audio_metadata without a layout key), which RescanMediaItem routes
|
||||
// through the generic per-file updater — the same writer whose unguarded
|
||||
// pass NULLed their payloads. The whole flow must keep the m4b's
|
||||
// duration and chapters intact.
|
||||
func TestRescanMediaItemPreservesAudioMetadataSingleFileM4B(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
t.Cleanup(func() { _ = setup.Close() })
|
||||
|
||||
ctx := context.Background()
|
||||
admin, _ := moveTestAdminID(t, setup)
|
||||
libID, folder := audioTestLibrary(t, setup, fmt.Sprintf("Test M4B Guard %d", time.Now().UnixNano()))
|
||||
t.Cleanup(func() { deleteLibraries(t, setup, libID) })
|
||||
|
||||
writeTestTrackM4B(t, filepath.Join(folder, "M4B Guard Author", "M4B Guard Book.m4b"))
|
||||
|
||||
scanTestFolder(t, setup, admin, folder)
|
||||
rel := filepath.ToSlash("M4B Guard Author/M4B Guard Book.m4b")
|
||||
book, found := findAudioItemByPath(t, setup, libID, rel)
|
||||
require.True(t, found, "single-file m4b must import")
|
||||
|
||||
audioBytes := func(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
var raw []byte
|
||||
require.NoError(t, setup.DBPool.QueryRow(ctx,
|
||||
`SELECT audio_metadata FROM media_items WHERE id = $1`, book.ID).Scan(&raw))
|
||||
return raw
|
||||
}
|
||||
before := audioBytes(t)
|
||||
require.NotNil(t, before, "initial scan must populate audio_metadata")
|
||||
var beforePayload services.AudioMetadataPayload
|
||||
require.NoError(t, json.Unmarshal(before, &beforePayload))
|
||||
require.Greater(t, beforePayload.DurationSeconds, 0.0, "m4b probe must carry a real duration")
|
||||
|
||||
scanner := services.NewMediaScanner(setup.DB)
|
||||
defer func() { _ = scanner.Close() }()
|
||||
scanner.SetAdminID(admin)
|
||||
require.NoError(t, scanner.RescanMediaItem(ctx, book.ID, false))
|
||||
|
||||
after := audioBytes(t)
|
||||
require.NotNil(t, after, "m4b rescan must never NULL audio_metadata")
|
||||
var afterPayload services.AudioMetadataPayload
|
||||
require.NoError(t, json.Unmarshal(after, &afterPayload))
|
||||
require.Greater(t, afterPayload.DurationSeconds, 0.0, "m4b duration must survive the rescan")
|
||||
}
|
||||
|
||||
// editorSave drives PUT /api/media-items/:id as the admin (the metadata
|
||||
// editor's endpoint; admin-only per the handler).
|
||||
func editorSave(t *testing.T, setup *TestServerSetup, itemID pgtype.UUID, fields map[string]interface{}) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(fields)
|
||||
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/media-items/"+uuid.UUID(itemID.Bytes).String(), bytes.NewBuffer(body))
|
||||
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.StatusOK, resp.StatusCode, "metadata save must succeed")
|
||||
}
|
||||
@@ -413,10 +413,15 @@ func TestCollectionSearchLibraryFilter(t *testing.T) {
|
||||
shouldContain: book2ID,
|
||||
},
|
||||
{
|
||||
name: "invalid library_id",
|
||||
query: "Harry",
|
||||
libraryID: "00000000-0000-0000-0000-000000000000",
|
||||
expectedStatus: http.StatusNotFound,
|
||||
name: "invalid library_id",
|
||||
query: "Harry",
|
||||
libraryID: "00000000-0000-0000-0000-000000000000",
|
||||
// Family-lens standing rule (AGENTS.md): an explicit
|
||||
// library_id outside the visible set is a 403, never a
|
||||
// silent pass — and a nonexistent id is out-of-set too,
|
||||
// so existence is not enumerable. Behavior shipped in
|
||||
// 7e586df4 (Phase 1); this pin update was missed then.
|
||||
expectedStatus: http.StatusForbidden,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -544,7 +544,7 @@ func TestLibraryUsageStats(t *testing.T) {
|
||||
media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: pgtype.UUID{Bytes: [16]byte(lib), Valid: true},
|
||||
Title: title,
|
||||
Author: pgtype.Text{String: author, Valid: true},
|
||||
Authors: []string{author},
|
||||
Genre: pgtype.Text{String: genre, Valid: true},
|
||||
FilePath: fmt.Sprintf("/tmp/usage-%s-%d.bin", title, time.Now().UnixNano()),
|
||||
FileSize: pgtype.Int8{Int64: size, Valid: true},
|
||||
|
||||
@@ -95,7 +95,7 @@ func createSyncTestMedia(t *testing.T, db *database.Queries, libraryID pgtype.UU
|
||||
media, err := db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: "Sync Test Book",
|
||||
Author: pgtype.Text{String: "Test Author", Valid: true},
|
||||
Authors: []string{"Test Author"},
|
||||
FilePath: "/tmp/test.epub",
|
||||
FileSize: pgtype.Int8{Int64: 1024, Valid: true},
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
|
||||
"github.com/go-playground/validator/v10"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/labstack/echo/v5"
|
||||
@@ -181,6 +182,54 @@ func getUploadPath() string {
|
||||
return "./uploads" // Host path (left side of volume mount)
|
||||
}
|
||||
|
||||
// setSystemSettingRestored writes a system setting through the sanctioned
|
||||
// API path and registers a cleanup that restores the value the row had
|
||||
// BEFORE the write. The testuser-experience rule: the suite must leave
|
||||
// shared settings exactly as it found them — a hardcoded restore would
|
||||
// silently clobber a deliberate dev bump (e.g. scan_extractor_version
|
||||
// pinned mid-deploy), and the live dev server obeys this setting on its
|
||||
// next poll, so what tests write here reaches real scans.
|
||||
//
|
||||
// Writes ride the settings API (never raw SQL) because the registry
|
||||
// caches values and only the API path reloads it. If the row did not
|
||||
// exist before the write, the test value is left in place and the test
|
||||
// log says so — there is no sanctioned delete; settings rows that
|
||||
// matter (scan_extractor_version) always exist.
|
||||
func setSystemSettingRestored(t *testing.T, setup *TestServerSetup, key, value string) {
|
||||
t.Helper()
|
||||
var prior *string
|
||||
err := setup.DBPool.QueryRow(context.Background(),
|
||||
`SELECT setting_value FROM system_settings WHERE setting_key = $1`, key).Scan(&prior)
|
||||
if err != nil && err != pgx.ErrNoRows {
|
||||
require.NoError(t, err, "failed to read prior value of %s", key)
|
||||
}
|
||||
|
||||
setSettingViaAPI(t, setup, key, value)
|
||||
|
||||
if prior == nil {
|
||||
t.Logf("setSystemSettingRestored: %s had no prior row; leaving %q in place after the test", key, value)
|
||||
return
|
||||
}
|
||||
priorValue := *prior
|
||||
t.Cleanup(func() {
|
||||
setSettingViaAPI(t, setup, key, priorValue)
|
||||
})
|
||||
}
|
||||
|
||||
// setSettingViaAPI PUTs one setting through /api/system/settings — the
|
||||
// only sanctioned write path (the settings registry reloads here).
|
||||
func setSettingViaAPI(t *testing.T, setup *TestServerSetup, key, value string) {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]string{"key": key, "value": value})
|
||||
req, _ := http.NewRequest("PUT", setup.Server.URL+"/api/system/settings", bytes.NewBuffer(body))
|
||||
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.StatusOK, resp.StatusCode, "settings API is the only sanctioned write path")
|
||||
}
|
||||
|
||||
// getCachePath returns the appropriate cache path based on runtime environment
|
||||
func getCachePath() string {
|
||||
// Check for explicit override first
|
||||
@@ -467,6 +516,17 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
|
||||
queries := database.New(dbPool)
|
||||
|
||||
// Wire the tunable settings registry exactly like main.go: Load
|
||||
// populates the cache (missing rows fall back to compiled defaults)
|
||||
// and the SystemSettingsHandler must hold it or every
|
||||
// /api/system/settings call 503s ("settings registry not
|
||||
// initialized") — tests that write settings through the API (the
|
||||
// only sanctioned path) need this wired.
|
||||
settingsRegistry := database.NewSettingsRegistry(queries)
|
||||
if err := settingsRegistry.Load(context.Background()); err != nil {
|
||||
t.Logf("settings registry Load failed (defaults apply): %v", err)
|
||||
}
|
||||
|
||||
// Create login attempt tracker
|
||||
loginAttemptTracker := ratelimit.NewLoginAttemptTracker(5, 15*time.Minute, 5*time.Minute)
|
||||
|
||||
@@ -492,7 +552,12 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
conflictHandler := handlers.NewConflictHandler(queries, connManager, progressService)
|
||||
analyticsHandler := handlers.NewAnalyticsHandler(queries)
|
||||
queueHandler := handlers.NewQueueHandler(queries, queueProcessor)
|
||||
// main.go:112 wires the registry into the auth handler too — without it
|
||||
// defaultTimezone() short-circuits to UTC and every new user (Register +
|
||||
// AdminCreateUser) ignores the system default_timezone setting in tests.
|
||||
authHandler.SetSettings(settingsRegistry)
|
||||
systemSettingsHandler := handlers.NewSystemSettingsHandler(queries)
|
||||
systemSettingsHandler.SetSettings(settingsRegistry)
|
||||
processingIssuesHandler := handlers.NewProcessingIssuesHandler(queries)
|
||||
|
||||
// Create refactored handlers (matching main.go)
|
||||
@@ -507,7 +572,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
statisticsService := services.NewStatisticsService(queries)
|
||||
statsHandler := handlers.NewStatsHandler(statisticsService)
|
||||
seriesHandler := handlers.NewSeriesHandler(queries)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, libraryService, progressService, annotationService, worker)
|
||||
mediaHandler := handlers.NewMediaHandler(queries, dbPool, libraryService, progressService, annotationService, worker)
|
||||
matchingHandler := handlers.NewMatchingHandler(queries, connManager)
|
||||
|
||||
// Create conversion service for OPDS
|
||||
@@ -571,7 +636,7 @@ func setupTestServer(t *testing.T) *TestServerSetup {
|
||||
// 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),
|
||||
Settings: settingsRegistry,
|
||||
// main.go:246 passes the shared LibraryService here; omitting it
|
||||
// hands the kobo/kepub CFI converter a typed-nil (kobo markup
|
||||
// panic-recovered as 500s in TestProgressWeb_KoboIntegration).
|
||||
|
||||
@@ -157,37 +157,14 @@ func TestWebSocketProgressBroadcast(t *testing.T) {
|
||||
assert.Equal(t, "web", sourceDevice["type"])
|
||||
}
|
||||
|
||||
// TestWebSocketPingPong tests that ping/pong messages work correctly
|
||||
func TestWebSocketPingPong(t *testing.T) {
|
||||
setup := setupTestServer(t)
|
||||
|
||||
token := setup.Token
|
||||
|
||||
wsURL := strings.Replace(setup.Server.URL, "http", "ws", 1) + "/ws/sync?token=" + token
|
||||
ws, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
require.NoError(t, err)
|
||||
defer ws.Close()
|
||||
|
||||
// Read initial message
|
||||
ws.SetReadDeadline(time.Now().Add(5 * time.Second))
|
||||
_, _, _ = ws.ReadMessage()
|
||||
|
||||
// Send a ping message (as a text message for testing)
|
||||
err = ws.WriteMessage(websocket.TextMessage, []byte(`{"type":"ping"}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Server should respond with pong
|
||||
ws.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, msg, err := ws.ReadMessage()
|
||||
if err == nil {
|
||||
var pongMsg map[string]interface{}
|
||||
err = json.Unmarshal(msg, &pongMsg)
|
||||
if err == nil {
|
||||
// Server might respond with pong
|
||||
assert.Equal(t, "pong", pongMsg["type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
// TestWebSocketPingPong was deleted (2026-10-20, Phase 6 full-suite
|
||||
// repair): it asserted a TEXT-level {"type":"ping"} → {"type":"pong"}
|
||||
// exchange that no server code has ever implemented and no client
|
||||
// (web/src or Android) has ever sent — it passed vacuously through its
|
||||
// read-timeout path until interleaved scan_progress broadcasts started
|
||||
// failing the assert. Keepalive is the standard protocol-level ping the
|
||||
// hub already sends (websocket.go's ticker); if a text-level app ping
|
||||
// is ever wanted, build it server-side first, then re-pin it.
|
||||
|
||||
// TestWebSocketConnectionLimit tests that the server handles multiple connections
|
||||
func TestWebSocketConnectionLimit(t *testing.T) {
|
||||
|
||||
+157
-3
@@ -37,6 +37,11 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- User-level default audiobook playback speed (player plan §8): books
|
||||
-- without a per-book audio_playback_speed row pick this up. REAL
|
||||
-- mirrors the per-book table's column.
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS default_audio_speed REAL NOT NULL DEFAULT 1.0;
|
||||
|
||||
-- Create system_settings table
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -61,6 +66,8 @@ ALTER TABLE system_settings ADD COLUMN IF NOT EXISTS category VARCHAR(40);
|
||||
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category) VALUES
|
||||
('scan_poll_interval_seconds', '60', 'How often to scan all libraries (seconds)', 'int', '1', '3600', FALSE, 'scanner'),
|
||||
('auto_scan_enabled', 'true', 'Whether auto-scanning is enabled system-wide', 'bool', NULL, NULL, FALSE, 'scanner'),
|
||||
('scan_extractor_version', '1', 'Parser version stamped on each book scan manifest; raising it re-extracts metadata from books scanned under an older parser (bounded batch per scan)', 'int', '1', '1000000', FALSE, 'scanner'),
|
||||
('auto_playlist_collections', 'true', 'Auto-create curated playlist collections from sidecar series marked as reading orders', 'bool', NULL, NULL, FALSE, 'scanner'),
|
||||
('default_timezone', 'UTC', 'System default timezone', 'string', NULL, NULL, FALSE, 'general'),
|
||||
-- security / auth (live)
|
||||
('session_duration_seconds', '604800', 'How long a login session stays valid', 'int', '300', '31536000', FALSE, 'security'),
|
||||
@@ -136,7 +143,8 @@ CREATE TABLE IF NOT EXISTS media_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
author VARCHAR(255),
|
||||
subtitle TEXT, -- Series-y/edition subtitle from sidecars; display field, never part of `title`
|
||||
author VARCHAR(255), -- DEPRECATED for writes: display string regenerated from `authors` by trigger
|
||||
isbn VARCHAR(13), -- Still relevant for ebooks
|
||||
description TEXT,
|
||||
file_path VARCHAR(500) NOT NULL,
|
||||
@@ -144,10 +152,15 @@ CREATE TABLE IF NOT EXISTS media_items (
|
||||
mime_type VARCHAR(100),
|
||||
cover_image_path VARCHAR(500),
|
||||
series VARCHAR(255),
|
||||
series_number INTEGER,
|
||||
series_number DOUBLE PRECISION, -- Real ordinals are decimals (0.5, 1.5 novellas); was INTEGER
|
||||
tags TEXT[],
|
||||
narrators TEXT[], -- Audiobook narrators (sidecar first; user-editable)
|
||||
authors TEXT[] NOT NULL DEFAULT '{}', -- Write truth for authorship; `author` above is trigger-regenerated display
|
||||
authors_search TEXT[] NOT NULL DEFAULT '{}',
|
||||
publishers TEXT[] NOT NULL DEFAULT '{}', -- Write truth; `publisher` below is trigger-regenerated display
|
||||
publishers_search TEXT[] NOT NULL DEFAULT '{}',
|
||||
abridged BOOLEAN NOT NULL DEFAULT FALSE, -- Libation/Audible abridged flag
|
||||
explicit BOOLEAN NOT NULL DEFAULT FALSE, -- Adult flag from sidecars (ABS explicit / Audible isAdult)
|
||||
asin VARCHAR(20), -- Still relevant for ebooks
|
||||
date_published DATE,
|
||||
publisher VARCHAR(255),
|
||||
@@ -267,6 +280,30 @@ ALTER TABLE media_items ADD COLUMN IF NOT EXISTS contributors_search TEXT[];
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS narrators_search TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS abridged BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- A2b: subtitle + explicit flags, authors/publishers arrays (narrators
|
||||
-- precedent — arrays are write truth, varchar display columns regenerate).
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS subtitle TEXT;
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS explicit BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS authors TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS authors_search TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS publishers TEXT[] NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE media_items ADD COLUMN IF NOT EXISTS publishers_search TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Series ordinals are real-world decimals (0.5, 1.5 novellas); INTEGER
|
||||
-- truncated them to the wrong shelf order. In-place widening; no-op on a
|
||||
-- fresh install (column is already double precision from CREATE TABLE).
|
||||
ALTER TABLE media_items ALTER COLUMN series_number TYPE double precision
|
||||
USING series_number::double precision;
|
||||
|
||||
-- Backfill the arrays from the legacy single-value columns. Deliberately NO
|
||||
-- comma-splitting ("Tolkien, J.R.R." would corrupt); every existing row is
|
||||
-- effectively single-valued because the scanner historically kept only
|
||||
-- authors[0]. Guarded so the pass is free after the first startup.
|
||||
UPDATE media_items SET authors = ARRAY[author]
|
||||
WHERE author IS NOT NULL AND author <> '' AND authors = '{}';
|
||||
UPDATE media_items SET publishers = ARRAY[publisher]
|
||||
WHERE publisher IS NOT NULL AND publisher <> '' AND publishers = '{}';
|
||||
-- audio_metadata is declared in the CREATE TABLE above, which only fresh
|
||||
-- installs ever see — this ALTER is the upgrade path for pre-A2 installs
|
||||
-- (its absence 42703'd every scan and archive pass on upgraded deployments).
|
||||
@@ -316,6 +353,20 @@ BEGIN
|
||||
IF NEW.narrators_search IS NULL THEN
|
||||
NEW.narrators_search := '{}';
|
||||
END IF;
|
||||
-- A2b: authors/publishers arrays are NOT NULL — Go nil slices become
|
||||
-- explicit NULLs in params and would 23502 (the narrators lesson).
|
||||
IF NEW.authors IS NULL THEN
|
||||
NEW.authors := '{}';
|
||||
END IF;
|
||||
IF NEW.authors_search IS NULL THEN
|
||||
NEW.authors_search := '{}';
|
||||
END IF;
|
||||
IF NEW.publishers IS NULL THEN
|
||||
NEW.publishers := '{}';
|
||||
END IF;
|
||||
IF NEW.publishers_search IS NULL THEN
|
||||
NEW.publishers_search := '{}';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -333,6 +384,35 @@ CREATE TRIGGER set_library_type_name_on_library_change
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_library_type_name();
|
||||
|
||||
-- Display sync: `author`/`publisher` are denormalized display strings
|
||||
-- regenerated from the authors/publishers arrays on every write. The varchar
|
||||
-- columns face search (trgm), facets and API responses; the arrays are the
|
||||
-- write truth. array_to_string is STABLE on PG15 (not IMMUTABLE), so this
|
||||
-- must be a trigger — a generated column would reject it. Absent arrays
|
||||
-- yield NULL display (matches the legacy no-author row shape).
|
||||
DROP TRIGGER IF EXISTS sync_author_publisher_display_trg ON media_items;
|
||||
DROP FUNCTION IF EXISTS sync_author_publisher_display();
|
||||
CREATE OR REPLACE FUNCTION sync_author_publisher_display() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Defense in depth: normalize NULL arrays first (this trigger may fire
|
||||
-- before set_library_type_name depending on name-ordered execution).
|
||||
IF NEW.authors IS NULL THEN
|
||||
NEW.authors := '{}';
|
||||
END IF;
|
||||
IF NEW.publishers IS NULL THEN
|
||||
NEW.publishers := '{}';
|
||||
END IF;
|
||||
NEW.author := CASE WHEN NEW.authors = '{}' THEN NULL
|
||||
ELSE array_to_string(NEW.authors, ', ') END;
|
||||
NEW.publisher := CASE WHEN NEW.publishers = '{}' THEN NULL
|
||||
ELSE array_to_string(NEW.publishers, ', ') END;
|
||||
RETURN NEW;
|
||||
END $$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER sync_author_publisher_display_trg
|
||||
BEFORE INSERT OR UPDATE ON media_items
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION sync_author_publisher_display();
|
||||
|
||||
-- Add GIN indexes for pg_trgm fuzzy search performance
|
||||
CREATE INDEX IF NOT EXISTS idx_media_items_author_trgm
|
||||
ON media_items USING GIN (author gin_trgm_ops);
|
||||
@@ -546,8 +626,33 @@ CREATE INDEX IF NOT EXISTS idx_processing_issues_library
|
||||
CREATE INDEX IF NOT EXISTS idx_processing_issues_severity
|
||||
ON processing_issues(severity, resolved);
|
||||
|
||||
-- Phase 11 (faceted plan): the ledger records ROW-LESS import failures
|
||||
-- too — a book whose media_items row was never created has no item to
|
||||
-- reference. media_item_id becomes nullable; file_path carries the
|
||||
-- failing file's library-relative path for those rows. The table-level
|
||||
-- UNIQUE stays (item-backed safety; row-less rows have NULL item ids,
|
||||
-- which unique constraints treat as pairwise-distinct). The widened
|
||||
-- dedup key is a COALESCE expression index covering BOTH classes in one
|
||||
-- shape: item-backed = (item, library, type, ''), row-less =
|
||||
-- (zero-uuid, library, type, path). An expression index (not NULLS NOT
|
||||
-- DISTINCT) because the two classes key on different columns.
|
||||
ALTER TABLE processing_issues ALTER COLUMN media_item_id DROP NOT NULL;
|
||||
-- TEXT, deliberately NOT VARCHAR(500): the ledger must be able to
|
||||
-- record paths LONGER than media_items' limit — an over-long path is
|
||||
-- itself a failure mode, and a 500-char cap would make the issue row
|
||||
-- die on the exact error it exists to report.
|
||||
ALTER TABLE processing_issues ADD COLUMN IF NOT EXISTS file_path TEXT;
|
||||
ALTER TABLE processing_issues ALTER COLUMN file_path TYPE TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_processing_issues_dedup
|
||||
ON processing_issues (
|
||||
COALESCE(media_item_id, '00000000-0000-0000-0000-000000000000'::uuid),
|
||||
library_id,
|
||||
issue_type,
|
||||
COALESCE(file_path, '')
|
||||
);
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON TABLE processing_issues IS 'Tracks media items that cannot be properly processed in their assigned library due to format mismatches or other issues';
|
||||
COMMENT ON TABLE processing_issues IS 'Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.';
|
||||
|
||||
|
||||
-- ============================================
|
||||
@@ -1173,6 +1278,28 @@ CREATE TABLE IF NOT EXISTS media_item_formats (
|
||||
CREATE INDEX IF NOT EXISTS idx_media_item_formats_media ON media_item_formats(media_item_id, format_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_media_item_formats_sha256 ON media_item_formats(file_sha256);
|
||||
|
||||
-- media_scan_state: the scanner's per-item fingerprint cache (faceted
|
||||
-- plan Part 2). Deliberately NOT a media_items column: the library-
|
||||
-- scoped media list emits raw sqlc rows as JSON, and scanner
|
||||
-- bookkeeping must never leak into API payloads (nor churn the model
|
||||
-- or the contract pins). One row per item; cascade on delete.
|
||||
-- manifest shape v1:
|
||||
-- {"v":1,"entries":[{"n":"metadata.opf","s":3013013,"m":1697500000123456789,"h":"<sha256>"}, ...]}
|
||||
-- n basename, s size, m mtime as unix NANOS (second granularity
|
||||
-- would hide edits landing in the backfill scan's own second), h
|
||||
-- sha256 of the content — present for ALL entries, covers included:
|
||||
-- the repoint reconciliation needs a baseline hash for any stat-
|
||||
-- drifted sidecar. "ev" (extractor version, default 1): the parser
|
||||
-- version that produced the item's row. ABSENT means "v1-or-unknown"
|
||||
-- — backfill stamps are unset so a row never claims a version its
|
||||
-- data can't prove; every post-extraction store stamps the setting's
|
||||
-- current value. A stored ev != scan_extractor_version → re-extract
|
||||
-- (bounded per scan, resumable across polls).
|
||||
CREATE TABLE IF NOT EXISTS media_scan_state (
|
||||
media_item_id UUID PRIMARY KEY REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
manifest JSONB
|
||||
);
|
||||
|
||||
-- Create device_file_aliases table (track file paths per device for cross-device matching)
|
||||
CREATE TABLE IF NOT EXISTS device_file_aliases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -1256,12 +1383,21 @@ CREATE TABLE IF NOT EXISTS collection_items (
|
||||
added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
excluded BOOLEAN DEFAULT false,
|
||||
-- Manual playlist ordering (reading-order collections). NULL = un-ordered
|
||||
-- legacy item; ordering falls back to added_at ASC.
|
||||
position INTEGER,
|
||||
UNIQUE(collection_id, media_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_id);
|
||||
|
||||
-- Playlist ordering (A2c): position-first ordering for collection listings;
|
||||
-- NULLs fall back to added_at ASC.
|
||||
ALTER TABLE collection_items ADD COLUMN IF NOT EXISTS position INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_items_order
|
||||
ON collection_items(collection_id, position ASC NULLS LAST);
|
||||
|
||||
-- Index for excluding auto-assigned items
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_items_excluded ON collection_items(collection_id, excluded)
|
||||
WHERE excluded = true;
|
||||
@@ -1432,6 +1568,24 @@ CREATE TABLE IF NOT EXISTS reading_speed (
|
||||
CREATE INDEX IF NOT EXISTS idx_reading_speed_user ON reading_speed(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reading_speed_item ON reading_speed(media_item_id);
|
||||
|
||||
-- Per-user PER-BOOK playback speed preference (player plan §8): a
|
||||
-- preference, NOT a measurement (reading_speed holds measured rates),
|
||||
-- and NOT a reading_progress column (a speed tap must not create a
|
||||
-- "currently reading" row). Resolution lives at the chapters endpoint:
|
||||
-- per-book row → users.default_audio_speed → 1.0.
|
||||
CREATE TABLE IF NOT EXISTS audio_playback_speed (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
|
||||
speed REAL NOT NULL DEFAULT 1.0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, media_item_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_user ON audio_playback_speed(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audio_playback_speed_item ON audio_playback_speed(media_item_id);
|
||||
|
||||
-- Dictionary cache (for offline use)
|
||||
CREATE TABLE IF NOT EXISTS dictionary_cache (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
@@ -119,3 +119,298 @@ cover_image_path, has_conflict, format_group, badge`.
|
||||
- `media_items.audio_metadata` payload gained additive JSON keys
|
||||
`layout` and `tracks` (see schema.sql comment). Consumers parse
|
||||
additively; stored single-file payloads are unchanged.
|
||||
|
||||
## 2026-10-19 — A2b: metadata array/model overhaul (phase 1)
|
||||
|
||||
- `media_items.series_number` widened INTEGER → DOUBLE PRECISION across all
|
||||
surfaces. JSON serializers emit the same key; values are JSON numbers, so
|
||||
integer-valued ordinals are byte-identical ("3") and decimal ordinals
|
||||
(0.5/1.5) now serialize as decimals instead of truncating. Clients reading
|
||||
JSON numbers are unaffected; the Android client parses either form.
|
||||
- `GET /api/media-items*` / detail responses gain ADDITIVE keys:
|
||||
`subtitle` (string), `explicit` (bool), `authors` ([]string, write truth —
|
||||
legacy `author` display string still emitted, now trigger-regenerated),
|
||||
`publishers` ([]string; legacy `publisher` likewise).
|
||||
- `POST/PUT /api/media-items*` accept ADDITIVE request keys `subtitle`,
|
||||
`explicit`, `authors`, `publishers`; legacy `author`/`publisher` singles
|
||||
still accepted (arrays win when both present).
|
||||
- Override keys unchanged: `author`/`publisher` override names are preserved
|
||||
for existing rows and now protect the arrays.
|
||||
|
||||
## 2026-10-19 — A2c: collections/playlists phase
|
||||
|
||||
- `GET /api/collections/:id` gained ADDITIVE `up_next` (array of
|
||||
`{id, title, author, cover, format, progress}` — the static collection's
|
||||
reading queue head in position order, viewer-progress-aware; empty array
|
||||
for smart collections).
|
||||
- `GET /api/dashboard/sections` rows gained ADDITIVE `is_playlist` (bool —
|
||||
collection was imported from a sidecar ordering construct) and
|
||||
`complete` (bool — hide_finished filtered every item out of a non-empty
|
||||
collection; render "playlist complete").
|
||||
- `PUT /api/collections/:id/items/order` is a NEW endpoint (owner-only,
|
||||
full ordered id list → positions 1..n).
|
||||
- User collections are dashboard-eligible by default (the
|
||||
show_on_dashboard gate is gone from GetUserCollectionsForDashboard);
|
||||
hiding stays dashboard-prefs (`hidden_collections`) territory.
|
||||
- `GET /api/media-items` (unscoped resolved-map branch) and
|
||||
`GET /api/media-items/:id` gained the same ADDITIVE keys as the raw
|
||||
branch: `subtitle`, `explicit`, `authors`, `publishers`.
|
||||
- `GET /api/media-items/search` rows gained the same ADDITIVE keys plus
|
||||
the search pairs `authors_search`, `publishers_search`,
|
||||
`narrators_search` (the unified SELECT now carries the new columns).
|
||||
- `author_filter` / `publisher_filter` are now per-element against the
|
||||
arrays; a comma-joined value is OR-within-facet multi-select. The
|
||||
param NAMES and single-value behavior are unchanged.
|
||||
|
||||
## 2026-10-19 — Faceted navigation Batch A: new filter facets
|
||||
|
||||
- `GET /api/media-items/search` accepts ADDITIVE REQUEST params (query
|
||||
string, response shapes unchanged): `story_arc_filter`,
|
||||
`imprint_filter`, `edition_filter` (scalar-fuzzy),
|
||||
`age_rating_filter`, `format_group_filter`, `manga_type_filter`,
|
||||
`reading_direction_filter` (exact), `explicit_filter`,
|
||||
`abridged_filter` (bool: absent = unset, "true"/"false" = bound).
|
||||
A comma-joined value is OR-within-facet multi-select on every new
|
||||
TEXT facet (the SQL splits via string_to_array). `services/facet_registry.go`
|
||||
is the Go-side source of truth; the drift-guard unit test
|
||||
(`TestFacetRegistrySQLDrift`) pins registry ↔ SQL agreement.
|
||||
- Genre model v2 changes SCANNER OUTPUT (not a wire contract): sidecar
|
||||
genre selection now stores only top-level shelves in `genre`
|
||||
(subgenres become tags mapped to their parent shelf; unknown-only
|
||||
input leaves `genre` empty). Existing rows are unaffected until a
|
||||
rescan/re-extract.
|
||||
|
||||
## 2026-10-19 — Faceted navigation Batch B/C: chips, FacetLink, facets endpoint
|
||||
|
||||
- `GET /api/media-items/facets?facet=X&library_id=...` is a NEW pinned
|
||||
surface (registry-driven value+count listing for the filter panel):
|
||||
200 → `{facet, values[]: {value, display?, count}}` (display present
|
||||
only for language codes → names); 400 for unknown facets and for
|
||||
bool/range facets (they render as toggles, not lists); 403 for an
|
||||
explicit library_id outside the requester's visible set.
|
||||
- `GET /api/media-items/search` (JSON) and its HTMX grid partial now
|
||||
share ONE param parser (`handlers.ParseMediaSearchParams`). Behavior
|
||||
notes: the HTMX branch previously dropped `publisher_filter` and
|
||||
`narrator_filter` silently — it now honors every facet param; and an
|
||||
explicit hidden-library `library_id` is now a 403 on BOTH branches
|
||||
(was: silent empty set on JSON). Response shapes are untouched; the
|
||||
search row pin (47 keys) is unchanged.
|
||||
- `language_filter` now normalizes comma elements through the ingest
|
||||
language map, so display names ("English") match rows stored as ISO
|
||||
codes ("en"). The param name is unchanged; single-code values behave
|
||||
identically.
|
||||
- Web: the bookshelf gains an active-filter chips bar (client-local
|
||||
form state rendered by Alpine; templ owns the markup) and
|
||||
`applyURLFilters` accepts the new facet params.
|
||||
|
||||
## 2026-10-19 — Scanner Phase 4: manifest-gated sidecar detection
|
||||
|
||||
- No wire change: `media_scan_state` lives in its own table, so the
|
||||
raw-row list JSON cannot contain scanner state (guarded by an
|
||||
assertion in TestScanManifestLifecycle).
|
||||
- Scanner BEHAVIOR: size-stable books are now checked against a stored
|
||||
sidecar fingerprint (`{"v":1,"entries":[{n,s,m-nanos,h-sha256},...]}`).
|
||||
Stat-pair-identical → skip with zero reads; stat drift → hash
|
||||
reconciliation (equal hash = host repoint, stat pair refreshed, no
|
||||
re-extraction; differing hash = metadata re-extract + fresh manifest,
|
||||
metadata_overrides protected, playlist collections synced on the
|
||||
change path — a "(Reading Order)" entry added to an EXISTING book now
|
||||
creates its collection). NULL stored manifest → backfill only, no
|
||||
re-extraction (deploy-day rule). Multifile folder/zip books keep the
|
||||
Phase 3 behavior (their change path does not consume sidecar
|
||||
extractors yet — gating them would flip books "changed" for nothing).
|
||||
|
||||
## 2026-10-19 — Scanner Phase 5: quiet scans, coalescing, watcher probe
|
||||
|
||||
- `POST /api/scanner/scan` now answers **409** (with `active_job_id`)
|
||||
when a scan for the same library is already pending/running —
|
||||
coalescing instead of stacking a second full scan. The endpoint was
|
||||
never pinned; no existing response shape changed. Poll ticks and
|
||||
watcher events coalesce silently (no second job enqueued).
|
||||
- Scan jobs carry a `trigger` label (poll/watcher/admin/initial); the
|
||||
per-scan log is now ONE summary line:
|
||||
`Scan of <folders> (trigger: <t>): N files, N changed, N new, Xs, N errors`.
|
||||
Per-file "processing/success/skipping" chatter is gone; inserts,
|
||||
changes, and errors still log individually.
|
||||
- `JobResult` gains additive `job_type` (coalescing needs it; the
|
||||
scan-progress surfaces tolerate unknown keys).
|
||||
- Watcher: a failed capability probe (one `Add` on a root folder)
|
||||
disables the watcher with ONE startup line instead of per-directory
|
||||
warning spam.
|
||||
|
||||
## 2026-10-20 — Stale pin repair (behavior shipped in 7e586df4)
|
||||
|
||||
- `GET /api/media-items/search` with an explicit `library_id` outside
|
||||
the requesting user's visible set — INCLUDING a nonexistent id —
|
||||
answers **403** (`library not accessible`), not 404. The family-lens
|
||||
standing rule (AGENTS.md) mandates 403 for every out-of-set id so
|
||||
library existence is not enumerable; Phase 1 (7e586df4) shipped it
|
||||
and documented it here, but the `invalid library_id` subtest in
|
||||
search_test.go still pinned the pre-family-lens 404. Pin widened to
|
||||
the shipped 403; no server change (discovered during the Phase 6
|
||||
full-suite run; the Phase 6 diff touches no handler code).
|
||||
- `TestWebSocketPingPong` DELETED: it asserted a text-level
|
||||
`{"type":"ping"}` → `{"type":"pong"}` exchange that no server code
|
||||
has ever implemented and no client (web/src, Android) has ever sent.
|
||||
It passed vacuously through its read-timeout path (deadline error
|
||||
skipped the asserts) until interleaved `scan_progress` broadcasts
|
||||
from the live server's scans started reaching the socket first.
|
||||
Keepalive is the standard protocol-level ping the hub already sends.
|
||||
Never-built behavior is not a pinnable contract.
|
||||
|
||||
## 2026-10-20 — Scanner Phase 7: all-facets shape + SSR facet panel
|
||||
|
||||
- `GET /api/media-items/facets` gains a SECOND shape: with NO `facet`
|
||||
param it answers `{"facets": {<param>: [{"value","display?","count"},
|
||||
…]}}` — every listable facet in one response, counts computed under
|
||||
the ACTIVE FILTERS (the same query params the search endpoint takes;
|
||||
each facet is counted under the others — its own filter does not
|
||||
restrict its own list). `library_id` is optional on this shape:
|
||||
absent = the All-Libraries view. Family-lens unchanged: an explicit
|
||||
out-of-set `library_id` is 403 on both shapes.
|
||||
- The PINNED single-facet shape (`?facet=X&library_id=…` →
|
||||
`{facet, values[]}`) is byte-identical in semantics; its
|
||||
implementation now routes through the same UNION-ALL query with
|
||||
neutral filters (the 14 per-column SQL queries were deleted — no
|
||||
wire change).
|
||||
- NEW SSR partial: `GET /media-items/facet-panel` (session-cookie
|
||||
auth, bookshelf-protected group) renders the drawer's value lists —
|
||||
templ-rendered, registry order, library-type-scoped sections, active
|
||||
values marked. An out-of-set explicit `library_id` is 403 HTML.
|
||||
- The 14 per-column facet SQL queries are replaced by one
|
||||
`FacetCountsUnderFilters` UNION-ALL statement; the registry drift
|
||||
guard now reflects over both generated param structs.
|
||||
|
||||
## 2026-10-20 — Filter plumbing: facet-panel partial removed; field-values `library_id` optional
|
||||
|
||||
- `GET /media-items/facet-panel` (the SSR partial documented above,
|
||||
same day) is REMOVED — the panel UX was rejected on scale grounds
|
||||
and the filter drawer becomes a rule builder with no other mode.
|
||||
Never contract-pinned while it lived (same-day entry, same-day
|
||||
removal); `TestFacetPanelPartial` was deleted with the behavior.
|
||||
The all-facets JSON endpoint and its two shapes are UNCHANGED and
|
||||
stay pinned; `FacetCountsUnderFilters` and the registry drift guard
|
||||
keep their roles (the engine outlives the panel).
|
||||
- `GET /api/media-items/search?field=value` autocomplete
|
||||
(author/genre/tags/narrator/series/language): `library_id` is now
|
||||
OPTIONAL — absent means every library VISIBLE to the caller (the
|
||||
per-user family lens applies inside the query), fixing the All-Books
|
||||
view's "library_id is required" 400. An explicit out-of-set
|
||||
`library_id` is now a 403 on this endpoint too (was: silent empty
|
||||
result for hidden libraries — the silent pass is the anti-pattern
|
||||
the standing rule forbids). The single-facet
|
||||
`/api/media-items/facets?facet=X` shape still REQUIRES `library_id`
|
||||
— that pin is unchanged.
|
||||
|
||||
## 2026-10-20 — Filter rule builder: the `qf` wire param + composed engines (faceted plan Phase 9)
|
||||
|
||||
- NEW wire param on the search endpoint family: `qf` — a JSON rule tree
|
||||
`{mode: all|any, children: [{field, op, values} | {mode, children}]}`.
|
||||
Fields/operators are registry-typed constants
|
||||
(`services/filter_registry.go` — ~55 fields incl. every book column,
|
||||
file/lifecycle facts, the three HOLDING STRUCTURES — library,
|
||||
collections (playlists included, `excluded IS NOT TRUE` membership),
|
||||
series (primary + `alternate_info` full membership) — and the whole
|
||||
user-relative class reading the VIEWER's rows). Unknown fields or
|
||||
operators, disallowed operator/field pairs, malformed values, empty
|
||||
groups, and nesting beyond 8 are a **400** (`ErrInvalidFilterTree`),
|
||||
never a silently-ignored token. Values are always bound pgx
|
||||
parameters — an injection-shaped value is just a value.
|
||||
- **Precedence (pinned)**: when a request carries BOTH `qf` and flat
|
||||
facet params, **qf wins** — the flat params are ignored by the engine.
|
||||
Flat links keep working unchanged server-side (and a tree equivalent
|
||||
to a flat link returns identical rows — pinned by test).
|
||||
- The composed engines (`services/filter_query.go`) run the mandatory
|
||||
WHERE head (visibility join + archived + missing-scan — family lens)
|
||||
for search, suggestions, and count alike; row shape is identical to
|
||||
`SearchMediaItemsUnifiedRow`. A rule naming a hidden library matches
|
||||
nothing; an explicit out-of-set `library_id` stays a 403 on every
|
||||
entry point.
|
||||
- NEW endpoint `GET /api/media-items/filter-count?qf=…[&library_id=…]`
|
||||
— the builder's live count: a true COUNT(*) of the rules + scope
|
||||
(not the page length). Family-lens 403 applies.
|
||||
- Field-values autocomplete UPGRADED in place (same route): ANY registry
|
||||
field key in its bare form (`?note_text=…`, `?collection=…`,
|
||||
`?library=…`) routes to the field-values path; with a `qf` param the
|
||||
OTHER rules bind as filters (symmetric under-filter suggestions,
|
||||
zero-match values never offered). Without a tree, the classic six
|
||||
(author/genre/tags/narrator/series/language) keep their static
|
||||
queries and response shape — pinned contract, unchanged. ID-valued
|
||||
suggestions (library/collection/added_by) add an OPTIONAL
|
||||
`display` field to result items (`value` stays the id; `omitempty`
|
||||
elsewhere).
|
||||
- The Phase-7 facet-panel partial stays removed; the all-facets JSON
|
||||
endpoint and its two shapes are unchanged (they serve flat params —
|
||||
the builder's UI does not use them).
|
||||
|
||||
## 2026-10-20 — Scanner Phase 10: extractor version (ev) mechanism
|
||||
- `media_scan_state.manifest` JSONB gains the reserved `ev` key:
|
||||
the parser version that produced the item's CURRENT row. ABSENT
|
||||
means "v1-or-unknown" — decode normalizes it to 1.
|
||||
- NEW setting `scan_extractor_version` (int, default 1, min 1,
|
||||
category scanner): the unified `GET/PUT /api/system/settings` list
|
||||
now includes this key (additive; the list was not a pinned closed
|
||||
set). Writes stay settings-API-only — direct SQL leaves the registry
|
||||
cache stale (known gotcha).
|
||||
- Scanner behavior: a stored manifest whose ev ≠ the setting is
|
||||
treated as CHANGED → full re-extract + the fresh manifest stamps
|
||||
the new ev. Stamping is TRUTHFUL-ONLY: create paths and
|
||||
post-extraction stores stamp; deploy-day BACKFILL and the
|
||||
extract-failure guard store UNSET (the row never claims a version
|
||||
its data can't prove — a mid-backfill bump self-heals next poll).
|
||||
Repoint reconciliation preserves the stored ev (stat-only refresh,
|
||||
nothing re-extracted).
|
||||
- Catch-up is BOUNDED per scan pass (`evReextractBudgetPerScan = 100`,
|
||||
atomic, reset at each ScanFolders): resumable across polls, no
|
||||
force rescan, no flood. Budget exhaustion leaves ev stale → later
|
||||
polls resume.
|
||||
- Deploy-day rule (unchanged, re-verified live): manifests without ev
|
||||
ride v1; at default setting 1 the first post-release scan re-extracts
|
||||
NOTHING (0 changed on 1198 files).
|
||||
|
||||
## 2026-10-20 — Scanner Phase 11: the processing-issues ledger
|
||||
- `processing_issues.media_item_id` is now NULLABLE and the table gains
|
||||
`file_path` (TEXT, deliberately unbounded — an over-long path is
|
||||
itself a failure mode the ledger must survive): ROW-LESS import
|
||||
failures (no media_items row exists) key on (library, file_path);
|
||||
item-backed rows keep (item, type). The widened dedup is the
|
||||
COALESCE-expression unique index idx_processing_issues_dedup; the old
|
||||
table-level UNIQUE stays (row-less rows have NULL items, pairwise
|
||||
distinct under unique constraints).
|
||||
- `GET /api/libraries/:id/issues/list` — the media_items join is now
|
||||
LEFT: row-less issues APPEAR (they were unrepresentable before).
|
||||
Response shape unchanged for item-backed rows; row-less rows carry
|
||||
`media_item_id: ""` and a `title` fallen back to the file path.
|
||||
- FAMILY LENS now applies to ALL issues endpoints (list, stats, resolve,
|
||||
delete) and the SSR page — they leak media metadata. An explicit
|
||||
library id outside the requester's visible set is a **403** (this
|
||||
tightens one pre-lens behavior: a nonexistent library id used to
|
||||
return 200-empty; it is trivially out-of-set → 403 now, pinned).
|
||||
- NEW scanner wirings: CreateMediaItem failure → row-less
|
||||
`import_failed` (error); extraction hard-fail with fallback import →
|
||||
item-backed `metadata_extraction_failed` (warning); change-path
|
||||
re-extraction failure → item-backed `metadata_extraction_failed`.
|
||||
- Auto-resolution: a successful import resolves row-less issues by
|
||||
(library, file_path); a successful (re)extraction resolves the item's
|
||||
open issues. Unchanged-skip scans do NOT resolve (extraction never
|
||||
re-ran — the degraded metadata is still degraded). A repeat failure
|
||||
reopens a resolved row (upsert sets resolved=false).
|
||||
|
||||
## 2026-10-21 — Phase 13: force rescan = original scan; the manual-edits filter
|
||||
- **Force rescan contract change (owner-directed)**: force rescan now
|
||||
behaves like an original import — the full metadata chain (sidecars →
|
||||
folder structure → tags → fallbacks) re-lands on MULTI-FILE books
|
||||
too (the pre-Phase-13 editorial-keep skip is retired; the owner's
|
||||
metadata_overrides count in the single digits, and overrides are the
|
||||
only protection, honored inside the apply). A force pass also STAMPS
|
||||
each re-derived book's manifest with the current parser version
|
||||
(success-only) and closes its open processing issues. Single-file
|
||||
force rescan already behaved this way — now pinned by test.
|
||||
- **NEW filter field** `manually_edited` ("Manually edited", boolean):
|
||||
is_true = `cardinality(mi.metadata_overrides) > 0`, is_false = `= 0`.
|
||||
Composed-engine (qf) only — no flat param, no value menu, no
|
||||
suggestion surface; family lens inherited.
|
||||
- Deployment ordering note (owner Q&A): set scan_extractor_version
|
||||
BEFORE Rescan All — force rescan stamps books with the current
|
||||
setting, so bump-first + force = one pass of work; force-first +
|
||||
bump = the trickle re-derives everything again (wasted, not harmful).
|
||||
+55
-32
@@ -8,6 +8,15 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type AudioPlaybackSpeed struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
UserID pgtype.UUID `db:"user_id" json:"user_id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
Speed float32 `db:"speed" json:"speed"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type CollectionItems struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"`
|
||||
@@ -15,6 +24,7 @@ type CollectionItems struct {
|
||||
AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"`
|
||||
AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"`
|
||||
Excluded pgtype.Bool `db:"excluded" json:"excluded"`
|
||||
Position pgtype.Int4 `db:"position" json:"position"`
|
||||
}
|
||||
|
||||
type Collections struct {
|
||||
@@ -232,25 +242,31 @@ type MediaItemFormats struct {
|
||||
}
|
||||
|
||||
type MediaItems struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
Series pgtype.Text `db:"series" json:"series"`
|
||||
SeriesNumber pgtype.Int4 `db:"series_number" json:"series_number"`
|
||||
Tags []string `db:"tags" json:"tags"`
|
||||
Narrators []string `db:"narrators" json:"narrators"`
|
||||
Abridged bool `db:"abridged" json:"abridged"`
|
||||
Asin pgtype.Text `db:"asin" json:"asin"`
|
||||
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
||||
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||
Contributors []string `db:"contributors" json:"contributors"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
Title string `db:"title" json:"title"`
|
||||
Subtitle pgtype.Text `db:"subtitle" json:"subtitle"`
|
||||
Author pgtype.Text `db:"author" json:"author"`
|
||||
Isbn pgtype.Text `db:"isbn" json:"isbn"`
|
||||
Description pgtype.Text `db:"description" json:"description"`
|
||||
FilePath string `db:"file_path" json:"file_path"`
|
||||
FileSize pgtype.Int8 `db:"file_size" json:"file_size"`
|
||||
MimeType pgtype.Text `db:"mime_type" json:"mime_type"`
|
||||
CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"`
|
||||
Series pgtype.Text `db:"series" json:"series"`
|
||||
SeriesNumber pgtype.Float8 `db:"series_number" json:"series_number"`
|
||||
Tags []string `db:"tags" json:"tags"`
|
||||
Narrators []string `db:"narrators" json:"narrators"`
|
||||
Authors []string `db:"authors" json:"authors"`
|
||||
AuthorsSearch []string `db:"authors_search" json:"authors_search"`
|
||||
Publishers []string `db:"publishers" json:"publishers"`
|
||||
PublishersSearch []string `db:"publishers_search" json:"publishers_search"`
|
||||
Abridged bool `db:"abridged" json:"abridged"`
|
||||
Explicit bool `db:"explicit" json:"explicit"`
|
||||
Asin pgtype.Text `db:"asin" json:"asin"`
|
||||
DatePublished pgtype.Date `db:"date_published" json:"date_published"`
|
||||
Publisher pgtype.Text `db:"publisher" json:"publisher"`
|
||||
Contributors []string `db:"contributors" json:"contributors"`
|
||||
// ISO 639-1 language code (e.g., en, es, fr)
|
||||
Language pgtype.Text `db:"language" json:"language"`
|
||||
// Edition information (e.g., "First Edition", "Revised Edition")
|
||||
@@ -365,6 +381,11 @@ type MediaReviews struct {
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
type MediaScanState struct {
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
Manifest []byte `db:"manifest" json:"manifest"`
|
||||
}
|
||||
|
||||
type OpdsTokens struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
|
||||
@@ -384,7 +405,7 @@ type PanelData struct {
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
// Tracks media items that cannot be properly processed in their assigned library due to format mismatches or other issues
|
||||
// Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.
|
||||
type ProcessingIssues struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
|
||||
@@ -395,6 +416,7 @@ type ProcessingIssues struct {
|
||||
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
|
||||
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
FilePath pgtype.Text `db:"file_path" json:"file_path"`
|
||||
}
|
||||
|
||||
type ReaderSettings struct {
|
||||
@@ -562,16 +584,17 @@ type UserDashboardPreferencesGlobal struct {
|
||||
}
|
||||
|
||||
type Users struct {
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||
Role string `db:"role" json:"role"`
|
||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
ID pgtype.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
PasswordHash string `db:"password_hash" json:"password_hash"`
|
||||
FirstName pgtype.Text `db:"first_name" json:"first_name"`
|
||||
LastName pgtype.Text `db:"last_name" json:"last_name"`
|
||||
Role string `db:"role" json:"role"`
|
||||
Theme pgtype.Text `db:"theme" json:"theme"`
|
||||
MaxDevices pgtype.Int4 `db:"max_devices" json:"max_devices"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
|
||||
Timezone pgtype.Text `db:"timezone" json:"timezone"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
|
||||
DefaultAudioSpeed float32 `db:"default_audio_speed" json:"default_audio_speed"`
|
||||
}
|
||||
@@ -14,6 +14,9 @@ type Querier interface {
|
||||
// COLLECTION ITEMS QUERIES
|
||||
// Add book to collection
|
||||
AddBookToCollection(ctx context.Context, arg AddBookToCollectionParams) (CollectionItems, error)
|
||||
// A2c: playlist upsert — position set ONLY on insert, so rescans and
|
||||
// re-adds never clobber the user's manual drag ordering.
|
||||
AddBookToCollectionOrdered(ctx context.Context, arg AddBookToCollectionOrderedParams) (CollectionItems, error)
|
||||
// ============================================
|
||||
// KOBO SHELF MANAGEMENT QUERIES
|
||||
// ============================================
|
||||
@@ -42,6 +45,10 @@ type Querier interface {
|
||||
ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error
|
||||
CountAdmins(ctx context.Context) (int64, error)
|
||||
CountArchivedMediaItems(ctx context.Context) (int64, error)
|
||||
// A2c §3.5: distinguish "playlist complete" from "genuinely empty" — the
|
||||
// dashboard renders a completion marker when hide_finished filtered
|
||||
// everything out of a non-empty collection.
|
||||
CountCollectionItems(ctx context.Context, collectionID pgtype.UUID) (int64, error)
|
||||
// Live item count for a STATIC collection (rule-free cards on the
|
||||
// collections page); same live filter as GetCollectionItems, plus the
|
||||
// future-hybrid excluded exclusion and an optional library scope.
|
||||
@@ -105,6 +112,11 @@ type Querier interface {
|
||||
// ============================================================================
|
||||
// PROCESSING ISSUES QUERIES
|
||||
// ============================================================================
|
||||
// Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
||||
// are nullable — item-backed rows key on (item, library, type, ''),
|
||||
// row-less import failures on (zero-uuid, library, type, path). The
|
||||
// conflict target matches idx_processing_issues_dedup's expressions.
|
||||
// A repeat failure REOPENS a resolved row (resolved=false).
|
||||
CreateProcessingIssue(ctx context.Context, arg CreateProcessingIssueParams) (ProcessingIssues, error)
|
||||
// Create reading history entry
|
||||
CreateReadingHistory(ctx context.Context, arg CreateReadingHistoryParams) (ReadingHistory, error)
|
||||
@@ -157,6 +169,27 @@ type Querier interface {
|
||||
// historically stuff the user id into DeviceID (it only fed a broadcast
|
||||
// label before stats); a capture row must only reference a REAL device.
|
||||
DeviceExistsForUser(ctx context.Context, arg DeviceExistsForUserParams) (bool, error)
|
||||
// Facet-value listings (faceted-navigation Batch C v2, faceted plan
|
||||
// Phase 7): ALL listable facets' top values + counts in ONE statement —
|
||||
// one round trip serves the filter drawer's panel, with classic faceted
|
||||
// semantics: each arm OMITS its own facet's filter block and carries
|
||||
// every OTHER registered facet's block (copied mechanically from
|
||||
// SearchMediaItemsUnified — keep the two in lockstep), so with filters
|
||||
// active each facet counts under the others, and with no filters the
|
||||
// blocks are neutral and the counts are the v1 global-per-library ones.
|
||||
// One query serves both cases.
|
||||
//
|
||||
// Visibility: same family-lens join as the search query — a user's
|
||||
// hidden-library rows are absent; the handler 403s an out-of-set
|
||||
// explicit library_id. library_id is nullable: NULL = the All-Libraries
|
||||
// view (no scope beyond the lens). Bool/range facets (has_cover,
|
||||
// explicit, abridged, year) filter every arm but list no values — they
|
||||
// render as toggles/inputs, not lists.
|
||||
//
|
||||
// Per-arm LIMIT (parens required: ORDER BY/LIMIT inside a compound
|
||||
// select bind per-arm only in parenthesized subqueries). The same
|
||||
// sqlc.narg names repeat across arms; sqlc binds them once.
|
||||
FacetCountsUnderFilters(ctx context.Context, arg FacetCountsUnderFiltersParams) ([]FacetCountsUnderFiltersRow, error)
|
||||
// Find content-duplicate groups (same library + SHA-256, more than one row)
|
||||
FindHashConflictGroups(ctx context.Context) ([]FindHashConflictGroupsRow, error)
|
||||
GenerateKoboEntitlementId(ctx context.Context) (interface{}, error)
|
||||
@@ -169,17 +202,37 @@ type Querier interface {
|
||||
GetAllSystemSettings(ctx context.Context) ([]GetAllSystemSettingsRow, error)
|
||||
GetAllSystemSettingsFull(ctx context.Context) ([]SystemSettings, error)
|
||||
GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error)
|
||||
// Per-book playback speed preference (player plan §8).
|
||||
GetAudioPlaybackSpeed(ctx context.Context, arg GetAudioPlaybackSpeedParams) (float32, error)
|
||||
// Lifetime session rows for ONE book (the book-detail stats card).
|
||||
GetBookReadingSessions(ctx context.Context, arg GetBookReadingSessionsParams) ([]GetBookReadingSessionsRow, error)
|
||||
GetBooksByTag(ctx context.Context, arg GetBooksByTagParams) ([]MediaItems, error)
|
||||
// Get collection
|
||||
GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error)
|
||||
// A2c §2.4: playlist-collection discovery — provenance key first so a user
|
||||
// rename can't spawn duplicates on the next scan.
|
||||
GetCollectionByImportedSeries(ctx context.Context, arg GetCollectionByImportedSeriesParams) (Collections, error)
|
||||
GetCollectionByUserAndName(ctx context.Context, arg GetCollectionByUserAndNameParams) (Collections, error)
|
||||
// Get collection items
|
||||
// A2c: position-first ordering (playlist reading order); un-positioned
|
||||
// legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
||||
// which walked a playlist backwards.
|
||||
GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error)
|
||||
// The excluded filter lives here (was: callers filtered ci.excluded in
|
||||
// Go over a 69-field row + a 45-field converter to MediaItems; embed
|
||||
// returns the real row so neither exists).
|
||||
// A2c: position-first ordering; optional hide_finished — when the flag is
|
||||
// set AND a viewer is supplied, items the viewer finished (percentage
|
||||
// >= 0.999) drop out, turning the section into the reading queue head.
|
||||
// viewer_user_id NULL = no progress filter (never-finished semantics for
|
||||
// anonymous/legacy callers is NOT implied: hide_finished without a viewer
|
||||
// is a caller bug and shows everything).
|
||||
GetCollectionItemsForDashboard(ctx context.Context, arg GetCollectionItemsForDashboardParams) ([]GetCollectionItemsForDashboardRow, error)
|
||||
// A2c §3.4: the collection's reading queue — first N un-finished items in
|
||||
// position order. Strict playlist semantics: finished items drop out as
|
||||
// they complete, so the head IS "up next". Library visibility is enforced
|
||||
// by the family-lens array param (never NULL for user-facing callers).
|
||||
GetCollectionUpNext(ctx context.Context, arg GetCollectionUpNextParams) ([]GetCollectionUpNextRow, error)
|
||||
// Get collection with book count
|
||||
GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error)
|
||||
// Get collections by user
|
||||
@@ -334,6 +387,9 @@ type Querier interface {
|
||||
GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error)
|
||||
GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error)
|
||||
GetMediaReview(ctx context.Context, arg GetMediaReviewParams) (MediaReviews, error)
|
||||
// GetMediaScanState reads one item's manifest (per-item rescan paths
|
||||
// and the manifest integration tests).
|
||||
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
|
||||
GetNextRetryTime(ctx context.Context) (interface{}, error)
|
||||
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
|
||||
// Get OPDS token
|
||||
@@ -343,6 +399,8 @@ type Querier interface {
|
||||
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
|
||||
// Get most popular books for a user
|
||||
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
|
||||
// Single-issue read for the resolve/delete endpoints' family-lens gate.
|
||||
GetProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
|
||||
GetProcessingIssueStats(ctx context.Context, libraryID pgtype.UUID) (GetProcessingIssueStatsRow, error)
|
||||
GetReaderSettings(ctx context.Context, userID pgtype.UUID) ([]byte, error)
|
||||
// Get reading history for a user and book
|
||||
@@ -354,6 +412,17 @@ type Querier interface {
|
||||
GetRefreshToken(ctx context.Context, token pgtype.UUID) (GetRefreshTokenRow, error)
|
||||
GetSavedFilterByID(ctx context.Context, arg GetSavedFilterByIDParams) (SavedFilters, error)
|
||||
GetSavedFilters(ctx context.Context, arg GetSavedFiltersParams) ([]SavedFilters, error)
|
||||
// GetScanStateByLibrary: the scanner's ONE bulk query per library scan
|
||||
// (faceted plan Part 2 §2) — every item's existence + scanner-owned
|
||||
// facts + sidecar manifest in a single pass. Replaces the per-file
|
||||
// GetMediaItemByFilePath flood (~3,094 queries per scan in prod).
|
||||
// Lean projection: metadata blobs (audio_metadata, chapter_metadata,
|
||||
// authors, description...) are NEVER loaded — the skip path never
|
||||
// needs them, and the change path re-reads the full row.
|
||||
// archived_at + missing_scan_count + file_sha256 ride along so the
|
||||
// skip path's archive-restore and hash self-heal checks stay
|
||||
// DB-free too.
|
||||
GetScanStateByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]GetScanStateByLibraryRow, error)
|
||||
// Scoped to ONE library: same-name series in different libraries are
|
||||
// different stories (manga adaptations, LN splits). Callers must pass a
|
||||
// library_id and verify the requester can see that library.
|
||||
@@ -393,6 +462,10 @@ type Querier interface {
|
||||
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||
GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error)
|
||||
GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error)
|
||||
// A2c (2026-10-19): user collections are dashboard-ELIGIBLE BY DEFAULT —
|
||||
// show_on_dashboard gated them forever-false (no writer ever set it on
|
||||
// user rows), and hiding is the dashboard-prefs job (hidden_collections).
|
||||
// The flag remains system-section provisioning state only.
|
||||
GetUserCollectionsForDashboard(ctx context.Context, userID pgtype.UUID) ([]Collections, error)
|
||||
// Per-device reading breakdown for a range. LEFT JOIN so pushes that
|
||||
// carry no device (web reader until it identifies) still appear as one
|
||||
@@ -471,6 +544,10 @@ type Querier interface {
|
||||
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
|
||||
// silently disabled the offline flush until 2026-10-17).
|
||||
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
|
||||
// LEFT JOIN (Phase 11): row-less import failures have NO media_items
|
||||
// row — an inner join would silently drop the ledger's newest kind.
|
||||
// Item-side projections COALESCE to keep them non-nullable strings;
|
||||
// the display file path falls back to the issue's own for row-less rows.
|
||||
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
|
||||
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
|
||||
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
|
||||
@@ -510,10 +587,19 @@ type Querier interface {
|
||||
// Remove book from collection
|
||||
RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error
|
||||
RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error
|
||||
// A2c: full-order rewrite for the drag-to-reorder endpoint. The payload is
|
||||
// the complete ordered id list; positions become 1..n in payload order.
|
||||
ReorderCollectionItems(ctx context.Context, arg ReorderCollectionItemsParams) error
|
||||
// Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
||||
ReparentMediaItemChildren(ctx context.Context, arg ReparentMediaItemChildrenParams) error
|
||||
ResetSystemCollectionMetadata(ctx context.Context, arg ResetSystemCollectionMetadataParams) error
|
||||
ResolveHashConflict(ctx context.Context, arg ResolveHashConflictParams) error
|
||||
// Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
||||
// item successfully closes its open issues (extraction failures heal).
|
||||
ResolveOpenProcessingIssuesForItem(ctx context.Context, mediaItemID pgtype.UUID) (int64, error)
|
||||
// Row-less issues resolve by (library, file_path) when the file finally
|
||||
// imports — the page heals itself without admin action.
|
||||
ResolveOpenProcessingIssuesForPath(ctx context.Context, arg ResolveOpenProcessingIssuesForPathParams) (int64, error)
|
||||
ResolveProcessingIssue(ctx context.Context, arg ResolveProcessingIssueParams) (ProcessingIssues, error)
|
||||
ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error)
|
||||
// Resolve unlinked book
|
||||
@@ -556,6 +642,9 @@ type Querier interface {
|
||||
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
||||
// of the JSONB without touching metadata stale clients did not send).
|
||||
UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error)
|
||||
// A2c §2.4: provenance + playlist flags on an existing collection without
|
||||
// touching the rest of the row.
|
||||
UpdateCollectionViewSettings(ctx context.Context, arg UpdateCollectionViewSettingsParams) error
|
||||
UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error)
|
||||
UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error)
|
||||
@@ -583,7 +672,7 @@ type Querier interface {
|
||||
// books): rescans recompute the track manifest and the aggregate facts
|
||||
// that follow from it. Editorial fields stay untouched — admins may have
|
||||
// edited title/narrators after ingest.
|
||||
UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (MediaItems, error)
|
||||
UpdateMediaItemAudioBookPayload(ctx context.Context, arg UpdateMediaItemAudioBookPayloadParams) (UpdateMediaItemAudioBookPayloadRow, error)
|
||||
UpdateMediaItemChapterMetadata(ctx context.Context, arg UpdateMediaItemChapterMetadataParams) (MediaItems, error)
|
||||
// Update media item format
|
||||
UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error)
|
||||
@@ -609,15 +698,22 @@ type Querier interface {
|
||||
UpdateSystemSetting(ctx context.Context, arg UpdateSystemSettingParams) error
|
||||
// Update universal progress
|
||||
UpdateUniversalProgress(ctx context.Context, arg UpdateUniversalProgressParams) (ReadingProgress, error)
|
||||
UpdateUserDefaultAudioSpeed(ctx context.Context, arg UpdateUserDefaultAudioSpeedParams) error
|
||||
UpdateUserMaxDevices(ctx context.Context, arg UpdateUserMaxDevicesParams) (Users, error)
|
||||
UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) error
|
||||
UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (UpdateUserRoleRow, error)
|
||||
UpdateUserTheme(ctx context.Context, arg UpdateUserThemeParams) error
|
||||
UpdateUserTimezone(ctx context.Context, arg UpdateUserTimezoneParams) error
|
||||
UpdateUsername(ctx context.Context, arg UpdateUsernameParams) error
|
||||
UpsertAudioPlaybackSpeed(ctx context.Context, arg UpsertAudioPlaybackSpeedParams) (float32, error)
|
||||
UpsertDashboardPreferences(ctx context.Context, arg UpsertDashboardPreferencesParams) (UserDashboardPreferences, error)
|
||||
UpsertGlobalDashboardPreferences(ctx context.Context, arg UpsertGlobalDashboardPreferencesParams) (UserDashboardPreferencesGlobal, error)
|
||||
UpsertMediaReview(ctx context.Context, arg UpsertMediaReviewParams) (MediaReviews, error)
|
||||
// UpsertMediaScanState stores a book's sidecar manifest fingerprint
|
||||
// (faceted plan Part 2 §3). Written on backfill (first sighting —
|
||||
// no re-extraction), on repoint reconciliation (stat pairs refreshed,
|
||||
// content identical), and after a manifest-gated re-extract.
|
||||
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error
|
||||
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
|
||||
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
|
||||
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
|
||||
|
||||
+2256
-205
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -58,6 +58,8 @@ type SettingDefault struct {
|
||||
var SettingDefaults = []SettingDefault{
|
||||
{Key: "scan_poll_interval_seconds", Value: "60", Type: SettingTypeInt, Min: "1", Max: "3600", Category: "scanner", Group: "Scanning", Description: "How often to scan all libraries (seconds)"},
|
||||
{Key: "auto_scan_enabled", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Whether auto-scanning is enabled system-wide"},
|
||||
{Key: "auto_playlist_collections", Value: "true", Type: SettingTypeBool, Category: "scanner", Group: "Scanning", Description: "Auto-create curated playlist collections from sidecar series marked as reading orders"},
|
||||
{Key: "scan_extractor_version", Value: "1", Type: SettingTypeInt, Min: "1", Max: "1000000", Category: "scanner", Group: "Scanning", Description: "Parser version stamped on each book's scan manifest. Raising it re-extracts metadata from books scanned under an older parser, a bounded batch per scan until all are caught up"},
|
||||
{Key: "default_timezone", Value: "UTC", Type: SettingTypeString, Category: "general", Group: "System Defaults", Description: "System default timezone"},
|
||||
{Key: "session_duration_seconds", Value: "604800", Type: SettingTypeInt, Min: "300", Max: "31536000", Category: "security", Group: "Session", Description: "How long a login session stays valid"},
|
||||
{Key: "password_min_length", Value: "8", Type: SettingTypeInt, Min: "1", Max: "128", Category: "security", Group: "Password Quality", Description: "Minimum password length"},
|
||||
@@ -189,6 +191,17 @@ func (r *SettingsRegistry) ScanPollInterval() time.Duration {
|
||||
// AutoScanEnabled reports whether auto-scanning is on.
|
||||
func (r *SettingsRegistry) AutoScanEnabled() bool { return r.getBool("auto_scan_enabled") }
|
||||
|
||||
// AutoPlaylistCollections reports whether the scanner should turn
|
||||
// obviously-playlist sidecar series ("The Witcher (Reading Order)") into
|
||||
// curated static collections (A2c §2.4).
|
||||
func (r *SettingsRegistry) AutoPlaylistCollections() bool { return r.getBool("auto_playlist_collections") }
|
||||
|
||||
// ScanExtractorVersion is the parser version stamped on each book's scan
|
||||
// manifest. Books whose stored stamp differs re-extract their metadata on
|
||||
// later scans (bounded per scan) — the mechanism that lets parser fixes
|
||||
// reach rows ingested under an older parser without a force rescan.
|
||||
func (r *SettingsRegistry) ScanExtractorVersion() int { return r.getInt("scan_extractor_version") }
|
||||
|
||||
// RegistrationEnabled reports whether public signup is allowed. Admin user
|
||||
// creation is never affected — only self-service registration rides this
|
||||
// flag; handlers additionally grant the first-user exception (see
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -120,6 +121,8 @@ type UserProfile struct {
|
||||
LastName string `json:"last_name"`
|
||||
Role string `json:"role"`
|
||||
Theme string `json:"theme,omitempty"`
|
||||
// User-level default audiobook playback speed (plan §8); 0 omits.
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
@@ -129,6 +132,9 @@ type UpdateProfileRequest struct {
|
||||
LastName string `json:"last_name,omitempty" form:"last_name" validate:"omitempty,max=100"`
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
// Per the SPEEDS list bounds; the profile select always sends it,
|
||||
// JSON clients omit it (0 = untouched).
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||||
}
|
||||
|
||||
type AdminUpdateUserRequest struct {
|
||||
@@ -139,6 +145,8 @@ type AdminUpdateUserRequest struct {
|
||||
Theme string `json:"theme,omitempty" form:"theme" validate:"omitempty"`
|
||||
Timezone string `json:"timezone,omitempty" form:"timezone" validate:"omitempty"`
|
||||
Role string `json:"role,omitempty" form:"role" validate:"omitempty,oneof=user admin"`
|
||||
// Same semantics as UpdateProfileRequest.DefaultAudioSpeed.
|
||||
DefaultAudioSpeed float64 `json:"default_audio_speed,omitempty" form:"default_audio_speed" validate:"omitempty,min=0.25,max=4"`
|
||||
}
|
||||
|
||||
// Register handles POST /api/auth/register
|
||||
@@ -538,13 +546,14 @@ func (h *AuthHandler) GetProfile(c *echo.Context) error {
|
||||
lastName = user.LastName.String
|
||||
}
|
||||
return c.JSON(http.StatusOK, UserProfile{
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Role: user.Role,
|
||||
Theme: user.Theme.String,
|
||||
ID: uuid.UUID(user.ID.Bytes).String(),
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
FirstName: firstName,
|
||||
LastName: lastName,
|
||||
Role: user.Role,
|
||||
Theme: user.Theme.String,
|
||||
DefaultAudioSpeed: float64(user.DefaultAudioSpeed),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -693,6 +702,22 @@ func (h *AuthHandler) UpdateProfile(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Update the default audiobook playback speed (plan §8). The form
|
||||
// select always sends it; JSON clients that omit it decode as 0 and
|
||||
// leave the column untouched.
|
||||
if req.DefaultAudioSpeed != 0 {
|
||||
if req.DefaultAudioSpeed < 0.25 || req.DefaultAudioSpeed > 4 || math.IsNaN(req.DefaultAudioSpeed) || math.IsInf(req.DefaultAudioSpeed, 0) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "default playback speed out of range"})
|
||||
}
|
||||
err := h.db.UpdateUserDefaultAudioSpeed(c.Request().Context(), database.UpdateUserDefaultAudioSpeedParams{
|
||||
ID: targetUserUUID,
|
||||
DefaultAudioSpeed: float32(req.DefaultAudioSpeed),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Browser path (htmx modal + profile page): refresh so every rendered
|
||||
// value (timezone select, table rows, header) reflects the save and the
|
||||
// modal closes. A bare JSON body swapped into #profile-result read as
|
||||
|
||||
@@ -52,6 +52,12 @@ type UpdateCollectionRequest struct {
|
||||
Icon string `form:"icon" json:"icon"`
|
||||
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
||||
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
||||
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
||||
// (nil = untouched). []string because the form posts a hidden "false"
|
||||
// sibling next to the checkbox; the LAST value wins. JSON clients may
|
||||
// send a single-element array (or omit — untouched).
|
||||
HideFinished []string `form:"hide_finished" json:"hide_finished"`
|
||||
ShowUpNext []string `form:"show_up_next" json:"show_up_next"`
|
||||
}
|
||||
|
||||
type AddBooksRequest struct {
|
||||
@@ -79,6 +85,10 @@ type SectionData struct {
|
||||
Items []BookInfo `json:"items"`
|
||||
ViewAllURL string `json:"view_all_url"`
|
||||
Priority int `json:"priority"`
|
||||
// A2c: curated playlist marker (imported reading-order collection) and
|
||||
// the hide_finished completion state — dashboard.ts renders both.
|
||||
IsPlaylist bool `json:"is_playlist"`
|
||||
Complete bool `json:"complete"`
|
||||
}
|
||||
|
||||
func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
||||
@@ -284,10 +294,28 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
} else if libUUID.Valid {
|
||||
// A2c §3.3: the detail page honors the same per-collection
|
||||
// hide_finished as the dashboard section, against the viewer's
|
||||
// progress. The viewer_percentage rides along for card graying.
|
||||
hideFinished := false
|
||||
if len(collection.ViewSettings) > 0 {
|
||||
var vs map[string]interface{}
|
||||
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
||||
if b, ok := vs["hide_finished"].(bool); ok {
|
||||
hideFinished = b
|
||||
}
|
||||
}
|
||||
}
|
||||
var viewerID pgtype.UUID
|
||||
if u, ok := c.Get("user").(database.Users); ok {
|
||||
viewerID = u.ID
|
||||
}
|
||||
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
|
||||
database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
LibraryID: libUUID,
|
||||
ViewerUserID: viewerID,
|
||||
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: hideFinished},
|
||||
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
||||
})
|
||||
if collErr != nil {
|
||||
@@ -317,6 +345,40 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
// A2c §3.4: the collection's reading queue for the Up Next strip.
|
||||
// Library visibility enforced — a hidden library's books never surface
|
||||
// here even if they sit in a visible collection.
|
||||
upNext := []map[string]interface{}{}
|
||||
if !isSmart {
|
||||
hideFinished := false
|
||||
if b, ok := viewSettings["hide_finished"].(bool); ok {
|
||||
hideFinished = b
|
||||
}
|
||||
_ = hideFinished
|
||||
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
||||
if visErr == nil {
|
||||
user := c.Get("user").(database.Users)
|
||||
rows, upErr := h.db.GetCollectionUpNext(c.Request().Context(), database.GetCollectionUpNextParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
ViewerUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
||||
VisibleLibraryIds: visibleIDs,
|
||||
Limit: pgtype.Int4{Int32: 10, Valid: true},
|
||||
})
|
||||
if upErr == nil {
|
||||
for _, row := range rows {
|
||||
upNext = append(upNext, map[string]interface{}{
|
||||
"id": uuid.UUID(row.MediaItems.ID.Bytes).String(),
|
||||
"title": row.MediaItems.Title,
|
||||
"author": utils.PgTextString(row.MediaItems.Author),
|
||||
"cover": utils.PgTextString(row.MediaItems.CoverImagePath),
|
||||
"format": row.MediaItems.FormatGroup,
|
||||
"progress": row.ViewerPercentage,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"id": uuid.UUID(collection.ID.Bytes).String(),
|
||||
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
||||
@@ -329,6 +391,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
||||
"books": bookList,
|
||||
"book_count": len(bookList),
|
||||
"is_smart": isSmart,
|
||||
"up_next": upNext,
|
||||
"created_at": collection.CreatedAt.Time.String(),
|
||||
})
|
||||
}
|
||||
@@ -347,6 +410,23 @@ func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
// A2c §3.3: playlist toggles ride the edit form as top-level fields and
|
||||
// merge into view_settings server-side (the form can't bind a map).
|
||||
// The form posts a hidden "false" sibling before the checkbox, so the
|
||||
// LAST value is the checkbox state.
|
||||
if v := optionalFlag(req.HideFinished); v != nil {
|
||||
if req.ViewSettings == nil {
|
||||
req.ViewSettings = map[string]interface{}{}
|
||||
}
|
||||
req.ViewSettings["hide_finished"] = *v
|
||||
}
|
||||
if v := optionalFlag(req.ShowUpNext); v != nil {
|
||||
if req.ViewSettings == nil {
|
||||
req.ViewSettings = map[string]interface{}{}
|
||||
}
|
||||
req.ViewSettings["show_up_next"] = *v
|
||||
}
|
||||
|
||||
collection, err := h.collectionService.UpdateCollection(
|
||||
c.Request().Context(),
|
||||
collectionID,
|
||||
@@ -1238,3 +1318,54 @@ func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
|
||||
}
|
||||
|
||||
// ReorderItems applies a static collection's manual playlist order
|
||||
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
|
||||
// ids omitted keep their relative order appended after (the service never
|
||||
// deletes membership).
|
||||
func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
|
||||
collectionID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
||||
}
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
ItemIDs []string `json:"item_ids"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil || len(req.ItemIDs) == 0 {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "item_ids must be a non-empty ordered id list"})
|
||||
}
|
||||
|
||||
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
|
||||
if err != nil || !owns {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can reorder it"})
|
||||
}
|
||||
|
||||
ordered := make([]uuid.UUID, 0, len(req.ItemIDs))
|
||||
for _, raw := range req.ItemIDs {
|
||||
id, parseErr := uuid.Parse(raw)
|
||||
if parseErr != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id: " + raw})
|
||||
}
|
||||
ordered = append(ordered, id)
|
||||
}
|
||||
|
||||
if err := h.collectionService.ReorderItems(c.Request().Context(), collectionID, ordered); err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
|
||||
}
|
||||
|
||||
// optionalFlag interprets a multi-valued form flag: last value wins
|
||||
// (hidden-false sibling precedes the checkbox), absent → nil (untouched).
|
||||
func optionalFlag(values []string) *bool {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
v := strings.EqualFold(values[len(values)-1], "true")
|
||||
return &v
|
||||
}
|
||||
@@ -211,6 +211,8 @@ func BuildSections(sections []services.DashboardSection, currentLibraryID, libra
|
||||
Items: bookCards,
|
||||
ViewAllURL: getViewAllURL(ds.CollectionID.String(), currentLibraryID),
|
||||
Priority: ds.Priority,
|
||||
IsPlaylist: ds.IsPlaylist,
|
||||
Complete: ds.Complete,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+479
-117
@@ -12,6 +12,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -39,10 +40,14 @@ type CreateMediaItemRequest struct {
|
||||
MimeType string `json:"mime_type" validate:"required"`
|
||||
CoverImagePath string `json:"cover_image_path"`
|
||||
Series string `json:"series"`
|
||||
SeriesNumber int32 `json:"series_number"`
|
||||
SeriesNumber float64 `json:"series_number"`
|
||||
Tags []string `json:"tags"`
|
||||
ASIN string `json:"asin"`
|
||||
DatePublished string `json:"date_published"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Explicit bool `json:"explicit"`
|
||||
Authors []string `json:"authors"` // write truth; legacy `author` still accepted
|
||||
Publishers []string `json:"publishers"` // write truth; legacy `publisher` still accepted
|
||||
Publisher string `json:"publisher"`
|
||||
Contributors []string `json:"contributors"`
|
||||
// NEW: Reading direction and comic metadata fields
|
||||
@@ -69,16 +74,20 @@ type CreateMediaItemRequest struct {
|
||||
type UpdateMediaItemRequest struct {
|
||||
Title string `form:"title" json:"title" validate:"required,min=1,max=500"`
|
||||
Author string `form:"author" json:"author"`
|
||||
Authors []string `form:"authors" json:"authors"` // write truth; legacy `author` still accepted
|
||||
Subtitle string `form:"subtitle" json:"subtitle"`
|
||||
Explicit bool `form:"explicit" json:"explicit"`
|
||||
ISBN string `form:"isbn" json:"isbn"`
|
||||
Description string `form:"description" json:"description"`
|
||||
CoverImagePath string `form:"cover_image_path" json:"cover_image_path"`
|
||||
CoverAction string `form:"cover_action" json:"cover_action"`
|
||||
Series string `form:"series" json:"series"`
|
||||
SeriesNumber int32 `form:"series_number" json:"series_number"`
|
||||
SeriesNumber float64 `form:"series_number" json:"series_number"`
|
||||
Tags []string `form:"tags" json:"tags"`
|
||||
ASIN string `form:"asin" json:"asin"`
|
||||
DatePublished string `form:"date_published" json:"date_published"`
|
||||
Publisher string `form:"publisher" json:"publisher"`
|
||||
Publishers []string `form:"publishers" json:"publishers"` // write truth; legacy `publisher` still accepted
|
||||
Contributors []string `form:"contributors" json:"contributors"`
|
||||
Language string `form:"language" json:"language"`
|
||||
Edition string `form:"edition" json:"edition"`
|
||||
@@ -218,11 +227,11 @@ type MediaHandler struct {
|
||||
// NewMediaHandler requires the shared ProgressService — progress writes
|
||||
// must funnel through SaveProgress (the LWW-seeded canonical writer);
|
||||
// a nil service has nowhere to fall back to and must not be wired.
|
||||
func NewMediaHandler(db *database.Queries, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
|
||||
func NewMediaHandler(db *database.Queries, raw database.DBTX, libraryService *services.LibraryService, progressSvc *wsync.ProgressService, annotationSvc *wsync.AnnotationService, worker ...*services.Worker) *MediaHandler {
|
||||
mh := &MediaHandler{
|
||||
db: db,
|
||||
libraryService: libraryService,
|
||||
searchService: services.NewSearchService(db),
|
||||
searchService: services.NewSearchService(db, raw),
|
||||
progressSvc: progressSvc,
|
||||
annotationSvc: annotationSvc,
|
||||
}
|
||||
@@ -588,13 +597,16 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
||||
updateParams := database.UpdateMediaItemParams{
|
||||
ID: mediaUUID,
|
||||
Title: existingMedia.Title,
|
||||
Author: existingMedia.Author,
|
||||
Subtitle: existingMedia.Subtitle,
|
||||
Authors: existingMedia.Authors,
|
||||
AuthorsSearch: existingMedia.AuthorsSearch,
|
||||
Genre: existingMedia.Genre,
|
||||
Language: existingMedia.Language,
|
||||
Tags: existingMedia.Tags,
|
||||
TagsSearch: existingMedia.TagsSearch,
|
||||
Description: existingMedia.Description,
|
||||
Publisher: existingMedia.Publisher,
|
||||
Publishers: existingMedia.Publishers,
|
||||
PublishersSearch: existingMedia.PublishersSearch,
|
||||
CopyrightYear: existingMedia.CopyrightYear,
|
||||
Isbn: existingMedia.Isbn,
|
||||
Series: existingMedia.Series,
|
||||
@@ -623,6 +635,7 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
||||
AlternateInfo: existingMedia.AlternateInfo,
|
||||
ScanInformation: existingMedia.ScanInformation,
|
||||
Summary: existingMedia.Summary,
|
||||
Explicit: existingMedia.Explicit,
|
||||
}
|
||||
|
||||
// Bulk edits are user customizations: keep existing overrides and mark
|
||||
@@ -633,7 +646,8 @@ func (h *MediaHandler) HandleBulkUpdate(c *echo.Context) error {
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideTitle)
|
||||
}
|
||||
if update.Updates.Author != nil {
|
||||
updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true}
|
||||
updateParams.Authors = []string{*update.Updates.Author}
|
||||
updateParams.AuthorsSearch = utils.NormalizeTagsSearch(updateParams.Authors)
|
||||
overrides = utils.MergeOverrides(overrides, utils.OverrideAuthor)
|
||||
}
|
||||
if update.Updates.Genre != nil {
|
||||
@@ -831,7 +845,9 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
"id": uuid.UUID(item.ID.Bytes).String(),
|
||||
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
||||
"title": item.Title,
|
||||
"subtitle": utils.PgTextString(item.Subtitle),
|
||||
"author": utils.PgTextString(item.Author),
|
||||
"authors": item.Authors,
|
||||
"isbn": utils.PgTextString(item.Isbn),
|
||||
"description": utils.PgTextString(item.Description),
|
||||
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
||||
@@ -844,6 +860,8 @@ func (mh *MediaHandler) ListMediaItems(c *echo.Context) error {
|
||||
"asin": utils.PgTextString(item.Asin),
|
||||
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
||||
"publisher": utils.PgTextString(item.Publisher),
|
||||
"publishers": item.Publishers,
|
||||
"explicit": item.Explicit,
|
||||
"contributors": item.Contributors,
|
||||
"language": utils.PgTextString(item.Language),
|
||||
"edition": utils.PgTextString(item.Edition),
|
||||
@@ -903,7 +921,9 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
||||
"id": uuid.UUID(item.ID.Bytes).String(),
|
||||
"library_id": uuid.UUID(item.LibraryID.Bytes).String(),
|
||||
"title": item.Title,
|
||||
"subtitle": utils.PgTextString(item.Subtitle),
|
||||
"author": utils.PgTextString(item.Author),
|
||||
"authors": item.Authors,
|
||||
"isbn": utils.PgTextString(item.Isbn),
|
||||
"description": utils.PgTextString(item.Description),
|
||||
"file_path": utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: item.FilePath != ""}),
|
||||
@@ -916,6 +936,8 @@ func (mh *MediaHandler) GetMediaItem(c *echo.Context) error {
|
||||
"asin": utils.PgTextString(item.Asin),
|
||||
"date_published": item.DatePublished.Time.Format("2006-01-02"),
|
||||
"publisher": utils.PgTextString(item.Publisher),
|
||||
"publishers": item.Publishers,
|
||||
"explicit": item.Explicit,
|
||||
"contributors": item.Contributors,
|
||||
"language": utils.PgTextString(item.Language),
|
||||
"edition": utils.PgTextString(item.Edition),
|
||||
@@ -1415,7 +1437,9 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
created, err := mh.db.CreateMediaItemIfNew(c.Request().Context(), database.CreateMediaItemIfNewParams{
|
||||
LibraryID: pgtype.UUID{Bytes: req.LibraryID, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
Subtitle: pgtype.Text{String: req.Subtitle, Valid: req.Subtitle != ""},
|
||||
Authors: utils.SingleOrSlice(req.Author, req.Authors),
|
||||
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Author, req.Authors)),
|
||||
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||
FilePath: req.FilePath,
|
||||
@@ -1423,15 +1447,17 @@ func (mh *MediaHandler) CreateMediaItem(c *echo.Context) error {
|
||||
MimeType: pgtype.Text{String: req.MimeType, Valid: req.MimeType != ""},
|
||||
CoverImagePath: pgtype.Text{String: req.CoverImagePath, Valid: req.CoverImagePath != ""},
|
||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
SeriesNumber: pgtype.Float8{Float64: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: req.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
Narrators: narrators,
|
||||
NarratorsSearch: narratorsSearch,
|
||||
Abridged: req.Abridged,
|
||||
Explicit: req.Explicit,
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
Publishers: utils.SingleOrSlice(req.Publisher, req.Publishers),
|
||||
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Publisher, req.Publishers)),
|
||||
Contributors: req.Contributors,
|
||||
ContributorsSearch: contributorsSearch,
|
||||
AddedByAdminID: user.ID,
|
||||
@@ -1541,20 +1567,24 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
params := database.UpdateMediaItemParams{
|
||||
ID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
Title: req.Title,
|
||||
Author: pgtype.Text{String: req.Author, Valid: req.Author != ""},
|
||||
Subtitle: pgtype.Text{String: req.Subtitle, Valid: req.Subtitle != ""},
|
||||
Authors: utils.SingleOrSlice(req.Author, req.Authors),
|
||||
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Author, req.Authors)),
|
||||
Isbn: pgtype.Text{String: isbnValue, Valid: req.ISBN != ""},
|
||||
Description: pgtype.Text{String: req.Description, Valid: req.Description != ""},
|
||||
CoverImagePath: pgtype.Text{String: coverPath, Valid: coverPath != ""},
|
||||
Series: pgtype.Text{String: req.Series, Valid: req.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
SeriesNumber: pgtype.Float8{Float64: req.SeriesNumber, Valid: req.SeriesNumber > 0},
|
||||
Tags: req.Tags,
|
||||
TagsSearch: tagsSearch,
|
||||
Narrators: narrators,
|
||||
NarratorsSearch: narratorsSearch,
|
||||
Abridged: req.Abridged,
|
||||
Explicit: req.Explicit,
|
||||
Asin: pgtype.Text{String: req.ASIN, Valid: req.ASIN != ""},
|
||||
DatePublished: pgtype.Date{Time: parseDate(req.DatePublished), Valid: req.DatePublished != ""},
|
||||
Publisher: pgtype.Text{String: req.Publisher, Valid: req.Publisher != ""},
|
||||
Publishers: utils.SingleOrSlice(req.Publisher, req.Publishers),
|
||||
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(req.Publisher, req.Publishers)),
|
||||
Contributors: req.Contributors,
|
||||
ContributorsSearch: contributorsSearch,
|
||||
Language: pgtype.Text{String: req.Language, Valid: req.Language != ""},
|
||||
@@ -1581,6 +1611,14 @@ func (mh *MediaHandler) UpdateMediaItem(c *echo.Context) error {
|
||||
Summary: pgtype.Text{String: req.Summary, Valid: req.Summary != ""},
|
||||
}
|
||||
|
||||
// audio_metadata is scan-owned (track manifest, chapters, duration)
|
||||
// and has no editor field: the full-row update writes it
|
||||
// unconditionally, so an unguarded save NULLed every audiobook's
|
||||
// payload — books dropped back to "1 second long" on a plain
|
||||
// metadata edit (2026-10-21 incident class). Write-after-success:
|
||||
// carry the existing bytes; only the audio chain replaces them.
|
||||
params.AudioMetadata = existing.AudioMetadata
|
||||
|
||||
// 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)
|
||||
@@ -2299,102 +2337,53 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
||||
}
|
||||
|
||||
// Detect autocomplete queries first (author=value, genre=value, etc.)
|
||||
if author := c.QueryParam("author"); author != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "author", author)
|
||||
}
|
||||
if genre := c.QueryParam("genre"); genre != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "genre", genre)
|
||||
}
|
||||
if tags := c.QueryParam("tags"); tags != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "tags", tags)
|
||||
}
|
||||
if narrator := c.QueryParam("narrator"); narrator != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "narrator", narrator)
|
||||
}
|
||||
if series := c.QueryParam("series"); series != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "series", series)
|
||||
}
|
||||
if language := c.QueryParam("language"); language != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, "language", language)
|
||||
// Autocomplete/suggestion queries: ANY registry field key in its
|
||||
// bare form (author=, genre=, note_text=, collection=, ...) routes
|
||||
// to the field-values path — static for the classic six without a
|
||||
// rule tree, composed under the tree otherwise (faceted plan
|
||||
// Phase 9: same route, no new API surface).
|
||||
for _, key := range services.FilterFieldKeys() {
|
||||
if v := c.QueryParam(key); v != "" {
|
||||
return mh.handleFieldValuesSearch(c, userID.ID, key, v)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle media item search with filters
|
||||
query := c.QueryParam("q")
|
||||
libraryID := c.QueryParam("library_id")
|
||||
params := ParseMediaSearchParams(c)
|
||||
params.UserID = userID.ID
|
||||
|
||||
// Validate library_id if provided
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
lib, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
||||
} else {
|
||||
libUUID = pgtype.UUID{Valid: false}
|
||||
// Filter rule builder: a malformed qf is a 400 (bad request), never
|
||||
// a silently-ignored token and never a 500.
|
||||
if params.FilterParseErr != "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid filter: " + params.FilterParseErr,
|
||||
})
|
||||
}
|
||||
|
||||
c.Logger().Info("Search parameters",
|
||||
"query", query,
|
||||
"library_id", libraryID,
|
||||
"libUUID_valid", libUUID.Valid,
|
||||
"libUUID_bytes", libUUID.Bytes)
|
||||
"query", params.SearchQuery,
|
||||
"library_id", libraryIDString(c),
|
||||
"libUUID_valid", params.LibraryID.Valid,
|
||||
"libUUID_bytes", params.LibraryID.Bytes)
|
||||
|
||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
||||
|
||||
// Extract filter parameters
|
||||
authorFilter := c.QueryParam("author_filter")
|
||||
seriesFilter := c.QueryParam("series_filter")
|
||||
genreFilter := c.QueryParam("genre_filter")
|
||||
publisherFilter := c.QueryParam("publisher_filter")
|
||||
tagsFilter := c.QueryParam("tags_filter")
|
||||
narratorFilter := c.QueryParam("narrator_filter")
|
||||
languageFilter := c.QueryParam("language_filter")
|
||||
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
||||
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
||||
hasCover := false
|
||||
hasCoverValid := false
|
||||
if _, exists := c.QueryParams()["has_cover"]; exists {
|
||||
hasCover = c.QueryParam("has_cover") == "true"
|
||||
hasCoverValid = true
|
||||
}
|
||||
|
||||
// Extract sort parameter
|
||||
sortParam := c.QueryParam("sort")
|
||||
if sortParam == "" {
|
||||
sortParam = "title ASC" // Default sort
|
||||
}
|
||||
|
||||
// Build search params
|
||||
params := services.SearchParams{
|
||||
UserID: userID.ID,
|
||||
LibraryID: libUUID,
|
||||
SearchQuery: query,
|
||||
AuthorFilter: authorFilter,
|
||||
SeriesFilter: seriesFilter,
|
||||
GenreFilter: genreFilter,
|
||||
PublisherFilter: publisherFilter,
|
||||
TagsFilter: tagsFilter,
|
||||
NarratorFilter: narratorFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
|
||||
Sort: sortParam,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
// Family-lens gate (AGENTS.md → Library visibility): an explicit
|
||||
// library_id outside the requester's visible set is a 403, never a
|
||||
// silent empty set.
|
||||
if params.LibraryID.Valid {
|
||||
visible, err := mh.libraryVisible(c, params.LibraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !visible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||
}
|
||||
}
|
||||
|
||||
c.Logger().Info("SearchMediaItems called",
|
||||
"query", query,
|
||||
"library_id_provided", libraryID != "",
|
||||
"library_id_uuid", libUUID.Valid,
|
||||
"library_id_bytes", libUUID.Bytes)
|
||||
"query", params.SearchQuery,
|
||||
"library_id_provided", params.LibraryID.Valid,
|
||||
"library_id_uuid", params.LibraryID.Valid,
|
||||
"library_id_bytes", params.LibraryID.Bytes)
|
||||
|
||||
// Call shared search service
|
||||
results, _, err := mh.searchService.ExecuteSearch(c.Request().Context(), params)
|
||||
@@ -2405,7 +2394,7 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
if len(results) == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]interface{}{
|
||||
"error": "no results found",
|
||||
"query": query,
|
||||
"query": params.SearchQuery,
|
||||
"results": []interface{}{},
|
||||
})
|
||||
}
|
||||
@@ -2416,18 +2405,240 @@ func (mh *MediaHandler) SearchMediaItems(c *echo.Context) error {
|
||||
return c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// handleFieldValuesSearch handles autocomplete queries (author=value, genre=value, etc.)
|
||||
// Returns distinct field values with counts and similarity scores for dropdown population
|
||||
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
||||
// libraryIDString returns the raw library_id query param (logging only).
|
||||
func libraryIDString(c *echo.Context) string { return c.QueryParam("library_id") }
|
||||
|
||||
// searchParamSource abstracts the query-param surface (echo.Context)
|
||||
// so the shared parser serves both handlers and router.
|
||||
type searchParamSource interface {
|
||||
QueryParam(name string) string
|
||||
QueryParams() url.Values
|
||||
}
|
||||
|
||||
// parseMediaSearchParams parses the unified search's query params into
|
||||
// services.SearchParams — the SINGLE parse for both the JSON endpoint
|
||||
// (MediaHandler.SearchMediaItems) and the HTMX grid partial
|
||||
// (router handleSearchHTML), so the two branches can never disagree.
|
||||
// Text facet params: comma = OR-within (the SQL splits via
|
||||
// string_to_array). Bool facet params: presence-bound (absent = no
|
||||
// filter, "true"/"false" = bound). Facet names come from the facet
|
||||
// registry (services/facet_registry.go) — adding a facet there means
|
||||
// one line here, mirroring its narg block in queries.sql.
|
||||
func ParseMediaSearchParams(c searchParamSource) services.SearchParams {
|
||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
||||
|
||||
sortParam := c.QueryParam("sort")
|
||||
if sortParam == "" {
|
||||
sortParam = "title ASC" // Default sort
|
||||
}
|
||||
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID := c.QueryParam("library_id"); libraryID != "" {
|
||||
if lib, err := uuid.Parse(libraryID); err == nil {
|
||||
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
||||
}
|
||||
}
|
||||
|
||||
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
||||
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
||||
|
||||
boolFacet := func(name string) pgtype.Bool {
|
||||
if _, exists := c.QueryParams()[name]; !exists {
|
||||
return pgtype.Bool{Valid: false}
|
||||
}
|
||||
return pgtype.Bool{Bool: c.QueryParam(name) == "true", Valid: true}
|
||||
}
|
||||
|
||||
params := services.SearchParams{
|
||||
SearchQuery: c.QueryParam("q"),
|
||||
LibraryID: libUUID,
|
||||
AuthorFilter: c.QueryParam("author_filter"),
|
||||
SeriesFilter: c.QueryParam("series_filter"),
|
||||
GenreFilter: c.QueryParam("genre_filter"),
|
||||
PublisherFilter: c.QueryParam("publisher_filter"),
|
||||
TagsFilter: c.QueryParam("tags_filter"),
|
||||
NarratorFilter: c.QueryParam("narrator_filter"),
|
||||
LanguageFilter: c.QueryParam("language_filter"),
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
HasCover: boolFacet("has_cover"),
|
||||
StoryArcFilter: c.QueryParam("story_arc_filter"),
|
||||
ImprintFilter: c.QueryParam("imprint_filter"),
|
||||
EditionFilter: c.QueryParam("edition_filter"),
|
||||
AgeRatingFilter: c.QueryParam("age_rating_filter"),
|
||||
FormatGroupFilter: c.QueryParam("format_group_filter"),
|
||||
MangaTypeFilter: c.QueryParam("manga_type_filter"),
|
||||
ReadingDirectionFilter: c.QueryParam("reading_direction_filter"),
|
||||
ExplicitFilter: boolFacet("explicit_filter"),
|
||||
AbridgedFilter: boolFacet("abridged_filter"),
|
||||
Sort: sortParam,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
// Filter rule builder (faceted plan Phase 9): the qf param carries
|
||||
// the rule tree. Parse + validate HERE (the shared parser — every
|
||||
// entry point validates identically); a failure lands in
|
||||
// FilterParseErr for the endpoints to answer 400, never a silently
|
||||
// ignored token. Precedence: qf present → flat facet params are
|
||||
// ignored by the engine (qf wins).
|
||||
if qf := c.QueryParam("qf"); qf != "" {
|
||||
tree, err := services.ParseFilterTree(qf)
|
||||
if err != nil {
|
||||
params.FilterParseErr = err.Error()
|
||||
} else {
|
||||
params.FilterTree = tree
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// libraryVisible reports whether the requested library is inside the
|
||||
// requester's visible-library set. Slice compare on pgtype.UUID bytes —
|
||||
// immune to string-format differences between the query param and the
|
||||
// membership map keys.
|
||||
func (mh *MediaHandler) libraryVisible(c *echo.Context, libraryID pgtype.UUID) (bool, error) {
|
||||
_, ids, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if id == libraryID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetFacets handles GET /api/media-items/facets — the filter panel's
|
||||
// value+count listing (faceted-navigation Batch C, extended in Phase 7).
|
||||
//
|
||||
// Two shapes:
|
||||
// - ?facet=X&library_id=… → the PINNED single-facet shape
|
||||
// ({facet, values[]}); facet must name a FacetArray or FacetScalar
|
||||
// entry (bool/range facets render as toggles, not lists).
|
||||
// - no facet param → the all-facets shape ({"facets": {param:
|
||||
// [value,…]}}), computed under the ACTIVE FILTERS (the same query
|
||||
// params the search endpoint takes) — each facet counts under the
|
||||
// others, the SQL omitting each arm's own block. library_id is
|
||||
// optional: absent = the All-Libraries view.
|
||||
//
|
||||
// Family-lens: an explicit library_id outside the visible set is 403
|
||||
// in both shapes.
|
||||
func (mh *MediaHandler) GetFacets(c *echo.Context) error {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
||||
}
|
||||
|
||||
facetParam := c.QueryParam("facet")
|
||||
libraryID := c.QueryParam("library_id")
|
||||
|
||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
if facetParam == "" {
|
||||
// All-facets shape: filters bind exactly like the search
|
||||
// endpoint's (ParseMediaSearchParams), so the panel's counts
|
||||
// and the grid's rows can never disagree about what's active.
|
||||
var libPg pgtype.UUID
|
||||
if libraryID != "" {
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libPg = pgtype.UUID{Bytes: libUUID, Valid: true}
|
||||
visible, err := mh.libraryVisible(c, libPg)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !visible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||
}
|
||||
}
|
||||
counts, err := mh.searchService.FacetCounts(c.Request().Context(), user.ID, libPg, ParseMediaSearchParams(c), limit)
|
||||
if err != nil {
|
||||
c.Logger().Error("facet counts error", "error", err.Error())
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"facets": counts,
|
||||
})
|
||||
}
|
||||
|
||||
if libraryID == "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library_id is required"})
|
||||
}
|
||||
facet := services.FacetByParam(facetParam)
|
||||
if facet == nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "unknown facet: " + facetParam})
|
||||
}
|
||||
if facet.Kind != services.FacetArray && facet.Kind != services.FacetScalar {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "facet does not list values: " + facetParam})
|
||||
}
|
||||
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libPg := pgtype.UUID{Bytes: libUUID, Valid: true}
|
||||
visible, err := mh.libraryVisible(c, libPg)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !visible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||
}
|
||||
|
||||
values, err := mh.searchService.FacetValues(c.Request().Context(), user.ID, *facet, libPg, limit)
|
||||
if err != nil {
|
||||
c.Logger().Error("facet values error", "error", err.Error(), "facet", facetParam)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"facet": facetParam,
|
||||
"values": values,
|
||||
})
|
||||
}
|
||||
|
||||
// handleFieldValuesSearch handles autocomplete/suggestion queries
|
||||
// (author=value, genre=value, note_text=value, …). Returns distinct
|
||||
// field values with counts and similarity scores for dropdown
|
||||
// population.
|
||||
//
|
||||
// library_id is OPTIONAL: absent means "every library the requesting user
|
||||
// can see" — the per-user family lens is enforced inside the query (the
|
||||
// library_visibility join), so the All-Books view's suggestions aggregate
|
||||
// the visible world. An explicit library_id outside the visible set is a
|
||||
// 403 (AGENTS standing rule: never silently pass).
|
||||
//
|
||||
// Routing (faceted plan Phase 9): with a qf rule tree, or for fields
|
||||
// beyond the classic six, suggestions come from the COMPOSED engine —
|
||||
// the other rules bind as filters (symmetric: every rule's menu sees
|
||||
// every other rule; zero-match values are never offered). Without a
|
||||
// tree, the classic six keep their static queries (pinned contract).
|
||||
func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.UUID, fieldType, searchQuery string) error {
|
||||
var libPg pgtype.UUID
|
||||
if libraryID := c.QueryParam("library_id"); libraryID != "" {
|
||||
libUUID, err := uuid.Parse(libraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
||||
}
|
||||
libPg = pgtype.UUID{Bytes: libUUID, Valid: true}
|
||||
visible, err := mh.libraryVisible(c, libPg)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !visible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||
}
|
||||
}
|
||||
|
||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||
if limit == 0 {
|
||||
@@ -2435,27 +2646,98 @@ func (mh *MediaHandler) handleFieldValuesSearch(c *echo.Context, userID pgtype.U
|
||||
}
|
||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
||||
|
||||
params := services.FieldSearchParams{
|
||||
UserID: userID,
|
||||
LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true},
|
||||
FieldType: fieldType,
|
||||
SearchQuery: searchQuery,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
// qf: the OTHER completed rules (the client strips the rule being
|
||||
// edited). Parse + validate here — a malformed tree is a 400.
|
||||
var tree *services.FilterTree
|
||||
if qf := c.QueryParam("qf"); qf != "" {
|
||||
t, err := services.ParseFilterTree(qf)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid filter: " + err.Error()})
|
||||
}
|
||||
tree = t
|
||||
}
|
||||
|
||||
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
|
||||
// Static path: the classic six with a REAL typed query and no rule
|
||||
// tree (unchanged contract — the tag-dropdown island and deep links
|
||||
// still ride it). A BLANK typed query is BROWSE (the builder's
|
||||
// click-the-box top-values list): the static queries would
|
||||
// similarity-match a single space into nothing, so browse rides the
|
||||
// composed engine, which trims the query and answers the top values
|
||||
// under the rules.
|
||||
staticSix := tree == nil && strings.TrimSpace(searchQuery) != ""
|
||||
if staticSix {
|
||||
switch fieldType {
|
||||
case "author", "genre", "tags", "narrator", "series", "language":
|
||||
params := services.FieldSearchParams{
|
||||
UserID: userID,
|
||||
LibraryID: libPg,
|
||||
FieldType: fieldType,
|
||||
SearchQuery: searchQuery,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
results, err := mh.searchService.SearchFieldValues(c.Request().Context(), params)
|
||||
if err != nil {
|
||||
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Composed path: registry field under the other rules.
|
||||
results, err := mh.searchService.ComposedFieldValues(c.Request().Context(), services.ComposedSuggestParams{
|
||||
UserID: userID,
|
||||
LibraryID: libPg,
|
||||
FieldKey: fieldType,
|
||||
Typed: searchQuery,
|
||||
Tree: tree,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
c.Logger().Error("field values search error", "error", err.Error(), "fieldType", fieldType)
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"results": results,
|
||||
"total": len(results),
|
||||
})
|
||||
}
|
||||
|
||||
// FilterCount handles GET /api/media-items/filter-count — the rule
|
||||
// builder's live match count: the composed rules + the library scope,
|
||||
// no pagination (a true COUNT(*), not the page length).
|
||||
func (mh *MediaHandler) FilterCount(c *echo.Context) error {
|
||||
user, ok := c.Get("user").(database.Users)
|
||||
if !ok {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "user not authenticated"})
|
||||
}
|
||||
params := ParseMediaSearchParams(c)
|
||||
if params.FilterParseErr != "" {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid filter: " + params.FilterParseErr,
|
||||
})
|
||||
}
|
||||
if params.LibraryID.Valid {
|
||||
visible, err := mh.libraryVisible(c, params.LibraryID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
if !visible {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not visible"})
|
||||
}
|
||||
}
|
||||
count, err := mh.searchService.ComposedCount(c.Request().Context(), user.ID, params.LibraryID, params.FilterTree)
|
||||
if err != nil {
|
||||
c.Logger().Error("filter count error", "error", err.Error())
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{"count": count})
|
||||
}
|
||||
|
||||
// getFullFilePath returns the absolute filesystem path for a media item
|
||||
// Uses LibraryService for resolution (one source of truth)
|
||||
func (mh *MediaHandler) getFullFilePath(ctx context.Context, libraryID pgtype.UUID, relativePath string) (string, error) {
|
||||
@@ -2709,16 +2991,96 @@ func (mh *MediaHandler) GetAudioChapters(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Playback speed (plan §8): resolve per-book row → user default →
|
||||
// 1.0 IN the payload so the player applies it with zero extra round
|
||||
// trips; default_playback_speed powers the dropdown's "(default)"
|
||||
// marker. Speed is a preference, so this rides the authenticated
|
||||
// user — anonymous readers just get 1.0.
|
||||
playbackSpeed, defaultSpeed := 1.0, 1.0
|
||||
if userID, ok := c.Get("user_id").(string); ok {
|
||||
if userUUID, err := uuid.Parse(userID); err == nil {
|
||||
userRow, err := mh.db.GetUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
|
||||
if err == nil && userRow.DefaultAudioSpeed > 0 {
|
||||
defaultSpeed = float64(userRow.DefaultAudioSpeed)
|
||||
}
|
||||
bookSpeed, err := mh.db.GetAudioPlaybackSpeed(c.Request().Context(), database.GetAudioPlaybackSpeedParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
})
|
||||
if err == nil && bookSpeed > 0 {
|
||||
playbackSpeed = float64(bookSpeed)
|
||||
} else {
|
||||
playbackSpeed = defaultSpeed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, map[string]interface{}{
|
||||
"media_item_id": item.ID,
|
||||
"chapters": payload.Chapters,
|
||||
"duration_seconds": payload.DurationSeconds,
|
||||
"codec_profile": payload.CodecProfile,
|
||||
"layout": payload.Layout,
|
||||
"tracks": tracks,
|
||||
"media_item_id": item.ID,
|
||||
"chapters": payload.Chapters,
|
||||
"duration_seconds": payload.DurationSeconds,
|
||||
"codec_profile": payload.CodecProfile,
|
||||
"layout": payload.Layout,
|
||||
"tracks": tracks,
|
||||
"playback_speed": playbackSpeed,
|
||||
"default_playback_speed": defaultSpeed,
|
||||
})
|
||||
}
|
||||
|
||||
// SetAudioSpeed handles PUT /api/media-items/:id/audio-speed — the
|
||||
// per-user per-book playback speed upsert (plan §8). The client SPEEDS
|
||||
// list is the real gate; the server sanity-checks the range.
|
||||
func (mh *MediaHandler) SetAudioSpeed(c *echo.Context) error { 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"})
|
||||
}
|
||||
userID, ok := c.Get("user_id").(string)
|
||||
if !ok || userID == "" {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "authentication required"})
|
||||
}
|
||||
userUUID, err := uuid.Parse(userID)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid user"})
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Speed float64 `json:"speed"`
|
||||
}
|
||||
if err := c.Bind(&req); err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
||||
}
|
||||
if req.Speed < 0.25 || req.Speed > 4 || math.IsNaN(req.Speed) || math.IsInf(req.Speed, 0) {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "speed out of range"})
|
||||
}
|
||||
|
||||
// The book must exist (and be visible): a preference for a hidden
|
||||
// library must not be writable — family-lens rule.
|
||||
item, err := mh.db.GetMediaItem(c.Request().Context(), pgtype.UUID{Bytes: mediaUUID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "book not found"})
|
||||
}
|
||||
if item.LibraryID.Valid {
|
||||
visibleSet, _, err := mh.visibleLibrarySet(c)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to check library access"})
|
||||
}
|
||||
if !visibleSet[uuid.UUID(item.LibraryID.Bytes).String()] {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
}
|
||||
|
||||
speed, err := mh.db.UpsertAudioPlaybackSpeed(c.Request().Context(), database.UpsertAudioPlaybackSpeedParams{
|
||||
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
|
||||
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
|
||||
Speed: float32(req.Speed),
|
||||
})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save playback speed"})
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]float64{"playback_speed": float64(speed)})
|
||||
}
|
||||
|
||||
// GetAudioTrack handles GET /api/media-items/:id/audio/:trackIndex — the
|
||||
// per-track byte stream for multi-file audiobooks (folder books serve the
|
||||
// real file; zip books extract the entry in place). Library visibility is
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
|
||||
"bookhoard/internal/services"
|
||||
)
|
||||
|
||||
type ProcessingIssuesHandler struct {
|
||||
@@ -19,6 +21,45 @@ func NewProcessingIssuesHandler(db *database.Queries) *ProcessingIssuesHandler {
|
||||
return &ProcessingIssuesHandler{db: db}
|
||||
}
|
||||
|
||||
// visibleLibrarySetFor resolves the requesting user's visible-library
|
||||
// set (family-lens) from the request context identity. The issues
|
||||
// endpoints leak media metadata (titles, paths) — a hidden library's
|
||||
// rows must never reach a user it's hidden from, same rule as every
|
||||
// media surface. Identity follows the router's visibleLibraryIDsFor
|
||||
// (the JWT middleware sets "user_id"; AdminMiddleware only reads the
|
||||
// role — there is no "user" object on this chain).
|
||||
func (h *ProcessingIssuesHandler) visibleLibrarySetFor(c *echo.Context) (map[string]bool, error) {
|
||||
userUUIDStr, ok := c.Get("user_id").(string)
|
||||
if !ok {
|
||||
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
userU, err := uuid.Parse(userUUIDStr)
|
||||
if err != nil {
|
||||
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
||||
}
|
||||
set, _, err := services.NewLibraryService(h.db).VisibleLibrarySet(
|
||||
c.Request().Context(), pgtype.UUID{Bytes: userU, Valid: true})
|
||||
return set, err
|
||||
}
|
||||
|
||||
// gateLibrary enforces the family lens on a library-scoped request:
|
||||
// an explicit library id outside the requester's visible set is a 403,
|
||||
// never a silent pass (faceted plan family-lens rule).
|
||||
func (h *ProcessingIssuesHandler) gateLibrary(c *echo.Context, libraryIDStr string) error {
|
||||
libUUID, err := uuid.Parse(libraryIDStr)
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Invalid library ID")
|
||||
}
|
||||
visibleSet, visErr := h.visibleLibrarySetFor(c)
|
||||
if visErr != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check library visibility")
|
||||
}
|
||||
if !visibleSet[libUUID.String()] {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Library not accessible")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ProcessingIssueResponse struct {
|
||||
ID string `json:"id"`
|
||||
MediaItemID string `json:"media_item_id"`
|
||||
@@ -40,10 +81,10 @@ type ProcessingIssueStats struct {
|
||||
|
||||
// ListProcessingIssues returns all processing issues for a library
|
||||
func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
libraryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||
if err := h.gateLibrary(c, c.Param("id")); err != nil {
|
||||
return err
|
||||
}
|
||||
libraryID, _ := uuid.Parse(c.Param("id"))
|
||||
|
||||
issues, err := h.db.ListProcessingIssuesByLibrary(
|
||||
c.Request().Context(),
|
||||
@@ -55,10 +96,21 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
|
||||
response := make([]ProcessingIssueResponse, len(issues))
|
||||
for i, issue := range issues {
|
||||
// Row-less import failures have no media item — emit an empty id
|
||||
// rather than the zero UUID, and fall back the display title to
|
||||
// the file path so the ledger reads like itself.
|
||||
itemID := ""
|
||||
if issue.MediaItemID.Valid {
|
||||
itemID = issue.MediaItemID.String()
|
||||
}
|
||||
title := issue.Title
|
||||
if title == "" {
|
||||
title = issue.FilePath
|
||||
}
|
||||
response[i] = ProcessingIssueResponse{
|
||||
ID: issue.ID.String(),
|
||||
MediaItemID: issue.MediaItemID.String(),
|
||||
Title: issue.Title,
|
||||
MediaItemID: itemID,
|
||||
Title: title,
|
||||
FilePath: issue.FilePath,
|
||||
FormatGroup: issue.FormatGroup,
|
||||
LibraryTypeName: issue.LibraryTypeName,
|
||||
@@ -74,10 +126,10 @@ func (h *ProcessingIssuesHandler) ListProcessingIssues(c *echo.Context) error {
|
||||
|
||||
// GetProcessingIssueStats returns statistics about processing issues
|
||||
func (h *ProcessingIssuesHandler) GetProcessingIssueStats(c *echo.Context) error {
|
||||
libraryID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid library ID"})
|
||||
if err := h.gateLibrary(c, c.Param("id")); err != nil {
|
||||
return err
|
||||
}
|
||||
libraryID, _ := uuid.Parse(c.Param("id"))
|
||||
|
||||
stats, err := h.db.GetProcessingIssueStats(
|
||||
c.Request().Context(),
|
||||
@@ -106,6 +158,10 @@ func (h *ProcessingIssuesHandler) ResolveProcessingIssue(c *echo.Context) error
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid media item ID"})
|
||||
}
|
||||
|
||||
if err := h.gateIssue(c, issueID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = h.db.ResolveProcessingIssue(
|
||||
c.Request().Context(),
|
||||
database.ResolveProcessingIssueParams{
|
||||
@@ -130,6 +186,10 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid issue ID"})
|
||||
}
|
||||
|
||||
if err := h.gateIssue(c, issueID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = h.db.DeleteProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to delete issue"})
|
||||
@@ -141,6 +201,23 @@ func (h *ProcessingIssuesHandler) DeleteProcessingIssue(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
// gateIssue enforces the family lens on an issue-addressed request: the
|
||||
// issue's library must be in the requester's visible set (403 otherwise).
|
||||
func (h *ProcessingIssuesHandler) gateIssue(c *echo.Context, issueID uuid.UUID) error {
|
||||
issue, err := h.db.GetProcessingIssue(c.Request().Context(), pgtype.UUID{Bytes: issueID, Valid: true})
|
||||
if err != nil {
|
||||
return echo.NewHTTPError(http.StatusNotFound, "Issue not found")
|
||||
}
|
||||
visibleSet, visErr := h.visibleLibrarySetFor(c)
|
||||
if visErr != nil {
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to check library visibility")
|
||||
}
|
||||
if !issue.LibraryID.Valid || !visibleSet[uuid.UUID(issue.LibraryID.Bytes).String()] {
|
||||
return echo.NewHTTPError(http.StatusForbidden, "Library not accessible")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProcessingIssueStatsData returns stats for SSR (not JSON response)
|
||||
func (h *ProcessingIssuesHandler) GetProcessingIssueStatsData(ctx context.Context, libraryID pgtype.UUID) (database.GetProcessingIssueStatsRow, error) {
|
||||
return h.db.GetProcessingIssueStats(ctx, libraryID)
|
||||
|
||||
@@ -84,6 +84,23 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
||||
return c.JSON(http.StatusBadRequest, map[string]string{"error": "either library_id or folder_paths required for scanning"})
|
||||
}
|
||||
|
||||
// Coalescing (plan Part 2 §5): a scan for this library is already
|
||||
// pending or running — 409 with the active job so the UI can show
|
||||
// progress instead of stacking a second full scan.
|
||||
if req.LibraryID != "" && h.worker.HasActiveScanForLibrary(req.LibraryID) {
|
||||
var activeJobID string
|
||||
for _, active := range h.worker.GetActiveScanJobs() {
|
||||
if active.JobType == services.JobTypeScan && active.LibraryID == req.LibraryID {
|
||||
activeJobID = active.JobID
|
||||
break
|
||||
}
|
||||
}
|
||||
return c.JSON(http.StatusConflict, map[string]string{
|
||||
"error": "a scan for this library is already pending or running",
|
||||
"active_job_id": activeJobID,
|
||||
})
|
||||
}
|
||||
|
||||
jobID := uuid.New().String()
|
||||
|
||||
job := &services.Job{
|
||||
@@ -96,6 +113,7 @@ func (h *Handler) ScanLibrary(c *echo.Context) error {
|
||||
"admin_id": userUUID.String(),
|
||||
"db": h.db,
|
||||
"force": req.Force,
|
||||
"trigger": "admin",
|
||||
},
|
||||
Status: services.JobStatusPending,
|
||||
Context: h.ctx,
|
||||
|
||||
@@ -16,6 +16,7 @@ func registerCollectionsRoutes(cfg *Config) {
|
||||
collections.DELETE("/:id", cfg.CollectionHandler.DeleteCollection)
|
||||
collections.GET("/:id/books", cfg.CollectionHandler.GetBookCollections)
|
||||
collections.POST("/:id/books", cfg.CollectionHandler.AddBooks)
|
||||
collections.PUT("/:id/items/order", cfg.CollectionHandler.ReorderItems)
|
||||
collections.DELETE("/:id/books/:bookId", cfg.CollectionHandler.RemoveBook)
|
||||
collections.POST("/:id/books/bulk-remove", cfg.CollectionHandler.BulkRemoveBooks)
|
||||
collections.POST("/bulk-add-books", cfg.CollectionHandler.HandleBulkAddBooks)
|
||||
|
||||
@@ -605,6 +605,19 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
Icon: utils.PgTextString(collection.Icon),
|
||||
}
|
||||
|
||||
// A2c §3.3: playlist toggles from view_settings.
|
||||
if len(collection.ViewSettings) > 0 {
|
||||
var vs map[string]interface{}
|
||||
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
||||
if b, ok := vs["hide_finished"].(bool); ok {
|
||||
colData.HideFinished = b
|
||||
}
|
||||
if b, ok := vs["show_up_next"].(bool); ok {
|
||||
colData.ShowUpNext = b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure default color if empty
|
||||
if colData.Color == "" {
|
||||
colData.Color = "blue"
|
||||
@@ -1210,6 +1223,15 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Invalid library ID", "invalid_id")
|
||||
}
|
||||
// Family lens: issues leak media metadata (titles, file paths) —
|
||||
// a library outside the requester's visible set renders nothing.
|
||||
visibleSet, _, visErr := visibleLibraryIDsFor(c, cfg)
|
||||
if visErr != nil {
|
||||
return renderErrorPage(c, "Error checking library access", "visibility_error")
|
||||
}
|
||||
if !visibleSet[libraryID] {
|
||||
return renderErrorPage(c, "Library not accessible", "library_not_accessible")
|
||||
}
|
||||
// Fetch stats
|
||||
stats, err := cfg.Queries.GetProcessingIssueStats(
|
||||
c.Request().Context(),
|
||||
@@ -1226,14 +1248,23 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading issues", "issues_load_error")
|
||||
}
|
||||
// Convert to template data
|
||||
// Convert to template data (row-less import failures have no
|
||||
// media item: empty id, and the title falls back to the path).
|
||||
issues := make([]templates.ProcessingIssueData, len(dbIssues))
|
||||
for i, issue := range dbIssues {
|
||||
issueUUID, _ := uuid.FromBytes(issue.ID.Bytes[0:16])
|
||||
itemID := ""
|
||||
if issue.MediaItemID.Valid {
|
||||
itemID = uuid.UUID(issue.MediaItemID.Bytes).String()
|
||||
}
|
||||
title := issue.Title
|
||||
if title == "" {
|
||||
title = issue.FilePath
|
||||
}
|
||||
issues[i] = templates.ProcessingIssueData{
|
||||
ID: issueUUID.String(),
|
||||
MediaItemID: uuid.UUID(issue.MediaItemID.Bytes).String(),
|
||||
Title: issue.Title,
|
||||
MediaItemID: itemID,
|
||||
Title: title,
|
||||
FilePath: issue.FilePath,
|
||||
FormatGroup: issue.FormatGroup,
|
||||
LibraryTypeName: issue.LibraryTypeName,
|
||||
|
||||
+11
-10
@@ -56,16 +56,17 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
}
|
||||
|
||||
return templates.User{
|
||||
ID: userID,
|
||||
Email: userEmail,
|
||||
Username: userUsername,
|
||||
Role: userRole,
|
||||
Theme: userTheme,
|
||||
Token: token,
|
||||
Timezone: userTimezone,
|
||||
FirstName: utils.PgTextString(userDB.FirstName),
|
||||
LastName: utils.PgTextString(userDB.LastName),
|
||||
CreatedAt: userDB.CreatedAt.Time,
|
||||
ID: userID,
|
||||
Email: userEmail,
|
||||
Username: userUsername,
|
||||
Role: userRole,
|
||||
Theme: userTheme,
|
||||
Token: token,
|
||||
Timezone: userTimezone,
|
||||
FirstName: utils.PgTextString(userDB.FirstName),
|
||||
LastName: utils.PgTextString(userDB.LastName),
|
||||
CreatedAt: userDB.CreatedAt.Time,
|
||||
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func registerMediaRoutes(cfg *Config) {
|
||||
// books stream through the existing /uploads Range-serving path;
|
||||
// multi-file books additionally expose per-track streaming.
|
||||
protected.GET("/media-items/:id/chapters", cfg.MediaHandler.GetAudioChapters)
|
||||
protected.PUT("/media-items/:id/audio-speed", cfg.MediaHandler.SetAudioSpeed)
|
||||
protected.GET("/media-items/:id/audio/:trackIndex", cfg.MediaHandler.GetAudioTrack)
|
||||
|
||||
// Media rating routes (all authenticated users)
|
||||
|
||||
+39
-54
@@ -3,15 +3,11 @@ package router
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/templates"
|
||||
"bytes"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
@@ -30,6 +26,12 @@ func registerSearchRoutes(cfg *Config) {
|
||||
// Otherwise return JSON (API clients)
|
||||
return cfg.MediaHandler.SearchMediaItems(c)
|
||||
})
|
||||
// Facet-value listings for the filter panel (faceted-navigation
|
||||
// Batch C): /api/media-items/facets?facet=X&library_id=...
|
||||
protected.GET("/media-items/facets", cfg.MediaHandler.GetFacets)
|
||||
// Rule-builder live count (faceted plan Phase 9): the composed
|
||||
// tree's true match count (COUNT(*)), rules + library scope.
|
||||
protected.GET("/media-items/filter-count", cfg.MediaHandler.FilterCount)
|
||||
protected.POST("/sync/books/query", cfg.MatchingHandler.QueryBooks)
|
||||
}
|
||||
|
||||
@@ -39,60 +41,43 @@ func handleSearchHTML(c *echo.Context, cfg *Config) error {
|
||||
if !ok {
|
||||
return c.String(http.StatusUnauthorized, "Unauthorized")
|
||||
}
|
||||
// Extract query parameters
|
||||
query := c.QueryParam("q")
|
||||
// Extract filters via the SHARED parser (handlers.ParseMediaSearchParams)
|
||||
// — the HTMX grid partial and the JSON endpoint can never disagree on
|
||||
// which facet params exist or how they parse. (This branch previously
|
||||
// dropped publisher_filter and narrator_filter entirely — the shared
|
||||
// parser fixes that too.)
|
||||
params := handlers.ParseMediaSearchParams(c)
|
||||
params.UserID = user.ID
|
||||
|
||||
// Filter rule builder: a malformed qf is a 400, never a silently
|
||||
// ignored token.
|
||||
if params.FilterParseErr != "" {
|
||||
return c.HTML(http.StatusBadRequest, `<div style="color: red;">Invalid filter</div>`)
|
||||
}
|
||||
limit := params.Limit
|
||||
offset := params.Offset
|
||||
libraryID := c.QueryParam("library_id")
|
||||
limit, _ := strconv.Atoi(c.QueryParam("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
offset, _ := strconv.Atoi(c.QueryParam("offset"))
|
||||
// Validate library_id
|
||||
var libUUID pgtype.UUID
|
||||
if libraryID != "" {
|
||||
lib, err := uuid.Parse(libraryID)
|
||||
|
||||
// Family-lens gate: an explicit library_id outside the requester's
|
||||
// visible set is a 403 (same rule as the JSON branch).
|
||||
if params.LibraryID.Valid {
|
||||
_, ids, err := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
|
||||
if err != nil {
|
||||
return c.String(http.StatusBadRequest, "invalid library_id")
|
||||
log.Printf("VisibleLibrarySet failed: %v", err)
|
||||
return c.HTML(http.StatusInternalServerError, `<div style="color: red;">Search error</div>`)
|
||||
}
|
||||
visible := false
|
||||
for _, id := range ids {
|
||||
if id == params.LibraryID {
|
||||
visible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !visible {
|
||||
return c.HTML(http.StatusForbidden, `<div style="color: red;">Library not visible</div>`)
|
||||
}
|
||||
libUUID = pgtype.UUID{Bytes: lib, Valid: true}
|
||||
} else {
|
||||
libUUID = pgtype.UUID{Valid: false}
|
||||
}
|
||||
// Extract filters
|
||||
authorFilter := c.QueryParam("author_filter")
|
||||
seriesFilter := c.QueryParam("series_filter")
|
||||
genreFilter := c.QueryParam("genre_filter")
|
||||
tagsFilter := c.QueryParam("tags_filter")
|
||||
languageFilter := c.QueryParam("language_filter")
|
||||
yearMin, _ := strconv.Atoi(c.QueryParam("year_min"))
|
||||
yearMax, _ := strconv.Atoi(c.QueryParam("year_max"))
|
||||
hasCover := false
|
||||
hasCoverValid := false
|
||||
if _, exists := c.QueryParams()["has_cover"]; exists {
|
||||
hasCover = c.QueryParam("has_cover") == "true"
|
||||
hasCoverValid = true
|
||||
}
|
||||
sortParam := c.QueryParam("sort")
|
||||
if sortParam == "" {
|
||||
sortParam = "title ASC"
|
||||
}
|
||||
// Build search params
|
||||
params := services.SearchParams{
|
||||
UserID: user.ID,
|
||||
LibraryID: libUUID,
|
||||
SearchQuery: query,
|
||||
AuthorFilter: authorFilter,
|
||||
SeriesFilter: seriesFilter,
|
||||
GenreFilter: genreFilter,
|
||||
TagsFilter: tagsFilter,
|
||||
LanguageFilter: languageFilter,
|
||||
YearMin: yearMin,
|
||||
YearMax: yearMax,
|
||||
HasCover: pgtype.Bool{Bool: hasCover, Valid: hasCoverValid},
|
||||
Sort: sortParam,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}
|
||||
|
||||
// Execute search using handler method
|
||||
results, totalCount, err := cfg.MediaHandler.ExecuteSearch(c.Request().Context(), params)
|
||||
if err != nil {
|
||||
|
||||
@@ -155,6 +155,23 @@ func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.
|
||||
|
||||
// UpdateCollection updates a collection
|
||||
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (database.Collections, error) {
|
||||
// A2c §3.3: the edit form carries only the toggles it renders — merge
|
||||
// them OVER the stored view_settings so provenance keys
|
||||
// (imported_from_series) and anything else on the row survive.
|
||||
if len(viewSettings) > 0 {
|
||||
existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err == nil && len(existing.ViewSettings) > 0 {
|
||||
var stored map[string]any
|
||||
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
|
||||
merged := stored
|
||||
for k, v := range viewSettings {
|
||||
merged[k] = v
|
||||
}
|
||||
viewSettings = merged
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert rules to JSONB ([]byte)
|
||||
var rulesJSON []byte
|
||||
if len(autoAssignRules) > 0 {
|
||||
@@ -785,3 +802,35 @@ func parseFloat(s string) (float64, error) {
|
||||
|
||||
return sign * result / divisor, nil
|
||||
}
|
||||
|
||||
// UserOwnsCollection gates playlist-ordering writes (A2c §3.2): the
|
||||
// collection row's owner is the only user whose drag actions rewrite
|
||||
// positions.
|
||||
func (s *CollectionService) UserOwnsCollection(ctx context.Context, collectionID, userID uuid.UUID) (bool, error) {
|
||||
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return uuid.UUID(collection.UserID.Bytes) == userID, nil
|
||||
}
|
||||
|
||||
// ReorderItems applies a full playlist order (A2c §3.2): the payload is the
|
||||
// complete ordered id list; positions become 1..n in payload order. Ids
|
||||
// not in the payload keep their relative order after — the SQL only
|
||||
// touches the given ids, and NULL positions sort last. Never deletes.
|
||||
func (s *CollectionService) ReorderItems(ctx context.Context, collectionID uuid.UUID, orderedIDs []uuid.UUID) error {
|
||||
if len(orderedIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]pgtype.UUID, 0, len(orderedIDs))
|
||||
for _, id := range orderedIDs {
|
||||
ids = append(ids, pgtype.UUID{Bytes: id, Valid: true})
|
||||
}
|
||||
if err := s.db.ReorderCollectionItems(ctx, database.ReorderCollectionItemsParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
||||
MediaItemIds: ids,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("failed to reorder collection items: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -24,6 +24,34 @@ func NewDashboardService(db *database.Queries) *DashboardService {
|
||||
}
|
||||
}
|
||||
|
||||
// viewSettingsFlag reads a boolean key from a collection's view_settings
|
||||
// JSONB. Malformed or absent → false.
|
||||
func viewSettingsFlag(raw []byte, key string) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
return false
|
||||
}
|
||||
b, ok := settings[key].(bool)
|
||||
return ok && b
|
||||
}
|
||||
|
||||
// viewSettingsHasKey reports whether the key exists at all (any value) —
|
||||
// used for provenance markers like imported_from_series.
|
||||
func viewSettingsHasKey(raw []byte, key string) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
var settings map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &settings); err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := settings[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
type DashboardSection struct {
|
||||
CollectionID uuid.UUID
|
||||
CollectionName string
|
||||
@@ -34,6 +62,13 @@ type DashboardSection struct {
|
||||
Title string
|
||||
Description string
|
||||
Icon string
|
||||
// A2c: the collection's own ordering-construct marker — the section is
|
||||
// a curated playlist ("The Witcher (Reading Order)"), rendered with a
|
||||
// badge by the frontend.
|
||||
IsPlaylist bool
|
||||
// A2c §3.5: hide_finished filtered every item out of a NON-empty
|
||||
// collection — render "playlist complete" instead of dropping silently.
|
||||
Complete bool
|
||||
}
|
||||
|
||||
func (s *DashboardService) GetDashboardSections(
|
||||
@@ -81,7 +116,29 @@ func (s *DashboardService) GetDashboardSections(
|
||||
continue
|
||||
}
|
||||
|
||||
// A2c §3.5: hide_finished filtered everything out — but the
|
||||
// collection is NOT empty. That's a finished playlist: render a
|
||||
// completion marker instead of silently vanishing.
|
||||
if len(items) == 0 {
|
||||
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
||||
if hideFinished {
|
||||
collUUIDBytes, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
||||
if count, countErr := s.db.CountCollectionItems(ctx, pgtype.UUID{Bytes: collUUIDBytes, Valid: true}); countErr == nil && count > 0 {
|
||||
results = append(results, DashboardSection{
|
||||
CollectionID: coll.ID.Bytes,
|
||||
CollectionName: coll.Name,
|
||||
Items: []database.MediaItems{},
|
||||
QueryType: coll.QueryType.String,
|
||||
Priority: int(coll.Priority.Int32),
|
||||
IsSystem: false,
|
||||
Title: coll.Name,
|
||||
Description: coll.Description.String,
|
||||
Icon: coll.Icon.String,
|
||||
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
||||
Complete: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -95,6 +152,7 @@ func (s *DashboardService) GetDashboardSections(
|
||||
Title: coll.Name,
|
||||
Description: coll.Description.String,
|
||||
Icon: coll.Icon.String,
|
||||
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -218,9 +276,15 @@ func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, co
|
||||
func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.UUID, limit int) ([]database.MediaItems, error) {
|
||||
collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
||||
|
||||
// A2c §3.3: per-collection hide_finished (view_settings), evaluated
|
||||
// against the VIEWER's reading progress. The section then shows the
|
||||
// head of the reading queue in playlist order.
|
||||
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
||||
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
||||
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
||||
LibraryID: libraryID,
|
||||
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||||
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package services
|
||||
|
||||
// Facet registry (faceted-navigation plan, Part 1 Batch A).
|
||||
//
|
||||
// The single declarative source of truth for bookshelf filter facets.
|
||||
// sqlc has NO dynamic WHERE — each registry entry corresponds to an
|
||||
// explicit `sqlc.narg('<param>_filter')` block in
|
||||
// `SearchMediaItemsUnified` (internal/database/queries/queries.sql);
|
||||
// the drift-guard test (facet_registry_test.go) fails the build when an
|
||||
// entry's block is missing. Adding a facet = one registry entry + one
|
||||
// narg block + one handler line (+ one templ call in Batch B).
|
||||
//
|
||||
// Wire semantics: the query param is `<Param>_filter`; a comma-joined
|
||||
// value is multi-select (OR within the facet, AND across facets) for
|
||||
// every text kind — the SQL splits via string_to_array. Bools are
|
||||
// presence-bound: absent → NULL (no filtering), "true"/"false" → bound.
|
||||
|
||||
import "strings"
|
||||
|
||||
// FacetKind classifies the media_items column shape a facet filters on.
|
||||
type FacetKind string
|
||||
|
||||
const (
|
||||
FacetArray FacetKind = "array" // TEXT[] column, per-element match
|
||||
FacetScalar FacetKind = "scalar" // varchar column
|
||||
FacetBool FacetKind = "bool" // bool column
|
||||
FacetRange FacetKind = "range" // int min/max pair (year_min/year_max)
|
||||
)
|
||||
|
||||
// FacetMatch classifies how values are compared within the kind's SQL
|
||||
// shape.
|
||||
type FacetMatch string
|
||||
|
||||
const (
|
||||
MatchPerElementFuzzy FacetMatch = "per_element_fuzzy" // word_similarity per array element
|
||||
MatchScalarFuzzy FacetMatch = "scalar_fuzzy" // word_similarity against the column
|
||||
MatchExact FacetMatch = "exact" // = / ANY() / bool equality
|
||||
)
|
||||
|
||||
// Facet is one registry entry.
|
||||
type Facet struct {
|
||||
Param string // "publisher" → ?publisher_filter= (see WireParam)
|
||||
// Wire is an optional full query-param override for facets whose
|
||||
// wire name predates the _filter convention (has_cover). Existing
|
||||
// facets migrate in with their frozen wire names — behavior does
|
||||
// not change.
|
||||
Wire string
|
||||
Kind FacetKind
|
||||
Match FacetMatch
|
||||
// Column is the media_items column(s) the facet filters (documentation).
|
||||
Column string
|
||||
// LibraryTypes limits the facet to those library_types.name values
|
||||
// (e.g. manga_type only on "manga"). Empty/nil = every library type.
|
||||
// The All-Libraries view shows the UNION of all facets — matching is
|
||||
// type-partitioned by the data, so cross-type filtering is correct
|
||||
// by construction (plan Batch D decision).
|
||||
LibraryTypes []string
|
||||
}
|
||||
|
||||
// facetRegistry — the inventory. Order is display order for the filter
|
||||
// panel (Batch B); the existing facets come first (they shipped
|
||||
// 646477af..7537063c and their wire behavior is frozen).
|
||||
var facetRegistry = []Facet{
|
||||
{Param: "author", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "authors"},
|
||||
{Param: "publisher", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "publishers"},
|
||||
{Param: "narrator", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "narrators", LibraryTypes: []string{"audiobooks"}},
|
||||
{Param: "tags", Kind: FacetArray, Match: MatchPerElementFuzzy, Column: "tags"},
|
||||
{Param: "series", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "series"},
|
||||
{Param: "genre", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "genre"},
|
||||
{Param: "language", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "language"},
|
||||
{Param: "year", Kind: FacetRange, Match: MatchExact, Column: "date_published / copyright_year"},
|
||||
{Param: "has_cover", Wire: "has_cover", Kind: FacetBool, Match: MatchExact, Column: "cover_image_path"},
|
||||
// Batch A additions:
|
||||
{Param: "story_arc", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "story_arc"},
|
||||
{Param: "imprint", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "imprint"},
|
||||
{Param: "edition", Kind: FacetScalar, Match: MatchScalarFuzzy, Column: "edition"},
|
||||
{Param: "age_rating", Kind: FacetScalar, Match: MatchExact, Column: "age_rating"},
|
||||
{Param: "format_group", Kind: FacetScalar, Match: MatchExact, Column: "format_group"},
|
||||
{Param: "manga_type", Kind: FacetScalar, Match: MatchExact, Column: "manga_type", LibraryTypes: []string{"manga"}},
|
||||
{Param: "reading_direction", Kind: FacetScalar, Match: MatchExact, Column: "reading_direction", LibraryTypes: []string{"manga", "comics"}},
|
||||
{Param: "explicit", Kind: FacetBool, Match: MatchExact, Column: "explicit"},
|
||||
{Param: "abridged", Kind: FacetBool, Match: MatchExact, Column: "abridged"},
|
||||
}
|
||||
|
||||
// Facets returns a copy of the registry.
|
||||
func Facets() []Facet {
|
||||
out := make([]Facet, len(facetRegistry))
|
||||
copy(out, facetRegistry)
|
||||
return out
|
||||
}
|
||||
|
||||
// WireParam is the query-param name for the facet: the Wire override
|
||||
// when set, else `<Param>_filter`.
|
||||
func (f Facet) WireParam() string {
|
||||
if f.Wire != "" {
|
||||
return f.Wire
|
||||
}
|
||||
return f.Param + "_filter"
|
||||
}
|
||||
|
||||
// SplitFacetValues splits a wire value into its OR-within alternatives.
|
||||
// Shared by the SQL semantics (string_to_array) and later by the
|
||||
// facet-count endpoint so display and matching can never disagree.
|
||||
func SplitFacetValues(wireValue string) []string {
|
||||
if wireValue == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(wireValue, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FacetByParam finds a registry entry by its base param name (nil when
|
||||
// absent).
|
||||
func FacetByParam(param string) *Facet {
|
||||
for i := range facetRegistry {
|
||||
if facetRegistry[i].Param == param {
|
||||
f := facetRegistry[i]
|
||||
return &f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FacetsForLibraryType returns the facets that RENDER for a view. An
|
||||
// empty libraryTypeName is the All-Libraries view → the UNION of every
|
||||
// registered facet (plan Batch D: no special SQL — matching is
|
||||
// type-partitioned by the data). A specific type gets facets with no
|
||||
// type restriction plus those listing it.
|
||||
func FacetsForLibraryType(libraryTypeName string) []Facet {
|
||||
if libraryTypeName == "" {
|
||||
return Facets()
|
||||
}
|
||||
var out []Facet
|
||||
for _, f := range facetRegistry {
|
||||
if len(f.LibraryTypes) == 0 {
|
||||
out = append(out, f)
|
||||
continue
|
||||
}
|
||||
for _, t := range f.LibraryTypes {
|
||||
if t == libraryTypeName {
|
||||
out = append(out, f)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"bookhoard/internal/database"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// The generated SQL constant for the unified search query. The
|
||||
// drift-guard test asserts every registry entry's narg block exists in
|
||||
// it — sqlc has no dynamic WHERE, so registry ↔ SQL agreement is
|
||||
// enforced here instead (plan: "one registry entry + one narg block").
|
||||
|
||||
func TestFacetRegistryShape(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
knownLibraryTypes := map[string]bool{
|
||||
"ebooks": true, "comics": true, "manga": true, "audiobooks": true,
|
||||
}
|
||||
for _, f := range facetRegistry {
|
||||
if f.Param == "" {
|
||||
t.Fatalf("facet with empty Param: %+v", f)
|
||||
}
|
||||
wire := f.WireParam()
|
||||
if seen[wire] {
|
||||
t.Errorf("duplicate wire param %q", wire)
|
||||
}
|
||||
seen[wire] = true
|
||||
if f.Column == "" {
|
||||
t.Errorf("%s: empty Column", wire)
|
||||
}
|
||||
// Kind ↔ Match consistency.
|
||||
switch f.Kind {
|
||||
case FacetArray:
|
||||
if f.Match != MatchPerElementFuzzy {
|
||||
t.Errorf("%s: array facets are per-element fuzzy, got %s", wire, f.Match)
|
||||
}
|
||||
case FacetScalar:
|
||||
if f.Match != MatchScalarFuzzy && f.Match != MatchExact {
|
||||
t.Errorf("%s: scalar facets are scalar-fuzzy or exact, got %s", wire, f.Match)
|
||||
}
|
||||
case FacetBool:
|
||||
if f.Match != MatchExact {
|
||||
t.Errorf("%s: bool facets are exact, got %s", wire, f.Match)
|
||||
}
|
||||
case FacetRange:
|
||||
if f.Match != MatchExact {
|
||||
t.Errorf("%s: range facets are exact, got %s", wire, f.Match)
|
||||
}
|
||||
default:
|
||||
t.Errorf("%s: unknown kind %q", wire, f.Kind)
|
||||
}
|
||||
for _, lt := range f.LibraryTypes {
|
||||
if !knownLibraryTypes[lt] {
|
||||
t.Errorf("%s: unknown library type %q (seed names: ebooks, comics, manga, audiobooks)", wire, lt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacetRegistrySQLDrift(t *testing.T) {
|
||||
// sqlc substitutes sqlc.narg(...) with ? placeholders in the emitted
|
||||
// const, so the drift guard reflects over the GENERATED params
|
||||
// struct instead: a narg block exists in the query iff the struct
|
||||
// has its field. This checks the artifact the binary actually runs.
|
||||
paramsType := reflect.TypeOf(database.SearchMediaItemsUnifiedParams{})
|
||||
dbTags := map[string]reflect.Type{}
|
||||
for i := 0; i < paramsType.NumField(); i++ {
|
||||
tag := strings.Split(paramsType.Field(i).Tag.Get("db"), ",")[0]
|
||||
dbTags[tag] = paramsType.Field(i).Type
|
||||
}
|
||||
boolType := reflect.TypeOf(pgtype.Bool{})
|
||||
for _, f := range facetRegistry {
|
||||
wire := f.WireParam()
|
||||
switch f.Kind {
|
||||
case FacetRange:
|
||||
// The year range binds the year_min/year_max pair.
|
||||
if _, ok := dbTags["year_min"]; !ok {
|
||||
t.Error("year range facets must bind year_min in SearchMediaItemsUnified")
|
||||
}
|
||||
if _, ok := dbTags["year_max"]; !ok {
|
||||
t.Error("year range facets must bind year_max in SearchMediaItemsUnified")
|
||||
}
|
||||
case FacetBool:
|
||||
ft, ok := dbTags[wire]
|
||||
if !ok {
|
||||
t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire)
|
||||
continue
|
||||
}
|
||||
if ft != boolType {
|
||||
t.Errorf("%s: bool facet must generate pgtype.Bool (the ::bool cast), got %s", wire, ft)
|
||||
}
|
||||
default:
|
||||
if _, ok := dbTags[wire]; !ok {
|
||||
t.Errorf("registry facet %q missing from SearchMediaItemsUnified (want a %s param)", wire, wire)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Batch A additions specifically (pin the param names).
|
||||
for _, wire := range []string{
|
||||
"story_arc_filter", "imprint_filter", "edition_filter",
|
||||
"age_rating_filter", "format_group_filter", "manga_type_filter",
|
||||
"reading_direction_filter", "explicit_filter", "abridged_filter",
|
||||
} {
|
||||
if _, ok := dbTags[wire]; !ok {
|
||||
t.Errorf("Batch A facet %s missing from SearchMediaItemsUnified", wire)
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 7: the counts query (FacetCountsUnderFilters) carries the
|
||||
// same filter blocks — copied mechanically from the search query —
|
||||
// so the panel's under-filter counts and the grid's rows can never
|
||||
// disagree about what a filter means. Reflect the generated params
|
||||
// struct the same way.
|
||||
countParamsType := reflect.TypeOf(database.FacetCountsUnderFiltersParams{})
|
||||
countTags := map[string]bool{}
|
||||
for i := 0; i < countParamsType.NumField(); i++ {
|
||||
tag := strings.Split(countParamsType.Field(i).Tag.Get("db"), ",")[0]
|
||||
countTags[tag] = true
|
||||
}
|
||||
for _, want := range []string{"user_id", "library_id", "limit"} {
|
||||
if !countTags[want] {
|
||||
t.Errorf("FacetCountsUnderFilters missing %s param", want)
|
||||
}
|
||||
}
|
||||
for _, f := range facetRegistry {
|
||||
wire := f.WireParam()
|
||||
if f.Kind == FacetRange {
|
||||
if !countTags["year_min"] || !countTags["year_max"] {
|
||||
t.Error("year range must bind year_min/year_max in FacetCountsUnderFilters")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !countTags[wire] {
|
||||
t.Errorf("registry facet %q missing from FacetCountsUnderFilters (each arm binds every OTHER facet's block, so every facet's param must exist)", wire)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacetsForLibraryType(t *testing.T) {
|
||||
// Empty = All Libraries → the UNION of every facet (Batch D decision).
|
||||
all := FacetsForLibraryType("")
|
||||
if len(all) != len(facetRegistry) {
|
||||
t.Fatalf("all-libraries union = %d facets, want %d", len(all), len(facetRegistry))
|
||||
}
|
||||
|
||||
byWire := func(fs []Facet) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, f := range fs {
|
||||
m[f.WireParam()] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
manga := byWire(FacetsForLibraryType("manga"))
|
||||
if !manga["manga_type_filter"] || !manga["reading_direction_filter"] {
|
||||
t.Error("manga view must include manga_type and reading_direction")
|
||||
}
|
||||
if manga["narrator_filter"] {
|
||||
t.Error("narrator facet is audiobooks-only, must not render on manga")
|
||||
}
|
||||
if !manga["author_filter"] || !manga["genre_filter"] {
|
||||
t.Error("unrestricted facets render on every library type")
|
||||
}
|
||||
|
||||
audio := byWire(FacetsForLibraryType("audiobooks"))
|
||||
if !audio["narrator_filter"] {
|
||||
t.Error("audiobooks view must include narrator")
|
||||
}
|
||||
if audio["manga_type_filter"] || audio["reading_direction_filter"] {
|
||||
t.Error("manga facets must not render on audiobooks")
|
||||
}
|
||||
|
||||
comics := byWire(FacetsForLibraryType("comics"))
|
||||
if !comics["reading_direction_filter"] {
|
||||
t.Error("comics view must include reading_direction")
|
||||
}
|
||||
if comics["manga_type_filter"] {
|
||||
t.Error("manga_type is manga-only, must not render on comics")
|
||||
}
|
||||
|
||||
unknown := byWire(FacetsForLibraryType("woodcuts"))
|
||||
if unknown["manga_type_filter"] || unknown["narrator_filter"] {
|
||||
t.Error("restricted facets must not render for an unknown type")
|
||||
}
|
||||
if !unknown["author_filter"] {
|
||||
t.Error("unrestricted facets still render for an unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFacetValues(t *testing.T) {
|
||||
if got := SplitFacetValues(""); got != nil {
|
||||
t.Errorf("empty → nil, got %v", got)
|
||||
}
|
||||
got := SplitFacetValues("Vertigo, DC Black Label ,Vertigo")
|
||||
if len(got) != 3 || got[0] != "Vertigo" || got[1] != "DC Black Label" || got[2] != "Vertigo" {
|
||||
t.Errorf("SplitFacetValues = %v, want [Vertigo DC Black Label Vertigo]", got)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,560 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Filter rule builder — the field registry (faceted plan Phase 9).
|
||||
//
|
||||
// The mandate: EVERYTHING is filterable — every attribute on a book and
|
||||
// everything that pertains to one. This registry is the completeness
|
||||
// check: fields and operators are typed constants (the allow-list — no
|
||||
// user string ever reaches a field/operator position), every value is a
|
||||
// bound pgx parameter, and each entry carries its own match-clause
|
||||
// closure so the composed engines (search + suggestions + count) grow
|
||||
// by adding entries, not SQL.
|
||||
//
|
||||
// User-relative fields read the VIEWER's rows: the user id is
|
||||
// server-injected (never a client param) — two users get different
|
||||
// results for the same tree (the "my data" weld).
|
||||
//
|
||||
// Holding structures (library, collections, series — primary AND
|
||||
// alternate) are fields like any other; any future structure that
|
||||
// "holds" a book must join this registry.
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Filter operators (the allow-list).
|
||||
const (
|
||||
OpContains = "contains"
|
||||
OpNotContains = "not_contains"
|
||||
OpIs = "is"
|
||||
OpIsNot = "is_not"
|
||||
OpEmpty = "empty"
|
||||
OpIsTrue = "is_true"
|
||||
OpIsFalse = "is_false"
|
||||
OpBetween = "between"
|
||||
OpAtLeast = "at_least"
|
||||
OpAtMost = "at_most"
|
||||
OpWithinDays = "within_days"
|
||||
OpHas = "has"
|
||||
OpMissing = "missing"
|
||||
OpUnrated = "unrated"
|
||||
OpIn = "in"
|
||||
OpNotIn = "not_in"
|
||||
OpInAny = "in_any"
|
||||
OpInNone = "in_none"
|
||||
)
|
||||
|
||||
// FilterKind — how a field's values match and what the value cell renders.
|
||||
type FilterKind string
|
||||
|
||||
const (
|
||||
KindScalarFuzzy FilterKind = "scalar_fuzzy" // word_similarity on a text column
|
||||
KindArrayFuzzy FilterKind = "array_fuzzy" // word_similarity per element of a text[]
|
||||
KindText FilterKind = "text" // fuzzy contains + exact is + empty (title-like)
|
||||
KindEnum FilterKind = "enum" // exact scalar, = ANY(values)
|
||||
KindBool FilterKind = "bool"
|
||||
KindNumber FilterKind = "number"
|
||||
KindDate FilterKind = "date"
|
||||
KindPresence FilterKind = "presence" // has / missing
|
||||
KindIdentifier FilterKind = "identifier" // exact is/is_not + has/missing
|
||||
KindNoteText FilterKind = "note_text" // joined-table ILIKE substring (viewer's rows)
|
||||
KindStatus FilterKind = "status" // unread / reading / finished
|
||||
KindLibrary FilterKind = "library"
|
||||
KindCollection FilterKind = "collection"
|
||||
)
|
||||
|
||||
// Suggestion sources — where a field's value menu comes from. A source
|
||||
// of suggestNone means the value cell is free input (numbers, dates,
|
||||
// bools, fixed enums).
|
||||
type SuggestSource string
|
||||
|
||||
const (
|
||||
suggestNone SuggestSource = ""
|
||||
suggestAuthor SuggestSource = "author"
|
||||
suggestNarrator SuggestSource = "narrator"
|
||||
suggestContrib SuggestSource = "contributor"
|
||||
suggestPublisher SuggestSource = "publisher"
|
||||
suggestTags SuggestSource = "tags"
|
||||
suggestColumn SuggestSource = "column" // the field's own scalar column
|
||||
suggestSeries SuggestSource = "series" // primary + alternate_info
|
||||
suggestNoteText SuggestSource = "note_text" // the viewer's note snippets
|
||||
suggestHighlight SuggestSource = "highlight_text"
|
||||
suggestLibrary SuggestSource = "library" // visibility-scoped libraries
|
||||
suggestCollection SuggestSource = "collection" // the viewer's collections
|
||||
suggestAddedBy SuggestSource = "added_by" // admins who added books in scope
|
||||
)
|
||||
|
||||
// Filter menu groups (display order for the field menu).
|
||||
const (
|
||||
GroupPeople = "People"
|
||||
GroupPublish = "Publishing"
|
||||
GroupTopics = "Topics"
|
||||
GroupBookFacts = "Book facts"
|
||||
GroupComics = "Comics & manga"
|
||||
GroupMyReading = "My reading"
|
||||
GroupLibrary = "Library"
|
||||
)
|
||||
|
||||
// FilterRule is one leaf: field + operator + values (all bound params).
|
||||
type FilterRule struct {
|
||||
Field string
|
||||
Op string
|
||||
Values []string
|
||||
}
|
||||
|
||||
// FilterTree is the qf wire shape: a group (mode + children) whose
|
||||
// children are rules or nested groups. Groups AND/OR their children
|
||||
// together; a group ANDs into its parent.
|
||||
type FilterTree struct {
|
||||
Mode string `json:"mode"`
|
||||
Children []FilterChild `json:"children"`
|
||||
}
|
||||
|
||||
// FilterChild is one node: a rule (Field set) or a group (Mode set).
|
||||
type FilterChild struct {
|
||||
Field string `json:"field,omitempty"`
|
||||
Op string `json:"op,omitempty"`
|
||||
Values []string `json:"values,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Children []FilterChild `json:"children,omitempty"`
|
||||
}
|
||||
|
||||
// FilterField is one registry entry.
|
||||
type FilterField struct {
|
||||
Key string // wire name (the qf "field" value)
|
||||
Label string // menu label
|
||||
Group string // menu group
|
||||
Kind FilterKind
|
||||
Ops []string // allowed operators, display order
|
||||
DefaultOp string // operator a new rule starts on (field-switch reset)
|
||||
Clause func(r FilterRule, b *filterBuilder) string
|
||||
Suggest SuggestSource
|
||||
}
|
||||
|
||||
// opsWithValue / opsWithoutValue drive validation.
|
||||
var opsNoValue = map[string]bool{
|
||||
OpEmpty: true, OpIsTrue: true, OpIsFalse: true, OpUnrated: true,
|
||||
OpInAny: true, OpInNone: true, OpHas: true, OpMissing: true,
|
||||
}
|
||||
|
||||
// fixedDomains are the operator value domains that are STATIC (validated
|
||||
// here) rather than data-driven suggestions.
|
||||
var fixedDomains = map[string][]string{
|
||||
"status": {"unread", "reading", "finished"},
|
||||
"file_availability": {"available", "missing"},
|
||||
}
|
||||
|
||||
// filterFields is THE registry. Order within a group = menu order.
|
||||
var filterFields = []FilterField{
|
||||
// ── People ────────────────────────────────────────────────────
|
||||
{Key: "author", Label: "Author", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseAuthorArray, Suggest: suggestAuthor},
|
||||
{Key: "narrator", Label: "Narrator", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseArrayFuzzyFactory("mi.narrators"), Suggest: suggestNarrator},
|
||||
{Key: "contributor", Label: "Contributor", Group: GroupPeople, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseArrayFuzzyFactory("mi.contributors"), Suggest: suggestContrib},
|
||||
|
||||
// ── Publishing ────────────────────────────────────────────────
|
||||
{Key: "publisher", Label: "Publisher", Group: GroupPublish, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseArrayFuzzyFactory("mi.publishers"), Suggest: suggestPublisher},
|
||||
{Key: "imprint", Label: "Imprint", Group: GroupPublish, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.imprint"), Suggest: suggestColumn},
|
||||
{Key: "edition", Label: "Edition", Group: GroupPublish, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.edition"), Suggest: suggestColumn},
|
||||
{Key: "published_year", Label: "Published year", Group: GroupPublish, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clausePublishedYear, Suggest: suggestNone},
|
||||
|
||||
// ── Topics ────────────────────────────────────────────────────
|
||||
{Key: "genre", Label: "Genre", Group: GroupTopics, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.genre"), Suggest: suggestColumn},
|
||||
{Key: "tags", Label: "Tags", Group: GroupTopics, Kind: KindArrayFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseArrayFuzzyFactory("mi.tags_search"), Suggest: suggestTags},
|
||||
{Key: "story_arc", Label: "Story arc", Group: GroupTopics, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.story_arc"), Suggest: suggestColumn},
|
||||
{Key: "age_rating", Label: "Age rating", Group: GroupTopics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.age_rating"), Suggest: suggestColumn},
|
||||
|
||||
// ── Book facts ────────────────────────────────────────────────
|
||||
{Key: "title", Label: "Title", Group: GroupBookFacts, Kind: KindText,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.title"), Suggest: suggestColumn},
|
||||
{Key: "subtitle", Label: "Subtitle", Group: GroupBookFacts, Kind: KindText,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.subtitle"), Suggest: suggestColumn},
|
||||
{Key: "description", Label: "Description", Group: GroupBookFacts, Kind: KindText,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseScalarFuzzyFactory("mi.description"), Suggest: suggestNone},
|
||||
{Key: "summary", Label: "Summary", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clausePresenceFactory("mi.summary"), Suggest: suggestNone},
|
||||
{Key: "language", Label: "Language", Group: GroupBookFacts, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseLanguage, Suggest: suggestColumn},
|
||||
{Key: "page_count", Label: "Page count", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.page_count", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "chapter_count", Label: "Chapter count", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.chapter_count", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "total_characters", Label: "Total characters", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.total_characters", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "community_rating", Label: "Community rating", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.community_rating", parseNumberFloat), Suggest: suggestNone},
|
||||
{Key: "added_at", Label: "Date added", Group: GroupBookFacts, Kind: KindDate,
|
||||
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
||||
Clause: clauseDateFactory("mi.created_at"), Suggest: suggestNone},
|
||||
{Key: "imported_at", Label: "Date imported", Group: GroupBookFacts, Kind: KindDate,
|
||||
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
||||
Clause: clauseDateFactory("mi.imported_at"), Suggest: suggestNone},
|
||||
{Key: "web_url", Label: "Web URL", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clausePresenceFactory("mi.web_url"), Suggest: suggestNone},
|
||||
{Key: "metadata_notes", Label: "Metadata notes", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clausePresenceFactory("mi.metadata_notes"), Suggest: suggestNone},
|
||||
{Key: "isbn", Label: "ISBN", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseIdentifierFactory("mi.isbn"), Suggest: suggestNone},
|
||||
{Key: "asin", Label: "ASIN", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||
Ops: []string{OpIs, OpIsNot, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseIdentifierFactory("mi.asin"), Suggest: suggestNone},
|
||||
{Key: "file_size", Label: "File size", Group: GroupBookFacts, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.file_size", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "file_availability", Label: "File availability", Group: GroupBookFacts, Kind: KindStatus,
|
||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||
Clause: clauseFileAvailability, Suggest: suggestNone},
|
||||
{Key: "explicit", Label: "Explicit", Group: GroupBookFacts, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.explicit"), Suggest: suggestNone},
|
||||
{Key: "abridged", Label: "Abridged", Group: GroupBookFacts, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.abridged"), Suggest: suggestNone},
|
||||
{Key: "manually_edited", Label: "Manually edited", Group: GroupBookFacts, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
// metadata_overrides is NOT NULL TEXT[] — '{}' means untouched.
|
||||
Clause: func(r FilterRule, b *filterBuilder) string {
|
||||
switch r.Op {
|
||||
case OpIsTrue:
|
||||
return "(cardinality(mi.metadata_overrides) > 0)"
|
||||
case OpIsFalse:
|
||||
return "(cardinality(mi.metadata_overrides) = 0)"
|
||||
}
|
||||
return "TRUE"
|
||||
}, Suggest: suggestNone},
|
||||
{Key: "cover", Label: "Cover", Group: GroupBookFacts, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseCover, Suggest: suggestNone},
|
||||
{Key: "added_by", Label: "Added by", Group: GroupBookFacts, Kind: KindIdentifier,
|
||||
Ops: []string{OpIs, OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseAddedBy, Suggest: suggestAddedBy},
|
||||
|
||||
// ── Comics & manga ────────────────────────────────────────────
|
||||
{Key: "series", Label: "Series", Group: GroupComics, Kind: KindScalarFuzzy,
|
||||
Ops: []string{OpContains, OpNotContains, OpIs, OpIsNot, OpEmpty}, DefaultOp: OpContains,
|
||||
// Full series membership: the primary series column AND the
|
||||
// additional series held in alternate_info — a book whose
|
||||
// membership of X is secondary must not hide.
|
||||
Clause: clauseSeriesMembership, Suggest: suggestSeries},
|
||||
{Key: "series_number", Label: "Series number", Group: GroupComics, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.series_number", parseNumberFloat), Suggest: suggestNone},
|
||||
{Key: "series_count", Label: "Series count", Group: GroupComics, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.series_count", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "volume", Label: "Volume", Group: GroupComics, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseNumberFactory("mi.volume", parseNumberInt), Suggest: suggestNone},
|
||||
{Key: "format_group", Label: "Format group", Group: GroupComics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.format_group"), Suggest: suggestColumn},
|
||||
{Key: "mime_type", Label: "File type (mime)", Group: GroupComics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.mime_type"), Suggest: suggestColumn},
|
||||
{Key: "is_reflowable", Label: "Reflowable", Group: GroupComics, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.is_reflowable"), Suggest: suggestNone},
|
||||
{Key: "has_fixed_layout", Label: "Fixed layout", Group: GroupComics, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.has_fixed_layout"), Suggest: suggestNone},
|
||||
{Key: "is_black_and_white", Label: "Black & white", Group: GroupComics, Kind: KindBool,
|
||||
Ops: []string{OpIsTrue, OpIsFalse}, DefaultOp: OpIsTrue,
|
||||
Clause: clauseBoolFactory("mi.is_black_and_white"), Suggest: suggestNone},
|
||||
{Key: "manga_type", Label: "Manga type", Group: GroupComics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.manga_type"), Suggest: suggestColumn},
|
||||
{Key: "reading_direction", Label: "Reading direction", Group: GroupComics, Kind: KindEnum,
|
||||
Ops: []string{OpIs, OpIsNot, OpEmpty}, DefaultOp: OpIs,
|
||||
Clause: clauseEnumFactory("mi.reading_direction"), Suggest: suggestColumn},
|
||||
|
||||
// ── My reading (user-relative — the VIEWER's rows) ────────────
|
||||
{Key: "status", Label: "Reading status", Group: GroupMyReading, Kind: KindStatus,
|
||||
Ops: []string{OpIs}, DefaultOp: OpIs,
|
||||
Clause: clauseStatus, Suggest: suggestNone},
|
||||
{Key: "progress_percent", Label: "Progress percent", Group: GroupMyReading, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost, OpEmpty}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseProgressPercent, Suggest: suggestNone},
|
||||
{Key: "last_read", Label: "Last read", Group: GroupMyReading, Kind: KindDate,
|
||||
Ops: []string{OpWithinDays, OpBetween, OpEmpty}, DefaultOp: OpWithinDays,
|
||||
Clause: clauseLastRead, Suggest: suggestNone},
|
||||
{Key: "my_rating", Label: "My rating", Group: GroupMyReading, Kind: KindNumber,
|
||||
Ops: []string{OpIs, OpAtLeast, OpAtMost, OpUnrated}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseMyRating, Suggest: suggestNone},
|
||||
{Key: "review", Label: "Has review", Group: GroupMyReading, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing}, DefaultOp: OpHas,
|
||||
Clause: clauseReviewPresence, Suggest: suggestNone},
|
||||
{Key: "notes", Label: "Notes", Group: GroupMyReading, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas,
|
||||
Clause: clauseNotesPresence, Suggest: suggestNone},
|
||||
{Key: "highlights", Label: "Highlights", Group: GroupMyReading, Kind: KindPresence,
|
||||
Ops: []string{OpHas, OpMissing, OpWithinDays}, DefaultOp: OpHas,
|
||||
Clause: clauseHighlightsPresence, Suggest: suggestNone},
|
||||
{Key: "note_text", Label: "Note text", Group: GroupMyReading, Kind: KindNoteText,
|
||||
// "Contains" here is substring ILIKE, NOT the system-wide fuzzy:
|
||||
// your own words deserve exact-ish. A book matches when ANY of
|
||||
// the viewer's notes on it match (EXISTS + bound values).
|
||||
Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseNoteText, Suggest: suggestNoteText},
|
||||
{Key: "highlight_text", Label: "Highlight text", Group: GroupMyReading, Kind: KindNoteText,
|
||||
Ops: []string{OpContains, OpNotContains, OpEmpty}, DefaultOp: OpContains,
|
||||
Clause: clauseHighlightText, Suggest: suggestHighlight},
|
||||
{Key: "time_spent", Label: "Time spent (minutes)", Group: GroupMyReading, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
||||
Clause: clauseTimeSpent, Suggest: suggestNone},
|
||||
{Key: "pages_read", Label: "Pages read", Group: GroupMyReading, Kind: KindNumber,
|
||||
Ops: []string{OpBetween, OpAtLeast, OpAtMost}, DefaultOp: OpAtLeast,
|
||||
Clause: clausePagesRead, Suggest: suggestNone},
|
||||
{Key: "collection", Label: "In collection", Group: GroupMyReading, Kind: KindCollection,
|
||||
// Membership mirrors the collection page: excluded items and
|
||||
// deleted collections never count; playlists are collections.
|
||||
Ops: []string{OpIn, OpNotIn, OpInAny, OpInNone}, DefaultOp: OpIn,
|
||||
Clause: clauseCollection, Suggest: suggestCollection},
|
||||
|
||||
// ── Library (holding structure + family lens) ─────────────────
|
||||
{Key: "library", Label: "Library", Group: GroupLibrary, Kind: KindLibrary,
|
||||
// Values are library IDs; the menu only ever offers the viewer's
|
||||
// VISIBLE libraries (family lens — a hidden library can not even
|
||||
// be offered).
|
||||
Ops: []string{OpIs, OpIsNot}, DefaultOp: OpIs,
|
||||
Clause: clauseLibrary, Suggest: suggestLibrary},
|
||||
}
|
||||
|
||||
// FilterFieldByKey returns the registry entry for a wire key.
|
||||
func FilterFieldByKey(key string) (FilterField, bool) {
|
||||
for _, f := range filterFields {
|
||||
if f.Key == key {
|
||||
return f, true
|
||||
}
|
||||
}
|
||||
return FilterField{}, false
|
||||
}
|
||||
|
||||
// FilterFieldsByGroup returns the registry grouped for the field menu,
|
||||
// groups in display order.
|
||||
func FilterFieldsByGroup() []struct {
|
||||
Group string
|
||||
Fields []FilterField
|
||||
} {
|
||||
order := []string{GroupPeople, GroupPublish, GroupTopics, GroupBookFacts, GroupComics, GroupMyReading, GroupLibrary}
|
||||
out := make([]struct {
|
||||
Group string
|
||||
Fields []FilterField
|
||||
}, 0, len(order))
|
||||
for _, g := range order {
|
||||
group := struct {
|
||||
Group string
|
||||
Fields []FilterField
|
||||
}{Group: g}
|
||||
for _, f := range filterFields {
|
||||
if f.Group == g {
|
||||
group.Fields = append(group.Fields, f)
|
||||
}
|
||||
}
|
||||
out = append(out, group)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterFieldKeys returns every registered key (the completeness check
|
||||
// for tests).
|
||||
func FilterFieldKeys() []string {
|
||||
keys := make([]string, 0, len(filterFields))
|
||||
for _, f := range filterFields {
|
||||
keys = append(keys, f.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// fieldOpsAllowed reports whether op is allowed on the field.
|
||||
func fieldOpsAllowed(f FilterField, op string) bool {
|
||||
for _, o := range f.Ops {
|
||||
if o == op {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maxFilterDepth bounds group nesting (BookOrbit allows 5; the UI
|
||||
// discourages depth but the model allows a little headroom).
|
||||
const maxFilterDepth = 8
|
||||
|
||||
// ParseFilterTree parses + validates the qf wire JSON. Validation is
|
||||
// the allow-list enforcement: unknown fields/operators and malformed
|
||||
// values are a parse error (→ 400), never a silently-ignored token.
|
||||
func ParseFilterTree(raw string) (*FilterTree, error) {
|
||||
var t FilterTree
|
||||
if err := json.Unmarshal([]byte(raw), &t); err != nil {
|
||||
return nil, fmt.Errorf("invalid qf JSON: %w", err)
|
||||
}
|
||||
if err := validateGroup(&FilterChild{Mode: t.Mode, Children: t.Children}, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func validateGroup(g *FilterChild, depth int) error {
|
||||
if depth > maxFilterDepth {
|
||||
return fmt.Errorf("filter group nesting deeper than %d", maxFilterDepth)
|
||||
}
|
||||
if g.Mode != "all" && g.Mode != "any" {
|
||||
return fmt.Errorf("filter group mode must be all|any, got %q", g.Mode)
|
||||
}
|
||||
if len(g.Children) == 0 {
|
||||
return fmt.Errorf("filter group has no children")
|
||||
}
|
||||
for _, c := range g.Children {
|
||||
if c.Field == "" && c.Mode != "" {
|
||||
if err := validateGroup(&c, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := validateRule(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// numericParsers map each numeric field's wire values to the Go value
|
||||
// bound into SQL — validation and clause-building share one parser per
|
||||
// field, so a value that passes validation always binds.
|
||||
var numericParsers = map[string]func(string) (any, error){
|
||||
"published_year": parseNumberInt,
|
||||
"page_count": parseNumberInt,
|
||||
"chapter_count": parseNumberInt,
|
||||
"total_characters": parseNumberInt,
|
||||
"file_size": parseNumberInt,
|
||||
"series_count": parseNumberInt,
|
||||
"volume": parseNumberInt,
|
||||
"my_rating": parseNumberInt,
|
||||
"series_number": parseNumberFloat,
|
||||
"community_rating": parseNumberFloat,
|
||||
"progress_percent": parseNumberFloat,
|
||||
"time_spent": parseNumberFloat,
|
||||
"pages_read": parseNumberFloat,
|
||||
}
|
||||
|
||||
func validateRule(c FilterChild) error {
|
||||
f, ok := FilterFieldByKey(c.Field)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown filter field %q", c.Field)
|
||||
}
|
||||
if !fieldOpsAllowed(f, c.Op) {
|
||||
return fmt.Errorf("operator %q not allowed on field %q", c.Op, c.Field)
|
||||
}
|
||||
if opsNoValue[c.Op] {
|
||||
if len(c.Values) != 0 {
|
||||
return fmt.Errorf("operator %q takes no values", c.Op)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(c.Values) == 0 {
|
||||
return fmt.Errorf("operator %q on field %q needs a value", c.Op, c.Field)
|
||||
}
|
||||
if c.Op == OpBetween && len(c.Values) != 2 {
|
||||
return fmt.Errorf("between takes exactly two values")
|
||||
}
|
||||
// Numeric fields: values must parse (they bind as numbers).
|
||||
if parse, ok := numericParsers[f.Key]; ok {
|
||||
for _, v := range c.Values {
|
||||
if _, err := parse(v); err != nil {
|
||||
return fmt.Errorf("field %q needs numeric values, got %q", f.Key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Date within_days: a positive integer day count.
|
||||
if c.Op == OpWithinDays {
|
||||
n, err := strconv.Atoi(c.Values[0])
|
||||
if err != nil || n <= 0 {
|
||||
return fmt.Errorf("within_days needs a positive day count, got %q", c.Values[0])
|
||||
}
|
||||
}
|
||||
// Static domains (status, file_availability).
|
||||
if domain, ok := fixedDomains[f.Key]; ok {
|
||||
for _, v := range c.Values {
|
||||
found := false
|
||||
for _, d := range domain {
|
||||
if d == v {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("field %q value must be one of %v, got %q", f.Key, domain, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// UUID-valued fields (collection, library, added_by "is").
|
||||
switch f.Key {
|
||||
case "library", "collection":
|
||||
for _, v := range c.Values {
|
||||
if _, err := uuid.Parse(v); err != nil {
|
||||
return fmt.Errorf("field %q values must be UUIDs, got %q", f.Key, v)
|
||||
}
|
||||
}
|
||||
case "added_by":
|
||||
if c.Op == OpIs {
|
||||
if _, err := uuid.Parse(c.Values[0]); err != nil {
|
||||
return fmt.Errorf("added_by \"is\" needs a user UUID, got %q", c.Values[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseNumberInt(v string) (any, error) {
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseNumberFloat(v string) (any, error) {
|
||||
f, err := strconv.ParseFloat(v, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package services
|
||||
|
||||
// Filter registry unit tests (faceted plan Phase 9): every entry is
|
||||
// complete (kind, operators, default operator, clause, suggestion
|
||||
// source), the mandated surfaces are present (including the three
|
||||
// holding structures), and the qf parse is a strict allow-list.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFilterRegistryCompleteness(t *testing.T) {
|
||||
keys := FilterFieldKeys()
|
||||
require.NotEmpty(t, keys)
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, key := range keys {
|
||||
require.False(t, seen[key], "duplicate registry key %q", key)
|
||||
seen[key] = true
|
||||
|
||||
f, ok := FilterFieldByKey(key)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, f.Label, "%s: label", key)
|
||||
require.NotEmpty(t, f.Group, "%s: menu group", key)
|
||||
require.NotEmpty(t, f.Kind, "%s: kind", key)
|
||||
require.NotEmpty(t, f.Ops, "%s: operators", key)
|
||||
require.NotNil(t, f.Clause, "%s: match clause", key)
|
||||
require.Contains(t, f.Ops, f.DefaultOp, "%s: default operator must be allowed", key)
|
||||
}
|
||||
|
||||
// The mandated surfaces — every book attribute class, the user-
|
||||
// relative class, and the three HOLDING STRUCTURES (library,
|
||||
// collections, series — the registry is the completeness check).
|
||||
mandated := []string{
|
||||
// people / publishing / topics
|
||||
"author", "narrator", "contributor", "publisher", "imprint",
|
||||
"edition", "published_year", "genre", "tags", "story_arc", "age_rating",
|
||||
// reading surface (comics & manga)
|
||||
"format_group", "mime_type", "is_reflowable", "has_fixed_layout",
|
||||
"is_black_and_white", "manga_type", "reading_direction", "explicit", "abridged",
|
||||
// series facts + numbers + dates + text + presence + identifiers
|
||||
"series", "series_number", "series_count", "volume",
|
||||
"page_count", "chapter_count", "total_characters", "file_size",
|
||||
"community_rating", "added_at", "imported_at",
|
||||
"title", "subtitle", "description", "summary", "language",
|
||||
"web_url", "metadata_notes", "isbn", "asin", "cover",
|
||||
"file_availability", "added_by",
|
||||
// user-relative (everything that pertains to a book)
|
||||
"status", "progress_percent", "last_read", "my_rating", "review",
|
||||
"notes", "highlights", "note_text", "highlight_text", "time_spent",
|
||||
"pages_read", "collection",
|
||||
// holding structure
|
||||
"library",
|
||||
}
|
||||
for _, key := range mandated {
|
||||
assert.True(t, seen[key], "mandated filter field %q missing from the registry", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterParseValidation(t *testing.T) {
|
||||
valid := func(raw string) {
|
||||
_, err := ParseFilterTree(raw)
|
||||
require.NoError(t, err, "expected %s to parse", raw)
|
||||
}
|
||||
invalid := func(raw, wantContain string) {
|
||||
_, err := ParseFilterTree(raw)
|
||||
require.Error(t, err, "expected %s to be rejected", raw)
|
||||
if wantContain != "" {
|
||||
require.Contains(t, err.Error(), wantContain)
|
||||
}
|
||||
}
|
||||
|
||||
valid(`{"mode":"all","children":[{"field":"author","op":"contains","values":["Voss"]}]}`)
|
||||
valid(`{"mode":"any","children":[
|
||||
{"field":"author","op":"contains","values":["Voss"]},
|
||||
{"mode":"all","children":[{"field":"genre","op":"is","values":["fantasy"]}]}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["10","20"]}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"cover","op":"has"}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"status","op":"is","values":["reading","finished"]}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["14"]}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"my_rating","op":"unrated"}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"collection","op":"in_any"}]}`)
|
||||
valid(`{"mode":"all","children":[{"field":"library","op":"is","values":["` +
|
||||
"12345678-1234-1234-1234-123456789012" + `"]}]}`)
|
||||
|
||||
invalid(`{not json`, "invalid qf JSON")
|
||||
invalid(`{"mode":"all","children":[]}`, "no children")
|
||||
invalid(`{"mode":"sometimes","children":[{"field":"author","op":"contains","values":["x"]}]}`, "mode must be all|any")
|
||||
invalid(`{"mode":"all","children":[{"field":"not_a_field","op":"is","values":["x"]}]}`, "unknown filter field")
|
||||
invalid(`{"mode":"all","children":[{"field":"author","op":"explodes","values":["x"]}]}`, "not allowed on field")
|
||||
invalid(`{"mode":"all","children":[{"field":"page_count","op":"contains","values":["1"]}]}`, "not allowed on field")
|
||||
invalid(`{"mode":"all","children":[{"field":"page_count","op":"at_least","values":["abc"]}]}`, "numeric")
|
||||
invalid(`{"mode":"all","children":[{"field":"page_count","op":"between","values":["1"]}]}`, "exactly two")
|
||||
invalid(`{"mode":"all","children":[{"field":"cover","op":"has","values":["x"]}]}`, "takes no values")
|
||||
invalid(`{"mode":"all","children":[{"field":"added_at","op":"within_days","values":["0"]}]}`, "positive day count")
|
||||
invalid(`{"mode":"all","children":[{"field":"status","op":"is","values":["platypus"]}]}`, "must be one of")
|
||||
invalid(`{"mode":"all","children":[{"field":"library","op":"is","values":["not-a-uuid"]}]}`, "must be UUIDs")
|
||||
invalid(`{"mode":"all","children":[{"field":"author","op":"contains"}]}`, "needs a value")
|
||||
}
|
||||
|
||||
func TestFilterParseDepthCap(t *testing.T) {
|
||||
// Nesting beyond maxFilterDepth is rejected.
|
||||
raw := `{"mode":"all","children":[`
|
||||
for i := 0; i < maxFilterDepth+2; i++ {
|
||||
raw += `{"mode":"all","children":[`
|
||||
}
|
||||
raw += `{"field":"author","op":"contains","values":["x"]}`
|
||||
for i := 0; i < maxFilterDepth+2; i++ {
|
||||
raw += `]}`
|
||||
}
|
||||
raw += `]}`
|
||||
_, err := ParseFilterTree(raw)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "nesting deeper")
|
||||
}
|
||||
|
||||
func TestFilterFieldsByGroupOrdering(t *testing.T) {
|
||||
groups := FilterFieldsByGroup()
|
||||
require.NotEmpty(t, groups)
|
||||
first := groups[0].Group
|
||||
if !strings.EqualFold(first, GroupPeople) {
|
||||
t.Fatalf("first menu group should be %q, got %q", GroupPeople, first)
|
||||
}
|
||||
for _, g := range groups {
|
||||
require.NotEmpty(t, g.Fields, "group %s must not be empty", g.Group)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package services
|
||||
|
||||
import "strings"
|
||||
|
||||
// Genre model v2 (faceted-navigation plan, Batch B): the `genre` column
|
||||
// holds ONLY top-level shelves; every more specific candidate is a tag
|
||||
// mapped to its parent shelf. User rule: *a subgenre is not a genre, it's
|
||||
// a tag*.
|
||||
//
|
||||
// Sources arrive as comma-blobs ("Epic, Action & Adventure, Military,
|
||||
// Sword & Sorcery" — one Audible genre string), media-format noise
|
||||
// ("Audiobook"), maximally generic tags ("Fiction"), or library-type
|
||||
// words ("Manga", "Comics" — ComicInfo files that describe the TYPE,
|
||||
// not a shelf). Deterministic ranked rule set — no network, no guessing:
|
||||
//
|
||||
// 1. split candidate strings on , ; /
|
||||
// 2. alias-map variants to canonical forms (shelf or subgenre)
|
||||
// 3. discard demoted tokens (formats/editions/type-words) — never win
|
||||
// 4. pick: first DIRECT shelf (source order) > first MAPPED subgenre
|
||||
// shelf > Fiction/Nonfiction floor > "" (unknown-only: tags carry it)
|
||||
// 5. every candidate (split parts included) still lands in tags
|
||||
//
|
||||
// "adventure" and "action & adventure" are BOTH shelves — provider data
|
||||
// (Audible/Calibre) uses both, and the user-verified traces pin them:
|
||||
// {Pirates} → adventure (mapped), Malazan blob → action & adventure
|
||||
// (direct beats mapped). Merging them is deferred aliasing work.
|
||||
var genreShelves = map[string]bool{
|
||||
// Fiction side
|
||||
"fiction": true, "action & adventure": true, "adventure": true,
|
||||
"fantasy": true, "science fiction": true, "horror": true,
|
||||
"mystery": true, "thriller": true, "romance": true,
|
||||
"historical fiction": true, "western": true, "war & military": true,
|
||||
"young adult": true, "middle grade": true, "children's": true,
|
||||
"classics": true, "humor": true, "poetry": true, "short stories": true,
|
||||
// Nonfiction side
|
||||
"nonfiction": true, "biography & memoir": true, "history": true,
|
||||
"science": true, "true crime": true, "self-help": true,
|
||||
"philosophy": true, "business": true,
|
||||
}
|
||||
|
||||
// genreFloors are shelves that only win when NO other shelf candidate
|
||||
// exists (direct or mapped) — maximally generic positions. {Fiction,
|
||||
// Epic Fantasy} → fantasy, {Fiction} alone → fiction.
|
||||
func isGenreFloor(lower string) bool {
|
||||
return lower == "fiction" || lower == "nonfiction"
|
||||
}
|
||||
|
||||
// genreSubgenreShelf maps subgenre candidates (lowercase) to their parent
|
||||
// shelf. Anything here is a TAG by definition; its shelf wins the genre
|
||||
// column only when no direct shelf hit exists.
|
||||
var genreSubgenreShelf = map[string]string{
|
||||
// Fantasy subgenres
|
||||
"epic fantasy": "fantasy", "sword & sorcery": "fantasy",
|
||||
"dark fantasy": "fantasy", "urban fantasy": "fantasy",
|
||||
"paranormal": "fantasy", "high fantasy": "fantasy",
|
||||
"fairy tales": "fantasy", "mythology": "fantasy",
|
||||
"folklore": "fantasy", "litrpg": "fantasy",
|
||||
"progression fantasy": "fantasy",
|
||||
// Science fiction subgenres
|
||||
"space opera": "science fiction", "cyberpunk": "science fiction",
|
||||
"military science fiction": "science fiction", "dystopian": "science fiction",
|
||||
"post-apocalyptic": "science fiction", "apocalyptic": "science fiction",
|
||||
"time travel": "science fiction", "alternate history": "science fiction",
|
||||
"first contact": "science fiction", "steampunk": "science fiction",
|
||||
// Romance subgenres
|
||||
"contemporary romance": "romance", "historical romance": "romance",
|
||||
"paranormal romance": "romance",
|
||||
// Mystery subgenres
|
||||
"cozy mystery": "mystery", "crime": "mystery",
|
||||
"police procedural": "mystery", "noir": "mystery", "hardboiled": "mystery",
|
||||
// Thriller subgenres
|
||||
"suspense": "thriller", "espionage": "thriller",
|
||||
// Horror subgenres
|
||||
"gothic": "horror", "supernatural": "horror",
|
||||
// Fiction-side themes
|
||||
"literary fiction": "fiction", "coming of age": "fiction",
|
||||
"satire": "fiction", "anthology": "fiction", "alternate world": "fiction",
|
||||
// War & Military subgenres
|
||||
"military": "war & military", "war": "war & military",
|
||||
// Adventure subgenres
|
||||
"pirates": "adventure",
|
||||
// Biography & Memoir subgenres
|
||||
"biography": "biography & memoir", "autobiography": "biography & memoir",
|
||||
"memoir": "biography & memoir",
|
||||
// Remaining nonfiction topics → the nonfiction umbrella shelf
|
||||
"psychology": "nonfiction", "economics": "nonfiction",
|
||||
"politics": "nonfiction", "religion": "nonfiction",
|
||||
"spirituality": "nonfiction", "travel": "nonfiction",
|
||||
"cooking": "nonfiction", "food": "nonfiction",
|
||||
"health": "nonfiction", "fitness": "nonfiction",
|
||||
"sports": "nonfiction", "music": "nonfiction",
|
||||
"art": "nonfiction", "photography": "nonfiction",
|
||||
"education": "nonfiction", "reference": "nonfiction",
|
||||
"legal": "nonfiction", "medical": "nonfiction",
|
||||
}
|
||||
|
||||
// genreAliases maps provider variants to a canonical shelf OR subgenre
|
||||
// form (which the maps above then resolve).
|
||||
var genreAliases = map[string]string{
|
||||
"sci-fi": "science fiction", "scifi": "science fiction",
|
||||
"sf": "science fiction", "sff": "science fiction",
|
||||
"sci fi": "science fiction", "sci_fi": "science fiction",
|
||||
"speculative fiction": "science fiction",
|
||||
"detectives": "mystery", "detective": "mystery",
|
||||
"whodunnit": "mystery", "whodunit": "mystery",
|
||||
"adventure fiction": "action & adventure",
|
||||
"action and adventure": "action & adventure",
|
||||
"ya": "young adult", "mg": "middle grade",
|
||||
"coming-of-age": "coming of age",
|
||||
"histfiction": "historical fiction",
|
||||
"epic": "epic fantasy",
|
||||
"military scifi": "military science fiction",
|
||||
"military sf": "military science fiction",
|
||||
"post apocalyptic": "post-apocalyptic",
|
||||
"self help": "self-help",
|
||||
}
|
||||
|
||||
// genreDemoted never wins the genre column — media formats, editions,
|
||||
// and library-TYPE words (ComicInfo genre="Manga" describes the type,
|
||||
// not a shelf; shelves apply uniformly across all library types). They
|
||||
// still pass through to tags.
|
||||
var genreDemoted = map[string]bool{
|
||||
"audiobook": true, "unabridged": true, "abridged": true,
|
||||
"dramatization": true, "dramatized": true, "audio drama": true,
|
||||
"manga": true, "comics": true, "graphic novel": true,
|
||||
"graphic novels": true,
|
||||
}
|
||||
|
||||
// splitGenreCandidates splits compound genre strings ("Epic, Action &
|
||||
// Adventure" — one Audible blob) into individual candidates.
|
||||
func splitGenreCandidates(genres []string) []string {
|
||||
var out []string
|
||||
for _, g := range genres {
|
||||
for _, part := range strings.Split(g, ",") {
|
||||
for _, p := range strings.Split(part, ";") {
|
||||
for _, piece := range strings.Split(p, "/") {
|
||||
t := strings.TrimSpace(piece)
|
||||
if t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeGenreCandidate lowercases, trims, and alias-resolves one
|
||||
// candidate; "" means noise (empty input).
|
||||
func normalizeGenreCandidate(c string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(c))
|
||||
if lower == "" {
|
||||
return ""
|
||||
}
|
||||
if a, ok := genreAliases[lower]; ok {
|
||||
return a
|
||||
}
|
||||
return lower
|
||||
}
|
||||
|
||||
// pickSmartGenre chooses the SHELF for the genre column from candidate
|
||||
// strings (genre model v2). Returns the winner ("" when no shelf is
|
||||
// known — unknown-only input stays tag-only) and the full candidate
|
||||
// list for the tags array.
|
||||
func pickSmartGenre(genres []string) (genre string, all []string) {
|
||||
all = splitGenreCandidates(genres)
|
||||
direct := "" // first non-floor shelf, source order
|
||||
mapped := "" // first subgenre-resolved shelf, source order
|
||||
floor := "" // fiction / nonfiction, source order
|
||||
for _, c := range all {
|
||||
lower := normalizeGenreCandidate(c)
|
||||
if lower == "" || genreDemoted[lower] {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case genreShelves[lower]:
|
||||
if isGenreFloor(lower) {
|
||||
if floor == "" {
|
||||
floor = lower
|
||||
}
|
||||
} else if direct == "" {
|
||||
direct = lower
|
||||
}
|
||||
case genreSubgenreShelf[lower] != "":
|
||||
if mapped == "" {
|
||||
mapped = genreSubgenreShelf[lower]
|
||||
}
|
||||
}
|
||||
// Unknown candidates are tags-only — never guess a shelf.
|
||||
}
|
||||
if direct != "" {
|
||||
return direct, all
|
||||
}
|
||||
if mapped != "" {
|
||||
return mapped, all
|
||||
}
|
||||
return floor, all
|
||||
}
|
||||
+962
-151
File diff suppressed because it is too large.
Load diff
@@ -408,12 +408,10 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
|
||||
metadata.Title = album
|
||||
}
|
||||
}
|
||||
// Subtitle joins the title Calibre-style, mirroring the ABS sidecar
|
||||
// path — there is no separate subtitle column. Skip when the title
|
||||
// already contains it (tools sometimes pre-join).
|
||||
if subtitle := report.tag("----:com.apple.itunes:subtitle", "subtitle"); subtitle != "" && metadata.Title != "" &&
|
||||
!strings.Contains(strings.ToLower(metadata.Title), strings.ToLower(subtitle)) {
|
||||
metadata.Title = metadata.Title + ": " + subtitle
|
||||
// Subtitle is a first-class column (A2b) — no more Calibre-style title
|
||||
// joining; the tool-provided subtitle lands in its own column.
|
||||
if subtitle := report.tag("----:com.apple.itunes:subtitle", "subtitle"); subtitle != "" {
|
||||
metadata.Subtitle = subtitle
|
||||
}
|
||||
if metadata.Author == "" {
|
||||
if artist := report.tag("album_artist"); artist != "" {
|
||||
@@ -437,8 +435,8 @@ func applyAudioTagFallbacks(metadata *MediaMetadata, report *ffprobeReport) {
|
||||
if series := report.tag("grouping", "----:com.apple.itunes:series"); series != "" {
|
||||
metadata.Series = series
|
||||
if part := report.tag("----:com.apple.itunes:series-part"); part != "" {
|
||||
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 32); err == nil {
|
||||
metadata.SeriesNumber = int32(idx)
|
||||
if idx, err := strconv.ParseFloat(strings.TrimSpace(part), 64); err == nil {
|
||||
metadata.SeriesNumber = idx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestAudioPayloadIsEmpty pins the write-after-success guard on
|
||||
// updateAudioBookItem (2026-10-21 incident: multi-file audiobooks
|
||||
// surfaced as 1 second long because a failed re-extraction overwrote a
|
||||
// populated audio_metadata). An empty payload — nil, or no tracks, no
|
||||
// chapters, no duration — is the signature of a failed extraction and
|
||||
// must never replace existing audio data.
|
||||
func TestAudioPayloadIsEmpty(t *testing.T) {
|
||||
require := func(cond bool, msg string) {
|
||||
if !cond {
|
||||
t.Fatal(msg)
|
||||
}
|
||||
}
|
||||
|
||||
require(audioPayloadIsEmpty(nil), "nil payload must read as empty")
|
||||
require(audioPayloadIsEmpty(&AudioMetadataPayload{}), "zero-value payload must read as empty")
|
||||
require(audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 0}), "zero-duration payload must read as empty")
|
||||
|
||||
// A real book always carries at least one of the audio facts.
|
||||
require(!audioPayloadIsEmpty(&AudioMetadataPayload{DurationSeconds: 302.6}),
|
||||
"duration-only payload (single-track book) is not empty")
|
||||
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
|
||||
Tracks: []AudioTrack{{Index: 0, Path: "01.mp3"}},
|
||||
}), "track manifest is not empty")
|
||||
require(!audioPayloadIsEmpty(&AudioMetadataPayload{
|
||||
Chapters: []AudioChapter{{Index: 0, Title: "Chapter 1"}},
|
||||
}), "chapters are not empty")
|
||||
}
|
||||
@@ -148,9 +148,13 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
|
||||
"----:com.apple.iTunes:ASIN": "B0ASIN1234",
|
||||
"----:com.apple.iTunes:ABRIDGED": "1",
|
||||
}))
|
||||
if meta.Title != "The Tagged Album: Or, A Life" {
|
||||
// A2b: subtitle is a first-class column — no title joining.
|
||||
if meta.Title != "The Tagged Album" {
|
||||
t.Fatalf("title = %q", meta.Title)
|
||||
}
|
||||
if meta.Subtitle != "Or, A Life" {
|
||||
t.Fatalf("subtitle = %q", meta.Subtitle)
|
||||
}
|
||||
if meta.Author != "Tag Author" {
|
||||
t.Fatalf("author = %q", meta.Author)
|
||||
}
|
||||
@@ -163,7 +167,7 @@ func TestApplyAudioTagFallbacks(t *testing.T) {
|
||||
t.Fatal("series not filled from tags")
|
||||
}
|
||||
if meta.SeriesNumber != 4 {
|
||||
t.Fatalf("series number = %d", meta.SeriesNumber)
|
||||
t.Fatalf("series number = %v", meta.SeriesNumber)
|
||||
}
|
||||
if meta.ASIN != "B0ASIN1234" {
|
||||
t.Fatalf("asin = %q", meta.ASIN)
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
@@ -533,10 +534,15 @@ func (s *MediaScanner) extractAudioBookMetadata(representative, rootFolder strin
|
||||
// zip books. The hash columns stay NULL — content dedup is path-based for
|
||||
// multi-file books (there is no single byte stream to hash).
|
||||
func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype.UUID, metadata *MediaMetadata, relPath string, totalSize int64, mimeType string, modTime pgtype.Timestamptz) (database.MediaItems, error) {
|
||||
return s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
// A2c §2.7: same title/subtitle-derived series fallback as the
|
||||
// single-file path — folder-of-tracks books carry the same naming.
|
||||
applyTitleDerivedSeries(metadata)
|
||||
created, err := s.db.CreateMediaItem(ctx, database.CreateMediaItemParams{
|
||||
LibraryID: libraryID,
|
||||
Title: metadata.Title,
|
||||
Author: pgtype.Text{String: metadata.Author, Valid: metadata.Author != ""},
|
||||
Subtitle: pgtype.Text{String: metadata.Subtitle, Valid: metadata.Subtitle != ""},
|
||||
Authors: nilToEmpty(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
AuthorsSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Author, metadata.Authors)),
|
||||
Isbn: pgtype.Text{String: utils.NormalizeISBNSafe(metadata.ISBN), Valid: metadata.ISBN != ""},
|
||||
Asin: pgtype.Text{String: metadata.ASIN, Valid: metadata.ASIN != ""},
|
||||
Description: pgtype.Text{String: metadata.Description, Valid: metadata.Description != ""},
|
||||
@@ -545,20 +551,36 @@ func (s *MediaScanner) createAudioBookItem(ctx context.Context, libraryID pgtype
|
||||
MimeType: pgtype.Text{String: mimeType, Valid: mimeType != ""},
|
||||
CoverImagePath: pgtype.Text{String: metadata.CoverPath, Valid: metadata.CoverPath != ""},
|
||||
Series: pgtype.Text{String: metadata.Series, Valid: metadata.Series != ""},
|
||||
SeriesNumber: pgtype.Int4{Int32: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publisher: pgtype.Text{String: metadata.Publisher, Valid: metadata.Publisher != ""},
|
||||
SeriesNumber: pgtype.Float8{Float64: metadata.SeriesNumber, Valid: metadata.SeriesNumber > 0},
|
||||
Publishers: nilToEmpty(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
PublishersSearch: utils.NormalizeTagsSearch(utils.SingleOrSlice(metadata.Publisher, metadata.Publishers)),
|
||||
DatePublished: pgtype.Date{Time: metadata.PublishDate, Valid: !metadata.PublishDate.IsZero()},
|
||||
Genre: pgtype.Text{String: metadata.Genre, Valid: metadata.Genre != ""},
|
||||
Contributors: utils.NormalizeContributors(metadata.Contributors),
|
||||
Narrators: nilToEmpty(metadata.Narrators),
|
||||
NarratorsSearch: utils.NormalizeNarratorsSearch(metadata.Narrators),
|
||||
Abridged: metadata.Abridged,
|
||||
Explicit: metadata.Explicit,
|
||||
Tags: utils.NormalizeTags(metadata.Tags),
|
||||
AddedByAdminID: s.adminID,
|
||||
CreatedAt: modTime,
|
||||
ImportedAt: pgtype.Timestamptz{Time: time.Now(), Valid: true},
|
||||
AudioMetadata: marshalAudioMetadata(metadata.AudioMeta),
|
||||
})
|
||||
if err != nil {
|
||||
// Ledger (Phase 11): row-less import failure keyed by
|
||||
// (library, path) — the folder/zip book's row was never created.
|
||||
if logErr := s.LogRowlessProcessingIssue(ctx,
|
||||
uuid.UUID(libraryID.Bytes), relPath,
|
||||
"import_failed", fmt.Sprintf("File failed to import: %v", err), "error"); logErr != nil {
|
||||
fmt.Printf("Warning: failed to record import-failure issue for %s: %v\n", relPath, logErr)
|
||||
}
|
||||
return database.MediaItems{}, err
|
||||
}
|
||||
// Ledger self-heal (Phase 11): the book finally imports — close any
|
||||
// open row-less import-failure issues keyed to this (library, path).
|
||||
s.resolveIssuesForPath(ctx, libraryID, relPath)
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// setAudioFormatGroup writes the audiobook format facts after create.
|
||||
@@ -833,7 +855,25 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
}
|
||||
|
||||
existing, existingErr := s.getMediaItemByFilePath(ctx, rootRel, libraryID)
|
||||
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) {
|
||||
|
||||
// The representative drives the sidecar/folder-structure checks; they
|
||||
// all look at Dir(representative), so a sentinel INSIDE the root
|
||||
// resolves covers and metadata.json living there (including disc-
|
||||
// layout roots where track zero sits deeper).
|
||||
sentinel := filepath.Join(rootDir, ".audiobook-root")
|
||||
|
||||
// Manifest-gated sidecar detection (faceted plan Phase 6): a
|
||||
// size-stable folder book is changed by EDITING its sidecars —
|
||||
// including on scans where the track fingerprint says "unchanged".
|
||||
// The ladder is stat-only in the steady state; a detected edit falls
|
||||
// through to the full probe below so the once-extracted metadata can
|
||||
// feed the editorial apply.
|
||||
manifestChanged := false
|
||||
if existingErr == nil && !s.forceRescan {
|
||||
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, rootDir, sentinel, rootDir)
|
||||
}
|
||||
|
||||
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(tracks)) && !manifestChanged {
|
||||
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
|
||||
@@ -881,11 +921,9 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
}
|
||||
orderTracksByTags(probed)
|
||||
|
||||
// The representative path drives the sidecar/folder-structure checks;
|
||||
// they all look at Dir(representative), so a sentinel INSIDE the root
|
||||
// resolves covers and metadata.json living there (including disc-
|
||||
// layout roots where track zero sits deeper).
|
||||
sentinel := filepath.Join(rootDir, ".audiobook-root")
|
||||
// The sentinel is defined above, next to the manifest check that
|
||||
// shares it; the probe pass reuses it as the extraction
|
||||
// representative.
|
||||
metadata := s.extractAudioBookMetadata(sentinel, rootFolder, firstReport, tracks[0], filepath.Base(rootDir))
|
||||
payload := buildMultiFileAudioPayload("folder", metadata.AudioMeta, probed)
|
||||
metadata.AudioMeta = payload
|
||||
@@ -908,10 +946,39 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
switch {
|
||||
case err == nil:
|
||||
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
|
||||
if changed || s.forceRescan {
|
||||
if changed || s.forceRescan || manifestChanged {
|
||||
// Editorial apply BEFORE the payload write (rescanMultiFileAudioBook's
|
||||
// ordering): the generic update's format-group pass derives the
|
||||
// mime from the sentinel (none) and chapters from
|
||||
// metadata.ChapterCount (unset) — the focused payload write below
|
||||
// must win. Force rescan applies TOO (faceted plan Phase 13:
|
||||
// force rescan = original-scan semantics — the full chain
|
||||
// re-lands on every field, with metadata_overrides as the only
|
||||
// protection, honored inside the apply).
|
||||
if manifestChanged || s.forceRescan {
|
||||
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, sentinel, metadata); applyErr != nil {
|
||||
fmt.Printf("Warning: %v\n", applyErr)
|
||||
} else {
|
||||
// Ledger self-heal (Phase 11): the book's metadata
|
||||
// re-applied successfully — close its open issues.
|
||||
s.resolveIssuesForItem(ctx, existing.ID)
|
||||
}
|
||||
// Fresh full manifest: the next scan compares against
|
||||
// TODAY's sidecars, not the stale pre-edit fingerprint.
|
||||
s.storeMultiFileManifest(ctx, libraryID, rootDir, sentinel, rootDir)
|
||||
}
|
||||
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
|
||||
return err
|
||||
}
|
||||
s.changedItems++
|
||||
// The book is demonstrably back and fresh: lift any archive
|
||||
// state the missing-root scans set (the same lift the
|
||||
// unchanged path applies).
|
||||
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive folder book %s: %v\n", rootRel, err)
|
||||
}
|
||||
}
|
||||
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
// Unchanged content that was archived while the root was gone.
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
@@ -928,6 +995,11 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
|
||||
fmt.Printf("Warning: failed to set format group for folder book %s: %v\n", rootRel, err)
|
||||
}
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
|
||||
// Seed the sidecar manifest at create (Phase 6): names derive
|
||||
// from the sentinel — the exact representative the extractor
|
||||
// reads — so the manifest can never disagree with extraction.
|
||||
s.seedMultiFileManifest(ctx, libraryID, created.ID, rootRel, sentinel, rootDir, totalSize)
|
||||
fmt.Printf("[AUDIO] Created folder book '%s' (%d tracks) at %s\n", metadata.Title, len(tracks), rootRel)
|
||||
s.newItems++
|
||||
default:
|
||||
@@ -943,6 +1015,16 @@ func (s *MediaScanner) processAudioFolderBook(ctx context.Context, rootDir strin
|
||||
// rescan saw the track set change (or the admin forced a rescan). Only
|
||||
// scan-derived fields move — editorial columns keep admin edits.
|
||||
func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing database.MediaItems, payload *AudioMetadataPayload, mimeType string, totalSize int64, chapterCount int) error {
|
||||
// Write-after-success discipline (2026-10-21 incident: multi-file
|
||||
// audiobooks surfaced as 1 second long because a re-extraction wrote
|
||||
// a NULL/empty audio_metadata over a populated one). An empty payload
|
||||
// is a FAILED extraction, not a book with no audio: when the row
|
||||
// already carries audio data, keep it and skip the write — the next
|
||||
// scan retries, and the reader keeps its manifest in the meantime.
|
||||
if audioPayloadIsEmpty(payload) && existing.AudioMetadata != nil {
|
||||
fmt.Printf("[AUDIO] WARNING: re-extraction of '%s' produced an empty audio payload; keeping the existing audio_metadata\n", existing.Title)
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.UpdateMediaItemAudioBookPayload(ctx, database.UpdateMediaItemAudioBookPayloadParams{
|
||||
ID: existing.ID,
|
||||
FileSize: pgtype.Int8{Int64: totalSize, Valid: true},
|
||||
@@ -956,6 +1038,76 @@ func (s *MediaScanner) updateAudioBookItem(ctx context.Context, existing databas
|
||||
return nil
|
||||
}
|
||||
|
||||
// audioPayloadIsEmpty reports whether a payload carries no audio facts
|
||||
// at all — no tracks, no chapters, no duration. Such a payload is the
|
||||
// signature of a failed/absent extraction, never of a real book.
|
||||
func audioPayloadIsEmpty(payload *AudioMetadataPayload) bool {
|
||||
return payload == nil ||
|
||||
(len(payload.Tracks) == 0 && len(payload.Chapters) == 0 && payload.DurationSeconds <= 0)
|
||||
}
|
||||
|
||||
// multifileSidecarChanged runs the manifest ladder for a folder/zip book
|
||||
// (faceted plan Phase 6): a size-stable multi-file book is changed by
|
||||
// EDITING its sidecars — the track-count/size fingerprint never saw it,
|
||||
// not even on force rescan. The bulk-map entry is keyed by the item's
|
||||
// file_path, but sidecar NAMES derive from the representative the
|
||||
// extractor reads (the sentinel inside a folder root, the zip itself).
|
||||
// NULL stored manifest → backfill only (deploy-day rule); no bulk entry
|
||||
// (nil map, brand-new item) → not detected this pass.
|
||||
func (s *MediaScanner) multifileSidecarChanged(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) bool {
|
||||
entry, ok := s.scanStateLookup(libraryID, itemPath)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return s.detectSidecarChangeWithRepresentative(ctx, entry, libraryID, representative, dir)
|
||||
}
|
||||
|
||||
// applyMultiFileSidecarEdit lands the once-extracted book metadata
|
||||
// (sidecars → folder structure → first-track tags → fallback title) on
|
||||
// the editorial columns of an EXISTING folder/zip book — exactly what
|
||||
// rescanMultiFileAudioBook applies; metadata_overrides stay protected
|
||||
// inside the update. The caller runs the focused payload write AFTER
|
||||
// this: the generic update's format-group pass derives the mime type
|
||||
// from the sentinel/zip path (none/audio-container) and the chapter
|
||||
// count from metadata.ChapterCount (unset on the book chain), so the
|
||||
// payload write must win. The playlist hook re-runs because a
|
||||
// "(Reading Order)" entry added to an EXISTING book's sidecar still
|
||||
// creates its collection (add-if-missing is idempotent).
|
||||
func (s *MediaScanner) applyMultiFileSidecarEdit(ctx context.Context, existing database.MediaItems, representative string, metadata *MediaMetadata) error {
|
||||
if err := s.updateMediaItemWithMetadata(ctx, existing, representative, metadata); err != nil {
|
||||
return fmt.Errorf("failed to apply sidecar metadata to %s: %w", existing.FilePath, err)
|
||||
}
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, existing.ID, metadata.PlaylistSeries)
|
||||
fmt.Printf("[AUDIO] Sidecar metadata applied to '%s'\n", existing.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedMultiFileManifest inserts the new book's scan-state entry and
|
||||
// hashes its sidecars into the initial manifest — the multifile twin of
|
||||
// the single-file create tail: the next scan compares instead of
|
||||
// backfilling, so an edit is detected on its first post-seed scan.
|
||||
func (s *MediaScanner) seedMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemID pgtype.UUID, relPath, representative, dir string, totalSize int64) {
|
||||
newEntry := &scanEntry{ID: itemID, FilePath: relPath, FileSize: totalSize}
|
||||
s.scanStateUpsert(libraryID, newEntry)
|
||||
if computed := statSidecarManifest(representative, dir); len(computed.Entries) > 0 {
|
||||
s.storeManifest(ctx, libraryID, newEntry, hashSidecarManifest(representative, dir, computed))
|
||||
} else {
|
||||
// Sidecar-less book still needs an ev home (faceted plan Phase
|
||||
// 10): stamp — the book was JUST extracted by the current
|
||||
// parser, so the stamp is truthful.
|
||||
s.storeManifest(ctx, libraryID, newEntry, &sidecarManifest{Version: sidecarManifestVersion})
|
||||
}
|
||||
}
|
||||
|
||||
// storeMultiFileManifest refreshes the book's full manifest after a
|
||||
// change-path apply: the next scan compares against TODAY's sidecars,
|
||||
// not the stale pre-edit fingerprint.
|
||||
func (s *MediaScanner) storeMultiFileManifest(ctx context.Context, libraryID pgtype.UUID, itemPath, representative, dir string) {
|
||||
if entry, ok := s.scanStateLookup(libraryID, itemPath); ok {
|
||||
s.storeManifest(ctx, libraryID, entry, hashSidecarManifest(representative, dir, statSidecarManifest(representative, dir)))
|
||||
}
|
||||
}
|
||||
|
||||
// processZipAudioBook ingests a zip archive of audio tracks as ONE book.
|
||||
// The zip is the item's file (downloadable like any single file);
|
||||
// audio_metadata carries layout "zip" and per-entry track paths the
|
||||
@@ -982,7 +1134,26 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
|
||||
}
|
||||
|
||||
existing, existingErr := s.getMediaItemByFilePath(ctx, zipRel, libraryID)
|
||||
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) {
|
||||
|
||||
// Manifest-gated sidecar detection (faceted plan Phase 6) — the zip
|
||||
// itself is both the map key and the representative: its sidecars are
|
||||
// the siblings the extractor reads (metadata.json next to the zip,
|
||||
// "<zip-basename>.<ext>" covers).
|
||||
manifestChanged := false
|
||||
if existingErr == nil && !s.forceRescan {
|
||||
manifestChanged = s.multifileSidecarChanged(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
|
||||
}
|
||||
|
||||
if existingErr == nil && !s.forceRescan && multiFileBookUnchanged(existing, totalSize, len(entries)) && !manifestChanged {
|
||||
// Archive-restore parity with the folder path (Phase 6 fix): a
|
||||
// zip book whose file returned must not stay archived forever.
|
||||
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
|
||||
} else {
|
||||
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1037,10 +1208,38 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
|
||||
switch {
|
||||
case err == nil:
|
||||
changed := existing.FileSize.Int64 != totalSize || !audioPayloadTracksEqual(existing.AudioMetadata, payload)
|
||||
if changed || s.forceRescan {
|
||||
if changed || s.forceRescan || manifestChanged {
|
||||
// Editorial apply BEFORE the payload write — see the folder
|
||||
// processor for the format-group ordering rationale. Force
|
||||
// rescan applies too (Phase 13: original-scan semantics,
|
||||
// metadata_overrides honored inside the apply).
|
||||
if manifestChanged || s.forceRescan {
|
||||
if applyErr := s.applyMultiFileSidecarEdit(ctx, existing, zipPath, metadata); applyErr != nil {
|
||||
fmt.Printf("Warning: %v\n", applyErr)
|
||||
} else {
|
||||
// Ledger self-heal (Phase 11): the book's metadata
|
||||
// re-applied successfully — close its open issues.
|
||||
s.resolveIssuesForItem(ctx, existing.ID)
|
||||
}
|
||||
s.storeMultiFileManifest(ctx, libraryID, zipPath, zipPath, filepath.Dir(zipPath))
|
||||
}
|
||||
if err := s.updateAudioBookItem(ctx, existing, payload, firstMime, totalSize, len(payload.Chapters)); err != nil {
|
||||
return err
|
||||
}
|
||||
s.changedItems++
|
||||
if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
|
||||
}
|
||||
}
|
||||
} else if existing.ArchivedAt.Valid || existing.MissingScanCount > 0 {
|
||||
// Parity with the folder change branch: content unchanged but
|
||||
// the row was archived while the zip was gone.
|
||||
if err := s.db.ClearMediaItemArchive(ctx, existing.ID); err != nil {
|
||||
fmt.Printf("Warning: failed to unarchive zip book %s: %v\n", zipRel, err)
|
||||
} else {
|
||||
fmt.Printf("[AUDIO] Restored zip book '%s' (file is back)\n", existing.Title)
|
||||
}
|
||||
}
|
||||
case errors.Is(err, pgx.ErrNoRows):
|
||||
created, err := s.createAudioBookItem(ctx, libraryID, metadata, zipRel, totalSize, firstMime, modTime)
|
||||
@@ -1050,6 +1249,8 @@ func (s *MediaScanner) processZipAudioBook(ctx context.Context, zipPath string,
|
||||
if err := s.setAudioFormatGroup(ctx, created.ID, firstMime, len(payload.Chapters)); err != nil {
|
||||
fmt.Printf("Warning: failed to set format group for zip book %s: %v\n", zipRel, err)
|
||||
}
|
||||
s.syncPlaylistCollectionsAfterCreate(ctx, created.ID, metadata.PlaylistSeries)
|
||||
s.seedMultiFileManifest(ctx, libraryID, created.ID, zipRel, zipPath, filepath.Dir(zipPath), totalSize)
|
||||
fmt.Printf("[AUDIO] Created zip book '%s' (%d tracks) at %s\n", metadata.Title, len(entries), zipRel)
|
||||
s.newItems++
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package services
|
||||
|
||||
// Unit tests for the extractor-version (ev) mechanism (faceted plan
|
||||
// Phase 10): the wire normalization (absent = v1-or-unknown = 1) and the
|
||||
// per-scan catch-up budget. The full scan-state machine (ev mismatch →
|
||||
// re-extract once → stable) lives in cmd/server/tests
|
||||
// (TestExtractorVersionReextract) because the store paths need a real DB.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestManifestEvNormalization(t *testing.T) {
|
||||
if got := manifestEv(nil); got != 1 {
|
||||
t.Errorf("nil manifest rides v1: got %d, want 1", got)
|
||||
}
|
||||
cases := []struct {
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{`{"v":1}`, 1}, // old shape: ev absent → v1-or-unknown
|
||||
{`{"v":1,"ev":0}`, 1}, // explicitly unset → same
|
||||
{`{"v":1,"ev":1}`, 1}, // baseline parser stamp
|
||||
{`{"v":1,"ev":2}`, 2}, // post-bump stamp
|
||||
{`{"v":1,"entries":[],"ev":3}`, 3}, // sidecar-less ev home
|
||||
}
|
||||
for _, tc := range cases {
|
||||
m := decodeSidecarManifest([]byte(tc.raw))
|
||||
if m == nil {
|
||||
t.Fatalf("%s must decode", tc.raw)
|
||||
}
|
||||
if got := manifestEv(m); got != tc.want {
|
||||
t.Errorf("manifestEv(%s) = %d, want %d", tc.raw, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvBudgetTake(t *testing.T) {
|
||||
scanner := &MediaScanner{}
|
||||
scanner.evBudgetRemaining.Store(2)
|
||||
if !scanner.evBudgetTake() {
|
||||
t.Error("budget 2: first take must pass")
|
||||
}
|
||||
if !scanner.evBudgetTake() {
|
||||
t.Error("budget 1: second take must pass")
|
||||
}
|
||||
if scanner.evBudgetTake() {
|
||||
t.Error("budget 0: third take must fail")
|
||||
}
|
||||
if got := scanner.evBudgetRemaining.Load(); got != 0 {
|
||||
t.Errorf("budget must land at exactly 0, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvBudgetTake_FailsFastAtZero(t *testing.T) {
|
||||
scanner := &MediaScanner{}
|
||||
scanner.evBudgetRemaining.Store(0)
|
||||
// Repeated takes at 0 must fail WITHOUT driving the counter negative
|
||||
// (the CAS loop only fires when cur > 0).
|
||||
for i := 0; i < 5; i++ {
|
||||
if scanner.evBudgetTake() {
|
||||
t.Fatalf("take %d must fail at budget 0", i+1)
|
||||
}
|
||||
}
|
||||
if got := scanner.evBudgetRemaining.Load(); got != 0 {
|
||||
t.Errorf("budget must stay 0, got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package services
|
||||
|
||||
// Sidecar manifest fingerprints (faceted plan Part 2 §3): size-stable
|
||||
// books are changed by EDITING their metadata sidecars, which the old
|
||||
// size-only detection never noticed. One manifest per book (JSONB in
|
||||
// media_scan_state) lists every sidecar the extractors consume, so the
|
||||
// manifest and the extractors can never disagree — the manifest stats
|
||||
// exactly the names extractCalibreSidecar, extractAudiobookshelfSidecar,
|
||||
// and findSidecarCover look for.
|
||||
//
|
||||
// Change evaluation ladder, in cost order:
|
||||
// 1. stat pairs all equal → unchanged, ZERO bytes read (steady state);
|
||||
// 2. a stat pair differs → read + hash that one sidecar:
|
||||
// hash equal → refresh the stored stat pair (host repoint
|
||||
// reconciliation: bounded reads, zero re-extraction);
|
||||
// hash differs → the item changed → re-extract (caller);
|
||||
// 3. stored manifest NULL → backfill only: hash + store, NO
|
||||
// re-extraction (deploy-day rule — the first scan after release
|
||||
// must not look like an mtime flood).
|
||||
//
|
||||
// mtime is a CACHE field only — it is never a change signal. Docker
|
||||
// bind-mount repoints reset every mtime, which is exactly why the hash
|
||||
// reconciliation ladder exists.
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const sidecarManifestVersion = 1
|
||||
|
||||
type manifestSidecar struct {
|
||||
Name string `json:"n"`
|
||||
Size int64 `json:"s"`
|
||||
MTime int64 `json:"m"`
|
||||
// Sha is the content hash. Present for ALL entries — covers too:
|
||||
// the repoint reconciliation needs a baseline hash for any stat-
|
||||
// drifted sidecar, and a hashless cover would turn harmless mtime
|
||||
// drift into a re-extraction (the exact flood this design kills).
|
||||
Sha string `json:"h,omitempty"`
|
||||
}
|
||||
|
||||
type sidecarManifest struct {
|
||||
Version int `json:"v"`
|
||||
Entries []manifestSidecar `json:"entries"`
|
||||
// Ev is the extractor (parser) version that produced the item's
|
||||
// CURRENT row. Absent (0) means "v1-or-unknown": backfill and
|
||||
// extract-failure stores leave it unset so a row never claims a
|
||||
// version its data can't prove; every store that FOLLOWS a
|
||||
// successful extraction stamps the setting's current value
|
||||
// (scan_extractor_version). A stored ev below the setting re-runs
|
||||
// extraction on a later scan — parser fixes reach old rows without
|
||||
// a force rescan (faceted plan Phase 10).
|
||||
Ev int `json:"ev,omitempty"`
|
||||
}
|
||||
|
||||
// manifestEv normalizes a stored manifest's extractor version: absent
|
||||
// manifest or unset ev means version 1 (the shipped baseline parser).
|
||||
// Current-parser versions only ever grow, so "unknown" safely rides the
|
||||
// oldest live version.
|
||||
func manifestEv(m *sidecarManifest) int {
|
||||
if m == nil || m.Ev < 1 {
|
||||
return 1
|
||||
}
|
||||
return m.Ev
|
||||
}
|
||||
|
||||
// coverSidecarNames mirrors findSidecarCover's priority list — the
|
||||
// manifest must stat the same names the extractor would consume.
|
||||
var coverSidecarNames = []string{
|
||||
"cover.jpg", "cover.jpeg", "cover.png", "cover.webp",
|
||||
"folder.jpg", "folder.jpeg", "folder.png", "folder.webp",
|
||||
".folder.jpg", ".folder.jpeg", ".folder.png",
|
||||
}
|
||||
|
||||
// bookSidecarDir is the directory whose sidecars feed a book: the book
|
||||
// file's directory (multifile book roots resolve to themselves because
|
||||
// file_path IS the directory).
|
||||
func bookSidecarDir(path string) string {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil && info.IsDir() {
|
||||
return path
|
||||
}
|
||||
return filepath.Dir(path)
|
||||
}
|
||||
|
||||
// sidecarNamesForBook lists every sidecar filename the extractors
|
||||
// consume for a book at path. The book basename covers the
|
||||
// findSidecarCover "<basename>.<ext>" pattern.
|
||||
func sidecarNamesForBook(path string) []string {
|
||||
names := make([]string, 0, len(coverSidecarNames)+5)
|
||||
names = append(names, "metadata.opf", "metadata.json")
|
||||
names = append(names, coverSidecarNames...)
|
||||
base := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
|
||||
if base != "" && base != "." {
|
||||
for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} {
|
||||
names = append(names, base+ext)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// statSidecarManifest stats every candidate sidecar (ReadDir-free: one
|
||||
// lstat per candidate name — the walk already visited the directory).
|
||||
// Returns nil when the directory itself is gone (deleted book mid-scan).
|
||||
func statSidecarManifest(path, dir string) *sidecarManifest {
|
||||
manifest := &sidecarManifest{Version: sidecarManifestVersion}
|
||||
for _, name := range sidecarNamesForBook(path) {
|
||||
info, err := os.Stat(filepath.Join(dir, name))
|
||||
if err != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
manifest.Entries = append(manifest.Entries, manifestSidecar{
|
||||
Name: name,
|
||||
Size: info.Size(),
|
||||
MTime: info.ModTime().UnixNano(),
|
||||
})
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
// hashSidecarManifest fills the content hashes of every entry — text
|
||||
// sidecars (KBs) and covers (a one-time bounded read at backfill; the
|
||||
// repoint ladder needs the baseline). Returns the same manifest for
|
||||
// chaining.
|
||||
func hashSidecarManifest(path, dir string, manifest *sidecarManifest) *sidecarManifest {
|
||||
for i := range manifest.Entries {
|
||||
entry := &manifest.Entries[i]
|
||||
sha, err := sha256File(filepath.Join(dir, entry.Name))
|
||||
if err != nil {
|
||||
continue // unreadable: stays size-only this pass
|
||||
}
|
||||
entry.Sha = sha
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
|
||||
func sha256File(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
// decodeSidecarManifest parses a stored manifest; nil when absent or
|
||||
// corrupt (the caller then treats it as a backfill, self-healing any
|
||||
// shape breakage).
|
||||
func decodeSidecarManifest(raw []byte) *sidecarManifest {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m sidecarManifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &m
|
||||
}
|
||||
|
||||
// reconcileSidecarManifest runs the evaluation ladder against the
|
||||
// freshly-statted manifest. Returns the manifest to store and whether
|
||||
// the item's metadata must be re-extracted:
|
||||
// - stat pairs all equal → fresh=nil, changed=false (zero reads);
|
||||
// - stat drift + equal hash → fresh with the refreshed pair
|
||||
// (repoint reconciliation);
|
||||
// - hash differs, a sidecar appeared/vanished, or a differing text
|
||||
// sidecar has no stored hash → changed.
|
||||
func reconcileSidecarManifest(dir string, stored, computed *sidecarManifest) (fresh *sidecarManifest, changed bool) {
|
||||
if stored == nil {
|
||||
return nil, true
|
||||
}
|
||||
if len(stored.Entries) != len(computed.Entries) {
|
||||
return nil, true // sidecar appeared or vanished
|
||||
}
|
||||
storedByName := make(map[string]manifestSidecar, len(stored.Entries))
|
||||
for _, e := range stored.Entries {
|
||||
storedByName[e.Name] = e
|
||||
}
|
||||
// Fast path: stat pairs all equal → unchanged, zero bytes read,
|
||||
// nothing to store.
|
||||
drifted := false
|
||||
for _, cur := range computed.Entries {
|
||||
old, ok := storedByName[cur.Name]
|
||||
if !ok || cur.Size != old.Size || cur.MTime != old.MTime {
|
||||
drifted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !drifted {
|
||||
return nil, false
|
||||
}
|
||||
fresh = &sidecarManifest{Version: sidecarManifestVersion, Ev: stored.Ev} // provenance preserved: stat-only reconciliation never re-extracted anything
|
||||
for _, cur := range computed.Entries {
|
||||
old, ok := storedByName[cur.Name]
|
||||
if !ok {
|
||||
return nil, true
|
||||
}
|
||||
if cur.Size == old.Size && cur.MTime == old.MTime {
|
||||
fresh.Entries = append(fresh.Entries, old) // untouched: keep the stored hash
|
||||
continue
|
||||
}
|
||||
if old.Sha == "" {
|
||||
return nil, true // unhashable drift: fall back to re-extract
|
||||
}
|
||||
sha, err := sha256File(filepath.Join(dir, cur.Name))
|
||||
if err != nil || sha != old.Sha {
|
||||
return nil, true
|
||||
}
|
||||
cur.Sha = old.Sha // repoint: content identical, stat pair refreshed
|
||||
fresh.Entries = append(fresh.Entries, cur)
|
||||
}
|
||||
return fresh, false
|
||||
}
|
||||
|
||||
// evReextractBudgetPerScan bounds the parser-version catch-up: at most
|
||||
// this many ev-triggered re-extractions per scan pass. Each one stamps
|
||||
// the fresh manifest with the new ev, so progress is resumable — later
|
||||
// polls pick up where this one stopped, and no single scan floods the
|
||||
// disk with a full-library re-extraction (faceted plan Phase 10).
|
||||
const evReextractBudgetPerScan = 100
|
||||
|
||||
// storeManifest persists the manifest and write-throughs the map entry.
|
||||
// The stored manifest is STAMPED with the setting's current extractor
|
||||
// version: every caller here follows (or is) a full extraction under
|
||||
// the current parser, so the stamp is truthful. Backfill and
|
||||
// extract-failure stores must use storeManifestBaseline instead.
|
||||
func (s *MediaScanner) storeManifest(ctx context.Context, libraryID pgtype.UUID, entry *scanEntry, manifest *sidecarManifest) {
|
||||
manifest.Ev = s.GetExtractorVersion()
|
||||
s.storeManifestBaseline(ctx, libraryID, entry, manifest)
|
||||
}
|
||||
|
||||
// storeManifestBaseline persists a manifest WITHOUT stamping the
|
||||
// extractor version — for stores that follow NO successful extraction:
|
||||
// the deploy-day backfill (the row's provenance is unknown; leaving ev
|
||||
// unset keeps it eligible for a later version bump) and the
|
||||
// extract-failure guard (stamping would mark parser work as done that
|
||||
// never ran; leaving it stale retries on the next poll).
|
||||
func (s *MediaScanner) storeManifestBaseline(ctx context.Context, libraryID pgtype.UUID, entry *scanEntry, manifest *sidecarManifest) {
|
||||
b, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
fmt.Printf("Warning: failed to encode sidecar manifest for %s: %v\n", entry.FilePath, err)
|
||||
return
|
||||
}
|
||||
if err := s.db.UpsertMediaScanState(ctx, database.UpsertMediaScanStateParams{
|
||||
MediaItemID: entry.ID,
|
||||
Manifest: b,
|
||||
}); err != nil {
|
||||
fmt.Printf("Warning: failed to store sidecar manifest for %s: %v\n", entry.FilePath, err)
|
||||
return
|
||||
}
|
||||
entry.Manifest = b
|
||||
s.scanStateUpsert(libraryID, entry)
|
||||
}
|
||||
|
||||
// evBudgetTake consumes one slot of the per-scan ev catch-up budget.
|
||||
// Atomic: the poll scan walks sequentially, but the watcher's
|
||||
// scanDirectory pass can overlap a poll scan (only scanDirectory holds
|
||||
// scanMutex), so the budget is shared state.
|
||||
func (s *MediaScanner) evBudgetTake() bool {
|
||||
for {
|
||||
cur := s.evBudgetRemaining.Load()
|
||||
if cur <= 0 {
|
||||
return false
|
||||
}
|
||||
if s.evBudgetRemaining.CompareAndSwap(cur, cur-1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// detectSidecarChange fingerprints the book's sidecar directory and
|
||||
// reconciles against the stored manifest. Returns true when the item's
|
||||
// metadata must be re-extracted (a sidecar's CONTENT changed, or
|
||||
// sidecars appeared/vanished). Backfill and repoint reconciliation are
|
||||
// handled here without re-extraction; the caller stores a fresh full
|
||||
// manifest after a change-path update.
|
||||
func (s *MediaScanner) detectSidecarChange(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, path string) bool {
|
||||
dir := bookSidecarDir(path)
|
||||
return s.detectSidecarChangeWithRepresentative(ctx, entry, libraryID, path, dir)
|
||||
}
|
||||
|
||||
// detectSidecarChangeWithRepresentative is detectSidecarChange with the
|
||||
// sidecar-name derivation decoupled from the map key. Multi-file books
|
||||
// (faceted plan Phase 6) are keyed by the item's file_path (the folder
|
||||
// root / zip), but the extractor reads a folder book's sidecars through
|
||||
// the sentinel INSIDE the root — whose basename yields no "<base>.<ext>"
|
||||
// cover candidates — so the manifest must stat the representative's name
|
||||
// list to never disagree with what extraction consumes.
|
||||
func (s *MediaScanner) detectSidecarChangeWithRepresentative(ctx context.Context, entry *scanEntry, libraryID pgtype.UUID, representative, dir string) bool {
|
||||
computed := statSidecarManifest(representative, dir)
|
||||
stored := decodeSidecarManifest(entry.Manifest)
|
||||
|
||||
// Extractor-version gate (faceted plan Phase 10): a manifest stamped
|
||||
// by an older parser re-extracts even when every sidecar byte is
|
||||
// unchanged — the fingerprint says "disk same", ev says "parser
|
||||
// newer". Budget-bounded: over budget → fall through, the stale ev
|
||||
// stays stored, a later poll resumes. Checked FIRST so books with
|
||||
// no sidecars (nothing to reconcile) are still caught up.
|
||||
if stored != nil && manifestEv(stored) != s.GetExtractorVersion() && s.evBudgetTake() {
|
||||
return true
|
||||
}
|
||||
|
||||
if len(computed.Entries) == 0 {
|
||||
if stored != nil && len(stored.Entries) > 0 {
|
||||
return true // every sidecar was removed — fall back to embedded
|
||||
}
|
||||
if stored == nil {
|
||||
// Never had sidecars: nothing to fingerprint, but the book
|
||||
// still needs an ev home so future parser bumps see it. A
|
||||
// first-sighting store follows no extraction → baseline
|
||||
// (ev unset → eligible for the next bump).
|
||||
s.storeManifestBaseline(ctx, libraryID, entry, &sidecarManifest{Version: sidecarManifestVersion})
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if stored == nil {
|
||||
// Deploy-day backfill: hash + store WITHOUT re-extraction. The
|
||||
// row was extracted by an unknown parser version — the stamp is
|
||||
// deliberately OMITTED (ev unset → "v1-or-unknown") so a later
|
||||
// scan_extractor_version bump still catches it.
|
||||
s.storeManifestBaseline(ctx, libraryID, entry, hashSidecarManifest(representative, dir, computed))
|
||||
return false
|
||||
}
|
||||
|
||||
fresh, changed := reconcileSidecarManifest(dir, stored, computed)
|
||||
if changed {
|
||||
return true
|
||||
}
|
||||
if fresh != nil {
|
||||
// Repoint reconciliation refreshed stat pairs only — the row was
|
||||
// NOT re-extracted, so the manifest must keep the row's stored
|
||||
// ev (a stamp here would claim parser work that never ran;
|
||||
// possible when the ev budget was exhausted this pass).
|
||||
s.storeManifestBaseline(ctx, libraryID, entry, fresh)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package services
|
||||
|
||||
// Unit tests for the sidecar manifest ladder (faceted plan Part 2 §3):
|
||||
// stat-pair fast compare, hash-based repoint reconciliation, and the
|
||||
// change verdicts. Real files only where the hash compare needs them.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDecodeSidecarManifest(t *testing.T) {
|
||||
if decodeSidecarManifest(nil) != nil {
|
||||
t.Error("NULL manifest decodes to nil (backfill territory)")
|
||||
}
|
||||
if decodeSidecarManifest([]byte("{not json")) != nil {
|
||||
t.Error("corrupt manifest decodes to nil (self-healing backfill)")
|
||||
}
|
||||
m := decodeSidecarManifest([]byte(`{"v":1,"entries":[{"n":"metadata.opf","s":10,"m":100,"h":"abc"}]}`))
|
||||
if m == nil || len(m.Entries) != 1 || m.Entries[0].Sha != "abc" {
|
||||
t.Errorf("valid manifest round-trip failed: %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatSidecarManifestSentinelNames pins the Phase 6 name derivation
|
||||
// for folder books: the manifest must stat exactly the names the
|
||||
// extractor consumes through the sentinel (metadata sidecars + the
|
||||
// standard cover set) — never the directory's own basename (the
|
||||
// extractor's basename-pattern covers resolve against the sentinel's
|
||||
// basename, which generates no candidates).
|
||||
func TestStatSidecarManifestSentinelNames(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("metadata.json")
|
||||
write("cover.jpg")
|
||||
write(filepath.Base(dir) + ".jpg") // extractor never reads this
|
||||
sentinel := filepath.Join(dir, ".audiobook-root")
|
||||
|
||||
m := statSidecarManifest(sentinel, dir)
|
||||
if m == nil {
|
||||
t.Fatal("manifest must stat, not nil")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, e := range m.Entries {
|
||||
seen[e.Name] = true
|
||||
}
|
||||
if !seen["metadata.json"] || !seen["cover.jpg"] {
|
||||
t.Errorf("extractor-consumed sidecars missing from manifest: %v", seen)
|
||||
}
|
||||
if seen[filepath.Base(dir)+".jpg"] {
|
||||
t.Errorf("directory-basename cover must not be tracked (extractor ignores it): %v", seen)
|
||||
}
|
||||
for name := range seen {
|
||||
if name == ".audiobook-root.jpg" {
|
||||
t.Errorf("sentinel-basename candidates must not appear (they cannot exist): %v", seen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReconcileSidecarManifest(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(name, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The book: one hashed sidecar + one size-only cover.
|
||||
write("metadata.opf", `<opf>original content</opf>`)
|
||||
originalSha, err := sha256File(filepath.Join(dir, "metadata.opf"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write("cover.jpg", "coverbytes")
|
||||
// stored is seeded exactly the way production backfill does: a real
|
||||
// stat pass + hash fill. Hand-built fake mtimes would drift on the
|
||||
// first compare (real files' mtimes never equal a constant).
|
||||
stored := hashSidecarManifest("book.epub", dir, statSidecarManifest(filepath.Join(dir, "book.epub"), dir))
|
||||
|
||||
stat := func() *sidecarManifest {
|
||||
m := statSidecarManifest(filepath.Join(dir, "book.epub"), dir)
|
||||
if m == nil || len(m.Entries) < 2 {
|
||||
t.Fatalf("stat manifest should find both sidecars, got %+v", m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Stat pairs identical → unchanged, zero reads, nothing to store.
|
||||
fresh, changed := reconcileSidecarManifest(dir, stored, stat())
|
||||
if changed || fresh != nil {
|
||||
t.Errorf("identical stats must be unchanged, got changed=%v fresh=%v", changed, fresh)
|
||||
}
|
||||
|
||||
// Repoint: mtime drifts, content identical → reconciled fresh
|
||||
// manifest with the refreshed stat pair and the KEPT hash, no change.
|
||||
if err := os.Chtimes(filepath.Join(dir, "metadata.opf"), time.Now(), time.Now().Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fresh, changed = reconcileSidecarManifest(dir, stored, stat())
|
||||
if changed {
|
||||
t.Error("mtime drift with identical content is repoint reconciliation, not a change")
|
||||
}
|
||||
if fresh == nil || len(fresh.Entries) != 2 {
|
||||
t.Fatalf("repoint must produce a refreshed manifest, got %+v", fresh)
|
||||
}
|
||||
if fresh.Entries[0].Sha != originalSha {
|
||||
t.Error("repointed entry keeps its stored hash")
|
||||
}
|
||||
if fresh.Entries[0].MTime == stored.Entries[0].MTime {
|
||||
t.Error("repointed entry refreshes its stat pair")
|
||||
}
|
||||
|
||||
// Same-size edit: drift + hash differs → changed.
|
||||
write("metadata.opf", `<opf>mutated XYY content</opf>`) // +1 byte, and hash differs
|
||||
_, changed = reconcileSidecarManifest(dir, stored, stat())
|
||||
if !changed {
|
||||
t.Error("content drift must be a change")
|
||||
}
|
||||
|
||||
// Same-LENGTH edit (the size-only detection blind spot) → changed.
|
||||
write("metadata.opf", `<opf>original Xontent</opf>`)
|
||||
_, changed = reconcileSidecarManifest(dir, stored, stat())
|
||||
if !changed {
|
||||
t.Error("same-size content drift must be a change (hash compare)")
|
||||
}
|
||||
|
||||
// New sidecar appears → changed.
|
||||
write("metadata.json", `{"title":"X"}`)
|
||||
_, changed = reconcileSidecarManifest(dir, stored, stat())
|
||||
if !changed {
|
||||
t.Error("an appearing sidecar must be a change")
|
||||
}
|
||||
|
||||
// Sidecar vanishes AND everything else returns to the stored state
|
||||
// (opf restored; its mtime drifts but the hash proves equality) →
|
||||
// the manifest reconciles, not a change.
|
||||
if err := os.Remove(filepath.Join(dir, "metadata.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write("metadata.opf", `<opf>original content</opf>`)
|
||||
fresh, changed = reconcileSidecarManifest(dir, stored, stat())
|
||||
if changed {
|
||||
t.Errorf("returning to the stored state is not a change: %+v", fresh)
|
||||
}
|
||||
if fresh == nil || len(fresh.Entries) != 2 {
|
||||
t.Errorf("reconciled manifest covers both sidecars: %+v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
// An unhashable drift (a stored entry with no baseline hash — legacy
|
||||
// or partially-written manifest) is a change: the manifest cannot prove
|
||||
// content equality for it.
|
||||
func TestReconcileUnhashableDrift(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
stored := &sidecarManifest{Version: 1, Entries: []manifestSidecar{
|
||||
{Name: "cover.jpg", Size: 10, MTime: 1000},
|
||||
}}
|
||||
computed := &sidecarManifest{Version: 1, Entries: []manifestSidecar{
|
||||
{Name: "cover.jpg", Size: 11, MTime: 1001},
|
||||
}}
|
||||
fresh, changed := reconcileSidecarManifest(dir, stored, computed)
|
||||
if !changed {
|
||||
t.Error("stat drift without a stored baseline hash must be a change")
|
||||
}
|
||||
if fresh != nil {
|
||||
t.Error("a change never returns a fresh manifest (caller builds one)")
|
||||
}
|
||||
}
|
||||
@@ -139,8 +139,8 @@ func TestParseOPFContentCalibreSeries(t *testing.T) {
|
||||
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.Series != "Great Series" || metadata.SeriesNumber != 2.5 {
|
||||
t.Errorf("Series = %q/%v, want Great Series/2.5", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("schemed ISBN not extracted")
|
||||
@@ -179,8 +179,8 @@ func TestExtractAudiobookshelfSidecar(t *testing.T) {
|
||||
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 m.Series != "The Series" || m.SeriesNumber != 4.5 {
|
||||
t.Errorf("Series = %q/%v, want The Series/4.5", m.Series, m.SeriesNumber)
|
||||
}
|
||||
if len(m.Tags) != 2 {
|
||||
t.Errorf("Tags = %v, want genres+tags merged", m.Tags)
|
||||
|
||||
@@ -171,8 +171,8 @@ func TestParseOPFContentTitleTypeAndSeries(t *testing.T) {
|
||||
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.Series != "Great Series" || metadata.SeriesNumber != 4.5 {
|
||||
t.Errorf("Series = %q/%v, want Great Series/4.5", metadata.Series, metadata.SeriesNumber)
|
||||
}
|
||||
if metadata.ISBN == "" {
|
||||
t.Error("urn:isbn: identifier not extracted")
|
||||
|
||||
@@ -30,3 +30,37 @@ func TestMediaScanner_GetAutoScanEnabled(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMediaScanner_GetExtractorVersion(t *testing.T) {
|
||||
t.Run("nil db returns default 1", func(t *testing.T) {
|
||||
scanner := &MediaScanner{
|
||||
db: nil,
|
||||
settingsCache: NewSettingsCache(30 * time.Second),
|
||||
}
|
||||
if v := scanner.GetExtractorVersion(); v != 1 {
|
||||
t.Errorf("expected 1, got %d", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cached value wins (scanner read-through path)", func(t *testing.T) {
|
||||
scanner := &MediaScanner{
|
||||
db: nil,
|
||||
settingsCache: NewSettingsCache(30 * time.Second),
|
||||
}
|
||||
scanner.settingsCache.Set("scan_extractor_version", "3")
|
||||
if v := scanner.GetExtractorVersion(); v != 3 {
|
||||
t.Errorf("expected 3, got %d", v)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cached garbage rides the default", func(t *testing.T) {
|
||||
scanner := &MediaScanner{
|
||||
db: nil,
|
||||
settingsCache: NewSettingsCache(30 * time.Second),
|
||||
}
|
||||
scanner.settingsCache.Set("scan_extractor_version", "zero")
|
||||
if v := scanner.GetExtractorVersion(); v != 1 {
|
||||
t.Errorf("expected 1, got %d", v)
|
||||
}
|
||||
})
|
||||
}
|
||||
+211
-15
@@ -2,8 +2,10 @@ package services
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/utils"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -12,12 +14,16 @@ import (
|
||||
|
||||
// SearchService handles all search and filter operations
|
||||
type SearchService struct {
|
||||
db *database.Queries
|
||||
db *database.Queries
|
||||
raw database.DBTX // the raw pgx handle — the composed filter engines' path
|
||||
}
|
||||
|
||||
// NewSearchService creates a new search service instance
|
||||
func NewSearchService(db *database.Queries) *SearchService {
|
||||
return &SearchService{db: db}
|
||||
// NewSearchService creates a new search service instance. raw is the
|
||||
// same pool db wraps (database.DBTX) — the composed filter engines run
|
||||
// hand-built, allow-listed SQL with bound parameters, which static sqlc
|
||||
// queries cannot express.
|
||||
func NewSearchService(db *database.Queries, raw database.DBTX) *SearchService {
|
||||
return &SearchService{db: db, raw: raw}
|
||||
}
|
||||
|
||||
// SearchParams contains parameters for unified search
|
||||
@@ -38,6 +44,26 @@ type SearchParams struct {
|
||||
Sort string
|
||||
Limit int
|
||||
Offset int
|
||||
|
||||
// Facet registry Batch A additions (services/facet_registry.go).
|
||||
// Empty strings / invalid Bools mean "facet not active".
|
||||
StoryArcFilter string
|
||||
ImprintFilter string
|
||||
EditionFilter string
|
||||
AgeRatingFilter string
|
||||
FormatGroupFilter string
|
||||
MangaTypeFilter string
|
||||
ReadingDirectionFilter string
|
||||
ExplicitFilter pgtype.Bool
|
||||
AbridgedFilter pgtype.Bool
|
||||
|
||||
// Filter rule builder (faceted plan Phase 9). When FilterTree is
|
||||
// set the composed engine runs INSTEAD of the flat facet params
|
||||
// (qf wins over flat on a hand-crafted URL — precedence pinned by
|
||||
// test); FilterParseErr carries a qf parse/validation failure for
|
||||
// the endpoints to answer 400.
|
||||
FilterTree *FilterTree
|
||||
FilterParseErr string
|
||||
}
|
||||
|
||||
// parseSearchQuery detects quoted strings for exact match search
|
||||
@@ -63,6 +89,14 @@ func (s *SearchService) parseSearchQuery(query string) (bool, string) {
|
||||
func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, error) {
|
||||
// Parse search query for exact match detection
|
||||
isExact, searchQuery := s.parseSearchQuery(params.SearchQuery)
|
||||
|
||||
// Language filter UX: the UI sends display names ("English"); the
|
||||
// column stores ISO codes ("en"). Normalize each comma element the
|
||||
// SAME way ingest does (normalizeLanguageCode), so a user-typed or
|
||||
// deep-linked display name matches the stored code — and unknown
|
||||
// values that fit still match verbatim rows. (Facet matching and
|
||||
// ingest can never disagree because both run the same map.)
|
||||
params.LanguageFilter = normalizeLanguageFilter(params.LanguageFilter)
|
||||
// For now implementing real exact search. If you want to reimplement wildcard patterns uncomment this and line 81 and check queries.sql line 475 and frontend.go line 211
|
||||
// searchPattern := ""
|
||||
// if isExact {
|
||||
@@ -79,11 +113,24 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
|
||||
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
|
||||
LanguageFilter: pgtype.Text{String: params.LanguageFilter, Valid: true},
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||
HasCover: params.HasCover,
|
||||
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
|
||||
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
|
||||
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
|
||||
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
|
||||
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
|
||||
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
|
||||
FormatGroupFilter: pgtype.Text{
|
||||
String: params.FormatGroupFilter, Valid: true,
|
||||
},
|
||||
MangaTypeFilter: pgtype.Text{String: params.MangaTypeFilter, Valid: true},
|
||||
ReadingDirectionFilter: pgtype.Text{
|
||||
String: params.ReadingDirectionFilter, Valid: true,
|
||||
},
|
||||
ExplicitFilter: params.ExplicitFilter,
|
||||
AbridgedFilter: params.AbridgedFilter,
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||
HasCover: params.HasCover,
|
||||
SearchQuery: pgtype.Text{String: searchQuery, Valid: true},
|
||||
IsExactSearch: pgtype.Bool{Bool: isExact, Valid: true},
|
||||
// SearchPattern: pgtype.Text{String: searchPattern, Valid: isExact},
|
||||
Sort: pgtype.Text{String: params.Sort, Valid: true},
|
||||
Limit: pgtype.Int4{Int32: int32(params.Limit), Valid: true},
|
||||
@@ -103,6 +150,130 @@ func (s *SearchService) SearchMediaItemsUnified(ctx context.Context, params Sear
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// normalizeLanguageFilter normalizes every comma element of a language
|
||||
// filter value through the ingest-side language map (display name →
|
||||
// ISO code), so filtering and ingest share one source of truth.
|
||||
func normalizeLanguageFilter(wireValue string) string {
|
||||
if wireValue == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(wireValue, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, normalizeLanguageCode(t))
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
// NormalizeLanguageFilter is the exported form of the search-path
|
||||
// normalization for callers that must compare RAW filter values
|
||||
// against stored codes without issuing a query (the facet panel marks
|
||||
// a value Active by matching the bound filter set).
|
||||
func NormalizeLanguageFilter(wireValue string) string {
|
||||
return normalizeLanguageFilter(wireValue)
|
||||
}
|
||||
|
||||
// FacetValue is one facet value + count for the filter panel.
|
||||
type FacetValue struct {
|
||||
Value string `json:"value"` // the raw value (rides the filter param)
|
||||
// Display is the prettified form when it differs from Value
|
||||
// (language codes → names); empty → the client shows Value.
|
||||
Display string `json:"display,omitempty"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
// facetCountsLimit is the per-facet top-N bound the panel renders.
|
||||
const facetCountsLimit = 50
|
||||
|
||||
// FacetCounts computes every listable facet's top values + counts under
|
||||
// the active filters (faceted plan Phase 7): ONE UNION-ALL round trip
|
||||
// (FacetCountsUnderFilters) in which each facet is counted under the
|
||||
// OTHER facets' filters — the SQL omits each arm's own block, classic
|
||||
// faceted-search semantics. With no filters bound every block is
|
||||
// neutral and the counts are the v1 global-per-library ones, so one
|
||||
// query serves both cases. libraryID may be NULL (the All-Libraries
|
||||
// view). Display enrichment matches the wire contract: language codes
|
||||
// → display names, genre values prettified — both only when they
|
||||
// differ from the raw value (Display is "the prettified form when it
|
||||
// differs", per FacetValue).
|
||||
func (s *SearchService) FacetCounts(ctx context.Context, userID, libraryID pgtype.UUID, params SearchParams, limit int) (map[string][]FacetValue, error) {
|
||||
if limit <= 0 {
|
||||
limit = facetCountsLimit
|
||||
}
|
||||
// Language filter UX (same normalization the search path runs —
|
||||
// see SearchMediaItemsUnified): display names in, ISO codes bound.
|
||||
langFilter := normalizeLanguageFilter(params.LanguageFilter)
|
||||
|
||||
rows, err := s.db.FacetCountsUnderFilters(ctx, database.FacetCountsUnderFiltersParams{
|
||||
UserID: userID,
|
||||
LibraryID: libraryID,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
AuthorFilter: pgtype.Text{String: params.AuthorFilter, Valid: true},
|
||||
SeriesFilter: pgtype.Text{String: params.SeriesFilter, Valid: true},
|
||||
GenreFilter: pgtype.Text{String: params.GenreFilter, Valid: true},
|
||||
PublisherFilter: pgtype.Text{String: params.PublisherFilter, Valid: true},
|
||||
TagsFilter: pgtype.Text{String: params.TagsFilter, Valid: true},
|
||||
NarratorFilter: pgtype.Text{String: params.NarratorFilter, Valid: true},
|
||||
LanguageFilter: pgtype.Text{String: langFilter, Valid: true},
|
||||
StoryArcFilter: pgtype.Text{String: params.StoryArcFilter, Valid: true},
|
||||
ImprintFilter: pgtype.Text{String: params.ImprintFilter, Valid: true},
|
||||
EditionFilter: pgtype.Text{String: params.EditionFilter, Valid: true},
|
||||
AgeRatingFilter: pgtype.Text{String: params.AgeRatingFilter, Valid: true},
|
||||
FormatGroupFilter: pgtype.Text{String: params.FormatGroupFilter, Valid: true},
|
||||
MangaTypeFilter: pgtype.Text{String: params.MangaTypeFilter, Valid: true},
|
||||
ReadingDirectionFilter: pgtype.Text{String: params.ReadingDirectionFilter, Valid: true},
|
||||
ExplicitFilter: params.ExplicitFilter,
|
||||
AbridgedFilter: params.AbridgedFilter,
|
||||
YearMin: pgtype.Int4{Int32: int32(params.YearMin), Valid: true},
|
||||
YearMax: pgtype.Int4{Int32: int32(params.YearMax), Valid: true},
|
||||
HasCover: params.HasCover,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make(map[string][]FacetValue, 14)
|
||||
for _, r := range rows {
|
||||
v := FacetValue{Value: r.Value, Count: r.Count}
|
||||
switch r.Facet {
|
||||
case "language":
|
||||
if name := LanguageDisplayName(r.Value); name != "" && name != r.Value {
|
||||
v.Display = name
|
||||
}
|
||||
case "genre":
|
||||
if d := utils.PrettifyGenre(r.Value); d != r.Value {
|
||||
v.Display = d
|
||||
}
|
||||
}
|
||||
out[r.Facet] = append(out[r.Facet], v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FacetValues lists ONE facet's top values + counts — the pinned
|
||||
// single-facet shape behind GET /api/media-items/facets?facet=X. It is
|
||||
// the all-facets query with every filter neutral (v1 counts:
|
||||
// global-per-library, not recomputed under other filters).
|
||||
// Registry-driven: only FacetArray and FacetScalar facets list values —
|
||||
// bool/range facets render as toggles/ranges in the panel, not value
|
||||
// lists.
|
||||
func (s *SearchService) FacetValues(ctx context.Context, userID pgtype.UUID, facet Facet, libraryID pgtype.UUID, limit int) ([]FacetValue, error) {
|
||||
if facet.Kind != FacetArray && facet.Kind != FacetScalar {
|
||||
return nil, fmt.Errorf("facet %q does not list values", facet.Param)
|
||||
}
|
||||
counts, err := s.FacetCounts(ctx, userID, libraryID, SearchParams{}, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values := counts[facet.Param]
|
||||
if values == nil {
|
||||
return []FacetValue{}, nil
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// FieldSearchParams contains parameters for field-specific search (autocomplete)
|
||||
type FieldSearchParams struct {
|
||||
UserID pgtype.UUID
|
||||
@@ -113,11 +284,15 @@ type FieldSearchParams struct {
|
||||
Offset int
|
||||
}
|
||||
|
||||
// FieldValue represents a single field value with metadata
|
||||
// FieldValue is a single field value with metadata. Display carries a
|
||||
// human label when the wire value is an ID (library/collection/added_by
|
||||
// suggestions) — omitted everywhere else, so the classic static shape
|
||||
// {value, count, score} is unchanged.
|
||||
type FieldValue struct {
|
||||
Value string `json:"value"`
|
||||
Count int64 `json:"count"`
|
||||
Score float64 `json:"score"`
|
||||
Value string `json:"value"`
|
||||
Count int64 `json:"count"`
|
||||
Score float64 `json:"score"`
|
||||
Display string `json:"display,omitempty"`
|
||||
}
|
||||
|
||||
// SearchFieldValues handles field-specific search for autocomplete dropdowns
|
||||
@@ -223,7 +398,14 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
|
||||
}
|
||||
fieldValues := make([]FieldValue, len(results))
|
||||
for i, r := range results {
|
||||
fieldValues[i] = FieldValue{Value: r.Value.String, Count: r.Count, Score: r.Score}
|
||||
// Serve display names ("English") — the user clicks a name,
|
||||
// the search service normalizes it back to the stored ISO
|
||||
// code ("en"). Unknown codes pass through verbatim.
|
||||
shown := r.Value.String
|
||||
if name := LanguageDisplayName(shown); name != "" {
|
||||
shown = name
|
||||
}
|
||||
fieldValues[i] = FieldValue{Value: shown, Count: r.Count, Score: r.Score}
|
||||
}
|
||||
return fieldValues, nil
|
||||
|
||||
@@ -233,8 +415,22 @@ func (s *SearchService) SearchFieldValues(ctx context.Context, params FieldSearc
|
||||
}
|
||||
|
||||
// ExecuteSearch performs search and returns results with count
|
||||
// Shared between JSON endpoint and HTML rendering
|
||||
// Shared between JSON endpoint and HTML rendering.
|
||||
// Routing: a qf filter tree → the composed engine (flat params ignored
|
||||
// — qf wins); else the static unified query. A parse/validation failure
|
||||
// surfaces as ErrInvalidFilterTree (→ 400 at the endpoints).
|
||||
func (s *SearchService) ExecuteSearch(ctx context.Context, params SearchParams) ([]database.SearchMediaItemsUnifiedRow, int, error) {
|
||||
if params.FilterParseErr != "" {
|
||||
return nil, 0, fmt.Errorf("%w: %s", ErrInvalidFilterTree, params.FilterParseErr)
|
||||
}
|
||||
if params.FilterTree != nil {
|
||||
rows, err := s.ComposedSearch(ctx, params)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, err
|
||||
}
|
||||
count := len(rows)
|
||||
return rows, count, nil
|
||||
}
|
||||
results, err := s.SearchMediaItemsUnified(ctx, params)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, 0, err
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Series-metadata parsing helpers for the scanner (A2c overhaul).
|
||||
//
|
||||
// Sources disagree wildly on how a series and its ordinal are spelled:
|
||||
// ABS sidecars write ["Name #3"] string arrays or [{series, sequence}]
|
||||
// objects, Libation writes a bare string, Audible prefixes ordinals with
|
||||
// "Book ", Calibre uses decimal indices. These helpers normalize all of
|
||||
// them into candidates the scanner can rank and assign.
|
||||
|
||||
// SeriesCandidate is one parsed series membership from any source.
|
||||
type SeriesCandidate struct {
|
||||
Name string
|
||||
Sequence string // raw sequence text ("3", "1.5", "1a", "Book 2")
|
||||
Number float64 // leading number of Sequence (0 = none parsed)
|
||||
HasNum bool
|
||||
FromSidecar bool
|
||||
}
|
||||
|
||||
// Playlist markers that identify ordering constructs rather than
|
||||
// bibliographic series. Deliberately explicit: this classification routes
|
||||
// entries away from the series column into curated collections, so a false
|
||||
// positive creates user-visible objects. Prefer misses over false hits.
|
||||
var playlistMarkers = []string{
|
||||
"reading order",
|
||||
"chronological order",
|
||||
"publication order",
|
||||
"in order",
|
||||
"timeline",
|
||||
}
|
||||
|
||||
var (
|
||||
seriesSeqSuffixRe = regexp.MustCompile(`\s+#([^#\s]+)$`)
|
||||
// Leading ordinal inside a sequence string: "3", "1.5", "Book 2",
|
||||
// "2, Dramatized Adaptation" → 2. Mirrors ABS cleanSeriesSequence.
|
||||
seriesLeadingNumRe = regexp.MustCompile(`\d+(?:\.\d+)?`)
|
||||
// "<Series Name>, Book N" (also ; – : separators), Audible/Amazon style.
|
||||
deriveSeriesRe = regexp.MustCompile(`(?i)^\s*(.+?)\s*[,;:–-]\s*book\s+(\d+(?:\.\d+)?)\s*$`)
|
||||
// Conservative "Title: <name>, Book N" split — only when the post-colon
|
||||
// text is itself a series-and-number tail.
|
||||
titleSubtitleSplitRe = regexp.MustCompile(`(?i)^(.{1,200}?)\s*:\s*(.+,\s*book\s+\d+(?:\.\d+)?)$`)
|
||||
whitespaceRe = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// splitSeriesString splits "Name #1a" into name + sequence, ABS
|
||||
// parseSeriesString-compatible: the sequence is anything after the last
|
||||
// " #" up to end-of-string, containing no whitespace or further '#'.
|
||||
func splitSeriesString(s string) (name, sequence string) {
|
||||
s = strings.TrimSpace(s)
|
||||
loc := seriesSeqSuffixRe.FindStringSubmatchIndex(s)
|
||||
if loc == nil {
|
||||
return s, ""
|
||||
}
|
||||
return strings.TrimSpace(s[:loc[0]]), s[loc[2]:loc[3]]
|
||||
}
|
||||
|
||||
// seriesSequenceNumber extracts the ordinal from a raw sequence string.
|
||||
// Returns the number, whether one was found, and whether information was
|
||||
// lost in the extraction (caller should preserve the raw text, e.g. "1a").
|
||||
func seriesSequenceNumber(sequence string) (num float64, found, lossy bool) {
|
||||
trimmed := strings.TrimSpace(sequence)
|
||||
if trimmed == "" {
|
||||
return 0, false, false
|
||||
}
|
||||
token := seriesLeadingNumRe.FindString(trimmed)
|
||||
if token == "" {
|
||||
return 0, false, false
|
||||
}
|
||||
num, err := strconv.ParseFloat(token, 64)
|
||||
if err != nil {
|
||||
return 0, false, false
|
||||
}
|
||||
// "1.5" tokenizes exactly (not lossy); "1a"/"Book 2" keep a raw tail.
|
||||
return num, true, trimmed != token
|
||||
}
|
||||
|
||||
// isPlaylistSeriesName reports whether the series name is an ordering
|
||||
// construct ("The Witcher (Reading Order)") rather than a bibliography.
|
||||
// Match discipline: the marker must be a parenthetical qualifier or a
|
||||
// trailing phrase — a series merely CONTAINING the word "timeline" mid-name
|
||||
// ("The Timeline of...") is a real series, not a playlist.
|
||||
func isPlaylistSeriesName(name string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(name))
|
||||
for _, m := range playlistMarkers {
|
||||
if strings.HasSuffix(lower, m) {
|
||||
return true
|
||||
}
|
||||
if open := strings.Index(lower, "("); open >= 0 {
|
||||
if strings.Contains(lower[open:], m) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseSeriesCandidates normalizes the sidecar `series` field, which is
|
||||
// written as an array of STRINGS ("Name #3" — ABS abmetadata), an array of
|
||||
// OBJECTS ({series, sequence} — some exports), or a plain JSON string
|
||||
// (Libation), into candidates.
|
||||
func parseSeriesCandidates(raw json.RawMessage) []SeriesCandidate {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
if trimmed == "" || trimmed == "null" {
|
||||
return nil
|
||||
}
|
||||
var out []SeriesCandidate
|
||||
|
||||
add := func(name, sequence string, fromSidecar bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
num, found, _ := seriesSequenceNumber(sequence)
|
||||
out = append(out, SeriesCandidate{
|
||||
Name: name, Sequence: sequence, Number: num, HasNum: found,
|
||||
FromSidecar: fromSidecar,
|
||||
})
|
||||
}
|
||||
|
||||
if strings.HasPrefix(trimmed, "[") {
|
||||
// Array of objects first ({"series": ..., "sequence": ...}).
|
||||
var objs []struct {
|
||||
Series string `json:"series"`
|
||||
Name string `json:"name"`
|
||||
Sequence string `json:"sequence"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &objs); err == nil && len(objs) > 0 {
|
||||
for _, o := range objs {
|
||||
name := o.Series
|
||||
if name == "" {
|
||||
name = o.Name
|
||||
}
|
||||
add(name, o.Sequence, true)
|
||||
}
|
||||
return out
|
||||
}
|
||||
// Array of strings: ABS abmetadata form "Name #3".
|
||||
var strs []string
|
||||
if err := json.Unmarshal(raw, &strs); err == nil {
|
||||
for _, s := range strs {
|
||||
name, seq := splitSeriesString(s)
|
||||
add(name, seq, true)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Plain JSON string (Libation): "Name" or "Name #3".
|
||||
var single string
|
||||
if err := json.Unmarshal(raw, &single); err == nil {
|
||||
name, seq := splitSeriesString(single)
|
||||
add(name, seq, true)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// deriveSeriesFromText extracts "<series>, Book N" from a title/subtitle
|
||||
// string (Audible/Amazon convention). Returns ok=false when the text is not
|
||||
// obviously a series tail — this fallback must not guess.
|
||||
func deriveSeriesFromText(text string) (name string, num float64, hasNum bool, ok bool) {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "", 0, false, false
|
||||
}
|
||||
m := deriveSeriesRe.FindStringSubmatch(text)
|
||||
if m == nil {
|
||||
return "", 0, false, false
|
||||
}
|
||||
name = whitespaceRe.ReplaceAllString(strings.TrimSpace(m[1]), " ")
|
||||
if name == "" || len(name) > 200 || strings.HasPrefix(strings.ToLower(name), "book ") {
|
||||
return "", 0, false, false
|
||||
}
|
||||
num, err := strconv.ParseFloat(m[2], 64)
|
||||
if err != nil {
|
||||
return "", 0, false, false
|
||||
}
|
||||
return name, num, true, true
|
||||
}
|
||||
|
||||
// splitTitleOnSubtitle splits "Title: <name>, Book N" into title +
|
||||
// subtitle, but ONLY when the post-colon text is itself a series-and-number
|
||||
// tail — arbitrary colons in titles are never touched.
|
||||
func splitTitleOnSubtitle(folderTitle string) (title, subtitle string, split bool) {
|
||||
m := titleSubtitleSplitRe.FindStringSubmatch(strings.TrimSpace(folderTitle))
|
||||
if m == nil {
|
||||
return folderTitle, "", false
|
||||
}
|
||||
return strings.TrimSpace(m[1]), strings.TrimSpace(m[2]), true
|
||||
}
|
||||
|
||||
// normalizeLanguageCode maps sidecar/display language names to ISO 639-1
|
||||
// codes (the media_items.language convention). Values already in code form
|
||||
// pass through; unknown display names are kept verbatim when they fit the
|
||||
// VARCHAR(10) column, else dropped (empty) — never truncate mid-word.
|
||||
func normalizeLanguageCode(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
if code, ok := languageNameToCode[strings.ToLower(v)]; ok {
|
||||
return code
|
||||
}
|
||||
lower := strings.ToLower(v)
|
||||
if len(lower) == 2 {
|
||||
return lower // already a code (or close enough to be harmless)
|
||||
}
|
||||
if len(v) <= 10 {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// languageCodeToName — canonical English display name per ISO code,
|
||||
// mirroring languageNameToCode (server owns display; the column stores
|
||||
// ISO 639-1).
|
||||
var languageCodeToName = map[string]string{
|
||||
"en": "English", "es": "Spanish", "fr": "French", "de": "German",
|
||||
"it": "Italian", "pt": "Portuguese", "nl": "Dutch", "ru": "Russian",
|
||||
"uk": "Ukrainian", "pl": "Polish", "cs": "Czech", "sk": "Slovak",
|
||||
"sv": "Swedish", "no": "Norwegian", "da": "Danish", "fi": "Finnish",
|
||||
"is": "Icelandic", "hu": "Hungarian", "ro": "Romanian", "bg": "Bulgarian",
|
||||
"el": "Greek", "tr": "Turkish", "ar": "Arabic", "he": "Hebrew",
|
||||
"fa": "Persian", "hi": "Hindi", "bn": "Bengali", "ur": "Urdu",
|
||||
"th": "Thai", "vi": "Vietnamese", "id": "Indonesian", "ms": "Malay",
|
||||
"ja": "Japanese", "ko": "Korean", "zh": "Chinese", "sw": "Swahili",
|
||||
"af": "Afrikaans", "ca": "Catalan", "hr": "Croatian", "sr": "Serbian",
|
||||
"sl": "Slovenian", "lt": "Lithuanian", "lv": "Latvian", "et": "Estonian",
|
||||
"ka": "Georgian", "hy": "Armenian", "cy": "Welsh", "ga": "Irish",
|
||||
"eu": "Basque", "gl": "Galician",
|
||||
}
|
||||
|
||||
// LanguageDisplayName renders a stored language value for DISPLAY.
|
||||
// Returns "" when the value is not a known code — callers keep the raw
|
||||
// value then (legacy verbatim rows like "Pig Latin" display as
|
||||
// themselves).
|
||||
func LanguageDisplayName(code string) string {
|
||||
if name, ok := languageCodeToName[strings.ToLower(strings.TrimSpace(code))]; ok {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var languageNameToCode = map[string]string{
|
||||
"english": "en", "spanish": "es", "español": "es", "french": "fr",
|
||||
"français": "fr", "german": "de", "deutsch": "de", "italian": "it",
|
||||
"italiano": "it", "portuguese": "pt", "dutch": "nl", "russian": "ru",
|
||||
"ukrainian": "uk", "polish": "pl", "czech": "cs", "slovak": "sk",
|
||||
"swedish": "sv", "norwegian": "no", "danish": "da", "finnish": "fi",
|
||||
"icelandic": "is", "hungarian": "hu", "romanian": "ro", "bulgarian": "bg",
|
||||
"greek": "el", "turkish": "tr", "arabic": "ar", "hebrew": "he",
|
||||
"persian": "fa", "hindi": "hi", "bengali": "bn", "urdu": "ur",
|
||||
"thai": "th", "vietnamese": "vi", "indonesian": "id", "malay": "ms",
|
||||
"japanese": "ja", "korean": "ko", "chinese": "zh", "mandarin": "zh",
|
||||
"cantonese": "zh", "swahili": "sw", "afrikaans": "af", "catalan": "ca",
|
||||
"croatian": "hr", "serbian": "sr", "slovenian": "sl", "lithuanian": "lt",
|
||||
"latvian": "lv", "estonian": "et", "georgian": "ka", "armenian": "hy",
|
||||
"welsh": "cy", "irish": "ga", "basque": "eu", "galician": "gl",
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func TestSplitSeriesString(t *testing.T) {
|
||||
cases := []struct{ in, name, seq string }{
|
||||
{"The Witcher #3", "The Witcher", "3"},
|
||||
{"Name #1a", "Name", "1a"},
|
||||
{"No Sequence", "No Sequence", ""},
|
||||
{"Name #1#a", "Name #1#a", ""}, // embedded # is not a sequence
|
||||
}
|
||||
for _, c := range cases {
|
||||
name, seq := splitSeriesString(c.in)
|
||||
if name != c.name || seq != c.seq {
|
||||
t.Errorf("splitSeriesString(%q) = (%q, %q), want (%q, %q)", c.in, name, seq, c.name, c.seq)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeriesSequenceNumber(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
num float64
|
||||
found bool
|
||||
lossy bool
|
||||
}{
|
||||
{"3", 3, true, false},
|
||||
{"1.5", 1.5, true, false},
|
||||
{"1a", 1, true, true},
|
||||
{"Book 2", 2, true, true},
|
||||
{"2, Dramatized Adaptation", 2, true, true},
|
||||
{"", 0, false, false},
|
||||
{"novella", 0, false, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
num, found, lossy := seriesSequenceNumber(c.in)
|
||||
if num != c.num || found != c.found || lossy != c.lossy {
|
||||
t.Errorf("seriesSequenceNumber(%q) = (%v,%v,%v), want (%v,%v,%v)",
|
||||
c.in, num, found, lossy, c.num, c.found, c.lossy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The A2c headline bug: ABS abmetadata writes series as an array of
|
||||
// "Name #seq" STRINGS. The old parser only accepted object arrays and
|
||||
// silently dropped every ABS-matched series.
|
||||
func TestParseSeriesCandidatesStringArray(t *testing.T) {
|
||||
raw := []byte(`["The Witcher #3", "The Witcher (Reading Order) #5"]`)
|
||||
candidates := parseSeriesCandidates(raw)
|
||||
if len(candidates) != 2 {
|
||||
t.Fatalf("got %d candidates, want 2", len(candidates))
|
||||
}
|
||||
if candidates[0].Name != "The Witcher" || candidates[0].Number != 3 || !candidates[0].HasNum {
|
||||
t.Errorf("candidate[0] = %+v", candidates[0])
|
||||
}
|
||||
if candidates[1].Name != "The Witcher (Reading Order)" || candidates[1].Number != 5 {
|
||||
t.Errorf("candidate[1] = %+v", candidates[1])
|
||||
}
|
||||
if !isPlaylistSeriesName(candidates[1].Name) {
|
||||
t.Errorf("reading-order entry must classify as playlist-ish")
|
||||
}
|
||||
if isPlaylistSeriesName(candidates[0].Name) {
|
||||
t.Errorf("publication series must not classify as playlist-ish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSeriesCandidatesOtherShapes(t *testing.T) {
|
||||
objs := parseSeriesCandidates([]byte(`[{"series":"Great Series","sequence":"2.5"}]`))
|
||||
if len(objs) != 1 || objs[0].Name != "Great Series" || objs[0].Number != 2.5 {
|
||||
t.Errorf("object form: %+v", objs)
|
||||
}
|
||||
libation := parseSeriesCandidates([]byte(`"Libation Series #4"`))
|
||||
if len(libation) != 1 || libation[0].Name != "Libation Series" || libation[0].Number != 4 {
|
||||
t.Errorf("plain string form: %+v", libation)
|
||||
}
|
||||
if parseSeriesCandidates([]byte(`null`)) != nil {
|
||||
t.Errorf("null must yield no candidates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPlaylistSeriesName(t *testing.T) {
|
||||
positives := []string{
|
||||
"The Witcher (Reading Order)",
|
||||
"Discworld Reading Order",
|
||||
"Narnia Chronological Order",
|
||||
"Robin Hobb in order",
|
||||
"Series Publication Order",
|
||||
"Middle Earth Timeline",
|
||||
}
|
||||
for _, p := range positives {
|
||||
if !isPlaylistSeriesName(p) {
|
||||
t.Errorf("%q should be playlist-ish", p)
|
||||
}
|
||||
}
|
||||
negatives := []string{"The Witcher", "Witcher Saga", "The Timeline of..."}
|
||||
for _, n := range negatives {
|
||||
if isPlaylistSeriesName(n) {
|
||||
t.Errorf("%q must not be playlist-ish", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveSeriesFromText(t *testing.T) {
|
||||
name, num, hasNum, ok := deriveSeriesFromText("The Witcher, Book 3")
|
||||
if !ok || name != "The Witcher" || num != 3 || !hasNum {
|
||||
t.Errorf("got (%q,%v,%v,%v)", name, num, hasNum, ok)
|
||||
}
|
||||
name, num, hasNum, ok = deriveSeriesFromText("The Witcher, Book 1.5")
|
||||
if !ok || name != "The Witcher" || num != 1.5 || !hasNum {
|
||||
t.Errorf("decimal: got (%q,%v,%v,%v)", name, num, hasNum, ok)
|
||||
}
|
||||
// Not obviously a series tail → must refuse rather than guess.
|
||||
for _, negative := range []string{"1984", "The Fellowship", "Book Ends"} {
|
||||
if _, _, _, ok := deriveSeriesFromText(negative); ok {
|
||||
t.Errorf("%q must not derive a series", negative)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitTitleOnSubtitle(t *testing.T) {
|
||||
title, subtitle, split := splitTitleOnSubtitle("Baptism of Fire: The Witcher, Book 3")
|
||||
if !split || title != "Baptism of Fire" || subtitle != "The Witcher, Book 3" {
|
||||
t.Errorf("got (%q,%q,%v)", title, subtitle, split)
|
||||
}
|
||||
// Arbitrary colons never split.
|
||||
_, _, split = splitTitleOnSubtitle("Star Wars: A New Hope")
|
||||
if split {
|
||||
t.Errorf("non-series colon must not split")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTitleDerivedSeries(t *testing.T) {
|
||||
// The user's actual naming, two-level folder, no sidecar anywhere.
|
||||
meta := &MediaMetadata{Title: "Baptism of Fire: The Witcher, Book 3"}
|
||||
applyTitleDerivedSeries(meta)
|
||||
if meta.Series != "The Witcher" || meta.SeriesNumber != 3 {
|
||||
t.Errorf("series = %q #%v, want The Witcher #3", meta.Series, meta.SeriesNumber)
|
||||
}
|
||||
if meta.Title != "Baptism of Fire: The Witcher, Book 3" {
|
||||
t.Errorf("fallback must never mutate the display title, got %q", meta.Title)
|
||||
}
|
||||
|
||||
// Subtitle already split by the folder parser: number + name both land.
|
||||
meta = &MediaMetadata{Title: "Blood of Elves", Subtitle: "The Witcher, Book 1"}
|
||||
applyTitleDerivedSeries(meta)
|
||||
if meta.Series != "The Witcher" || meta.SeriesNumber != 1 {
|
||||
t.Errorf("series = %q #%v, want The Witcher #1", meta.Series, meta.SeriesNumber)
|
||||
}
|
||||
|
||||
// Existing series data is never overwritten (blanks-only fallback)...
|
||||
meta = &MediaMetadata{Title: "X", Subtitle: "Derived, Book 9", Series: "Real Series"}
|
||||
applyTitleDerivedSeries(meta)
|
||||
if meta.Series != "Real Series" || meta.SeriesNumber != 0 {
|
||||
t.Errorf("a number from a different series name must not apply, got %q #%v", meta.Series, meta.SeriesNumber)
|
||||
}
|
||||
// ...but a number whose derived name MATCHES the existing series fills it.
|
||||
meta = &MediaMetadata{Title: "X", Subtitle: "Real Series, Book 9", Series: "Real Series"}
|
||||
applyTitleDerivedSeries(meta)
|
||||
if meta.Series != "Real Series" || meta.SeriesNumber != 9 {
|
||||
t.Errorf("matching-name number must fill, got %q #%v", meta.Series, meta.SeriesNumber)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickSmartGenre(t *testing.T) {
|
||||
// The real Malazan Audible blob: one string, four genres.
|
||||
genre, all := pickSmartGenre([]string{"Epic, Action & Adventure, Military, Sword & Sorcery"})
|
||||
if genre != "action & adventure" {
|
||||
t.Errorf("genre = %q, want action & adventure", genre)
|
||||
}
|
||||
if len(all) != 4 {
|
||||
t.Errorf("candidates = %v, want the 4 split parts", all)
|
||||
}
|
||||
// Format noise never wins.
|
||||
if genre, _ := pickSmartGenre([]string{"Audiobook"}); genre != "" {
|
||||
t.Errorf("audiobook is not a genre, got %q", genre)
|
||||
}
|
||||
// Fiction is the floor: alone it resolves, against a real shelf it loses.
|
||||
if genre, _ := pickSmartGenre([]string{"Fiction"}); genre != "fiction" {
|
||||
t.Errorf("plain Fiction should still resolve, got %q", genre)
|
||||
}
|
||||
// Genre model v2: Epic Fantasy is a SUBGENRE — the genre column gets
|
||||
// its parent shelf and the specific label lives in tags. (Intended
|
||||
// change from the old longest-match behavior, which returned
|
||||
// "epic fantasy" here.)
|
||||
if genre, _ := pickSmartGenre([]string{"Fiction", "Epic Fantasy"}); genre != "fantasy" {
|
||||
t.Errorf("subgenre must resolve to its shelf, got %q", genre)
|
||||
}
|
||||
// User-verified traces (Batch B decision):
|
||||
if genre, _ := pickSmartGenre([]string{"Pirates", "Adventure"}); genre != "adventure" {
|
||||
t.Errorf("direct shelf beats mapped subgenre, got %q", genre)
|
||||
}
|
||||
if genre, _ := pickSmartGenre([]string{"Pirates"}); genre != "adventure" {
|
||||
t.Errorf("mapped subgenre alone resolves to its shelf, got %q", genre)
|
||||
}
|
||||
if genre, _ := pickSmartGenre([]string{"Sci-Fi", "Science Fiction", "Fiction"}); genre != "science fiction" {
|
||||
t.Errorf("direct shelf beats floor, got %q", genre)
|
||||
}
|
||||
// Type-words describe the library type, not a shelf.
|
||||
if genre, _ := pickSmartGenre([]string{"Manga"}); genre != "" {
|
||||
t.Errorf("manga is a type-word, got %q", genre)
|
||||
}
|
||||
// Unknown-only input: no shelf is known — tags carry it, never guess.
|
||||
if genre, all := pickSmartGenre([]string{"Completely Made-Up Genre"}); genre != "" {
|
||||
t.Errorf("unknown-only input must leave genre empty, got %q (tags=%v)", genre, all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLanguageCode(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"English", "en"},
|
||||
{"en", "en"},
|
||||
{"German", "de"},
|
||||
{"", ""},
|
||||
{"Norwegian (Bokmål)", ""}, // too long for VARCHAR(10); dropped, never truncated
|
||||
{"Pig Latin", "Pig Latin"}, // unknown display name that fits: kept
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := normalizeLanguageCode(c.in); got != c.want {
|
||||
t.Errorf("normalizeLanguageCode(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end: a REAL ABS-shaped sidecar (string-array series, the shape
|
||||
// every ABS-matched book writes) must land series, language, explicit and
|
||||
// the full author array.
|
||||
func TestExtractAudiobookshelfSidecarStringArraySeries(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sidecar := `{
|
||||
"title": "Blood of Elves",
|
||||
"authors": ["Andrzej Sapkowski"],
|
||||
"narrators": ["Peter Kenny"],
|
||||
"series": ["The Witcher #1", "The Witcher (Reading Order) #3"],
|
||||
"genres": ["Fiction"],
|
||||
"language": "English",
|
||||
"explicit": true,
|
||||
"publisher": "Orbit"
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(sidecar), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := extractAudiobookshelfSidecar(filepath.Join(dir, "book.m4b"))
|
||||
if meta == nil {
|
||||
t.Fatal("sidecar not extracted")
|
||||
}
|
||||
if meta.Series != "The Witcher" || meta.SeriesNumber != 1 {
|
||||
t.Errorf("series = %q #%v", meta.Series, meta.SeriesNumber)
|
||||
}
|
||||
if len(meta.Authors) != 1 || meta.Authors[0] != "Andrzej Sapkowski" {
|
||||
t.Errorf("authors array = %v", meta.Authors)
|
||||
}
|
||||
if meta.Language != "en" {
|
||||
t.Errorf("language = %q, want en", meta.Language)
|
||||
}
|
||||
if !meta.Explicit {
|
||||
t.Errorf("explicit flag lost")
|
||||
}
|
||||
if len(meta.PlaylistSeries) != 1 || meta.PlaylistSeries[0].Name != "The Witcher (Reading Order)" {
|
||||
t.Errorf("playlist series = %+v", meta.PlaylistSeries)
|
||||
}
|
||||
if meta.AlternateInfo != "" {
|
||||
t.Errorf("playlist entry must not leak into alternate_info, got %q", meta.AlternateInfo)
|
||||
}
|
||||
}
|
||||
|
||||
// Additional real series (the Drizzt case: Icewind Dale #1 = Legend of
|
||||
// Drizzt #4) land in alternate_info with the comic-established shape.
|
||||
func TestExtractAudiobookshelfSidecarExtraSeriesToAlternateInfo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sidecar := `{
|
||||
"title": "The Crystal Shard",
|
||||
"authors": ["R.A. Salvatore"],
|
||||
"series": [
|
||||
{"series": "The Icewind Dale Trilogy", "sequence": "1"},
|
||||
{"series": "The Legend of Drizzt", "sequence": "4"}
|
||||
]
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(sidecar), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta := extractAudiobookshelfSidecar(filepath.Join(dir, "book.m4b"))
|
||||
if meta.Series != "The Icewind Dale Trilogy" || meta.SeriesNumber != 1 {
|
||||
t.Errorf("series = %q #%v", meta.Series, meta.SeriesNumber)
|
||||
}
|
||||
if meta.AlternateInfo == "" {
|
||||
t.Fatal("second series must land in alternate_info")
|
||||
}
|
||||
// Lossy sequence text is preserved, never silently dropped.
|
||||
meta2 := extractAudiobookshelfSidecarPath(t, dir, `{"title":"X","series":["Saga #1a"]}`)
|
||||
if meta2 == nil || meta2.Series != "Saga" || meta2.SeriesNumber != 1 {
|
||||
t.Fatalf("lossy series = %+v", meta2)
|
||||
}
|
||||
if meta2.AlternateInfo == "" || !strings.Contains(meta2.AlternateInfo, "series_sequence_raw") || !strings.Contains(meta2.AlternateInfo, "1a") {
|
||||
t.Errorf("raw sequence not preserved: %q", meta2.AlternateInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func extractAudiobookshelfSidecarPath(t *testing.T, dir, json string) *MediaMetadata {
|
||||
t.Helper()
|
||||
// extractAudiobookshelfSidecar looks for exactly "metadata.json"; use a
|
||||
// fresh dir per call instead.
|
||||
fresh := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(fresh, "metadata.json"), []byte(json), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return extractAudiobookshelfSidecar(filepath.Join(fresh, "book.m4b"))
|
||||
}
|
||||
|
||||
// The series page tiebreaker rides on the same ordering contract the
|
||||
// scanner feeds: numbers sort numerically, NULLs last (§3.6).
|
||||
func TestSeriesNumberFloatOrderingValues(t *testing.T) {
|
||||
if !(pgtype.Float8{Float64: 1}.Float64 < pgtype.Float8{Float64: 1.5}.Float64) {
|
||||
t.Fatal("1 must sort before 1.5")
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
|
||||
MimeType: pgtype.Text{String: "application/epub+zip", Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: "/covers/test.jpg", Valid: true},
|
||||
Series: pgtype.Text{String: "Test Series", Valid: true},
|
||||
SeriesNumber: pgtype.Int4{Int32: 3, Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 3, Valid: true},
|
||||
Tags: []string{"fantasy", "adventure"},
|
||||
FormatGroup: "epub",
|
||||
AddedByAdminID: pgtype.UUID{Bytes: adminUUID, Valid: true},
|
||||
@@ -43,7 +43,7 @@ func TestContinueSeriesRowToMediaItems_FieldsMappedCorrectly(t *testing.T) {
|
||||
assert.Equal(t, pgtype.Text{String: "application/epub+zip", Valid: true}, result.MimeType, "MimeType should match")
|
||||
assert.Equal(t, pgtype.Text{String: "/covers/test.jpg", Valid: true}, result.CoverImagePath, "CoverImagePath should match")
|
||||
assert.Equal(t, pgtype.Text{String: "Test Series", Valid: true}, result.Series, "Series should match")
|
||||
assert.Equal(t, pgtype.Int4{Int32: 3, Valid: true}, result.SeriesNumber, "SeriesNumber should match")
|
||||
assert.Equal(t, pgtype.Float8{Float64: 3, Valid: true}, result.SeriesNumber, "SeriesNumber should match")
|
||||
assert.Equal(t, []string{"fantasy", "adventure"}, result.Tags, "Tags should match")
|
||||
assert.Equal(t, "epub", result.FormatGroup, "FormatGroup should match")
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func TestContinueSeriesRowToMediaItems_NullFieldsHandled(t *testing.T) {
|
||||
MimeType: pgtype.Text{Valid: false},
|
||||
CoverImagePath: pgtype.Text{Valid: false},
|
||||
Series: pgtype.Text{Valid: false},
|
||||
SeriesNumber: pgtype.Int4{Valid: false},
|
||||
SeriesNumber: pgtype.Float8{Valid: false},
|
||||
Tags: nil,
|
||||
FormatGroup: "epub",
|
||||
}
|
||||
@@ -94,7 +94,7 @@ func TestContinueSeriesRowToMediaItems_AllFieldsMapped(t *testing.T) {
|
||||
MimeType: pgtype.Text{String: "epub", Valid: true},
|
||||
CoverImagePath: pgtype.Text{String: "/cover.jpg", Valid: true},
|
||||
Series: pgtype.Text{String: "Series", Valid: true},
|
||||
SeriesNumber: pgtype.Int4{Int32: 1, Valid: true},
|
||||
SeriesNumber: pgtype.Float8{Float64: 1, Valid: true},
|
||||
Tags: []string{"tag1"},
|
||||
Asin: pgtype.Text{String: "ASIN", Valid: true},
|
||||
Publisher: pgtype.Text{String: "Pub", Valid: true},
|
||||
|
||||
@@ -72,6 +72,7 @@ func (w *Worker) recordPending(job *Job) {
|
||||
JobID: job.ID,
|
||||
Status: JobStatusPending,
|
||||
LibraryID: libID,
|
||||
JobType: job.Type,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,7 +105,11 @@ type JobResult struct {
|
||||
// LibraryID carries the scan job's library (from Params) so clients
|
||||
// can label progress rows after a page refresh, when the original
|
||||
// start-request context is gone.
|
||||
LibraryID string `json:"library_id,omitempty"`
|
||||
LibraryID string `json:"library_id,omitempty"`
|
||||
// JobType distinguishes scan jobs in the active set — the enqueue
|
||||
// coalescing check (HasActiveScanForLibrary) must only coalesce
|
||||
// scans, never imports/converts sharing a library id.
|
||||
JobType JobType `json:"job_type,omitempty"`
|
||||
Error string `json:"error"`
|
||||
Result interface{} `json:"result"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -286,6 +291,7 @@ func (w *Worker) processJob(job *Job) {
|
||||
JobID: job.ID,
|
||||
Status: JobStatusRunning,
|
||||
LibraryID: libID,
|
||||
JobType: job.Type,
|
||||
}
|
||||
|
||||
// Derive a per-job context so cancelling this job (CancelJob) stops
|
||||
@@ -486,6 +492,12 @@ func (w *Worker) processScanJob(job *Job) (interface{}, error) {
|
||||
scanner.SetAdminID(adminUUID)
|
||||
scanner.SetForce(force)
|
||||
|
||||
// Trigger label (plan Part 2 §4): poll / watcher / admin / initial —
|
||||
// surfaces in the per-scan summary line.
|
||||
if trigger, ok := job.Params["trigger"].(string); ok {
|
||||
scanner.SetTrigger(trigger)
|
||||
}
|
||||
|
||||
var libraryUUID pgtype.UUID
|
||||
if err := libraryUUID.Scan(libraryID); err != nil {
|
||||
return nil, err
|
||||
@@ -1073,6 +1085,28 @@ func (w *Worker) GetActiveScanJobs() []JobResult {
|
||||
return active
|
||||
}
|
||||
|
||||
// HasActiveScanForLibrary reports whether a pending or running SCAN job
|
||||
// already targets the library (faceted plan Part 2 §5): coalescing
|
||||
// stops poll ticks from stacking full scans when a scan outlives its
|
||||
// interval, and enforces the one-scan-per-library invariant the bulk
|
||||
// scan-state map leans on.
|
||||
func (w *Worker) HasActiveScanForLibrary(libraryID string) bool {
|
||||
if w == nil || libraryID == "" {
|
||||
return false
|
||||
}
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
for _, result := range w.results {
|
||||
if result.JobType != JobTypeScan || result.LibraryID != libraryID {
|
||||
continue
|
||||
}
|
||||
if result.Status == JobStatusRunning || result.Status == JobStatusPending {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CancelJob cancels a pending or running job by id. Pending jobs are
|
||||
// marked cancelled so processJob skips them at dequeue (no context
|
||||
// exists yet); running jobs have their per-job context cancelled — the
|
||||
|
||||
@@ -58,3 +58,46 @@ func TestGetActiveScanJobsEmpty(t *testing.T) {
|
||||
t.Fatalf("expected no active jobs, got %d", len(jobs))
|
||||
}
|
||||
}
|
||||
|
||||
// HasActiveScanForLibrary is the enqueue-coalescing predicate (plan
|
||||
// Part 2 §5): a pending/running SCAN for the same library makes the
|
||||
// next poll tick / watcher event a no-op. Only scans coalesce — an
|
||||
// import or convert sharing the library id must not block one.
|
||||
func TestHasActiveScanForLibrary(t *testing.T) {
|
||||
w := NewWorker(1, nil)
|
||||
|
||||
seed := map[string]*JobResult{
|
||||
"running-scan": {JobID: "running-scan", Status: JobStatusRunning, LibraryID: "lib-1", JobType: JobTypeScan},
|
||||
"pending-scan": {JobID: "pending-scan", Status: JobStatusPending, LibraryID: "lib-2", JobType: JobTypeScan},
|
||||
"done-scan": {JobID: "done-scan", Status: JobStatusCompleted, LibraryID: "lib-3", JobType: JobTypeScan},
|
||||
"failed-scan": {JobID: "failed-scan", Status: JobStatusFailed, LibraryID: "lib-4", JobType: JobTypeScan},
|
||||
"import-lib1": {JobID: "import-lib1", Status: JobStatusRunning, LibraryID: "lib-1", JobType: JobTypeImport},
|
||||
"no-library": {JobID: "no-library", Status: JobStatusRunning, JobType: JobTypeScan},
|
||||
}
|
||||
w.mu.Lock()
|
||||
w.results = seed
|
||||
w.mu.Unlock()
|
||||
|
||||
cases := []struct {
|
||||
library string
|
||||
want bool
|
||||
}{
|
||||
{"lib-1", true}, // running scan
|
||||
{"lib-2", true}, // pending scan
|
||||
{"lib-3", false}, // completed = terminal
|
||||
{"lib-4", false}, // failed = terminal
|
||||
{"lib-9", false}, // unknown library
|
||||
{"", false}, // jobs without a library never coalesce
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := w.HasActiveScanForLibrary(c.library); got != c.want {
|
||||
t.Errorf("HasActiveScanForLibrary(%q) = %v, want %v", c.library, got, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A nil worker (tests, early startup) must not panic.
|
||||
var nilWorker *Worker
|
||||
if nilWorker.HasActiveScanForLibrary("lib-1") {
|
||||
t.Error("nil worker must report no active scans")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package utils
|
||||
|
||||
// Genre display rendering (faceted plan Batch B / Phase 7): the
|
||||
// canonically-lowercase genre value is title-cased at DISPLAY time
|
||||
// only — never in the DB. Canonical home: the filter panel (services),
|
||||
// templ (templates wraps this), and the TS port in bookshelf.ts all
|
||||
// share these exact rules.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// genreSmallWords stay lowercase in title-case rendering (except the
|
||||
// first word of the value).
|
||||
var genreSmallWords = map[string]bool{
|
||||
"a": true, "an": true, "and": true, "of": true, "the": true,
|
||||
"in": true, "on": true, "for": true, "to": true, "or": true,
|
||||
}
|
||||
|
||||
// PrettifyGenre renders the canonically-lowercase genre value in title
|
||||
// case at display time (Batch B transform — never in the DB). Only
|
||||
// values that are ENTIRELY lowercase get transformed; anything else is
|
||||
// returned verbatim, protecting user-entered mixed case. Small words
|
||||
// stay down except in the first position; "&" has no case and passes
|
||||
// through. "science fiction" → "Science Fiction", "sword & sorcery" →
|
||||
// "Sword & Sorcery".
|
||||
func PrettifyGenre(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if trimmed != strings.ToLower(trimmed) {
|
||||
return trimmed // mixed case = user-authored; display as written
|
||||
}
|
||||
words := strings.Fields(trimmed)
|
||||
out := make([]string, len(words))
|
||||
for i, w := range words {
|
||||
if i > 0 && genreSmallWords[w] {
|
||||
out[i] = w
|
||||
continue
|
||||
}
|
||||
runes := []rune(w)
|
||||
out[i] = string(append([]rune{unicode.ToUpper(runes[0])}, runes[1:]...))
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
|
||||
const (
|
||||
OverrideTitle = "title"
|
||||
OverrideAuthor = "author"
|
||||
OverrideAuthor = "author" // protects the authors[] array (legacy key preserved)
|
||||
OverrideISBN = "isbn"
|
||||
OverrideDescription = "description"
|
||||
OverrideCoverImagePath = "cover_image_path"
|
||||
@@ -29,7 +29,9 @@ const (
|
||||
OverrideTags = "tags"
|
||||
OverrideAsin = "asin"
|
||||
OverrideDatePublished = "date_published"
|
||||
OverridePublisher = "publisher"
|
||||
OverrideSubtitle = "subtitle"
|
||||
OverrideExplicit = "explicit"
|
||||
OverridePublisher = "publisher" // protects the publishers[] array (legacy key preserved)
|
||||
OverrideContributors = "contributors"
|
||||
OverrideLanguage = "language"
|
||||
OverrideEdition = "edition"
|
||||
@@ -150,17 +152,19 @@ func detectMetadataOverridesDiff(params database.UpdateMediaItemParams, existing
|
||||
}
|
||||
|
||||
add(OverrideTitle, params.Title != existing.Title)
|
||||
add(OverrideAuthor, normText(params.Author) != normText(existing.Author))
|
||||
add(OverrideSubtitle, normText(params.Subtitle) != normText(existing.Subtitle))
|
||||
add(OverrideAuthor, normStringSlice(params.Authors) != normStringSlice(existing.Authors))
|
||||
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(OverrideSeriesNumber, normFloat(params.SeriesNumber) != normFloat(existing.SeriesNumber))
|
||||
add(OverrideTags, normStringSlice(params.Tags) != normStringSlice(existing.Tags))
|
||||
add(OverrideNarrators, normStringSlice(params.Narrators) != normStringSlice(existing.Narrators))
|
||||
add(OverrideAbridged, params.Abridged != existing.Abridged)
|
||||
add(OverrideExplicit, params.Explicit != existing.Explicit)
|
||||
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(OverridePublisher, normStringSlice(params.Publishers) != normStringSlice(existing.Publishers))
|
||||
add(OverrideContributors, normStringSlice(params.Contributors) != normStringSlice(existing.Contributors))
|
||||
add(OverrideLanguage, normText(params.Language) != normText(existing.Language))
|
||||
add(OverrideEdition, normText(params.Edition) != normText(existing.Edition))
|
||||
@@ -205,8 +209,12 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
|
||||
if hasOverride(o, OverrideTitle) {
|
||||
params.Title = existing.Title
|
||||
}
|
||||
if hasOverride(o, OverrideSubtitle) {
|
||||
params.Subtitle = existing.Subtitle
|
||||
}
|
||||
if hasOverride(o, OverrideAuthor) {
|
||||
params.Author = existing.Author
|
||||
params.Authors = existing.Authors
|
||||
params.AuthorsSearch = existing.AuthorsSearch
|
||||
}
|
||||
if hasOverride(o, OverrideISBN) {
|
||||
params.Isbn = existing.Isbn
|
||||
@@ -241,7 +249,8 @@ func ApplyMetadataOverrides(params *database.UpdateMediaItemParams, existing dat
|
||||
params.DatePublished = existing.DatePublished
|
||||
}
|
||||
if hasOverride(o, OverridePublisher) {
|
||||
params.Publisher = existing.Publisher
|
||||
params.Publishers = existing.Publishers
|
||||
params.PublishersSearch = existing.PublishersSearch
|
||||
}
|
||||
if hasOverride(o, OverrideContributors) {
|
||||
params.Contributors = existing.Contributors
|
||||
|
||||
@@ -63,12 +63,12 @@ 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},
|
||||
SeriesNumber: pgtype.Float8{Float64: 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
|
||||
SeriesNumber: pgtype.Float8{Float64: 0, Valid: false}, // cleared by user -> changed
|
||||
CommunityRating: pgtype.Float8{Float64: 0, Valid: false},
|
||||
}
|
||||
got := DetectMetadataOverrides(params, existing)
|
||||
|
||||
@@ -183,6 +183,21 @@ func NormalizeNarratorsSearch(narrators []string) []string {
|
||||
return NormalizeTagsSearch(narrators)
|
||||
}
|
||||
|
||||
// SingleOrSlice returns the write-truth array for a field that has both a
|
||||
// legacy single-value form and an array form: the explicit array when
|
||||
// populated, else the single string as a one-element slice. Used by the
|
||||
// authors/publishers arrays (`author`/`publisher` varchars regenerate from
|
||||
// them via the sync_author_publisher_display trigger).
|
||||
func SingleOrSlice(single string, arr []string) []string {
|
||||
if len(arr) > 0 {
|
||||
return arr
|
||||
}
|
||||
if single == "" {
|
||||
return []string{}
|
||||
}
|
||||
return []string{single}
|
||||
}
|
||||
|
||||
// NormalizeContributors normalizes an array of contributors for display:
|
||||
// 1. Trim whitespace
|
||||
// 2. Preserve original casing (including CAPSLOCK companies)
|
||||
|
||||
+12
-5
@@ -5,11 +5,12 @@
|
||||
"scripts": {
|
||||
"build:css": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --watch",
|
||||
"build:css:prod": "tailwindcss -i ./web/static/input.css -o ./web/static/style.css --minify",
|
||||
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build",
|
||||
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --mode development",
|
||||
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vite build --watch",
|
||||
"build:ts": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build",
|
||||
"build:ts:dev": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --mode development",
|
||||
"build:ts:watch": "cp node_modules/htmx.org/dist/htmx.min.js web/static/htmx.min.js && vp build --watch",
|
||||
"build": "npm run build:ts && npm run build:css:prod",
|
||||
"dev": "npm run build:ts:dev && npm run build:css"
|
||||
"dev": "npm run build:ts:dev && npm run build:css",
|
||||
"test": "vp test web/src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bookhoard/foliate-js": "git+https://git.linuxhg.com/Bookhoard/foliate-js.git#f622841",
|
||||
@@ -31,8 +32,14 @@
|
||||
"@types/markdown-it": "^14.2.0",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"jsdom": "^30.1.3",
|
||||
"playwright": "^1.64.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
|
||||
"vite-plus": "1.3.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Filter-builder UI regression harness (faceted plan Phase 9 follow-up).
|
||||
//
|
||||
// Drives the real bookshelf in headless Chromium: opens the filter
|
||||
// drawer, types an author value, and sweeps every author operator,
|
||||
// asserting the live count fetch behaves (200s, correct numbers, no
|
||||
// console errors). Born from the mis-keyed operator table that made
|
||||
// every non-self-named operator (doesn't contain, is empty, at least…)
|
||||
// a silent 400 — the class of bug only a real browser catches.
|
||||
//
|
||||
// Prerequisites: dev stack up (make dev / docker compose), playwright
|
||||
// installed (cd web && npm i -D playwright && npx playwright install
|
||||
// chromium — the repo root's devDependency already carries it), and a
|
||||
// reachable server.
|
||||
//
|
||||
// Usage: NODE_PATH=<repo>/node_modules node scripts/filter_builder_harness.js
|
||||
// Env: HARNESS_BASE (default http://localhost:8765), HARNESS_LOGIN,
|
||||
// HARNESS_PASSWORD.
|
||||
const { chromium } = require("playwright");
|
||||
|
||||
const BASE = process.env.HARNESS_BASE || "http://localhost:8765";
|
||||
const LOGIN = process.env.HARNESS_LOGIN || "testuser@tests.bookhoard.internal";
|
||||
const PASSWORD = process.env.HARNESS_PASSWORD || "Test@Pass123!";
|
||||
const AUTHOR = "Andrzej Sapkowski";
|
||||
const OPS_SWEEP = ["doesn't contain", "is", "isn't", "is empty", "contains"];
|
||||
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
const events = [];
|
||||
page.on("console", (m) => {
|
||||
if (m.type() === "error" || m.type() === "warning") {
|
||||
events.push(`[console.${m.type()}] ${m.text()}`);
|
||||
}
|
||||
});
|
||||
page.on("pageerror", (e) => events.push(`[pageerror] ${e.message}`));
|
||||
page.on("response", async (r) => {
|
||||
if (r.url().includes("filter-count")) {
|
||||
let body = "";
|
||||
try { body = (await r.text()).slice(0, 120); } catch {}
|
||||
events.push(`[net] filter-count ${r.status()} ${body}`);
|
||||
}
|
||||
});
|
||||
|
||||
const login = await context.request.post(`${BASE}/api/auth/login`, {
|
||||
data: { login: LOGIN, password: PASSWORD },
|
||||
});
|
||||
const loginBody = await login.json();
|
||||
await context.addInitScript((t) => localStorage.setItem("token", t), loginBody.access_token);
|
||||
console.log("login:", login.status());
|
||||
|
||||
await page.goto(`${BASE}/bookshelf`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.evaluate(() => {
|
||||
const el = document.getElementById("library-select");
|
||||
const empty = [...el.options].find((o) => o.value === "");
|
||||
if (empty) {
|
||||
el.value = "";
|
||||
el.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
await page.click('button:has-text("Filters")');
|
||||
await page.waitForTimeout(300);
|
||||
const valueInput = page.locator(".fb-value").first();
|
||||
await valueInput.click();
|
||||
await valueInput.fill(AUTHOR);
|
||||
await page.waitForTimeout(1200);
|
||||
const countEl = page.locator('span[x-show*="matchCount"]').first();
|
||||
console.log("contains:", JSON.stringify(await countEl.textContent()));
|
||||
|
||||
for (const label of OPS_SWEEP) {
|
||||
await page.locator(".fb-picker-op").first().click();
|
||||
await page.waitForTimeout(150);
|
||||
await page
|
||||
.locator(".fb-menu-item:visible")
|
||||
.filter({ hasText: new RegExp(`^${label.replace(/[\W_]+/g, "\\$&")}$`) })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(1300);
|
||||
console.log(`after "${label}":`, JSON.stringify(await countEl.textContent()));
|
||||
}
|
||||
|
||||
// contains-with-empty-value is incomplete (counter hidden) — refill
|
||||
// and confirm the count returns.
|
||||
await valueInput.fill(AUTHOR);
|
||||
await page.waitForTimeout(1300);
|
||||
console.log("refilled contains:", JSON.stringify(await countEl.textContent()));
|
||||
|
||||
console.log("--- warnings/errors + count calls ---");
|
||||
for (const l of events) console.log(l);
|
||||
await browser.close();
|
||||
})().catch((e) => { console.error("HARNESS ERROR:", e.message); process.exit(1); });
|
||||
@@ -45,7 +45,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -79,7 +79,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(retentionDays)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 31, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 31, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -135,7 +135,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 66, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 66, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +153,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 68, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 68, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -171,7 +171,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 70, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 70, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -184,7 +184,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 72, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 72, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -197,7 +197,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Status)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 73, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 73, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -215,7 +215,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 78, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 78, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -228,7 +228,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MatchID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 79, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 79, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -241,7 +241,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, 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: 81, Col: 174}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 81, Col: 174}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -267,7 +267,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 89, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 89, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -288,7 +288,7 @@ func AdminArchived(user User, items []ArchivedItem, retentionDays int) templ.Com
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_archived.templ`, Line: 98, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_archived.templ`, Line: 98, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -59,7 +59,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("conflict-" + conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 27, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -80,7 +80,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d copies", len(conflict.Items)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 33, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -93,7 +93,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.LibraryName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 36, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -106,7 +106,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.SHAShort)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 38, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -119,7 +119,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflict.ID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 46, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -132,7 +132,7 @@ func HashConflictCard(conflict HashConflictData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 48, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -194,7 +194,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 71, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -212,7 +212,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 73, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -230,7 +230,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 75, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -243,7 +243,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.1f MB", float64(item.FileSize)/(1024*1024)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 77, Col: 64}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -261,7 +261,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 79, Col: 85}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -279,7 +279,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(item.UsageSummary)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 81, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -297,7 +297,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/admin/hash-conflicts/" + conflictID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 87, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -310,7 +310,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf(`{"action": "keep", "keep_uuid": "%s"}`, item.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 88, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -323,7 +323,7 @@ func HashConflictItemCardWrapper(item HashConflictItemData, conflictID string) t
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#conflict-" + conflictID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 89, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -383,7 +383,7 @@ func AdminHashConflicts(user User, conflicts []HashConflictData) templ.Component
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var19).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_hash_conflicts.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -47,7 +47,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -89,7 +89,7 @@ func AdminLibrary(user User, libraries []LibraryData, users []User, archivedCoun
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(archivedCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 45, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 45, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -220,7 +220,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(t.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 186, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 186, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -233,7 +233,7 @@ func LibraryTypeOptions(types []database.LibraryTypes) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(LibraryTypeDisplayName(t.Name))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 186, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 186, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -303,7 +303,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 210, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 210, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -321,7 +321,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 212, Col: 95}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 212, Col: 95}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -343,7 +343,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(library.TypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 217, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 217, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -365,7 +365,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(library.FolderCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 222, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 222, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -383,7 +383,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + library.ID + "/scan")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 228, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 228, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -404,7 +404,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 238, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 238, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -425,7 +425,7 @@ func LibraryList(user User, libraries []LibraryData, users []User) templ.Compone
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("library-panel-" + library.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 247, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 247, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -500,7 +500,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(folder.FolderPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 269, Col: 99}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 269, Col: 99}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -513,7 +513,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 272, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 272, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -526,7 +526,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(hxValsFolderDelete(folder.FolderPath))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 273, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 273, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -539,7 +539,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 274, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 274, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -570,7 +570,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/folders")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 284, Col: 80}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 80}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -583,7 +583,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 284, Col: 124}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 284, Col: 124}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -596,7 +596,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 288, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 288, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -609,7 +609,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 295, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 295, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -622,7 +622,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("folder-input-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 296, Col: 52}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 296, Col: 52}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -679,7 +679,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/" + libraryID + "/visibility")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 325, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 325, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -692,7 +692,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(`{"user_id": "` + u.ID + `"}`)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 326, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 326, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -705,7 +705,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("#library-panel-" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 328, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 328, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -718,7 +718,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(u.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 331, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 331, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -731,7 +731,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(u.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 332, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 332, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -759,7 +759,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var31 templ.SafeURL
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/admin/libraries/" + libraryID + "/issues")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 341, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 341, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -786,7 +786,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(issueCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 346, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 346, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -813,7 +813,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var33 string
|
||||
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 355, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 355, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -826,7 +826,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 356, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 356, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -839,7 +839,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 357, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 357, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -860,7 +860,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 365, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 365, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -873,7 +873,7 @@ func LibraryPanel(user User, libraryID string, library LibraryData, folders []Fo
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(library.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 366, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 366, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -931,7 +931,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 381, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 381, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -949,7 +949,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + parentPath + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 387, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 387, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -980,7 +980,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/library/browse?path=" + entry.Path + "&target_input=" + targetInput + "&library_id=" + libraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 400, Col: 118}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 400, Col: 118}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1001,7 +1001,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(entry.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 405, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 405, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1019,7 +1019,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(targetInput)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 412, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 412, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1032,7 +1032,7 @@ func FolderBrowserContent(currentPath string, parentPath string, entries []DirEn
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentPath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_library.templ`, Line: 413, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_library.templ`, Line: 413, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -45,7 +45,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -92,7 +92,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.ErrorCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 37, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 37, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -119,7 +119,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.WarningCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 46, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 46, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -146,7 +146,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.InfoCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 55, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 55, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -188,7 +188,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("issue-" + issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 71, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 71, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -201,7 +201,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 74, Col: 98}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 74, Col: 98}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -214,7 +214,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueDescription)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 75, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 75, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -227,7 +227,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(issue.IssueType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 77, Col: 140}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 77, Col: 140}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -240,7 +240,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FormatGroup)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 78, Col: 144}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 78, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -253,7 +253,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(issue.FilePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 79, Col: 139}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 79, Col: 139}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -266,7 +266,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(issue.LibraryTypeName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 80, Col: 149}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 80, Col: 149}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -284,7 +284,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 85, Col: 62}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 85, Col: 62}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -302,7 +302,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 87, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 87, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -320,7 +320,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(issue.Severity)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 89, Col: 66}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 89, Col: 66}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -343,7 +343,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/libraries/" + libraryID + "/issues/" + issue.ID + "/" + issue.MediaItemID + "/resolve")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 96, Col: 113}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 96, Col: 113}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -356,7 +356,7 @@ func AdminProcessingIssues(user User, libraryID string, issues []ProcessingIssue
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#issue-" + issue.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_processing_issues.templ`, Line: 97, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_processing_issues.templ`, Line: 97, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -50,7 +50,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -92,7 +92,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 35, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 35, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -118,7 +118,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 49, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 49, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -395,7 +395,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 110, Col: 145}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 110, Col: 145}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -408,7 +408,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 111, Col: 144}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 111, Col: 144}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -421,7 +421,7 @@ func AdminSettings(user User, systemConfig map[string]string, scanSettings ScanS
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(systemConfig["base_url"])
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 112, Col: 152}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 112, Col: 152}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -501,7 +501,7 @@ func ScanSettingsSection(scanSettings ScanSettingsData) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%d", scanSettings.ScanPollIntervalSeconds))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 159, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 159, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -593,7 +593,7 @@ func TunableSettingsSection(groups []SettingGroup, restartRequired bool) templ.C
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 199, Col: 112}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 199, Col: 112}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -651,7 +651,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(e.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 214, Col: 94}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 214, Col: 94}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -669,7 +669,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 216, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 216, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -687,7 +687,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 218, Col: 74}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 218, Col: 74}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -705,7 +705,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue("#status-" + e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 224, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 224, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -718,7 +718,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 228, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 228, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -761,7 +761,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 238, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 238, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -779,7 +779,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Min)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 240, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 240, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -798,7 +798,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Max)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 243, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 243, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -821,7 +821,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(e.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 251, Col: 20}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 251, Col: 20}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -847,7 +847,7 @@ func TunableSettingRow(e SettingEntry) templ.Component {
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("status-" + e.Key)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 260, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 260, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -900,7 +900,7 @@ func AboutSection() templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(version.Version)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_settings.templ`, Line: 274, Col: 134}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_settings.templ`, Line: 274, Col: 134}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -45,7 +45,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -82,7 +82,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(stats.LibraryCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 33, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 33, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -103,7 +103,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(stats.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 40, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 40, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -124,7 +124,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(stats.UserCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 47, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 47, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -145,7 +145,7 @@ func Admin(user User, stats AdminStats) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(stats.DeviceCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin.templ`, Line: 54, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin.templ`, Line: 54, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -86,7 +86,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -128,7 +128,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 102, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 102, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -149,7 +149,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 111, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 111, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -172,7 +172,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 116, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 116, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -185,7 +185,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(user.CreatedAt, currentUser.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 121, Col: 73}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 121, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -208,7 +208,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 138, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 138, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -221,7 +221,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("#role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 139, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 139, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -259,7 +259,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("role-result-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 150, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 150, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -272,7 +272,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("/admin/users/" + user.ID + "/profile-modal")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 154, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 154, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -311,7 +311,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/auth/profile/" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 173, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 173, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -324,7 +324,7 @@ func AdminUsers(currentUser User, users []User, adminCount int) templ.Component
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("#user-" + user.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `admin_users.templ`, Line: 174, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/admin_users.templ`, Line: 174, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -42,7 +42,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 8, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 8, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -60,7 +60,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 10, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 10, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -78,7 +78,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 12, Col: 120}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 12, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -96,7 +96,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 14, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 14, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -114,7 +114,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 16, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 16, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -127,7 +127,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 24, Col: 131}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 24, Col: 131}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -140,7 +140,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Response)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 28, Col: 128}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 28, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -153,7 +153,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(" + JSONArg(explorer.Endpoint.RequestBody) + ")")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 31, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 31, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -174,7 +174,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(" + JSONArg(explorer.Endpoint.Response) + ")")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 35, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 35, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -200,7 +200,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue("initAPIExplorerDoc(" + JSONArg(explorer.Endpoint.Path) + ", " + JSONArg(explorer.Endpoint.RequestBody) + ", " + JSONArg(explorer.Endpoint.Response) + ")")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 42, Col: 226}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 42, Col: 226}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -218,7 +218,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 45, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 45, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -236,7 +236,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 47, Col: 65}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 47, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -254,7 +254,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 49, Col: 120}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 49, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -272,7 +272,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Method)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 51, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 51, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -290,7 +290,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.Path)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 53, Col: 88}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 53, Col: 88}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -343,7 +343,7 @@ func APIExplorer(explorer APIExplorerData) templ.Component {
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(explorer.Endpoint.RequestBody)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `api_explorer.templ`, Line: 70, Col: 136}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/api_explorer.templ`, Line: 70, Col: 136}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+306
-19
@@ -10,14 +10,26 @@ package templates
|
||||
import "fmt"
|
||||
|
||||
templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<!DOCTYPE html/>
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{ "Listening · " + metadata.Title } · Bookhoard</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<link href="/static/style.css" rel="stylesheet"/>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
||||
<script type="module" src="/static/main.js"></script>
|
||||
<style>
|
||||
/* Layered scrubber (plan §1): the native range stays for
|
||||
a11y (focus, arrow keys, aria) but its track is
|
||||
transparent so the tick/buffer/fill layers beneath show
|
||||
through. Static page CSS — templ owns the look. */
|
||||
#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }
|
||||
#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }
|
||||
#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
|
||||
#audio-scrub::-moz-range-track { background: transparent; border: none; }
|
||||
#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }
|
||||
</style>
|
||||
</head>
|
||||
<body
|
||||
x-data="audioReader"
|
||||
@@ -32,6 +44,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress") }
|
||||
>
|
||||
<!-- §17 wind-down dim: the sleep ticker eases this toward 35%
|
||||
black across a countdown's final 30s; pointer-events-none
|
||||
keeps the page usable, stop/finish resets it. -->
|
||||
<div class="fixed inset-0 pointer-events-none z-40"
|
||||
:style="'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'"
|
||||
></div>
|
||||
<div class="h-full flex flex-col items-center justify-center px-4 gap-6 select-none">
|
||||
<!-- Header: back to the detail page (reader-style exit) and the
|
||||
chapter launcher. Floating drawer, never in-flow (A2b). -->
|
||||
@@ -75,6 +93,86 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="helpOpen = !helpOpen"
|
||||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
|
||||
aria-label="Shortcuts & interactions"
|
||||
title="Shortcuts & interactions (?)"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
|
||||
<path d="M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="relative" @click.outside="settingsOpen = false">
|
||||
<button
|
||||
type="button"
|
||||
@click="settingsOpen = !settingsOpen"
|
||||
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
|
||||
:class="settingsOpen ? 'opacity-100' : 'opacity-90'"
|
||||
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
|
||||
aria-label="Player settings"
|
||||
title="Settings"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
x-show="settingsOpen"
|
||||
x-cloak
|
||||
x-transition
|
||||
class="absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border);"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2 px-1">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">Player settings</span>
|
||||
<button type="button" @click="settingsOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close settings">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18"></path>
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Option rows: autoplay first, panel structured for more -->
|
||||
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
|
||||
<span class="text-sm" style="color: var(--text-secondary);">Autoplay on open</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="autoplayOn ? 'true' : 'false'"
|
||||
@click="toggleAutoplay()"
|
||||
class="relative h-5 w-9 rounded-full transition-colors shrink-0"
|
||||
:style="autoplayOn ? 'background-color: var(--accent);' : 'background-color: var(--surface-hover); border: 1px solid var(--border);'"
|
||||
aria-label="Autoplay on open"
|
||||
>
|
||||
<span class="absolute top-0.5 left-0.5 h-4 w-4 rounded-full transition-transform" :class="autoplayOn ? 'translate-x-4' : ''" style="background-color: var(--text-primary);"></span>
|
||||
</button>
|
||||
</div>
|
||||
<!-- §12: default speed — always visible (unlike the
|
||||
Speed ▾ footer, which hides when the current rate
|
||||
IS the default), same setDefaultSpeed PUT -->
|
||||
<div class="flex items-center justify-between gap-3 px-1 py-2 rounded-lg">
|
||||
<span class="text-sm" style="color: var(--text-secondary);">Default playback speed</span>
|
||||
<select
|
||||
class="text-xs rounded-md px-1.5 py-1"
|
||||
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
aria-label="Default playback speed"
|
||||
@change="setDefaultSpeed(Number($event.target.value))"
|
||||
>
|
||||
for _, rate := range SpeedOptions {
|
||||
<option
|
||||
value={ fmt.Sprintf("%g", rate) }
|
||||
:selected={ fmt.Sprintf("Number(defaultSpeed) === %g", rate) }
|
||||
>{ fmt.Sprintf("%g×", rate) }</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -101,21 +199,96 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
<!-- Scrubber + transports -->
|
||||
<div class="w-full max-w-xl flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2 text-xs tabular-nums" style="color: var(--text-secondary);">
|
||||
<span x-text="fmtTime(currentTime)">0:00</span>
|
||||
<input
|
||||
id="audio-scrub"
|
||||
type="range"
|
||||
min="0"
|
||||
step="0.1"
|
||||
:max="chaptersDuration() || 1"
|
||||
:value="scrubbing ? scrubValue : currentTime"
|
||||
class="flex-1 accent-current"
|
||||
@input="scrubbing = true; scrubValue = Number($event.target.value)"
|
||||
@pointerup="seekTo(scrubValue); scrubbing = false"
|
||||
@change="seekTo(scrubValue); scrubbing = false"
|
||||
style="z-index: 20;"
|
||||
/>
|
||||
<span x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
|
||||
<span class="inline-block min-w-[4.75rem]" x-text="fmtTime(scrubbing ? scrubValue : currentTime)">0:00</span>
|
||||
<div
|
||||
id="scrub-area"
|
||||
class="relative flex-1 h-6 flex items-center cursor-pointer"
|
||||
style="touch-action: none;"
|
||||
@pointerdown="scrubPointerDown($event)"
|
||||
@pointermove="scrubPointerMove($event)"
|
||||
@pointerup="scrubPointerUp($event)"
|
||||
@pointercancel="scrubPointerUp($event)"
|
||||
@pointerleave="scrubPointerLeave()"
|
||||
@wheel.prevent="onScrubWheel($event)"
|
||||
>
|
||||
<!-- Base track -->
|
||||
<div class="absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" style="background-color: var(--surface-hover);"></div>
|
||||
<!-- Elapsed fill -->
|
||||
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'"></div>
|
||||
<!-- Buffered span: the ACTIVE track's element buffer only -->
|
||||
<template x-if="bufferSpan()">
|
||||
<div class="absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none" :style="'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'"></div>
|
||||
</template>
|
||||
<!-- Chapter ticks: density-decimated, current chapter tinted -->
|
||||
<div id="scrub-ticks" class="absolute inset-0 pointer-events-none">
|
||||
<template x-for="idx in tickIndices" :key="'tick' + idx">
|
||||
<div
|
||||
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full"
|
||||
:class="currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'"
|
||||
:style="'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'"
|
||||
></div>
|
||||
</template>
|
||||
</div>
|
||||
<!-- §9 magnet: the line a click would land on (hover
|
||||
inside the pixel radius locks the tooltip here) -->
|
||||
<div
|
||||
x-show="hoverSnapped && !scrubbing"
|
||||
x-cloak
|
||||
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none"
|
||||
:style="'left:' + hoverPct + '%;background-color:var(--accent);'"
|
||||
></div>
|
||||
<input
|
||||
id="audio-scrub"
|
||||
type="range"
|
||||
min="0"
|
||||
step="0.1"
|
||||
:max="chaptersDuration() || 1"
|
||||
:value="scrubbing ? scrubValue : currentTime"
|
||||
class="relative w-full"
|
||||
@input="scrubbing = true; scrubValue = Number($event.target.value)"
|
||||
@change="seekTo(scrubValue); scrubbing = false"
|
||||
aria-label="Seek position"
|
||||
/>
|
||||
<!-- Hover tooltip: time + chapter under the pointer -->
|
||||
<div
|
||||
x-show="hoverSeconds !== null && !scrubbing"
|
||||
x-cloak
|
||||
class="absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none"
|
||||
:style="'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'"
|
||||
>
|
||||
<span x-text="hoverSeconds !== null ? fmtTime(hoverSeconds) : ''"></span>
|
||||
<span style="color: var(--text-secondary);" x-text="hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''"></span>
|
||||
</div>
|
||||
<!-- Precision rail (§9): Shift+drag on mouse, or a touch
|
||||
drag pulled off the bar — ±5 min or tighter, free
|
||||
mapping inside. Edge labels self-document the window
|
||||
(floats over the transports, page never shifts) -->
|
||||
<div
|
||||
x-show="railVisible"
|
||||
x-cloak
|
||||
class="absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none"
|
||||
:style="'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')"
|
||||
>
|
||||
<div class="relative h-full">
|
||||
<div class="absolute inset-x-0 top-1/2 h-px" style="background-color: var(--border);"></div>
|
||||
<template x-for="tick in railTickList" :key="'rtick' + tick.start">
|
||||
<div class="absolute top-1 bottom-1 w-px" :style="'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'"></div>
|
||||
</template>
|
||||
<div class="absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full" :style="'left:' + railMarkerPct() + '%;background-color:var(--accent);'"></div>
|
||||
<div class="absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap" style="color: var(--text-primary);" x-text="fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)"></div>
|
||||
<div class="absolute left-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railFrom)"></div>
|
||||
<div class="absolute right-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railTo)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-block min-w-[4.75rem] text-right cursor-pointer"
|
||||
x-text="endTimeText()"
|
||||
:aria-label="endTimeAria()"
|
||||
:title="endTimeAria()"
|
||||
@click="cycleEndTime()"
|
||||
>0:00</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-3 md:gap-5">
|
||||
<button
|
||||
@@ -219,7 +392,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7.5rem]"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
|
||||
>
|
||||
<template x-for="secs in [1, 5, 10, 15, 30]" :key="'skip' + secs">
|
||||
<template x-for="secs in skipChoices" :key="'skip' + secs">
|
||||
<button
|
||||
type="button"
|
||||
@click="setSkip(secs); skipOpen = false"
|
||||
@@ -249,16 +422,26 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[7rem]"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
|
||||
>
|
||||
<template x-for="rate in [0.75, 1, 1.25, 1.5, 2]" :key="'speed' + rate">
|
||||
<template x-for="rate in speeds" :key="'speed' + rate">
|
||||
<button
|
||||
type="button"
|
||||
@click="setSpeed(rate); speedOpen = false"
|
||||
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||
:class="Number(speed) === rate ? 'font-semibold' : ''"
|
||||
:style="Number(speed) === rate ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||
x-text="rate + '×'"
|
||||
x-text="rate + '×' + (isDefaultSpeed(rate) ? ' · default' : '')"
|
||||
></button>
|
||||
</template>
|
||||
<!-- §8: pin the current rate as the user default —
|
||||
books without their own speed pick it up -->
|
||||
<button
|
||||
type="button"
|
||||
x-show="!isDefaultSpeed(speed)"
|
||||
@click="setDefaultSpeed(speed); speedOpen = false"
|
||||
class="w-full text-left px-3 py-1.5 mt-1 rounded-md text-xs border-t"
|
||||
style="border-color: var(--border); color: var(--text-secondary);"
|
||||
x-text="'Make ' + speed + '× my default'"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -275,6 +458,55 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
</svg>
|
||||
<span>Bookmark</span>
|
||||
</button>
|
||||
<div class="relative" @click.outside="sleepOpen = false">
|
||||
<button
|
||||
type="button"
|
||||
@click="sleepOpen = !sleepOpen"
|
||||
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
|
||||
:aria-label="'Sleep timer, currently ' + sleepLabelStr"
|
||||
style="color: var(--text-secondary);"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
|
||||
</svg>
|
||||
<span class="font-semibold tabular-nums" style="color: var(--text-primary);" x-text="sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr">Sleep</span>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
|
||||
</button>
|
||||
<div
|
||||
x-show="sleepOpen"
|
||||
x-cloak
|
||||
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[8.5rem]"
|
||||
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
|
||||
>
|
||||
<template x-for="opt in sleepChoices" :key="opt.key">
|
||||
<button
|
||||
type="button"
|
||||
@click="chooseSleep(opt); sleepOpen = false"
|
||||
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
|
||||
:class="isSleepActive(opt.key) ? 'font-semibold' : ''"
|
||||
:style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
|
||||
x-text="opt.label"
|
||||
></button>
|
||||
</template>
|
||||
<!-- §17 clock mode: stop at a wall-clock time (its next
|
||||
occurrence). The input drives chooseSleepClock(). -->
|
||||
<div class="flex items-center gap-1.5 px-2 pt-1.5 mt-1 border-t" style="border-color: var(--border);">
|
||||
<input
|
||||
type="time"
|
||||
x-model="sleepClockTime"
|
||||
class="text-xs px-1.5 py-1 rounded-md flex-1 min-w-0"
|
||||
style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
aria-label="Stop at time"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@click="chooseSleepClock(); sleepOpen = false"
|
||||
class="text-xs px-2.5 py-1 rounded-md font-semibold"
|
||||
:style="sleepArmedChoiceKey === 'clock' ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'background-color: var(--surface-hover); color: var(--text-secondary);'"
|
||||
>Arm</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" @click.outside="volumeOpen = false">
|
||||
<button
|
||||
type="button"
|
||||
@@ -453,10 +685,12 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
>
|
||||
<p class="text-sm font-semibold mb-2" style="color: var(--text-primary);">Bookmark at <span class="tabular-nums" x-text="fmtTime(currentTime)">0:00</span></p>
|
||||
<textarea
|
||||
id="bookmark-note"
|
||||
x-model="bookmarkNote"
|
||||
rows="3"
|
||||
placeholder="Optional note — markdown welcome (e.g. **great quote**, list, [link](https://…))"
|
||||
class="input mb-2 resize-y"
|
||||
@keydown.enter="if (!$event.shiftKey) { $event.preventDefault(); saveBookmark() }"
|
||||
></textarea>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" @click="bookmarkCaptureOpen = false" class="btn btn-ghost text-sm px-3 py-1.5">Cancel</button>
|
||||
@@ -464,6 +698,59 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Volume/mute OSD: brief confirmation for keyboard-driven changes -->
|
||||
<div
|
||||
x-show="osdVisible"
|
||||
x-cloak
|
||||
x-transition.opacity.duration.200ms
|
||||
class="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 text-xs px-3 py-1.5 rounded-full shadow-lg tabular-nums"
|
||||
style="background-color: var(--bg-secondary); color: var(--text-primary); border: 1px solid var(--border);"
|
||||
x-text="osdText"
|
||||
></div>
|
||||
|
||||
<!-- Shortcut help (?): rows come from the shared KEYMAP
|
||||
(audio-keys.ts) — the same table drives the keydown handler. -->
|
||||
<div x-show="helpOpen" x-cloak class="fixed inset-0 z-40" style="background-color: color-mix(in srgb, black 32%, transparent);" @click="helpOpen = false"></div>
|
||||
<div x-show="helpOpen" x-cloak x-transition class="fixed inset-0 z-50 grid place-items-center p-4" @click.outside="helpOpen = false">
|
||||
<div class="w-full max-w-md rounded-2xl shadow-2xl p-4 max-h-[80vh] overflow-y-auto" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
|
||||
<div class="flex items-center justify-between mb-3 px-1">
|
||||
<span class="text-sm font-semibold" style="color: var(--text-primary);">Shortcuts & interactions</span>
|
||||
<button type="button" @click="helpOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close shortcuts">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M18 6 6 18"></path>
|
||||
<path d="m6 6 12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<template x-for="spec in visibleKeymap()" :key="spec.action">
|
||||
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
|
||||
<span class="text-sm" style="color: var(--text-secondary);" x-text="spec.description"></span>
|
||||
<span class="flex items-center gap-1 shrink-0">
|
||||
<template x-for="k in spec.keys" :key="k.label">
|
||||
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="k.label"></kbd>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Pointer gestures (audio-keys.ts MOUSE_GUIDE/TOUCH_GUIDE):
|
||||
two sections — the bar has two scrub systems -->
|
||||
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Mouse</div>
|
||||
<template x-for="row in mouseGuide" :key="'m' + row.label">
|
||||
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
|
||||
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
|
||||
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
|
||||
</div>
|
||||
</template>
|
||||
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Touch</div>
|
||||
<template x-for="row in touchGuide" :key="'t' + row.label">
|
||||
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
|
||||
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
|
||||
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<audio id="audio-element" preload="auto" class="hidden"></audio>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -38,20 +38,20 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html/><html lang=\"en\"><head><title>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\"><head><title>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs("Listening · " + metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 16, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 16, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><script type=\"module\" src=\"/static/main.js\"></script></head>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · Bookhoard</title><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><link href=\"/static/style.css\" rel=\"stylesheet\"><link rel=\"icon\" type=\"image/svg+xml\" href=\"/static/favicon.svg\"><script type=\"module\" src=\"/static/main.js\"></script><style>\n\t\t\t\t/* Layered scrubber (plan §1): the native range stays for\n\t\t\t\t a11y (focus, arrow keys, aria) but its track is\n\t\t\t\t transparent so the tick/buffer/fill layers beneath show\n\t\t\t\t through. Static page CSS — templ owns the look. */\n\t\t\t\t#audio-scrub { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; }\n\t\t\t\t#audio-scrub::-webkit-slider-runnable-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t\t#audio-scrub::-moz-range-track { background: transparent; border: none; }\n\t\t\t\t#audio-scrub::-moz-range-thumb { height: 14px; width: 14px; border-radius: 9999px; background: var(--accent); border: 2px solid var(--bg-primary); box-shadow: 0 1px 4px rgba(0,0,0,0.4); }\n\t\t\t</style></head>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -80,7 +80,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 27, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 39, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -93,7 +93,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 28, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 40, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -106,7 +106,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 29, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 41, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -119,7 +119,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.FileURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 30, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 42, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -134,67 +134,116 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
"/api/media-items/"+metadata.MediaItemID+"/chapters",
|
||||
"/api/media-items/"+metadata.MediaItemID+"/progress"))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 33, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 45, Col: 57}
|
||||
}
|
||||
_, 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, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><!-- §17 wind-down dim: the sleep ticker eases this toward 35%\n\t\t\t black across a countdown's final 30s; pointer-events-none\n\t\t\t keeps the page usable, stop/finish resets it. --><div class=\"fixed inset-0 pointer-events-none z-40\" :style=\"'opacity:' + sleepDim + '; background: #000; transition: opacity 1s linear;'\"></div><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @click=\"helpOpen = !helpOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Shortcuts & interactions\" title=\"Shortcuts & interactions (?)\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><rect x=\"2\" y=\"4\" width=\"20\" height=\"16\" rx=\"2\"></rect> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if metadata.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<img src=\"")
|
||||
for _, rate := range SpeedOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 86, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 168, Col: 41}
|
||||
}
|
||||
_, 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, 11, "\" alt=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" :selected=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Number(defaultSpeed) === %g", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 87, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 70}
|
||||
}
|
||||
_, 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, 12, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</select></div></div></div></span></div><!-- Cover + title --><div class=\"flex flex-col items-center gap-3 text-center\"><div class=\"w-48 h-72 md:w-56 md:h-80 rounded-2xl overflow-hidden shadow-lg shrink-0 bg-cover bg-center\" style=\"background-color: var(--surface-hover);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if metadata.CoverImagePath != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 184, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" class=\"w-full h-full object-cover\" onerror=\"this.style.display='none'\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<span class=\"text-5xl flex items-center justify-center h-full\" style=\"color: var(--text-secondary);\">🎧</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</div><div class=\"max-w-md px-4\"><h1 class=\"font-semibold text-lg leading-snug line-clamp-2\" style=\"color: var(--text-primary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `audio_reader.templ`, Line: 96, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 194, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></Line truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" :style=\"'background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;' + (railLiftY ? ' transform: translateY(' + railLiftY + 'px);' : '')\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button typeLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+81
-13
@@ -5,10 +5,32 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
// NOTE: do not import "github.com/a-h/templ" here — templ's generator
|
||||
// already emits that import into the generated file, and a second copy
|
||||
// is a compile error (redeclared block).
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// cssURL neutralizes characters that would break an UNQUOTED CSS url()
|
||||
// token (whitespace, quotes, parens, backslash). The stored cover paths
|
||||
// are already percent-encoded URL paths, so this is defensive only — but
|
||||
// it must exist: templ's codegen HTML-escapes style attribute values a
|
||||
// second time, so ANY quote character (SafeCSS included) reaches the DOM
|
||||
// as literal ' text and the browser never parses the url() at all
|
||||
// (2026-10-21: every hero backdrop broken + /media/& 500 spam, because
|
||||
// Firefox resolved the mangled token relative to the page URL).
|
||||
func cssURL(s string) string {
|
||||
return strings.NewReplacer(
|
||||
" ", "%20",
|
||||
"(", "%28",
|
||||
")", "%29",
|
||||
"'", "%27",
|
||||
"\"", "%22",
|
||||
"\\", "%5C",
|
||||
).Replace(s)
|
||||
}
|
||||
|
||||
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
@@ -30,10 +52,27 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
|
||||
>
|
||||
@Header(user, "/media/{ uuidToString(book.ID) }")
|
||||
if book.ActiveConflict != nil {
|
||||
<!-- Sync-conflict banner (user request): the progress
|
||||
card's warning sits far down a long page — this
|
||||
strip is unmissable at the very top, and clicking
|
||||
it opens the same resolution modal. -->
|
||||
<button
|
||||
type="button"
|
||||
@click="showProgressSyncModal()"
|
||||
class="w-full flex items-center gap-3 px-4 sm:px-6 lg:px-8 py-3 text-left"
|
||||
style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border-bottom: 1px solid var(--status-warning);"
|
||||
>
|
||||
@Icon("alert", "h-5 w-5 shrink-0")
|
||||
<span class="text-sm" style="color: var(--text-primary);">
|
||||
Progress sync conflict detected on this item — click to review and resolve.
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
<!-- Hero with blurred cover backdrop -->
|
||||
<div class="relative overflow-hidden">
|
||||
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
|
||||
<div class="hero-backdrop" style={ "background-image: url('" + book.CoverImagePath.String + "')" }></div>
|
||||
<div class="hero-backdrop" style={ templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")") }></div>
|
||||
}
|
||||
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
|
||||
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
|
||||
@@ -63,14 +102,33 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
<!-- Details -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-3xl md:text-4xl font-bold tracking-tight mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
|
||||
if book.Author.Valid && book.Author.String != "" {
|
||||
if book.Subtitle.Valid && book.Subtitle.String != "" {
|
||||
<p class="text-lg mb-2" style="color: var(--text-secondary)">{ book.Subtitle.String }</p>
|
||||
}
|
||||
if len(book.Authors) > 0 {
|
||||
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
||||
by
|
||||
for i, authorName := range book.Authors {
|
||||
if i > 0 {
|
||||
<span>, </span>
|
||||
}
|
||||
<a href={ "/bookshelf?author_filter=" + url.QueryEscape(authorName) } class="hover:underline" style="color: var(--text-secondary);">{ authorName }</a>
|
||||
}
|
||||
</h2>
|
||||
} else if book.Author.Valid && book.Author.String != "" {
|
||||
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
|
||||
by { book.Author.String }
|
||||
</h2>
|
||||
}
|
||||
if book.IsAudio() && len(book.Narrators) > 0 {
|
||||
<h3 class="text-sm md:text-base mb-5" style="color: var(--text-secondary)">
|
||||
Narrated by { strings.Join(book.Narrators, ", ") }
|
||||
Narrated by
|
||||
for i, narratorName := range book.Narrators {
|
||||
if i > 0 {
|
||||
<span>, </span>
|
||||
}
|
||||
@FacetLink("narrator", narratorName, narratorName)
|
||||
}
|
||||
</h3>
|
||||
}
|
||||
<!-- Action buttons -->
|
||||
@@ -184,6 +242,9 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
}
|
||||
<!-- Badges: series, direction, tags -->
|
||||
<div class="flex flex-wrap items-center gap-2 mb-3">
|
||||
if book.Explicit {
|
||||
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);" title="Marked explicit in source metadata">Explicit</span>
|
||||
}
|
||||
if book.Series.Valid && book.Series.String != "" {
|
||||
<a
|
||||
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
|
||||
@@ -191,8 +252,8 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
|
||||
>
|
||||
{ book.Series.String }
|
||||
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
||||
#{ book.SeriesNumber.Int32 }
|
||||
if book.SeriesNumber.Valid && book.SeriesNumber.Float64 > 0 {
|
||||
#{ fmtNumber(book.SeriesNumber.Float64) }
|
||||
}
|
||||
</a>
|
||||
}
|
||||
@@ -200,13 +261,13 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">{ strings.ToUpper(book.ReadingDirection.String) }</span>
|
||||
}
|
||||
if book.AgeRating.Valid && book.AgeRating.String != "" {
|
||||
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.AgeRating.String }</span>
|
||||
@FacetBadge("age_rating", book.AgeRating.String, book.AgeRating.String)
|
||||
}
|
||||
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
||||
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">B&W</span>
|
||||
}
|
||||
if book.StoryArc.Valid && book.StoryArc.String != "" {
|
||||
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">{ book.StoryArc.String }</span>
|
||||
@FacetBadge("story_arc", book.StoryArc.String, book.StoryArc.String)
|
||||
}
|
||||
</div>
|
||||
if len(book.Tags) > 0 {
|
||||
@@ -361,7 +422,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
if book.Publisher.Valid && book.Publisher.String != "" {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Publisher</p>
|
||||
<p style="color: var(--text-primary)">{ book.Publisher.String }</p>
|
||||
<p style="color: var(--text-primary)">@FacetLink("publisher", book.Publisher.String, book.Publisher.String)</p>
|
||||
</div>
|
||||
}
|
||||
if book.DatePublished.Valid {
|
||||
@@ -379,13 +440,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
if book.Language.Valid && book.Language.String != "" {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Language</p>
|
||||
<p class="capitalize" style="color: var(--text-primary)">{ book.Language.String }</p>
|
||||
<p style="color: var(--text-primary)">@FacetLink("language", book.Language.String, languageDisplay(book.Language.String))</p>
|
||||
</div>
|
||||
}
|
||||
if book.IsAudio() && len(book.Narrators) > 0 {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Narrator</p>
|
||||
<p style="color: var(--text-primary)">{ strings.Join(book.Narrators, ", ") }</p>
|
||||
<p style="color: var(--text-primary)">
|
||||
for i, narratorName := range book.Narrators {
|
||||
if i > 0 {
|
||||
<span>, </span>
|
||||
}
|
||||
@FacetLink("narrator", narratorName, narratorName)
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
if book.IsAudio() && book.AudioDuration() > 0 {
|
||||
@@ -403,7 +471,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
if book.Edition.Valid && book.Edition.String != "" {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
|
||||
<p style="color: var(--text-primary)">{ book.Edition.String }</p>
|
||||
<p style="color: var(--text-primary)">@FacetLink("edition", book.Edition.String, book.Edition.String)</p>
|
||||
</div>
|
||||
}
|
||||
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
|
||||
@@ -415,7 +483,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
if book.Genre.Valid && book.Genre.String != "" {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Genre</p>
|
||||
<p style="color: var(--text-primary)">{ book.Genre.String }</p>
|
||||
<p style="color: var(--text-primary)">@FacetLink("genre", book.Genre.String, prettifyGenre(book.Genre.String))</p>
|
||||
</div>
|
||||
}
|
||||
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
||||
@@ -433,7 +501,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tomb
|
||||
if book.Imprint.Valid && book.Imprint.String != "" {
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Imprint</p>
|
||||
<p style="color: var(--text-primary)">{ book.Imprint.String }</p>
|
||||
<p style="color: var(--text-primary)">@FacetLink("imprint", book.Imprint.String, book.Imprint.String)</p>
|
||||
</div>
|
||||
}
|
||||
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
|
||||
|
||||
@@ -323,9 +323,13 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Title</label>
|
||||
<input type="text" name="title" value={ book.Title } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Subtitle</label>
|
||||
<input type="text" name="subtitle" value={ textToString(book.Subtitle) } class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Author</label>
|
||||
<input type="text" name="author" value={ textToString(book.Author) } class="input" />
|
||||
<input type="text" name="authors" value={ authorsFormValue(book.Authors, textToString(book.Author)) } class="input" placeholder="Comma-separated names"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Description</label>
|
||||
@@ -400,7 +404,7 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<div x-show="openSections.publication" x-transition class="p-4 space-y-3 border-t" style="border-color: var(--border);">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Publisher</label>
|
||||
<input type="text" name="publisher" value={ textToString(book.Publisher) } class="input" />
|
||||
<input type="text" name="publishers" value={ authorsFormValue(book.Publishers, textToString(book.Publisher)) } class="input" placeholder="Comma-separated publishers"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Date Published</label>
|
||||
@@ -446,8 +450,8 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Number</label>
|
||||
<input type="number" name="series_number"
|
||||
value={ fmt.Sprintf("%d", book.SeriesNumber.Int32) }
|
||||
<input type="number" name="series_number" step="any"
|
||||
value={ fmtNumber(book.SeriesNumber.Float64) }
|
||||
class="input" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -524,6 +528,12 @@ templ MetadataEditorModal(book handlers.MediaDetail) {
|
||||
<span>Abridged edition</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||
<input type="checkbox" name="explicit" checked={ book.Explicit } class="h-4 w-4 accent-current"/>
|
||||
<span>Explicit content</span>
|
||||
</label>
|
||||
</div>
|
||||
if book.AudioDuration() > 0 {
|
||||
<div>
|
||||
<label class="block text-xs font-semibold uppercase tracking-wide mb-1" style="color: var(--text-secondary);">Duration (scanned)</label>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+687
-616
File diff suppressed because it is too large.
Load diff
+125
-107
@@ -170,114 +170,107 @@ templ BookShelf(
|
||||
}
|
||||
</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)">Narrator</label>
|
||||
<input
|
||||
type="text"
|
||||
name="narrator_filter"
|
||||
placeholder="Filter by narrator"
|
||||
class="input"
|
||||
list="narrator-datalist"
|
||||
@input.debounce.300ms="if($el.value.length >= 2) fetchNarratorValues($el)"
|
||||
/>
|
||||
<datalist id="narrator-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="{
|
||||
'state-null': hasCoverState === null,
|
||||
'state-true': hasCoverState === true,
|
||||
'state-false': hasCoverState === false
|
||||
}"
|
||||
: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'"
|
||||
/>
|
||||
<!-- The rule builder (faceted plan Phase 9): the drawer's
|
||||
ONLY filter UI. Templ owns the look, Alpine owns the
|
||||
rule-tree state (the sanctioned island — same pattern
|
||||
as the chips bar; server JSON rendered by Alpine, no
|
||||
innerHTML templating). Rows are a flat projection of
|
||||
the tree (depth = indent); groups nest via + group. -->
|
||||
<div x-data="filterBuilderExtra" x-cloak
|
||||
x-effect="if (filtersOpen === true) { ensureReadyRule(); }"
|
||||
>
|
||||
<template x-for="row in builderRows" :key="row.key">
|
||||
<div>
|
||||
<template x-if="row.type === 'groupOpen'">
|
||||
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-1" :style="`margin-left:${row.depth * 14}px`">
|
||||
<span class="text-[11px] font-semibold uppercase tracking-wide" style="color: var(--text-secondary)" x-text="row.depth === 0 ? 'Match' : 'Group'"></span>
|
||||
<span class="flex-1"></span>
|
||||
<button type="button" class="fb-mini-btn" @click="addRule(row.path)">+ rule</button>
|
||||
<button type="button" class="fb-mini-btn" @click="addGroup(row.path)">+ group</button>
|
||||
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove group">×</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="row.type === 'rule'">
|
||||
<div :class="row.depth > 0 ? 'fb-nested' : ''" class="flex items-center gap-1.5 flex-wrap py-0.5" :style="`margin-left:${(row.depth + 1) * 14}px`">
|
||||
<button type="button" class="fb-connective fb-connective-btn" :class="row.chipClickable ? '' : 'fb-chip-static'" x-show="!row.isFirst" x-text="row.chip" @click="if (row.chipClickable) toggleRuleConnective(row.path)" :title="row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip"></button>
|
||||
<!-- field picker: grouped menu -->
|
||||
<div class="relative">
|
||||
<button type="button" class="fb-picker" :class="{'fb-picker-active': row.rule.field !== ''}" @click="toggleMenu(row.key + ':field')" x-text="fieldLabelOf(row.rule.field)"></button>
|
||||
<div class="fb-menu fb-menu-fields" x-show="openMenu === row.key + ':field'" @click.outside="openMenu = ''">
|
||||
<div class="fb-menu-filter-wrap">
|
||||
<input type="text" class="fb-menu-filter" placeholder="Filter fields…" x-model="fieldFilter" @keydown.enter.prevent @click.stop/>
|
||||
</div>
|
||||
<div class="fb-menu-scroll">
|
||||
<template x-for="grp in visibleFieldGroups" :key="grp.group">
|
||||
<div>
|
||||
<div class="fb-menu-group" x-text="grp.group"></div>
|
||||
<template x-for="f in grp.fields" :key="f.key">
|
||||
<button type="button" class="fb-menu-item" :class="row.rule.field === f.key ? 'fb-menu-item-active' : ''" @click="setRuleField(row.path, f.key)" x-text="f.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fb-menu-empty" x-show="visibleFieldGroups.length === 0">No matching fields</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- operator picker (negation lives here) -->
|
||||
<div class="relative">
|
||||
<button type="button" class="fb-picker fb-picker-op" :class="{'fb-negated-text': isNegated(row.rule)}" @click="toggleMenu(row.key + ':op')" x-text="opLabelOf(row.rule)"></button>
|
||||
<div class="fb-menu" x-show="openMenu === row.key + ':op'" @click.outside="openMenu = ''">
|
||||
<template x-for="o in opsFor(row.rule.field)" :key="o.key">
|
||||
<button type="button" class="fb-menu-item" :class="[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']" @click="setRuleOp(row.path, o.key)" x-text="o.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<!-- value cell: shape follows the operator + field kind -->
|
||||
<template x-if="hasValueCell(row.rule) && valueArity(row.rule) === 'two'">
|
||||
<span class="flex items-center gap-1">
|
||||
<input type="text" class="input fb-value" :value="row.rule.values[0] || ''" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" @click.outside="suggestionsOpen = ''" placeholder="From"/>
|
||||
<span class="text-xs" style="color: var(--text-secondary)">and</span>
|
||||
<input type="text" class="input fb-value" :value="row.rule.values[1] || ''" @input.debounce.250ms="setRuleValue(row.path, 1, $el.value)" placeholder="To"/>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')">
|
||||
<span class="fb-value-cell relative flex items-center gap-1">
|
||||
<input type="text" class="input fb-value" :value="row.rule.values.join(',')" @input.debounce.250ms="setRuleValue(row.path, 0, $el.value)" @focus="openValueMenu(row.key, row.path, row.rule)" :placeholder="fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'"/>
|
||||
<template x-if="fieldMeta(row.rule.field)?.valueKind === 'days'">
|
||||
<span class="text-xs whitespace-nowrap" style="color: var(--text-secondary)" x-text="'days'"></span>
|
||||
</template>
|
||||
<!-- field-aware value menu: server JSON (the other
|
||||
rules bound), or the field's fixed options.
|
||||
Clicks anywhere inside the value cell never close
|
||||
it — the opening focus-click must not race the
|
||||
outside handler. -->
|
||||
<div class="fb-menu fb-menu-value" x-show="suggestionShown(row.key)" @click.outside="if (!$event.target.closest('.fb-value-cell')) suggestionsOpen = ''">
|
||||
<template x-for="s in (suggestions[row.key] || [])" :key="s.value">
|
||||
<button type="button" class="fb-menu-item" @click="pickSuggestion(row.key, row.path, s)">
|
||||
<span x-text="s.display || s.value"></span>
|
||||
<span class="text-xs" style="color: var(--text-secondary)" x-show="s.count > 0" x-text="s.count + ' books'"></span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="fb-menu-empty" x-show="suggestionsLoading === row.key">Loading…</div>
|
||||
<div class="fb-menu-empty" x-show="suggestionsLoading !== row.key && !(suggestions[row.key] || []).length">No matching values</div>
|
||||
</div>
|
||||
</span>
|
||||
</template>
|
||||
<!-- delete rule -->
|
||||
<button type="button" class="fb-mini-btn fb-danger" @click="removeNode(row.path)" aria-label="Remove rule">×</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="row.type === 'groupClose' && row.depth > 0">
|
||||
<div class="fb-group-end" :style="`margin-left:${row.depth * 14}px`"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<!-- footer: add rule/group + the debounced live count
|
||||
(the composed engine's true COUNT — what the grid will
|
||||
show once applied) -->
|
||||
<div class="flex items-center gap-2 pt-2 flex-wrap">
|
||||
<button type="button" class="fb-mini-btn" @click="addRule([])">+ Rule</button>
|
||||
<button type="button" class="fb-mini-btn" @click="addGroup([])">+ Group</button>
|
||||
<span class="flex-1"></span>
|
||||
<span class="text-xs" style="color: var(--text-secondary)" x-show="matchCount !== null" x-text="matchCountLoading ? 'counting…' : (matchCount === 1 ? '1 book matches' : matchCount + ' books match')"></span>
|
||||
</div>
|
||||
</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);">
|
||||
@@ -286,6 +279,31 @@ templ BookShelf(
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<!-- Active-filter chips: a pure function of the client-side
|
||||
form state (Alpine renders them, templ owns the look —
|
||||
sanctioned TS island). Removing a chip clears that facet
|
||||
and re-applies the form. -->
|
||||
<div
|
||||
id="active-filter-chips"
|
||||
class="flex flex-wrap items-center gap-2 mb-4"
|
||||
x-show="activeFilters.length > 0"
|
||||
x-cloak
|
||||
>
|
||||
<template x-for="chip in activeFilters" :key="chip.param">
|
||||
<button
|
||||
type="button"
|
||||
class="badge inline-flex items-center gap-1.5 hover:opacity-80 transition-opacity cursor-pointer"
|
||||
:class="chip.negated ? 'fb-chip-negated' : ''"
|
||||
:style="chip.negated
|
||||
? 'background-color: color-mix(in srgb, var(--status-danger) 12%, transparent); color: var(--status-danger); border: 1px solid color-mix(in srgb, var(--status-danger) 40%, transparent);'
|
||||
: 'background-color: var(--accent-muted); color: var(--accent); border: 1px solid transparent;'"
|
||||
@click="removeFilterChip(chip.param)"
|
||||
>
|
||||
<span x-text="chip.label"></span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div id="books-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
@BooksGrid(books, limit, offset, count, currentLibraryID)
|
||||
if errorMessage != "" {
|
||||
|
||||
@@ -61,7 +61,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -119,7 +119,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(uuidToString(filter.ID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 93, Col: 135}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 93, Col: 135}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -132,7 +132,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(filter.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 95, Col: 25}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 95, Col: 25}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -195,7 +195,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 153, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 153, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -213,7 +213,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(TotalMediaCount(libraries))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 155, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 155, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -234,7 +234,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -247,7 +247,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -260,7 +260,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 160, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 160, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -278,7 +278,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -291,7 +291,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -304,7 +304,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 162, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 162, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -323,7 +323,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 34}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -336,7 +336,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 56}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 56}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -349,7 +349,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 165, Col: 76}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 165, Col: 76}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -367,7 +367,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 34}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -380,7 +380,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -393,7 +393,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(lib.MediaCount)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 167, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 167, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -406,7 +406,7 @@ func BookShelf(
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</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)\">Narrator</label> <input type=\"text\" name=\"narrator_filter\" placeholder=\"Filter by narrator\" class=\"input\" list=\"narrator-datalist\" @input.debounce.300ms=\"if($el.value.length >= 2) fetchNarratorValues($el)\"> <datalist id=\"narrator-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, 39, "</select></div><!-- The rule builder (faceted plan Phase 9): the drawer's\n\t\t\t\t\t\t\t\t\tONLY filter UI. Templ owns the look, Alpine owns the\n\t\t\t\t\t\t\t\t\trule-tree state (the sanctioned island — same pattern\n\t\t\t\t\t\t\t\t\tas the chips bar; server JSON rendered by Alpine, no\n\t\t\t\t\t\t\t\t\tinnerHTML templating). Rows are a flat projection of\n\t\t\t\t\t\t\t\t\tthe tree (depth = indent); groups nest via + group. --><div x-data=\"filterBuilderExtra\" x-cloak x-effect=\"if (filtersOpen === true) { ensureReadyRule(); }\"><template x-for=\"row in builderRows\" :key=\"row.key\"><div><template x-if=\"row.type === 'groupOpen'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-1\" :style=\"`margin-left:${row.depth * 14}px`\"><span class=\"text-[11px] font-semibold uppercase tracking-wide\" style=\"color: var(--text-secondary)\" x-text=\"row.depth === 0 ? 'Match' : 'Group'\"></span> <span class=\"flex-1\"></span> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addRule(row.path)\">+ rule</button> <button type=\"button\" class=\"fb-mini-btn\" @click=\"addGroup(row.path)\">+ group</button> <button type=\"button\" class=\"fb-mini-btn fb-danger\" @click=\"removeNode(row.path)\" aria-label=\"Remove group\">×</button></div></template><template x-if=\"row.type === 'rule'\"><div :class=\"row.depth > 0 ? 'fb-nested' : ''\" class=\"flex items-center gap-1.5 flex-wrap py-0.5\" :style=\"`margin-left:${(row.depth + 1) * 14}px`\"><button type=\"button\" class=\"fb-connective fb-connective-btn\" :class=\"row.chipClickable ? '' : 'fb-chip-static'\" x-show=\"!row.isFirst\" x-text=\"row.chip\" @click=\"if (row.chipClickable) toggleRuleConnective(row.path)\" :title=\"row.chipClickable ? 'Switch this rule between AND / OR' : 'This rule joins with ' + row.chip\"></button><!-- field picker: grouped menu --><div class=\"relative\"><button type=\"button\" class=\"fb-picker\" :class=\"{'fb-picker-active': row.rule.field !== ''}\" @click=\"toggleMenu(row.key + ':field')\" x-text=\"fieldLabelOf(row.rule.field)\"></button><div class=\"fb-menu fb-menu-fields\" x-show=\"openMenu === row.key + ':field'\" @click.outside=\"openMenu = ''\"><div class=\"fb-menu-filter-wrap\"><input type=\"text\" class=\"fb-menu-filter\" placeholder=\"Filter fields…\" x-model=\"fieldFilter\" @keydown.enter.prevent @click.stop></div><div class=\"fb-menu-scroll\"><template x-for=\"grp in visibleFieldGroups\" :key=\"grp.group\"><div><div class=\"fb-menu-group\" x-text=\"grp.group\"></div><template x-for=\"f in grp.fields\" :key=\"f.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"row.rule.field === f.key ? 'fb-menu-item-active' : ''\" @click=\"setRuleField(row.path, f.key)\" x-text=\"f.label\"></button></template></div></template><div class=\"fb-menu-empty\" x-show=\"visibleFieldGroups.length === 0\">No matching fields</div></div></div></div><!-- operator picker (negation lives here) --><div class=\"relative\"><button type=\"button\" class=\"fb-picker fb-picker-op\" :class=\"{'fb-negated-text': isNegated(row.rule)}\" @click=\"toggleMenu(row.key + ':op')\" x-text=\"opLabelOf(row.rule)\"></button><div class=\"fb-menu\" x-show=\"openMenu === row.key + ':op'\" @click.outside=\"openMenu = ''\"><template x-for=\"o in opsFor(row.rule.field)\" :key=\"o.key\"><button type=\"button\" class=\"fb-menu-item\" :class=\"[row.rule.op === o.key ? 'fb-menu-item-active' : '', o.negated ? 'fb-negated-text' : '']\" @click=\"setRuleOp(row.path, o.key)\" x-text=\"o.label\"></button></template></div></div><!-- value cell: shape follows the operator + field kind --><template x-if=\"hasValueCell(row.rule) && valueArity(row.rule) === 'two'\"><span class=\"flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[0] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" @click.outside=\"suggestionsOpen = ''\" placeholder=\"From\"> <span class=\"text-xs\" style=\"color: var(--text-secondary)\">and</span> <input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values[1] || ''\" @input.debounce.250ms=\"setRuleValue(row.path, 1, $el.value)\" placeholder=\"To\"></span></template><template x-if=\"hasValueCell(row.rule) && (valueArity(row.rule) === 'one' || valueArity(row.rule) === 'many')\"><span class=\"fb-value-cell relative flex items-center gap-1\"><input type=\"text\" class=\"input fb-value\" :value=\"row.rule.values.join(',')\" @input.debounce.250ms=\"setRuleValue(row.path, 0, $el.value)\" @focus=\"openValueMenu(row.key, row.path, row.rule)\" :placeholder=\"fieldMeta(row.rule.field)?.valueKind === 'days' ? 'days' : 'value'\"><template x-if=\"fieldMeta(row.rule.field)?.valueKind === 'days'\"><span class=\"text-xs whitespace-nowrap\" style=\"color: var(--text-secondary)\" x-text=\"'days'\"></span></template><!-- field-awarLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -422,7 +422,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(errorMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 293, Col: 59}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 311, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -445,7 +445,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var21 string
|
||||
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 {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 302, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 320, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -476,7 +476,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(offset/limit + 1)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 311, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 329, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -489,7 +489,7 @@ func BookShelf(
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, 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 {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `bookshelf.templ`, Line: 316, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/bookshelf.templ`, Line: 334, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -41,7 +41,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(pageTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 14, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 14, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -63,7 +63,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -84,7 +84,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var5 templ.SafeURL
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinURLErrs(backUrl)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 22, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 22, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -105,7 +105,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(backLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 24, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 24, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -118,7 +118,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(badgeIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -131,7 +131,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(badgeLabel)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 31, Col: 32}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 31, Col: 32}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -144,7 +144,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 33, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 33, Col: 93}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -157,7 +157,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(books)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 34, Col: 125}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 34, Col: 125}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -190,7 +190,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(emptyIcon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 44, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 44, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -203,7 +203,7 @@ func BrowseDetail(user User, badgeIcon string, badgeLabel string, title string,
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(emptyMessage)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `browse_detail.templ`, Line: 46, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/browse_detail.templ`, Line: 46, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -72,6 +72,18 @@ templ CollectionModal(collection CollectionData) {
|
||||
<button type="button" @click="selectColor('purple')" data-color="purple" class="w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400" aria-label="Purple"></button>
|
||||
</div>
|
||||
<input type="hidden" name="color" id="collection-color" value={ collection.Color }/>
|
||||
<div class="mb-4 space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||
<input type="hidden" name="hide_finished" value="false"/>
|
||||
<input type="checkbox" name="hide_finished" value="true" checked={ collection.HideFinished } class="h-4 w-4 accent-current"/>
|
||||
<span>Hide finished books (reading-queue mode)</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm" style="color: var(--text-primary);">
|
||||
<input type="hidden" name="show_up_next" value="false"/>
|
||||
<input type="checkbox" name="show_up_next" value="true" checked={ collection.ShowUpNext } class="h-4 w-4 accent-current"/>
|
||||
<span>Show Up Next strip</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" @click="closeCollectionModal()" class="btn btn-secondary">Cancel</button>
|
||||
|
||||
@@ -72,7 +72,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/collections/" + collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 23, Col: 49}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 23, Col: 49}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -85,7 +85,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 26, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 26, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -98,7 +98,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 32, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 32, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -111,7 +111,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 45, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 45, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -124,7 +124,7 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 58, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 58, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -137,36 +137,62 @@ func CollectionModal(collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 74, Col: 86}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 74, Col: 86}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "\"><div class=\"mb-4 space-y-2\"><label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"hide_finished\" value=\"false\"> <input type=\"checkbox\" name=\"hide_finished\" value=\"true\" checked=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.HideFinished)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_modal.templ`, Line: 135, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 78, Col: 98}
|
||||
}
|
||||
_, 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, 15, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\" class=\"h-4 w-4 accent-current\"> <span>Hide finished books (reading-queue mode)</span></label> <label class=\"flex items-center gap-2 text-sm\" style=\"color: var(--text-primary);\"><input type=\"hidden\" name=\"show_up_next\" value=\"false\"> <input type=\"checkbox\" name=\"show_up_next\" value=\"true\" checked=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ShowUpNext)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 83, Col: 95}
|
||||
}
|
||||
_, 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, 15, "\" class=\"h-4 w-4 accent-current\"> <span>Show Up Next strip</span></label></div></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Update Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<form hx-post=\"/api/collections\" hx-redirect=\"/collections\"><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Type</label><div class=\"grid grid-cols-2 gap-2\"><label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"static\" checked class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">Static</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Pick books by hand</p></div></label> <label class=\"relative cursor-pointer\"><input type=\"radio\" name=\"collection_type\" value=\"smart\" class=\"peer sr-only\"><div class=\"p-3 rounded-lg border text-center peer-checked:border-blue-500 peer-checked:ring-2 peer-checked:ring-blue-400\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"><span class=\"font-semibold text-sm\" style=\"color: var(--text-primary)\">⚡ Smart</span><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">Match books by rules — always live</p></div></label></div></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Name</label> <input type=\"text\" name=\"name\" required class=\"input\" placeholder=\"My Reading List\"></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Description</label> <textarea name=\"description\" class=\"input\" placeholder=\"Optional description\" rows=\"3\"></textarea></div><div class=\"mb-4\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Icon</label> <input type=\"text\" id=\"icon-search\" class=\"input mb-2\" placeholder=\"Search or type emoji...\" maxlength=\"4\" @input=\"filterIcons($event.target.value)\" @focus=\"showAllIcons()\"> <input type=\"hidden\" name=\"icon\" id=\"collection-icon\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_modal.templ`, Line: 147, Col: 83}
|
||||
}
|
||||
_, 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, 17, "\"><div id=\"icon-grid\" class=\"grid grid-cols-8 gap-1 max-h-32 overflow-y-auto p-2 rounded-lg border\" style=\"background-color: var(--bg-primary); border-color: var(--border);\"></div></div><div class=\"mb-6\"><label class=\"block text-xs font-semibold uppercase tracking-wide mb-2\" style=\"color: var(--text-secondary)\">Color</label><div class=\"flex gap-2\"><button type=\"button\" @click=\"selectColor('blue')\" data-color=\"blue\" class=\"w-8 h-8 rounded-full color-option bg-blue-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-blue-400\" aria-label=\"Blue\"></button> <button type=\"button\" @click=\"selectColor('red')\" data-color=\"red\" class=\"w-8 h-8 rounded-full color-option bg-red-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-red-400\" aria-label=\"Red\"></button> <button type=\"button\" @click=\"selectColor('yellow')\" data-color=\"yellow\" class=\"w-8 h-8 rounded-full color-option bg-yellow-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-yellow-400\" aria-label=\"Yellow\"></button> <button type=\"button\" @click=\"selectColor('green')\" data-color=\"green\" class=\"w-8 h-8 rounded-full color-option bg-green-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-green-400\" aria-label=\"Green\"></button> <button type=\"button\" @click=\"selectColor('purple')\" data-color=\"purple\" class=\"w-8 h-8 rounded-full color-option bg-purple-500 hover:ring-2 hover:ring-offset-2 hover:ring-offset-transparent hover:ring-purple-400\" aria-label=\"Purple\"></button></div><input type=\"hidden\" name=\"color\" id=\"collection-color\" value=\"blue\"></div><div class=\"flex justify-end space-x-3\"><button type=\"button\" @click=\"closeCollectionModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"submit\" class=\"btn btn-primary\">Create Collection</button></div></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 9, Col: 46}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 9, Col: 46}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -58,7 +58,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -71,7 +71,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("initCollectionRules('" + collection.ID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 14, Col: 120}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 14, Col: 120}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -100,7 +100,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("background-color: var(--accent-muted); color: " + collection.Color)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 159}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 159}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -113,7 +113,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 23, Col: 179}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 23, Col: 179}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -126,7 +126,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(collection.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 25, Col: 90}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 25, Col: 90}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -147,7 +147,7 @@ func CollectionRules(user User, collection CollectionData) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(collection.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `collection_rules.templ`, Line: 49, Col: 67}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/collection_rules.templ`, Line: 49, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -36,6 +36,12 @@ templ CollectionBooksGrid(collection CollectionData, books []handlers.BookInfo)
|
||||
@change={ "toggleSelection(" + JSONArg(book.MediaItemID) + ")" }
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-col items-center gap-0.5 mt-1">
|
||||
<button type="button" class="icon-btn text-xs" title="Move earlier (playlist order)"
|
||||
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", -1)" }>↑</button>
|
||||
<button type="button" class="icon-btn text-xs" title="Move later (playlist order)"
|
||||
@click={ "moveCollectionBook(" + JSONArg(book.MediaItemID) + ", 1)" }>↓</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="flex-1 min-w-0">
|
||||
|
||||
+430
-404
File diff suppressed because it is too large.
Load diff
@@ -60,7 +60,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 25, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var2)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -73,7 +73,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ResolutionStatus)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 25, Col: 114}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 25, Col: 114}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -86,7 +86,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(conflict.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 31, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 31, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -107,7 +107,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.MediaItemTitle)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 39, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 39, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -120,7 +120,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ConflictType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 40, Col: 96}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 40, Col: 96}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -133,7 +133,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue("showResolveModal(" + JSONArg(conflict.ID) + ")")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 44, Col: 73}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 44, Col: 73}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -146,7 +146,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(conflict.CreatedAt, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 47, Col: 70}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 47, Col: 70}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -164,7 +164,7 @@ func ConflictsListContent(user User, conflicts []handlers.ConflictDetailResponse
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(conflict.ResolvedBy)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 49, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 49, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -213,7 +213,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(total)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 62, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 62, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -226,7 +226,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(unresolved)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 66, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 66, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -239,7 +239,7 @@ func ConflictStatsBar(total int, unresolved int) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(total - unresolved)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 70, Col: 97}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 70, Col: 97}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -339,7 +339,7 @@ func Conflicts(user User, conflicts []handlers.ConflictDetailResponse, total int
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var16).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `conflicts.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/conflicts.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -45,7 +45,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -87,7 +87,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(lib.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -100,7 +100,7 @@ func CustomSectionBuilder(user User, libraries []LibraryData, errorMessage strin
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(lib.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `custom_section.templ`, Line: 74, Col: 44}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/custom_section.templ`, Line: 74, Col: 44}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -50,7 +50,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -88,7 +88,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", unlinkedCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 37, Col: 69}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 37, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -101,7 +101,7 @@ func Dashboard(user User, sections []handlers.SectionData, allSections []handler
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(pluralizeBooks(unlinkedCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 38, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 38, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -184,7 +184,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 63, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 63, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -197,7 +197,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.IsSystem)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 64, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 64, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -210,7 +210,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 68, Col: 130}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 68, Col: 130}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -223,7 +223,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 70, Col: 100}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 70, Col: 100}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -241,7 +241,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(section.Description)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 72, Col: 83}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 72, Col: 83}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -264,7 +264,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var12 templ.SafeURL
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(section.ViewAllURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 78, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 78, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -290,7 +290,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 91, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 91, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -311,7 +311,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue("carousel-track-" + section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 99, Col: 39}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 99, Col: 39}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -329,7 +329,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 104, Col: 92}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 104, Col: 92}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -369,7 +369,7 @@ func CollectionCarousel(section handlers.SectionData) templ.Component {
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 118, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 118, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -419,7 +419,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var18 templ.SafeURL
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 131, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 131, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -437,7 +437,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.CoverImagePath)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 138, Col: 31}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 138, Col: 31}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -450,7 +450,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 139, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 139, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -468,7 +468,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 147, Col: 22}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 147, Col: 22}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -491,7 +491,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(badgeLabel(item.Badge))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 157, Col: 36}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 157, Col: 36}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -517,7 +517,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 164, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 164, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -530,7 +530,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 165, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 165, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -548,7 +548,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 168, Col: 100}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 168, Col: 100}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -561,7 +561,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(item.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 169, Col: 19}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 169, Col: 19}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -584,7 +584,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var27 templ.SafeURL
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs("/media/" + item.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 177, Col: 40}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 177, Col: 40}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -597,7 +597,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("Resolve progress conflict for " + item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 179, Col: 63}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 179, Col: 63}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -623,7 +623,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var29 templ.SafeURL
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 186, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 186, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -636,7 +636,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("Listen to " + item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 188, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 188, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -662,7 +662,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var31 templ.SafeURL
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs("/readers/" + item.MediaItemID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 195, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 195, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -675,7 +675,7 @@ func BookCard(item handlers.BookInfo) templ.Component {
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue("Read " + item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 197, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 197, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -743,7 +743,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 228, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 228, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -756,7 +756,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%v", section.IsSystem))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 229, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 229, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -777,7 +777,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(section.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 234, Col: 43}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 234, Col: 43}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -790,7 +790,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 236, Col: 84}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 236, Col: 84}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -818,7 +818,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
var templ_7745c5c3_Var38 string
|
||||
templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(section.ID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 246, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 246, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -831,7 +831,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue("Restore " + section.Title + " to defaults")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 248, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 248, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -864,7 +864,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(itemsPerSection)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 272, Col: 128}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 272, Col: 128}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -877,7 +877,7 @@ func DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections [
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(itemsPerSection)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 279, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 279, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -959,7 +959,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 342, Col: 111}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 342, Col: 111}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -972,7 +972,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d pages", week.WeekPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 343, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 343, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -985,7 +985,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d sessions", week.WeekSessions))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 344, Col: 109}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 344, Col: 109}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -998,7 +998,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d books", week.WeekBooks))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 345, Col: 103}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 345, Col: 103}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1016,7 +1016,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.CurrentStreak))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 347, Col: 111}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 347, Col: 111}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1047,7 +1047,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 359, Col: 108}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 359, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1060,7 +1060,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 360, Col: 122}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 360, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1073,7 +1073,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 364, Col: 109}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 364, Col: 109}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1086,7 +1086,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var51 string
|
||||
templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 368, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 368, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1104,7 +1104,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var52 string
|
||||
templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(fmtReadingMinutes(week.WeekMinutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 373, Col: 108}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 373, Col: 108}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1117,7 +1117,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var53 string
|
||||
templ_7745c5c3_Var53, templ_7745c5c3_Err = templ.JoinStringErrs(weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 374, Col: 122}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 374, Col: 122}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var53))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1130,7 +1130,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var54 string
|
||||
templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 378, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 378, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1143,7 +1143,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var55 string
|
||||
templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekSessions))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 382, Col: 109}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 382, Col: 109}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1156,7 +1156,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var56 string
|
||||
templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", week.WeekBooks))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 386, Col: 106}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 386, Col: 106}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1176,7 +1176,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var57 string
|
||||
templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 394, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 394, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -1186,7 +1186,7 @@ func DashboardWeekWidget(week WeekStatsData, activeLibType string) templ.Compone
|
||||
var templ_7745c5c3_Var58 string
|
||||
templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard.templ`, Line: 396, Col: 91}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/dashboard.templ`, Line: 396, Col: 91}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
+21
-21
@@ -47,7 +47,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var3)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -133,7 +133,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue("showShelfMappings('" + device.ID.String() + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 64, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 64, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -154,7 +154,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue("showDeviceSettings('" + device.ID.String() + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 67, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 67, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -175,7 +175,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 72, Col: 102}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 72, Col: 102}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -188,7 +188,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(device.DeviceType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 73, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 73, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -221,7 +221,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSync, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 86, Col: 104}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 86, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -249,7 +249,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(FormatInTimezone(*device.LastSeen, user.Timezone))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 94, Col: 104}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 94, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -277,7 +277,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue("sync-url-" + device.ID.String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 108, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 108, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -290,7 +290,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(baseURL + "/api/sync/kobo/" + device.AuthToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 110, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 110, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -303,7 +303,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(document.getElementById('sync-url-" + device.ID.String() + "').value, 'Kobo sync URL')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 114, Col: 127}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 114, Col: 127}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var12)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -330,7 +330,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue("auth-token-" + device.ID.String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 130, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 130, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -343,7 +343,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(device.AuthToken)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 132, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 132, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -356,7 +356,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard(document.getElementById('auth-token-" + device.ID.String() + "').value, 'Auth token')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 136, Col: 126}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 136, Col: 126}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -382,7 +382,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("/api/devices/" + device.ID.String() + "/regenerate-token")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 147, Col: 78}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 147, Col: 78}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -423,7 +423,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceName)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 168, Col: 87}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 168, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -436,7 +436,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(reg.DeviceType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 170, Col: 27}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -449,7 +449,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(reg.ExpiresAt)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 170, Col: 58}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 170, Col: 58}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -462,7 +462,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue("approveDevice('" + reg.RegistrationID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 174, Col: 72}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 174, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -475,7 +475,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue("rejectDevice('" + reg.RegistrationID + "')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 177, Col: 71}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 177, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -514,7 +514,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(baseURL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 242, Col: 148}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 242, Col: 148}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -527,7 +527,7 @@ func Devices(user User, devices []handlers.DeviceInfo, pendingRegistrations []Pe
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("copyToClipboard('" + baseURL + "', 'Server URL')")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `devices.templ`, Line: 244, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/devices.templ`, Line: 244, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
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
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 11, Col: 21}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 11, Col: 21}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
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
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var3).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
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
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(section.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 50, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 50, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
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
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 56, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 56, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
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
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 57, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 57, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
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
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 58, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 58, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
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
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs(item.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 65, Col: 29}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 65, Col: 29}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
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
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item.Icon)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 66, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 66, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
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
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 67, Col: 30}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 67, Col: 30}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
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
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(crumb.URL)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
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
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(crumb.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 82, Col: 129}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 82, Col: 129}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
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
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(doc.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 86, Col: 104}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 86, Col: 104}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
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
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs("#" + item.Anchor)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 96, Col: 35}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 96, Col: 35}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
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
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues("margin-left: " + fmt.Sprintf("%drem", item.Level) + "; color: var(--text-secondary);")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 98, Col: 105}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 98, Col: 105}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
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
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(item.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `docs.templ`, Line: 100, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/docs.templ`, Line: 100, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
@@ -36,7 +36,7 @@ func ErrorPage(message string, errorType string) templ.Component {
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(message)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 84, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 84, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
@@ -49,7 +49,7 @@ func ErrorPage(message string, errorType string) templ.Component {
|
||||
var templ_7745c5c3_Var3 string
|
||||
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorType)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `error.templ`, Line: 90, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/error.templ`, Line: 90, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
||||
Loaded 100 of 181 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user