diff --git a/cmd/server/tests/unlinked_books_test.go b/cmd/server/tests/unlinked_books_test.go
new file mode 100644
index 00000000..c803b8b8
--- /dev/null
+++ b/cmd/server/tests/unlinked_books_test.go
@@ -0,0 +1,189 @@
+package main
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "bookhoard/internal/database"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgtype"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// unlinkedBooksFixture is one user + device + two queue rows. The regular
+// user's cleanup cascades: users → devices → unlinked_books.
+type unlinkedBooksFixture struct {
+ setup *TestServerSetup
+ owner UserTestData
+ ownerTok string
+ deviceID pgtype.UUID
+ contentA string
+ contentB string
+ rowA pgtype.UUID
+ rowB pgtype.UUID
+}
+
+func setupUnlinkedBooksFixture(t *testing.T) *unlinkedBooksFixture {
+ f := &unlinkedBooksFixture{setup: setupTestServer(t), contentA: "test-content-aaa", contentB: "test-content-bbb"}
+ f.owner = createRegularUserOnce(t, f.setup.DB)
+ f.ownerTok = loginUserWithCredentials(t, f.setup.Server, f.owner.Email, f.owner.Password)
+
+ device, err := f.setup.DB.CreateDevice(context.Background(), database.CreateDeviceParams{
+ UserID: pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true},
+ DeviceName: "test-unlinked-reader",
+ DeviceType: "koreader",
+ DeviceIdentifier: "test-unlinked-" + uuid.New().String()[:8],
+ AuthToken: "dev_test_" + uuid.New().String(),
+ SyncEnabled: pgtype.Bool{Bool: true, Valid: true},
+ AutoSync: pgtype.Bool{Bool: true, Valid: true},
+ SyncFrequencyMinutes: pgtype.Int4{Int32: 5, Valid: true},
+ DeviceMetadata: []byte("{}"),
+ })
+ require.NoError(t, err, "device for unlinked-books tests")
+ f.deviceID = device.ID
+
+ f.rowA = f.seedRow(t, f.contentA, "Test Book A")
+ f.rowB = f.seedRow(t, f.contentB, "Test Book B")
+ return f
+}
+
+// seedRow inserts one queue row for the fixture device.
+func (f *unlinkedBooksFixture) seedRow(t *testing.T, contentID, title string) pgtype.UUID {
+ row, err := f.setup.DB.CreateUnlinkedBook(context.Background(), database.CreateUnlinkedBookParams{
+ DeviceID: f.deviceID,
+ ContentID: contentID,
+ Title: pgtype.Text{String: title, Valid: true},
+ })
+ require.NoError(t, err, "seed unlinked_books row")
+ return row.ID
+}
+
+// doAction fires an htmx-style page action as the given user.
+func (f *unlinkedBooksFixture) doAction(t *testing.T, token, method, path string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(method, path, nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Cookie", "token="+token)
+ req.Header.Set("HX-Request", "true")
+ rec := httptest.NewRecorder()
+ f.setup.Server.Config.Handler.ServeHTTP(rec, req)
+ return rec
+}
+
+func (f *unlinkedBooksFixture) unresolvedCount(t *testing.T) int {
+ rows, err := f.setup.DB.ListUnresolvedUnlinkedBooksForUser(context.Background(),
+ pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
+ require.NoError(t, err)
+ return len(rows)
+}
+
+func (f *unlinkedBooksFixture) ignoredIDs(t *testing.T) []uuid.UUID {
+ rows, err := f.setup.DB.ListIgnoredUnlinkedBooksForUser(context.Background(),
+ pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
+ require.NoError(t, err)
+ ids := make([]uuid.UUID, 0, len(rows))
+ for _, r := range rows {
+ ids = append(ids, uuid.UUID(r.ID.Bytes))
+ }
+ return ids
+}
+
+// TestUnlinkedBooksIgnoreLifecycle pins the soft-dismiss contract: hidden
+// from the page list, badge count, and per-device API; preserved across
+// the sync upsert; reversible via unignore. Hard delete is covered too.
+func TestUnlinkedBooksIgnoreLifecycle(t *testing.T) {
+ f := setupUnlinkedBooksFixture(t)
+ ctx := context.Background()
+ pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
+ pathB := "/unlinked-books/" + uuid.UUID(f.rowB.Bytes).String()
+
+ t.Run("ignore hides the row everywhere", func(t *testing.T) {
+ rec := f.doAction(t, f.ownerTok, "POST", pathA+"/ignore")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+ // The htmx swap re-renders the whole section partial.
+ assert.Contains(t, rec.Body.String(), `id="unlinked-section"`)
+
+ assert.Equal(t, 1, f.unresolvedCount(t), "row B remains queued")
+ ignored := f.ignoredIDs(t)
+ require.Len(t, ignored, 1)
+ assert.Equal(t, uuid.UUID(f.rowA.Bytes), ignored[0])
+
+ count, err := f.setup.DB.CountUnresolvedUnlinkedBooksForUser(ctx, pgtype.UUID{Bytes: [16]byte(f.owner.ID), Valid: true})
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), count, "dashboard badge must still count row B only")
+
+ perDevice, err := f.setup.DB.GetUnlinkedBooksByDevice(ctx, f.deviceID)
+ require.NoError(t, err)
+ assert.Len(t, perDevice, 1, "per-device API must hide the ignored row")
+ deviceCount, err := f.setup.DB.CountUnlinkedBooks(ctx, f.deviceID)
+ require.NoError(t, err)
+ assert.Equal(t, int64(1), deviceCount)
+ })
+
+ t.Run("device sync refresh preserves the ignore", func(t *testing.T) {
+ // The exact query the KOReader sync path runs on every re-sync.
+ _, err := f.setup.DB.UpsertUnlinkedBook(ctx, database.UpsertUnlinkedBookParams{
+ DeviceID: f.deviceID,
+ ContentID: f.contentA,
+ Title: pgtype.Text{String: "Test Book A (restored metadata)", Valid: true},
+ })
+ require.NoError(t, err)
+
+ row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
+ require.NoError(t, err)
+ assert.True(t, row.IgnoredAt.Valid, "upsert must never clear ignored_at")
+ assert.Equal(t, "Test Book A (restored metadata)", row.Title.String, "upsert still refreshes metadata")
+ assert.Equal(t, 1, f.unresolvedCount(t), "ignored row must not re-enter the queue")
+ })
+
+ t.Run("unignore restores the row to the queue", func(t *testing.T) {
+ rec := f.doAction(t, f.ownerTok, "POST", pathA+"/unignore")
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+
+ assert.Equal(t, 2, f.unresolvedCount(t))
+ assert.Empty(t, f.ignoredIDs(t))
+ row, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowA)
+ require.NoError(t, err)
+ assert.False(t, row.IgnoredAt.Valid)
+ })
+
+ t.Run("delete removes the row outright", func(t *testing.T) {
+ rec := f.doAction(t, f.ownerTok, "DELETE", pathB)
+ require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
+
+ _, err := f.setup.DB.GetUnlinkedBookByID(ctx, f.rowB)
+ assert.Error(t, err, "hard-deleted row must be gone")
+ assert.Equal(t, 1, f.unresolvedCount(t))
+ })
+
+ t.Run("unknown and malformed ids are rejected", func(t *testing.T) {
+ rec := f.doAction(t, f.ownerTok, "DELETE", "/unlinked-books/"+uuid.New().String())
+ assert.Equal(t, http.StatusNotFound, rec.Code)
+ rec = f.doAction(t, f.ownerTok, "POST", "/unlinked-books/not-a-uuid/ignore")
+ assert.Equal(t, http.StatusBadRequest, rec.Code)
+ })
+}
+
+// TestUnlinkedBooksOwnership pins row → device → user authorization:
+// another user's queue rows are untouchable, ignored or deleted.
+func TestUnlinkedBooksOwnership(t *testing.T) {
+ f := setupUnlinkedBooksFixture(t)
+
+ stranger := createRegularUserOnce(t, f.setup.DB)
+ strangerTok := loginUserWithCredentials(t, f.setup.Server, stranger.Email, stranger.Password)
+ pathA := "/unlinked-books/" + uuid.UUID(f.rowA.Bytes).String()
+
+ rec := f.doAction(t, strangerTok, "POST", pathA+"/ignore")
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+ row, err := f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
+ require.NoError(t, err)
+ assert.False(t, row.IgnoredAt.Valid, "foreign ignore must not land")
+
+ rec = f.doAction(t, strangerTok, "DELETE", pathA)
+ assert.Equal(t, http.StatusForbidden, rec.Code)
+ _, err = f.setup.DB.GetUnlinkedBookByID(context.Background(), f.rowA)
+ assert.NoError(t, err, "foreign delete must not land")
+}
diff --git a/database/schema/schema.sql b/database/schema/schema.sql
index 56d9c8f9..38d4c6d3 100644
--- a/database/schema/schema.sql
+++ b/database/schema/schema.sql
@@ -1496,6 +1496,12 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
+-- Soft-dismiss for unlinked_books: ignored rows stay on the device's sync
+-- queue (UpsertUnlinkedBook keeps refreshing their file metadata, and must
+-- never clear this column) but are hidden from the page list, badge count,
+-- and per-device API. Reversible via UnignoreUnlinkedBook.
+ALTER TABLE unlinked_books ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMP WITH TIME ZONE;
+
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
-- One queue row per (device, document): the sync ingest upserts on this pair.
diff --git a/internal/database/models.go b/internal/database/models.go
index a8ba1b55..56d5358e 100644
--- a/internal/database/models.go
+++ b/internal/database/models.go
@@ -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 {
diff --git a/internal/database/querier.go b/internal/database/querier.go
index c7aa81ec..f7b20a31 100644
--- a/internal/database/querier.go
+++ b/internal/database/querier.go
@@ -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
diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go
index e2e040f3..351a75e5 100644
--- a/internal/database/queries.sql.go
+++ b/internal/database/queries.sql.go
@@ -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
}
diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql
index b92d0702..3aa48e31 100644
--- a/internal/database/queries/queries.sql
+++ b/internal/database/queries/queries.sql
@@ -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
-- ============================================
diff --git a/internal/router/frontend.go b/internal/router/frontend.go
index 4d7008da..05dc77d4 100644
--- a/internal/router/frontend.go
+++ b/internal/router/frontend.go
@@ -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, "
Unauthorized
")
+ }
+ rows, err := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
+ if err != nil {
+ return c.HTML(http.StatusInternalServerError, "Failed to load unlinked books
")
+ }
+ ignoredRows, err := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
+ if err != nil {
+ return c.HTML(http.StatusInternalServerError, "Failed to load unlinked books
")
+ }
+ 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, "Invalid entry id
"
+ }
+ row, err := cfg.Queries.GetUnlinkedBookWithOwner(c.Request().Context(), pgtype.UUID{Bytes: rowID, Valid: true})
+ if err != nil {
+ return nil, http.StatusNotFound, "Entry not found
"
+ }
+ ownerID := uuid.UUID(row.UserID.Bytes).String()
+ if ownerID != dbUser.ID.String() && dbUser.Role != "admin" {
+ return nil, http.StatusForbidden, "Not allowed
"
+ }
+ 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, "Unauthorized
")
+ }
+ 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, "Action failed
")
+ }
+ 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,
diff --git a/templates/partials_render_test.go b/templates/partials_render_test.go
index 8880fa54..8b940b85 100644
--- a/templates/partials_render_test.go
+++ b/templates/partials_render_test.go
@@ -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)
}
diff --git a/templates/unlinked_books.templ b/templates/unlinked_books.templ
index a662e937..7da6bebe 100644
--- a/templates/unlinked_books.templ
+++ b/templates/unlinked_books.templ
@@ -48,7 +48,7 @@ templ UnlinkedMatchCards(progressId string, matches []services.BookMatch) {
}
}
-templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
+templ UnlinkedBooks(user User, unresolved []UnlinkedBookData, ignored []UnlinkedBookData) {
@@ -62,193 +62,274 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
@Header(user, "/unlinked")
-
-
-
- @Icon("sync", "h-5 w-5")
-
-
Unlinked Books
-
-
Resolve books that couldn't be automatically matched between devices
-
- if len(unlinkedBooks) > 0 {
-
-
-
- Select All
- 0 selected
-
-
-
- @Icon("sync", "h-4 w-4")
- Auto-Link Selected (High Confidence)
-
-
- @Icon("search", "h-4 w-4")
- Get Suggestions for Selected
-
-
- @Icon("edit", "h-4 w-4")
- Bulk Manual Link
-
-
-
- }
-
- if len(unlinkedBooks) == 0 {
-
-
- @Icon("check-circle", "h-7 w-7")
-
-
All Books Linked!
-
There are no unlinked books that need manual resolution.
-
- }
- for _, book := range unlinkedBooks {
-
-
-
-
-
-
-
-
- if book.DeviceType == "koreader" {
- @Icon("book-open", "h-5 w-5")
- } else if book.DeviceType == "kobo" {
- @Icon("library", "h-5 w-5")
- } else {
- @Icon("device", "h-5 w-5")
- }
-
-
-
{ book.TitleFromDevice }
-
- Device: { book.DeviceName } ({ book.DeviceType })
-
-
-
-
-
- File Path:
-
- @Icon("folder", "h-3.5 w-3.5")
- { book.FilePath }
-
-
- if book.SHA256 != "" {
-
- SHA-256:
- { book.SHA256 }
-
- }
-
- Last Synced:
- { book.LastSyncTime }
-
- if book.ConfidenceScore > 0 {
-
- Auto-Link Confidence:
-
- { book.ConfidenceScore }% confidence
-
-
- }
-
-
-
- @Icon("search", "h-4 w-4")
- Find Potential Matches
-
-
- @Icon("edit", "h-4 w-4")
- Manually Link
-
-
-
-
-
Potential Matches
-
-
Click "Find Potential Matches" to search
-
-
-
-
-
-
- }
-
+ @UnlinkedBooksSection(unresolved, ignored)
-
-
-
-
Manually Link Book
- @Icon("close", "h-5 w-5")
-
-
-
-
- Book Title from Device
-
-
-
-
Search for Book to Link
-
-
-
- @Icon("search", "h-4 w-4")
- Search
-
-
-
-
-
Search for books to link
-
-
-
Confidence Score
-
-
1.0 = manual override (highest confidence)
-
-
-
- Cancel
-
-
- @Icon("check", "h-4 w-4")
- Link Book
-
-
-
-
+ @unlinkedManualLinkModal()
}
+
+// UnlinkedBooksSection is the whole actionable page body: toolbar, the
+// unresolved queue, and the collapsed ignored bucket. Every dismiss/
+// delete/restore action re-renders this partial server-side (htmx
+// hx-target="#unlinked-section" outerHTML) so counts and buckets can
+// never drift from the database after an action.
+templ UnlinkedBooksSection(unresolved []UnlinkedBookData, ignored []UnlinkedBookData) {
+
+
+
+
+ @Icon("sync", "h-5 w-5")
+
+
Unlinked Books
+
+
Resolve books that couldn't be automatically matched between devices
+
+ if len(unresolved) > 0 {
+
+
+
+ Select All
+ 0 selected
+
+
+
+ @Icon("sync", "h-4 w-4")
+ Auto-Link Selected (High Confidence)
+
+
+ @Icon("search", "h-4 w-4")
+ Get Suggestions for Selected
+
+
+ @Icon("edit", "h-4 w-4")
+ Bulk Manual Link
+
+
+
+ }
+
+ if len(unresolved) == 0 {
+
+
+ @Icon("check-circle", "h-7 w-7")
+
+
All Books Linked!
+
There are no unlinked books that need manual resolution.
+
+ }
+ for _, book := range unresolved {
+ @unlinkedBookCard(book, false)
+ }
+
+ if len(ignored) > 0 {
+
+
+ Ignored ({ fmt.Sprintf("%d", len(ignored)) }) — hidden from the queue and the dashboard badge
+
+
+ for _, book := range ignored {
+ @unlinkedBookCard(book, true)
+ }
+
+
+ }
+
+}
+
+// unlinkedBookCard renders one queue row. Ignored rows drop the checkbox
+// and the match/link actions (they are dismissed) and offer Restore +
+// Delete instead; all action buttons re-render the section server-side.
+templ unlinkedBookCard(book UnlinkedBookData, ignored bool) {
+
+
+ if !ignored {
+
+
+
+ }
+
+
+
+ if book.DeviceType == "koreader" {
+ @Icon("book-open", "h-5 w-5")
+ } else if book.DeviceType == "kobo" {
+ @Icon("library", "h-5 w-5")
+ } else {
+ @Icon("device", "h-5 w-5")
+ }
+
+
+
{ book.TitleFromDevice }
+
+ Device: { book.DeviceName } ({ book.DeviceType })
+
+
+
+
+
+ File Path:
+
+ @Icon("folder", "h-3.5 w-3.5")
+ { book.FilePath }
+
+
+ if book.SHA256 != "" {
+
+ SHA-256:
+ { book.SHA256 }
+
+ }
+
+ Last Synced:
+ { book.LastSyncTime }
+
+ if book.ConfidenceScore > 0 {
+
+ Auto-Link Confidence:
+
+ { book.ConfidenceScore }% confidence
+
+
+ }
+
+ if ignored {
+
+
+ @Icon("check", "h-4 w-4")
+ Restore
+
+
+ @Icon("close", "h-4 w-4")
+ Delete
+
+
+ } else {
+
+
+ @Icon("search", "h-4 w-4")
+ Find Potential Matches
+
+
+ @Icon("edit", "h-4 w-4")
+ Manually Link
+
+
+ @Icon("close", "h-4 w-4")
+ Ignore
+
+
+ @Icon("close", "h-4 w-4")
+ Delete
+
+
+
+
+
Potential Matches
+
+
Click "Find Potential Matches" to search
+
+
+
+ }
+
+
+
+}
+
+// unlinkedManualLinkModal holds the manual-link dialog markup (unchanged
+// behavior; extracted so the section partial can be re-rendered on its own).
+templ unlinkedManualLinkModal() {
+
+
+
+
Manually Link Book
+ @Icon("close", "h-5 w-5")
+
+
+
+
+ Book Title from Device
+
+
+
+
Search for Book to Link
+
+
+
+ @Icon("search", "h-4 w-4")
+ Search
+
+
+
+
+
Search for books to link
+
+
+
Confidence Score
+
+
1.0 = manual override (highest confidence)
+
+
+
+ Cancel
+
+
+ @Icon("check", "h-4 w-4")
+ Link Book
+
+
+
+
+}
diff --git a/templates/unlinked_books_templ.go b/templates/unlinked_books_templ.go
index 0268d303..f2c84f1e 100644
--- a/templates/unlinked_books_templ.go
+++ b/templates/unlinked_books_templ.go
@@ -170,7 +170,7 @@ func UnlinkedMatchCards(progressId string, matches []services.BookMatch) templ.C
})
}
-func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component {
+func UnlinkedBooks(user User, unresolved []UnlinkedBookData, ignored []UnlinkedBookData) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -221,7 +221,57 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "")
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = UnlinkedBooksSection(unresolved, ignored).Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, " ")
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = unlinkedManualLinkModal().Render(ctx, templ_7745c5c3_Buffer)
+ if templ_7745c5c3_Err != nil {
+ return templ_7745c5c3_Err
+ }
+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "