Compare commits

...
1 Commits
Author SHA1 Message Date
John O'Keefe f6a73ab5aa feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s
Unidentified sync files used to be stuck: every KOReader re-sync
re-queues them, and there was no way to say "this isn't a book" or "I
don't want this one on the server".

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

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

No files matched your search

+189
View File
@@ -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")
}
+6
View File
@@ -1496,6 +1496,12 @@ CREATE TABLE IF NOT EXISTS unlinked_books (
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() 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_device ON unlinked_books(device_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_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. -- One queue row per (device, document): the sync ingest upserts on this pair.
+1
View File
@@ -562,6 +562,7 @@ type UnlinkedBooks struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
} }
type UserDashboardPreferences struct { type UserDashboardPreferences struct {
+16 -4
View File
@@ -53,9 +53,10 @@ type Querier interface {
// collections page); same live filter as GetCollectionItems, plus the // collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope. // future-hybrid excluded exclusion and an optional library scope.
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) 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) 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) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
CountUserDevices(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) CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
@@ -456,7 +457,9 @@ type Querier interface {
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
// Get unlinked book by ID // Get unlinked book by ID
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error) 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) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
@@ -504,6 +507,9 @@ type Querier interface {
// static collection paths apply. // static collection paths apply.
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, 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) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
// Check if book is in collection // Check if book is in collection
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error) 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 // archived row's reading data into its active twin instead of waiting for a
// file that will never return. // file that will never return.
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error) 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) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, 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) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// List unresolved unlinked books with pagination // List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) 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) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk. // First consecutive scan that cannot find the file on disk.
@@ -637,6 +647,8 @@ type Querier interface {
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Undo a soft-dismiss.
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
// Update collection // Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write // Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
+171 -17
View File
@@ -372,10 +372,10 @@ func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollect
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books 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) { func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID) row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID)
var count int64 var count int64
@@ -387,10 +387,11 @@ const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBoo
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id 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) { func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID) row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
var count int64 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) INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6) 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 { type CreateUnlinkedBookParams struct {
@@ -1996,6 +1997,7 @@ func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBook
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
@@ -9809,7 +9811,7 @@ func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProg
} }
const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one 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 { type GetUnlinkedBookByContentIdParams struct {
@@ -9835,12 +9837,13 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one 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 // Get unlinked book by ID
@@ -9861,15 +9864,69 @@ func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (Unli
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &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 return i, err
} }
const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many 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 FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id 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 ORDER BY ub.last_seen_at DESC
` `
@@ -9887,11 +9944,12 @@ type GetUnlinkedBooksByDeviceRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_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"` DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"` 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) { func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) {
rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID) rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID)
if err != nil { if err != nil {
@@ -9915,6 +9973,7 @@ func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName, &i.DeviceName,
&i.DeviceType, &i.DeviceType,
); err != nil { ); err != nil {
@@ -10985,6 +11044,17 @@ func (q *Queries) HasRecentConflictResolution(ctx context.Context, arg HasRecent
return exists, err 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 const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one
UPDATE sync_queue UPDATE sync_queue
SET SET
@@ -11071,7 +11141,7 @@ SET
resolved_at = NOW(), resolved_at = NOW(),
resolution_method = 'manual_link' resolution_method = 'manual_link'
WHERE id = $1 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 { type LinkUnlinkedBookParams struct {
@@ -11098,6 +11168,7 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
@@ -11680,6 +11751,72 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt
return items, nil 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 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 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 FROM libraries l
@@ -13040,7 +13177,7 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI
} }
const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many 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 FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id JOIN devices d ON ub.device_id = d.id
WHERE ub.resolved = false WHERE ub.resolved = false
@@ -13067,6 +13204,7 @@ type ListUnresolvedUnlinkedBooksRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_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"` DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"` DeviceType string `db:"device_type" json:"device_type"`
} }
@@ -13095,6 +13233,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName, &i.DeviceName,
&i.DeviceType, &i.DeviceType,
); err != nil { ); err != nil {
@@ -13109,10 +13248,10 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
} }
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many 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 FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id 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 ORDER BY ub.last_seen_at DESC
` `
@@ -13130,11 +13269,13 @@ type ListUnresolvedUnlinkedBooksForUserRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_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"` DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"` 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) { func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID) rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
if err != nil { if err != nil {
@@ -13158,6 +13299,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName, &i.DeviceName,
&i.DeviceType, &i.DeviceType,
); err != nil { ); err != nil {
@@ -13814,7 +13956,7 @@ SET
resolved_at = NOW(), resolved_at = NOW(),
resolution_method = $3 resolution_method = $3
WHERE id = $1 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 { type ResolveUnlinkedBookParams struct {
@@ -13841,6 +13983,7 @@ func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBo
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
@@ -15246,6 +15389,16 @@ func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID)
return err 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 const UpdateCollection = `-- name: UpdateCollection :one
UPDATE collections UPDATE collections
SET SET
@@ -17649,7 +17802,7 @@ SET file_path = EXCLUDED.file_path,
author = EXCLUDED.author, author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score, confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW() 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 { type UpsertUnlinkedBookParams struct {
@@ -17687,6 +17840,7 @@ func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBook
&i.ResolutionMethod, &i.ResolutionMethod,
&i.LastSeenAt, &i.LastSeenAt,
&i.CreatedAt, &i.CreatedAt,
&i.IgnoredAt,
) )
return i, err return i, err
} }
+35 -8
View File
@@ -3701,12 +3701,12 @@ INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, con
VALUES ($1, $2, $3, $4, $5, $6) VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *; RETURNING *;
-- Get unlinked books for a device -- Get unlinked books for a device (ignored rows stay hidden)
-- name: GetUnlinkedBooksByDevice :many -- name: GetUnlinkedBooksByDevice :many
SELECT ub.*, d.device_name, d.device_type SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id 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; ORDER BY ub.last_seen_at DESC;
-- Get unlinked book by ContentId -- Get unlinked book by ContentId
@@ -3724,11 +3724,11 @@ SET
WHERE id = $1 WHERE id = $1
RETURNING *; RETURNING *;
-- Count unlinked books for a device -- Count unlinked books for a device (ignored rows stay hidden)
-- name: CountUnlinkedBooks :one -- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books 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 -- Link unlinked book to media item
-- name: LinkUnlinkedBook :one -- name: LinkUnlinkedBook :one
@@ -3772,21 +3772,48 @@ SET file_path = EXCLUDED.file_path,
last_seen_at = NOW() last_seen_at = NOW()
RETURNING *; 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 -- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count SELECT COUNT(*) as count
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id 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 -- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id 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; 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 -- CAROUSEL-STYLE DASHBOARD
-- ============================================ -- ============================================
+127 -1
View File
@@ -767,6 +767,8 @@ func registerFrontendRoutes(cfg *Config) {
// resolution rules as the dashboard page (family-lens safe). // resolution rules as the dashboard page (family-lens safe).
// Unlinked-books page: the identification queue for device files the // Unlinked-books page: the identification queue for device files the
// sync could not match (personal data — the caller's own devices only). // 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 { frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg) user, err := getTemplateUserWithTheme(c, cfg)
if err != nil { if err != nil {
@@ -781,6 +783,11 @@ func registerFrontendRoutes(cfg *Config) {
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr) log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{} 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)) books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows { for _, r := range rows {
@@ -796,14 +803,133 @@ func registerFrontendRoutes(cfg *Config) {
ConfidenceScore: r.ConfidenceScore.Float64, 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 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 renderErr
} }
return c.HTML(http.StatusOK, buf.String()) return c.HTML(http.StatusOK, buf.String())
}) })
// Unlinked-book dismiss/delete actions (htmx: the buttons post/delete
// and swap in the re-rendered section). Authorization is row → device
// → requesting user (admin may act on anyone's rows); a non-htmx hit
// just bounces back to the page. These are page-scoped SSR routes, so
// they are intentionally NOT under /api and not in the OpenAPI docs.
unlinkedSectionHTML := func(c *echo.Context, dbUser database.Users) error {
userUUID, err := uuid.Parse(dbUser.ID.String())
if err != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
rows, err := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
ignoredRows, err := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
books = append(books, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
for _, r := range ignoredRows {
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooksSection(books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
}
// authorizeUnlinkedAction resolves the :id row with its device owner
// and gates the action: nil row + status/message on failure.
authorizeUnlinkedAction := func(c *echo.Context, dbUser database.Users) (*database.GetUnlinkedBookWithOwnerRow, int, string) {
rowID, err := uuid.Parse(c.Param("id"))
if err != nil {
return nil, http.StatusBadRequest, "<div>Invalid entry id</div>"
}
row, err := cfg.Queries.GetUnlinkedBookWithOwner(c.Request().Context(), pgtype.UUID{Bytes: rowID, Valid: true})
if err != nil {
return nil, http.StatusNotFound, "<div>Entry not found</div>"
}
ownerID := uuid.UUID(row.UserID.Bytes).String()
if ownerID != dbUser.ID.String() && dbUser.Role != "admin" {
return nil, http.StatusForbidden, "<div>Not allowed</div>"
}
return &row, 0, ""
}
for _, action := range []struct {
method string
path string
run func(ctx context.Context, rowID pgtype.UUID) error
}{
{"POST", "/unlinked-books/:id/ignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.IgnoreUnlinkedBook(ctx, rowID)
}},
{"POST", "/unlinked-books/:id/unignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.UnignoreUnlinkedBook(ctx, rowID)
}},
{"DELETE", "/unlinked-books/:id", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.DeleteUnlinkedBook(ctx, rowID)
}},
} {
method, path, run := action.method, action.path, action.run
frontendProtected.Add(method, path, func(c *echo.Context) error {
dbUser, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
row, status, msg := authorizeUnlinkedAction(c, dbUser)
if row == nil {
return c.HTML(status, msg)
}
if err := run(c.Request().Context(), row.ID); err != nil {
log.Printf("unlinked-book action %s %s failed: %v", method, path, err)
return c.HTML(http.StatusInternalServerError, "<div>Action failed</div>")
}
if c.Request().Header.Get("HX-Request") != "true" {
return c.Redirect(http.StatusSeeOther, "/unlinked-books")
}
return unlinkedSectionHTML(c, dbUser)
})
}
// Unlinked-book match results partial: the match cascade for one device // Unlinked-book match results partial: the match cascade for one device
// file, rendered server-side and scoped to the requester's visible // file, rendered server-side and scoped to the requester's visible
// libraries (absent-from-results) — rendering-ownership: templ renders, // libraries (absent-from-results) — rendering-ownership: templ renders,
+18 -3
View File
@@ -68,7 +68,7 @@ func TestDashboardUnlinkedBanner(t *testing.T) {
} }
} }
// The unlinked-books page renders queue rows standalone. // The unlinked-books page renders queue rows standalone, both buckets.
func TestUnlinkedBooksPageRender(t *testing.T) { func TestUnlinkedBooksPageRender(t *testing.T) {
var b strings.Builder var b strings.Builder
user := User{ID: "u1", Timezone: "UTC"} user := User{ID: "u1", Timezone: "UTC"}
@@ -81,11 +81,26 @@ func TestUnlinkedBooksPageRender(t *testing.T) {
TitleFromDevice: "Some Sideloaded Book", TitleFromDevice: "Some Sideloaded Book",
LastSyncTime: "2026-10-18 12:00", 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) t.Fatalf("render: %v", err)
} }
out := b.String() 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) { if !strings.Contains(out, want) {
t.Errorf("unlinked page missing %q", want) t.Errorf("unlinked page missing %q", want)
} }
+268 -187
View File
@@ -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) {
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@@ -62,193 +62,274 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
<body class={ "theme-" + user.Theme } x-data="unlinkedBooks" x-init="setupEventDelegation()"> <body class={ "theme-" + user.Theme } x-data="unlinkedBooks" x-init="setupEventDelegation()">
@Header(user, "/unlinked") @Header(user, "/unlinked")
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8"> <main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
<div class="mb-8"> @UnlinkedBooksSection(unresolved, ignored)
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("sync", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Unlinked Books</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p>
</div>
if len(unlinkedBooks) > 0 {
<div class="card p-4 mb-6 flex flex-wrap justify-between items-center gap-3">
<div class="flex items-center gap-4">
<input type="checkbox" id="select-all-unlinked" @change="toggleAllUnlinked()" class="w-5 h-5"/>
<label for="select-all-unlinked" class="cursor-pointer text-sm font-medium" style="color: var(--text-primary)">Select All</label>
<span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span>
</div>
<div class="flex gap-2 flex-wrap">
<button @click="bulkAutoLink()" class="btn btn-secondary text-sm">
@Icon("sync", "h-4 w-4")
Auto-Link Selected (High Confidence)
</button>
<button @click="bulkGetSuggestions()" class="btn btn-secondary text-sm">
@Icon("search", "h-4 w-4")
Get Suggestions for Selected
</button>
<button @click="showBulkManualLink()" class="btn btn-primary text-sm">
@Icon("edit", "h-4 w-4")
Bulk Manual Link
</button>
</div>
</div>
}
<div id="unlinked-container" class="space-y-6">
if len(unlinkedBooks) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">All Books Linked!</h3>
<p style="color: var(--text-secondary)">There are no unlinked books that need manual resolution.</p>
</div>
}
for _, book := range unlinkedBooks {
<div class="card p-6">
<div class="flex gap-6">
<div class="flex items-start pt-2">
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id={ book.ProgressID } data-title={ book.TitleFromDevice }/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-4">
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
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")
}
</span>
<div class="min-w-0">
<h3 class="font-semibold text-lg truncate" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
<p class="text-sm" style="color: var(--text-secondary)">
Device: { book.DeviceName } ({ book.DeviceType })
</p>
</div>
</div>
<div class="space-y-2 text-sm mb-4">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">File Path:</span>
<code class="chip font-mono">
@Icon("folder", "h-3.5 w-3.5")
{ book.FilePath }
</code>
</div>
if book.SHA256 != "" {
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">SHA-256:</span>
<code class="chip font-mono text-xs" title={ book.SHA256 }>{ book.SHA256 }</code>
</div>
}
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Last Synced:</span>
<span style="color: var(--text-primary)">{ book.LastSyncTime }</span>
</div>
if book.ConfidenceScore > 0 {
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Auto-Link Confidence:</span>
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">
{ book.ConfidenceScore }% confidence
</span>
</div>
}
</div>
<div class="mb-4 flex gap-2 flex-wrap">
<button
@click={ "searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")" }
class="btn btn-secondary text-sm"
>
@Icon("search", "h-4 w-4")
Find Potential Matches
</button>
<button
@click={ "showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")" }
class="btn btn-primary text-sm"
>
@Icon("edit", "h-4 w-4")
Manually Link
</button>
</div>
<div id={ "matches-" + book.ProgressID } class="hidden">
<div class="border-t pt-4 mt-2" style="border-color: var(--border);">
<h4 class="font-semibold mb-3" style="color: var(--text-primary)">Potential Matches</h4>
<div id={ "matches-list-" + book.ProgressID } class="space-y-3">
<p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p>
</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
</main> </main>
<div id="manual-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);"> @unlinkedManualLinkModal()
<div class="card p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2>
<button @click="hideManualLinkModal()" class="icon-btn" aria-label="Close">@Icon("close", "h-5 w-5")</button>
</div>
<input type="hidden" id="link-progress-id"/>
<input type="hidden" id="link-book-sha256"/>
<div class="mb-4">
<label for="link-book-title" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
<input
type="text"
id="link-book-title"
readonly
class="input"
style="color: var(--text-secondary);"
/>
</div>
<div class="mb-4">
<label for="link-search-input" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label>
<div class="flex gap-2">
<input
type="text"
id="link-search-input"
placeholder="Search by title, author…"
class="input flex-1"
/>
<button
type="button"
@click="searchBooksForLink()"
class="btn btn-primary"
>
@Icon("search", "h-4 w-4")
Search
</button>
</div>
</div>
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2">
<p class="text-sm" style="color: var(--text-secondary)">Search for books to link</p>
</div>
<div class="mb-4">
<label for="link-confidence" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
<input
type="number"
id="link-confidence"
min="0"
max="1"
step="0.1"
value="1.0"
class="input"
/>
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="hideManualLinkModal()" class="btn btn-secondary">
Cancel
</button>
<button type="button" @click="confirmManualLink()" class="btn btn-primary">
@Icon("check", "h-4 w-4")
Link Book
</button>
</div>
</div>
</div>
</body> </body>
</html> </html>
} }
// 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) {
<div id="unlinked-section">
<div class="mb-8">
<div class="flex items-center gap-3 mb-1">
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("sync", "h-5 w-5")
</span>
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Unlinked Books</h1>
</div>
<p class="text-sm" style="color: var(--text-secondary)">Resolve books that couldn't be automatically matched between devices</p>
</div>
if len(unresolved) > 0 {
<div class="card p-4 mb-6 flex flex-wrap justify-between items-center gap-3">
<div class="flex items-center gap-4">
<input type="checkbox" id="select-all-unlinked" @change="toggleAllUnlinked()" class="w-5 h-5"/>
<label for="select-all-unlinked" class="cursor-pointer text-sm font-medium" style="color: var(--text-primary)">Select All</label>
<span id="selected-count" class="text-sm" style="color: var(--text-secondary)">0 selected</span>
</div>
<div class="flex gap-2 flex-wrap">
<button @click="bulkAutoLink()" class="btn btn-secondary text-sm">
@Icon("sync", "h-4 w-4")
Auto-Link Selected (High Confidence)
</button>
<button @click="bulkGetSuggestions()" class="btn btn-secondary text-sm">
@Icon("search", "h-4 w-4")
Get Suggestions for Selected
</button>
<button @click="showBulkManualLink()" class="btn btn-primary text-sm">
@Icon("edit", "h-4 w-4")
Bulk Manual Link
</button>
</div>
</div>
}
<div id="unlinked-container" class="space-y-6">
if len(unresolved) == 0 {
<div class="card text-center py-16">
<span class="grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl" style="background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);">
@Icon("check-circle", "h-7 w-7")
</span>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">All Books Linked!</h3>
<p style="color: var(--text-secondary)">There are no unlinked books that need manual resolution.</p>
</div>
}
for _, book := range unresolved {
@unlinkedBookCard(book, false)
}
</div>
if len(ignored) > 0 {
<details class="mt-8">
<summary class="cursor-pointer text-sm font-medium mb-3 select-none" style="color: var(--text-secondary)">
Ignored ({ fmt.Sprintf("%d", len(ignored)) }) — hidden from the queue and the dashboard badge
</summary>
<div class="space-y-6">
for _, book := range ignored {
@unlinkedBookCard(book, true)
}
</div>
</details>
}
</div>
}
// 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) {
<div class="card p-6">
<div class="flex gap-6">
if !ignored {
<div class="flex items-start pt-2">
<input type="checkbox" class="unlinked-checkbox w-5 h-5" data-progress-id={ book.ProgressID } data-title={ book.TitleFromDevice }/>
</div>
}
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 mb-4">
<span class="grid place-items-center h-11 w-11 rounded-xl shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
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")
}
</span>
<div class="min-w-0">
<h3 class="font-semibold text-lg truncate" style="color: var(--text-primary)">{ book.TitleFromDevice }</h3>
<p class="text-sm" style="color: var(--text-secondary)">
Device: { book.DeviceName } ({ book.DeviceType })
</p>
</div>
</div>
<div class="space-y-2 text-sm mb-4">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">File Path:</span>
<code class="chip font-mono">
@Icon("folder", "h-3.5 w-3.5")
{ book.FilePath }
</code>
</div>
if book.SHA256 != "" {
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">SHA-256:</span>
<code class="chip font-mono text-xs" title={ book.SHA256 }>{ book.SHA256 }</code>
</div>
}
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Last Synced:</span>
<span style="color: var(--text-primary)">{ book.LastSyncTime }</span>
</div>
if book.ConfidenceScore > 0 {
<div class="flex items-center gap-2">
<span class="text-xs uppercase tracking-wide font-medium" style="color: var(--text-secondary)">Auto-Link Confidence:</span>
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">
{ book.ConfidenceScore }% confidence
</span>
</div>
}
</div>
if ignored {
<div class="mb-4 flex gap-2 flex-wrap">
<button
hx-post={ "/unlinked-books/" + book.ProgressID + "/unignore" }
hx-target="#unlinked-section"
hx-swap="outerHTML"
class="btn btn-secondary text-sm"
>
@Icon("check", "h-4 w-4")
Restore
</button>
<button
hx-delete={ "/unlinked-books/" + book.ProgressID }
hx-confirm="Delete this ignored entry permanently?"
hx-target="#unlinked-section"
hx-swap="outerHTML"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Delete
</button>
</div>
} else {
<div class="mb-4 flex gap-2 flex-wrap">
<button
@click={ "searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")" }
class="btn btn-secondary text-sm"
>
@Icon("search", "h-4 w-4")
Find Potential Matches
</button>
<button
@click={ "showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")" }
class="btn btn-primary text-sm"
>
@Icon("edit", "h-4 w-4")
Manually Link
</button>
<button
hx-post={ "/unlinked-books/" + book.ProgressID + "/ignore" }
hx-target="#unlinked-section"
hx-swap="outerHTML"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Ignore
</button>
<button
hx-delete={ "/unlinked-books/" + book.ProgressID }
hx-confirm="Delete this entry? If the device syncs this file again, it will reappear here."
hx-target="#unlinked-section"
hx-swap="outerHTML"
class="btn btn-secondary text-sm"
>
@Icon("close", "h-4 w-4")
Delete
</button>
</div>
<div id={ "matches-" + book.ProgressID } class="hidden">
<div class="border-t pt-4 mt-2" style="border-color: var(--border);">
<h4 class="font-semibold mb-3" style="color: var(--text-primary)">Potential Matches</h4>
<div id={ "matches-list-" + book.ProgressID } class="space-y-3">
<p style="color: var(--text-secondary)">Click "Find Potential Matches" to search</p>
</div>
</div>
</div>
}
</div>
</div>
</div>
}
// unlinkedManualLinkModal holds the manual-link dialog markup (unchanged
// behavior; extracted so the section partial can be re-rendered on its own).
templ unlinkedManualLinkModal() {
<div id="manual-link-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: var(--surface-overlay);">
<div class="card p-6 w-full max-w-md mx-4">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Manually Link Book</h2>
<button @click="hideManualLinkModal()" class="icon-btn" aria-label="Close">@Icon("close", "h-5 w-5")</button>
</div>
<input type="hidden" id="link-progress-id"/>
<input type="hidden" id="link-book-sha256"/>
<div class="mb-4">
<label for="link-book-title" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Book Title from Device</label>
<input
type="text"
id="link-book-title"
readonly
class="input"
style="color: var(--text-secondary);"
/>
</div>
<div class="mb-4">
<label for="link-search-input" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Search for Book to Link</label>
<div class="flex gap-2">
<input
type="text"
id="link-search-input"
placeholder="Search by title, author…"
class="input flex-1"
/>
<button
type="button"
@click="searchBooksForLink()"
class="btn btn-primary"
>
@Icon("search", "h-4 w-4")
Search
</button>
</div>
</div>
<div id="link-search-results" class="mb-4 max-h-48 overflow-y-auto space-y-2">
<p class="text-sm" style="color: var(--text-secondary)">Search for books to link</p>
</div>
<div class="mb-4">
<label for="link-confidence" class="block text-xs uppercase tracking-wide font-medium mb-2" style="color: var(--text-secondary)">Confidence Score</label>
<input
type="number"
id="link-confidence"
min="0"
max="1"
step="0.1"
value="1.0"
class="input"
/>
<p class="text-xs mt-1" style="color: var(--text-secondary)">1.0 = manual override (highest confidence)</p>
</div>
<div class="flex justify-end gap-3">
<button type="button" @click="hideManualLinkModal()" class="btn btn-secondary">
Cancel
</button>
<button type="button" @click="confirmManualLink()" class="btn btn-primary">
@Icon("check", "h-4 w-4")
Link Book
</button>
</div>
</div>
</div>
}
+410 -155
View File
@@ -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) { 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 templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<main class=\"mx-auto container px-4 sm:px-6 lg:px-8 py-8\">")
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, "</main>")
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, "</body></html>")
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, "<div id=\"unlinked-section\"><div class=\"mb-8\"><div class=\"flex items-center gap-3 mb-1\"><span class=\"grid place-items-center h-10 w-10 rounded-xl\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -229,12 +279,12 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Unlinked Books</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</span><h1 class=\"text-2xl font-bold tracking-tight\" style=\"color: var(--text-primary)\">Unlinked Books</h1></div><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Resolve books that couldn't be automatically matched between devices</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(unlinkedBooks) > 0 { if len(unresolved) > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<div class=\"card p-4 mb-6 flex flex-wrap justify-between items-center gap-3\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer text-sm font-medium\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"bulkAutoLink()\" class=\"btn btn-secondary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<div class=\"card p-4 mb-6 flex flex-wrap justify-between items-center gap-3\"><div class=\"flex items-center gap-4\"><input type=\"checkbox\" id=\"select-all-unlinked\" @change=\"toggleAllUnlinked()\" class=\"w-5 h-5\"> <label for=\"select-all-unlinked\" class=\"cursor-pointer text-sm font-medium\" style=\"color: var(--text-primary)\">Select All</label> <span id=\"selected-count\" class=\"text-sm\" style=\"color: var(--text-secondary)\">0 selected</span></div><div class=\"flex gap-2 flex-wrap\"><button @click=\"bulkAutoLink()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -242,7 +292,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn btn-secondary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "Auto-Link Selected (High Confidence)</button> <button @click=\"bulkGetSuggestions()\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -250,7 +300,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn btn-primary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "Get Suggestions for Selected</button> <button @click=\"showBulkManualLink()\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -258,17 +308,17 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "Bulk Manual Link</button></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "Bulk Manual Link</button></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<div id=\"unlinked-container\" class=\"space-y-6\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div id=\"unlinked-container\" class=\"space-y-6\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if len(unlinkedBooks) == 0 { if len(unresolved) == 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"card text-center py-16\"><span class=\"grid place-items-center h-14 w-14 mx-auto mb-4 rounded-2xl\" style=\"background-color: color-mix(in srgb, var(--status-success) 16%, transparent); color: var(--status-success);\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -276,200 +326,332 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p style=\"color: var(--text-secondary)\">There are no unlinked books that need manual resolution.</p></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span><h3 class=\"text-xl font-semibold mb-2\" style=\"color: var(--text-primary)\">All Books Linked!</h3><p style=\"color: var(--text-secondary)\">There are no unlinked books that need manual resolution.</p></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
for _, book := range unlinkedBooks { for _, book := range unresolved {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"card p-6\"><div class=\"flex gap-6\"><div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"") templ_7745c5c3_Err = unlinkedBookCard(book, false).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var13 string }
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 111, Col: 100} return templ_7745c5c3_Err
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if len(ignored) > 0 {
if templ_7745c5c3_Err != nil { templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<details class=\"mt-8\"><summary class=\"cursor-pointer text-sm font-medium mb-3 select-none\" style=\"color: var(--text-secondary)\">Ignored (")
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" data-title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var14 string 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 { 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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></div><div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, ") — hidden from the queue and the dashboard badge</summary><div class=\"space-y-6\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.DeviceType == "koreader" { for _, book := range ignored {
templ_7745c5c3_Err = Icon("book-open", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer) templ_7745c5c3_Err = unlinkedBookCard(book, true).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)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></details>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var15 string }
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice) templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 125, Col: 111} return templ_7745c5c3_Err
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) return nil
if templ_7745c5c3_Err != nil { })
return templ_7745c5c3_Err }
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ") // 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, "<div class=\"card p-6\"><div class=\"flex gap-6\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if !ignored {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"flex items-start pt-2\"><input type=\"checkbox\" class=\"unlinked-checkbox w-5 h-5\" data-progress-id=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var16 string var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName) templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 127, Col: 37} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 148, Col: 96}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " (") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var17 string var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType) templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 127, Col: 58} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 148, Col: 132}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">File Path:</span> <code class=\"chip font-mono\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err 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, "<div class=\"flex-1 min-w-0\"><div class=\"flex items-center gap-3 mb-4\"><span class=\"grid place-items-center h-11 w-11 rounded-xl shrink-0\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
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 { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var18 string } else if book.DeviceType == "kobo" {
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath) templ_7745c5c3_Err = Icon("library", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
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))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</code></div>") } else {
templ_7745c5c3_Err = Icon("device", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
if book.SHA256 != "" { }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">SHA-256:</span> <code class=\"chip font-mono text-xs\" title=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var19 string var templ_7745c5c3_Var18 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256) templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 142, Col: 68} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 163, Col: 106}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var20 string var templ_7745c5c3_Var19 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256) templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 142, Col: 84} 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_Var20)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</code></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " (")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} var templ_7745c5c3_Var20 string
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">") 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, ")</p></div></div><div class=\"space-y-2 text-sm mb-4\"><div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">File Path:</span> <code class=\"chip font-mono\">")
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, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.SHA256 != "" {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<div class=\"flex items-center gap-2 flex-wrap\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">SHA-256:</span> <code class=\"chip font-mono text-xs\" title=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var21 string var templ_7745c5c3_Var22 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime) templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 147, Col: 71} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 180, Col: 63}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.ConfidenceScore > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
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</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div><div class=\"mb-4 flex gap-2 flex-wrap\"><button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var23 string var templ_7745c5c3_Var23 string
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")") templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 160, Col: 138} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 180, Col: 79}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "\" class=\"btn btn-secondary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Last Synced:</span> <span style=\"color: var(--text-primary)\">")
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, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if book.ConfidenceScore > 0 {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"flex items-center gap-2\"><span class=\"text-xs uppercase tracking-wide font-medium\" style=\"color: var(--text-secondary)\">Auto-Link Confidence:</span> <span class=\"badge\" style=\"background-color: var(--accent-muted); color: var(--accent);\">")
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</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
if ignored {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"mb-4 flex gap-2 flex-wrap\"><button hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var26 string
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("/unlinked-books/" + book.ProgressID + "/unignore")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 199, Col: 67}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" hx-target=\"#unlinked-section\" hx-swap=\"outerHTML\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("check", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "Restore</button> <button hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var27 string
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue("/unlinked-books/" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 208, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\" hx-confirm=\"Delete this ignored entry permanently?\" hx-target=\"#unlinked-section\" hx-swap=\"outerHTML\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "Delete</button></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
} else {
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<div class=\"mb-4 flex gap-2 flex-wrap\"><button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var28 string
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue("searchMatches(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.SHA256) + ", " + JSONArg(book.TitleFromDevice) + ")")
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 221, Col: 134}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -477,20 +659,20 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "Find Potential Matches</button> <button @click=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "Find Potential Matches</button> <button @click=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var24 string var templ_7745c5c3_Var29 string
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")") templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 167, Col: 114} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 228, Col: 110}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" class=\"btn btn-primary text-sm\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -498,38 +680,111 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "Manually Link</button></div><div id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "Manually Link</button> <button hx-post=\"")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var25 string var templ_7745c5c3_Var30 string
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID) templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("/unlinked-books/" + book.ProgressID + "/ignore")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 174, Col: 47} return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 235, Col: 65}
} }
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25) _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\" class=\"hidden\"><div class=\"border-t pt-4 mt-2\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" hx-target=\"#unlinked-section\" hx-swap=\"outerHTML\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
var templ_7745c5c3_Var26 string templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 177, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div></div></div></div>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "Ignore</button> <button hx-delete=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var31 string
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue("/unlinked-books/" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 244, Col: 55}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" hx-confirm=\"Delete this entry? If the device syncs this file again, it will reappear here.\" hx-target=\"#unlinked-section\" hx-swap=\"outerHTML\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "Delete</button></div><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var32 string
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 254, Col: 43}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\" class=\"hidden\"><div class=\"border-t pt-4 mt-2\" style=\"border-color: var(--border);\"><h4 class=\"font-semibold mb-3\" style=\"color: var(--text-primary)\">Potential Matches</h4><div id=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var33 string
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue("matches-list-" + book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 257, Col: 50}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</div></main><div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"icon-btn\" aria-label=\"Close\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</div></div></div>")
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, "<div id=\"manual-link-modal\" class=\"hidden fixed inset-0 z-50 flex items-center justify-center p-4\" style=\"background-color: var(--surface-overlay);\"><div class=\"card p-6 w-full max-w-md mx-4\"><div class=\"flex justify-between items-center mb-6\"><h2 class=\"text-xl font-bold\" style=\"color: var(--text-primary)\">Manually Link Book</h2><button @click=\"hideManualLinkModal()\" class=\"icon-btn\" aria-label=\"Close\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -537,7 +792,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label for=\"link-book-title\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"input\" style=\"color: var(--text-secondary);\"></div><div class=\"mb-4\"><label for=\"link-search-input\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author…\" class=\"input flex-1\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "</button></div><input type=\"hidden\" id=\"link-progress-id\"> <input type=\"hidden\" id=\"link-book-sha256\"><div class=\"mb-4\"><label for=\"link-book-title\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Book Title from Device</label> <input type=\"text\" id=\"link-book-title\" readonly class=\"input\" style=\"color: var(--text-secondary);\"></div><div class=\"mb-4\"><label for=\"link-search-input\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Search for Book to Link</label><div class=\"flex gap-2\"><input type=\"text\" id=\"link-search-input\" placeholder=\"Search by title, author…\" class=\"input flex-1\"> <button type=\"button\" @click=\"searchBooksForLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -545,7 +800,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label for=\"link-confidence\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"input\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn btn-primary\">") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "Search</button></div></div><div id=\"link-search-results\" class=\"mb-4 max-h-48 overflow-y-auto space-y-2\"><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Search for books to link</p></div><div class=\"mb-4\"><label for=\"link-confidence\" class=\"block text-xs uppercase tracking-wide font-medium mb-2\" style=\"color: var(--text-secondary)\">Confidence Score</label> <input type=\"number\" id=\"link-confidence\" min=\"0\" max=\"1\" step=\"0.1\" value=\"1.0\" class=\"input\"><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">1.0 = manual override (highest confidence)</p></div><div class=\"flex justify-end gap-3\"><button type=\"button\" @click=\"hideManualLinkModal()\" class=\"btn btn-secondary\">Cancel</button> <button type=\"button\" @click=\"confirmManualLink()\" class=\"btn btn-primary\">")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
@@ -553,7 +808,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Link Book</button></div></div></div></body></html>") templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "Link Book</button></div></div></div>")
if templ_7745c5c3_Err != nil { if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err return templ_7745c5c3_Err
} }