diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go new file mode 100644 index 0000000..1f18056 --- /dev/null +++ b/internal/services/dashboard_service.go @@ -0,0 +1,384 @@ +package services + +import ( + "bookhoard/internal/database" + "context" + "encoding/json" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" +) + +func mediaItemsToListMediaItemsRow(item database.MediaItems) database.ListMediaItemsRow { + return database.ListMediaItemsRow{ + ID: item.ID, + LibraryID: item.LibraryID, + Title: item.Title, + Author: item.Author, + Isbn: item.Isbn, + Description: item.Description, + FilePath: item.FilePath, + FileSize: item.FileSize, + MimeType: item.MimeType, + CoverImagePath: item.CoverImagePath, + Series: item.Series, + SeriesNumber: item.SeriesNumber, + Tags: item.Tags, + Asin: item.Asin, + DatePublished: item.DatePublished, + Publisher: item.Publisher, + Contributors: item.Contributors, + Language: item.Language, + Edition: item.Edition, + PageCount: item.PageCount, + Genre: item.Genre, + CopyrightYear: item.CopyrightYear, + GoodreadsID: item.GoodreadsID, + OpenlibraryID: item.OpenlibraryID, + GoogleBooksID: item.GoogleBooksID, + AddedByAdminID: item.AddedByAdminID, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + FormatGroup: item.FormatGroup, + FormatMimetype: item.FormatMimetype, + IsReflowable: item.IsReflowable, + HasFixedLayout: item.HasFixedLayout, + TotalCharacters: item.TotalCharacters, + ChapterCount: item.ChapterCount, + EntitlementID: item.EntitlementID, + RevisionNumber: item.RevisionNumber, + KoboContentID: item.KoboContentID, + KoboMetadata: item.KoboMetadata, + TagsSearch: item.TagsSearch, + ContributorsSearch: item.ContributorsSearch, + FileSha256: item.FileSha256, + OpfIdentifier: item.OpfIdentifier, + OpfUuid: item.OpfUuid, + HashConfidence: item.HashConfidence, + LibraryName: "", + LibraryTypeName: "", + } +} + +func getCollectionItemsRowToMediaItems(item database.GetCollectionItemsForDashboardRow) database.MediaItems { + return database.MediaItems{ + ID: item.ID, + LibraryID: item.LibraryID, + Title: item.Title, + Author: item.Author, + Isbn: item.Isbn, + Description: item.Description, + FilePath: item.FilePath, + FileSize: item.FileSize, + MimeType: item.MimeType, + CoverImagePath: item.CoverImagePath, + Series: item.Series, + SeriesNumber: item.SeriesNumber, + Tags: item.Tags, + Asin: item.Asin, + DatePublished: item.DatePublished, + Publisher: item.Publisher, + Contributors: item.Contributors, + Language: item.Language, + Edition: item.Edition, + PageCount: item.PageCount, + Genre: item.Genre, + CopyrightYear: item.CopyrightYear, + GoodreadsID: item.GoodreadsID, + OpenlibraryID: item.OpenlibraryID, + GoogleBooksID: item.GoogleBooksID, + AddedByAdminID: item.AddedByAdminID, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + FormatGroup: item.FormatGroup, + FormatMimetype: item.FormatMimetype, + IsReflowable: item.IsReflowable, + HasFixedLayout: item.HasFixedLayout, + TotalCharacters: item.TotalCharacters, + ChapterCount: item.ChapterCount, + EntitlementID: item.EntitlementID, + RevisionNumber: item.RevisionNumber, + KoboContentID: item.KoboContentID, + KoboMetadata: item.KoboMetadata, + TagsSearch: item.TagsSearch, + ContributorsSearch: item.ContributorsSearch, + FileSha256: item.FileSha256, + OpfIdentifier: item.OpfIdentifier, + OpfUuid: item.OpfUuid, + HashConfidence: item.HashConfidence, + } +} + +type DashboardService struct { + db *database.Queries + collectionService *CollectionService +} + +func NewDashboardService(db *database.Queries) *DashboardService { + return &DashboardService{ + db: db, + collectionService: NewCollectionService(db), + } +} + +type DashboardSection struct { + CollectionID uuid.UUID + CollectionName string + Items []database.MediaItems + QueryType string + Priority int + IsSystem bool + Title string + Description string + Icon string +} + +func (s *DashboardService) GetDashboardSections( + ctx context.Context, + userID, libraryID uuid.UUID, + limit int, + collectionOrder []string, + hiddenCollections []string, +) ([]DashboardSection, error) { + var results []DashboardSection + + systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx) + if err != nil { + return nil, err + } + + for _, coll := range systemCollections { + items, err := s.getCollectionItemsByQueryType(ctx, coll, userID, libraryID, limit) + if err != nil { + continue + } + + results = append(results, DashboardSection{ + CollectionID: uuid.UUID(coll.ID.Bytes), + CollectionName: coll.Name, + Items: items, + QueryType: coll.QueryType.String, + Priority: int(coll.Priority.Int32), + IsSystem: coll.IsSystemCollection.Bool, + Title: coll.Name, + Description: coll.Description.String, + Icon: coll.Icon.String, + }) + } + + userCollections, err := s.db.GetUserCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) + if err != nil { + return nil, err + } + + for _, coll := range userCollections { + items, err := s.getUserCollectionItems(ctx, coll, userID, libraryID, limit) + if err != nil { + continue + } + + if len(items) == 0 { + continue + } + + results = append(results, DashboardSection{ + CollectionID: uuid.UUID(coll.ID.Bytes), + CollectionName: coll.Name, + Items: items, + QueryType: coll.QueryType.String, + Priority: int(coll.Priority.Int32), + IsSystem: false, + Title: coll.Name, + Description: coll.Description.String, + Icon: coll.Icon.String, + }) + } + + results = s.filterHiddenCollections(results, hiddenCollections) + results = s.reorderCollections(results, collectionOrder) + + if len(collectionOrder) == 0 { + results = s.sortByPriority(results) + } + + return results, nil +} + +func (s *DashboardService) filterHiddenCollections(sections []DashboardSection, hidden []string) []DashboardSection { + if len(hidden) == 0 { + return sections + } + + var filtered []DashboardSection + for _, section := range sections { + isHidden := false + for _, h := range hidden { + if section.CollectionName == h { + isHidden = true + break + } + } + if !isHidden { + filtered = append(filtered, section) + } + } + return filtered +} + +func (s *DashboardService) reorderCollections(sections []DashboardSection, order []string) []DashboardSection { + if len(order) == 0 { + return sections + } + + var ordered []DashboardSection + remaining := make(map[string]DashboardSection) + for _, section := range sections { + remaining[section.CollectionName] = section + } + + for _, name := range order { + if section, exists := remaining[name]; exists { + ordered = append(ordered, section) + delete(remaining, name) + } + } + + for _, section := range sections { + if _, exists := remaining[section.CollectionName]; exists { + ordered = append(ordered, section) + } + } + + return ordered +} + +func (s *DashboardService) sortByPriority(sections []DashboardSection) []DashboardSection { + sorted := make([]DashboardSection, len(sections)) + copy(sorted, sections) + + for i := 0; i < len(sorted)-1; i++ { + for j := 0; j < len(sorted)-i-1; j++ { + if sorted[j].Priority > sorted[j+1].Priority { + sorted[j], sorted[j+1] = sorted[j+1], sorted[j] + } + } + } + + return sorted +} + +func (s *DashboardService) getCollectionItemsByQueryType(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { + switch coll.QueryType.String { + case "continue-reading": + return s.db.GetContinueReadingItems(ctx, database.GetContinueReadingItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + case "recently-added": + return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{ + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + case "recently-read": + return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + case "not-started": + return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + default: + return []database.MediaItems{}, nil + } +} + +func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) { + collUUID, _ := uuid.FromBytes(coll.ID.Bytes[0:16]) + + manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{ + CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + Limit: int32(limit), + }) + if err != nil { + return nil, err + } + + var manualNonExcluded []database.MediaItems + for _, item := range manualItems { + if !item.Excluded.Valid || !item.Excluded.Bool { + manualNonExcluded = append(manualNonExcluded, getCollectionItemsRowToMediaItems(item)) + } + } + + var autoItems []database.MediaItems + if len(coll.AutoAssignRules) > 0 { + var rules []Rule + if err := json.Unmarshal(coll.AutoAssignRules, &rules); err == nil && len(rules) > 0 { + allLibraryItems, err := s.db.GetLibraryItems(ctx, pgtype.UUID{Bytes: libraryID, Valid: true}) + if err == nil { + for _, item := range allLibraryItems { + alreadyInCollection := false + for _, manualItem := range manualNonExcluded { + manualUUID := uuid.UUID(manualItem.ID.Bytes) + itemUUID := uuid.UUID(item.ID.Bytes) + if manualUUID == itemUUID { + alreadyInCollection = true + break + } + } + if alreadyInCollection { + continue + } + + listItem := mediaItemsToListMediaItemsRow(item) + evaluations := s.collectionService.EvaluateRules(listItem, rules) + for _, eval := range evaluations { + if eval.Matches { + autoItems = append(autoItems, item) + break + } + } + } + } + } + } + + var finalItems []database.MediaItems + finalItems = append(finalItems, manualNonExcluded...) + finalItems = append(finalItems, autoItems...) + + if len(finalItems) > limit { + finalItems = finalItems[:limit] + } + + return finalItems, nil +} + +func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) { + return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true}, + }) +} + +func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) { + return s.db.UpsertDashboardPreferences(ctx, params) +} + +func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string) error { + err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{ + UserID: pgtype.UUID{Bytes: userID, Valid: true}, + Name: collectionName, + }) + if err != nil { + return err + } + + return nil +}