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

+16 -4
View File
@@ -53,9 +53,10 @@ type Querier interface {
// collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope.
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
// Count unlinked books for a device
// Count unlinked books for a device (ignored rows stay hidden)
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved queue rows across the caller's own devices.
// Banner count: unresolved, non-ignored queue rows across the caller's own
// devices. Ignored rows must not nag from the dashboard badge.
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
@@ -456,7 +457,9 @@ type Querier interface {
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
// Get unlinked book by ID
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error)
// Get unlinked books for a device
// Fetch one row together with its device's owner for authorization.
GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error)
// Get unlinked books for a device (ignored rows stay hidden)
GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
@@ -504,6 +507,9 @@ type Querier interface {
// static collection paths apply.
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
// Check if book is in collection
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
@@ -530,6 +536,9 @@ type Querier interface {
// archived row's reading data into its active twin instead of waiting for a
// file that will never return.
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
// Page list, ignored bucket: rows the user dismissed, kept for the
// collapsible "Ignored" section so a misclick is recoverable.
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
@@ -553,7 +562,8 @@ type Querier interface {
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices.
// Page list: unresolved queue rows for the caller's own devices (ignored
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
@@ -637,6 +647,8 @@ type Querier interface {
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Undo a soft-dismiss.
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write