From 82ff6356b555340e14e69fee40e6b766dceacfa6 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Feb 2026 12:15:47 -0500 Subject: [PATCH] feat(collections,media): add bulk operations for books and collections Collections: - HandleBulkAddBooks: add multiple books to multiple collections Media: - HandleBulkDelete: delete multiple media items - HandleBulkUpdate: bulk update book metadata (tags, status) - Individual result tracking for each operation - WebSocket broadcasts for collection updates --- internal/handlers/collections.go | 99 ++++++++++++++++ internal/handlers/media.go | 194 +++++++++++++++++++++++++++++++ 2 files changed, 293 insertions(+) diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 42a882e..5fa19b2 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -715,3 +715,102 @@ func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) } 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) + + 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.Broadcast(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), + "success": successCount, + "failed": failedCount, + }) +} diff --git a/internal/handlers/media.go b/internal/handlers/media.go index df89893..12489dd 100644 --- a/internal/handlers/media.go +++ b/internal/handlers/media.go @@ -260,3 +260,197 @@ func (h *MediaHandler) ClearShelf(c echo.Context) error { "message": result, }) } + +// Bulk Operations + +// POST /api/books/bulk-delete +// Bulk delete books +func (h *MediaHandler) HandleBulkDelete(c echo.Context) error { + var req struct { + BookIDs []string `json:"book_ids" validate:"required"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) + } + + if len(req.BookIDs) == 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "book_ids required"}) + } + + results := make([]map[string]interface{}, 0) + successCount := 0 + failedCount := 0 + + for _, bookIDStr := range req.BookIDs { + bookID, err := uuid.Parse(bookIDStr) + if err != nil { + results = append(results, map[string]interface{}{ + "book_id": bookIDStr, + "status": "error", + "error": "Invalid UUID", + }) + failedCount++ + continue + } + + bookUUID := pgtype.UUID{Bytes: bookID, Valid: true} + + book, err := h.db.GetMediaItem(c.Request().Context(), bookUUID) + if err != nil { + results = append(results, map[string]interface{}{ + "book_id": bookIDStr, + "status": "error", + "error": "Book not found", + }) + failedCount++ + continue + } + + err = h.db.DeleteMediaItem(c.Request().Context(), bookUUID) + if err != nil { + results = append(results, map[string]interface{}{ + "book_id": bookIDStr, + "status": "error", + "error": err.Error(), + }) + failedCount++ + continue + } + + if book.FilePath != "" { + os.Remove(book.FilePath) + } + + results = append(results, map[string]interface{}{ + "book_id": bookIDStr, + "status": "success", + }) + successCount++ + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "results": results, + "total": len(req.BookIDs), + "success": successCount, + "failed": failedCount, + }) +} + +// POST /api/books/bulk-update +// Bulk update book metadata +func (h *MediaHandler) HandleBulkUpdate(c echo.Context) error { + var req struct { + Updates []struct { + BookID string `json:"book_id" validate:"required"` + Updates struct { + Title *string `json:"title,omitempty"` + Author *string `json:"author,omitempty"` + Genre *string `json:"genre,omitempty"` + Language *string `json:"language,omitempty"` + Tags *string `json:"tags,omitempty"` + } `json:"updates"` + } `json:"updates" validate:"required"` + } + + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"}) + } + + if len(req.Updates) == 0 { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "updates required"}) + } + + results := make([]map[string]interface{}, 0) + successCount := 0 + failedCount := 0 + + for _, update := range req.Updates { + bookID, err := uuid.Parse(update.BookID) + if err != nil { + results = append(results, map[string]interface{}{ + "book_id": update.BookID, + "status": "error", + "error": "Invalid UUID", + }) + failedCount++ + continue + } + + bookUUID := pgtype.UUID{Bytes: bookID, Valid: true} + + existingBook, err := h.db.GetMediaItem(c.Request().Context(), bookUUID) + if err != nil { + results = append(results, map[string]interface{}{ + "book_id": update.BookID, + "status": "error", + "error": "Book not found", + }) + failedCount++ + continue + } + + updateParams := database.UpdateMediaItemParams{ + ID: bookUUID, + Title: existingBook.Title, + Author: existingBook.Author, + Genre: existingBook.Genre, + Language: existingBook.Language, + Tags: existingBook.Tags, + Description: existingBook.Description, + Publisher: existingBook.Publisher, + CopyrightYear: existingBook.CopyrightYear, + Isbn: existingBook.Isbn, + Series: existingBook.Series, + SeriesNumber: existingBook.SeriesNumber, + Asin: existingBook.Asin, + DatePublished: existingBook.DatePublished, + Contributors: existingBook.Contributors, + Edition: existingBook.Edition, + PageCount: existingBook.PageCount, + GoodreadsID: existingBook.GoodreadsID, + OpenlibraryID: existingBook.OpenlibraryID, + CoverImagePath: existingBook.CoverImagePath, + } + + if update.Updates.Title != nil { + updateParams.Title = *update.Updates.Title + } + if update.Updates.Author != nil { + updateParams.Author = pgtype.Text{String: *update.Updates.Author, Valid: true} + } + if update.Updates.Genre != nil { + updateParams.Genre = pgtype.Text{String: *update.Updates.Genre, Valid: true} + } + if update.Updates.Language != nil { + updateParams.Language = pgtype.Text{String: *update.Updates.Language, Valid: true} + } + if update.Updates.Tags != nil { + updateParams.Tags = pgtype.Text{String: *update.Updates.Tags, Valid: true} + } + + _, err = h.db.UpdateMediaItem(c.Request().Context(), updateParams) + if err != nil { + results = append(results, map[string]interface{}{ + "book_id": update.BookID, + "status": "error", + "error": err.Error(), + }) + failedCount++ + continue + } + + results = append(results, map[string]interface{}{ + "book_id": update.BookID, + "status": "success", + }) + successCount++ + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "results": results, + "total": len(req.Updates), + "success": successCount, + "failed": failedCount, + }) +}