package services import ( "bookhoard/internal/database" "bookhoard/internal/utils" "context" "encoding/json" "fmt" "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: utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: true}), FileSize: item.FileSize, MimeType: item.MimeType, CoverImagePath: pgtype.Text{String: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), Valid: true}, 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, } } 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: utils.ResolveMediaURL(item.LibraryID, pgtype.Text{String: item.FilePath, Valid: true}), FileSize: item.FileSize, MimeType: item.MimeType, CoverImagePath: pgtype.Text{String: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), Valid: true}, 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 uuid.UUID, libraryID pgtype.UUID, limit int, collectionOrder []string, hiddenCollections []string, ) ([]DashboardSection, error) { var results []DashboardSection systemCollections, err := s.db.GetSystemCollectionsForDashboard(ctx, pgtype.UUID{Bytes: userID, Valid: true}) 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: 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: 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 uuid.UUID, libraryID pgtype.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: libraryID, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, }) case "recently-added": return s.db.GetRecentlyAddedItems(ctx, database.GetRecentlyAddedItemsParams{ LibraryID: libraryID, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, }) case "recently-read": return s.db.GetRecentlyReadItems(ctx, database.GetRecentlyReadItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: libraryID, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, }) case "not-started": return s.db.GetNotStartedItems(ctx, database.GetNotStartedItemsParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: libraryID, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, }) case "continue-series": rows, err := s.db.GetContinueSeriesItems(ctx, database.GetContinueSeriesItemsParams{ LibraryID: libraryID, UserID: pgtype.UUID{Bytes: userID, Valid: true}, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, }) if err != nil { return nil, err } items := make([]database.MediaItems, 0, len(rows)) for _, row := range rows { items = append(items, continueSeriesRowToMediaItems(row)) } return items, nil default: return []database.MediaItems{}, nil } } func (s *DashboardService) getUserCollectionItems(ctx context.Context, coll database.Collections, userID uuid.UUID, libraryID pgtype.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: libraryID, Limit: pgtype.Int4{Int32: int32(limit), Valid: true}, }) 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, libraryID) 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 uuid.UUID, libraryID pgtype.UUID) (database.UserDashboardPreferences, error) { return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, LibraryID: libraryID, }) } func (s *DashboardService) UpsertDashboardPreferences(ctx context.Context, params database.UpsertDashboardPreferencesParams) (database.UserDashboardPreferences, error) { return s.db.UpsertDashboardPreferences(ctx, params) } func (s *DashboardService) SanitizeDashboardPreferences(hiddenCollections, collectionOrder []string) ([]string, []string) { // Deduplicate collection_order while preserving order seen := make(map[string]bool) var sanitizedOrder []string for _, id := range collectionOrder { if !seen[id] { seen[id] = true sanitizedOrder = append(sanitizedOrder, id) } } // Deduplicate hidden_collections seen = make(map[string]bool) var sanitizedHidden []string for _, id := range hiddenCollections { if !seen[id] { seen[id] = true sanitizedHidden = append(sanitizedHidden, id) } } return sanitizedHidden, sanitizedOrder } func (s *DashboardService) RestoreSystemCollection(ctx context.Context, userID uuid.UUID, collectionName string, resetType string) error { defaultMetadata := map[string]struct { Description string Icon string Color string Priority int32 QueryType string }{ "Continue Reading": {"Books you're currently reading (0 < progress < 1)", "📖", "#7aa2f7", 1, "continue-reading"}, "Recently Added": {"Newly added items to this library", "🆕", "#9ece6a", 2, "recently-added"}, "Recently Read": {"Books you've finished (progress >= 1)", "✅", "#e0af68", 3, "recently-read"}, "Not Started": {"Books you haven't read yet (progress = 0 or no record)", "📕", "#f7768e", 4, "not-started"}, "Continue Series": {"Next book in series you're reading", "📚", "#bb9af7", 5, "continue-series"}, } meta, exists := defaultMetadata[collectionName] if !exists { return fmt.Errorf("unknown collection: %s", collectionName) } if resetType == "keep_books" { return s.db.ResetSystemCollectionMetadata(ctx, database.ResetSystemCollectionMetadataParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, Name: collectionName, Description: pgtype.Text{String: meta.Description, Valid: true}, Icon: pgtype.Text{String: meta.Icon, Valid: true}, Color: pgtype.Text{String: meta.Color, Valid: true}, Priority: pgtype.Int4{Int32: meta.Priority, Valid: true}, }) } err := s.db.DeleteUserSystemCollection(ctx, database.DeleteUserSystemCollectionParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, Name: collectionName, }) if err != nil { return err } _, err = s.db.CreateSystemCollection(ctx, database.CreateSystemCollectionParams{ UserID: pgtype.UUID{Bytes: userID, Valid: true}, Name: collectionName, Description: pgtype.Text{String: meta.Description, Valid: true}, Icon: pgtype.Text{String: meta.Icon, Valid: true}, Color: pgtype.Text{String: meta.Color, Valid: true}, ShowOnDashboard: pgtype.Bool{Bool: true, Valid: true}, QueryType: pgtype.Text{String: meta.QueryType, Valid: true}, Priority: pgtype.Int4{Int32: meta.Priority, Valid: true}, }) return err }