Phases 4 + 5 of the metadata overhaul (plan §4, §5) plus §3 handler wiring. Filters (§4.1): author_filter/publisher_filter now match per-element against the authors[]/publishers[] arrays — a co-authored book matches any one of its authors — with comma-joined params acting as OR-within-facet multi-select (string_to_array + cross unnest). Legacy single-value params unchanged on the wire; series/genre/language filters stay on their varchar columns; narrator filter already per-element. Collections API (§3.3-3.5): collection detail carries viewer + hide_finished into the listing query and exposes up_next (visible- library-enforced reading-queue head); PUT /api/collections/:id/items/order (owner-only, full-order rewrite); UpdateCollectionRequest gains hide_finished/show_up_next ([] string last-value-wins for the hidden-false-sibling checkbox pattern) merged server-side OVER stored view_settings so imported_from_series provenance survives edits; EditCollectionModal renders the toggles from view_settings. Dashboard (§5.5): sections JSON carries is_playlist/complete (dashboard.ts is the AGENTS-documented JSON-render exception; first paint stays templ). Fully-read playlists render '✓ Playlist complete' instead of an empty rail; imported playlists get a 📖 Playlist badge. Frontend: book detail shows subtitle + full authors + Explicit badge; edit modal gains subtitle, comma-separated authors/publishers (client- split to arrays exactly like narrators), step=any series number, explicit checkbox; collection book cards get playlist move ↑/↓ buttons persisting the full order via the reorder endpoint (templ partial re-render on failure — DOM order is client-local, API order is truth). AGENTS.md gotcha #3 amended per plan §3.7: user collections are dashboard sections by default; the show_on_dashboard premise is retired. Verified: full affected integration sweep green (pins widened for up_next/is_playlist/complete — changelog entries in docs/contract_pins.md); unit suite green; go build + templ + vite green.
1372 lines
46 KiB
Go
1372 lines
46 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/services"
|
|
wsync "bookhoard/internal/sync"
|
|
"bookhoard/internal/utils"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/labstack/echo/v5"
|
|
)
|
|
|
|
type CollectionHandler struct {
|
|
db *database.Queries
|
|
collectionService *services.CollectionService
|
|
libraryService *services.LibraryService
|
|
connManager *wsync.ConnectionManager
|
|
}
|
|
|
|
func NewCollectionHandler(db *database.Queries, libraryService *services.LibraryService, connManager *wsync.ConnectionManager) *CollectionHandler {
|
|
return &CollectionHandler{
|
|
db: db,
|
|
collectionService: services.NewCollectionService(db),
|
|
libraryService: libraryService,
|
|
connManager: connManager,
|
|
}
|
|
}
|
|
|
|
type CreateCollectionRequest struct {
|
|
Name string `form:"name" json:"name" validate:"required"`
|
|
Description string `form:"description" json:"description"`
|
|
Color string `form:"color" json:"color"`
|
|
Icon string `form:"icon" json:"icon"`
|
|
CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based)
|
|
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
|
}
|
|
|
|
type UpdateCollectionRequest struct {
|
|
Name string `form:"name" json:"name" validate:"required"`
|
|
Description string `form:"description" json:"description"`
|
|
Color string `form:"color" json:"color"`
|
|
Icon string `form:"icon" json:"icon"`
|
|
AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"`
|
|
ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"`
|
|
// A2c §3.3: playlist toggles — merged into view_settings server-side
|
|
// (nil = untouched). []string because the form posts a hidden "false"
|
|
// sibling next to the checkbox; the LAST value wins. JSON clients may
|
|
// send a single-element array (or omit — untouched).
|
|
HideFinished []string `form:"hide_finished" json:"hide_finished"`
|
|
ShowUpNext []string `form:"show_up_next" json:"show_up_next"`
|
|
}
|
|
|
|
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 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"`
|
|
// A2c: curated playlist marker (imported reading-order collection) and
|
|
// the hide_finished completion state — dashboard.ts renders both.
|
|
IsPlaylist bool `json:"is_playlist"`
|
|
Complete bool `json:"complete"`
|
|
}
|
|
|
|
func (h *CollectionHandler) CreateCollection(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req CreateCollectionRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
collection, err := h.collectionService.CreateCollection(
|
|
c.Request().Context(),
|
|
userUUID,
|
|
req.Name,
|
|
req.Description,
|
|
req.Color,
|
|
req.Icon,
|
|
req.AutoAssignRules,
|
|
req.ViewSettings,
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, services.ErrCollectionNameTaken) {
|
|
return c.JSON(http.StatusConflict, map[string]string{"error": "a collection with this name already exists"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
bookCount := int32(0)
|
|
isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0
|
|
response := map[string]interface{}{
|
|
"id": uuid.UUID(collection.ID.Bytes).String(),
|
|
"user_id": uuid.UUID(collection.UserID.Bytes).String(),
|
|
"name": collection.Name,
|
|
"description": utils.PgTextString(collection.Description),
|
|
"color": utils.PgTextString(collection.Color),
|
|
"icon": utils.PgTextString(collection.Icon),
|
|
"collection_type": map[bool]string{true: "smart", false: "static"}[isSmart],
|
|
"auto_assign_rules": collection.AutoAssignRules,
|
|
"view_settings": collection.ViewSettings,
|
|
"book_count": bookCount,
|
|
"created_at": collection.CreatedAt.Time.String(),
|
|
}
|
|
|
|
// Add redirect header for HTMX requests
|
|
if c.Request().Header.Get("HX-Request") == "true" {
|
|
// A smart collection starts with an empty ruleset; the builder is
|
|
// where it becomes real. Static collections head back to the grid.
|
|
if isSmart {
|
|
c.Response().Header().Set("HX-Redirect", "/collections/"+uuid.UUID(collection.ID.Bytes).String()+"/rules")
|
|
} else {
|
|
c.Response().Header().Set("HX-Redirect", "/collections")
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, response)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollections(c *echo.Context) error {
|
|
includeAuto := c.QueryParam("include_auto") == "true"
|
|
sortBy := c.QueryParam("sort_by")
|
|
libraryID := c.QueryParam("library_id")
|
|
|
|
collections, err := h.GetCollectionsData(c)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
type CollectionResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Color string `json:"color"`
|
|
Icon string `json:"icon"`
|
|
AutoAssignRules json.RawMessage `json:"auto_assign_rules"`
|
|
CreatedAt string `json:"created_at"`
|
|
BookCount int `json:"book_count"`
|
|
IsSmart bool `json:"is_smart"`
|
|
}
|
|
|
|
response := make([]CollectionResponse, 0, len(collections))
|
|
for _, col := range collections {
|
|
isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules)
|
|
if !includeAuto && isSmart {
|
|
// Legacy default for API clients (the Android app pre-dates
|
|
// smart collections): rule-driven collections stay opt-in so
|
|
// their membership could never be stale for old readers.
|
|
continue
|
|
}
|
|
|
|
bookCount := 0
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
if libParsed, libErr := uuid.Parse(libraryID); libErr == nil {
|
|
libUUID = pgtype.UUID{Bytes: libParsed, Valid: true}
|
|
}
|
|
}
|
|
if isSmart {
|
|
rules := h.collectionService.ParseRules(col.AutoAssignRules)
|
|
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
|
if visErr == nil {
|
|
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
|
LibraryID: libUUID,
|
|
VisibleIDs: visibleIDs,
|
|
})
|
|
if evalErr == nil {
|
|
bookCount = len(matches)
|
|
}
|
|
}
|
|
} else {
|
|
count, countErr := h.db.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{
|
|
CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true},
|
|
LibraryID: libUUID,
|
|
})
|
|
if countErr == nil {
|
|
bookCount = int(count)
|
|
}
|
|
}
|
|
|
|
response = append(response, CollectionResponse{
|
|
ID: col.ID.Bytes,
|
|
Name: col.Name,
|
|
Description: utils.PgTextString(col.Description),
|
|
Color: utils.PgTextString(col.Color),
|
|
Icon: utils.PgTextString(col.Icon),
|
|
AutoAssignRules: col.AutoAssignRules,
|
|
CreatedAt: col.CreatedAt.Time.String(),
|
|
BookCount: bookCount,
|
|
IsSmart: isSmart,
|
|
})
|
|
}
|
|
|
|
if sortBy == "name" {
|
|
for i := 0; i < len(response); i++ {
|
|
for j := i + 1; j < len(response); j++ {
|
|
if response[i].Name > response[j].Name {
|
|
response[i], response[j] = response[j], response[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"collections": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollection(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
collection, err := h.GetCollectionData(c, collectionID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
|
}
|
|
|
|
libraryID := c.QueryParam("library_id")
|
|
var bookList []BookInfo
|
|
|
|
var libUUID pgtype.UUID
|
|
if libraryID != "" {
|
|
parsed, parseErr := uuid.Parse(libraryID)
|
|
if parseErr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
libUUID = pgtype.UUID{Bytes: parsed, Valid: true}
|
|
}
|
|
|
|
isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules)
|
|
libTypes := LibraryTypeMap(c.Request().Context(), h.db)
|
|
if isSmart {
|
|
rules := h.collectionService.ParseRules(collection.AutoAssignRules)
|
|
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{
|
|
LibraryID: libUUID,
|
|
VisibleIDs: visibleIDs,
|
|
})
|
|
if evalErr != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()})
|
|
}
|
|
bookList = BookCardsFromMediaItems(matches, libTypes)
|
|
} else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
dashboardSvc := services.NewDashboardService(h.db)
|
|
sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{})
|
|
if secErr != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": secErr.Error()})
|
|
}
|
|
for _, section := range sections {
|
|
if section.CollectionID == collectionID {
|
|
bookList = BookCardsFromMediaItems(section.Items, libTypes)
|
|
break
|
|
}
|
|
}
|
|
} else if libUUID.Valid {
|
|
// A2c §3.3: the detail page honors the same per-collection
|
|
// hide_finished as the dashboard section, against the viewer's
|
|
// progress. The viewer_percentage rides along for card graying.
|
|
hideFinished := false
|
|
if len(collection.ViewSettings) > 0 {
|
|
var vs map[string]interface{}
|
|
if json.Unmarshal(collection.ViewSettings, &vs) == nil {
|
|
if b, ok := vs["hide_finished"].(bool); ok {
|
|
hideFinished = b
|
|
}
|
|
}
|
|
}
|
|
var viewerID pgtype.UUID
|
|
if u, ok := c.Get("user").(database.Users); ok {
|
|
viewerID = u.ID
|
|
}
|
|
collItems, collErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(),
|
|
database.GetCollectionItemsForDashboardParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
LibraryID: libUUID,
|
|
ViewerUserID: viewerID,
|
|
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: hideFinished},
|
|
Limit: pgtype.Int4{Int32: 10000, Valid: true},
|
|
})
|
|
if collErr != nil {
|
|
bookList = BookCardsFromCollectionDashRows(nil, libTypes)
|
|
} else {
|
|
// The query filters excluded rows in SQL; cards build straight
|
|
// from the embedded media rows.
|
|
bookList = BookCardsFromCollectionDashRows(collItems, libTypes)
|
|
}
|
|
} else {
|
|
books, booksErr := h.GetCollectionBooksData(c, collectionID)
|
|
if booksErr != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": booksErr.Error()})
|
|
}
|
|
bookList = BookCardsFromCollectionItemRows(books, libTypes)
|
|
}
|
|
|
|
if u, ok := c.Get("user").(database.Users); ok {
|
|
bookList = MarkActiveConflicts(c.Request().Context(), h.db, u.ID, bookList)
|
|
}
|
|
|
|
var viewSettings map[string]interface{}
|
|
if len(collection.ViewSettings) > 0 {
|
|
err := json.Unmarshal(collection.ViewSettings, &viewSettings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// A2c §3.4: the collection's reading queue for the Up Next strip.
|
|
// Library visibility enforced — a hidden library's books never surface
|
|
// here even if they sit in a visible collection.
|
|
upNext := []map[string]interface{}{}
|
|
if !isSmart {
|
|
hideFinished := false
|
|
if b, ok := viewSettings["hide_finished"].(bool); ok {
|
|
hideFinished = b
|
|
}
|
|
_ = hideFinished
|
|
visibleIDs, visErr := h.userVisibleLibraryIDs(c)
|
|
if visErr == nil {
|
|
user := c.Get("user").(database.Users)
|
|
rows, upErr := h.db.GetCollectionUpNext(c.Request().Context(), database.GetCollectionUpNextParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
ViewerUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
VisibleLibraryIds: visibleIDs,
|
|
Limit: pgtype.Int4{Int32: 10, Valid: true},
|
|
})
|
|
if upErr == nil {
|
|
for _, row := range rows {
|
|
upNext = append(upNext, map[string]interface{}{
|
|
"id": uuid.UUID(row.MediaItems.ID.Bytes).String(),
|
|
"title": row.MediaItems.Title,
|
|
"author": utils.PgTextString(row.MediaItems.Author),
|
|
"cover": utils.PgTextString(row.MediaItems.CoverImagePath),
|
|
"format": row.MediaItems.FormatGroup,
|
|
"progress": row.ViewerPercentage,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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": utils.PgTextString(collection.Description),
|
|
"color": utils.PgTextString(collection.Color),
|
|
"icon": utils.PgTextString(collection.Icon),
|
|
"auto_assign_rules": json.RawMessage(collection.AutoAssignRules),
|
|
"view_settings": viewSettings,
|
|
"books": bookList,
|
|
"book_count": len(bookList),
|
|
"is_smart": isSmart,
|
|
"up_next": upNext,
|
|
"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()})
|
|
}
|
|
|
|
// A2c §3.3: playlist toggles ride the edit form as top-level fields and
|
|
// merge into view_settings server-side (the form can't bind a map).
|
|
// The form posts a hidden "false" sibling before the checkbox, so the
|
|
// LAST value is the checkbox state.
|
|
if v := optionalFlag(req.HideFinished); v != nil {
|
|
if req.ViewSettings == nil {
|
|
req.ViewSettings = map[string]interface{}{}
|
|
}
|
|
req.ViewSettings["hide_finished"] = *v
|
|
}
|
|
if v := optionalFlag(req.ShowUpNext); v != nil {
|
|
if req.ViewSettings == nil {
|
|
req.ViewSettings = map[string]interface{}{}
|
|
}
|
|
req.ViewSettings["show_up_next"] = *v
|
|
}
|
|
|
|
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": utils.PgTextString(collection.Description),
|
|
"color": utils.PgTextString(collection.Color),
|
|
"icon": utils.PgTextString(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 := `<div class="text-green-500">Collection deleted! Redirecting...</div><script>window.location.href = '/collections';</script>`
|
|
return c.HTML(http.StatusOK, html)
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *CollectionHandler) AddBooks(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req AddBooksRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
addedCount := 0
|
|
var addedBookIDs []string
|
|
for _, bookIDStr := range req.BookIDs {
|
|
bookID, err := uuid.Parse(bookIDStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
err = h.collectionService.AddBookToCollection(c.Request().Context(), collectionID, bookID, userUUID)
|
|
if err == nil {
|
|
addedCount++
|
|
addedBookIDs = append(addedBookIDs, bookID.String())
|
|
}
|
|
}
|
|
|
|
if addedCount > 0 && h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"collection_id": collectionID.String(),
|
|
"action": "books_added",
|
|
"book_ids": addedBookIDs,
|
|
"count": addedCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *CollectionHandler) RemoveBook(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
|
|
bookID, err := uuid.Parse(c.Param("bookId"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
|
}
|
|
|
|
// Get user from context for WebSocket broadcast
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Broadcast to user's other devices via WebSocket
|
|
if h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"collection_id": collectionID.String(),
|
|
"action": "book_removed",
|
|
"book_id": bookID.String(),
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
type BulkRemoveBooksRequest struct {
|
|
BookIDs []string `json:"book_ids" validate:"required"`
|
|
}
|
|
|
|
func (h *CollectionHandler) BulkRemoveBooks(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req BulkRemoveBooksRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
removedCount := 0
|
|
var removedBookIDs []string
|
|
for _, bookIDStr := range req.BookIDs {
|
|
bookID, err := uuid.Parse(bookIDStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
err = h.collectionService.RemoveBookFromCollection(c.Request().Context(), collectionID, bookID)
|
|
if err == nil {
|
|
removedCount++
|
|
removedBookIDs = append(removedBookIDs, bookID.String())
|
|
}
|
|
}
|
|
|
|
if removedCount > 0 && h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"collection_id": collectionID.String(),
|
|
"action": "books_removed",
|
|
"book_ids": removedBookIDs,
|
|
"count": removedCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"removed": removedCount,
|
|
"total": len(req.BookIDs),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) GetDeviceMappings(c *echo.Context) error {
|
|
deviceID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid device id"})
|
|
}
|
|
|
|
mappings, err := h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
type MappingResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CollectionID uuid.UUID `json:"collection_id"`
|
|
CollectionName string `json:"collection_name"`
|
|
DeviceShelfName string `json:"device_shelf_name"`
|
|
SyncDirection string `json:"sync_direction"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
response := make([]MappingResponse, 0, len(mappings))
|
|
for _, m := range mappings {
|
|
response = append(response, MappingResponse{
|
|
ID: m.ID.Bytes,
|
|
CollectionID: m.CollectionID.Bytes,
|
|
CollectionName: m.CollectionName,
|
|
DeviceShelfName: utils.PgTextString(m.DeviceShelfName),
|
|
SyncDirection: utils.PgTextString(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": utils.PgTextString(mapping.DeviceShelfName),
|
|
"sync_direction": utils.PgTextString(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": utils.PgTextString(mapping.DeviceShelfName),
|
|
"sync_direction": utils.PgTextString(mapping.SyncDirection),
|
|
"created_at": mapping.CreatedAt.Time.String(),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) DeleteDeviceMapping(c *echo.Context) error {
|
|
mappingID, err := uuid.Parse(c.Param("mappingId"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid mapping id"})
|
|
}
|
|
|
|
err = h.collectionService.DeleteDeviceShelfMapping(c.Request().Context(), mappingID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.NoContent(http.StatusNoContent)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetBookCollections(c *echo.Context) error {
|
|
bookID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"})
|
|
}
|
|
|
|
collections, err := h.collectionService.GetBookCollections(c.Request().Context(), bookID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
type CollectionResponse struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Color string `json:"color"`
|
|
Icon string `json:"icon"`
|
|
}
|
|
|
|
response := make([]CollectionResponse, 0, len(collections))
|
|
for _, col := range collections {
|
|
response = append(response, CollectionResponse{
|
|
ID: col.ID.Bytes,
|
|
Name: col.Name,
|
|
Description: utils.PgTextString(col.Description),
|
|
Color: utils.PgTextString(col.Color),
|
|
Icon: utils.PgTextString(col.Icon),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"book_id": bookID.String(),
|
|
"collections": response,
|
|
"total": len(response),
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollectionsData(c *echo.Context) ([]database.Collections, error) {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollectionData(c *echo.Context, collectionID uuid.UUID) (database.Collections, error) {
|
|
return h.collectionService.GetCollection(c.Request().Context(), collectionID)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetCollectionBooksData(c *echo.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
|
|
return h.collectionService.GetCollectionBooks(c.Request().Context(), collectionID)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetDeviceMappingsData(c *echo.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) {
|
|
return h.collectionService.GetDeviceShelfMappings(c.Request().Context(), deviceID)
|
|
}
|
|
|
|
func (h *CollectionHandler) GetUserCollectionsList(c *echo.Context) ([]database.Collections, error) {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
return h.collectionService.GetUserCollections(c.Request().Context(), userUUID)
|
|
}
|
|
|
|
// userVisibleLibraryIDs loads the requester's visible-library set (the
|
|
// family lens). Smart-collection evaluation funnels every book candidate
|
|
// through it so hidden libraries contribute nothing.
|
|
// userVisibleLibraryIDs adapts the collection endpoints' identity
|
|
// source to the canonical resolver (H4.5).
|
|
func (h *CollectionHandler) userVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
|
user, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user")
|
|
}
|
|
_, ids, err := h.libraryService.VisibleLibrarySet(c.Request().Context(), user.ID)
|
|
return ids, err
|
|
}
|
|
|
|
// UserVisibleLibraryIDs is the exported entry point for the router's
|
|
// frontend page handlers, which resolve the visible-library set before
|
|
// evaluating smart-collection rule matches.
|
|
func (h *CollectionHandler) UserVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) {
|
|
return h.userVisibleLibraryIDs(c)
|
|
}
|
|
|
|
// CollectionService returns the underlying service so the router's
|
|
// frontend handlers can share the exact same evaluation the API uses.
|
|
func (h *CollectionHandler) CollectionService() *services.CollectionService {
|
|
return h.collectionService
|
|
}
|
|
|
|
// rejectSmartMutation returns a 400 for smart collections: membership is
|
|
// derived live from rules, so stored link mutations have nothing to act
|
|
// on. v1 has no hybrid mode — add/remove with both rules and hand-picked
|
|
// books is deliberately unsupported (collection_items.excluded awaits it).
|
|
func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uuid.UUID) *echo.HTTPError {
|
|
collection, err := h.GetCollectionData(c, collectionID)
|
|
if err != nil {
|
|
return echo.NewHTTPError(http.StatusNotFound, "collection not found")
|
|
}
|
|
if h.collectionService.IsSmartCollection(collection.AutoAssignRules) {
|
|
return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type AddRuleRequest struct {
|
|
Field string `json:"field" validate:"required"`
|
|
Operator string `json:"operator" validate:"required"`
|
|
Value string `json:"value" validate:"required"`
|
|
Enabled *bool `json:"enabled"`
|
|
Priority int `json:"priority"`
|
|
}
|
|
|
|
// AddRule handles POST /api/collections/:id/rules — appends one rule to
|
|
// the ruleset. The first rule converts a static collection to smart.
|
|
func (h *CollectionHandler) AddRule(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
|
|
collection, err := h.GetCollectionData(c, collectionID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"})
|
|
}
|
|
if collection.IsSystemCollection.Bool {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"})
|
|
}
|
|
|
|
var req AddRuleRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{
|
|
Field: req.Field,
|
|
Operator: req.Operator,
|
|
Value: req.Value,
|
|
Enabled: req.Enabled,
|
|
Priority: req.Priority,
|
|
})
|
|
if err != nil {
|
|
var ruleErr *services.RuleValidationError
|
|
if errors.As(err, &ruleErr) {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, map[string]interface{}{
|
|
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
"rule": rule,
|
|
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
|
})
|
|
}
|
|
|
|
// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently
|
|
// the enabled toggle; the payload may only carry {"enabled": bool}.
|
|
func (h *CollectionHandler) UpdateRule(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
ruleID := c.Param("ruleId")
|
|
if ruleID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
|
}
|
|
|
|
var req struct {
|
|
Enabled *bool `json:"enabled" validate:"required"`
|
|
}
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "rule not found") {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
|
})
|
|
}
|
|
|
|
// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId.
|
|
func (h *CollectionHandler) RemoveRule(c *echo.Context) error {
|
|
collectionID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"})
|
|
}
|
|
ruleID := c.Param("ruleId")
|
|
if ruleID == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"})
|
|
}
|
|
|
|
updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "rule not found") {
|
|
return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"collection_id": uuid.UUID(updated.ID.Bytes).String(),
|
|
"is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules),
|
|
})
|
|
}
|
|
|
|
type TestRulesRequest struct {
|
|
Rules []map[string]interface{} `json:"rules" validate:"required"`
|
|
}
|
|
|
|
type BookMatch struct {
|
|
MediaItemID string `json:"media_item_id"`
|
|
Title string `json:"title"`
|
|
Author string `json:"author"`
|
|
CoverImagePath string `json:"cover_image_path"`
|
|
MatchReason string `json:"match_reason"`
|
|
}
|
|
|
|
func (h *CollectionHandler) TestRules(c *echo.Context) error {
|
|
var req TestRulesRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"})
|
|
}
|
|
if err := c.Validate(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
// Candidates mirror what smart collections actually evaluate against:
|
|
// the requester's visible libraries and live items only — the preview
|
|
// must not promise matches the live render will never produce.
|
|
visibleIDs, err := h.userVisibleLibraryIDs(c)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
var visibleArg []pgtype.UUID
|
|
if len(visibleIDs) > 0 {
|
|
visibleArg = visibleIDs
|
|
}
|
|
mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{
|
|
LibraryID: pgtype.UUID{},
|
|
VisibleLibraryIds: visibleArg,
|
|
})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"})
|
|
}
|
|
|
|
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.MediaItems, 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.MediaItems, field, operator, value string) bool {
|
|
var itemValue string
|
|
|
|
switch field {
|
|
case "genre":
|
|
if item.Genre.Valid {
|
|
itemValue = item.Genre.String
|
|
}
|
|
case "series":
|
|
if item.Series.Valid {
|
|
itemValue = item.Series.String
|
|
}
|
|
case "author":
|
|
if item.Author.Valid {
|
|
itemValue = item.Author.String
|
|
}
|
|
case "language":
|
|
if item.Language.Valid {
|
|
itemValue = item.Language.String
|
|
}
|
|
case "publisher":
|
|
if item.Publisher.Valid {
|
|
itemValue = item.Publisher.String
|
|
}
|
|
case "copyright_year":
|
|
if item.CopyrightYear.Valid {
|
|
itemValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
|
|
}
|
|
case "tags":
|
|
if len(item.Tags) > 0 {
|
|
itemValue = strings.Join(item.Tags, ", ")
|
|
}
|
|
}
|
|
|
|
return h.compareValues(itemValue, operator, value)
|
|
}
|
|
|
|
func (h *CollectionHandler) compareValues(itemValue, operator, ruleValue string) bool {
|
|
switch operator {
|
|
case "equals":
|
|
return itemValue == ruleValue
|
|
case "not_equals":
|
|
return itemValue != ruleValue
|
|
case "contains":
|
|
return strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
case "not_contains":
|
|
return !strings.Contains(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
case "starts_with":
|
|
return strings.HasPrefix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
case "ends_with":
|
|
return strings.HasSuffix(strings.ToLower(itemValue), strings.ToLower(ruleValue))
|
|
case "greater_than":
|
|
itemNum, err1 := strconv.Atoi(itemValue)
|
|
ruleNum, err2 := strconv.Atoi(ruleValue)
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
return itemNum > ruleNum
|
|
case "less_than":
|
|
itemNum, err1 := strconv.Atoi(itemValue)
|
|
ruleNum, err2 := strconv.Atoi(ruleValue)
|
|
if err1 != nil || err2 != nil {
|
|
return false
|
|
}
|
|
return itemNum < ruleNum
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Bulk Operations
|
|
|
|
// POST /api/collections/bulk-add-books
|
|
// Bulk add books to multiple collections
|
|
func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error {
|
|
user := c.Get("user").(database.Users)
|
|
userUUID := uuid.UUID(user.ID.Bytes)
|
|
|
|
var req struct {
|
|
Operations []struct {
|
|
CollectionID string `json:"collection_id" validate:"required"`
|
|
BookIDs []string `json:"book_ids" validate:"required"`
|
|
} `json:"operations" validate:"required"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
if len(req.Operations) == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"})
|
|
}
|
|
|
|
// Smart collections are rejected up front per operation so the
|
|
// caller sees a clean, actionable error instead of silent no-ops.
|
|
for _, op := range req.Operations {
|
|
if colUUID, err := uuid.Parse(op.CollectionID); err == nil {
|
|
if guardErr := h.rejectSmartMutation(c, colUUID); guardErr != nil {
|
|
return guardErr
|
|
}
|
|
}
|
|
}
|
|
|
|
results := make([]map[string]interface{}, 0)
|
|
successCount := 0
|
|
failedCount := 0
|
|
|
|
for _, op := range req.Operations {
|
|
collectionID, err := uuid.Parse(op.CollectionID)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"status": "error",
|
|
"error": "Invalid collection UUID",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
for _, bookIDStr := range op.BookIDs {
|
|
bookID, err := uuid.Parse(bookIDStr)
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookIDStr,
|
|
"status": "error",
|
|
"error": "Invalid book UUID",
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
collectionUUID := pgtype.UUID{Bytes: collectionID, Valid: true}
|
|
bookUUID := pgtype.UUID{Bytes: bookID, Valid: true}
|
|
|
|
_, err = h.db.AddBookToCollection(c.Request().Context(), database.AddBookToCollectionParams{
|
|
CollectionID: collectionUUID,
|
|
MediaItemID: bookUUID,
|
|
AddedByUserID: pgtype.UUID{Bytes: user.ID.Bytes, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookIDStr,
|
|
"status": "error",
|
|
"error": err.Error(),
|
|
})
|
|
failedCount++
|
|
continue
|
|
}
|
|
|
|
results = append(results, map[string]interface{}{
|
|
"collection_id": op.CollectionID,
|
|
"book_id": bookIDStr,
|
|
"status": "success",
|
|
})
|
|
successCount++
|
|
}
|
|
}
|
|
|
|
if successCount > 0 && h.connManager != nil {
|
|
h.connManager.BroadcastToUser(userUUID.String(), wsync.BroadcastMessage{
|
|
Type: "collection_updated",
|
|
Timestamp: time.Now().Format(time.RFC3339),
|
|
Data: map[string]interface{}{
|
|
"action": "books_added_bulk",
|
|
"count": successCount,
|
|
},
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{
|
|
"results": results,
|
|
"total": len(results),
|
|
"added": successCount,
|
|
"failed": failedCount,
|
|
})
|
|
}
|
|
|
|
func (h *CollectionHandler) PreviewCollection(c *echo.Context) error {
|
|
user := c.Get("user")
|
|
if user == nil {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
}
|
|
|
|
var req struct {
|
|
LibraryID string `json:"library_id"`
|
|
Rules []services.Rule `json:"rules"`
|
|
ManualBookIDs []string `json:"manual_book_ids"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
|
|
}
|
|
|
|
libUUID, err := uuid.Parse(req.LibraryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid library_id"})
|
|
}
|
|
|
|
_, err = h.db.GetLibrary(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "library not found"})
|
|
}
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
if req.Limit <= 0 || req.Limit > 100 {
|
|
req.Limit = 20
|
|
}
|
|
|
|
allItems, err := h.db.GetLibraryItems(c.Request().Context(), pgtype.UUID{Bytes: libUUID, Valid: true})
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load library items"})
|
|
}
|
|
|
|
var matchedItems []database.MediaItems
|
|
for _, item := range allItems {
|
|
evaluations := h.collectionService.EvaluateRules(item, 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 := BookCardsFromMediaItems(matchedItems, LibraryTypeMap(c.Request().Context(), h.db))
|
|
if u, ok := c.Get("user").(database.Users); ok {
|
|
bookCards = MarkActiveConflicts(c.Request().Context(), h.db, u.ID, bookCards)
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]interface{}{"items": bookCards})
|
|
}
|
|
|
|
// ReorderItems applies a static collection's manual playlist order
|
|
// (A2c §3.2). Owner-only; the payload is the COMPLETE ordered id list —
|
|
// ids omitted keep their relative order appended after (the service never
|
|
// deletes membership).
|
|
func (h *CollectionHandler) ReorderItems(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, ok := c.Get("user").(database.Users)
|
|
if !ok {
|
|
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
|
}
|
|
|
|
var req struct {
|
|
ItemIDs []string `json:"item_ids"`
|
|
}
|
|
if err := c.Bind(&req); err != nil || len(req.ItemIDs) == 0 {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "item_ids must be a non-empty ordered id list"})
|
|
}
|
|
|
|
owns, err := h.collectionService.UserOwnsCollection(c.Request().Context(), collectionID, uuid.UUID(user.ID.Bytes))
|
|
if err != nil || !owns {
|
|
return c.JSON(http.StatusForbidden, map[string]string{"error": "only the collection owner can reorder it"})
|
|
}
|
|
|
|
ordered := make([]uuid.UUID, 0, len(req.ItemIDs))
|
|
for _, raw := range req.ItemIDs {
|
|
id, parseErr := uuid.Parse(raw)
|
|
if parseErr != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid media item id: " + raw})
|
|
}
|
|
ordered = append(ordered, id)
|
|
}
|
|
|
|
if err := h.collectionService.ReorderItems(c.Request().Context(), collectionID, ordered); err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
|
}
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "reordered"})
|
|
}
|
|
|
|
// optionalFlag interprets a multi-valued form flag: last value wins
|
|
// (hidden-false sibling precedes the checkbox), absent → nil (untouched).
|
|
func optionalFlag(values []string) *bool {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
v := strings.EqualFold(values[len(values)-1], "true")
|
|
return &v
|
|
}
|