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

+1
View File
@@ -562,6 +562,7 @@ type UnlinkedBooks struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
}
type UserDashboardPreferences struct {
+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
+171 -17
View File
@@ -372,10 +372,10 @@ func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollect
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count
FROM unlinked_books
WHERE device_id = $1 AND resolved = false
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL
`
// Count unlinked books for a device
// Count unlinked books for a device (ignored rows stay hidden)
func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID)
var count int64
@@ -387,10 +387,11 @@ const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBoo
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
`
// 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.
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
var count int64
@@ -1956,7 +1957,7 @@ const CreateUnlinkedBook = `-- name: CreateUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type CreateUnlinkedBookParams struct {
@@ -1996,6 +1997,7 @@ func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBook
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
@@ -9809,7 +9811,7 @@ func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProg
}
const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
`
type GetUnlinkedBookByContentIdParams struct {
@@ -9835,12 +9837,13 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE id = $1
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE id = $1
`
// Get unlinked book by ID
@@ -9861,15 +9864,69 @@ func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (Unli
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
const GetUnlinkedBookWithOwner = `-- name: GetUnlinkedBookWithOwner :one
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.user_id, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.id = $1
`
type GetUnlinkedBookWithOwnerRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Fetch one row together with its device's owner for authorization.
func (q *Queries) GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error) {
row := q.db.QueryRow(ctx, GetUnlinkedBookWithOwner, id)
var i GetUnlinkedBookWithOwnerRow
err := row.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.UserID,
&i.DeviceName,
&i.DeviceType,
)
return i, err
}
const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.device_id = $1 AND ub.resolved = false
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC
`
@@ -9887,11 +9944,12 @@ type GetUnlinkedBooksByDeviceRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Get unlinked books for a device
// Get unlinked books for a device (ignored rows stay hidden)
func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) {
rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID)
if err != nil {
@@ -9915,6 +9973,7 @@ func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
@@ -10985,6 +11044,17 @@ func (q *Queries) HasRecentConflictResolution(ctx context.Context, arg HasRecent
return exists, err
}
const IgnoreUnlinkedBook = `-- name: IgnoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1
`
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
func (q *Queries) IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, IgnoreUnlinkedBook, id)
return err
}
const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one
UPDATE sync_queue
SET
@@ -11071,7 +11141,7 @@ SET
resolved_at = NOW(),
resolution_method = 'manual_link'
WHERE id = $1
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type LinkUnlinkedBookParams struct {
@@ -11098,6 +11168,7 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
@@ -11680,6 +11751,72 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt
return items, nil
}
const ListIgnoredUnlinkedBooksForUser = `-- name: ListIgnoredUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
ORDER BY ub.ignored_at DESC
`
type ListIgnoredUnlinkedBooksForUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Page list, ignored bucket: rows the user dismissed, kept for the
// collapsible "Ignored" section so a misclick is recoverable.
func (q *Queries) ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListIgnoredUnlinkedBooksForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIgnoredUnlinkedBooksForUserRow{}
for rows.Next() {
var i ListIgnoredUnlinkedBooksForUserRow
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLibraries = `-- name: ListLibraries :many
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description
FROM libraries l
@@ -13040,7 +13177,7 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI
}
const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.resolved = false
@@ -13067,6 +13204,7 @@ type ListUnresolvedUnlinkedBooksRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
@@ -13095,6 +13233,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
@@ -13109,10 +13248,10 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
}
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC
`
@@ -13130,11 +13269,13 @@ type ListUnresolvedUnlinkedBooksForUserRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// 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).
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
if err != nil {
@@ -13158,6 +13299,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
@@ -13814,7 +13956,7 @@ SET
resolved_at = NOW(),
resolution_method = $3
WHERE id = $1
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type ResolveUnlinkedBookParams struct {
@@ -13841,6 +13983,7 @@ func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBo
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
@@ -15246,6 +15389,16 @@ func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID)
return err
}
const UnignoreUnlinkedBook = `-- name: UnignoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1
`
// Undo a soft-dismiss.
func (q *Queries) UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, UnignoreUnlinkedBook, id)
return err
}
const UpdateCollection = `-- name: UpdateCollection :one
UPDATE collections
SET
@@ -17649,7 +17802,7 @@ SET file_path = EXCLUDED.file_path,
author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW()
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type UpsertUnlinkedBookParams struct {
@@ -17687,6 +17840,7 @@ func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBook
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
+35 -8
View File
@@ -3701,12 +3701,12 @@ INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, con
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- Get unlinked books for a device
-- Get unlinked books for a device (ignored rows stay hidden)
-- name: GetUnlinkedBooksByDevice :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.device_id = $1 AND ub.resolved = false
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC;
-- Get unlinked book by ContentId
@@ -3724,11 +3724,11 @@ SET
WHERE id = $1
RETURNING *;
-- Count unlinked books for a device
-- Count unlinked books for a device (ignored rows stay hidden)
-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count
FROM unlinked_books
WHERE device_id = $1 AND resolved = false;
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL;
-- Link unlinked book to media item
-- name: LinkUnlinkedBook :one
@@ -3772,21 +3772,48 @@ SET file_path = EXCLUDED.file_path,
last_seen_at = NOW()
RETURNING *;
-- 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.
-- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false;
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL;
-- 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).
-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC;
-- Page list, ignored bucket: rows the user dismissed, kept for the
-- collapsible "Ignored" section so a misclick is recoverable.
-- name: ListIgnoredUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
ORDER BY ub.ignored_at DESC;
-- Soft-dismiss one row (ownership is checked by the caller against the
-- device's user_id before calling this).
-- name: IgnoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1;
-- Undo a soft-dismiss.
-- name: UnignoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1;
-- Fetch one row together with its device's owner for authorization.
-- name: GetUnlinkedBookWithOwner :one
SELECT ub.*, d.user_id, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.id = $1;
-- ============================================
-- CAROUSEL-STYLE DASHBOARD
-- ============================================
+127 -1
View File
@@ -767,6 +767,8 @@ func registerFrontendRoutes(cfg *Config) {
// resolution rules as the dashboard page (family-lens safe).
// Unlinked-books page: the identification queue for device files the
// sync could not match (personal data — the caller's own devices only).
// Unlinked-book queue page: unresolved rows plus the ignored bucket
// (soft-dismissed rows, kept for the collapsible recovery section).
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
@@ -781,6 +783,11 @@ func registerFrontendRoutes(cfg *Config) {
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
}
ignoredRows, ignoredErr := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if ignoredErr != nil {
log.Printf("ListIgnoredUnlinkedBooksForUser failed: %v", ignoredErr)
ignoredRows = []database.ListIgnoredUnlinkedBooksForUserRow{}
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
@@ -796,14 +803,133 @@ func registerFrontendRoutes(cfg *Config) {
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
for _, r := range ignoredRows {
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
if renderErr := templates.UnlinkedBooks(user, books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Unlinked-book dismiss/delete actions (htmx: the buttons post/delete
// and swap in the re-rendered section). Authorization is row → device
// → requesting user (admin may act on anyone's rows); a non-htmx hit
// just bounces back to the page. These are page-scoped SSR routes, so
// they are intentionally NOT under /api and not in the OpenAPI docs.
unlinkedSectionHTML := func(c *echo.Context, dbUser database.Users) error {
userUUID, err := uuid.Parse(dbUser.ID.String())
if err != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
rows, err := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
ignoredRows, err := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
books = append(books, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
for _, r := range ignoredRows {
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooksSection(books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
}
// authorizeUnlinkedAction resolves the :id row with its device owner
// and gates the action: nil row + status/message on failure.
authorizeUnlinkedAction := func(c *echo.Context, dbUser database.Users) (*database.GetUnlinkedBookWithOwnerRow, int, string) {
rowID, err := uuid.Parse(c.Param("id"))
if err != nil {
return nil, http.StatusBadRequest, "<div>Invalid entry id</div>"
}
row, err := cfg.Queries.GetUnlinkedBookWithOwner(c.Request().Context(), pgtype.UUID{Bytes: rowID, Valid: true})
if err != nil {
return nil, http.StatusNotFound, "<div>Entry not found</div>"
}
ownerID := uuid.UUID(row.UserID.Bytes).String()
if ownerID != dbUser.ID.String() && dbUser.Role != "admin" {
return nil, http.StatusForbidden, "<div>Not allowed</div>"
}
return &row, 0, ""
}
for _, action := range []struct {
method string
path string
run func(ctx context.Context, rowID pgtype.UUID) error
}{
{"POST", "/unlinked-books/:id/ignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.IgnoreUnlinkedBook(ctx, rowID)
}},
{"POST", "/unlinked-books/:id/unignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.UnignoreUnlinkedBook(ctx, rowID)
}},
{"DELETE", "/unlinked-books/:id", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.DeleteUnlinkedBook(ctx, rowID)
}},
} {
method, path, run := action.method, action.path, action.run
frontendProtected.Add(method, path, func(c *echo.Context) error {
dbUser, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
row, status, msg := authorizeUnlinkedAction(c, dbUser)
if row == nil {
return c.HTML(status, msg)
}
if err := run(c.Request().Context(), row.ID); err != nil {
log.Printf("unlinked-book action %s %s failed: %v", method, path, err)
return c.HTML(http.StatusInternalServerError, "<div>Action failed</div>")
}
if c.Request().Header.Get("HX-Request") != "true" {
return c.Redirect(http.StatusSeeOther, "/unlinked-books")
}
return unlinkedSectionHTML(c, dbUser)
})
}
// Unlinked-book match results partial: the match cascade for one device
// file, rendered server-side and scoped to the requester's visible
// libraries (absent-from-results) — rendering-ownership: templ renders,