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 { -
-
- - - 0 selected -
-
- - - -
-
- } -
- 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 - -
- } -
-
- - -
- -
-
-
- } -
+ @UnlinkedBooksSection(unresolved, ignored)
- + @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 { +
+
+ + + 0 selected +
+
+ + + +
+
+ } +
+ 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 { +
+ + +
+ } else { +
+ + + + +
+ + } +
+
+
+} + +// unlinkedManualLinkModal holds the manual-link dialog markup (unchanged +// behavior; extracted so the section partial can be re-rendered on its own). +templ unlinkedManualLinkModal() { + +} 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, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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. +func UnlinkedBooksSection(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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var13 := templ.GetChildren(ctx) + if templ_7745c5c3_Var13 == nil { + templ_7745c5c3_Var13 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -229,12 +279,12 @@ 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, 17, "

Unlinked Books

Resolve books that couldn't be automatically matched between devices

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

Unlinked Books

Resolve books that couldn't be automatically matched between devices

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if len(unlinkedBooks) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "
0 selected
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Bulk Manual Link
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if len(unlinkedBooks) == 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "
") + if len(unresolved) == 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -276,200 +326,332 @@ 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, 24, "

All Books Linked!

There are no unlinked books that need manual resolution.

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "

All Books Linked!

There are no unlinked books that need manual resolution.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - for _, book := range unlinkedBooks { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(ignored) > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "
Ignored (") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } var templ_7745c5c3_Var14 string - templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice) + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(ignored))) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 111, Col: 136} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 128, Col: 47} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\">
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ") — hidden from the queue and the dashboard badge
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if book.DeviceType == "koreader" { - templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else if book.DeviceType == "kobo" { - templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } else { - templ_7745c5c3_Err = Icon("device", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) + for _, book := range ignored { + templ_7745c5c3_Err = unlinkedBookCard(book, true).Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var15 string - templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 125, Col: 111} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "

Device: ") + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// 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. +func unlinkedBookCard(book UnlinkedBookData, ignored bool) 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var15 := templ.GetChildren(ctx) + if templ_7745c5c3_Var15 == nil { + templ_7745c5c3_Var15 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if !ignored { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "
File Path: ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\">
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = Icon("folder", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer) + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.DeviceType == "koreader" { + templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var18 string - templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 136, Col: 27} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + } else if book.DeviceType == "kobo" { + templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "
") + } else { + templ_7745c5c3_Err = Icon("device", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - if book.SHA256 != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "
SHA-256: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var20 string - templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 142, Col: 84} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "
Last Synced: ") + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var18 string + templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 163, Col: 106} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "

Device: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 165, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " (") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 165, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, ")

File Path: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Icon("folder", "h-3.5 w-3.5").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 174, Col: 22} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.SHA256 != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "
SHA-256:
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - if book.ConfidenceScore > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "
Auto-Link Confidence: ") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - var templ_7745c5c3_Var22 string - templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore) - if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 153, Col: 35} - } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "% confidence
") - if templ_7745c5c3_Err != nil { - return templ_7745c5c3_Err - } - } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "
Last Synced: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 185, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if book.ConfidenceScore > 0 { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "
Auto-Link Confidence: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var25 string + templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 191, Col: 30} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "% confidence
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if ignored { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "Ignore

Potential Matches

Click \"Find Potential Matches\" to search

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +// unlinkedManualLinkModal holds the manual-link dialog markup (unchanged +// behavior; extracted so the section partial can be re-rendered on its own). +func unlinkedManualLinkModal() 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 { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var34 := templ.GetChildren(ctx) + if templ_7745c5c3_Var34 == nil { + templ_7745c5c3_Var34 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "Link Book") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }