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:
1 parent
a31da89df5
commit
327fc2c0f9
5 files changed
+383
-160
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user