feat(matching): match results as a scoped templ partial + enriched matches

- BookMatch now carries title/author/cover (the fields the old client
  renderer always assumed but the API never sent — cards would have
  shown 'undefined'); filled during the family-lens pass in QueryBooks
- new partial route GET /unlinked-books/matches renders the scoped
  match cascade server-side; searchMatches swaps it via htmx.ajax
  instead of rebuilding cards from JSON
- NOTE: the unlinked-books surface is currently dark end-to-end — the
  templ page has no route, the live linking.ts renderer targets a
  container no template has, and its Link button calls a
  window.showMatchModal that does not exist. This commit ships the
  scoped backend + partial; wiring the UI is a product decision.
This commit is contained in:
John O'Keefe committed 2026-10-18 16:00:04 -04:00
1 parent a31da89df5
commit 327fc2c0f9
5 files changed
+383 -160

No files matched your search

+39
View File
@@ -742,6 +742,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-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,
// JS only swaps. NOTE: nothing routes to the unlinked-books page yet;
// this endpoint is ready for whichever UI wires it up.
frontendProtected.GET("/unlinked-books/matches", func(c *echo.Context) error {
user, ok := c.Get("user").(database.Users)
if !ok {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
userUUID, uuidErr := uuid.Parse(user.ID.String())
if uuidErr != nil {
return c.HTML(http.StatusUnauthorized, "<div>Unauthorized</div>")
}
set, _, setErr := cfg.LibraryService.VisibleLibrarySet(c.Request().Context(), pgtype.UUID{Bytes: userUUID, Valid: true})
if setErr != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to resolve visible libraries</div>")
}
visibleIDs := make([]string, 0, len(set))
for id := range set {
visibleIDs = append(visibleIDs, id)
}
req := &services.BookQueryRequest{
SHA256: c.QueryParam("sha256"),
Title: c.QueryParam("title"),
VisibleLibraryIDs: visibleIDs,
}
resp, matchErr := services.NewBookMatchingService(cfg.Queries).QueryBooks(c.Request().Context(), req)
if matchErr != nil {
return c.HTML(http.StatusInternalServerError, "<div>Failed to search</div>")
}
var buf bytes.Buffer
if renderErr := templates.UnlinkedMatchCards(c.QueryParam("progress_id"), resp.Matches).Render(c.Request().Context(), &buf); renderErr != nil {
return renderErr
}
return c.HTML(http.StatusOK, buf.String())
})
frontendProtected.GET("/dashboard/week-widget", func(c *echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
+29 -16
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"math"
"bookhoard/internal/utils"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
@@ -16,6 +18,10 @@ type BookMatch struct {
BookhoardUUID uuid.UUID `json:"bookhoard_uuid"`
Confidence float64 `json:"confidence"`
MatchMethod string `json:"match_method"`
// Display fields, filled during the family-lens pass in QueryBooks.
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
// BookQueryRequest represents a book query from a device
@@ -115,23 +121,30 @@ func (s *BookMatchingService) QueryBooks(ctx context.Context, req *BookQueryRequ
// matches (absent-from-results). Runs before determineAction so the
// suggested action reflects the filtered set. nil (unset) keeps
// legacy callers unfiltered.
if req.VisibleLibraryIDs != nil {
allowed := make(map[string]bool, len(req.VisibleLibraryIDs))
for _, id := range req.VisibleLibraryIDs {
allowed[id] = true
}
filtered := matches[:0]
for _, m := range matches {
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: m.MediaItemID, Valid: true})
if err != nil || !item.LibraryID.Valid {
continue // vanished between cascade and filter, or unscoped — drop
}
if allowed[uuid.UUID(item.LibraryID.Bytes).String()] {
filtered = append(filtered, m)
}
}
matches = filtered
// Enrich + scope in one pass over the cascade hits: display fields come
// from the resolved item, and when scoped the family-lens filter drops
// matches from libraries the requester cannot see
// (absent-from-results).
scoped := req.VisibleLibraryIDs != nil
allowed := make(map[string]bool, len(req.VisibleLibraryIDs))
for _, id := range req.VisibleLibraryIDs {
allowed[id] = true
}
filtered := matches[:0]
for _, m := range matches {
item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: m.MediaItemID, Valid: true})
if err != nil || !item.LibraryID.Valid {
continue // vanished between cascade and filter — drop
}
m.Title = item.Title
m.Author = utils.PgTextString(item.Author)
m.CoverImagePath = utils.PgTextString(item.CoverImagePath)
if scoped && !allowed[uuid.UUID(item.LibraryID.Bytes).String()] {
continue
}
filtered = append(filtered, m)
}
matches = filtered
// Determine action
action := s.determineAction(matches)