Phase 2b + 3 of the metadata overhaul (plan §2.4, §3.1-3.7). Auto-playlist collections (§2.4): the scanner turns obviously-playlist sidecar series (explicit '(Reading Order)'/chronological/publication/ in-order/timeline markers only — precision over recall) into curated static collections: upsert by view_settings.imported_from_series then name (rename-safe), position set ONLY on insert (clean integers; 1.5/1a stay unpositioned rather than guessing), membership add-if-missing, never removed or re-ordered by rescans, provenance in description + view_settings, show_on_dashboard true (dashboard-eligible by design), 'scanner.auto_playlist_collections' setting (default on, Settings Registry + schema seed). Ordering (§3.1-3.2): collection listings order position ASC NULLS LAST then added_at ASC (was added_at DESC — walked playlists backwards); PUT /api/collections/:id/items/order applies full-order rewrites (owner-only, service-gated). hide_finished + Up Next (§3.3-3.4): GetCollectionItemsForDashboard gains viewer_user_id + hide_finished (LEFT JOIN reading_progress, COALESCE percentage < 0.999) and viewer_percentage rides the row for card graying; GetCollectionUpNext serves the collection's reading-queue head with the family-lens visible-library array enforced. Dashboard (§3.5): user collections are dashboard-ELIGIBLE BY DEFAULT — the show_on_dashboard gate (never writable for user rows since the column shipped) is gone from GetUserCollectionsForDashboard; hiding stays dashboard-prefs (hidden_collections) territory; hide_finished filtered-everything now emits a Complete section instead of silently vanishing; sections JSON carries is_playlist/complete. Series page ordering hardening (§3.6): GetSeriesBooks tiebreaks by title ASC — NULL/tied numbers previously returned heap (scan) order, which is what the user saw. Frontend custom-section-builder note: the builder has sent show_on_dashboard: true since its creation; the API never bound it. User-row visibility is now intentionally prefs-governed, so the field stays unbound by design (documented in code). Pins widened (docs/contract_pins.md): collection detail + up_next; dashboard sections + is_playlist/complete; new reorder endpoint recorded. Verified: TestPlaylistCollectionsEndToEnd (new, full e2e: sidecar series split, collection auto-creation with position 7, rescan idempotency, owner-only reorder, finished-filter + completion marker + Up Next drain) plus the full affected integration sweep green.
480 lines
15 KiB
Go
480 lines
15 KiB
Go
package services
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
type DashboardService struct {
|
|
db *database.Queries
|
|
collectionService *CollectionService
|
|
}
|
|
|
|
func NewDashboardService(db *database.Queries) *DashboardService {
|
|
return &DashboardService{
|
|
db: db,
|
|
collectionService: NewCollectionService(db),
|
|
}
|
|
}
|
|
|
|
// viewSettingsFlag reads a boolean key from a collection's view_settings
|
|
// JSONB. Malformed or absent → false.
|
|
func viewSettingsFlag(raw []byte, key string) bool {
|
|
if len(raw) == 0 {
|
|
return false
|
|
}
|
|
var settings map[string]interface{}
|
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
|
return false
|
|
}
|
|
b, ok := settings[key].(bool)
|
|
return ok && b
|
|
}
|
|
|
|
// viewSettingsHasKey reports whether the key exists at all (any value) —
|
|
// used for provenance markers like imported_from_series.
|
|
func viewSettingsHasKey(raw []byte, key string) bool {
|
|
if len(raw) == 0 {
|
|
return false
|
|
}
|
|
var settings map[string]interface{}
|
|
if err := json.Unmarshal(raw, &settings); err != nil {
|
|
return false
|
|
}
|
|
_, ok := settings[key]
|
|
return ok
|
|
}
|
|
|
|
type DashboardSection struct {
|
|
CollectionID uuid.UUID
|
|
CollectionName string
|
|
Items []database.MediaItems
|
|
QueryType string
|
|
Priority int
|
|
IsSystem bool
|
|
Title string
|
|
Description string
|
|
Icon string
|
|
// A2c: the collection's own ordering-construct marker — the section is
|
|
// a curated playlist ("The Witcher (Reading Order)"), rendered with a
|
|
// badge by the frontend.
|
|
IsPlaylist bool
|
|
// A2c §3.5: hide_finished filtered every item out of a NON-empty
|
|
// collection — render "playlist complete" instead of dropping silently.
|
|
Complete bool
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// A2c §3.5: hide_finished filtered everything out — but the
|
|
// collection is NOT empty. That's a finished playlist: render a
|
|
// completion marker instead of silently vanishing.
|
|
if len(items) == 0 {
|
|
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
|
if hideFinished {
|
|
collUUIDBytes, _ := uuid.FromBytes(coll.ID.Bytes[0:16])
|
|
if count, countErr := s.db.CountCollectionItems(ctx, pgtype.UUID{Bytes: collUUIDBytes, Valid: true}); countErr == nil && count > 0 {
|
|
results = append(results, DashboardSection{
|
|
CollectionID: coll.ID.Bytes,
|
|
CollectionName: coll.Name,
|
|
Items: []database.MediaItems{},
|
|
QueryType: coll.QueryType.String,
|
|
Priority: int(coll.Priority.Int32),
|
|
IsSystem: false,
|
|
Title: coll.Name,
|
|
Description: coll.Description.String,
|
|
Icon: coll.Icon.String,
|
|
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
|
Complete: true,
|
|
})
|
|
}
|
|
}
|
|
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,
|
|
IsPlaylist: viewSettingsHasKey(coll.ViewSettings, "imported_from_series"),
|
|
})
|
|
}
|
|
|
|
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])
|
|
|
|
// A2c §3.3: per-collection hide_finished (view_settings), evaluated
|
|
// against the VIEWER's reading progress. The section then shows the
|
|
// head of the reading queue in playlist order.
|
|
hideFinished := viewSettingsFlag(coll.ViewSettings, "hide_finished")
|
|
manualItems, err := s.db.GetCollectionItemsForDashboard(ctx, database.GetCollectionItemsForDashboardParams{
|
|
CollectionID: pgtype.UUID{Bytes: collUUID, Valid: true},
|
|
LibraryID: libraryID,
|
|
ViewerUserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
HideFinished: pgtype.Bool{Bool: hideFinished, Valid: true},
|
|
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The query filters excluded rows in SQL and embeds the media row,
|
|
// so the items are MediaItems as-is.
|
|
var manualNonExcluded []database.MediaItems
|
|
for _, row := range manualItems {
|
|
manualNonExcluded = append(manualNonExcluded, row.MediaItems)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
evaluations := s.collectionService.EvaluateRules(item, 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) {
|
|
// Absent library = the global (All Libraries) scope: a separate
|
|
// one-row-per-user table, so an accidental per-library write can never
|
|
// masquerade as global.
|
|
if !libraryID.Valid {
|
|
g, err := s.db.GetGlobalDashboardPreferences(ctx, pgtype.UUID{Bytes: userID, Valid: true})
|
|
if err != nil {
|
|
return database.UserDashboardPreferences{}, err
|
|
}
|
|
return database.UserDashboardPreferences{
|
|
UserID: g.UserID,
|
|
HiddenCollections: g.HiddenCollections,
|
|
CollectionOrder: g.CollectionOrder,
|
|
ItemsPerSection: g.ItemsPerSection,
|
|
UpdatedAt: g.UpdatedAt,
|
|
}, nil
|
|
}
|
|
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) {
|
|
if !params.LibraryID.Valid {
|
|
g, err := s.db.UpsertGlobalDashboardPreferences(ctx, database.UpsertGlobalDashboardPreferencesParams{
|
|
UserID: params.UserID,
|
|
HiddenCollections: params.HiddenCollections,
|
|
CollectionOrder: params.CollectionOrder,
|
|
ItemsPerSection: params.ItemsPerSection,
|
|
})
|
|
if err != nil {
|
|
return database.UserDashboardPreferences{}, err
|
|
}
|
|
return database.UserDashboardPreferences{
|
|
UserID: g.UserID,
|
|
HiddenCollections: g.HiddenCollections,
|
|
CollectionOrder: g.CollectionOrder,
|
|
ItemsPerSection: g.ItemsPerSection,
|
|
UpdatedAt: g.UpdatedAt,
|
|
}, nil
|
|
}
|
|
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)
|
|
}
|
|
|
|
// System collections are PERMANENT rows identified by the stable
|
|
// (user_id, query_type) pair — restore never deletes or re-creates,
|
|
// so the row id (and every reference to it: device shelf mappings,
|
|
// dashboard order/hidden prefs, /collections/{id} links) never churns.
|
|
// Resolution includes tombstoned rows: restoring a deleted section
|
|
// un-deletes the exact same row.
|
|
row, err := s.db.GetSystemCollectionByQueryType(ctx, database.GetSystemCollectionByQueryTypeParams{
|
|
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
|
QueryType: pgtype.Text{String: meta.QueryType, Valid: true},
|
|
})
|
|
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
|
return err
|
|
}
|
|
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Provision: the only path that mints a system-collection row.
|
|
_, cerr := 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 cerr
|
|
}
|
|
|
|
// "full": clear item assignments (a fresh section has none) and reset
|
|
// everything, IN PLACE. "keep_books": metadata-only, in place. Both
|
|
// un-tombstone, so a deleted section comes back as the same row.
|
|
if resetType != "keep_books" {
|
|
if err := s.db.ClearCollectionItems(ctx, row.ID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
_, err = s.db.RestoreSystemCollectionRow(ctx, database.RestoreSystemCollectionRowParams{
|
|
ID: row.ID,
|
|
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},
|
|
})
|
|
return err
|
|
}
|