Files
bookhoard/internal/services/dashboard_service.go
T
john-okeefe 6a352a6afb refactor(services): accept optional libraryID for All Libraries support
- dashboard_service.go: Change libraryID parameter from uuid.UUID to
  pgtype.UUID across GetDashboardSections, GetDashboardPreferences,
  and all helper methods. pgtype.UUID{Valid: false} now signals
  "no library filter" (All Libraries), which gets passed through
  to sqlc.narg() in the SQL layer.

- series_service.go: Drop libraryID parameter from GetSeriesBooks
  entirely. Series are not library-specific — all books in a series
  are shown regardless of which library they belong to.
2026-05-18 17:52:24 -04:00

464 lines
15 KiB
Go

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
}