package services import ( "bookhoard/internal/database" "context" "encoding/json" "fmt" "strings" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) // 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 } // 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 } // NewCollectionService creates a new collection service func NewCollectionService(db *database.Queries) *CollectionService { return &CollectionService{ db: db, } } // CreateCollection creates a new collection func (s *CollectionService) CreateCollection(ctx context.Context, userID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (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, }) if err != nil { return database.Collections{}, fmt.Errorf("failed to create collection: %v", 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 } // GetCollectionWithBookCount gets a collection with book count func (s *CollectionService) GetCollectionWithBookCount(ctx context.Context, collectionID uuid.UUID) (map[string]any, error) { collection, err := s.db.GetCollectionWithBookCount(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}) if err != nil { return nil, fmt.Errorf("failed to get collection: %v", err) } return map[string]any{ "id": uuid.UUID(collection.ID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(), "name": collection.Name, "description": collection.Description.String, "color": collection.Color.String, "icon": collection.Icon.String, "auto_assign_rules": collection.AutoAssignRules, "view_settings": collection.ViewSettings, "created_at": collection.CreatedAt.Time.String(), "book_count": collection.BookCount, }, 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 func (s *CollectionService) UpdateCollection(ctx context.Context, collectionID uuid.UUID, name, description, color, icon string, autoAssignRules []Rule, viewSettings map[string]any) (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.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, }) 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 { err := s.db.DeleteCollection(ctx, pgtype.UUID{Bytes: collectionID, Valid: true}) if err != nil { return fmt.Errorf("failed to delete collection: %v", 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 } // EvaluateRules evaluates auto-assign rules for a media item func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow, 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 { eval := RuleEvaluation{ RuleID: rule.ID, Matches: false, } switch rule.Field { case "genre": if mediaItem.Genre.Valid { eval.Matches = s.evaluateRule(mediaItem.Genre.String, rule.Operator, rule.Value) } case "series": if mediaItem.Series.Valid { eval.Matches = s.evaluateRule(mediaItem.Series.String, rule.Operator, rule.Value) } case "author": if mediaItem.Author.Valid { eval.Matches = s.evaluateRule(mediaItem.Author.String, rule.Operator, rule.Value) } case "language": if mediaItem.Language.Valid { eval.Matches = s.evaluateRule(mediaItem.Language.String, rule.Operator, rule.Value) } case "publisher": if mediaItem.Publisher.Valid { eval.Matches = s.evaluateRule(mediaItem.Publisher.String, rule.Operator, rule.Value) } case "copyright_year": if mediaItem.CopyrightYear.Valid { yearStr := fmt.Sprintf("%d", mediaItem.CopyrightYear.Int32) eval.Matches = s.evaluateRule(yearStr, rule.Operator, rule.Value) } case "tags": if len(mediaItem.Tags) > 0 { tagsStr := strings.Join(mediaItem.Tags, ", ") eval.Matches = s.evaluateRule(tagsStr, 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 } // ApplyAutoAssignRules applies auto-assign rules for all collections to a media item func (s *CollectionService) ApplyAutoAssignRules(ctx context.Context, mediaItem database.ListMediaItemsRow, userID uuid.UUID) ([]uuid.UUID, error) { var matchedCollectionIDs []uuid.UUID collections, err := s.GetUserCollections(ctx, userID) if err != nil { return nil, fmt.Errorf("failed to get user collections: %v", err) } for _, collection := range collections { if len(collection.AutoAssignRules) == 0 { continue } var rules []Rule if err := json.Unmarshal(collection.AutoAssignRules, &rules); err != nil { continue } evaluations := s.EvaluateRules(mediaItem, rules) // Check if any rules match for _, eval := range evaluations { if eval.Matches && eval.Confidence > 0.7 { matchedCollectionIDs = append(matchedCollectionIDs, uuid.UUID(collection.ID.Bytes)) break } } } return matchedCollectionIDs, nil } // Helper functions (Go standard library functions) 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 }