feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s

Unidentified sync files used to be stuck: every KOReader re-sync
re-queues them, and there was no way to say "this isn't a book" or "I
don't want this one on the server".

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

Actions are page-scoped SSR routes: htmx swaps in the re-rendered
section so counts and buckets can never drift from the DB; ownership
is row → device → user (admins may act on any row). Integration tests
pin the lifecycle (hide everywhere, survive the sync upsert, restore,
delete), foreign-user 403s, and the render surface.
This commit is contained in:
John O'Keefe committed 2026-10-26 14:58:50 -04:00
1 parent afbf1029a9
commit f6a73ab5aa
10 files changed
+1056 -190

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()
);
-- Soft-dismiss for unlinked_books: ignored rows stay on the device's sync
-- queue (UpsertUnlinkedBook keeps refreshing their file metadata, and must
-- never clear this column) but are hidden from the page list, badge count,
-- and per-device API. Reversible via UnignoreUnlinkedBook.
ALTER TABLE unlinked_books ADD COLUMN IF NOT EXISTS ignored_at TIMESTAMP WITH TIME ZONE;
CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id);
CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id);
-- One queue row per (device, document): the sync ingest upserts on this pair.
+1
View File
@@ -562,6 +562,7 @@ type UnlinkedBooks struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
}
type UserDashboardPreferences struct {
+16 -4
View File
@@ -53,9 +53,10 @@ type Querier interface {
// collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope.
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
// Count unlinked books for a device
// Count unlinked books for a device (ignored rows stay hidden)
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved queue rows across the caller's own devices.
// Banner count: unresolved, non-ignored queue rows across the caller's own
// devices. Ignored rows must not nag from the dashboard badge.
CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error)
CreateAutoResolvedSyncConflict(ctx context.Context, arg CreateAutoResolvedSyncConflictParams) (SyncConflicts, error)
@@ -456,7 +457,9 @@ type Querier interface {
GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error)
// Get unlinked book by ID
GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (UnlinkedBooks, error)
// Get unlinked books for a device
// Fetch one row together with its device's owner for authorization.
GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error)
// Get unlinked books for a device (ignored rows stay hidden)
GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error)
GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error)
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
@@ -504,6 +507,9 @@ type Querier interface {
// static collection paths apply.
GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error)
HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error)
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error)
// Check if book is in collection
IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error)
@@ -530,6 +536,9 @@ type Querier interface {
// archived row's reading data into its active twin instead of waiting for a
// file that will never return.
ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaItemsRow, error)
// Page list, ignored bucket: rows the user dismissed, kept for the
// collapsible "Ignored" section so a misclick is recoverable.
ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error)
ListLibraries(ctx context.Context) ([]ListLibrariesRow, error)
ListMediaItems(ctx context.Context, arg ListMediaItemsParams) ([]ListMediaItemsRow, error)
ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListMediaItemsByLibraryRow, error)
@@ -553,7 +562,8 @@ type Querier interface {
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices.
// Page list: unresolved queue rows for the caller's own devices (ignored
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error)
ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk.
@@ -637,6 +647,8 @@ type Querier interface {
TombstoneMediaNoteByDedupKey(ctx context.Context, arg TombstoneMediaNoteByDedupKeyParams) error
TombstoneMediaNoteByID(ctx context.Context, arg TombstoneMediaNoteByIDParams) error
TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error
// Undo a soft-dismiss.
UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error
// Update collection
UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error)
// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
+171 -17
View File
@@ -372,10 +372,10 @@ func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollect
const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count
FROM unlinked_books
WHERE device_id = $1 AND resolved = false
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL
`
// Count unlinked books for a device
// Count unlinked books for a device (ignored rows stay hidden)
func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID)
var count int64
@@ -387,10 +387,11 @@ const CountUnresolvedUnlinkedBooksForUser = `-- name: CountUnresolvedUnlinkedBoo
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
`
// Banner count: unresolved queue rows across the caller's own devices.
// Banner count: unresolved, non-ignored queue rows across the caller's own
// devices. Ignored rows must not nag from the dashboard badge.
func (q *Queries) CountUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountUnresolvedUnlinkedBooksForUser, userID)
var count int64
@@ -1956,7 +1957,7 @@ const CreateUnlinkedBook = `-- name: CreateUnlinkedBook :one
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type CreateUnlinkedBookParams struct {
@@ -1996,6 +1997,7 @@ func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBook
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
@@ -9809,7 +9811,7 @@ func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProg
}
const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2
`
type GetUnlinkedBookByContentIdParams struct {
@@ -9835,12 +9837,13 @@ func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinke
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
const GetUnlinkedBookByID = `-- name: GetUnlinkedBookByID :one
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE id = $1
SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at FROM unlinked_books WHERE id = $1
`
// Get unlinked book by ID
@@ -9861,15 +9864,69 @@ func (q *Queries) GetUnlinkedBookByID(ctx context.Context, id pgtype.UUID) (Unli
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
const GetUnlinkedBookWithOwner = `-- name: GetUnlinkedBookWithOwner :one
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.user_id, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.id = $1
`
type GetUnlinkedBookWithOwnerRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Fetch one row together with its device's owner for authorization.
func (q *Queries) GetUnlinkedBookWithOwner(ctx context.Context, id pgtype.UUID) (GetUnlinkedBookWithOwnerRow, error) {
row := q.db.QueryRow(ctx, GetUnlinkedBookWithOwner, id)
var i GetUnlinkedBookWithOwnerRow
err := row.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.UserID,
&i.DeviceName,
&i.DeviceType,
)
return i, err
}
const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.device_id = $1 AND ub.resolved = false
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC
`
@@ -9887,11 +9944,12 @@ type GetUnlinkedBooksByDeviceRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Get unlinked books for a device
// Get unlinked books for a device (ignored rows stay hidden)
func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) {
rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID)
if err != nil {
@@ -9915,6 +9973,7 @@ func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
@@ -10985,6 +11044,17 @@ func (q *Queries) HasRecentConflictResolution(ctx context.Context, arg HasRecent
return exists, err
}
const IgnoreUnlinkedBook = `-- name: IgnoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1
`
// Soft-dismiss one row (ownership is checked by the caller against the
// device's user_id before calling this).
func (q *Queries) IgnoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, IgnoreUnlinkedBook, id)
return err
}
const IncrementSyncQueueAttempts = `-- name: IncrementSyncQueueAttempts :one
UPDATE sync_queue
SET
@@ -11071,7 +11141,7 @@ SET
resolved_at = NOW(),
resolution_method = 'manual_link'
WHERE id = $1
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type LinkUnlinkedBookParams struct {
@@ -11098,6 +11168,7 @@ func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookPara
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
@@ -11680,6 +11751,72 @@ func (q *Queries) ListHiddenMediaItems(ctx context.Context) ([]ListHiddenMediaIt
return items, nil
}
const ListIgnoredUnlinkedBooksForUser = `-- name: ListIgnoredUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
ORDER BY ub.ignored_at DESC
`
type ListIgnoredUnlinkedBooksForUserRow struct {
ID pgtype.UUID `db:"id" json:"id"`
DeviceID pgtype.UUID `db:"device_id" json:"device_id"`
ContentID string `db:"content_id" json:"content_id"`
FilePath pgtype.Text `db:"file_path" json:"file_path"`
Title pgtype.Text `db:"title" json:"title"`
Author pgtype.Text `db:"author" json:"author"`
ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"`
Resolved pgtype.Bool `db:"resolved" json:"resolved"`
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"`
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Page list, ignored bucket: rows the user dismissed, kept for the
// collapsible "Ignored" section so a misclick is recoverable.
func (q *Queries) ListIgnoredUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListIgnoredUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListIgnoredUnlinkedBooksForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIgnoredUnlinkedBooksForUserRow{}
for rows.Next() {
var i ListIgnoredUnlinkedBooksForUserRow
if err := rows.Scan(
&i.ID,
&i.DeviceID,
&i.ContentID,
&i.FilePath,
&i.Title,
&i.Author,
&i.ConfidenceScore,
&i.Resolved,
&i.MediaItemID,
&i.ResolvedAt,
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const ListLibraries = `-- name: ListLibraries :many
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description
FROM libraries l
@@ -13040,7 +13177,7 @@ func (q *Queries) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUI
}
const ListUnresolvedUnlinkedBooks = `-- name: ListUnresolvedUnlinkedBooks :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.resolved = false
@@ -13067,6 +13204,7 @@ type ListUnresolvedUnlinkedBooksRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
@@ -13095,6 +13233,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
@@ -13109,10 +13248,10 @@ func (q *Queries) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnres
}
const ListUnresolvedUnlinkedBooksForUser = `-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, d.device_name, d.device_type
SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, ub.ignored_at, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC
`
@@ -13130,11 +13269,13 @@ type ListUnresolvedUnlinkedBooksForUserRow struct {
ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"`
LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
IgnoredAt pgtype.Timestamptz `db:"ignored_at" json:"ignored_at"`
DeviceName string `db:"device_name" json:"device_name"`
DeviceType string `db:"device_type" json:"device_type"`
}
// Page list: unresolved queue rows for the caller's own devices.
// Page list: unresolved queue rows for the caller's own devices (ignored
// rows are listed by ListIgnoredUnlinkedBooksForUser instead).
func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID pgtype.UUID) ([]ListUnresolvedUnlinkedBooksForUserRow, error) {
rows, err := q.db.Query(ctx, ListUnresolvedUnlinkedBooksForUser, userID)
if err != nil {
@@ -13158,6 +13299,7 @@ func (q *Queries) ListUnresolvedUnlinkedBooksForUser(ctx context.Context, userID
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
&i.DeviceName,
&i.DeviceType,
); err != nil {
@@ -13814,7 +13956,7 @@ SET
resolved_at = NOW(),
resolution_method = $3
WHERE id = $1
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type ResolveUnlinkedBookParams struct {
@@ -13841,6 +13983,7 @@ func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBo
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
@@ -15246,6 +15389,16 @@ func (q *Queries) TombstoneSystemCollection(ctx context.Context, id pgtype.UUID)
return err
}
const UnignoreUnlinkedBook = `-- name: UnignoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1
`
// Undo a soft-dismiss.
func (q *Queries) UnignoreUnlinkedBook(ctx context.Context, id pgtype.UUID) error {
_, err := q.db.Exec(ctx, UnignoreUnlinkedBook, id)
return err
}
const UpdateCollection = `-- name: UpdateCollection :one
UPDATE collections
SET
@@ -17649,7 +17802,7 @@ SET file_path = EXCLUDED.file_path,
author = EXCLUDED.author,
confidence_score = EXCLUDED.confidence_score,
last_seen_at = NOW()
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at
RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at, ignored_at
`
type UpsertUnlinkedBookParams struct {
@@ -17687,6 +17840,7 @@ func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBook
&i.ResolutionMethod,
&i.LastSeenAt,
&i.CreatedAt,
&i.IgnoredAt,
)
return i, err
}
+35 -8
View File
@@ -3701,12 +3701,12 @@ INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, con
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- Get unlinked books for a device
-- Get unlinked books for a device (ignored rows stay hidden)
-- name: GetUnlinkedBooksByDevice :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.device_id = $1 AND ub.resolved = false
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC;
-- Get unlinked book by ContentId
@@ -3724,11 +3724,11 @@ SET
WHERE id = $1
RETURNING *;
-- Count unlinked books for a device
-- Count unlinked books for a device (ignored rows stay hidden)
-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count
FROM unlinked_books
WHERE device_id = $1 AND resolved = false;
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL;
-- Link unlinked book to media item
-- name: LinkUnlinkedBook :one
@@ -3772,21 +3772,48 @@ SET file_path = EXCLUDED.file_path,
last_seen_at = NOW()
RETURNING *;
-- Banner count: unresolved queue rows across the caller's own devices.
-- Banner count: unresolved, non-ignored queue rows across the caller's own
-- devices. Ignored rows must not nag from the dashboard badge.
-- name: CountUnresolvedUnlinkedBooksForUser :one
SELECT COUNT(*) as count
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false;
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL;
-- Page list: unresolved queue rows for the caller's own devices.
-- Page list: unresolved queue rows for the caller's own devices (ignored
-- rows are listed by ListIgnoredUnlinkedBooksForUser instead).
-- name: ListUnresolvedUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
ORDER BY ub.last_seen_at DESC;
-- Page list, ignored bucket: rows the user dismissed, kept for the
-- collapsible "Ignored" section so a misclick is recoverable.
-- name: ListIgnoredUnlinkedBooksForUser :many
SELECT ub.*, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
ORDER BY ub.ignored_at DESC;
-- Soft-dismiss one row (ownership is checked by the caller against the
-- device's user_id before calling this).
-- name: IgnoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1;
-- Undo a soft-dismiss.
-- name: UnignoreUnlinkedBook :exec
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1;
-- Fetch one row together with its device's owner for authorization.
-- name: GetUnlinkedBookWithOwner :one
SELECT ub.*, d.user_id, d.device_name, d.device_type
FROM unlinked_books ub
JOIN devices d ON ub.device_id = d.id
WHERE ub.id = $1;
-- ============================================
-- CAROUSEL-STYLE DASHBOARD
-- ============================================
+127 -1
View File
@@ -767,6 +767,8 @@ func registerFrontendRoutes(cfg *Config) {
// resolution rules as the dashboard page (family-lens safe).
// Unlinked-books page: the identification queue for device files the
// sync could not match (personal data — the caller's own devices only).
// Unlinked-book queue page: unresolved rows plus the ignored bucket
// (soft-dismissed rows, kept for the collapsible recovery section).
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
@@ -781,6 +783,11 @@ func registerFrontendRoutes(cfg *Config) {
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
}
ignoredRows, ignoredErr := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if ignoredErr != nil {
log.Printf("ListIgnoredUnlinkedBooksForUser failed: %v", ignoredErr)
ignoredRows = []database.ListIgnoredUnlinkedBooksForUserRow{}
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
@@ -796,14 +803,133 @@ func registerFrontendRoutes(cfg *Config) {
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
for _, r := range ignoredRows {
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, user.Timezone),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
if renderErr := templates.UnlinkedBooks(user, books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// Unlinked-book dismiss/delete actions (htmx: the buttons post/delete
// and swap in the re-rendered section). Authorization is row → device
// → requesting user (admin may act on anyone's rows); a non-htmx hit
// just bounces back to the page. These are page-scoped SSR routes, so
// they are intentionally NOT under /api and not in the OpenAPI docs.
unlinkedSectionHTML := func(c *echo.Context, dbUser database.Users) error {
userUUID, err := uuid.Parse(dbUser.ID.String())
if err != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
rows, err := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
ignoredRows, err := cfg.Queries.ListIgnoredUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if err != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to load unlinked books</div>")
}
books := make([]templates.UnlinkedBookData, 0, len(rows))
for _, r := range rows {
books = append(books, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
ignoredBooks := make([]templates.UnlinkedBookData, 0, len(ignoredRows))
for _, r := range ignoredRows {
ignoredBooks = append(ignoredBooks, templates.UnlinkedBookData{
ProgressID: uuid.UUID(r.ID.Bytes).String(),
DeviceID: uuid.UUID(r.DeviceID.Bytes).String(),
DeviceType: r.DeviceType,
DeviceName: r.DeviceName,
FilePath: utils.PgTextString(r.FilePath),
SHA256: r.ContentID,
TitleFromDevice: utils.PgTextString(r.Title),
LastSyncTime: utils.FormatTimeInTimezone(r.LastSeenAt.Time, utils.PgTextString(dbUser.Timezone)),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooksSection(books, ignoredBooks).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
}
// authorizeUnlinkedAction resolves the :id row with its device owner
// and gates the action: nil row + status/message on failure.
authorizeUnlinkedAction := func(c *echo.Context, dbUser database.Users) (*database.GetUnlinkedBookWithOwnerRow, int, string) {
rowID, err := uuid.Parse(c.Param("id"))
if err != nil {
return nil, http.StatusBadRequest, "<div>Invalid entry id</div>"
}
row, err := cfg.Queries.GetUnlinkedBookWithOwner(c.Request().Context(), pgtype.UUID{Bytes: rowID, Valid: true})
if err != nil {
return nil, http.StatusNotFound, "<div>Entry not found</div>"
}
ownerID := uuid.UUID(row.UserID.Bytes).String()
if ownerID != dbUser.ID.String() && dbUser.Role != "admin" {
return nil, http.StatusForbidden, "<div>Not allowed</div>"
}
return &row, 0, ""
}
for _, action := range []struct {
method string
path string
run func(ctx context.Context, rowID pgtype.UUID) error
}{
{"POST", "/unlinked-books/:id/ignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.IgnoreUnlinkedBook(ctx, rowID)
}},
{"POST", "/unlinked-books/:id/unignore", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.UnignoreUnlinkedBook(ctx, rowID)
}},
{"DELETE", "/unlinked-books/:id", func(ctx context.Context, rowID pgtype.UUID) error {
return cfg.Queries.DeleteUnlinkedBook(ctx, rowID)
}},
} {
method, path, run := action.method, action.path, action.run
frontendProtected.Add(method, path, func(c *echo.Context) error {
dbUser, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
row, status, msg := authorizeUnlinkedAction(c, dbUser)
if row == nil {
return c.HTML(status, msg)
}
if err := run(c.Request().Context(), row.ID); err != nil {
log.Printf("unlinked-book action %s %s failed: %v", method, path, err)
return c.HTML(http.StatusInternalServerError, "<div>Action failed</div>")
}
if c.Request().Header.Get("HX-Request") != "true" {
return c.Redirect(http.StatusSeeOther, "/unlinked-books")
}
return unlinkedSectionHTML(c, dbUser)
})
}
// Unlinked-book match results partial: the match cascade for one device
// file, rendered server-side and scoped to the requester's visible
// libraries (absent-from-results) — rendering-ownership: templ renders,
+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) {
var b strings.Builder
user := User{ID: "u1", Timezone: "UTC"}
@@ -81,11 +81,26 @@ func TestUnlinkedBooksPageRender(t *testing.T) {
TitleFromDevice: "Some Sideloaded Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books).Render(context.Background(), &b); err != nil {
ignored := []UnlinkedBookData{{
ProgressID: "ub2",
DeviceName: "KOReader Phone",
DeviceType: "koreader",
FilePath: "/sdcard/ignored.epub",
SHA256: "def456",
TitleFromDevice: "Some Dismissed Book",
LastSyncTime: "2026-10-18 12:00",
}}
if err := UnlinkedBooks(user, books, ignored).Render(context.Background(), &b); err != nil {
t.Fatalf("render: %v", err)
}
out := b.String()
for _, want := range []string{"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches"} {
for _, want := range []string{
"Some Sideloaded Book", "abc123", `data-progress-id="ub1"`, "searchMatches",
// Dismiss/delete actions and the ignored recovery bucket.
"Some Dismissed Book", `hx-post="/unlinked-books/ub1/ignore"`,
`hx-delete="/unlinked-books/ub1"`, `hx-post="/unlinked-books/ub2/unignore"`,
"Ignored (1)",
} {
if !strings.Contains(out, want) {
t.Errorf("unlinked page missing %q", want)
}
+89 -8
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>
<html lang="en">
<head>
@@ -62,6 +62,20 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
<body class={ "theme-" + user.Theme } x-data="unlinkedBooks" x-init="setupEventDelegation()">
@Header(user, "/unlinked")
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
@UnlinkedBooksSection(unresolved, ignored)
</main>
@unlinkedManualLinkModal()
</body>
</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);">
@@ -71,7 +85,7 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
</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 {
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"/>
@@ -95,7 +109,7 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
</div>
}
<div id="unlinked-container" class="space-y-6">
if len(unlinkedBooks) == 0 {
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")
@@ -104,12 +118,36 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
<p style="color: var(--text-secondary)">There are no unlinked books that need manual resolution.</p>
</div>
}
for _, book := range unlinkedBooks {
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);">
@@ -155,6 +193,29 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
</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) + ")" }
@@ -170,6 +231,25 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
@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);">
@@ -179,12 +259,15 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
</div>
</div>
</div>
}
</div>
</div>
</div>
}
</div>
</main>
// 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">
@@ -249,6 +332,4 @@ templ UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) {
</div>
</div>
</div>
</body>
</html>
}
+404 -149
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) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
@@ -221,7 +221,57 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<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 {
return templ_7745c5c3_Err
}
@@ -229,12 +279,12 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</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 {
return templ_7745c5c3_Err
}
if len(unlinkedBooks) > 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\">")
if len(unresolved) > 0 {
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 {
return templ_7745c5c3_Err
}
@@ -242,7 +292,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
return templ_7745c5c3_Err
}
@@ -250,7 +300,7 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
return templ_7745c5c3_Err
}
@@ -258,17 +308,17 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
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 {
return templ_7745c5c3_Err
}
if len(unlinkedBooks) == 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);\">")
if len(unresolved) == 0 {
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 {
return templ_7745c5c3_Err
}
@@ -276,39 +326,119 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</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 {
return templ_7745c5c3_Err
}
}
for _, book := range unlinkedBooks {
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=\"")
for _, book := range unresolved {
templ_7745c5c3_Err = unlinkedBookCard(book, false).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var13 string
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 111, Col: 100}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" data-title=\"")
if len(ignored) > 0 {
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 (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var14 string
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", len(ignored)))
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 111, Col: 136}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 128, Col: 47}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\"></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 {
return templ_7745c5c3_Err
}
for _, book := range ignored {
templ_7745c5c3_Err = unlinkedBookCard(book, true).Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></details>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
return nil
})
}
// unlinkedBookCard renders one queue row. Ignored rows drop the checkbox
// and the match/link actions (they are dismissed) and offer Restore +
// Delete instead; all action buttons re-render the section server-side.
func unlinkedBookCard(book UnlinkedBookData, ignored bool) templ.Component {
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
return templ_7745c5c3_CtxErr
}
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
if !templ_7745c5c3_IsBuffer {
defer func() {
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
if templ_7745c5c3_Err == nil {
templ_7745c5c3_Err = templ_7745c5c3_BufErr
}
}()
}
ctx = templ.InitializeContext(ctx)
templ_7745c5c3_Var15 := templ.GetChildren(ctx)
if templ_7745c5c3_Var15 == nil {
templ_7745c5c3_Var15 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.ProgressID)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 148, Col: 96}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" data-title=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.TitleFromDevice)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 148, Col: 132}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\"></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
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
}
@@ -328,46 +458,46 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
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, 38, "</span><div class=\"min-w-0\"><h3 class=\"font-semibold text-lg truncate\" style=\"color: var(--text-primary)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var15 string
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.TitleFromDevice)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 125, Col: 111}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 163, Col: 106}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</h3><p class=\"text-sm\" style=\"color: var(--text-secondary)\">Device: ")
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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var16 string
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceName)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 127, Col: 37}
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_Var16))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, " (")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, " (")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var17 string
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.DeviceType)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 127, Col: 58}
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_Var17))
_, 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, 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, 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
}
@@ -375,177 +505,106 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var18 string
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(book.FilePath)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 136, Col: 27}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</code></div>")
if templ_7745c5c3_Err != nil {
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=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var19 string
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 142, Col: 68}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var20 string
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(book.SHA256)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 142, Col: 84}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</code></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
}
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)\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var21 string
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(book.LastSyncTime)
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: 147, Col: 71}
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, 37, "</span></div>")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</code></div>")
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 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 {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var22 string
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(book.ConfidenceScore)
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(book.SHA256)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 153, Col: 35}
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_Var22))
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(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=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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 {
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 {
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 = Icon("search", "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, 42, "Find Potential Matches</button> <button @click=\"")
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.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
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: 167, Col: 114}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 185, Col: 66}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
_, 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, 43, "\" class=\"btn btn-primary text-sm\">")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("edit", "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, 44, "Manually Link</button></div><div id=\"")
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.ResolveAttributeValue("matches-" + book.ProgressID)
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: 174, Col: 47}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/unlinked_books.templ`, Line: 191, Col: 30}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
_, 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, 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, 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("matches-list-" + book.ProgressID)
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: 177, Col: 54}
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, 46, "\" class=\"space-y-3\"><p style=\"color: var(--text-secondary)\">Click \"Find Potential Matches\" to search</p></div></div></div></div></div></div>")
if templ_7745c5c3_Err != nil {
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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "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, 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, 52, "\" hx-target=\"#unlinked-section\" hx-swap=\"outerHTML\" class=\"btn btn-secondary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
@@ -553,7 +612,203 @@ func UnlinkedBooks(user User, unlinkedBooks []UnlinkedBookData) templ.Component
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "Link Book</button></div></div></div></body></html>")
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 {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "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, 58, "Find Potential Matches</button> <button @click=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var29 string
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue("showManualLinkModal(" + JSONArg(book.ProgressID) + ", " + JSONArg(book.TitleFromDevice) + ")")
if templ_7745c5c3_Err != nil {
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_Var29)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\" class=\"btn btn-primary text-sm\">")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("edit", "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, 60, "Manually Link</button> <button hx-post=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var30 string
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue("/unlinked-books/" + book.ProgressID + "/ignore")
if templ_7745c5c3_Err != nil {
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_Var30)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
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 {
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, 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 {
return templ_7745c5c3_Err
}
}
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 {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("close", "h-5 w-5").Render(ctx, templ_7745c5c3_Buffer)
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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 {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = Icon("search", "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, 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 {
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, 71, "Link Book</button></div></div></div>")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}