package handlers import ( "bookhoard/internal/database" "bookhoard/internal/services" wsync "bookhoard/internal/sync" "bookhoard/internal/utils" "encoding/json" "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/v4" ) 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"` 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"` } 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 { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) } bookCount := int32(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), "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" { 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") 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"` } response := make([]CollectionResponse, 0, len(collections)) for _, col := range collections { if !includeAuto && len(col.AutoAssignRules) > 0 { continue } response = append(response, CollectionResponse{ ID: uuid.UUID(col.ID.Bytes), Name: col.Name, Description: textToString(col.Description), Color: textToString(col.Color), Icon: textToString(col.Icon), AutoAssignRules: json.RawMessage(col.AutoAssignRules), CreatedAt: col.CreatedAt.Time.String(), }) } 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"}) } books, err := h.GetCollectionBooksData(c, collectionID) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.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 { json.Unmarshal(collection.ViewSettings, &viewSettings) } 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), "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"}) } 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"}) } 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"}) } 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: uuid.UUID(m.ID.Bytes), CollectionID: uuid.UUID(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: uuid.UUID(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) } 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()}) } mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{ Limit: 1000, Offset: 0, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"}) } var matches []BookMatch for _, item := range mediaItems { 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"}) } 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 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, LibraryName: "", LibraryTypeName: "", } }