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) *CollectionHandler { return &CollectionHandler{ db: db, collectionService: services.NewCollectionService(db), 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"` } type UpdateCollectionRequest 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"` AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"` ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"` } 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"` } type BookInfo struct { MediaItemID string `json:"media_item_id"` Title string `json:"title"` Author string `json:"author"` CoverImagePath string `json:"cover_image_path"` HasConflict bool `json:"has_conflict"` } 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"` } 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()}) } collection, err := h.collectionService.CreateCollection( c.Request().Context(), userUUID, req.Name, req.Description, req.Color, req.Icon, req.AutoAssignRules, req.ViewSettings, ) 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": textToString(collection.Description), "color": textToString(collection.Color), "icon": textToString(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()}) } type CollectionResponse 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"` } response := make([]CollectionResponse, 0, len(collections)) for _, col := range collections { isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules) 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 { rules := h.collectionService.ParseRules(col.AutoAssignRules) visibleIDs, visErr := h.userVisibleLibraryIDs(c) if visErr == nil { matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ LibraryID: libUUID, VisibleIDs: visibleIDs, }) 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, CollectionResponse{ ID: col.ID.Bytes, Name: col.Name, Description: textToString(col.Description), Color: textToString(col.Color), Icon: textToString(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 := h.collectionService.IsSmartCollection(collection.AutoAssignRules) if isSmart { rules := h.collectionService.ParseRules(collection.AutoAssignRules) visibleIDs, err := h.userVisibleLibraryIDs(c) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ LibraryID: libUUID, VisibleIDs: visibleIDs, }) if evalErr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()}) } bookList = make([]BookInfo, 0, len(matches)) for _, item := range matches { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) bookList = append(bookList, BookInfo{ MediaItemID: itemUUID.String(), Title: item.Title, Author: textToString(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), }) } } 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 { bookCards := make([]BookInfo, len(section.Items)) for i, item := range section.Items { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) bookCards[i] = BookInfo{ MediaItemID: itemUUID.String(), Title: item.Title, Author: textToString(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), } } bookList = bookCards break } } } else if libUUID.Valid { collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{ CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true}, LibraryID: libUUID, Limit: pgtype.Int4{Int32: 10000, Valid: true}, }) if collErr != nil { bookList = []BookInfo{} } else { var validItems []database.GetCollectionItemsForDashboardRow for _, item := range collItems { if !item.Excluded.Valid || !item.Excluded.Bool { validItems = append(validItems, item) } } bookCards := make([]BookInfo, len(validItems)) for i, item := range validItems { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) bookCards[i] = BookInfo{ MediaItemID: itemUUID.String(), Title: item.Title, Author: textToString(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), } } bookList = bookCards } } else { books, booksErr := h.GetCollectionBooksData(c, collectionID) if booksErr != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": booksErr.Error()}) } bookList = make([]BookInfo, 0, len(books)) for _, book := range books { bookList = append(bookList, BookInfo{ MediaItemID: uuid.UUID(book.MediaItemID.Bytes).String(), Title: book.Title, Author: textToString(book.Author), CoverImagePath: utils.ResolveMediaURL(book.LibraryID, book.CoverImagePath), }) } } var viewSettings map[string]interface{} if len(collection.ViewSettings) > 0 { err := json.Unmarshal(collection.ViewSettings, &viewSettings) if err != nil { return err } } 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": textToString(collection.Description), "color": textToString(collection.Color), "icon": textToString(collection.Icon), "auto_assign_rules": json.RawMessage(collection.AutoAssignRules), "view_settings": viewSettings, "books": bookList, "book_count": len(bookList), "is_smart": isSmart, "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()}) } collection, err := h.collectionService.UpdateCollection( c.Request().Context(), collectionID, req.Name, req.Description, req.Color, req.Icon, req.AutoAssignRules, req.ViewSettings, ) 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": textToString(collection.Description), "color": textToString(collection.Color), "icon": textToString(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" { 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()}) } type MappingResponse 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"` } response := make([]MappingResponse, 0, len(mappings)) for _, m := range mappings { response = append(response, MappingResponse{ ID: m.ID.Bytes, CollectionID: m.CollectionID.Bytes, CollectionName: m.CollectionName, DeviceShelfName: textToString(m.DeviceShelfName), SyncDirection: textToString(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": textToString(mapping.DeviceShelfName), "sync_direction": textToString(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": textToString(mapping.DeviceShelfName), "sync_direction": textToString(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()}) } type CollectionResponse struct { ID uuid.UUID `json:"id"` Name string `json:"name"` Description string `json:"description"` Color string `json:"color"` Icon string `json:"icon"` } response := make([]CollectionResponse, 0, len(collections)) for _, col := range collections { response = append(response, CollectionResponse{ ID: col.ID.Bytes, Name: col.Name, Description: textToString(col.Description), Color: textToString(col.Color), Icon: textToString(col.Icon), }) } return c.JSON(http.StatusOK, map[string]interface{}{ "book_id": bookID.String(), "collections": response, "total": len(response), }) } func textToString(t pgtype.Text) string { if t.Valid { return t.String } return "" } 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) } func (h *CollectionHandler) GetDeviceMappingsData(c *echo.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) { return h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID) } func (h *CollectionHandler) GetUserCollectionsList(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) } // 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. 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") } rows, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID) if err != nil { return nil, fmt.Errorf("failed to check library access: %w", err) } ids := make([]pgtype.UUID, 0, len(rows)) for _, lib := range rows { if lib.ID.Valid { ids = append(ids, lib.ID) } } return ids, nil } // 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 h.collectionService.IsSmartCollection(collection.AutoAssignRules) { 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": h.collectionService.IsSmartCollection(updated.AutoAssignRules), }) } // 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": h.collectionService.IsSmartCollection(updated.AutoAssignRules), }) } // 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": h.collectionService.IsSmartCollection(updated.AutoAssignRules), }) } type TestRulesRequest struct { Rules []map[string]interface{} `json:"rules" validate:"required"` } 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 } mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{ LibraryID: pgtype.UUID{}, VisibleLibraryIds: visibleArg, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"}) } listRows := make([]database.ListMediaItemsRow, len(mediaItems)) for i, item := range mediaItems { listRows[i] = mediaItemsToListMediaItemsRow(item) } var matches []BookMatch for _, item := range listRows { matchReason := h.checkRulesAgainstBook(item, req.Rules) if matchReason != "" { 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), }) } func (h *CollectionHandler) checkRulesAgainstBook(item database.ListMediaItemsRow, rules []map[string]interface{}) string { for _, rule := range rules { field, _ := rule["field"].(string) operator, _ := rule["operator"].(string) value, _ := rule["value"].(string) if h.evaluateRule(item, field, operator, value) { return fmt.Sprintf("Matched rule: %s %s %s", field, operator, value) } } return "" } func (h *CollectionHandler) evaluateRule(item database.ListMediaItemsRow, 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"}) } var matchedItems []database.MediaItems for _, item := range allItems { listItem := mediaItemsToListMediaItemsRow(item) evaluations := h.collectionService.EvaluateRules(listItem, 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 := make([]BookInfo, len(matchedItems)) for i, item := range matchedItems { itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) bookCards[i] = BookInfo{ MediaItemID: itemUUID.String(), Title: item.Title, Author: textToString(item.Author), CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), } } return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards}) } func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow { return database.ListMediaItemsRow{ ID: item.ID, LibraryID: item.LibraryID, Title: item.Title, Author: item.Author, Isbn: item.Isbn, Description: item.Description, FilePath: utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: true}), FileSize: item.FileSize, MimeType: item.MimeType, CoverImagePath: pgtype.Text{String: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), Valid: true}, Series: item.Series, SeriesNumber: item.SeriesNumber, Tags: item.Tags, Asin: item.Asin, DatePublished: item.DatePublished, Publisher: item.Publisher, Contributors: item.Contributors, Language: item.Language, Edition: item.Edition, PageCount: item.PageCount, Genre: item.Genre, CopyrightYear: item.CopyrightYear, GoodreadsID: item.GoodreadsID, OpenlibraryID: item.OpenlibraryID, GoogleBooksID: item.GoogleBooksID, AddedByAdminID: item.AddedByAdminID, CreatedAt: item.CreatedAt, UpdatedAt: item.UpdatedAt, FormatGroup: item.FormatGroup, FormatMimetype: item.FormatMimetype, IsReflowable: item.IsReflowable, HasFixedLayout: item.HasFixedLayout, TotalCharacters: item.TotalCharacters, ChapterCount: item.ChapterCount, EntitlementID: item.EntitlementID, RevisionNumber: item.RevisionNumber, KoboContentID: item.KoboContentID, KoboMetadata: item.KoboMetadata, TagsSearch: item.TagsSearch, ContributorsSearch: item.ContributorsSearch, FileSha256: item.FileSha256, OpfIdentifier: item.OpfIdentifier, OpfUuid: item.OpfUuid, HashConfidence: item.HashConfidence, } }