feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s

Unidentified sync files used to be stuck: every KOReader re-sync
re-queues them, and there was no way to say "this isn't a book" or "I
don't want this one on the server".

Ignore is the durable dismiss: a new ignored_at column hides the row
from the page list, dashboard badge, and per-device API while the sync
keeps refreshing its metadata (the upsert never clears the column).
A collapsible "Ignored" section lists dismissed rows with Restore and
Delete so a misclick is recoverable. Delete is the hard exit — the row
is gone, and honestly re-queued if the device syncs that file again
(the confirm dialog says so).

Actions are page-scoped SSR routes: htmx swaps in the re-rendered
section so counts and buckets can never drift from the DB; ownership
is row → device → user (admins may act on any row). Integration tests
pin the lifecycle (hide everywhere, survive the sync upsert, restore,
delete), foreign-user 403s, and the render surface.
This commit is contained in:
John O'Keefe committed 2026-10-26 14:58:50 -04:00
1 parent afbf1029a9
commit f6a73ab5aa
10 files changed
+1241 -375

No files matched your search

+18 -3
View File
@@ -68,7 +68,7 @@ func TestDashboardUnlinkedBanner(t *testing.T) {
}
}
// The unlinked-books page renders queue rows standalone.
// The unlinked-books page renders queue rows standalone, both buckets.
func TestUnlinkedBooksPageRender(t *testing.T) {
var b strings.Builder
user := User{ID: "u1", Timezone: "UTC"}
@@ -81,11 +81,26 @@ func TestUnlinkedBooksPageRender(t *testing.T) {
TitleFromDevice: "Some Sideloaded Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books).Render(context.Background(), &b); err != nil {
ignored := []UnlinkedBookData{{
ProgressID: "ub2",
DeviceName: "KOReader Phone",
DeviceType: "koreader",
FilePath: "/sdcard/ignored.epub",
SHA256: "def456",
TitleFromDevice: "Some Dismissed Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books, ignored).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"} {
for _, want := range []string{
"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches",
// Dismiss/delete actions and the ignored recovery bucket.
"Some Dismissed Book", `hx-post="/unlinked-books/ub1/ignore"`,
`hx-delete="/unlinked-books/ub1"`, `hx-post="/unlinked-books/ub2/unignore"`,
"Ignored (1)",
} {
if !strings.Contains(out, want) {
t.Errorf("unlinked page missing %q", want)
}