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

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

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

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

No files matched your search

+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,