collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it).
1051 lines
34 KiB
Go
1051 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
|
|
}
|
|
}
|
|
}
|
|
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: %v", 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
|
|
}
|