feat(services): Add book matching and collection services
- Add book matching service for intelligent book deduplication - Add collection service for collection management - Add test files for book matching and collections
This commit is contained in:
@@ -0,0 +1,522 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bookmann/internal/database"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"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]interface{}) (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]interface{}, 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]interface{}{
|
||||
"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]interface{}) (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 := 0; i < len(sortedRules); i++ {
|
||||
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 mediaItem.Tags.Valid {
|
||||
eval.Matches = s.evaluateRule(mediaItem.Tags.String, rule.Operator, rule.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate confidence based on rule type and priority
|
||||
if eval.Matches {
|
||||
baseConfidence := 0.7
|
||||
if rule.Field == "genre" || rule.Field == "series" {
|
||||
baseConfidence = 0.9
|
||||
} else if rule.Field == "author" || rule.Field == "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 int = 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
|
||||
}
|
||||
Reference in New Issue
Block a user