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/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"`
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,
}
}