- Add libraryService dependency to CollectionHandler and OPDSHandler for centralized path resolution - Create internal/utils/mediaurl.go with ResolveMediaURL() function as single source of truth - Update GetMediaItem and ListMediaItems handlers to return resolved URLs in API responses - Update collection handlers (GetCollection, TestRules, PreviewCollection) to use resolved cover URLs - Update progress handler (GetAllProgress) to use resolved cover URLs - Add library_id to GetCollectionItems SQL query to enable URL resolution - Refactor media scanner to store relative paths instead of absolute filesystem paths - Add ResolveMediaPath() to LibraryService for resolving relative paths to absolute paths - Add ServeFile endpoint at /uploads/library-:id/* for authenticated file serving - Add MimeTypes map to library_service.go for consistent MIME type handling - Update DownloadBook handler to use resolved filesystem paths - Add getRelativePath() helper to MediaScanner for converting absolute to relative paths - Use strings.EqualFold for case-insensitive path comparisons in zip extraction This change enables the application to work with relative paths stored in the database, making it portable across different server environments while maintaining backward compatibility with existing absolute paths.
973 lines
30 KiB
Go
973 lines
30 KiB
Go
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 `json:"name" validate:"required"`
|
|
Description string `json:"description"`
|
|
Color string `json:"color"`
|
|
Icon string `json:"icon"`
|
|
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
|
|
ViewSettings map[string]interface{} `json:"view_settings"`
|
|
}
|
|
|
|
type UpdateCollectionRequest struct {
|
|
Name string `json:"name" validate:"required"`
|
|
Description string `json:"description"`
|
|
Color string `json:"color"`
|
|
Icon string `json:"icon"`
|
|
AutoAssignRules []services.Rule `json:"auto_assign_rules"`
|
|
ViewSettings map[string]interface{} `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"`
|
|
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)
|
|
return c.JSON(http.StatusCreated, 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(),
|
|
})
|
|
}
|
|
|
|
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()})
|
|
}
|
|
|
|
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": json.RawMessage(collection.ViewSettings),
|
|
"created_at": collection.CreatedAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
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()})
|
|
}
|
|
|
|
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.Broadcast(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"})
|
|
}
|
|
|
|
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
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"})
|
|
}
|
|
|
|
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.Broadcast(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)
|
|
|
|
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),
|
|
"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: item.FilePath,
|
|
FileSize: item.FileSize,
|
|
MimeType: item.MimeType,
|
|
CoverImagePath: item.CoverImagePath,
|
|
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: "",
|
|
}
|
|
}
|