collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it).
1518 lines
51 KiB
Go
1518 lines
51 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
wsync "bookhoard/internal/sync"
|
|
"bookhoard/internal/utils"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
type CollectionHandler struct {
|
|
db *database.Queries
|
|
collectionService *services.CollectionService
|
|
libraryService *services.LibraryService
|
|
connManager *wsync.ConnectionManager
|
|
}
|
|
|
|
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager, search ...*services.SearchService) *CollectionHandler {
|
|
return &CollectionHandler{
|
|
db: db,
|
|
collectionService: services.NewCollectionService(db, search...),
|
|
libraryService: libraryService,
|
|
connManager: connManager,
|
|
}
|
|
}
|
|
|
|
type CreateCollectionRequest struct {
|
|
Name string `form:"name" json:"name" validate:"required"`
|
|
Description string `form:"description" json:"description"`
|
|
Color string `form:"color" json:"color"`
|
|
Icon string `form:"icon" json:"icon"`
|
|
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
|
|
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
|
// The unified filter tree (item 3.2): when present the collection's
|
|
// smart membership runs through the qf engine. INVALID trees are a
|
|
// 400 (ParseFilterTree at the handler); a tree supersedes
|
|
// auto_assign_rules. Persisted as-is (any). RawMessage for
|
|
// round-trip safety.
|
|
FilterTree any `form:"filter_tree" json:"filter_tree"`
|
|
}
|
|
|
|
// treeFilterJSON returns the request's filter tree as JSON storage
|
|
// bytes (nil when none): object payloads marshal; strings (a legacy
|
|
// double-encoded client, the custom-section builder's known bug class)
|
|
// are written verbatim after object-attempt.
|
|
func treeFilterJSON(payload any) ([]byte, error) {
|
|
switch v := payload.(type) {
|
|
case nil:
|
|
return nil, nil
|
|
case map[string]interface{}:
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return b, nil
|
|
case string:
|
|
return []byte(v), nil
|
|
default:
|
|
return nil, fmt.Errorf("filter_tree must be an object")
|
|
}
|
|
}
|
|
|
|
// parseRequestFilterTree validates the request's tree against the
|
|
// engine grammar (ParseFilterTree) so an invalid tree is a 400, never
|
|
// a stored 500 at render time.
|
|
func parseRequestFilterTree(payload any) ([]byte, error) {
|
|
b, err := treeFilterJSON(payload)
|
|
if err != nil || b == nil {
|
|
return b, err
|
|
}
|
|
if _, err := services.ParseFilterTree(string(b)); err != nil {
|
|
return nil, fmt.Errorf("invalid filter_tree: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
type UpdateCollectionRequest struct {
|
|
Name string `form:"name" json:"name" validate:"required"`
|
|
// Smart-collection match semantics (item 2.4): "any" (OR) | "all"
|
|
// (AND). Empty/invalid keeps the stored value.
|
|
RuleMatchMode string `form:"rule_match_mode" json:"rule_match_mode"`
|
|
// The unified filter tree (item 3.2): when present the collection's
|
|
// smart membership runs through the qf engine. INVALID trees are a
|
|
// 400 (ParseFilterTree at the handler); a tree supersedes
|
|
// auto_assign_rules.
|
|
FilterTree any `form:"filter_tree" json:"filter_tree"`
|
|
Description string `form:"description" json:"description"`
|
|
Color string `form:"color" json:"color"`
|
|
Icon string `form:"icon" json:"icon"`
|
|
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
|
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
|
// (nil = untouched). []string because the form posts a hidden "false"
|
|
// sibling next to the checkbox; the LAST value wins. JSON clients may
|
|
// send a single-element array (or omit — untouched).
|
|
HideFinished []string `form:"hide_finished" json:"hide_finished"`
|
|
ShowUpNext []string `form:"show_up_next" json:"show_up_next"`
|
|
}
|
|
|
|
type AddBooksRequest struct {
|
|
BookIDs []string `json:"book_ids" validate:"required"`
|
|
}
|
|
|
|
type CreateDeviceMappingRequest struct {
|
|
CollectionID string `json:"collection_id" validate:"required"`
|
|
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
|
|
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
|
|
}
|
|
|
|
type UpdateDeviceMappingRequest struct {
|
|
DeviceShelfName string `json:"device_shelf_name" validate:"required"`
|
|
SyncDirection string `json:"sync_direction" validate:"required,oneof=bidirectional book_to_device device_to_book none"`
|
|
}
|
|
|
|
// DeviceMappingSummary is one entry in GET /api/devices/:id/collections. Hoisted
|
|
// to package scope so the docs schema registry can pin the response shape.
|
|
type DeviceMappingSummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CollectionID uuid.UUID `json:"collection_id"`
|
|
CollectionName string `json:"collection_name"`
|
|
DeviceShelfName string `json:"device_shelf_name"`
|
|
SyncDirection string `json:"sync_direction"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// CollectionSummary is one entry in GET /api/collections. Package-level so
|
|
// the docs schema registry (internal/docs) can pin the response shape.
|
|
type CollectionSummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Color string `json:"color"`
|
|
Icon string `json:"icon"`
|
|
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
|
CreatedAt string `json:"created_at"`
|
|
BookCount int `json:"book_count"`
|
|
IsSmart bool `json:"is_smart"`
|
|
}
|
|
|
|
// BookCollectionSummary is one entry in GET /api/collections/:id/books —
|
|
// the collections a book belongs to (list view: no rules/count fields).
|
|
type BookCollectionSummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Color string `json:"color"`
|
|
Icon string `json:"icon"`
|
|
}
|
|
|
|
type SectionData struct {
|
|
ID string `json:"id"`
|
|
CollectionID string `json:"collection_id"`
|
|
IsSystem bool `json:"is_system"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Icon string `json:"icon"`
|
|
Items []BookInfo `json:"items"`
|
|
ViewAllURL string `json:"view_all_url"`
|
|
Priority int `json:"priority"`
|
|
// A2c: curated playlist marker (imported reading-order collection) and
|
|
// the hide_finished completion state — dashboard.ts renders both.
|
|
IsPlaylist bool `json:"is_playlist"`
|
|
Complete bool `json:"complete"`
|
|
}
|
|
|
|
func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req CreateCollectionRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
treeJSON, treeErr := parseRequestFilterTree(req.FilterTree)
|
|
if treeErr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
|
}
|
|
collection, err := h.collectionService.CreateCollection(
|
|
c.Request().Context(),
|
|
userUUID,
|
|
req.Name,
|
|
req.Description,
|
|
req.Color,
|
|
req.Icon,
|
|
req.AutoAssignRules,
|
|
req.ViewSettings,
|
|
treeJSON,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrCollectionNameTaken) {
|
|
return c.JSON(http.StatusConflict, map[string]string{"error": "a collection with this name already exists"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
bookCount := int32(0)
|
|
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
|
|
response := map[string]interface{}{
|
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
|
"name": collection.Name,
|
|
"description": utils.PgTextString(collection.Description),
|
|
"color": utils.PgTextString(collection.Color),
|
|
"icon": utils.PgTextString(collection.Icon),
|
|
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
|
"auto_assign_rules": collection.AutoAssignRules,
|
|
"view_settings": collection.ViewSettings,
|
|
"book_count": bookCount,
|
|
"created_at": collection.CreatedAt.Time.String(),
|
|
}
|
|
|
|
// Add redirect header for HTMX requests
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
// A smart collection starts with an empty ruleset; the builder is
|
|
// where it becomes real. Static collections head back to the grid.
|
|
if isSmart {
|
|
c.Response().Header().Set("HX-Redirect", "/collections/"+uuid.UUID(collection.ID.Bytes).String()+"/rules")
|
|
} else {
|
|
c.Response().Header().Set("HX-Redirect", "/collections")
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, response)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|
includeAuto := c.QueryParam("include_auto") == "true"
|
|
sortBy := c.QueryParam("sort_by")
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
collections, err := h.GetCollectionsData(c)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
response := make([]CollectionSummary, 0, len(collections))
|
|
for _, col := range collections {
|
|
isSmart := services.IsCollectionSmart(col)
|
|
if !includeAuto && isSmart {
|
|
// Legacy default for API clients (the Android app pre-dates
|
|
// smart collections): rule-driven collections stay opt-in so
|
|
// their membership could never be stale for old readers.
|
|
continue
|
|
}
|
|
|
|
bookCount := 0
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
if libParsed, libErr := uuid.Parse(libraryID); libErr == nil {
|
|
libUUID = pgtype.UUID{Bytes: libParsed, Valid: true}
|
|
}
|
|
}
|
|
if isSmart {
|
|
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
|
if visErr == nil {
|
|
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), col, services.SmartEvalOptions{
|
|
LibraryID: libUUID,
|
|
VisibleIDs: visibleIDs,
|
|
MatchMode: col.RuleMatchMode,
|
|
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
|
})
|
|
if evalErr == nil {
|
|
bookCount = len(matches)
|
|
}
|
|
}
|
|
} else {
|
|
count, countErr := h.db.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{
|
|
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
|
LibraryID: libUUID,
|
|
})
|
|
if countErr == nil {
|
|
bookCount = int(count)
|
|
}
|
|
}
|
|
|
|
response = append(response, CollectionSummary{
|
|
ID: col.ID.Bytes,
|
|
Name: col.Name,
|
|
Description: utils.PgTextString(col.Description),
|
|
Color: utils.PgTextString(col.Color),
|
|
Icon: utils.PgTextString(col.Icon),
|
|
AutoAssignRules: col.AutoAssignRules,
|
|
CreatedAt: col.CreatedAt.Time.String(),
|
|
BookCount: bookCount,
|
|
IsSmart: isSmart,
|
|
})
|
|
}
|
|
|
|
if sortBy == "name" {
|
|
for i := 0; i < len(response); i++ {
|
|
for j := i + 1; j < len(response); j++ {
|
|
if response[i].Name > response[j].Name {
|
|
response[i], response[j] = response[j], response[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"collections": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
collection, err := h.GetCollectionData(c, collectionID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
|
}
|
|
|
|
libraryID := c.QueryParam("library_id")
|
|
var bookList []BookInfo
|
|
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
parsed, parseErr := uuid.Parse(libraryID)
|
|
if parseErr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
|
|
isSmart := services.IsCollectionSmart(collection)
|
|
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
|
|
if isSmart {
|
|
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
matches, evalErr := h.collectionService.EvaluateSmartMembership(c.Request().Context(), collection, services.SmartEvalOptions{
|
|
LibraryID: libUUID,
|
|
VisibleIDs: visibleIDs,
|
|
MatchMode: collection.RuleMatchMode,
|
|
ViewerUserID: MustGetAuthenticatedUser(c).ID,
|
|
})
|
|
if evalErr != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
|
|
}
|
|
bookList = BookCardsFromMediaItems(matches, libTypes)
|
|
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
dashboardSvc := services.NewDashboardService(h.db)
|
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
|
if secErr != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
|
|
}
|
|
for _, section := range sections {
|
|
if section.CollectionID == collectionID {
|
|
bookList = BookCardsFromMediaItems(section.Items, libTypes)
|
|
break
|
|
}
|
|
}
|
|
} else if libUUID.Valid {
|
|
// A2c §3.3: the detail page honors the same per-collection
|
|
// hide_finished as the dashboard section, against the viewer's
|
|
// progress. The viewer_percentage rides along for card graying.
|
|
hideFinished := false
|
|
if len(collection.ViewSettings) > 0 {
|
|
var vs map[string]interface{}
|
|
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
|
if b, ok := vs["hide_finished"].(bool); ok {
|
|
hideFinished = b
|
|
}
|
|
}
|
|
}
|
|
var viewerID pgtype.UUID
|
|
if u, ok := c.Get("user").(database.Users); ok {
|
|
viewerID = u.ID
|
|
}
|
|
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
|
|
database.GetCollectionItemsForDashboardParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
LibraryID: libUUID,
|
|
ViewerUserID: viewerID,
|
|
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: hideFinished},
|
|
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
|
})
|
|
if collErr != nil {
|
|
bookList = BookCardsFromCollectionDashRows(nil, libTypes)
|
|
} else {
|
|
// The query filters excluded rows in SQL; cards build straight
|
|
// from the embedded media rows.
|
|
bookList = BookCardsFromCollectionDashRows(collItems, libTypes)
|
|
}
|
|
} else {
|
|
books, booksErr := h.GetCollectionBooksData(c, collectionID)
|
|
if booksErr != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": booksErr.Error()})
|
|
}
|
|
bookList = BookCardsFromCollectionItemRows(books, libTypes)
|
|
}
|
|
|
|
if u, ok := c.Get("user").(database.Users); ok {
|
|
bookList = MarkActiveConflicts(c.Request().Context(), h.db, u.ID, bookList)
|
|
}
|
|
|
|
var viewSettings map[string]interface{}
|
|
if len(collection.ViewSettings) > 0 {
|
|
err := json.Unmarshal(collection.ViewSettings, &viewSettings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// A2c §3.4: the collection's reading queue for the Up Next strip.
|
|
// Library visibility enforced — a hidden library's books never surface
|
|
// here even if they sit in a visible collection.
|
|
upNext := []map[string]interface{}{}
|
|
if !isSmart {
|
|
hideFinished := false
|
|
if b, ok := viewSettings["hide_finished"].(bool); ok {
|
|
hideFinished = b
|
|
}
|
|
_ = hideFinished
|
|
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
|
if visErr == nil {
|
|
user := c.Get("user").(database.Users)
|
|
rows, upErr := h.db.GetCollectionUpNext(c.Request().Context(), database.GetCollectionUpNextParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
ViewerUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
VisibleLibraryIds: visibleIDs,
|
|
Limit: pgtype.Int4{Int32: 10, Valid: true},
|
|
})
|
|
if upErr == nil {
|
|
for _, row := range rows {
|
|
upNext = append(upNext, map[string]interface{}{
|
|
"id": uuid.UUID(row.MediaItems.ID.Bytes).String(),
|
|
"title": row.MediaItems.Title,
|
|
"author": utils.PgTextString(row.MediaItems.Author),
|
|
"cover": utils.PgTextString(row.MediaItems.CoverImagePath),
|
|
"format": row.MediaItems.FormatGroup,
|
|
"progress": row.ViewerPercentage,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
|
"name": collection.Name,
|
|
"description": utils.PgTextString(collection.Description),
|
|
"color": utils.PgTextString(collection.Color),
|
|
"icon": utils.PgTextString(collection.Icon),
|
|
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
|
"view_settings": viewSettings,
|
|
"rule_match_mode": collection.RuleMatchMode,
|
|
"filter_tree": treeEnvelope(collection.FilterTree),
|
|
"books": bookList,
|
|
"book_count": len(bookList),
|
|
"is_smart": isSmart,
|
|
"up_next": upNext,
|
|
"created_at": collection.CreatedAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) UpdateCollection(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
var req UpdateCollectionRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// A2c §3.3: playlist toggles ride the edit form as top-level fields and
|
|
// merge into view_settings server-side (the form can't bind a map).
|
|
// The form posts a hidden "false" sibling before the checkbox, so the
|
|
// LAST value is the checkbox state.
|
|
if v := optionalFlag(req.HideFinished); v != nil {
|
|
if req.ViewSettings == nil {
|
|
req.ViewSettings = map[string]interface{}{}
|
|
}
|
|
req.ViewSettings["hide_finished"] = *v
|
|
}
|
|
if v := optionalFlag(req.ShowUpNext); v != nil {
|
|
if req.ViewSettings == nil {
|
|
req.ViewSettings = map[string]interface{}{}
|
|
}
|
|
req.ViewSettings["show_up_next"] = *v
|
|
}
|
|
|
|
treeJSON, treeErr := parseRequestFilterTree(req.FilterTree)
|
|
if treeErr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()})
|
|
}
|
|
collection, err := h.collectionService.UpdateCollection(
|
|
c.Request().Context(),
|
|
collectionID,
|
|
req.Name,
|
|
req.Description,
|
|
req.Color,
|
|
req.Icon,
|
|
req.AutoAssignRules,
|
|
req.ViewSettings,
|
|
req.RuleMatchMode,
|
|
treeJSON,
|
|
)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
|
"name": collection.Name,
|
|
"description": utils.PgTextString(collection.Description),
|
|
"color": utils.PgTextString(collection.Color),
|
|
"icon": utils.PgTextString(collection.Icon),
|
|
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
|
"view_settings": json.RawMessage(collection.ViewSettings),
|
|
"created_at": collection.CreatedAt.Time.String(),
|
|
}
|
|
|
|
// Add redirect header for HTMX requests
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
c.Response().Header().Set("HX-Redirect", "/collections")
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, response)
|
|
}
|
|
|
|
func (h *CollectionHandler) DeleteCollection(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
err = h.collectionService.DeleteCollection(c.Request().Context(), collectionID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
// CSP: no inline scripts in partials — auth_forms.ts navigates.
|
|
html := `<div class="text-green-500" data-redirect="/collections">Collection deleted! Redirecting...</div>`
|
|
return c.HTML(http.StatusOK, html)
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *CollectionHandler) AddBooks(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req AddBooksRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
addedCount := 0
|
|
var addedBookIDs []string
|
|
for _, bookIDStr := range req.BookIDs {
|
|
bookID, err := uuid.Parse(bookIDStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID)
|
|
if err == nil {
|
|
addedCount++
|
|
addedBookIDs = append(addedBookIDs, bookID.String())
|
|
}
|
|
}
|
|
|
|
if addedCount > 0 && h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"collection_id": collectionID.String(),
|
|
"action": "books_added",
|
|
"book_ids": addedBookIDs,
|
|
"count": addedCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *CollectionHandler) RemoveBook(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
|
|
bookID, err := uuid.Parse(c.Param("bookId"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
|
}
|
|
|
|
// Get user from context for WebSocket broadcast
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Broadcast to user's other devices via WebSocket
|
|
if h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"collection_id": collectionID.String(),
|
|
"action": "book_removed",
|
|
"book_id": bookID.String(),
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
type BulkRemoveBooksRequest struct {
|
|
BookIDs []string `json:"book_ids" validate:"required"`
|
|
}
|
|
|
|
func (h *CollectionHandler) BulkRemoveBooks(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req BulkRemoveBooksRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
removedCount := 0
|
|
var removedBookIDs []string
|
|
for _, bookIDStr := range req.BookIDs {
|
|
bookID, err := uuid.Parse(bookIDStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
|
if err == nil {
|
|
removedCount++
|
|
removedBookIDs = append(removedBookIDs, bookID.String())
|
|
}
|
|
}
|
|
|
|
if removedCount > 0 && h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"collection_id": collectionID.String(),
|
|
"action": "books_removed",
|
|
"book_ids": removedBookIDs,
|
|
"count": removedCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"removed": removedCount,
|
|
"total": len(req.BookIDs),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) GetDeviceMappings(c *echo.Context) error {
|
|
deviceID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
|
}
|
|
|
|
mappings, err := h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
response := make([]DeviceMappingSummary, 0, len(mappings))
|
|
for _, m := range mappings {
|
|
response = append(response, DeviceMappingSummary{
|
|
ID: m.ID.Bytes,
|
|
CollectionID: m.CollectionID.Bytes,
|
|
CollectionName: m.CollectionName,
|
|
DeviceShelfName: utils.PgTextString(m.DeviceShelfName),
|
|
SyncDirection: utils.PgTextString(m.SyncDirection),
|
|
CreatedAt: m.CreatedAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"device_id": deviceID.String(),
|
|
"mappings": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) CreateDeviceMapping(c *echo.Context) error {
|
|
deviceID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
|
}
|
|
|
|
var req CreateDeviceMappingRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
collectionID, err := uuid.Parse(req.CollectionID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
mapping, err := h.collectionService.CreateDeviceShelfMapping(
|
|
c.Request().Context(),
|
|
collectionID,
|
|
deviceID,
|
|
req.DeviceShelfName,
|
|
req.SyncDirection,
|
|
)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
|
"id": uuid.UUID(mapping.ID.Bytes).String(),
|
|
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
|
|
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
|
|
"device_shelf_name": utils.PgTextString(mapping.DeviceShelfName),
|
|
"sync_direction": utils.PgTextString(mapping.SyncDirection),
|
|
"created_at": mapping.CreatedAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) UpdateDeviceMapping(c *echo.Context) error {
|
|
mappingID, err := uuid.Parse(c.Param("mappingId"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
|
|
}
|
|
|
|
var req UpdateDeviceMappingRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
mapping, err := h.collectionService.UpdateDeviceShelfMapping(
|
|
c.Request().Context(),
|
|
mappingID,
|
|
req.DeviceShelfName,
|
|
req.SyncDirection,
|
|
)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"id": uuid.UUID(mapping.ID.Bytes).String(),
|
|
"collection_id": uuid.UUID(mapping.CollectionID.Bytes).String(),
|
|
"device_id": uuid.UUID(mapping.DeviceID.Bytes).String(),
|
|
"device_shelf_name": utils.PgTextString(mapping.DeviceShelfName),
|
|
"sync_direction": utils.PgTextString(mapping.SyncDirection),
|
|
"created_at": mapping.CreatedAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) DeleteDeviceMapping(c *echo.Context) error {
|
|
mappingID, err := uuid.Parse(c.Param("mappingId"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
|
|
}
|
|
|
|
err = h.collectionService.DeleteDeviceShelfMapping(c.Request().Context(), mappingID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetBookCollections(c *echo.Context) error {
|
|
bookID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
|
}
|
|
|
|
collections, err := h.collectionService.GetBookCollections(c.Request().Context(), bookID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
response := make([]BookCollectionSummary, 0, len(collections))
|
|
for _, col := range collections {
|
|
response = append(response, BookCollectionSummary{
|
|
ID: col.ID.Bytes,
|
|
Name: col.Name,
|
|
Description: utils.PgTextString(col.Description),
|
|
Color: utils.PgTextString(col.Color),
|
|
Icon: utils.PgTextString(col.Icon),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"book_id": bookID.String(),
|
|
"collections": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollectionsData(c *echo.Context) ([]database.Collections, error) {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollectionData(c *echo.Context, collectionID uuid.UUID) (database.Collections, error) {
|
|
return h.collectionService.GetCollection(c.Request().Context(), collectionID)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollectionBooksData(c *echo.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
|
|
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
|
|
}
|
|
|
|
// userVisibleLibraryIDs loads the requester's visible-library set (the
|
|
// family lens). Smart-collection evaluation funnels every book candidate
|
|
// through it so hidden libraries contribute nothing.
|
|
// userVisibleLibraryIDs adapts the collection endpoints' identity
|
|
// source to the canonical resolver (H4.5).
|
|
func (h *CollectionHandler) userVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
_, ids, err := h.libraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
|
|
return ids, err
|
|
}
|
|
|
|
// UserVisibleLibraryIDs is the exported entry point for the router's
|
|
// frontend page handlers, which resolve the visible-library set before
|
|
// evaluating smart-collection rule matches.
|
|
func (h *CollectionHandler) UserVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
|
return h.userVisibleLibraryIDs(c)
|
|
}
|
|
|
|
// CollectionService returns the underlying service so the router's
|
|
// frontend handlers can share the exact same evaluation the API uses.
|
|
func (h *CollectionHandler) CollectionService() *services.CollectionService {
|
|
return h.collectionService
|
|
}
|
|
|
|
// rejectSmartMutation returns a 400 for smart collections: membership is
|
|
// derived live from rules, so stored link mutations have nothing to act
|
|
// on. v1 has no hybrid mode — add/remove with both rules and hand-picked
|
|
// books is deliberately unsupported (collection_items.excluded awaits it).
|
|
func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uuid.UUID) *echo.HTTPError {
|
|
collection, err := h.GetCollectionData(c, collectionID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
|
}
|
|
if services.IsCollectionSmart(collection) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type AddRuleRequest struct {
|
|
Field string `json:"field" validate:"required"`
|
|
Operator string `json:"operator" validate:"required"`
|
|
Value string `json:"value" validate:"required"`
|
|
Enabled *bool `json:"enabled"`
|
|
Priority int `json:"priority"`
|
|
}
|
|
|
|
// AddRule handles POST /api/collections/:id/rules — appends one rule to
|
|
// the ruleset. The first rule converts a static collection to smart.
|
|
func (h *CollectionHandler) AddRule(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
collection, err := h.GetCollectionData(c, collectionID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
|
}
|
|
if collection.IsSystemCollection.Bool {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"})
|
|
}
|
|
|
|
var req AddRuleRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{
|
|
Field: req.Field,
|
|
Operator: req.Operator,
|
|
Value: req.Value,
|
|
Enabled: req.Enabled,
|
|
Priority: req.Priority,
|
|
})
|
|
if err != nil {
|
|
var ruleErr *services.RuleValidationError
|
|
if errors.As(err, &ruleErr) {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
|
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
"rule": rule,
|
|
"is_smart": services.IsCollectionSmart(updated),
|
|
})
|
|
}
|
|
|
|
// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently
|
|
// the enabled toggle; the payload may only carry {"enabled": bool}.
|
|
func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
ruleID := c.Param("ruleId")
|
|
if ruleID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
|
}
|
|
|
|
var req struct {
|
|
Enabled *bool `json:"enabled" validate:"required"`
|
|
}
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "rule not found") {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
"is_smart": services.IsCollectionSmart(updated),
|
|
})
|
|
}
|
|
|
|
// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId.
|
|
func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
ruleID := c.Param("ruleId")
|
|
if ruleID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
|
}
|
|
|
|
updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "rule not found") {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
"is_smart": services.IsCollectionSmart(updated),
|
|
})
|
|
}
|
|
|
|
type TestRulesRequest struct {
|
|
Rules []map[string]interface{} `json:"rules" validate:"required"`
|
|
// MatchMode previews the collection's rule_match_mode: "any" (OR,
|
|
// default) or "all" (AND — the daily briefing). Empty = any.
|
|
MatchMode string `json:"match_mode"`
|
|
}
|
|
|
|
// treeEnvelope renders the stored filter tree for the envelope: nil
|
|
// (legacy/static collections) serializes as null, trees as the object.
|
|
func treeEnvelope(t []byte) any {
|
|
if len(t) == 0 {
|
|
return nil
|
|
}
|
|
var v interface{}
|
|
if json.Unmarshal(t, &v) == nil {
|
|
return v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type BookMatch struct {
|
|
MediaItemID string `json:"media_item_id"`
|
|
Title string `json:"title"`
|
|
Author string `json:"author"`
|
|
CoverImagePath string `json:"cover_image_path"`
|
|
MatchReason string `json:"match_reason"`
|
|
}
|
|
|
|
func (h *CollectionHandler) TestRules(c *echo.Context) error {
|
|
var req TestRulesRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Candidates mirror what smart collections actually evaluate against:
|
|
// the requester's visible libraries and live items only — the preview
|
|
// must not promise matches the live render will never produce.
|
|
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
var visibleArg []pgtype.UUID
|
|
if len(visibleIDs) > 0 {
|
|
visibleArg = visibleIDs
|
|
}
|
|
viewerID := pgtype.UUID{}
|
|
if u, ok := c.Get("user").(database.Users); ok {
|
|
viewerID = u.ID
|
|
}
|
|
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
|
|
LibraryID: pgtype.UUID{},
|
|
VisibleLibraryIds: visibleArg,
|
|
ViewerUserID: viewerID,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
|
|
}
|
|
|
|
matchAll := req.MatchMode == services.RuleMatchAll
|
|
|
|
var matches []BookMatch
|
|
for _, row := range mediaItems {
|
|
matchReason := h.checkRulesAgainstBook(row.MediaItems, row.ViewerPercentage, req.Rules, matchAll)
|
|
if matchReason != "" {
|
|
item := row.MediaItems
|
|
author := ""
|
|
if item.Author.Valid {
|
|
author = item.Author.String
|
|
}
|
|
|
|
matches = append(matches, BookMatch{
|
|
MediaItemID: uuid.UUID(item.ID.Bytes).String(),
|
|
Title: item.Title,
|
|
Author: author,
|
|
CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath),
|
|
MatchReason: matchReason,
|
|
})
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"matches": matches,
|
|
"total": len(matches),
|
|
})
|
|
}
|
|
|
|
// checkRulesAgainstBook evaluates the candidate against the ruleset via
|
|
// the SERVICE evaluator (one vocabulary, one semantics — the handler
|
|
// keeps only the match-reason display). matchAll=true (the collection's
|
|
// "all" mode) requires every rule to hit.
|
|
func (h *CollectionHandler) checkRulesAgainstBook(item database.MediaItems, viewerPercentage float64, rules []map[string]interface{}, matchAll bool) string {
|
|
service := h.collectionService
|
|
if matchAll {
|
|
matched := 0
|
|
for _, rule := range rules {
|
|
field, _ := rule["field"].(string)
|
|
operator, _ := rule["operator"].(string)
|
|
value, _ := rule["value"].(string)
|
|
if !service.FieldMatches(item, viewerPercentage, field, operator, value) {
|
|
return ""
|
|
}
|
|
matched++
|
|
}
|
|
if matched > 0 {
|
|
return fmt.Sprintf("Matched all %d rules", matched)
|
|
}
|
|
return ""
|
|
}
|
|
for _, rule := range rules {
|
|
field, _ := rule["field"].(string)
|
|
operator, _ := rule["operator"].(string)
|
|
value, _ := rule["value"].(string)
|
|
|
|
if service.FieldMatches(item, viewerPercentage, field, operator, value) {
|
|
return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (h *CollectionHandler) evaluateRule(item database.MediaItems, field, operator, value string) bool {
|
|
var itemValue string
|
|
|
|
switch field {
|
|
case "genre":
|
|
if item.Genre.Valid {
|
|
itemValue = item.Genre.String
|
|
}
|
|
case "series":
|
|
if item.Series.Valid {
|
|
itemValue = item.Series.String
|
|
}
|
|
case "author":
|
|
if item.Author.Valid {
|
|
itemValue = item.Author.String
|
|
}
|
|
case "language":
|
|
if item.Language.Valid {
|
|
itemValue = item.Language.String
|
|
}
|
|
case "publisher":
|
|
if item.Publisher.Valid {
|
|
itemValue = item.Publisher.String
|
|
}
|
|
case "copyright_year":
|
|
if item.CopyrightYear.Valid {
|
|
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
|
|
}
|
|
case "tags":
|
|
if len(item.Tags) > 0 {
|
|
itemValue = strings.Join(item.Tags, ", ")
|
|
}
|
|
}
|
|
|
|
return h.compareValues(itemValue, operator, value)
|
|
}
|
|
|
|
func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) bool {
|
|
switch operator {
|
|
case "equals":
|
|
return itemValue == ruleValue
|
|
case "not_equals":
|
|
return itemValue != ruleValue
|
|
case "contains":
|
|
return strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
case "not_contains":
|
|
return !strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
case "starts_with":
|
|
return strings.HasPrefix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
case "ends_with":
|
|
return strings.HasSuffix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
case "greater_than":
|
|
itemNum, err1 := strconv.Atoi(itemValue)
|
|
ruleNum, err2 := strconv.Atoi(ruleValue)
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
return itemNum > ruleNum
|
|
case "less_than":
|
|
itemNum, err1 := strconv.Atoi(itemValue)
|
|
ruleNum, err2 := strconv.Atoi(ruleValue)
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
return itemNum < ruleNum
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Bulk Operations
|
|
|
|
// POST /api/collections/bulk-add-books
|
|
// Bulk add books to multiple collections
|
|
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req struct {
|
|
Operations []struct {
|
|
CollectionID string `json:"collection_id" validate:"required"`
|
|
BookIDs []string `json:"book_ids" validate:"required"`
|
|
} `json:"operations" validate:"required"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
if len(req.Operations) == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"})
|
|
}
|
|
|
|
// Smart collections are rejected up front per operation so the
|
|
// caller sees a clean, actionable error instead of silent no-ops.
|
|
for _, op := range req.Operations {
|
|
if colUUID, err := uuid.Parse(op.CollectionID); err == nil {
|
|
if guardErr := h.rejectSmartMutation(c, colUUID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
}
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
successCount := 0
|
|
failedCount := 0
|
|
|
|
for _, op := range req.Operations {
|
|
collectionID, err := uuid.Parse(op.CollectionID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"status": "error",
|
|
"error": "Invalid collection UUID",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
for _, bookIDStr := range op.BookIDs {
|
|
bookID, err := uuid.Parse(bookIDStr)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookIDStr,
|
|
"status": "error",
|
|
"error": "Invalid book UUID",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
collectionUUID := pgtype.UUID{Bytes: collectionID, Valid: true}
|
|
bookUUID := pgtype.UUID{Bytes: bookID, Valid: true}
|
|
|
|
_, err = h.db.AddBookToCollection(c.Request().Context(), database.AddBookToCollectionParams{
|
|
CollectionID: collectionUUID,
|
|
MediaItemID: bookUUID,
|
|
AddedByUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookIDStr,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookIDStr,
|
|
"status": "success",
|
|
})
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
if successCount > 0 && h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"action": "books_added_bulk",
|
|
"count": successCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(results),
|
|
"added": successCount,
|
|
"failed": failedCount,
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
|
user := c.Get("user")
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
}
|
|
|
|
var req struct {
|
|
LibraryID string `json:"library_id"`
|
|
Rules []services.Rule `json:"rules"`
|
|
ManualBookIDs []string `json:"manual_book_ids"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
libUUID, err := uuid.Parse(req.LibraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
|
|
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
if req.Limit <= 0 || req.Limit > 100 {
|
|
req.Limit = 20
|
|
}
|
|
|
|
allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
|
|
}
|
|
|
|
// Book-scope preview (the rules page remains books-first until the
|
|
// filter-unification pass); no viewer progress loaded here, so
|
|
// played/unplayed rules preview at the no-progress default.
|
|
var matchedItems []database.MediaItems
|
|
for _, item := range allItems {
|
|
evaluations := h.collectionService.EvaluateRules(item, 0, req.Rules)
|
|
for _, eval := range evaluations {
|
|
if eval.Matches {
|
|
matchedItems = append(matchedItems, item)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, bookID := range req.ManualBookIDs {
|
|
bookUUID, err := uuid.Parse(bookID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, item := range allItems {
|
|
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
|
|
if itemUUID == bookUUID {
|
|
alreadyAdded := false
|
|
for _, added := range matchedItems {
|
|
addedUUID, _ := uuid.FromBytes(added.ID.Bytes[0:16])
|
|
if addedUUID == bookUUID {
|
|
alreadyAdded = true
|
|
break
|
|
}
|
|
}
|
|
if !alreadyAdded {
|
|
matchedItems = append(matchedItems, item)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(matchedItems) > req.Limit {
|
|
matchedItems = matchedItems[:req.Limit]
|
|
}
|
|
|
|
bookCards := BookCardsFromMediaItems(matchedItems, LibraryTypeMap(c.Request().Context(), h.db))
|
|
if u, ok := c.Get("user").(database.Users); ok {
|
|
bookCards = MarkActiveConflicts(c.Request().Context(), h.db, u.ID, bookCards)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
|
|
}
|
|
|
|
// ReorderItems applies a static collection's manual playlist order
|
|
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
|
|
// ids omitted keep their relative order appended after (the service never
|
|
// deletes membership).
|
|
// SetMatchMode handles PUT /api/collections/:id/match-mode (plan item
|
|
// 2.4): flips the smart ruleset's semantics any|all on its own channel
|
|
// — the rules page toggle writes exactly this column, nothing else.
|
|
func (h *CollectionHandler) SetMatchMode(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
}
|
|
|
|
var req struct {
|
|
Mode string `json:"mode" form:"mode" validate:"required,oneof=any all"`
|
|
}
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "mode must be any or all"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
|
|
if err != nil || !owns {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"})
|
|
}
|
|
|
|
if err := h.db.UpdateCollectionMatchMode(c.Request().Context(), database.UpdateCollectionMatchModeParams{
|
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
RuleMatchMode: req.Mode,
|
|
}); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"mode": req.Mode})
|
|
}
|
|
|
|
func (h *CollectionHandler) ReorderItems(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
}
|
|
|
|
var req struct {
|
|
ItemIDs []string `json:"item_ids"`
|
|
}
|
|
if err := c.Bind(&req); err != nil || len(req.ItemIDs) == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "item_ids must be a non-empty ordered id list"})
|
|
}
|
|
|
|
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
|
|
if err != nil || !owns {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can reorder it"})
|
|
}
|
|
|
|
ordered := make([]uuid.UUID, 0, len(req.ItemIDs))
|
|
for _, raw := range req.ItemIDs {
|
|
id, parseErr := uuid.Parse(raw)
|
|
if parseErr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id: " + raw})
|
|
}
|
|
ordered = append(ordered, id)
|
|
}
|
|
|
|
if err := h.collectionService.ReorderItems(c.Request().Context(), collectionID, ordered); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
|
|
}
|
|
|
|
// optionalFlag interprets a multi-valued form flag: last value wins
|
|
// (hidden-false sibling precedes the checkbox), absent → nil (untouched).
|
|
func optionalFlag(values []string) *bool {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
v := strings.EqualFold(values[len(values)-1], "true")
|
|
return &v
|
|
}
|