collections + collection_items join the RLS-governed tables, same doctrine as the media_items family lens (2026-10-29). Policies route through two PURE predicate functions (app_can_read_collection / app_can_write_collection) that never query collections — no policy recursion under FORCE RLS — so a future sharing model is one EXISTS branch inside the functions, not a schema re-touch. Reads: owner or admin; writes: owner, admin escape for SYSTEM collections only. Why: the write path had no app-level ownership gate on Update/Delete/ AddBooks — any authenticated user could mutate another user's collection by id. The DB is now the enforcement of record; existing app-level gates (match-mode, reorder, view-settings) stay as defense-in-depth. Behavior changes (contract_pins 2026-10-31): - cross-user GET detail: 200 -> 404 (row invisible) - cross-user PUT update: silent success -> 404 (ErrNoRows mapping; service wrap fixed %v -> %w so errors.Is sees pgx.ErrNoRows) - cross-user POST books: silent success -> 404 - cross-user DELETE: 204, now a true DB no-op - anonymous sees nothing; admin persona unchanged Bootstrap: registration seeds system collections BEFORE the JWT exists — CreateDefaultCollectionsForUser declares the new user's identity (rls.WithUser), the same fix mirrored in the test harness (createDefaultCollectionsForUser owner-scoped; collection sweeps run under the owner's identity). Tests: new TestCollectionsRLS pins the cross-user matrix end-to-end (404 detail/update/add/view-settings, 204 no-op delete, admin escape, owner positive control, registration seeding under RLS). Harness fixture writes to non-system collections now run as the row's OWNER (scopeExecAs/ownerSeedCtx) — the admin write-escape covers system rows only, and bare-ctx pool reads of collections see nothing. Filter/playlist/audiobook/scan-state fixtures converted. Full container suite green (159s).
1060 lines
34 KiB
Go
1060 lines
34 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
// ErrCollectionNameTaken: the user already has a collection with this name.
|
|
var ErrCollectionNameTaken = errors.New("collection name already exists")
|
|
|
|
// Rule represents an auto-assign rule for collections
|
|
type Rule struct {
|
|
ID string `json:"id"`
|
|
Field string `json:"field"` // "genre", "series", "author", "language", "publisher", "copyright_year", "tags"
|
|
Operator string `json:"operator"` // "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than"
|
|
Value string `json:"value"`
|
|
Priority int `json:"priority"` // 1-10, higher values take precedence
|
|
// Enabled gates the rule in the LIVE evaluation used by smart
|
|
// collections; absent (JSON omitempty) means enabled — rules written
|
|
// before the field existed keep matching.
|
|
Enabled *bool `json:"enabled,omitempty"`
|
|
}
|
|
|
|
// Known rule fields and operators — the rules builder UI and the
|
|
// persisted JSONB documents must agree with this vocabulary.
|
|
// Episode fields (plan item 2.4): feed/downloaded/published_within ride
|
|
// the media row; played reads the VIEWER's progress (the candidate
|
|
// query carries it — SmartEvalOptions) so "unplayed" is per-user.
|
|
var ruleFields = map[string]bool{
|
|
"genre": true, "series": true, "author": true, "language": true,
|
|
"publisher": true, "copyright_year": true, "tags": true,
|
|
// Episode collections (plan item 2.4):
|
|
"title": true, "description": true,
|
|
"feed": true, "played": true, "downloaded": true, "published_within": true,
|
|
}
|
|
|
|
var ruleOperators = map[string]bool{
|
|
"equals": true, "not_equals": true, "contains": true, "not_contains": true,
|
|
"starts_with": true, "ends_with": true, "greater_than": true, "less_than": true,
|
|
"in": true, // multi-value (comma-separated) — the feed multi-select
|
|
}
|
|
|
|
// Smart-collection match semantics (plan item 2.4): 'any' = OR (the
|
|
// legacy behavior), 'all' = AND (the daily briefing).
|
|
const (
|
|
RuleMatchAny = "any"
|
|
RuleMatchAll = "all"
|
|
)
|
|
|
|
func ValidRuleMatchMode(m string) bool {
|
|
return m == RuleMatchAny || m == RuleMatchAll
|
|
}
|
|
|
|
// RuleValidationError reports a rule payload the evaluator could never
|
|
// honor (unknown field/operator, empty value).
|
|
type RuleValidationError struct{ Msg string }
|
|
|
|
func (e *RuleValidationError) Error() string { return e.Msg }
|
|
|
|
// RuleEvaluation represents the result of evaluating a rule
|
|
type RuleEvaluation struct {
|
|
RuleID string `json:"rule_id"`
|
|
Matches bool `json:"matches"`
|
|
Confidence float64 `json:"confidence"`
|
|
}
|
|
|
|
// CollectionService handles collection management
|
|
type CollectionService struct {
|
|
db *database.Queries
|
|
// search powers the unified-engine path (item 3.2): tree-driven
|
|
// smart collections evaluate via ComposedLiveMedia. Optional —
|
|
// nil disables tree evaluation (legacy-only services, which keeps
|
|
// every existing constructor call site valid).
|
|
search *SearchService
|
|
}
|
|
|
|
// NewCollectionService creates a new collection service. search is
|
|
// optional (variadic for constructor compatibility) — required for
|
|
// TREE-driven smart collections (item 3.2), ignored otherwise.
|
|
func NewCollectionService(db *database.Queries, search ...*SearchService) *CollectionService {
|
|
svc := &CollectionService{db: db}
|
|
if len(search) > 0 {
|
|
svc.search = search[0]
|
|
}
|
|
return svc
|
|
}
|
|
|
|
// CreateCollection creates a new collection. filterTree (item 3.2)
|
|
// replaces autoAssignRules as the smart engine's source when present —
|
|
// both may coexist on the row (tree wins at evaluation).
|
|
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, filterTree []byte) (database.Collections, error) {
|
|
// Convert rules to JSONB ([]byte)
|
|
var rulesJSON []byte
|
|
if len(autoAssignRules) > 0 {
|
|
var err error
|
|
rulesJSON, err = json.Marshal(autoAssignRules)
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
|
}
|
|
}
|
|
|
|
// Convert view settings to JSONB ([]byte)
|
|
var settingsJSON []byte
|
|
if len(viewSettings) > 0 {
|
|
var err error
|
|
settingsJSON, err = json.Marshal(viewSettings)
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal view settings: %v", err)
|
|
}
|
|
}
|
|
|
|
collection, err := s.db.CreateCollection(ctx, database.CreateCollectionParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
Name: name,
|
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
|
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
|
AutoAssignRules: rulesJSON,
|
|
ViewSettings: settingsJSON,
|
|
FilterTree: filterTree,
|
|
})
|
|
|
|
if err != nil {
|
|
// Duplicate name (UNIQUE(user_id, name)) is a client error, not a
|
|
// server failure: surface it as 409 so callers can recover.
|
|
var pgErr *pgconn.PgError
|
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
|
return database.Collections{}, ErrCollectionNameTaken
|
|
}
|
|
return database.Collections{}, fmt.Errorf("failed to create collection: %w", err)
|
|
}
|
|
|
|
return collection, nil
|
|
}
|
|
|
|
// GetCollection gets a collection by ID
|
|
func (s *CollectionService) GetCollection(ctx context.Context, collectionID uuid.UUID) (database.Collections, error) {
|
|
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to get collection: %v", err)
|
|
}
|
|
|
|
return collection, nil
|
|
}
|
|
|
|
// GetUserCollections gets all collections for a user
|
|
func (s *CollectionService) GetUserCollections(ctx context.Context, userID uuid.UUID) ([]database.Collections, error) {
|
|
collections, err := s.db.GetCollectionsByUser(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get user collections: %v", err)
|
|
}
|
|
|
|
return collections, nil
|
|
}
|
|
|
|
// UpdateCollection updates a collection
|
|
// UpdateCollection rewrites the collection row. ruleMatchMode empty or
|
|
// invalid = keep the stored value (the edit form only sends the toggles
|
|
// it renders — plan item 2.4). filterTree semantics: nil = leave the
|
|
// stored tree untouched.
|
|
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any, ruleMatchMode string, filterTree []byte) (database.Collections, error) {
|
|
// A2c §3.3: the edit form carries only the toggles it renders — merge
|
|
// them OVER the stored view_settings so provenance keys
|
|
// (imported_from_series) and anything else on the row survive.
|
|
existingMode := RuleMatchAny
|
|
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
|
|
existingMode = existing.RuleMatchMode
|
|
if filterTree == nil {
|
|
filterTree = existing.FilterTree // nil = untouched (forms don't send trees)
|
|
}
|
|
if len(viewSettings) > 0 && len(existing.ViewSettings) > 0 {
|
|
var stored map[string]any
|
|
if json.Unmarshal(existing.ViewSettings, &stored) == nil {
|
|
merged := stored
|
|
for k, v := range viewSettings {
|
|
merged[k] = v
|
|
}
|
|
viewSettings = merged
|
|
}
|
|
}
|
|
// Same convention as filterTree: a NIL view_settings payload
|
|
// means untouched — keep the stored doc. Without this, an edit
|
|
// that carries no settings wrote NULL over the row and silently
|
|
// dropped every stored key (chain_order, imported provenance);
|
|
// the 2026-10-31 chain-order test pinned exactly this clobber.
|
|
if viewSettings == nil && len(existing.ViewSettings) > 0 {
|
|
var stored map[string]any
|
|
if json.Unmarshal(existing.ViewSettings, &stored) == nil && len(stored) > 0 {
|
|
viewSettings = stored
|
|
}
|
|
}
|
|
}
|
|
if !ValidRuleMatchMode(ruleMatchMode) {
|
|
ruleMatchMode = existingMode
|
|
}
|
|
|
|
// Convert rules to JSONB ([]byte)
|
|
var rulesJSON []byte
|
|
if len(autoAssignRules) > 0 {
|
|
var err error
|
|
rulesJSON, err = json.Marshal(autoAssignRules)
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal auto-assign rules: %v", err)
|
|
}
|
|
}
|
|
|
|
// Convert view settings to JSONB ([]byte)
|
|
var settingsJSON []byte
|
|
if len(viewSettings) > 0 {
|
|
var err error
|
|
settingsJSON, err = json.Marshal(viewSettings)
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal view settings: %v", err)
|
|
}
|
|
}
|
|
|
|
collection, err := s.db.UpdateCollection(ctx, database.UpdateCollectionParams{
|
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
Name: name,
|
|
Description: pgtype.Text{String: description, Valid: description != ""},
|
|
Color: pgtype.Text{String: color, Valid: color != ""},
|
|
Icon: pgtype.Text{String: icon, Valid: icon != ""},
|
|
AutoAssignRules: rulesJSON,
|
|
ViewSettings: settingsJSON,
|
|
RuleMatchMode: ruleMatchMode,
|
|
FilterTree: filterTree,
|
|
})
|
|
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to update collection: %w", err)
|
|
}
|
|
|
|
return collection, nil
|
|
}
|
|
|
|
// DeleteCollection deletes a collection
|
|
func (s *CollectionService) DeleteCollection(ctx context.Context, collectionID uuid.UUID) error {
|
|
// System collections (the 5 dashboard sections) are PERMANENT: a
|
|
// delete tombstones the row (invisible everywhere, restorable with
|
|
// the same id) instead of destroying it and every reference to it.
|
|
row, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil // already gone: keep delete idempotent
|
|
}
|
|
return fmt.Errorf("failed to fetch collection: %w", err)
|
|
}
|
|
|
|
if row.IsSystemCollection.Bool {
|
|
return s.db.TombstoneSystemCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
}
|
|
|
|
err = s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete collection: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// AddBookToCollection adds a book to a collection
|
|
func (s *CollectionService) AddBookToCollection(ctx context.Context, collectionID, mediaItemID, userID uuid.UUID) error {
|
|
_, err := s.db.AddBookToCollection(ctx, database.AddBookToCollectionParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
AddedByUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to add book to collection: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveBookFromCollection removes a book from a collection
|
|
func (s *CollectionService) RemoveBookFromCollection(ctx context.Context, collectionID, mediaItemID uuid.UUID) error {
|
|
err := s.db.RemoveBookFromCollection(ctx, database.RemoveBookFromCollectionParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
MediaItemID: pgtype.UUID{Bytes: mediaItemID, Valid: true},
|
|
})
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove book from collection: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetCollectionBooks gets all books in a collection
|
|
func (s *CollectionService) GetCollectionBooks(ctx context.Context, collectionID uuid.UUID) ([]database.GetCollectionItemsRow, error) {
|
|
books, err := s.db.GetCollectionItems(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get collection books: %v", err)
|
|
}
|
|
|
|
return books, nil
|
|
}
|
|
|
|
// GetBookCollections gets all collections that contain a specific book
|
|
func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID uuid.UUID) ([]database.Collections, error) {
|
|
collections, err := s.db.GetCollectionsForBook(ctx, pgtype.UUID{Bytes: mediaItemID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get book collections: %v", err)
|
|
}
|
|
|
|
return collections, nil
|
|
}
|
|
|
|
// ParseRules decodes a collection's auto_assign_rules JSONB into typed
|
|
// rules. "null", empty, and malformed docs mean "no rules".
|
|
func (s *CollectionService) ParseRules(raw []byte) []Rule {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var rules []Rule
|
|
if err := json.Unmarshal(raw, &rules); err != nil {
|
|
return nil
|
|
}
|
|
return rules
|
|
}
|
|
|
|
// IsSmartCollection derives the collection type: a non-empty ruleset
|
|
// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC
|
|
// (stored collection_items links).
|
|
func (s *CollectionService) IsSmartCollection(raw []byte) bool {
|
|
return len(s.ParseRules(raw)) > 0
|
|
}
|
|
|
|
// EnabledRules filters a ruleset down to the rules that participate in
|
|
// live evaluation (absent enabled = enabled for backward compatibility).
|
|
func (s *CollectionService) EnabledRules(rules []Rule) []Rule {
|
|
enabled := make([]Rule, 0, len(rules))
|
|
for _, rule := range rules {
|
|
if rule.Enabled == nil || *rule.Enabled {
|
|
enabled = append(enabled, rule)
|
|
}
|
|
}
|
|
return enabled
|
|
}
|
|
|
|
// validateRule checks a rule payload the evaluator will have to honor.
|
|
func (s *CollectionService) validateRule(rule Rule) error {
|
|
if !ruleFields[rule.Field] {
|
|
return &RuleValidationError{Msg: "unknown rule field: " + rule.Field}
|
|
}
|
|
if !ruleOperators[rule.Operator] {
|
|
return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator}
|
|
}
|
|
if strings.TrimSpace(rule.Value) == "" {
|
|
return &RuleValidationError{Msg: "rule value is required"}
|
|
}
|
|
if rule.Priority < 0 || rule.Priority > 10 {
|
|
return &RuleValidationError{Msg: "priority must be between 0 and 10"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AddRule appends a rule to the collection's ruleset, generating an id
|
|
// when the caller did not supply one. Adding the first rule CONVERTS a
|
|
// static collection to smart (type is derived from rules presence).
|
|
func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) {
|
|
if err := s.validateRule(rule); err != nil {
|
|
return database.Collections{}, Rule{}, err
|
|
}
|
|
|
|
collection, err := s.GetCollection(ctx, collectionID)
|
|
if err != nil {
|
|
return database.Collections{}, Rule{}, err
|
|
}
|
|
|
|
rules := s.ParseRules(collection.AutoAssignRules)
|
|
if rule.ID == "" {
|
|
rule.ID = uuid.NewString()
|
|
}
|
|
rules = append(rules, rule)
|
|
|
|
rulesJSON, marshalErr := json.Marshal(rules)
|
|
if marshalErr != nil {
|
|
return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
|
}
|
|
|
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
AutoAssignRules: rulesJSON,
|
|
})
|
|
if err != nil {
|
|
return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err)
|
|
}
|
|
return updated, rule, nil
|
|
}
|
|
|
|
// SetRuleEnabled flips a single rule's enabled flag in place.
|
|
func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) {
|
|
collection, err := s.GetCollection(ctx, collectionID)
|
|
if err != nil {
|
|
return database.Collections{}, err
|
|
}
|
|
rules := s.ParseRules(collection.AutoAssignRules)
|
|
found := false
|
|
for i := range rules {
|
|
if rules[i].ID == ruleID {
|
|
rules[i].Enabled = &enabled
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
|
}
|
|
|
|
rulesJSON, marshalErr := json.Marshal(rules)
|
|
if marshalErr != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
|
}
|
|
|
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
AutoAssignRules: rulesJSON,
|
|
})
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// RemoveRule deletes a single rule from the ruleset. Removing the LAST
|
|
// rule converts the collection back to static (type is derived).
|
|
func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) {
|
|
collection, err := s.GetCollection(ctx, collectionID)
|
|
if err != nil {
|
|
return database.Collections{}, err
|
|
}
|
|
rules := s.ParseRules(collection.AutoAssignRules)
|
|
kept := make([]Rule, 0, len(rules))
|
|
found := false
|
|
for _, rule := range rules {
|
|
if rule.ID == ruleID {
|
|
found = true
|
|
continue
|
|
}
|
|
kept = append(kept, rule)
|
|
}
|
|
if !found {
|
|
return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID)
|
|
}
|
|
|
|
rulesJSON, marshalErr := json.Marshal(kept)
|
|
if marshalErr != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr)
|
|
}
|
|
|
|
updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{
|
|
ID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
AutoAssignRules: rulesJSON,
|
|
})
|
|
if err != nil {
|
|
return database.Collections{}, fmt.Errorf("failed to save rules: %w", err)
|
|
}
|
|
return updated, nil
|
|
}
|
|
|
|
// SmartEvalOptions scopes a smart collection's live evaluation.
|
|
type SmartEvalOptions struct {
|
|
// LibraryID, when valid, restricts matching to one library (the
|
|
// detail page's library switcher).
|
|
LibraryID pgtype.UUID
|
|
// VisibleIDs is the requesting user's visible-library set (family
|
|
// lens). Empty means no visible-library filter.
|
|
VisibleIDs []pgtype.UUID
|
|
// MatchMode is the collection's rule_match_mode: "any" (OR —
|
|
// legacy default) or "all" (AND — the daily briefing). Empty
|
|
// resolves to "any".
|
|
MatchMode string
|
|
// ViewerUserID scopes the candidate query's progress join — the
|
|
// played/unplayed rule is per-user. Zero UUID = no viewer (played
|
|
// rules then see "unplayed" for everything, honestly).
|
|
ViewerUserID pgtype.UUID
|
|
}
|
|
|
|
// IsCollectionSmart is the ONE smart detection: a collection is smart
|
|
// when it carries the unified filter tree (item 3.2) or a legacy rules
|
|
// array (item 2's shape). Every surface routes through this — rules
|
|
// presence alone misses tree collections.
|
|
func IsCollectionSmart(coll database.Collections) bool {
|
|
if len(coll.FilterTree) > 0 {
|
|
return true
|
|
}
|
|
var rules []Rule
|
|
if err := json.Unmarshal(coll.AutoAssignRules, &rules); err != nil {
|
|
return false
|
|
}
|
|
return len(rules) > 0
|
|
}
|
|
|
|
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
|
|
// tree-bearing collections evaluate through the unified filter engine;
|
|
// legacy rule arrays keep per-item evaluation until rewritten. One
|
|
// call site surface, two storage epochs.
|
|
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
|
if len(coll.FilterTree) > 0 {
|
|
return s.evaluateTreeMembership(ctx, coll, opts)
|
|
}
|
|
return s.EvaluateSmartCollection(ctx, s.ParseRules(coll.AutoAssignRules), opts)
|
|
}
|
|
|
|
// RulesToFilterTree converts a legacy rules array + its match mode
|
|
// into a qf tree so a collection upgrades to the unified engine at its
|
|
// first edit (item 3.2): zero-rewrite back-compat at read, one-way
|
|
// upgrade at write. Episode rules map mechanically (feed→in-list,
|
|
// played/downloaded→booleans, published_within→published_date
|
|
// within_days); legacy operators without an engine equivalent
|
|
// (starts_with/ends_with) fall back to contains — documented, and the
|
|
// operator labels stay in the rules docs.
|
|
func RulesToFilterTree(rules []Rule, matchMode string) (*FilterTree, error) {
|
|
children := make([]FilterChild, 0, len(rules))
|
|
scope := ""
|
|
for _, r := range rules {
|
|
child, err := ruleToFilterChild(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if episodeOnlyFields[r.Field] {
|
|
scope = ScopeEpisodes
|
|
}
|
|
children = append(children, child)
|
|
}
|
|
if len(children) == 0 {
|
|
return nil, fmt.Errorf("no rules to convert")
|
|
}
|
|
mode := RuleMatchAny
|
|
if matchMode == RuleMatchAll {
|
|
mode = RuleMatchAll
|
|
}
|
|
return &FilterTree{Scope: scope, Mode: mode, Children: children}, nil
|
|
}
|
|
|
|
func ruleToFilterChild(r Rule) (FilterChild, error) {
|
|
child := FilterChild{Field: r.Field}
|
|
switch r.Field {
|
|
case "feed":
|
|
// The rules format stores comma-separated feed IDs in one value.
|
|
child.Op = OpIn
|
|
child.Values = splitRuleValues(r.Value)
|
|
return child, nil
|
|
case "downloaded":
|
|
child.Op = isTrueFalse(r.Value == "true")
|
|
return child, nil
|
|
case "played":
|
|
child.Op = isTrueFalse(r.Value == "true")
|
|
return child, nil
|
|
case "published_within":
|
|
child.Field = "published_date"
|
|
child.Op = OpWithinDays
|
|
child.Values = []string{r.Value}
|
|
return child, nil
|
|
}
|
|
// Registry fields: legacy operators → engine operators.
|
|
switch r.Operator {
|
|
case "equals":
|
|
child.Op = OpIs
|
|
case "not_equals":
|
|
child.Op = OpIsNot
|
|
case "contains":
|
|
child.Op = OpContains
|
|
case "not_contains":
|
|
child.Op = OpNotContains
|
|
case "greater_than":
|
|
child.Op = OpAtLeast
|
|
case "less_than":
|
|
child.Op = OpAtMost
|
|
case "in":
|
|
child.Op = OpIn
|
|
child.Values = splitRuleValues(r.Value)
|
|
case "starts_with", "ends_with":
|
|
// No engine equivalent — contains is the honest nearest.
|
|
child.Op = OpContains
|
|
default:
|
|
return FilterChild{}, fmt.Errorf("legacy rule operator %q has no filter-engine equivalent", r.Operator)
|
|
}
|
|
child.Values = []string{r.Value}
|
|
return child, nil
|
|
}
|
|
|
|
func isTrueFalse(b bool) string {
|
|
if b {
|
|
return OpIsTrue
|
|
}
|
|
return OpIsFalse
|
|
}
|
|
|
|
func splitRuleValues(v string) []string {
|
|
parts := strings.Split(v, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *CollectionService) evaluateTreeMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
|
tree, err := ParseFilterTree(string(coll.FilterTree))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("collection %s has an invalid filter tree: %v", coll.ID, err)
|
|
}
|
|
if s.search == nil {
|
|
return nil, errors.New("tree collections require the search service (constructor wiring)")
|
|
}
|
|
return s.search.ComposedLiveMedia(ctx, opts.ViewerUserID, opts.LibraryID, opts.VisibleIDs, tree)
|
|
}
|
|
|
|
// EvaluateSmartCollection resolves a smart collection's membership LIVE:
|
|
// every visible live book matched by at least one enabled rule is in.
|
|
// No stored links are consulted — "truly dynamic," always current.
|
|
func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
|
enabled := s.EnabledRules(rules)
|
|
if len(enabled) == 0 {
|
|
return []database.MediaItems{}, nil
|
|
}
|
|
|
|
var visibleArg []pgtype.UUID
|
|
if len(opts.VisibleIDs) > 0 {
|
|
visibleArg = opts.VisibleIDs
|
|
}
|
|
items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{
|
|
LibraryID: opts.LibraryID,
|
|
VisibleLibraryIds: visibleArg,
|
|
ViewerUserID: opts.ViewerUserID,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load candidate books: %w", err)
|
|
}
|
|
|
|
mode := opts.MatchMode
|
|
if !ValidRuleMatchMode(mode) {
|
|
mode = RuleMatchAny
|
|
}
|
|
matches := make([]database.MediaItems, 0, len(items))
|
|
for _, row := range items {
|
|
if s.mediaItemMatches(mode, row.MediaItems, row.ViewerPercentage, enabled) {
|
|
matches = append(matches, row.MediaItems)
|
|
}
|
|
}
|
|
return matches, nil
|
|
}
|
|
|
|
// mediaItemMatches evaluates the enabled rules under the collection's
|
|
// match mode: "any" (OR — legacy) or "all" (AND — the briefing).
|
|
// viewerPercentage is the requesting user's progress for this item
|
|
// (0 when none) — the played/unplayed episode rule reads it.
|
|
func (s *CollectionService) mediaItemMatches(mode string, item database.MediaItems, viewerPercentage float64, rules []Rule) bool {
|
|
if len(rules) == 0 {
|
|
return false
|
|
}
|
|
if mode == RuleMatchAll {
|
|
for _, rule := range rules {
|
|
if !s.mediaItemMatchesRule(item, viewerPercentage, rule) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
for _, rule := range rules {
|
|
if s.mediaItemMatchesRule(item, viewerPercentage, rule) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// mediaItemMatchesAnyRule is the legacy OR path, kept for callers that
|
|
// predate match modes.
|
|
func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool {
|
|
return s.mediaItemMatches(RuleMatchAny, item, 0, rules)
|
|
}
|
|
|
|
// episodeFinished is THE canonical finished definition (same predicate
|
|
// as hide_finished): viewer progress percentage >= 0.999.
|
|
func episodeFinished(viewerPercentage float64) bool {
|
|
return viewerPercentage >= 0.999
|
|
}
|
|
|
|
// mediaItemMatchesRule evaluates one rule against one item.
|
|
func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, viewerPercentage float64, rule Rule) bool {
|
|
if rule.Enabled != nil && !*rule.Enabled {
|
|
return false
|
|
}
|
|
return s.FieldMatches(item, viewerPercentage, rule.Field, rule.Operator, rule.Value)
|
|
}
|
|
|
|
// FieldMatches is THE single rule evaluator (the rules-builder preview
|
|
// delegates here too — one vocabulary, one semantics). Episode/user-state
|
|
// fields (feed/played/downloaded/published_within) evaluate directly;
|
|
// everything else extracts a string and hits the generic comparator.
|
|
func (s *CollectionService) FieldMatches(item database.MediaItems, viewerPercentage float64, field, operator, value string) bool {
|
|
switch field {
|
|
case "feed":
|
|
if !item.SourceFeedID.Valid {
|
|
return false
|
|
}
|
|
feedID := uuid.UUID(item.SourceFeedID.Bytes).String()
|
|
for _, raw := range strings.Split(value, ",") {
|
|
if strings.TrimSpace(raw) == feedID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
case "played":
|
|
wantPlayed := value == "true"
|
|
return episodeFinished(viewerPercentage) == wantPlayed
|
|
case "downloaded":
|
|
wantDownloaded := value == "true"
|
|
return (item.DownloadState == "downloaded") == wantDownloaded
|
|
case "published_within":
|
|
if !item.DatePublished.Valid {
|
|
return false
|
|
}
|
|
days, err := strconv.Atoi(strings.TrimSpace(value))
|
|
if err != nil || days < 0 {
|
|
return false
|
|
}
|
|
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
|
return item.DatePublished.Time.After(cutoff)
|
|
}
|
|
|
|
var fieldValue string
|
|
switch field {
|
|
case "title":
|
|
fieldValue = item.Title
|
|
case "description":
|
|
if item.Description.Valid {
|
|
fieldValue = item.Description.String
|
|
}
|
|
case "genre":
|
|
if item.Genre.Valid {
|
|
fieldValue = item.Genre.String
|
|
}
|
|
case "series":
|
|
if item.Series.Valid {
|
|
fieldValue = item.Series.String
|
|
}
|
|
case "author":
|
|
if item.Author.Valid {
|
|
fieldValue = item.Author.String
|
|
}
|
|
case "language":
|
|
if item.Language.Valid {
|
|
fieldValue = item.Language.String
|
|
}
|
|
case "publisher":
|
|
if item.Publisher.Valid {
|
|
fieldValue = item.Publisher.String
|
|
}
|
|
case "copyright_year":
|
|
if item.CopyrightYear.Valid {
|
|
fieldValue = fmt.Sprintf("%d", item.CopyrightYear.Int32)
|
|
}
|
|
case "tags":
|
|
if len(item.Tags) > 0 {
|
|
fieldValue = strings.Join(item.Tags, ", ")
|
|
}
|
|
}
|
|
|
|
return s.evaluateRule(fieldValue, operator, value)
|
|
}
|
|
|
|
// EvaluateRules evaluates auto-assign rules for a media item — the
|
|
// rules-builder preview's per-rule view. viewerPercentage is the
|
|
// requesting user's progress for this item (0 = none), so played /
|
|
// unplayed rules preview honestly (plan item 2.4).
|
|
func (s *CollectionService) EvaluateRules(mediaItem database.MediaItems, viewerPercentage float64, rules []Rule) []RuleEvaluation {
|
|
var evaluations []RuleEvaluation
|
|
|
|
// Sort rules by priority (higher priority first)
|
|
sortedRules := make([]Rule, len(rules))
|
|
copy(sortedRules, rules)
|
|
for i := range len(sortedRules) {
|
|
for j := i + 1; j < len(sortedRules); j++ {
|
|
if sortedRules[i].Priority < sortedRules[j].Priority {
|
|
sortedRules[i], sortedRules[j] = sortedRules[j], sortedRules[i]
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, rule := range sortedRules {
|
|
// Disabled rules never match (kept for context in the UI only).
|
|
if rule.Enabled != nil && !*rule.Enabled {
|
|
evaluations = append(evaluations, RuleEvaluation{RuleID: rule.ID, Matches: false})
|
|
continue
|
|
}
|
|
eval := RuleEvaluation{
|
|
RuleID: rule.ID,
|
|
Matches: s.FieldMatches(mediaItem, viewerPercentage, rule.Field, rule.Operator, rule.Value),
|
|
}
|
|
|
|
// Calculate confidence based on rule type and priority
|
|
if eval.Matches {
|
|
baseConfidence := 0.7
|
|
switch rule.Field {
|
|
case "genre", "series":
|
|
baseConfidence = 0.9
|
|
case "author", "publisher":
|
|
baseConfidence = 0.8
|
|
}
|
|
// Add priority boost (0.01 per priority point, max 0.1)
|
|
priorityBoost := float64(rule.Priority) * 0.01
|
|
if priorityBoost > 0.1 {
|
|
priorityBoost = 0.1
|
|
}
|
|
eval.Confidence = baseConfidence + priorityBoost
|
|
if eval.Confidence > 1.0 {
|
|
eval.Confidence = 1.0
|
|
}
|
|
}
|
|
|
|
evaluations = append(evaluations, eval)
|
|
}
|
|
|
|
return evaluations
|
|
}
|
|
|
|
// evaluateRule evaluates a single rule
|
|
func (s *CollectionService) evaluateRule(fieldValue, operator, ruleValue string) bool {
|
|
switch operator {
|
|
case "equals":
|
|
return containsIgnoreCase(fieldValue, ruleValue) && len(fieldValue) == len(ruleValue)
|
|
case "contains":
|
|
return containsIgnoreCase(fieldValue, ruleValue)
|
|
case "starts_with":
|
|
return hasPrefixIgnoreCase(fieldValue, ruleValue)
|
|
case "ends_with":
|
|
return hasSuffixIgnoreCase(fieldValue, ruleValue)
|
|
case "greater_than":
|
|
return compareNumbers(fieldValue, ruleValue, ">")
|
|
case "less_than":
|
|
return compareNumbers(fieldValue, ruleValue, "<")
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// containsIgnoreCase checks if a string contains another (case-insensitive)
|
|
func containsIgnoreCase(s, substr string) bool {
|
|
s = toLower(s)
|
|
substr = toLower(substr)
|
|
return contains(s, substr)
|
|
}
|
|
|
|
// hasPrefixIgnoreCase checks if a string starts with a prefix (case-insensitive)
|
|
func hasPrefixIgnoreCase(s, prefix string) bool {
|
|
s = toLower(s)
|
|
prefix = toLower(prefix)
|
|
return hasPrefix(s, prefix)
|
|
}
|
|
|
|
// hasSuffixIgnoreCase checks if a string ends with a suffix (case-insensitive)
|
|
func hasSuffixIgnoreCase(s, suffix string) bool {
|
|
s = toLower(s)
|
|
suffix = toLower(suffix)
|
|
return hasSuffix(s, suffix)
|
|
}
|
|
|
|
// compareNumbers compares two numeric strings
|
|
func compareNumbers(a, b, op string) bool {
|
|
aFloat, errA := parseFloat(a)
|
|
bFloat, errB := parseFloat(b)
|
|
|
|
if errA != nil || errB != nil {
|
|
return false
|
|
}
|
|
|
|
switch op {
|
|
case ">":
|
|
return aFloat > bFloat
|
|
case "<":
|
|
return aFloat < bFloat
|
|
case ">=":
|
|
return aFloat >= bFloat
|
|
case "<=":
|
|
return aFloat <= bFloat
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// CreateDeviceShelfMapping creates a shelf mapping for a device
|
|
func (s *CollectionService) CreateDeviceShelfMapping(ctx context.Context, collectionID, deviceID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
|
|
mapping, err := s.db.CreateDeviceShelfMapping(ctx, database.CreateDeviceShelfMappingParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true},
|
|
DeviceShelfName: pgtype.Text{String: deviceShelfName, Valid: deviceShelfName != ""},
|
|
SyncDirection: pgtype.Text{String: syncDirection, Valid: syncDirection != ""},
|
|
})
|
|
|
|
if err != nil {
|
|
return database.DeviceShelfMappings{}, fmt.Errorf("failed to create shelf mapping: %v", err)
|
|
}
|
|
|
|
return mapping, nil
|
|
}
|
|
|
|
// GetDeviceShelfMappings gets all shelf mappings for a device
|
|
func (s *CollectionService) GetDeviceShelfMappings(ctx context.Context, deviceID uuid.UUID) ([]database.GetDeviceShelfMappingsRow, error) {
|
|
mappings, err := s.db.GetDeviceShelfMappings(ctx, pgtype.UUID{Bytes: deviceID, Valid: true})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get shelf mappings: %v", err)
|
|
}
|
|
|
|
return mappings, nil
|
|
}
|
|
|
|
// UpdateDeviceShelfMapping updates a shelf mapping
|
|
func (s *CollectionService) UpdateDeviceShelfMapping(ctx context.Context, mappingID uuid.UUID, deviceShelfName, syncDirection string) (database.DeviceShelfMappings, error) {
|
|
mapping, err := s.db.UpdateDeviceShelfMapping(ctx, database.UpdateDeviceShelfMappingParams{
|
|
ID: pgtype.UUID{Bytes: mappingID, Valid: true},
|
|
DeviceShelfName: pgtype.Text{String: deviceShelfName, Valid: deviceShelfName != ""},
|
|
SyncDirection: pgtype.Text{String: syncDirection, Valid: syncDirection != ""},
|
|
})
|
|
|
|
if err != nil {
|
|
return database.DeviceShelfMappings{}, fmt.Errorf("failed to update shelf mapping: %v", err)
|
|
}
|
|
|
|
return mapping, nil
|
|
}
|
|
|
|
// DeleteDeviceShelfMapping deletes a shelf mapping
|
|
func (s *CollectionService) DeleteDeviceShelfMapping(ctx context.Context, mappingID uuid.UUID) error {
|
|
err := s.db.DeleteDeviceShelfMapping(ctx, pgtype.UUID{Bytes: mappingID, Valid: true})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to delete shelf mapping: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func toLower(s string) string {
|
|
if len(s) == 0 {
|
|
return s
|
|
}
|
|
|
|
result := make([]byte, len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if c >= 'A' && c <= 'Z' {
|
|
result[i] = c + 32
|
|
} else {
|
|
result[i] = c
|
|
}
|
|
}
|
|
return string(result)
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && indexOf(s, substr) >= 0
|
|
}
|
|
|
|
func hasPrefix(s, prefix string) bool {
|
|
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
|
}
|
|
|
|
func hasSuffix(s, suffix string) bool {
|
|
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
|
|
}
|
|
|
|
func indexOf(s, substr string) int {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func parseFloat(s string) (float64, error) {
|
|
var result float64
|
|
var sign float64 = 1
|
|
var divisor float64 = 1
|
|
var decimalPlaces = 0
|
|
var seenDecimal bool
|
|
|
|
// Skip leading whitespace
|
|
start := 0
|
|
for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n') {
|
|
start++
|
|
}
|
|
|
|
// Handle sign
|
|
if start < len(s) && s[start] == '-' {
|
|
sign = -1
|
|
start++
|
|
} else if start < len(s) && s[start] == '+' {
|
|
start++
|
|
}
|
|
|
|
// Parse digits
|
|
for i := start; i < len(s); i++ {
|
|
c := s[i]
|
|
if c >= '0' && c <= '9' {
|
|
result = result*10 + float64(c-'0')
|
|
if seenDecimal {
|
|
decimalPlaces++
|
|
divisor *= 10
|
|
}
|
|
} else if c == '.' && !seenDecimal {
|
|
seenDecimal = true
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
return sign * result / divisor, nil
|
|
}
|
|
|
|
// UserOwnsCollection gates playlist-ordering writes (A2c §3.2): the
|
|
// collection row's owner is the only user whose drag actions rewrite
|
|
// positions.
|
|
func (s *CollectionService) UserOwnsCollection(ctx context.Context, collectionID, userID uuid.UUID) (bool, error) {
|
|
collection, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true})
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return uuid.UUID(collection.UserID.Bytes) == userID, nil
|
|
}
|
|
|
|
// ReorderItems applies a full playlist order (A2c §3.2): the payload is the
|
|
// complete ordered id list; positions become 1..n in payload order. Ids
|
|
// not in the payload keep their relative order after — the SQL only
|
|
// touches the given ids, and NULL positions sort last. Never deletes.
|
|
func (s *CollectionService) ReorderItems(ctx context.Context, collectionID uuid.UUID, orderedIDs []uuid.UUID) error {
|
|
if len(orderedIDs) == 0 {
|
|
return nil
|
|
}
|
|
ids := make([]pgtype.UUID, 0, len(orderedIDs))
|
|
for _, id := range orderedIDs {
|
|
ids = append(ids, pgtype.UUID{Bytes: id, Valid: true})
|
|
}
|
|
if err := s.db.ReorderCollectionItems(ctx, database.ReorderCollectionItemsParams{
|
|
CollectionID: pgtype.UUID{Bytes: collectionID, Valid: true},
|
|
MediaItemIds: ids,
|
|
}); err != nil {
|
|
return fmt.Errorf("failed to reorder collection items: %v", err)
|
|
}
|
|
return nil
|
|
}
|