collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it).
483 lines
16 KiB
Go
483 lines
16 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, search ...*SearchService) *DashboardService {
|
|
return &DashboardService{
|
|
db: db,
|
|
collectionService: NewCollectionService(db, search...),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Legacy dashboard auto-assign path: no viewer progress
|
|
// loaded here (the dashboard's own hide_finished handles
|
|
// queue semantics) — played rules evaluate unplayed, honestly.
|
|
evaluations := s.collectionService.EvaluateRules(item, 0, 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
|
|
}
|