feat(collections): smart (dynamic) collections — rules evaluated live at render
Type is DERIVED, not stored: a saved ruleset makes a collection SMART,
no rules = STATIC (stored collection_items links, unchanged behavior).
No schema change — collections.auto_assign_rules JSONB already carried
the config; user's decision 2026-10-14 (handoff: live-at-render, not
refresh-on-scan; create-time Static/Smart toggle; v1 NO hybrid).
- Rule gains an enabled flag (absent = enabled, legacy rules unaffected).
- Rule CRUD: POST/PUT(DELETE) /api/collections/:id/rules[/:ruleId] — the
survivor dead effort of the rules-builder UI finally has real
endpoints (rules were never editable after creation; nothing ever
re-evaluated them). First rule converts a user collection to smart;
removing the last reverts it to static. UpdateCollectionRulesJSON
edits ONLY the rules doc so rule CRUD cannot clobber metadata.
- Live evaluation: candidates from GetVisibleLiveMediaForRules —
visible-library set (family lens enforced at source) + archived IS
NULL AND missing_scan_count = 0 — any enabled rule matches, title
order. Zero refresh hooks by design: always current by construction.
- Membership mutations (add/remove/bulk-add) are 400 on smart — v1 no
hybrid; collection_items.excluded stays the reserved future hook.
- Rules builder page (/collections/:id/rules) first time ever routable;
templ whole-attribute interpolation ("{ expr }" inside a quoted attr
value is literal text, as theme-{ user.Theme } silently proved).
- UI: create modal Static/⚡Smart cards (smart create redirects into the
rules builder), ⚡ badge + real per-card counts on the grid (smart =
live matches, static = stored-link count), smart detail renders
matched books with no hand-pick actions + an Edit Rules button.
- API: GET /:id gains is_smart + live books; GET /: keeps the Android
app's default exclusion, include_auto=true surfaces smart rows with
live counts; TestRules previews against the SAME candidates the live
render uses (visible + live, was raw first-1000).
- FIXED pre-existing regression from 7ce7321: static collection detail
with NO library_id returned zero books — QueryType != '' caught user
rows ('filter' default) and routed them to dashboard sections that
never contain them; now only real system query types go there
(handlers + SSR). App-visible symptom: /api/collections/:id without
library_id came back books:[] for every static collection.
Tests: collections_smart_test.go (conversion, disabled rules, mutation
guards, revert-to-static, validation, system-collection prohibition,
include_auto semantics). Full offline integration suite PASS.
This commit is contained in:
1 parent
df556d8208
commit
0a7fbd6f48
17 files changed
+1766
-440
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user