feat(sync): unlinked-books queue — ingest, page route, dashboard banner
Release / build-and-push (push) Successful in 3m7s

The identification queue for device files the sync cannot match is now
live end-to-end. Previously the whole feature was dark: unmatched device
files were silently dropped (losing their reading progress), the queue
table sat empty by construction, and the page template had no route.

- Ingest: the KOReader sync loop, on an unresolvable file, upserts
  unlinked_books (one row per device + content id; re-syncs bump
  last_seen_at and refresh device-reported metadata) instead of
  dropping the file. Schema gains the (device_id, content_id) unique
  index the upsert needs.
- Page: GET /unlinked-books routes the existing page template, listing
  the caller's own unresolved rows (ListUnresolvedUnlinkedBooksForUser
  — devices.user_id scoping; no cross-user leakage).
- Dashboard banner: a warning card above the shelves, rendered only
  when the caller's queue is non-empty (hidden-unless-needed), linking
  to the page.
- Resolve flow unchanged and already gated: MatchingHandler.LinkBook
  verifies device ownership and target-item visibility (403s) and
  creates the device file alias, so the next sync matches by path.
  SyncHandler.LinkBook and Handler.LinkBook are unrouted dead handlers
  — triage candidates, not part of the live flow.
This commit is contained in:
John O'Keefe committed 2026-10-18 16:45:48 -04:00
1 parent 9e831085ef
commit d74db03cf3
10 files changed
+823 -454

No files matched your search

+50 -1
View File
@@ -475,8 +475,18 @@ func registerFrontendRoutes(cfg *Config) {
}
}
userUUID, userUUIDErr := uuid.Parse(user.ID)
if userUUIDErr != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
unlinkedCount, countErr := cfg.Queries.CountUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if countErr != nil {
log.Printf("CountUnresolvedUnlinkedBooksForUser failed: %v", countErr)
unlinkedCount = 0
}
var buf bytes.Buffer
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg).Render(c.Request().Context(), &buf)
err = templates.Dashboard(user, sectionData, allSectionsData, libRes.Libraries, libraryID, prefs.HiddenCollections, limit, week, activeLibType, errorMsg, int(unlinkedCount)).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
@@ -742,6 +752,45 @@ func registerFrontendRoutes(cfg *Config) {
// itself here (htmx) whenever the library switcher dispatches
// week-refresh, carrying the current #library-select value. Same
// 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).
frontendProtected.GET("/unlinked-books", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
userUUID, userUUIDErr := uuid.Parse(user.ID)
if userUUIDErr != nil {
return renderErrorPage(c, "Error loading user", "user_load_error")
}
rows, listErr := cfg.Queries.ListUnresolvedUnlinkedBooksForUser(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if listErr != nil {
log.Printf("ListUnresolvedUnlinkedBooksForUser failed: %v", listErr)
rows = []database.ListUnresolvedUnlinkedBooksForUserRow{}
}
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, user.Timezone),
ConfidenceScore: r.ConfidenceScore.Float64,
})
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedBooks(user, books).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
// 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,