feat(sync): unlinked-books queue — ingest, page route, dashboard banner
Release / build-and-push (push) Successful in 3m7s
Release / build-and-push (push) Successful in 3m7s
The identification queue for device files the sync cannot match is now live end-to-end. Previously the whole feature was dark: unmatched device files were silently dropped (losing their reading progress), the queue table sat empty by construction, and the page template had no route. - Ingest: the KOReader sync loop, on an unresolvable file, upserts unlinked_books (one row per device + content id; re-syncs bump last_seen_at and refresh device-reported metadata) instead of dropping the file. Schema gains the (device_id, content_id) unique index the upsert needs. - Page: GET /unlinked-books routes the existing page template, listing the caller's own unresolved rows (ListUnresolvedUnlinkedBooksForUser — devices.user_id scoping; no cross-user leakage). - Dashboard banner: a warning card above the shelves, rendered only when the caller's queue is non-empty (hidden-unless-needed), linking to the page. - Resolve flow unchanged and already gated: MatchingHandler.LinkBook verifies device ownership and target-item visibility (403s) and creates the device file alias, so the next sync matches by path. SyncHandler.LinkBook and Handler.LinkBook are unrouted dead handlers — triage candidates, not part of the live flow.
This commit is contained in:
1 parent
9e831085ef
commit
d74db03cf3
10 files changed
+823
-454
No files matched your search
@@ -47,3 +47,47 @@ func TestRenderingPartialsRender(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The unlinked-books queue banner renders only when the queue is
|
||||
// non-empty (hidden-unless-needed).
|
||||
func TestDashboardUnlinkedBanner(t *testing.T) {
|
||||
render := func(count int) string {
|
||||
var b strings.Builder
|
||||
err := Dashboard(User{ID: "u1"}, nil, nil, nil, "", nil, 0, nil, "", "", count).Render(context.Background(), &b)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
if out := render(0); strings.Contains(out, "/unlinked-books") {
|
||||
t.Error("banner rendered with empty queue")
|
||||
}
|
||||
out := render(2)
|
||||
if !strings.Contains(out, "/unlinked-books") || !strings.Contains(out, "could not be identified") {
|
||||
t.Error("banner missing with non-empty queue")
|
||||
}
|
||||
}
|
||||
|
||||
// The unlinked-books page renders queue rows standalone.
|
||||
func TestUnlinkedBooksPageRender(t *testing.T) {
|
||||
var b strings.Builder
|
||||
user := User{ID: "u1", Timezone: "UTC"}
|
||||
books := []UnlinkedBookData{{
|
||||
ProgressID: "ub1",
|
||||
DeviceName: "Kobo Clara",
|
||||
DeviceType: "kobo",
|
||||
FilePath: "/mnt/onboard/book.epub",
|
||||
SHA256: "abc123",
|
||||
TitleFromDevice: "Some Sideloaded Book",
|
||||
LastSyncTime: "2026-10-18 12:00",
|
||||
}}
|
||||
if err := UnlinkedBooks(user, books).Render(context.Background(), &b); err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
out := b.String()
|
||||
for _, want := range []string{"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("unlinked page missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user