From 964a4583ab3d5eb9b80f0b53cd75e97248d4fa33 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 31 Jan 2026 22:32:28 -0500 Subject: [PATCH] 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 --- internal/services/book_matching.go | 373 +++++++++++++ internal/services/book_matching_test.go | 366 ++++++++++++ internal/services/collection_service.go | 522 ++++++++++++++++++ internal/services/collection_service_test.go | 278 ++++++++++ .../services/ebook_scanner_phase2_test.go | 436 +++++++++++++++ 5 files changed, 1975 insertions(+) create mode 100644 internal/services/book_matching.go create mode 100644 internal/services/book_matching_test.go create mode 100644 internal/services/collection_service.go create mode 100644 internal/services/collection_service_test.go create mode 100644 internal/services/ebook_scanner_phase2_test.go diff --git a/internal/services/book_matching.go b/internal/services/book_matching.go new file mode 100644 index 0000000..8c32646 --- /dev/null +++ b/internal/services/book_matching.go @@ -0,0 +1,373 @@ +package services + +import ( + "bookmann/internal/database" + "context" + "fmt" + "math" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +// BookMatch represents a potential match with confidence score +type BookMatch struct { + MediaItemID uuid.UUID `json:"media_item_id"` + BookmannUUID uuid.UUID `json:"bookmann_uuid"` + Confidence float64 `json:"confidence"` + MatchMethod string `json:"match_method"` +} + +// BookQueryRequest represents a book query from a device +type BookQueryRequest struct { + Identifiers []string `json:"identifiers"` // ["isbn:...", "uuid:...", "opf_uuid:..."] + SHA256 string `json:"sha256"` + Title string `json:"title"` + Author string `json:"author"` + FileSize int64 `json:"file_size"` +} + +// BookQueryResponse represents the response to a book query +type BookQueryResponse struct { + Matches []BookMatch `json:"matches"` + Action string `json:"action"` // "auto_link", "multiple_matches", "no_match" +} + +// LinkBookRequest represents a manual linking request +type LinkBookRequest struct { + DeviceFile struct { + FilePath string `json:"file_path"` + SHA256 string `json:"sha256"` + Title string `json:"title"` + } `json:"device_file"` + MediaItemID uuid.UUID `json:"media_item_id"` + ConfidenceScore float64 `json:"confidence_score"` +} + +// BookMatchingService handles universal book matching +type BookMatchingService struct { + db *database.Queries +} + +// NewBookMatchingService creates a new book matching service +func NewBookMatchingService(db *database.Queries) *BookMatchingService { + return &BookMatchingService{ + db: db, + } +} + +// QueryBooks queries for a book using multiple identifier types with confidence scoring +func (s *BookMatchingService) QueryBooks(ctx context.Context, req *BookQueryRequest) (*BookQueryResponse, error) { + var matches []BookMatch + + // Priority 1: Bookmann UUID (canonical) - Confidence: 1.0 + if match := s.matchByBookmannUUID(ctx, req.Identifiers); match != nil { + matches = append(matches, *match) + } + + // Priority 2: OPF UUID (from EPUB metadata) - Confidence: 0.95 + if match := s.matchByOPFUUID(ctx, req.Identifiers); match != nil { + matches = append(matches, *match) + } + + // Priority 3: SHA-256 hash - Confidence: 0.9 + if req.SHA256 != "" { + if match := s.matchBySHA256(ctx, req.SHA256); match != nil { + matches = append(matches, *match) + } + } + + // Priority 4: OPF identifier (non-UUID) - Confidence: 0.85 + if match := s.matchByOPFIdentifier(ctx, req.Identifiers); match != nil { + matches = append(matches, *match) + } + + // Priority 5: ISBN/ASIN - Confidence: 0.8 + if match := s.matchByISBNASIN(ctx, req.Identifiers); match != nil { + matches = append(matches, *match) + } + + // Priority 6: File path (device-specific) - Need device_id for this + // This will be handled at API layer with device context + + // Priority 7: Title + author + file size - Confidence: 0.5 + if req.Title != "" && req.Author != "" && req.FileSize > 0 { + if matches := s.matchByTitleAuthorSize(ctx, req.Title, req.Author, req.FileSize); len(matches) > 0 { + matches = append(matches, matches...) + } + } + + // Priority 8: Title only (last resort) - Confidence: 0.3 + if req.Title != "" && len(matches) == 0 { + if matches := s.matchByTitleOnly(ctx, req.Title); len(matches) > 0 { + matches = append(matches, matches...) + } + } + + // Determine action + action := s.determineAction(matches) + + return &BookQueryResponse{ + Matches: matches, + Action: action, + }, nil +} + +// matchByBookmannUUID attempts to match by Bookmann UUID +func (s *BookMatchingService) matchByBookmannUUID(ctx context.Context, identifiers []string) *BookMatch { + for _, id := range identifiers { + if len(id) > 4 && id[:4] == "uuid:" { + uuidStr := id[5:] + parsedUUID, err := uuid.Parse(uuidStr) + if err != nil { + continue + } + + // Check if media item exists + item, err := s.db.GetMediaItem(ctx, pgtype.UUID{Bytes: parsedUUID, Valid: true}) + if err == nil { + mediaUUID := item.ID.Bytes + return &BookMatch{ + MediaItemID: mediaUUID, + BookmannUUID: mediaUUID, + Confidence: 1.0, + MatchMethod: "uuid_match", + } + } + } + } + return nil +} + +// matchByOPFUUID attempts to match by OPF UUID +func (s *BookMatchingService) matchByOPFUUID(ctx context.Context, identifiers []string) *BookMatch { + for _, id := range identifiers { + if len(id) > 8 && id[:8] == "opf_uuid:" { + uuidStr := id[9:] + items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{ + Limit: 100, + Offset: 0, + }) + if err != nil { + continue + } + + for _, item := range items { + if item.OpfUuid.Valid && item.OpfUuid.String == uuidStr { + return &BookMatch{ + MediaItemID: item.ID.Bytes, + BookmannUUID: item.ID.Bytes, + Confidence: 0.95, + MatchMethod: "opf_uuid_match", + } + } + } + } + } + return nil +} + +// matchBySHA256 attempts to match by file SHA-256 hash +func (s *BookMatchingService) matchBySHA256(ctx context.Context, sha256 string) *BookMatch { + items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{ + Limit: 1000, + Offset: 0, + }) + if err != nil { + return nil + } + + for _, item := range items { + if item.FileSha256.Valid && item.FileSha256.String == sha256 { + return &BookMatch{ + MediaItemID: item.ID.Bytes, + BookmannUUID: item.ID.Bytes, + Confidence: 0.9, + MatchMethod: "sha256_match", + } + } + } + return nil +} + +// matchByOPFIdentifier attempts to match by OPF identifier +func (s *BookMatchingService) matchByOPFIdentifier(ctx context.Context, identifiers []string) *BookMatch { + for _, id := range identifiers { + if len(id) > 15 && id[:15] == "opf_identifier:" { + identifier := id[16:] + items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{ + Limit: 1000, + Offset: 0, + }) + if err != nil { + continue + } + + for _, item := range items { + if item.OpfIdentifier.Valid && item.OpfIdentifier.String == identifier { + return &BookMatch{ + MediaItemID: item.ID.Bytes, + BookmannUUID: item.ID.Bytes, + Confidence: 0.85, + MatchMethod: "opf_identifier_match", + } + } + } + } + } + return nil +} + +// matchByISBNASIN attempts to match by ISBN or ASIN +func (s *BookMatchingService) matchByISBNASIN(ctx context.Context, identifiers []string) *BookMatch { + for _, id := range identifiers { + if len(id) > 5 && id[:5] == "isbn:" { + isbn := id[6:] + items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{ + Limit: 1000, + Offset: 0, + }) + if err != nil { + continue + } + + for _, item := range items { + if item.Isbn.Valid && item.Isbn.String == isbn { + return &BookMatch{ + MediaItemID: item.ID.Bytes, + BookmannUUID: item.ID.Bytes, + Confidence: 0.8, + MatchMethod: "isbn_match", + } + } + } + } + + if len(id) > 5 && id[:5] == "asin:" { + asin := id[6:] + items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{ + Limit: 1000, + Offset: 0, + }) + if err != nil { + continue + } + + for _, item := range items { + if item.Asin.Valid && item.Asin.String == asin { + return &BookMatch{ + MediaItemID: item.ID.Bytes, + BookmannUUID: item.ID.Bytes, + Confidence: 0.8, + MatchMethod: "asin_match", + } + } + } + } + } + return nil +} + +// matchByTitleAuthorSize attempts to match by title, author, and file size +func (s *BookMatchingService) matchByTitleAuthorSize(ctx context.Context, title, author string, fileSize int64) []BookMatch { + var matches []BookMatch + + items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{ + Limit: 1000, + Offset: 0, + }) + if err != nil { + return matches + } + + for _, item := range items { + // Check title match (case-insensitive) + titleMatch := item.Title == title + + // Check author match (case-insensitive) + authorMatch := item.Author.Valid && item.Author.String == author + + // Check file size within 10% + sizeMatch := item.FileSize.Valid && math.Abs(float64(item.FileSize.Int64-fileSize))/float64(fileSize) <= 0.1 + + if titleMatch && authorMatch && sizeMatch { + matches = append(matches, BookMatch{ + MediaItemID: item.ID.Bytes, + BookmannUUID: item.ID.Bytes, + Confidence: 0.5, + MatchMethod: "title_author_size_match", + }) + } + } + + return matches +} + +// matchByTitleOnly attempts to match by title only (last resort) +func (s *BookMatchingService) matchByTitleOnly(ctx context.Context, title string) []BookMatch { + var matches []BookMatch + + items, err := s.db.ListMediaItems(ctx, database.ListMediaItemsParams{ + Limit: 1000, + Offset: 0, + }) + if err != nil { + return matches + } + + for _, item := range items { + if item.Title == title { + matches = append(matches, BookMatch{ + MediaItemID: item.ID.Bytes, + BookmannUUID: item.ID.Bytes, + Confidence: 0.3, + MatchMethod: "title_match", + }) + } + } + + return matches +} + +// determineAction determines the action to take based on matches +func (s *BookMatchingService) determineAction(matches []BookMatch) string { + if len(matches) == 0 { + return "no_match" + } + + if len(matches) == 1 { + // Auto-link if confidence is high enough (> 0.7) + if matches[0].Confidence > 0.7 { + return "auto_link" + } + } + + return "multiple_matches" +} + +// LinkBook manually links a device file to a media item +func (s *BookMatchingService) LinkBook(ctx context.Context, deviceID uuid.UUID, req *LinkBookRequest) (*database.DeviceFileAliases, error) { + // Create device file alias + alias, err := s.db.CreateDeviceFileAlias(ctx, database.CreateDeviceFileAliasParams{ + MediaItemID: pgtype.UUID{Bytes: req.MediaItemID, Valid: true}, + DeviceID: pgtype.UUID{Bytes: deviceID, Valid: true}, + FilePath: req.DeviceFile.FilePath, + FileSha256: pgtype.Text{String: req.DeviceFile.SHA256, Valid: req.DeviceFile.SHA256 != ""}, + ConfidenceScore: pgtype.Float8{Float64: req.ConfidenceScore, Valid: true}, + }) + + if err != nil { + return nil, fmt.Errorf("failed to create device file alias: %v", err) + } + + return &alias, nil +} + +// GetUnlinkedBooks returns books that need manual linking +func (s *BookMatchingService) GetUnlinkedBooks(ctx context.Context, deviceID uuid.UUID) ([]map[string]interface{}, error) { + // Get device file aliases that don't have media_item_id set + // This is a placeholder - actual implementation would query progress records + // that exist without matching media items + + return []map[string]interface{}{}, nil +} diff --git a/internal/services/book_matching_test.go b/internal/services/book_matching_test.go new file mode 100644 index 0000000..55a92c9 --- /dev/null +++ b/internal/services/book_matching_test.go @@ -0,0 +1,366 @@ +package services + +import ( + "testing" + + "github.com/google/uuid" +) + +// TestBookMatchingService_QueryBooks tests the book query functionality +func TestBookMatchingService_QueryBooks(t *testing.T) { + // This is a placeholder test that would need a mock database + // For now, we'll test the matching priority logic structure + + t.Run("Priority 1: Bookmann UUID match", func(t *testing.T) { + // Test that UUID matching returns highest confidence + req := &BookQueryRequest{ + Identifiers: []string{"uuid:550e8400-e29b-41d4-a716-446655440000"}, + } + + // Would need mock DB to test actual matching + _ = req + t.Log("UUID matching test placeholder - requires mock database") + }) + + t.Run("Priority 2: OPF UUID match", func(t *testing.T) { + req := &BookQueryRequest{ + Identifiers: []string{"opf_uuid:550e8400-e29b-41d4-a716-446655440000"}, + } + + _ = req + t.Log("OPF UUID matching test placeholder - requires mock database") + }) + + t.Run("Priority 3: SHA-256 match", func(t *testing.T) { + req := &BookQueryRequest{ + SHA256: "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + } + + _ = req + t.Log("SHA-256 matching test placeholder - requires mock database") + }) + + t.Run("Priority 7: Title + author + size match", func(t *testing.T) { + req := &BookQueryRequest{ + Title: "Test Book", + Author: "Test Author", + FileSize: 1024000, + } + + _ = req + t.Log("Title+author+size matching test placeholder - requires mock database") + }) +} + +// TestBookMatch_ConfidenceLevels tests confidence scoring +func TestBookMatch_ConfidenceLevels(t *testing.T) { + tests := []struct { + name string + match BookMatch + confidence float64 + method string + }{ + { + name: "UUID match - highest confidence", + match: BookMatch{ + Confidence: 1.0, + MatchMethod: "uuid_match", + }, + confidence: 1.0, + method: "uuid_match", + }, + { + name: "SHA-256 match - high confidence", + match: BookMatch{ + Confidence: 0.9, + MatchMethod: "sha256_match", + }, + confidence: 0.9, + method: "sha256_match", + }, + { + name: "ISBN match - medium confidence", + match: BookMatch{ + Confidence: 0.8, + MatchMethod: "isbn_match", + }, + confidence: 0.8, + method: "isbn_match", + }, + { + name: "Title+author+size match - lower confidence", + match: BookMatch{ + Confidence: 0.5, + MatchMethod: "title_author_size_match", + }, + confidence: 0.5, + method: "title_author_size_match", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.match.Confidence != tt.confidence { + t.Errorf("Expected confidence %f, got %f", tt.confidence, tt.match.Confidence) + } + + if tt.match.MatchMethod != tt.method { + t.Errorf("Expected method %s, got %s", tt.method, tt.match.MatchMethod) + } + }) + } +} + +// TestBookQueryResponse_ActionDetermination tests action logic +func TestBookQueryResponse_ActionDetermination(t *testing.T) { + tests := []struct { + name string + response BookQueryResponse + action string + }{ + { + name: "No matches", + response: BookQueryResponse{ + Matches: []BookMatch{}, + }, + action: "no_match", + }, + { + name: "Single high-confidence match", + response: BookQueryResponse{ + Matches: []BookMatch{ + {Confidence: 0.9, MatchMethod: "sha256_match"}, + }, + }, + action: "auto_link", + }, + { + name: "Single low-confidence match", + response: BookQueryResponse{ + Matches: []BookMatch{ + {Confidence: 0.5, MatchMethod: "title_author_size_match"}, + }, + }, + action: "multiple_matches", + }, + { + name: "Multiple matches", + response: BookQueryResponse{ + Matches: []BookMatch{ + {Confidence: 0.8, MatchMethod: "isbn_match"}, + {Confidence: 0.5, MatchMethod: "title_match"}, + }, + }, + action: "multiple_matches", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Determine action + var action string + if len(tt.response.Matches) == 0 { + action = "no_match" + } else if len(tt.response.Matches) == 1 && tt.response.Matches[0].Confidence > 0.7 { + action = "auto_link" + } else { + action = "multiple_matches" + } + + if action != tt.action { + t.Errorf("Expected action %s, got %s", tt.action, action) + } + }) + } +} + +// TestDetermineAction tests the action determination logic +func TestDetermineAction(t *testing.T) { + tests := []struct { + name string + matches []BookMatch + expected string + }{ + { + name: "No matches", + matches: []BookMatch{}, + expected: "no_match", + }, + { + name: "Single high-confidence match", + matches: []BookMatch{ + {Confidence: 0.9, MatchMethod: "sha256_match"}, + }, + expected: "auto_link", + }, + { + name: "Single low-confidence match", + matches: []BookMatch{ + {Confidence: 0.5, MatchMethod: "title_match"}, + }, + expected: "multiple_matches", + }, + { + name: "Multiple matches", + matches: []BookMatch{ + {Confidence: 0.8, MatchMethod: "isbn_match"}, + {Confidence: 0.7, MatchMethod: "asin_match"}, + }, + expected: "multiple_matches", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := &BookMatchingService{} + result := service.determineAction(tt.matches) + if result != tt.expected { + t.Errorf("Expected action %s, got %s", tt.expected, result) + } + }) + } +} + +// TestBookQueryRequest_Validation tests request validation +func TestBookQueryRequest_Validation(t *testing.T) { + tests := []struct { + name string + req BookQueryRequest + valid bool + reason string + }{ + { + name: "Valid UUID identifier", + req: BookQueryRequest{ + Identifiers: []string{"uuid:550e8400-e29b-41d4-a716-446655440000"}, + }, + valid: true, + reason: "", + }, + { + name: "Valid ISBN identifier", + req: BookQueryRequest{ + Identifiers: []string{"isbn:9783161484100"}, + }, + valid: true, + reason: "", + }, + { + name: "Valid SHA-256", + req: BookQueryRequest{ + SHA256: "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + }, + valid: true, + reason: "", + }, + { + name: "Valid title+author+size", + req: BookQueryRequest{ + Title: "Test Book", + Author: "Test Author", + FileSize: 1024000, + }, + valid: true, + reason: "", + }, + { + name: "Empty request", + req: BookQueryRequest{}, + valid: false, + reason: "No identifiers provided", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Basic validation logic + valid := len(tt.req.Identifiers) > 0 || + tt.req.SHA256 != "" || + (tt.req.Title != "" && tt.req.Author != "") + + if valid != tt.valid { + t.Errorf("Expected valid=%v, got valid=%v. Reason: %s", tt.valid, valid, tt.reason) + } + }) + } +} + +// TestLinkBookRequest_Validation tests linking request validation +func TestLinkBookRequest_Validation(t *testing.T) { + tests := []struct { + name string + req LinkBookRequest + valid bool + reason string + }{ + { + name: "Valid link request", + req: LinkBookRequest{ + MediaItemID: uuid.New(), + ConfidenceScore: 1.0, + DeviceFile: struct { + FilePath string `json:"file_path"` + SHA256 string `json:"sha256"` + Title string `json:"title"` + }{ + FilePath: "/path/to/book.epub", + SHA256: "abc123", + Title: "Test Book", + }, + }, + valid: true, + reason: "", + }, + { + name: "Missing media item ID", + req: LinkBookRequest{ + ConfidenceScore: 1.0, + }, + valid: false, + reason: "MediaItemID is required", + }, + { + name: "Invalid confidence score", + req: LinkBookRequest{ + MediaItemID: uuid.New(), + ConfidenceScore: 2.0, // Invalid, should be 0-1 + }, + valid: false, + reason: "Confidence score must be between 0 and 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Check if MediaItemID is valid + valid := tt.req.MediaItemID != uuid.Nil && + tt.req.ConfidenceScore >= 0 && + tt.req.ConfidenceScore <= 1 + + if valid != tt.valid { + t.Errorf("Expected valid=%v, got valid=%v. Reason: %s", tt.valid, valid, tt.reason) + } + }) + } +} + +// Example usage +func ExampleBookMatchingService_QueryBooks() { + service := NewBookMatchingService(nil) // Would need real DB + + req := &BookQueryRequest{ + Identifiers: []string{ + "uuid:550e8400-e29b-41d4-a716-446655440000", + "isbn:9783161484100", + }, + SHA256: "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592", + Title: "Test Book", + Author: "Test Author", + FileSize: 1024000, + } + + _ = service + _ = req + // response, err := service.QueryBooks(context.Background(), req) + // fmt.Printf("Matches: %d, Action: %s\n", len(response.Matches), response.Action) +} diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go new file mode 100644 index 0000000..1fba253 --- /dev/null +++ b/internal/services/collection_service.go @@ -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 +} diff --git a/internal/services/collection_service_test.go b/internal/services/collection_service_test.go new file mode 100644 index 0000000..d137b31 --- /dev/null +++ b/internal/services/collection_service_test.go @@ -0,0 +1,278 @@ +package services + +import ( + "bookmann/internal/database" + "testing" + + "github.com/jackc/pgx/v5/pgtype" +) + +func TestEvaluateRules_Equality(t *testing.T) { + service := &CollectionService{} + + mediaItem := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + } + + rules := []Rule{ + { + ID: "rule-1", + Field: "genre", + Operator: "equals", + Value: "Science Fiction", + Priority: 5, + }, + } + + evaluations := service.EvaluateRules(mediaItem, rules) + + if len(evaluations) != 1 { + t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) + } + + if !evaluations[0].Matches { + t.Errorf("expected rule to match, but it didn't") + } + + if evaluations[0].Confidence <= 0.85 { + t.Errorf("expected confidence > 0.85, got %f", evaluations[0].Confidence) + } +} + +func TestEvaluateRules_NoMatch(t *testing.T) { + service := &CollectionService{} + + mediaItem := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Fantasy", Valid: true}, + } + + rules := []Rule{ + { + ID: "rule-1", + Field: "genre", + Operator: "equals", + Value: "Science Fiction", + Priority: 5, + }, + } + + evaluations := service.EvaluateRules(mediaItem, rules) + + if len(evaluations) != 1 { + t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) + } + + if evaluations[0].Matches { + t.Errorf("expected rule not to match, but it did") + } + + if evaluations[0].Confidence != 0 { + t.Errorf("expected confidence 0, got %f", evaluations[0].Confidence) + } +} + +func TestEvaluateRules_Contains(t *testing.T) { + service := &CollectionService{} + + mediaItem := database.ListMediaItemsRow{ + Series: pgtype.Text{String: "The Expanse", Valid: true}, + } + + rules := []Rule{ + { + ID: "rule-1", + Field: "series", + Operator: "contains", + Value: "Expanse", + Priority: 7, + }, + } + + evaluations := service.EvaluateRules(mediaItem, rules) + + if len(evaluations) != 1 { + t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) + } + + if !evaluations[0].Matches { + t.Errorf("expected rule to match, but it didn't") + } + + if evaluations[0].Confidence <= 0.9 { + t.Errorf("expected confidence > 0.9 with priority boost, got %f", evaluations[0].Confidence) + } +} + +func TestEvaluateRules_PriorityOrder(t *testing.T) { + service := &CollectionService{} + + mediaItem := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + Series: pgtype.Text{String: "Foundation", Valid: true}, + } + + rules := []Rule{ + { + ID: "rule-1", + Field: "genre", + Operator: "equals", + Value: "Science Fiction", + Priority: 5, + }, + { + ID: "rule-2", + Field: "series", + Operator: "equals", + Value: "Foundation", + Priority: 9, + }, + } + + evaluations := service.EvaluateRules(mediaItem, rules) + + if len(evaluations) != 2 { + t.Fatalf("expected 2 evaluations, got %d", len(evaluations)) + } + + if evaluations[0].RuleID != "rule-2" { + t.Errorf("expected rule-2 (higher priority) to be first, got %s", evaluations[0].RuleID) + } + + if evaluations[1].RuleID != "rule-1" { + t.Errorf("expected rule-1 (lower priority) to be second, got %s", evaluations[1].RuleID) + } + + if evaluations[0].Confidence <= evaluations[1].Confidence { + t.Errorf("expected higher priority rule to have higher confidence") + } +} + +func TestEvaluateRules_CopyrightYear(t *testing.T) { + service := &CollectionService{} + + mediaItem := database.ListMediaItemsRow{ + CopyrightYear: pgtype.Int4{Int32: 2020, Valid: true}, + } + + rules := []Rule{ + { + ID: "rule-1", + Field: "copyright_year", + Operator: "greater_than", + Value: "2019", + Priority: 5, + }, + } + + evaluations := service.EvaluateRules(mediaItem, rules) + + if len(evaluations) != 1 { + t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) + } + + if !evaluations[0].Matches { + t.Errorf("expected rule to match (2020 > 2019), but it didn't") + } +} + +func TestEvaluateRules_MissingField(t *testing.T) { + service := &CollectionService{} + + mediaItem := database.ListMediaItemsRow{} + + rules := []Rule{ + { + ID: "rule-1", + Field: "genre", + Operator: "equals", + Value: "Science Fiction", + Priority: 5, + }, + } + + evaluations := service.EvaluateRules(mediaItem, rules) + + if len(evaluations) != 1 { + t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) + } + + if evaluations[0].Matches { + t.Errorf("expected rule not to match when field is missing, but it did") + } +} + +func TestEvaluateRules_CaseInsensitive(t *testing.T) { + service := &CollectionService{} + + mediaItem := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "science fiction", Valid: true}, + } + + rules := []Rule{ + { + ID: "rule-1", + Field: "genre", + Operator: "equals", + Value: "SCIENCE FICTION", + Priority: 5, + }, + } + + evaluations := service.EvaluateRules(mediaItem, rules) + + if len(evaluations) != 1 { + t.Fatalf("expected 1 evaluation, got %d", len(evaluations)) + } + + if !evaluations[0].Matches { + t.Errorf("expected case-insensitive match, but it didn't match") + } +} + +func TestEvaluateRules_PriorityBoost(t *testing.T) { + service := &CollectionService{} + + mediaItem := database.ListMediaItemsRow{ + Genre: pgtype.Text{String: "Science Fiction", Valid: true}, + } + + rules := []Rule{ + { + ID: "rule-1", + Field: "genre", + Operator: "equals", + Value: "Science Fiction", + Priority: 1, + }, + { + ID: "rule-2", + Field: "genre", + Operator: "equals", + Value: "Science Fiction", + Priority: 10, + }, + } + + evaluations := service.EvaluateRules(mediaItem, rules) + + if len(evaluations) != 2 { + t.Fatalf("expected 2 evaluations, got %d", len(evaluations)) + } + + // Higher priority (10) should give higher confidence than lower priority (1) + // Genre base confidence is 0.9 + // Priority 1: 0.9 + 0.01 = 0.91 + // Priority 10: 0.9 + 0.1 = 1.0 (capped) + if evaluations[0].Confidence != 1.0 { + t.Errorf("expected higher priority confidence to be 1.0, got %f", evaluations[0].Confidence) + } + + if evaluations[1].Confidence != 0.91 { + t.Errorf("expected lower priority confidence to be 0.91, got %f", evaluations[1].Confidence) + } + + // The higher priority rule should come first (sorted by priority descending) + if evaluations[0].RuleID != "rule-2" { + t.Errorf("expected higher priority rule to be first, got %s", evaluations[0].RuleID) + } +} diff --git a/internal/services/ebook_scanner_phase2_test.go b/internal/services/ebook_scanner_phase2_test.go new file mode 100644 index 0000000..667312e --- /dev/null +++ b/internal/services/ebook_scanner_phase2_test.go @@ -0,0 +1,436 @@ +package services + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestCalculateFileSHA256 tests the SHA-256 calculation with streaming +func TestCalculateFileSHA256(t *testing.T) { + scanner := &EbookScanner{} + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test.txt") + testContent := "The quick brown fox jumps over the lazy dog" + + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Calculate expected hash + hasher := sha256.New() + hasher.Write([]byte(testContent)) + expectedHash := hex.EncodeToString(hasher.Sum(nil)) + + // Test the function + calculatedHash, err := scanner.calculateFileSHA256(testFile) + if err != nil { + t.Fatalf("calculateFileSHA256 failed: %v", err) + } + + if calculatedHash != expectedHash { + t.Errorf("Expected hash %s, got %s", expectedHash, calculatedHash) + } + + t.Logf("SHA-256 hash calculation successful: %s", calculatedHash) +} + +// TestCalculateFileSHA256LargeFile tests streaming with large file +func TestCalculateFileSHA256LargeFile(t *testing.T) { + scanner := &EbookScanner{} + + // Create a temporary test file with larger content + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "large_test.txt") + + // Create a 10MB file + file, err := os.Create(testFile) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + defer file.Close() + + writer := bufio.NewWriter(file) + testLine := strings.Repeat("This is a test line for SHA-256 calculation\n", 100) + for i := 0; i < 1000; i++ { + if _, err := writer.WriteString(testLine); err != nil { + t.Fatalf("Failed to write to test file: %v", err) + } + } + writer.Flush() + + // Calculate expected hash + file.Seek(0, 0) + hasher := sha256.New() + buf := make([]byte, 4096) + for { + n, err := file.Read(buf) + if err != nil && err != bufio.ErrBufferFull { + if err == io.EOF { + break + } + t.Fatalf("Failed to read file for expected hash: %v", err) + } + hasher.Write(buf[:n]) + if err == io.EOF { + break + } + } + expectedHash := hex.EncodeToString(hasher.Sum(nil)) + + // Test the function + calculatedHash, err := scanner.calculateFileSHA256(testFile) + if err != nil { + t.Fatalf("calculateFileSHA256 failed for large file: %v", err) + } + + if calculatedHash != expectedHash { + t.Errorf("Expected hash %s, got %s", expectedHash, calculatedHash) + } + + t.Logf("Large file SHA-256 hash calculation successful") +} + +// TestExtractISBNFromIdentifier tests ISBN extraction +func TestExtractISBNFromIdentifier(t *testing.T) { + scanner := &EbookScanner{} + + tests := []struct { + name string + input string + expected string + }{ + { + name: "ISBN with prefix", + input: "isbn:978-3-16-148410-0", + expected: "9783161484100", + }, + { + name: "ISBN with hyphens", + input: "978-3-16-148410-0", + expected: "9783161484100", + }, + { + name: "ISBN with spaces", + input: "978 3 16 148410 0", + expected: "9783161484100", + }, + { + name: "ISBN-10", + input: "isbn:0-306-40615-2", + expected: "0306406152", + }, + { + name: "Clean ISBN-13", + input: "9783161484100", + expected: "9783161484100", + }, + { + name: "Invalid identifier", + input: "not-an-isbn", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := scanner.extractISBNFromIdentifier(tt.input) + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} + +// TestIsValidUUID tests UUID validation +func TestIsValidUUID(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "Valid UUID v4", + input: "550e8400-e29b-41d4-a716-446655440000", + expected: true, + }, + { + name: "Valid UUID with uppercase", + input: "550E8400-E29B-41D4-A716-446655440000", + expected: true, + }, + { + name: "Invalid UUID - missing dashes", + input: "550e8400e29b41d4a716446655440000", + expected: false, + }, + { + name: "Invalid UUID - wrong format", + input: "not-a-uuid", + expected: false, + }, + { + name: "Empty string", + input: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isValidUUID(tt.input) + if result != tt.expected { + t.Errorf("Expected %v, got %v for input %s", tt.expected, result, tt.input) + } + }) + } +} + +// TestDetermineHashConfidence tests confidence level determination +func TestDetermineHashConfidence(t *testing.T) { + scanner := &EbookScanner{} + + tests := []struct { + name string + uuid string + identifier string + expectedConf string + }{ + { + name: "High confidence - valid UUID", + uuid: "550e8400-e29b-41d4-a716-446655440000", + identifier: "", + expectedConf: "high", + }, + { + name: "Medium confidence - ISBN", + uuid: "", + identifier: "isbn:978-3-16-148410-0", + expectedConf: "medium", + }, + { + name: "Medium confidence - long identifier", + uuid: "", + identifier: "some-long-identifier-string", + expectedConf: "medium", + }, + { + name: "Low confidence - no identifiers", + uuid: "", + identifier: "", + expectedConf: "low", + }, + { + name: "Low confidence - short identifier", + uuid: "", + identifier: "abc", + expectedConf: "low", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := scanner.determineHashConfidence(tt.uuid, tt.identifier) + if result != tt.expectedConf { + t.Errorf("Expected confidence %s, got %s", tt.expectedConf, result) + } + }) + } +} + +// TestDetectFormatType tests format type detection +func TestDetectFormatType(t *testing.T) { + scanner := &EbookScanner{} + + tests := []struct { + name string + filePath string + expectedType string + }{ + { + name: "EPUB file", + filePath: "/path/to/book.epub", + expectedType: "epub", + }, + { + name: "KEPUB file", + filePath: "/path/to/book.kepub.epub", + expectedType: "kepub", + }, + { + name: "PDF file", + filePath: "/path/to/document.pdf", + expectedType: "pdf", + }, + { + name: "CBZ file", + filePath: "/path/to/comic.cbz", + expectedType: "comic_archive", + }, + { + name: "MOBI file", + filePath: "/path/to/book.mobi", + expectedType: "mobi", + }, + { + name: "TXT file", + filePath: "/path/to/book.txt", + expectedType: "txt", + }, + { + name: "Unknown format", + filePath: "/path/to/book.xyz", + expectedType: "unknown", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := scanner.detectFormatType(tt.filePath) + if result != tt.expectedType { + t.Errorf("Expected format type %s, got %s", tt.expectedType, result) + } + }) + } +} + +// TestExtractHashInfo tests the complete hash extraction flow +func TestExtractHashInfo(t *testing.T) { + scanner := &EbookScanner{} + + // Create a temporary test file + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "test_book.txt") + testContent := "Test book content for hash extraction" + + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + // Test hash extraction + hashInfo, formatInfo, err := scanner.extractHashInfo(testFile) + if err != nil { + t.Fatalf("extractHashInfo failed: %v", err) + } + + // Verify hash info + if hashInfo == nil { + t.Fatal("hashInfo is nil") + } + + if hashInfo.FileSHA256 == "" { + t.Error("FileSHA256 is empty") + } + + // Verify format info + if formatInfo == nil { + t.Fatal("formatInfo is nil") + } + + if formatInfo.FormatType != "txt" { + t.Errorf("Expected format type 'txt', got %s", formatInfo.FormatType) + } + + if formatInfo.FileSHA256 != hashInfo.FileSHA256 { + t.Error("FormatInfo.FileSHA256 doesn't match HashInfo.FileSHA256") + } + + t.Logf("Hash extraction test passed: SHA256=%s, Format=%s, Confidence=%s", + hashInfo.FileSHA256, formatInfo.FormatType, hashInfo.HashConfidence) +} + +// BenchmarkCalculateFileSHA256 benchmarks SHA-256 calculation +func BenchmarkCalculateFileSHA256(b *testing.B) { + scanner := &EbookScanner{} + + // Create a temporary test file + tmpDir := b.TempDir() + testFile := filepath.Join(tmpDir, "bench_test.txt") + testContent := strings.Repeat("Benchmark test content for SHA-256 calculation\n", 10000) + + if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil { + b.Fatalf("Failed to create test file: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, _ = scanner.calculateFileSHA256(testFile) + } +} + +// TestExtractHashInfoIntegration is an integration test that tests a realistic scenario +func TestExtractHashInfoIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + scanner := &EbookScanner{} + tmpDir := t.TempDir() + + // Test multiple file types + testFiles := []struct { + name string + filename string + content string + }{ + { + name: "Text file", + filename: "test.txt", + content: "Plain text file", + }, + { + name: "HTML file (simulating EPUB content)", + filename: "test.html", + content: "Test content", + }, + } + + for _, tf := range testFiles { + t.Run(tf.name, func(t *testing.T) { + testFile := filepath.Join(tmpDir, tf.filename) + if err := os.WriteFile(testFile, []byte(tf.content), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + hashInfo, formatInfo, err := scanner.extractHashInfo(testFile) + if err != nil { + t.Fatalf("extractHashInfo failed: %v", err) + } + + // Verify SHA-256 is calculated + if hashInfo.FileSHA256 == "" { + t.Error("SHA-256 hash not calculated") + } + + // Verify format detection + if formatInfo.FormatType == "unknown" { + t.Logf("Warning: Format detected as 'unknown' for %s", tf.filename) + } + + t.Logf("Integration test passed for %s: SHA256=%s, Format=%s", + tf.name, hashInfo.FileSHA256, formatInfo.FormatType) + }) + } +} + +// Example usage +func ExampleEbookScanner_calculateFileSHA256() { + scanner := &EbookScanner{} + + // Calculate SHA-256 hash of a file + hash, err := scanner.calculateFileSHA256("/path/to/ebook.epub") + if err != nil { + fmt.Printf("Error: %v\n", err) + return + } + + fmt.Printf("SHA-256: %s\n", hash) +}