package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" wsync "bookhoard/internal/sync" "bookhoard/internal/utils" "encoding/json" "errors" "fmt" "net/http" "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" // Custom-section builder payload (adapted 2026-10-31): the page // posts the legacy rules vocabulary — rules as a JSON-encoded array // (a string), a match_type select, hand-picked book ids, and the // dashboard pin. The server converts rules → filter tree at write // time and attaches the manual picks; storage stays tree-only. AutoAssignRules json.RawMessage `json:"auto_assign_rules"` MatchType string `json:"match_type"` ManualBookIDs []string `json:"manual_book_ids"` LibraryID string `json:"library_id"` // selection context only — not stored ShowOnDashboard bool `json:"show_on_dashboard"` 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. JSON bodies bind FilterTree; FORM bodies // (the create modal's urlencoded hx-post) bind FilterTreeForm — // echo's binder cannot set an any-typed field from a form string // (the create-modal seed POST 400'd "invalid request" on it, // 2026-10-31). FilterTree any `json:"filter_tree"` // FilterTreeForm mirrors filter_tree for application/x-www-form- // urlencoded submissions: the raw JSON as a string, validated by // the same parseRequestFilterTree path. FilterTreeForm string `form:"filter_tree"` } // parseBuilderRules decodes the custom-section builder's rules payload: // the page posts JSON.stringify(rules) — a JSON string CONTAINING the // array (it has done so since the page was written, 2026-02-19) — while // JSON clients may post the array directly. Null/empty = no rules. func parseBuilderRules(raw json.RawMessage) ([]services.Rule, error) { if len(raw) == 0 || string(raw) == "null" { return nil, nil } var rules []services.Rule if raw[0] == '"' { var encoded string if err := json.Unmarshal(raw, &encoded); err != nil { return nil, err } if strings.TrimSpace(encoded) == "" { return nil, nil } return rules, json.Unmarshal([]byte(encoded), &rules) } return rules, json.Unmarshal(raw, &rules) } // 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 } // resolveTreePayload — the tree payload from either transport: JSON // bodies carry the object in FilterTree, form bodies (the create // modal's urlencoded hx-post) the raw JSON string in FilterTreeForm. func resolveTreePayload(jsonTree any, formTree string) any { if formTree != "" { return formTree } return jsonTree } type UpdateCollectionRequest struct { Name string `form:"name" json:"name" validate:"required"` // 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). JSON bodies bind FilterTree; form bodies bind // FilterTreeForm (echo's binder cannot set an any-typed field from // a form string — see CreateCollectionRequest). FilterTree any `json:"filter_tree"` FilterTreeForm string `form:"filter_tree"` Description string `form:"description" json:"description"` Color string `form:"color" json:"color"` Icon string `form:"icon" json:"icon"` 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"` 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()}) } // Custom-section builder adapter: convert the posted rules to a // filter tree at WRITE time (the save-fix — the page's payload had // never once bound successfully before this). builderRules, rulesErr := parseBuilderRules(req.AutoAssignRules) if rulesErr != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "auto_assign_rules: " + rulesErr.Error()}) } matchMode := req.MatchType if matchMode != services.RuleMatchAll && matchMode != services.RuleMatchAny { matchMode = services.RuleMatchAny } treeJSON, treeErr := parseRequestFilterTree(resolveTreePayload(req.FilterTree, req.FilterTreeForm)) if treeErr != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": treeErr.Error()}) } if treeJSON == nil && len(builderRules) > 0 { tree, cerr := services.RulesToFilterTree(builderRules, matchMode) if cerr != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + cerr.Error()}) } b, merr := json.Marshal(tree) if merr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": merr.Error()}) } // The strict-parse guarantee holds for converted trees too: an // invalid tree is a 400 at write, never a stored 500 at render. if _, verr := services.ParseFilterTree(string(b)); verr != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "section rule: " + verr.Error()}) } treeJSON = b } collection, err := h.collectionService.CreateCollection( c.Request().Context(), userUUID, req.Name, req.Description, req.Color, req.Icon, req.ViewSettings, req.ShowOnDashboard, 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()}) } // Manual picks: a section = rules + hand-picked books. Attach after // create; unparsable ids are skipped (same leniency as AddBooks). for _, bookIDStr := range req.ManualBookIDs { bookID, perr := uuid.Parse(bookIDStr) if perr != nil { continue } _ = h.collectionService.AddBookToCollection(c.Request().Context(), uuid.UUID(collection.ID.Bytes), bookID, userUUID) } bookCount := int32(0) isSmart := req.CollectionType == "smart" || treeJSON != nil 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], "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, 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), 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, 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, h.collectionService.Search()) 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), "view_settings": viewSettings, "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(resolveTreePayload(req.FilterTree, req.FilterTreeForm)) 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.ViewSettings, treeJSON, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // Collections RLS: another user's collection is INVISIBLE — // the update touched no rows. Answer 404, not a 500 that // reads like a bug (2026-10-31 collections RLS). return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"}) } 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), "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) } // ValidChainOrders — the collection chain's listening-order vocabulary // (plan §10 R2: a collection's playback order is CURATED on the // collection, not a player-side knob). Mirrors OrderEpisodes' orders; // "" means native (the pre-chain-order default). var ValidChainOrders = map[string]bool{"": true, "native": true, "newest": true, "oldest": true, "reverse": true} // CollectionChainOrder resolves a collection row's curated chain order // from its view_settings JSONB — "" (native) when absent/unknown. The // episode chain producer routes OrderEpisodes through this; the // view-settings PUT writes the key (SetCollectionChainOrder). func CollectionChainOrder(viewSettings []byte) string { if len(viewSettings) == 0 { return "" } var vs map[string]interface{} if json.Unmarshal(viewSettings, &vs) != nil { return "" } if s, ok := vs["chain_order"].(string); ok && ValidChainOrders[s] { return s } return "" } // SetCollectionChainOrder — PUT /api/collections/:id/view-settings with // {"chain_order": "..."}: merges the key into the collection's stored // view_settings (the playlist toggles share that JSONB — a blind write // would clobber them). The episode chain producer // (buildSmartCollectionSequenceContext) honors it. func (h *CollectionHandler) SetCollectionChainOrder(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 struct { ChainOrder string `json:"chain_order"` } if err := c.Bind(&req); err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) } if !ValidChainOrders[req.ChainOrder] { return c.JSON(http.StatusBadRequest, map[string]string{ "error": "chain_order must be one of: native, newest, oldest, reverse"}) } collection, err := h.collectionService.GetCollection(c.Request().Context(), collectionID) if err != nil { return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"}) } // Same owner gate as the sibling config mutation (ReorderItems) — // my endpoint writes the row, so it must not let a non-owner flip // someone else's chain order. if u, ok := c.Get("user").(database.Users); ok { owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(u.ID.Bytes)) if err != nil || !owns { return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can change it"}) } } else { return c.JSON(http.StatusUnauthorized, map[string]string{"error": "no user in context"}) } settings := map[string]interface{}{} if len(collection.ViewSettings) > 0 { if uerr := json.Unmarshal(collection.ViewSettings, &settings); uerr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "corrupt view_settings"}) } } if req.ChainOrder == "" || req.ChainOrder == "native" { // Both spellings mean the default — drop the key entirely so the // stored doc stays clean. delete(settings, "chain_order") } else { settings["chain_order"] = req.ChainOrder } blob, err := json.Marshal(settings) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } if err := h.db.UpdateCollectionViewSettings(c.Request().Context(), database.UpdateCollectionViewSettingsParams{ ID: pgtype.UUID{Bytes: collectionID, Valid: true}, ViewSettings: blob, }); err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } return c.JSON(http.StatusOK, map[string]interface{}{ "id": uuid.UUID(collection.ID.Bytes).String(), "chain_order": req.ChainOrder, "view_settings": settings, }) } 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 := `
Collection deleted! Redirecting...
` 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 tree-based — membership is managed by the collection's filter tree, not by hand") } return nil } // treeEnvelope renders the stored filter tree for the envelope: nil // (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 } 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). 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 }