Archaeology, prompted by the user asking when it broke: the builder's save NEVER worked. The page was born (2026-02-19,a92bf99a) posting auto_assign_rules as a JSON-encoded STRING into an endpoint that has bound []Rule since its creation (2026-01-31,6495cc2c), plus fields (manual_book_ids, match_type, library_id, show_on_dashboard) the endpoint never accepted. The preview button hit a real endpoint, so the page looked alive while Save always failed at request binding. Verified live before this change: the page's exact payload 400'd "invalid request" against the unmodified server. The fix adapts the builder's payload at write time — the legacy rules vocabulary converts to a filter tree (RulesToFilterTree restored from the retirement commit as the single write path, unit test restored with it): - match_type seeds the converted tree's root mode (any default). - manual_book_ids attach to the created collection after create (unparsable ids skipped — the AddBooks leniency). - show_on_dashboard persists (new create field). - Converted trees validate under ParseFilterTree: an unhonorable rule is a 400 at write naming the problem, never a stored 500 at render. Dashboard sections: smart user sections now evaluate their tree LIVE — GetUserCollectionItems merges stored membership with the composed engine's matches (deduped, library-scoped, capped at the section limit). This exposed a REAL production wiring bug: every production NewDashboardService call omitted the search service, so tree evaluation through the dashboard could never have worked (the test harness wired it correctly — the gap was production-only and latent, because nothing in the dashboard evaluated trees before this migration). The dashboard handler and both inline sites now receive the search service, and evaluation failures log instead of silently emptying a section. Supersedes the same-day loud-400 stance for the builder's payload (contract_pins migration entry); custom-section-builder.md documents the save contract; the create_collection API doc gains the adapted fields. New TestCustomSectionBuilderSave/BadRule pin the payload → tree → dashboard-render path end to end. Full container suite green (155s).
779 lines
26 KiB
Go
779 lines
26 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"`
|
|
}
|
|
|
|
// 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), when
|
|
// present, makes it smart — the qf engine is the only membership source
|
|
// (the legacy rules-array storage retired 2026-10-31).
|
|
func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, showOnDashboard bool, filterTree []byte) (database.Collections, error) {
|
|
// 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 != ""},
|
|
ViewSettings: settingsJSON,
|
|
FilterTree: filterTree,
|
|
ShowOnDashboard: pgtype.Bool{Bool: showOnDashboard, Valid: true},
|
|
})
|
|
|
|
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 rewrites the collection row. filterTree semantics:
|
|
// nil = leave the stored tree untouched (forms don't send trees).
|
|
func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, viewSettings map[string]any, 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.
|
|
if existing, err := s.db.GetCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}); err == nil {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
// 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 != ""},
|
|
ViewSettings: settingsJSON,
|
|
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
|
|
} // EnabledRules filters a ruleset down to the rules that participate in// validateRule checks a rule payload the evaluator will have to honor.// AddRule appends a rule to the collection's ruleset, generating an id// SetRuleEnabled flips a single rule's enabled flag in place.// RemoveRule deletes a single rule from the ruleset. Removing the LAST// 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
|
|
// 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). Every surface
|
|
// routes through this. (The legacy rules-array branch retired
|
|
// 2026-10-31 — the tree is the only smart storage.)
|
|
func IsCollectionSmart(coll database.Collections) bool {
|
|
return len(coll.FilterTree) > 0
|
|
}
|
|
|
|
// Search exposes the composed search engine the service was built with
|
|
// (nil when the constructor omitted it) — callers composing secondary
|
|
// services that evaluate trees (the dashboard service) pass it through.
|
|
func (s *CollectionService) Search() *SearchService { return s.search }
|
|
|
|
// IsCollectionSmartColl is IsCollectionSmart on the service handle —
|
|
// the dashboard service composes this service and calls the detector
|
|
// through it (no import cycle, one detection definition).
|
|
func (s *CollectionService) IsCollectionSmartColl(coll database.Collections) bool {
|
|
return IsCollectionSmart(coll)
|
|
}
|
|
|
|
// EvaluateSmartMembership is THE single smart evaluator (item 3.2):
|
|
// tree-bearing collections evaluate through the unified filter engine.
|
|
func (s *CollectionService) EvaluateSmartMembership(ctx context.Context, coll database.Collections, opts SmartEvalOptions) ([]database.MediaItems, error) {
|
|
return s.evaluateTreeMembership(ctx, coll, opts)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
|
|
// ── Custom-section-builder adapter (2026-10-31) ──────────────────
|
|
// The builder posts the legacy rules vocabulary; these convert it to a
|
|
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
|
|
// (The 2026-10-31 retirement deleted these as READ-time conversion;
|
|
// the builder's tree migration revived them as the single write path.)
|
|
|
|
// The builder's match_type select: "any" (OR) | "all" (AND).
|
|
const (
|
|
RuleMatchAny = "any"
|
|
RuleMatchAll = "all"
|
|
)
|
|
|
|
// The builder posts the legacy rules vocabulary; these convert it to a
|
|
// qf tree at WRITE time — one-way upgrade, storage stays tree-only.
|
|
// (The 2026-10-31 retirement deleted these as READ-time conversion;
|
|
// the builder's tree migration revived them as the single write path.)
|
|
|
|
// 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) 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
|
|
}
|