feat(unlinked-books): dismiss (ignore) or delete queue entries
Release / build-and-push (push) Successful in 3m15s
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:
1 parent
afbf1029a9
commit
f6a73ab5aa
10 files changed
+1241
-375
No files matched your search
+127
-1
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user