diff --git a/cmd/server/tests/collections_smart_test.go b/cmd/server/tests/collections_smart_test.go new file mode 100644 index 00000000..e944ab2b --- /dev/null +++ b/cmd/server/tests/collections_smart_test.go @@ -0,0 +1,353 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "bookhoard/internal/database" +) + +// HTTP helpers ----------------------------------------------------------- + +func smartCreateCollection(t *testing.T, setup *TestServerSetup, token, name string, collectionType string) string { + t.Helper() + payload := map[string]interface{}{ + "name": name, + "description": "smart collections test", + "color": "blue", + "icon": "📚", + "collection_type": collectionType, + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) + require.Equal(t, http.StatusCreated, resp.StatusCode, "collection creation should succeed") + + var result map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + return result["id"].(string) +} + +func smartGetCollection(t *testing.T, setup *TestServerSetup, token, collectionID string) map[string]interface{} { + t.Helper() + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections/"+collectionID, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode) + var result map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + return result +} + +func smartPostRule(t *testing.T, setup *TestServerSetup, token, collectionID, field, operator, value string, enabled *bool) *http.Response { + t.Helper() + payload := map[string]interface{}{ + "field": field, + "operator": operator, + "value": value, + } + if enabled != nil { + payload["enabled"] = *enabled + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collectionID+"/rules", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + return resp +} + +func smartDecodeRuleID(t *testing.T, resp *http.Response) string { + t.Helper() + defer func(Body io.ReadCloser) { _ = Body.Close() }(resp.Body) + var result map[string]interface{} + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + rule := result["rule"].(map[string]interface{}) + return rule["id"].(string) +} + +// Data helpers ----------------------------------------------------------- + +func smartCreateLibraryAndBooks(t *testing.T, setup *TestServerSetup, userID uuid.UUID, titlesByGenre map[string]string) (libraryID string, bookIDs []string) { + t.Helper() + ctx := context.Background() + + libType, err := setup.DB.GetLibraryTypeByName(ctx, "ebooks") + require.NoError(t, err) + uniqueName := fmt.Sprintf("Test Smart Rules Library %d", time.Now().UnixNano()) + library, err := setup.DB.CreateLibrary(ctx, database.CreateLibraryParams{ + Name: uniqueName, + Description: pgtype.Text{String: "smart collections test library", Valid: true}, + LibraryTypeID: libType.ID, + CreatedByAdminID: pgtype.UUID{Bytes: [16]byte(userID), Valid: true}, + }) + require.NoError(t, err, "should create test library") + libUUID, err := uuid.FromBytes(library.ID.Bytes[0:16]) + require.NoError(t, err) + + t.Cleanup(func() { + _ = setup.DB.DeleteLibrary(ctx, library.ID) + }) + + for title, genre := range titlesByGenre { + media, err := setup.DB.CreateMediaItem(ctx, database.CreateMediaItemParams{ + LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, + Title: title, + Author: pgtype.Text{String: "Smart Rule Author", Valid: true}, + Genre: pgtype.Text{String: genre, Valid: true}, + FilePath: fmt.Sprintf("/tmp/%s-%d.epub", title, time.Now().UnixNano()), + FileSize: pgtype.Int8{Int64: 1024, Valid: true}, + MimeType: pgtype.Text{String: "application/epub+zip", Valid: true}, + }) + require.NoError(t, err, "should create test media item") + mediaUUID, err := uuid.FromBytes(media.ID.Bytes[0:16]) + require.NoError(t, err) + bookIDs = append(bookIDs, mediaUUID.String()) + } + return libUUID.String(), bookIDs +} + +func smartLibraryTitleSet(t *testing.T, coll map[string]interface{}) map[string]bool { + t.Helper() + books, _ := coll["books"].([]interface{}) + set := make(map[string]bool, len(books)) + for _, b := range books { + if m, ok := b.(map[string]interface{}); ok { + if title, ok := m["title"].(string); ok { + set[title] = true + } + } + } + return set +} + +// Tests ------------------------------------------------------------------ + +func TestSmartCollections(t *testing.T) { + setup := setupTestServer(t) + adminID := getTestUserID(t, setup.DB) + + t.Run("FirstRuleConvertsCollectionToSmart_LiveMembership", func(t *testing.T) { + _, bookIDs := smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart SF Book One": "Science Fiction", + "Smart SF Book Two": "Science Fiction", + "Smart FANT Book": "Fantasy", + }) + + // Rule evaluation ignores stored links: a static collection's own + // added item must NOT show up once the collection is smart. + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Live "+uuid.NewString()[:8], "static") + + // Add one hand-picked book (a Fantasy one that the smart rule + // will not match — so it must vanish from the render). + addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{bookIDs[2]}}) + addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) + addReq.Header.Set("Content-Type", "application/json") + addReq.Header.Set("Authorization", "Bearer "+setup.Token) + addResp, err := http.DefaultClient.Do(addReq) + require.NoError(t, err) + require.Equal(t, http.StatusNoContent, addResp.StatusCode) + _ = addResp.Body.Close() + + staticDetail := smartGetCollection(t, setup, setup.Token, collID) + assert.Equal(t, false, staticDetail["is_smart"]) + assert.Equal(t, float64(1), staticDetail["book_count"], "static collection shows its stored link") + + resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + _ = resp.Body.Close() + + smartDetail := smartGetCollection(t, setup, setup.Token, collID) + assert.Equal(t, true, smartDetail["is_smart"], "rules presence derives the type") + assert.Equal(t, float64(2), smartDetail["book_count"], "live rule matches only") + + memberTitles := smartLibraryTitleSet(t, smartDetail) + assert.True(t, memberTitles["Smart SF Book One"]) + assert.True(t, memberTitles["Smart SF Book Two"]) + assert.False(t, memberTitles["Smart FANT Book"], "hand-picked link is ignored under smart evaluation (v1 no hybrid)") + }) + + t.Run("DisabledRuleExcludedFromLiveSet", func(t *testing.T) { + smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart Diss Book": "Science Fiction", + }) + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Disabled "+uuid.NewString()[:8], "smart") + + resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + ruleID := smartDecodeRuleID(t, resp) + + assert.Equal(t, float64(1), smartGetCollection(t, setup, setup.Token, collID)["book_count"]) + + // Disable: membership drops to zero at once. + falseFlag := false + dBody, _ := json.Marshal(map[string]interface{}{"enabled": falseFlag}) + dReq, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, bytes.NewBuffer(dBody)) + dReq.Header.Set("Content-Type", "application/json") + dReq.Header.Set("Authorization", "Bearer "+setup.Token) + dResp, err := http.DefaultClient.Do(dReq) + require.NoError(t, err) + require.Equal(t, http.StatusOK, dResp.StatusCode) + _ = dResp.Body.Close() + + assert.Equal(t, float64(0), smartGetCollection(t, setup, setup.Token, collID)["book_count"], "disabled rules never match") + }) + + t.Run("SmartCollectionsRejectMembershipMutations", func(t *testing.T) { + smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart Guard Book": "Science Fiction", + }) + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Guard "+uuid.NewString()[:8], "smart") + + resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + _ = resp.Body.Close() + + addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}}) + addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) + addReq.Header.Set("Content-Type", "application/json") + addReq.Header.Set("Authorization", "Bearer "+setup.Token) + addResp, err := http.DefaultClient.Do(addReq) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, addResp.StatusCode) + _ = addResp.Body.Close() + + removeReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/books/"+uuid.NewString(), nil) + removeReq.Header.Set("Authorization", "Bearer "+setup.Token) + removeResp, err := http.DefaultClient.Do(removeReq) + require.NoError(t, err) + require.Equal(t, http.StatusBadRequest, removeResp.StatusCode) + _ = removeResp.Body.Close() + }) + + t.Run("RemoveLastRuleRevertsToStatic", func(t *testing.T) { + smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart Back Book": "Science Fiction", + }) + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Revert "+uuid.NewString()[:8], "smart") + + resp := smartPostRule(t, setup, setup.Token, collID, "tags", "contains", "no-such-tag", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + ruleID := smartDecodeRuleID(t, resp) + + smartDetail := smartGetCollection(t, setup, setup.Token, collID) + assert.Equal(t, true, smartDetail["is_smart"]) + + delReq, _ := http.NewRequest("DELETE", setup.Server.URL+"/api/collections/"+collID+"/rules/"+ruleID, nil) + delReq.Header.Set("Authorization", "Bearer "+setup.Token) + delResp, err := http.DefaultClient.Do(delReq) + require.NoError(t, err) + require.Equal(t, http.StatusOK, delResp.StatusCode) + _ = delResp.Body.Close() + + staticDetail := smartGetCollection(t, setup, setup.Token, collID) + assert.Equal(t, false, staticDetail["is_smart"], "removing the last rule reverts to static") + + // Membership mutations work again on the reverted static. + addBody, _ := json.Marshal(map[string]interface{}{"book_ids": []string{uuid.NewString()}}) + addReq, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections/"+collID+"/books", bytes.NewBuffer(addBody)) + addReq.Header.Set("Content-Type", "application/json") + addReq.Header.Set("Authorization", "Bearer "+setup.Token) + addResp, err := http.DefaultClient.Do(addReq) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, addResp.StatusCode) + _ = addResp.Body.Close() + }) + + t.Run("RuleValidationAndSystemCollections", func(t *testing.T) { + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart Valid "+uuid.NewString()[:8], "static") + + resp := smartPostRule(t, setup, setup.Token, collID, "not_a_field", "equals", "x", nil) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + _ = resp.Body.Close() + + resp = smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + _ = resp.Body.Close() + + // System collections may never carry rules. + colls, err := setup.DB.GetCollectionsByUser(context.Background(), pgtype.UUID{Bytes: [16]byte(adminID), Valid: true}) + require.NoError(t, err) + var systemID string + for _, c := range colls { + if c.IsSystemCollection.Bool { + systemID = uuid.UUID(c.ID.Bytes).String() + break + } + } + require.NotEmpty(t, systemID, "admin user should have system collections") + + sysResp := smartPostRule(t, setup, setup.Token, systemID, "genre", "equals", "x", nil) + require.Equal(t, http.StatusBadRequest, sysResp.StatusCode) + _ = sysResp.Body.Close() + }) + + t.Run("CollectionsListRespectsIncludeAutoWithLiveCounts", func(t *testing.T) { + _, _ = smartCreateLibraryAndBooks(t, setup, adminID, map[string]string{ + "Smart List Book": "Science Fiction", + }) + collID := smartCreateCollection(t, setup, setup.Token, "Test Smart List "+uuid.NewString()[:8], "smart") + + resp := smartPostRule(t, setup, setup.Token, collID, "genre", "equals", "Science Fiction", nil) + require.Equal(t, http.StatusCreated, resp.StatusCode) + _ = resp.Body.Close() + + // Default list (the Android app's read): smart collections hidden. + req, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections", nil) + req.Header.Set("Authorization", "Bearer "+setup.Token) + defaultResp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func(Body io.ReadCloser) { _ = Body.Close() }(defaultResp.Body) + var defaultData struct { + Collections []struct { + ID string `json:"id"` + } `json:"collections"` + } + require.NoError(t, json.NewDecoder(defaultResp.Body).Decode(&defaultData)) + for _, c := range defaultData.Collections { + assert.NotEqual(t, collID, c.ID, "default listing excludes rule-driven collections (app compat)") + } + + // Opt-in listing includes them with LIVE counts. + scopeReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/collections?include_auto=true", nil) + scopeReq.Header.Set("Authorization", "Bearer "+setup.Token) + scopeResp, err := http.DefaultClient.Do(scopeReq) + require.NoError(t, err) + defer func(Body io.ReadCloser) { _ = Body.Close() }(scopeResp.Body) + var scopeData struct { + Collections []struct { + ID string `json:"id"` + BookCount int `json:"book_count"` + IsSmart bool `json:"is_smart"` + } `json:"collections"` + } + require.NoError(t, json.NewDecoder(scopeResp.Body).Decode(&scopeData)) + found := false + for _, c := range scopeData.Collections { + if c.ID == collID { + found = true + assert.True(t, c.IsSmart) + assert.Equal(t, 1, c.BookCount, "smart card counts rule matches live") + } + } + assert.True(t, found, "include_auto=true must surface smart collections") + }) +} diff --git a/internal/database/querier.go b/internal/database/querier.go index a84280c0..730ffc8a 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -42,6 +42,10 @@ type Querier interface { ClearMediaItemMetadataOverrides(ctx context.Context, id pgtype.UUID) error CountAdmins(ctx context.Context) (int64, error) CountArchivedMediaItems(ctx context.Context) (int64, error) + // Live item count for a STATIC collection (rule-free cards on the + // collections page); same live filter as GetCollectionItems, plus the + // future-hybrid excluded exclusion and an optional library scope. + CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) // Count unlinked books for a device CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) @@ -358,6 +362,12 @@ type Querier interface { GetUserReadingHistory(ctx context.Context, arg GetUserReadingHistoryParams) ([]GetUserReadingHistoryRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error) + // Smart ("dynamic") collections: rules are evaluated LIVE at render, so + // membership comes from the candidate book set instead of collection_items. + // The set is scoped to the requesting user's visible libraries (family + // lens enforced at the source) and live items only — the same filters the + // static collection paths apply. + GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) HasRecentConflictResolution(ctx context.Context, arg HasRecentConflictResolutionParams) (bool, error) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) // Check if book is in collection @@ -474,6 +484,9 @@ type Querier interface { TombstoneSystemCollection(ctx context.Context, id pgtype.UUID) error // Update collection UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) + // Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write + // of the JSONB without touching metadata stale clients did not send). + UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) UpdateDashboardPreferences(ctx context.Context, arg UpdateDashboardPreferencesParams) (UserDashboardPreferences, error) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error) UpdateDeviceAuthToken(ctx context.Context, arg UpdateDeviceAuthTokenParams) (Devices, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index cbe71880..53d82863 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -293,6 +293,31 @@ func (q *Queries) CountArchivedMediaItems(ctx context.Context) (int64, error) { return count, err } +const CountCollectionLiveItems = `-- name: CountCollectionLiveItems :one +SELECT COUNT(*)::int FROM collection_items ci +JOIN media_items mi ON ci.media_item_id = mi.id +WHERE ci.collection_id = $1 + AND NOT COALESCE(ci.excluded, false) + AND ($2::uuid IS NULL OR mi.library_id = $2::uuid) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 +` + +type CountCollectionLiveItemsParams struct { + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` +} + +// Live item count for a STATIC collection (rule-free cards on the +// collections page); same live filter as GetCollectionItems, plus the +// future-hybrid excluded exclusion and an optional library scope. +func (q *Queries) CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) { + row := q.db.QueryRow(ctx, CountCollectionLiveItems, arg.CollectionID, arg.LibraryID) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one SELECT COUNT(*) as count FROM unlinked_books @@ -8512,6 +8537,110 @@ func (q *Queries) GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtyp return items, nil } +const GetVisibleLiveMediaForRules = `-- name: GetVisibleLiveMediaForRules :many +SELECT mi.id, mi.library_id, mi.title, mi.author, mi.isbn, mi.description, mi.file_path, mi.file_size, mi.mime_type, mi.cover_image_path, mi.series, mi.series_number, mi.tags, mi.asin, mi.date_published, mi.publisher, mi.contributors, mi.language, mi.edition, mi.page_count, mi.genre, mi.copyright_year, mi.goodreads_id, mi.openlibrary_id, mi.google_books_id, mi.added_by_admin_id, mi.created_at, mi.imported_at, mi.updated_at, mi.format_group, mi.format_mimetype, mi.is_reflowable, mi.has_fixed_layout, mi.total_characters, mi.chapter_count, mi.entitlement_id, mi.revision_number, mi.kobo_content_id, mi.kobo_metadata, mi.manga_type, mi.reading_direction, mi.series_count, mi.volume, mi.imprint, mi.age_rating, mi.web_url, mi.story_arc, mi.is_black_and_white, mi.metadata_notes, mi.community_rating, mi.alternate_info, mi.scan_information, mi.summary, mi.metadata_overrides, mi.missing_scan_count, mi.archived_at, mi.chapter_metadata, mi.library_type_name, mi.tags_search, mi.contributors_search, mi.file_sha256, mi.opf_identifier, mi.opf_uuid, mi.hash_confidence FROM media_items mi +WHERE ($1::uuid IS NULL OR mi.library_id = $1::uuid) + AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[])) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 +ORDER BY mi.title ASC +` + +type GetVisibleLiveMediaForRulesParams struct { + LibraryID pgtype.UUID `db:"library_id" json:"library_id"` + VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"` +} + +// Smart ("dynamic") collections: rules are evaluated LIVE at render, so +// membership comes from the candidate book set instead of collection_items. +// The set is scoped to the requesting user's visible libraries (family +// lens enforced at the source) and live items only — the same filters the +// static collection paths apply. +func (q *Queries) GetVisibleLiveMediaForRules(ctx context.Context, arg GetVisibleLiveMediaForRulesParams) ([]MediaItems, error) { + rows, err := q.db.Query(ctx, GetVisibleLiveMediaForRules, arg.LibraryID, arg.VisibleLibraryIds) + if err != nil { + return nil, err + } + defer rows.Close() + items := []MediaItems{} + for rows.Next() { + var i MediaItems + if err := rows.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.ImportedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.MangaType, + &i.ReadingDirection, + &i.SeriesCount, + &i.Volume, + &i.Imprint, + &i.AgeRating, + &i.WebUrl, + &i.StoryArc, + &i.IsBlackAndWhite, + &i.MetadataNotes, + &i.CommunityRating, + &i.AlternateInfo, + &i.ScanInformation, + &i.Summary, + &i.MetadataOverrides, + &i.MissingScanCount, + &i.ArchivedAt, + &i.ChapterMetadata, + &i.LibraryTypeName, + &i.TagsSearch, + &i.ContributorsSearch, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const HasRecentConflictResolution = `-- name: HasRecentConflictResolution :one SELECT EXISTS( SELECT 1 FROM sync_conflicts @@ -12383,6 +12512,39 @@ func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionPara return i, err } +const UpdateCollectionRulesJSON = `-- name: UpdateCollectionRulesJSON :one +UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING id, user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, query_type, priority, is_system_collection, created_at, deleted_at +` + +type UpdateCollectionRulesJSONParams struct { + ID pgtype.UUID `db:"id" json:"id"` + AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` +} + +// Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write +// of the JSONB without touching metadata stale clients did not send). +func (q *Queries) UpdateCollectionRulesJSON(ctx context.Context, arg UpdateCollectionRulesJSONParams) (Collections, error) { + row := q.db.QueryRow(ctx, UpdateCollectionRulesJSON, arg.ID, arg.AutoAssignRules) + var i Collections + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Color, + &i.Icon, + &i.AutoAssignRules, + &i.ViewSettings, + &i.ShowOnDashboard, + &i.QueryType, + &i.Priority, + &i.IsSystemCollection, + &i.CreatedAt, + &i.DeletedAt, + ) + return i, err +} + const UpdateDashboardPreferences = `-- name: UpdateDashboardPreferences :one UPDATE user_dashboard_preferences SET hidden_collections = $2, diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index eec78ccc..5ed4ffa6 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -2190,6 +2190,11 @@ SET WHERE id = $1 RETURNING *; +-- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write +-- of the JSONB without touching metadata stale clients did not send). +-- name: UpdateCollectionRulesJSON :one +UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *; + -- Delete collection -- name: DeleteCollection :exec DELETE FROM collections WHERE id = $1; @@ -2628,6 +2633,31 @@ WHERE ci.collection_id = sqlc.narg('collection_id') ORDER BY ci.added_at DESC LIMIT sqlc.narg('limit'); +-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so +-- membership comes from the candidate book set instead of collection_items. +-- The set is scoped to the requesting user's visible libraries (family +-- lens enforced at the source) and live items only — the same filters the +-- static collection paths apply. +-- name: GetVisibleLiveMediaForRules :many +SELECT mi.* FROM media_items mi +WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) + AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[])) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0 +ORDER BY mi.title ASC; + +-- Live item count for a STATIC collection (rule-free cards on the +-- collections page); same live filter as GetCollectionItems, plus the +-- future-hybrid excluded exclusion and an optional library scope. +-- name: CountCollectionLiveItems :one +SELECT COUNT(*)::int FROM collection_items ci +JOIN media_items mi ON ci.media_item_id = mi.id +WHERE ci.collection_id = $1 + AND NOT COALESCE(ci.excluded, false) + AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) + AND mi.archived_at IS NULL + AND mi.missing_scan_count = 0; + -- name: GetLibraryItems :many SELECT mi.* FROM media_items mi WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid) diff --git a/internal/handlers/collections.go b/internal/handlers/collections.go index 47aa892a..2c776b26 100644 --- a/internal/handlers/collections.go +++ b/internal/handlers/collections.go @@ -40,6 +40,7 @@ type CreateCollectionRequest struct { Description string `form:"description" json:"description"` Color string `form:"color" json:"color"` Icon string `form:"icon" json:"icon"` + CollectionType string `form:"collection_type" json:"collection_type"` // "static" (default) or "smart" (rule-based) AutoAssignRules []services.Rule `form:"auto_assign_rules" json:"auto_assign_rules"` ViewSettings map[string]interface{} `form:"view_settings" json:"view_settings"` } @@ -118,6 +119,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { } bookCount := int32(0) + isSmart := req.CollectionType == "smart" || len(req.AutoAssignRules) > 0 response := map[string]interface{}{ "id": uuid.UUID(collection.ID.Bytes).String(), "user_id": uuid.UUID(collection.UserID.Bytes).String(), @@ -125,6 +127,7 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { "description": textToString(collection.Description), "color": textToString(collection.Color), "icon": textToString(collection.Icon), + "collection_type": map[bool]string{true: "smart", false: "static"}[isSmart], "auto_assign_rules": collection.AutoAssignRules, "view_settings": collection.ViewSettings, "book_count": bookCount, @@ -133,7 +136,13 @@ func (h *CollectionHandler) CreateCollection(c *echo.Context) error { // Add redirect header for HTMX requests if c.Request().Header.Get("HX-Request") == "true" { - c.Response().Header().Set("HX-Redirect", "/collections") + // A smart collection starts with an empty ruleset; the builder is + // where it becomes real. Static collections head back to the grid. + if isSmart { + c.Response().Header().Set("HX-Redirect", "/collections/"+uuid.UUID(collection.ID.Bytes).String()+"/rules") + } else { + c.Response().Header().Set("HX-Redirect", "/collections") + } } return c.JSON(http.StatusCreated, response) @@ -158,31 +167,46 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error { AutoAssignRules json.RawMessage `json:"auto_assign_rules"` CreatedAt string `json:"created_at"` BookCount int `json:"book_count"` + IsSmart bool `json:"is_smart"` } response := make([]CollectionResponse, 0, len(collections)) for _, col := range collections { - if !includeAuto && len(col.AutoAssignRules) > 0 { + isSmart := h.collectionService.IsSmartCollection(col.AutoAssignRules) + if !includeAuto && isSmart { + // Legacy default for API clients (the Android app pre-dates + // smart collections): rule-driven collections stay opt-in so + // their membership could never be stale for old readers. continue } bookCount := 0 + var libUUID pgtype.UUID if libraryID != "" { - libUUID, libErr := uuid.Parse(libraryID) - if libErr == nil { - items, countErr := h.db.GetCollectionItemsForDashboard(c.Request().Context(), database.GetCollectionItemsForDashboardParams{ - CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true}, - LibraryID: pgtype.UUID{Bytes: libUUID, Valid: true}, - Limit: pgtype.Int4{Int32: 10000, Valid: true}, + if libParsed, libErr := uuid.Parse(libraryID); libErr == nil { + libUUID = pgtype.UUID{Bytes: libParsed, Valid: true} + } + } + if isSmart { + rules := h.collectionService.ParseRules(col.AutoAssignRules) + visibleIDs, visErr := h.userVisibleLibraryIDs(c) + if visErr == nil { + matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ + LibraryID: libUUID, + VisibleIDs: visibleIDs, }) - if countErr == nil { - for _, item := range items { - if !item.Excluded.Valid || !item.Excluded.Bool { - bookCount++ - } - } + if evalErr == nil { + bookCount = len(matches) } } + } else { + count, countErr := h.db.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{ + CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true}, + LibraryID: libUUID, + }) + if countErr == nil { + bookCount = int(count) + } } response = append(response, CollectionResponse{ @@ -194,6 +218,7 @@ func (h *CollectionHandler) GetCollections(c *echo.Context) error { AutoAssignRules: col.AutoAssignRules, CreatedAt: col.CreatedAt.Time.String(), BookCount: bookCount, + IsSmart: isSmart, }) } @@ -236,7 +261,31 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { libUUID = pgtype.UUID{Bytes: parsed, Valid: true} } - if collection.QueryType.Valid && collection.QueryType.String != "" { + isSmart := h.collectionService.IsSmartCollection(collection.AutoAssignRules) + if isSmart { + rules := h.collectionService.ParseRules(collection.AutoAssignRules) + visibleIDs, err := h.userVisibleLibraryIDs(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + matches, evalErr := h.collectionService.EvaluateSmartCollection(c.Request().Context(), rules, services.SmartEvalOptions{ + LibraryID: libUUID, + VisibleIDs: visibleIDs, + }) + if evalErr != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": evalErr.Error()}) + } + bookList = make([]BookInfo, 0, len(matches)) + for _, item := range matches { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookList = append(bookList, BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: textToString(item.Author), + CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), + }) + } + } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) dashboardSvc := services.NewDashboardService(h.db) @@ -323,6 +372,7 @@ func (h *CollectionHandler) GetCollection(c *echo.Context) error { "view_settings": viewSettings, "books": bookList, "book_count": len(bookList), + "is_smart": isSmart, "created_at": collection.CreatedAt.Time.String(), }) } @@ -400,6 +450,10 @@ func (h *CollectionHandler) AddBooks(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } + if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil { + return guardErr + } + user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) @@ -447,6 +501,10 @@ func (h *CollectionHandler) RemoveBook(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } + if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil { + return guardErr + } + bookID, err := uuid.Parse(c.Param("bookId")) if err != nil { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid book id"}) @@ -487,6 +545,10 @@ func (h *CollectionHandler) BulkRemoveBooks(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) } + if guardErr := h.rejectSmartMutation(c, collectionID); guardErr != nil { + return guardErr + } + user := c.Get("user").(database.Users) userUUID := uuid.UUID(user.ID.Bytes) @@ -727,6 +789,170 @@ func (h *CollectionHandler) GetUserCollectionsList(c *echo.Context) ([]database. return h.collectionService.GetUserCollections(c.Request().Context(), userUUID) } +// userVisibleLibraryIDs loads the requester's visible-library set (the +// family lens). Smart-collection evaluation funnels every book candidate +// through it so hidden libraries contribute nothing. +func (h *CollectionHandler) userVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) { + user, ok := c.Get("user").(database.Users) + if !ok { + return nil, echo.NewHTTPError(http.StatusUnauthorized, "invalid user") + } + rows, err := h.libraryService.GetUserVisibleLibraries(c.Request().Context(), user.ID) + if err != nil { + return nil, fmt.Errorf("failed to check library access: %w", err) + } + ids := make([]pgtype.UUID, 0, len(rows)) + for _, lib := range rows { + if lib.ID.Valid { + ids = append(ids, lib.ID) + } + } + return ids, nil +} + +// UserVisibleLibraryIDs is the exported entry point for the router's +// frontend page handlers, which resolve the visible-library set before +// evaluating smart-collection rule matches. +func (h *CollectionHandler) UserVisibleLibraryIDs(c *echo.Context) ([]pgtype.UUID, error) { + return h.userVisibleLibraryIDs(c) +} + +// CollectionService returns the underlying service so the router's +// frontend handlers can share the exact same evaluation the API uses. +func (h *CollectionHandler) CollectionService() *services.CollectionService { + return h.collectionService +} + +// rejectSmartMutation returns a 400 for smart collections: membership is +// derived live from rules, so stored link mutations have nothing to act +// on. v1 has no hybrid mode — add/remove with both rules and hand-picked +// books is deliberately unsupported (collection_items.excluded awaits it). +func (h *CollectionHandler) rejectSmartMutation(c *echo.Context, collectionID uuid.UUID) *echo.HTTPError { + collection, err := h.GetCollectionData(c, collectionID) + if err != nil { + return echo.NewHTTPError(http.StatusNotFound, "collection not found") + } + if h.collectionService.IsSmartCollection(collection.AutoAssignRules) { + return echo.NewHTTPError(http.StatusBadRequest, "smart collections are rule-based — membership is managed by the collection's rules, not by hand") + } + return nil +} + +type AddRuleRequest struct { + Field string `json:"field" validate:"required"` + Operator string `json:"operator" validate:"required"` + Value string `json:"value" validate:"required"` + Enabled *bool `json:"enabled"` + Priority int `json:"priority"` +} + +// AddRule handles POST /api/collections/:id/rules — appends one rule to +// the ruleset. The first rule converts a static collection to smart. +func (h *CollectionHandler) AddRule(c *echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + + collection, err := h.GetCollectionData(c, collectionID) + if err != nil { + return c.JSON(http.StatusNotFound, map[string]string{"error": "collection not found"}) + } + if collection.IsSystemCollection.Bool { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "system collections cannot have rules"}) + } + + var req AddRuleRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + updated, rule, err := h.collectionService.AddRule(c.Request().Context(), collectionID, services.Rule{ + Field: req.Field, + Operator: req.Operator, + Value: req.Value, + Enabled: req.Enabled, + Priority: req.Priority, + }) + if err != nil { + var ruleErr *services.RuleValidationError + if errors.As(err, &ruleErr) { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusCreated, map[string]interface{}{ + "collection_id": uuid.UUID(updated.ID.Bytes).String(), + "rule": rule, + "is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules), + }) +} + +// UpdateRule handles PUT /api/collections/:id/rules/:ruleId — currently +// the enabled toggle; the payload may only carry {"enabled": bool}. +func (h *CollectionHandler) UpdateRule(c *echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + ruleID := c.Param("ruleId") + if ruleID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"}) + } + + var req struct { + Enabled *bool `json:"enabled" validate:"required"` + } + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request"}) + } + if err := c.Validate(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + + updated, err := h.collectionService.SetRuleEnabled(c.Request().Context(), collectionID, ruleID, *req.Enabled) + if err != nil { + if strings.Contains(err.Error(), "rule not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "collection_id": uuid.UUID(updated.ID.Bytes).String(), + "is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules), + }) +} + +// RemoveRule handles DELETE /api/collections/:id/rules/:ruleId. +func (h *CollectionHandler) RemoveRule(c *echo.Context) error { + collectionID, err := uuid.Parse(c.Param("id")) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid collection id"}) + } + ruleID := c.Param("ruleId") + if ruleID == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "rule id required"}) + } + + updated, err := h.collectionService.RemoveRule(c.Request().Context(), collectionID, ruleID) + if err != nil { + if strings.Contains(err.Error(), "rule not found") { + return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + + return c.JSON(http.StatusOK, map[string]interface{}{ + "collection_id": uuid.UUID(updated.ID.Bytes).String(), + "is_smart": h.collectionService.IsSmartCollection(updated.AutoAssignRules), + }) +} + type TestRulesRequest struct { Rules []map[string]interface{} `json:"rules" validate:"required"` } @@ -748,16 +974,32 @@ func (h *CollectionHandler) TestRules(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) } - mediaItems, err := h.db.ListMediaItems(c.Request().Context(), database.ListMediaItemsParams{ - Limit: 1000, - Offset: 0, + // Candidates mirror what smart collections actually evaluate against: + // the requester's visible libraries and live items only — the preview + // must not promise matches the live render will never produce. + visibleIDs, err := h.userVisibleLibraryIDs(c) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + var visibleArg []pgtype.UUID + if len(visibleIDs) > 0 { + visibleArg = visibleIDs + } + mediaItems, err := h.db.GetVisibleLiveMediaForRules(c.Request().Context(), database.GetVisibleLiveMediaForRulesParams{ + LibraryID: pgtype.UUID{}, + VisibleLibraryIds: visibleArg, }) if err != nil { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load media items"}) } + listRows := make([]database.ListMediaItemsRow, len(mediaItems)) + for i, item := range mediaItems { + listRows[i] = mediaItemsToListMediaItemsRow(item) + } + var matches []BookMatch - for _, item := range mediaItems { + for _, item := range listRows { matchReason := h.checkRulesAgainstBook(item, req.Rules) if matchReason != "" { author := "" @@ -886,6 +1128,16 @@ func (h *CollectionHandler) HandleBulkAddBooks(c *echo.Context) error { return c.JSON(http.StatusBadRequest, map[string]string{"error": "operations required"}) } + // Smart collections are rejected up front per operation so the + // caller sees a clean, actionable error instead of silent no-ops. + for _, op := range req.Operations { + if colUUID, err := uuid.Parse(op.CollectionID); err == nil { + if guardErr := h.rejectSmartMutation(c, colUUID); guardErr != nil { + return guardErr + } + } + } + results := make([]map[string]interface{}, 0) successCount := 0 failedCount := 0 diff --git a/internal/router/collections.go b/internal/router/collections.go index 03490e28..ab15a948 100644 --- a/internal/router/collections.go +++ b/internal/router/collections.go @@ -22,6 +22,12 @@ func registerCollectionsRoutes(cfg *Config) { collections.POST("/test-rules", cfg.CollectionHandler.TestRules) collections.POST("/preview", cfg.CollectionHandler.PreviewCollection) + // Smart collection rule CRUD (the rules builder persists through + // these; type follows rules presence — first rule = smart). + collections.POST("/:id/rules", cfg.CollectionHandler.AddRule) + collections.PUT("/:id/rules/:ruleId", cfg.CollectionHandler.UpdateRule) + collections.DELETE("/:id/rules/:ruleId", cfg.CollectionHandler.RemoveRule) + // Device shelf mapping routes deviceCollections := protected.Group("/devices/:id/collections") deviceCollections.GET("", cfg.CollectionHandler.GetDeviceMappings) diff --git a/internal/router/frontend.go b/internal/router/frontend.go index 319f5cc2..2e7f317a 100644 --- a/internal/router/frontend.go +++ b/internal/router/frontend.go @@ -19,8 +19,10 @@ import ( "bookhoard/internal/utils" "bookhoard/internal/version" "bookhoard/templates" + "errors" "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/labstack/echo/v5" @@ -496,14 +498,56 @@ func registerFrontendRoutes(cfg *Config) { errorMsg = "Error loading collections" } + // Live per-card counts: smart collections are rule-matched at + // render against the requesting user's visible libraries; static + // ones count their stored links. Library filter applies when a + // specific library is selected. + libRes := resolveLibrary(c, cfg, user.ID) + var libScope pgtype.UUID + if !libRes.IsAll { + if parsed, parseErr := uuid.Parse(libRes.LibraryID); parseErr == nil { + libScope = pgtype.UUID{Bytes: parsed, Valid: true} + } + } + visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c) + if visErr != nil { + log.Printf("UserVisibleLibraryIDs failed: %v", visErr) + visibleIDs = nil + } + colData := make([]templates.CollectionData, len(collections)) + colService := cfg.CollectionHandler.CollectionService() for i, col := range collections { + isSmart := colService.IsSmartCollection(col.AutoAssignRules) + bookCount := 0 + if isSmart { + matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), + colService.ParseRules(col.AutoAssignRules), services.SmartEvalOptions{ + LibraryID: libScope, + VisibleIDs: visibleIDs, + }) + if evalErr == nil { + bookCount = len(matches) + } + } else { + count, countErr := cfg.Queries.CountCollectionLiveItems(c.Request().Context(), database.CountCollectionLiveItemsParams{ + CollectionID: pgtype.UUID{Bytes: col.ID.Bytes, Valid: true}, + LibraryID: libScope, + }) + if countErr == nil { + bookCount = int(count) + } + } + colData[i] = templates.CollectionData{ ID: uuid.UUID(col.ID.Bytes).String(), Name: col.Name, Description: getText(col.Description), Color: getText(col.Color), Icon: getText(col.Icon), + IsSystem: col.IsSystemCollection.Bool, + IsSmart: isSmart, + BookCount: bookCount, } } @@ -572,6 +616,47 @@ func registerFrontendRoutes(cfg *Config) { return c.HTML(http.StatusOK, buf.String()) }) + // Smart collection rules builder page (the smart editor). User + // collections only: system sections keep their hardwired queries. + frontendProtected.GET("/collections/:id/rules", func(c *echo.Context) error { + user, err := getTemplateUserWithTheme(c, cfg) + if err != nil { + return renderErrorPage(c, "Error loading user", "user_load_error") + } + collectionID := c.Param("id") + collUUID, err := uuid.Parse(collectionID) + if err != nil { + return renderErrorPage(c, "Invalid collection ID", "invalid_id") + } + collection, err := cfg.Queries.GetCollection(c.Request().Context(), pgtype.UUID{Bytes: collUUID, Valid: true}) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return renderErrorPage(c, "Collection not found", "not_found") + } + return renderErrorPage(c, "Error loading collection", "collection_load_error") + } + if collection.IsSystemCollection.Bool { + return renderErrorPage(c, "System collections cannot have rules", "not_allowed") + } + + colData := templates.CollectionData{ + ID: collectionID, + Name: collection.Name, + Description: getText(collection.Description), + Color: getText(collection.Color), + Icon: getText(collection.Icon), + IsSystem: collection.IsSystemCollection.Bool, + IsSmart: cfg.CollectionHandler.CollectionService().IsSmartCollection(collection.AutoAssignRules), + } + + var buf bytes.Buffer + err = templates.CollectionRules(user, colData).Render(c.Request().Context(), &buf) + if err != nil { + return err + } + return c.HTML(http.StatusOK, buf.String()) + }) + // Collection detail page (works for both system and user collections) frontendProtected.GET("/collections/:id", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) @@ -599,7 +684,44 @@ func registerFrontendRoutes(cfg *Config) { libRes := resolveLibrary(c, cfg, user.ID) libraryID := libRes.LibraryID - if collection.QueryType.Valid && collection.QueryType.String != "" { + var libScope pgtype.UUID + if !libRes.IsAll { + if parsed, parseErr := uuid.Parse(libraryID); parseErr == nil { + libScope = pgtype.UUID{Bytes: parsed, Valid: true} + } + } + colService := cfg.CollectionHandler.CollectionService() + isSmart := colService.IsSmartCollection(collection.AutoAssignRules) + + visibleIDs, visErr := cfg.CollectionHandler.UserVisibleLibraryIDs(c) + if visErr != nil { + log.Printf("UserVisibleLibraryIDs failed: %v", visErr) + visibleIDs = nil + } + + if isSmart { + // Smart (dynamic) collection: membership is evaluated live — + // visible libraries, live items only, any enabled rule matches. + matches, evalErr := colService.EvaluateSmartCollection(c.Request().Context(), + colService.ParseRules(collection.AutoAssignRules), services.SmartEvalOptions{ + LibraryID: libScope, + VisibleIDs: visibleIDs, + }) + if evalErr != nil { + return renderErrorPage(c, "Error loading books", "books_load_error") + } + bookCards := make([]handlers.BookInfo, 0, len(matches)) + for _, item := range matches { + itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16]) + bookCards = append(bookCards, handlers.BookInfo{ + MediaItemID: itemUUID.String(), + Title: item.Title, + Author: getText(item.Author), + CoverImagePath: utils.ResolveMediaURL(item.LibraryID, item.CoverImagePath), + }) + } + books = bookCards + } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { dashboardSvc := services.NewDashboardService(cfg.Queries) sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libRes.LibUUID, 1000, []string{}, []string{}) if secErr != nil { @@ -684,6 +806,8 @@ func registerFrontendRoutes(cfg *Config) { Color: collection.Color.String, Icon: collection.Icon.String, IsSystem: collection.IsSystemCollection.Bool, + IsSmart: isSmart, + BookCount: len(books), } var buf bytes.Buffer diff --git a/internal/services/collection_service.go b/internal/services/collection_service.go index f7252dfe..e9d0dbcb 100644 --- a/internal/services/collection_service.go +++ b/internal/services/collection_service.go @@ -24,8 +24,30 @@ type Rule struct { Operator string `json:"operator"` // "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than" Value string `json:"value"` Priority int `json:"priority"` // 1-10, higher values take precedence + // Enabled gates the rule in the LIVE evaluation used by smart + // collections; absent (JSON omitempty) means enabled — rules written + // before the field existed keep matching. + Enabled *bool `json:"enabled,omitempty"` } +// Known rule fields and operators — the rules builder UI and the +// persisted JSONB documents must agree with this vocabulary. +var ruleFields = map[string]bool{ + "genre": true, "series": true, "author": true, "language": true, + "publisher": true, "copyright_year": true, "tags": true, +} + +var ruleOperators = map[string]bool{ + "equals": true, "not_equals": true, "contains": true, "not_contains": true, + "starts_with": true, "ends_with": true, "greater_than": true, "less_than": true, +} + +// RuleValidationError reports a rule payload the evaluator could never +// honor (unknown field/operator, empty value). +type RuleValidationError struct{ Msg string } + +func (e *RuleValidationError) Error() string { return e.Msg } + // RuleEvaluation represents the result of evaluating a rule type RuleEvaluation struct { RuleID string `json:"rule_id"` @@ -244,6 +266,248 @@ func (s *CollectionService) GetBookCollections(ctx context.Context, mediaItemID return collections, nil } +// ParseRules decodes a collection's auto_assign_rules JSONB into typed +// rules. "null", empty, and malformed docs mean "no rules". +func (s *CollectionService) ParseRules(raw []byte) []Rule { + if len(raw) == 0 { + return nil + } + var rules []Rule + if err := json.Unmarshal(raw, &rules); err != nil { + return nil + } + return rules +} + +// IsSmartCollection derives the collection type: a non-empty ruleset +// makes it SMART (live-matched), an empty/absent ruleset makes it STATIC +// (stored collection_items links). +func (s *CollectionService) IsSmartCollection(raw []byte) bool { + return len(s.ParseRules(raw)) > 0 +} + +// EnabledRules filters a ruleset down to the rules that participate in +// live evaluation (absent enabled = enabled for backward compatibility). +func (s *CollectionService) EnabledRules(rules []Rule) []Rule { + enabled := make([]Rule, 0, len(rules)) + for _, rule := range rules { + if rule.Enabled == nil || *rule.Enabled { + enabled = append(enabled, rule) + } + } + return enabled +} + +// validateRule checks a rule payload the evaluator will have to honor. +func (s *CollectionService) validateRule(rule Rule) error { + if !ruleFields[rule.Field] { + return &RuleValidationError{Msg: "unknown rule field: " + rule.Field} + } + if !ruleOperators[rule.Operator] { + return &RuleValidationError{Msg: "unknown rule operator: " + rule.Operator} + } + if strings.TrimSpace(rule.Value) == "" { + return &RuleValidationError{Msg: "rule value is required"} + } + if rule.Priority < 0 || rule.Priority > 10 { + return &RuleValidationError{Msg: "priority must be between 0 and 10"} + } + return nil +} + +// AddRule appends a rule to the collection's ruleset, generating an id +// when the caller did not supply one. Adding the first rule CONVERTS a +// static collection to smart (type is derived from rules presence). +func (s *CollectionService) AddRule(ctx context.Context, collectionID uuid.UUID, rule Rule) (database.Collections, Rule, error) { + if err := s.validateRule(rule); err != nil { + return database.Collections{}, Rule{}, err + } + + collection, err := s.GetCollection(ctx, collectionID) + if err != nil { + return database.Collections{}, Rule{}, err + } + + rules := s.ParseRules(collection.AutoAssignRules) + if rule.ID == "" { + rule.ID = uuid.NewString() + } + rules = append(rules, rule) + + rulesJSON, marshalErr := json.Marshal(rules) + if marshalErr != nil { + return database.Collections{}, Rule{}, fmt.Errorf("failed to marshal rules: %w", marshalErr) + } + + updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{ + ID: pgtype.UUID{Bytes: collectionID, Valid: true}, + AutoAssignRules: rulesJSON, + }) + if err != nil { + return database.Collections{}, Rule{}, fmt.Errorf("failed to save rules: %w", err) + } + return updated, rule, nil +} + +// SetRuleEnabled flips a single rule's enabled flag in place. +func (s *CollectionService) SetRuleEnabled(ctx context.Context, collectionID uuid.UUID, ruleID string, enabled bool) (database.Collections, error) { + collection, err := s.GetCollection(ctx, collectionID) + if err != nil { + return database.Collections{}, err + } + rules := s.ParseRules(collection.AutoAssignRules) + found := false + for i := range rules { + if rules[i].ID == ruleID { + rules[i].Enabled = &enabled + found = true + break + } + } + if !found { + return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID) + } + + rulesJSON, marshalErr := json.Marshal(rules) + if marshalErr != nil { + return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr) + } + + updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{ + ID: pgtype.UUID{Bytes: collectionID, Valid: true}, + AutoAssignRules: rulesJSON, + }) + if err != nil { + return database.Collections{}, fmt.Errorf("failed to save rules: %w", err) + } + return updated, nil +} + +// RemoveRule deletes a single rule from the ruleset. Removing the LAST +// rule converts the collection back to static (type is derived). +func (s *CollectionService) RemoveRule(ctx context.Context, collectionID uuid.UUID, ruleID string) (database.Collections, error) { + collection, err := s.GetCollection(ctx, collectionID) + if err != nil { + return database.Collections{}, err + } + rules := s.ParseRules(collection.AutoAssignRules) + kept := make([]Rule, 0, len(rules)) + found := false + for _, rule := range rules { + if rule.ID == ruleID { + found = true + continue + } + kept = append(kept, rule) + } + if !found { + return database.Collections{}, fmt.Errorf("rule not found: %s", ruleID) + } + + rulesJSON, marshalErr := json.Marshal(kept) + if marshalErr != nil { + return database.Collections{}, fmt.Errorf("failed to marshal rules: %w", marshalErr) + } + + updated, err := s.db.UpdateCollectionRulesJSON(ctx, database.UpdateCollectionRulesJSONParams{ + ID: pgtype.UUID{Bytes: collectionID, Valid: true}, + AutoAssignRules: rulesJSON, + }) + if err != nil { + return database.Collections{}, fmt.Errorf("failed to save rules: %w", err) + } + return updated, nil +} + +// SmartEvalOptions scopes a smart collection's live evaluation. +type SmartEvalOptions struct { + // LibraryID, when valid, restricts matching to one library (the + // detail page's library switcher). + LibraryID pgtype.UUID + // VisibleIDs is the requesting user's visible-library set (family + // lens). Empty means no visible-library filter. + VisibleIDs []pgtype.UUID +} + +// EvaluateSmartCollection resolves a smart collection's membership LIVE: +// every visible live book matched by at least one enabled rule is in. +// No stored links are consulted — "truly dynamic," always current. +func (s *CollectionService) EvaluateSmartCollection(ctx context.Context, rules []Rule, opts SmartEvalOptions) ([]database.MediaItems, error) { + enabled := s.EnabledRules(rules) + if len(enabled) == 0 { + return []database.MediaItems{}, nil + } + + var visibleArg []pgtype.UUID + if len(opts.VisibleIDs) > 0 { + visibleArg = opts.VisibleIDs + } + items, err := s.db.GetVisibleLiveMediaForRules(ctx, database.GetVisibleLiveMediaForRulesParams{ + LibraryID: opts.LibraryID, + VisibleLibraryIds: visibleArg, + }) + if err != nil { + return nil, fmt.Errorf("failed to load candidate books: %w", err) + } + + matches := make([]database.MediaItems, 0, len(items)) + for _, item := range items { + if s.mediaItemMatchesAnyRule(item, enabled) { + matches = append(matches, item) + } + } + return matches, nil +} + +func (s *CollectionService) mediaItemMatchesAnyRule(item database.MediaItems, rules []Rule) bool { + for _, rule := range rules { + if s.mediaItemMatchesRule(item, rule) { + return true + } + } + return false +} + +func (s *CollectionService) mediaItemMatchesRule(item database.MediaItems, rule Rule) bool { + if rule.Enabled != nil && !*rule.Enabled { + return false + } + + var fieldValue string + switch rule.Field { + case "genre": + if item.Genre.Valid { + fieldValue = item.Genre.String + } + case "series": + if item.Series.Valid { + fieldValue = item.Series.String + } + case "author": + if item.Author.Valid { + fieldValue = item.Author.String + } + case "language": + if item.Language.Valid { + fieldValue = item.Language.String + } + case "publisher": + if item.Publisher.Valid { + fieldValue = item.Publisher.String + } + case "copyright_year": + if item.CopyrightYear.Valid { + fieldValue = fmt.Sprintf("%d", item.CopyrightYear.Int32) + } + case "tags": + if len(item.Tags) > 0 { + fieldValue = strings.Join(item.Tags, ", ") + } + } + + return s.evaluateRule(fieldValue, rule.Operator, rule.Value) +} + // EvaluateRules evaluates auto-assign rules for a media item func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow, rules []Rule) []RuleEvaluation { var evaluations []RuleEvaluation @@ -260,6 +524,11 @@ func (s *CollectionService) EvaluateRules(mediaItem database.ListMediaItemsRow, } for _, rule := range sortedRules { + // Disabled rules never match (kept for context in the UI only). + if rule.Enabled != nil && !*rule.Enabled { + evaluations = append(evaluations, RuleEvaluation{RuleID: rule.ID, Matches: false}) + continue + } eval := RuleEvaluation{ RuleID: rule.ID, Matches: false, diff --git a/templates/collection_modal.templ b/templates/collection_modal.templ index 39fd2c16..f1f25f7a 100644 --- a/templates/collection_modal.templ +++ b/templates/collection_modal.templ @@ -83,6 +83,25 @@ templ CollectionModal(collection CollectionData) { hx-post="/api/collections" hx-redirect="/collections" > +
Auto-Assign Rules
++ ⚡ Smart + Rules evaluated live — the collection is always current, no refresh needed +
A book is in the collection while it matches ANY enabled rule. Saving here takes effect immediately, everywhere the collection renders.
Create auto-assign rules to automatically add books to this collection
+Add a rule to make this collection smart — it will then match the rule live, everywhere. Removing the last rule makes it static again.
Add all Science Fiction books
Field: genre
Operator: equals
Value: Science FictionAdd books from a specific series
Field: series
Operator: starts with
Value: Harry PotterAdd books published in a year range
Field: copyright_year
Operator: greater than
Value: 2020