From e34d983a6824133992b979fa9ea3e1389c16f51f Mon Sep 17 00:00:00 2001 From: Daniel Tombor Date: Fri, 10 Jul 2026 12:39:33 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Feed=20core=20=E2=80=94=20env-provision?= =?UTF-8?q?ed,=20input-driven=20paging/search=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The foundational layer for offline-first paged feeds. One call — .feed(.posts) — owns a FeedModel, wires its fetch pipeline from the environment, and scopes the model + FeedState + control actions for the subtree; children read the model back with @Feed(PostsQuery.self). - PageableQuery: the domain query itself drives the feed (isSearch/cursor/ page(after:)); conformances for Posts/Quotes/CatalogueSearch queries and an input-less CatalogueSyncQuery for the SyncGroup-backed catalogue refresh - FeedModel: fetch(input) is the one entry point — the input decides browse vs search vs continuation; page-1 supersedes in-flight requests and superseded callers converge on the winner's receipt (race-proof); continuations fail quietly; returns FeedPage receipts so the owning view keeps paging state - FeedConfiguration: label + composed fetch pipeline (loader → upsert → nextCursor), with real pipelines as statics and stub injection for previews - Self-contained components: FeedStatusLine, FeedLoadMoreCell, FeedEmptyView read \.feedState + \.loadFeed/\.retryFeed — nothing passed through inits - tmbr-core: query DTOs gain Hashable, page(after:), languages/sources params, and view-facing convenience inits; closure-based RequestLoader in app-api - Groundwork: CatalogueItemSyncDescriptor rename + .book descriptor fix, CatalogueSearchEngine, detail-section pre-work, test-target dependency and container-lifetime fixes Tabs are intentionally untouched — they migrate in the stacked follow-ups (blog → quotes → catalogue). Co-Authored-By: Claude Opus 4.8 --- .../Sources/AppApi/HTTP/RequestLoader.swift | 11 +- app-api/Sources/AppApi/Sync/SyncLoaders.swift | 14 +- .../Sources/AppApi/Sync/SyncRequests.swift | 14 +- app-core/Package.swift | 7 +- app-core/Sources/AppCore/Blog/BlogModel.swift | 6 +- .../Catalogue/CatalogueItemDetailView.swift | 12 +- .../AppCore/Catalogue/CatalogueTab.swift | 4 +- .../Catalogue/Detail/AlbumDetailSection.swift | 39 +- .../Catalogue/Detail/BookDetailSection.swift | 43 +- .../Detail/CataloguePreviewHeader.swift | 36 ++ .../Catalogue/Detail/MovieDetailSection.swift | 36 +- .../Detail/OrphanDetailSection.swift | 29 +- .../Detail/PlaylistDetailSection.swift | 32 +- .../Detail/PodcastDetailSection.swift | 39 +- .../Catalogue/Detail/SongDetailSection.swift | 39 +- .../Search/CatalogueSearchEngine.swift | 55 +++ .../Catalogue/Sync/CatalogueItemSync.swift | 54 --- .../Sync/CatalogueItemSyncDescriptor.swift | 65 +++ .../AppCore/Catalogue/Sync/Loader.swift | 11 +- .../AppCore/Catalogue/Sync/Upserter.swift | 29 +- .../Views/CatalogueItemStatusLine.swift | 4 +- app-core/Sources/AppCore/Feed/Feed.swift | 31 ++ .../AppCore/Feed/FeedConfiguration.swift | 198 ++++++++ .../Sources/AppCore/Feed/FeedEmptyView.swift | 38 ++ .../AppCore/Feed/FeedEnvironment.swift | 31 ++ .../AppCore/Feed/FeedLoadMoreCell.swift | 35 ++ app-core/Sources/AppCore/Feed/FeedModel.swift | 228 +++++++++ .../AppCore/Feed/FeedPageLoadAction.swift | 15 + .../AppCore/Feed/FeedRetryAction.swift | 16 + app-core/Sources/AppCore/Feed/FeedState.swift | 44 ++ .../Sources/AppCore/Feed/FeedStatusLine.swift | 41 ++ .../Sources/AppCore/Feed/FilterMapping.swift | 34 ++ .../Sources/AppCore/Feed/PageableQuery.swift | 43 ++ .../AppCore/Feed/SyncGroup+Catalogue.swift | 74 +++ .../Preferences/View+Preferences.swift | 4 +- .../Sources/AppCore/Shared/LoadError.swift | 2 +- app-core/Sources/AppCore/Shared/Log.swift | 1 + .../CatalogueItemSyncSeamTests.swift | 17 +- .../CoreAppTests/CatalogueUpsertTests.swift | 1 + .../Tests/CoreAppTests/FeedModelTests.swift | 431 ++++++++++++++++++ .../Tests/CoreAppTests/FeedSeamTests.swift | 118 +++++ .../Tests/CoreAppTests/PostUpsertTests.swift | 23 +- .../AppPersistence/CatalogueCategory.swift | 4 + tmbr-app/Personal/PersonalApp.swift | 3 +- tmbr-app/Reader/CatalogueSync.swift | 24 - tmbr-app/Reader/ReaderApp.swift | 33 +- tmbr-app/Reader/ReaderPosts.swift | 69 --- tmbr-app/tmbr-app.xcodeproj/project.pbxproj | 32 +- tmbr-app/tmbr/RootView.swift | 3 +- .../TmbrCore/Pagination/PostsQuery.swift | 54 ++- .../TmbrCore/Pagination/QuotesQuery.swift | 52 ++- .../Search/CatalogueSearchQuery.swift | 7 +- 52 files changed, 1885 insertions(+), 400 deletions(-) create mode 100644 app-core/Sources/AppCore/Catalogue/Detail/CataloguePreviewHeader.swift create mode 100644 app-core/Sources/AppCore/Catalogue/Search/CatalogueSearchEngine.swift delete mode 100644 app-core/Sources/AppCore/Catalogue/Sync/CatalogueItemSync.swift create mode 100644 app-core/Sources/AppCore/Catalogue/Sync/CatalogueItemSyncDescriptor.swift create mode 100644 app-core/Sources/AppCore/Feed/Feed.swift create mode 100644 app-core/Sources/AppCore/Feed/FeedConfiguration.swift create mode 100644 app-core/Sources/AppCore/Feed/FeedEmptyView.swift create mode 100644 app-core/Sources/AppCore/Feed/FeedEnvironment.swift create mode 100644 app-core/Sources/AppCore/Feed/FeedLoadMoreCell.swift create mode 100644 app-core/Sources/AppCore/Feed/FeedModel.swift create mode 100644 app-core/Sources/AppCore/Feed/FeedPageLoadAction.swift create mode 100644 app-core/Sources/AppCore/Feed/FeedRetryAction.swift create mode 100644 app-core/Sources/AppCore/Feed/FeedState.swift create mode 100644 app-core/Sources/AppCore/Feed/FeedStatusLine.swift create mode 100644 app-core/Sources/AppCore/Feed/FilterMapping.swift create mode 100644 app-core/Sources/AppCore/Feed/PageableQuery.swift create mode 100644 app-core/Sources/AppCore/Feed/SyncGroup+Catalogue.swift create mode 100644 app-core/Tests/CoreAppTests/FeedModelTests.swift create mode 100644 app-core/Tests/CoreAppTests/FeedSeamTests.swift delete mode 100644 tmbr-app/Reader/CatalogueSync.swift delete mode 100644 tmbr-app/Reader/ReaderPosts.swift diff --git a/app-api/Sources/AppApi/HTTP/RequestLoader.swift b/app-api/Sources/AppApi/HTTP/RequestLoader.swift index 031d2ef5..c9632769 100644 --- a/app-api/Sources/AppApi/HTTP/RequestLoader.swift +++ b/app-api/Sources/AppApi/HTTP/RequestLoader.swift @@ -31,14 +31,21 @@ public extension RequestLoader where Input == Void { public extension RequestLoader { /// Loads `request` without authentication. - init(request: R, session: URLSession = .shared) where R.Input == Input, R.Response == Response { + init( + request: R, + session: URLSession = .shared + ) where R.Input == Input, R.Response == Response { self.init { input in try await Self.send(request, input, token: nil, session: session) } } /// Loads `request` with a bearer token from `auth`, refreshing once on a 401 and retrying. - init(request: R, session: URLSession = .shared, auth: AuthProvider) where R.Input == Input, R.Response == Response { + init( + request: R, + session: URLSession = .shared, + auth: AuthProvider + ) where R.Input == Input, R.Response == Response { self.init { input in let token = await auth.value do { diff --git a/app-api/Sources/AppApi/Sync/SyncLoaders.swift b/app-api/Sources/AppApi/Sync/SyncLoaders.swift index c51f07d1..546ffd20 100644 --- a/app-api/Sources/AppApi/Sync/SyncLoaders.swift +++ b/app-api/Sources/AppApi/Sync/SyncLoaders.swift @@ -10,7 +10,7 @@ public typealias BooksLoader = RequestLoader> public typealias PodcastsLoader = RequestLoader> public typealias PlaylistsLoader = RequestLoader> -public typealias PostsLoader = RequestLoader> +public typealias PostsLoader = RequestLoader> public typealias OrphansLoader = RequestLoader> public typealias CategoriesLoader = RequestLoader public typealias DeletionsLoader = RequestLoader @@ -138,15 +138,15 @@ public extension DeletionsLoader { // MARK: - Search loaders (unauthenticated — Reader app) // -// One-shot loaders: call `.load(from: query)` with the search query, get results back. -// No pagination — search endpoints return an unpaginated array. +// Catalogue uses a dedicated search endpoint. Quotes reuses its paginated list endpoint with +// an optional `term` field — same pattern as PostsLoader. -public typealias PostSearchLoader = RequestLoader> +public typealias QuotesLoader = RequestLoader> public typealias CatalogueSearchLoader = RequestLoader -public extension PostSearchLoader { - static func searchPosts(baseURL: URL, session: URLSession = .shared) -> Self { - PostSearchLoader(request: PostSearchRequest.postSearch(baseURL: baseURL), session: session) +public extension QuotesLoader { + static func quotes(baseURL: URL, session: URLSession = .shared) -> Self { + QuotesLoader(request: QuotesRequest.quotes(baseURL: baseURL), session: session) } } diff --git a/app-api/Sources/AppApi/Sync/SyncRequests.swift b/app-api/Sources/AppApi/Sync/SyncRequests.swift index 761e035b..18da3188 100644 --- a/app-api/Sources/AppApi/Sync/SyncRequests.swift +++ b/app-api/Sources/AppApi/Sync/SyncRequests.swift @@ -37,7 +37,7 @@ public extension Request where Self == PlaylistsRequest { static func playlistQuery(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/playlists") } } -public typealias PostsRequest = BasicRequest> +public typealias PostsRequest = BasicRequest> public extension Request where Self == PostsRequest { static func postQuery(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/posts") } } @@ -62,13 +62,13 @@ public extension Request where Self == DeletionsRequest { // MARK: - Search requests // -// Unpaginated one-shot requests — send the search query, get a complete result array back. -// Each uses `.query(baseURL:path:)` so the `Input` struct is URL-encoded into query parameters -// via `QueryItemEncoder`. +// Catalogue search uses a dedicated endpoint; quotes and blog reuse their paginated list endpoints +// with an optional `term` field. Each uses `.query(baseURL:path:)` so the Input struct is +// URL-encoded into query parameters via `QueryItemEncoder`. -public typealias PostSearchRequest = BasicRequest> -public extension Request where Self == PostSearchRequest { - static func postSearch(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/posts") } +public typealias QuotesRequest = BasicRequest> +public extension Request where Self == QuotesRequest { + static func quotes(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/quotes") } } public typealias CatalogueSearchRequest = BasicRequest diff --git a/app-core/Package.swift b/app-core/Package.swift index fad6a804..3852ba45 100644 --- a/app-core/Package.swift +++ b/app-core/Package.swift @@ -27,7 +27,12 @@ let package = Package( ), .testTarget( name: "AppCoreTests", - dependencies: ["AppCore"] + dependencies: [ + "AppCore", + .product(name: "TmbrCore", package: "tmbr-core"), + .product(name: "AppApi", package: "app-api"), + .product(name: "AppPersistence", package: "app-persistence"), + ] ), ] ) diff --git a/app-core/Sources/AppCore/Blog/BlogModel.swift b/app-core/Sources/AppCore/Blog/BlogModel.swift index 4d73203b..4c9c6eb7 100644 --- a/app-core/Sources/AppCore/Blog/BlogModel.swift +++ b/app-core/Sources/AppCore/Blog/BlogModel.swift @@ -1,11 +1,7 @@ import Foundation import OSLog -/// Which load operation is currently in flight, if any. -public enum LoadingState: Equatable, Sendable { - case refresh - case page -} +// `LoadingState` now lives with `FeedState` (Feed/FeedState.swift); this model reuses it. /// Headless model for the Blog tab. Tracks refresh activity and outcome; the list itself comes /// from `@Query` in the view. diff --git a/app-core/Sources/AppCore/Catalogue/CatalogueItemDetailView.swift b/app-core/Sources/AppCore/Catalogue/CatalogueItemDetailView.swift index 1e313f2d..188c0378 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueItemDetailView.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueItemDetailView.swift @@ -55,17 +55,17 @@ struct CatalogueItemDetailView: View { private var typeSection: some View { switch item.category { case .song: - SongDetailSection(previewID: item.id) + SongDetailSection(item: item) case .album: - AlbumDetailSection(previewID: item.id) + AlbumDetailSection(item: item) case .playlist: - PlaylistDetailSection(previewID: item.id) + PlaylistDetailSection(item: item) case .book: - BookDetailSection(previewID: item.id) + BookDetailSection(item: item) case .podcast: - PodcastDetailSection(previewID: item.id) + PodcastDetailSection(item: item) case .movie: - MovieDetailSection(previewID: item.id) + MovieDetailSection(item: item) case nil: OrphanDetailSection(item: item) } diff --git a/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift b/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift index 6be5e002..89c6d7ba 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift @@ -104,8 +104,8 @@ private struct CatalogueItemsList: View { } label: { VStack(alignment: .leading, spacing: 2) { Text(item.primaryInfo) - if let subtitle = item.secondaryInfo { - Text(subtitle) + if !item.secondaryInfo.isEmpty { + Text(item.secondaryInfo) .font(.subheadline) .foregroundStyle(.secondary) } diff --git a/app-core/Sources/AppCore/Catalogue/Detail/AlbumDetailSection.swift b/app-core/Sources/AppCore/Catalogue/Detail/AlbumDetailSection.swift index cd9216d4..8803deb2 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/AlbumDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/AlbumDetailSection.swift @@ -20,35 +20,44 @@ private struct AlbumInfoLine: View { struct AlbumDetailSection: View { - let previewID: UUID + let item: PreviewRecord - @Query private var records: [AlbumRecord] + @Query + private var records: [AlbumRecord] - @Upserter(\.album) private var syncer + @Upserter(.album) + private var syncer - init(previewID: UUID) { - self.previewID = previewID + init(item: PreviewRecord) { + self.item = item + let previewID = item.id _records = Query(filter: #Predicate { $0.previewID == previewID }) } var body: some View { - if let album = records.first { + let album = records.first + let artist = album?.artist ?? item.secondaryInfo + Group { Section { CatalogueItemHeader( - title: album.title, - artworkURL: album.artworkURL, - credit: album.artist.isEmpty ? nil : "by \(album.artist)", - info: { AlbumInfoLine(album: album) }, - resourceURLs: album.resourceURLs + title: album?.title ?? item.primaryInfo, + artworkURL: album?.artworkURL ?? item.imageURL, + credit: artist.isEmpty ? nil : "by \(artist)", + info: { if let album { AlbumInfoLine(album: album) } }, + resourceURLs: album?.resourceURLs ?? item.externalLinks ) } - .catalogueItemRefresh(id: previewID) { [sourceID = album.sourceID] in - if let sourceID { try await syncer(sourceID) } - } - if let sourceID = album.sourceID { + if let sourceID = item.sourceID { TrackListSection(containerType: "album", containerSourceID: sourceID) } } + .catalogueItemRefresh(id: item.id) { [sourceID = item.sourceID] in + if let sourceID { try await syncer(sourceID) } + } + .task(id: item.id) { + guard records.isEmpty, let sourceID = item.sourceID else { return } + try? await syncer(sourceID) + } } } diff --git a/app-core/Sources/AppCore/Catalogue/Detail/BookDetailSection.swift b/app-core/Sources/AppCore/Catalogue/Detail/BookDetailSection.swift index b267d2cb..9d828429 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/BookDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/BookDetailSection.swift @@ -20,31 +20,38 @@ private struct BookInfoLine: View { struct BookDetailSection: View { - let previewID: UUID + let item: PreviewRecord - @Query private var records: [BookRecord] + @Query + private var records: [BookRecord] - @Upserter(\.book) private var syncer + @Upserter(.book) + private var syncer - init(previewID: UUID) { - self.previewID = previewID + init(item: PreviewRecord) { + self.item = item + let previewID = item.id _records = Query(filter: #Predicate { $0.previewID == previewID }) } var body: some View { - if let book = records.first { - Section { - CatalogueItemHeader( - title: book.title, - artworkURL: book.coverURL, - credit: book.author.isEmpty ? nil : "by \(book.author)", - info: { BookInfoLine(book: book) }, - resourceURLs: book.resourceURLs - ) - } - .catalogueItemRefresh(id: previewID) { [sourceID = book.sourceID] in - if let sourceID { try await syncer(sourceID) } - } + let book = records.first + let author = book?.author ?? item.secondaryInfo + Section { + CatalogueItemHeader( + title: book?.title ?? item.primaryInfo, + artworkURL: book?.coverURL ?? item.imageURL, + credit: author.isEmpty ? nil : "by \(author)", + info: { if let book { BookInfoLine(book: book) } }, + resourceURLs: book?.resourceURLs ?? item.externalLinks + ) + } + .catalogueItemRefresh(id: item.id) { [sourceID = item.sourceID] in + if let sourceID { try await syncer(sourceID) } + } + .task(id: item.id) { + guard records.isEmpty, let sourceID = item.sourceID else { return } + try? await syncer(sourceID) } } diff --git a/app-core/Sources/AppCore/Catalogue/Detail/CataloguePreviewHeader.swift b/app-core/Sources/AppCore/Catalogue/Detail/CataloguePreviewHeader.swift new file mode 100644 index 00000000..b90eafe3 --- /dev/null +++ b/app-core/Sources/AppCore/Catalogue/Detail/CataloguePreviewHeader.swift @@ -0,0 +1,36 @@ +import SwiftUI +import AppPersistence + +/// A preview-backed catalogue item header, rendered entirely from a `PreviewRecord`. +/// Used as a placeholder by typed detail sections while their backing record is being +/// fetched, and as the permanent header by `OrphanDetailSection`. +struct CataloguePreviewHeader: View { + + let item: PreviewRecord + + var body: some View { + Section { + CatalogueItemHeader( + title: item.primaryInfo, + artworkURL: item.imageURL, + info: { PreviewInfoLine(item: item) }, + resourceURLs: item.externalLinks + ) + } + } +} + +// MARK: - Info line + +struct PreviewInfoLine: View { + + let item: PreviewRecord + + var body: some View { + let text = [item.secondaryInfo, item.categoryType.capitalized] + .filter { !$0.isEmpty }.joined(separator: " · ") + if !text.isEmpty { + Text(text).foregroundStyle(.secondary) + } + } +} diff --git a/app-core/Sources/AppCore/Catalogue/Detail/MovieDetailSection.swift b/app-core/Sources/AppCore/Catalogue/Detail/MovieDetailSection.swift index ef12eafb..96a51755 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/MovieDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/MovieDetailSection.swift @@ -20,30 +20,34 @@ private struct MovieInfoLine: View { struct MovieDetailSection: View { - let previewID: UUID + let item: PreviewRecord @Query private var records: [MovieRecord] - @Upserter(\.movie) private var syncer + @Upserter(.movie) private var syncer - init(previewID: UUID) { - self.previewID = previewID + init(item: PreviewRecord) { + self.item = item + let previewID = item.id _records = Query(filter: #Predicate { $0.previewID == previewID }) } var body: some View { - if let movie = records.first { - Section { - CatalogueItemHeader( - title: movie.title, - artworkURL: movie.coverURL, - info: { MovieInfoLine(movie: movie) }, - resourceURLs: movie.resourceURLs - ) - } - .catalogueItemRefresh(id: previewID) { [sourceID = movie.sourceID] in - if let sourceID { try await syncer(sourceID) } - } + let movie = records.first + Section { + CatalogueItemHeader( + title: movie?.title ?? item.primaryInfo, + artworkURL: movie?.coverURL ?? item.imageURL, + info: { if let movie { MovieInfoLine(movie: movie) } }, + resourceURLs: movie?.resourceURLs ?? item.externalLinks + ) + } + .catalogueItemRefresh(id: item.id) { [sourceID = item.sourceID] in + if let sourceID { try await syncer(sourceID) } + } + .task(id: item.id) { + guard records.isEmpty, let sourceID = item.sourceID else { return } + try? await syncer(sourceID) } } diff --git a/app-core/Sources/AppCore/Catalogue/Detail/OrphanDetailSection.swift b/app-core/Sources/AppCore/Catalogue/Detail/OrphanDetailSection.swift index c2802946..ecb17781 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/OrphanDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/OrphanDetailSection.swift @@ -1,35 +1,16 @@ import SwiftUI import AppPersistence -private struct OrphanInfoLine: View { - let item: PreviewRecord - - var body: some View { - let text = [item.secondaryInfo, item.categoryType.capitalized] - .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") - if !text.isEmpty { - Text(text).foregroundStyle(.secondary) - } - } -} - struct OrphanDetailSection: View { let item: PreviewRecord - @Upserter(\.orphan) private var syncer + @Upserter(.orphan) private var syncer var body: some View { - Section { - CatalogueItemHeader( - title: item.primaryInfo, - artworkURL: item.imageURL, - info: { OrphanInfoLine(item: item) }, - resourceURLs: item.externalLinks - ) - } - .catalogueItemRefresh(id: item.id) { [id = item.id] in - try await syncer(id) - } + CataloguePreviewHeader(item: item) + .catalogueItemRefresh(id: item.id) { [id = item.id] in + try await syncer(id) + } } } diff --git a/app-core/Sources/AppCore/Catalogue/Detail/PlaylistDetailSection.swift b/app-core/Sources/AppCore/Catalogue/Detail/PlaylistDetailSection.swift index 9e0f403c..c5f7a45d 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/PlaylistDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/PlaylistDetailSection.swift @@ -14,33 +14,39 @@ private struct PlaylistInfoLine: View { struct PlaylistDetailSection: View { - let previewID: UUID + let item: PreviewRecord @Query private var records: [PlaylistRecord] - @Upserter(\.playlist) private var syncer + @Upserter(.playlist) private var syncer - init(previewID: UUID) { - self.previewID = previewID + init(item: PreviewRecord) { + self.item = item + let previewID = item.id _records = Query(filter: #Predicate { $0.previewID == previewID }) } var body: some View { - if let playlist = records.first { + let playlist = records.first + Group { Section { CatalogueItemHeader( - title: playlist.title, - artworkURL: playlist.artworkURL, - info: { PlaylistInfoLine(playlist: playlist) }, - resourceURLs: playlist.resourceURLs + title: playlist?.title ?? item.primaryInfo, + artworkURL: playlist?.artworkURL ?? item.imageURL, + info: { if let playlist { PlaylistInfoLine(playlist: playlist) } }, + resourceURLs: playlist?.resourceURLs ?? item.externalLinks ) } - .catalogueItemRefresh(id: previewID) { [sourceID = playlist.sourceID] in - if let sourceID { try await syncer(sourceID) } - } - if let sourceID = playlist.sourceID { + if let sourceID = item.sourceID { TrackListSection(containerType: "playlist", containerSourceID: sourceID) } } + .catalogueItemRefresh(id: item.id) { [sourceID = item.sourceID] in + if let sourceID { try await syncer(sourceID) } + } + .task(id: item.id) { + guard records.isEmpty, let sourceID = item.sourceID else { return } + try? await syncer(sourceID) + } } } diff --git a/app-core/Sources/AppCore/Catalogue/Detail/PodcastDetailSection.swift b/app-core/Sources/AppCore/Catalogue/Detail/PodcastDetailSection.swift index 6d3bb6ed..39f49945 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/PodcastDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/PodcastDetailSection.swift @@ -26,31 +26,36 @@ private struct PodcastInfoLine: View { struct PodcastDetailSection: View { - let previewID: UUID + let item: PreviewRecord @Query private var records: [PodcastRecord] - @Upserter(\.podcast) private var syncer + @Upserter(.podcast) private var syncer - init(previewID: UUID) { - self.previewID = previewID + init(item: PreviewRecord) { + self.item = item + let previewID = item.id _records = Query(filter: #Predicate { $0.previewID == previewID }) } var body: some View { - if let podcast = records.first { - Section { - CatalogueItemHeader( - title: podcast.title, - artworkURL: podcast.artworkURL, - credit: podcast.episodeTitle.isEmpty ? nil : podcast.episodeTitle, - info: { PodcastInfoLine(podcast: podcast) }, - resourceURLs: podcast.resourceURLs - ) - } - .catalogueItemRefresh(id: previewID) { [sourceID = podcast.sourceID] in - if let sourceID { try await syncer(sourceID) } - } + let podcast = records.first + let episodeTitle = podcast?.episodeTitle ?? item.secondaryInfo + Section { + CatalogueItemHeader( + title: podcast?.title ?? item.primaryInfo, + artworkURL: podcast?.artworkURL ?? item.imageURL, + credit: episodeTitle.isEmpty ? nil : episodeTitle, + info: { if let podcast { PodcastInfoLine(podcast: podcast) } }, + resourceURLs: podcast?.resourceURLs ?? item.externalLinks + ) + } + .catalogueItemRefresh(id: item.id) { [sourceID = item.sourceID] in + if let sourceID { try await syncer(sourceID) } + } + .task(id: item.id) { + guard records.isEmpty, let sourceID = item.sourceID else { return } + try? await syncer(sourceID) } } diff --git a/app-core/Sources/AppCore/Catalogue/Detail/SongDetailSection.swift b/app-core/Sources/AppCore/Catalogue/Detail/SongDetailSection.swift index 8327e0a5..a7aec5d3 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/SongDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/SongDetailSection.swift @@ -20,31 +20,36 @@ private struct SongInfoLine: View { struct SongDetailSection: View { - let previewID: UUID + let item: PreviewRecord @Query private var records: [SongRecord] - @Upserter(\.song) private var syncer + @Upserter(.song) private var syncer - init(previewID: UUID) { - self.previewID = previewID + init(item: PreviewRecord) { + self.item = item + let previewID = item.id _records = Query(filter: #Predicate { $0.previewID == previewID }) } var body: some View { - if let song = records.first { - Section { - CatalogueItemHeader( - title: song.title, - artworkURL: song.artworkURL, - credit: song.artist.isEmpty ? nil : "by \(song.artist)", - info: { SongInfoLine(song: song) }, - resourceURLs: song.resourceURLs - ) - } - .catalogueItemRefresh(id: previewID) { [sourceID = song.sourceID] in - if let sourceID { try await syncer(sourceID) } - } + let song = records.first + let artist = song?.artist ?? item.secondaryInfo + Section { + CatalogueItemHeader( + title: song?.title ?? item.primaryInfo, + artworkURL: song?.artworkURL ?? item.imageURL, + credit: artist.isEmpty ? nil : "by \(artist)", + info: { if let song { SongInfoLine(song: song) } }, + resourceURLs: song?.resourceURLs ?? item.externalLinks + ) + } + .catalogueItemRefresh(id: item.id) { [sourceID = item.sourceID] in + if let sourceID { try await syncer(sourceID) } + } + .task(id: item.id) { + guard records.isEmpty, let sourceID = item.sourceID else { return } + try? await syncer(sourceID) } } diff --git a/app-core/Sources/AppCore/Catalogue/Search/CatalogueSearchEngine.swift b/app-core/Sources/AppCore/Catalogue/Search/CatalogueSearchEngine.swift new file mode 100644 index 00000000..4a233b0a --- /dev/null +++ b/app-core/Sources/AppCore/Catalogue/Search/CatalogueSearchEngine.swift @@ -0,0 +1,55 @@ +import Foundation +import SwiftData +import AppPersistence +import TmbrCore + +/// Observable engine that drives catalogue text search against the local SwiftData store. +/// +/// The engine delegates the actual fetching and merging to `CatalogueStore.search(...)`, which runs +/// two predicate-filtered `FetchDescriptor` queries — one over `PreviewRecord` (title/subtitle) and +/// one over `NoteRecord` (body) — and resolves the cross-model note→preview join via a targeted +/// predicate fetch of only the needed IDs. +/// +/// This class exists because manual fetches don't trigger SwiftUI `@Query` reactivity — views observe +/// `engine.results` via `@Observable` instead. +/// +/// **Usage pattern** (from the view's `.task(id: searchText)`): +/// 1. Call `search(term:in:categories:languages:)` immediately — renders cached results. +/// 2. Await the network search action — it upserts newly-fetched results into the store. +/// 3. Call `search(...)` again — picks up the upserted data. +@MainActor +@Observable +public final class CatalogueSearchEngine { + + /// The current search results, updated synchronously on each `search(...)` call. + public private(set) var results: [CatalogueSearchResult] = [] + + public init() {} + + /// Runs the search via `CatalogueStore` and updates `results` synchronously. + /// Clears results when `term` is blank. + public func search( + term: String, + in context: ModelContext, + categories: Set, + languages: Set + ) { + guard !term.trimmingCharacters(in: .whitespaces).isEmpty else { + results = [] + return + } + do { + results = try CatalogueStore(context: context).search( + term: term, + categories: categories, + languages: languages + ) + } catch { + results = [] + } + } + + public func clear() { + results = [] + } +} diff --git a/app-core/Sources/AppCore/Catalogue/Sync/CatalogueItemSync.swift b/app-core/Sources/AppCore/Catalogue/Sync/CatalogueItemSync.swift deleted file mode 100644 index a9438692..00000000 --- a/app-core/Sources/AppCore/Catalogue/Sync/CatalogueItemSync.swift +++ /dev/null @@ -1,54 +0,0 @@ -import Foundation -import AppPersistence -import AppApi -import TmbrCore - -/// Links the loader factory and store upsert function for one catalogue item type. -/// -/// `@Loader(\.song)` reads `\.itemLoaders` directly (no recipe needed). -/// `@Upserter(\.song)` keys into this recipe to find both `loaderPath` and `upserterPath`, -/// ensuring the loader output type always matches the upserter input type at compile time. -public struct CatalogueItemSync: Sendable { - - public let label: String - public let loaderPath: KeyPath RequestLoader> & Sendable - public let upserterPath: KeyPath Void> & Sendable - - public init( - _ label: String, - loaderPath: KeyPath RequestLoader> & Sendable, - upserterPath: KeyPath Void> & Sendable - ) { - self.label = label - self.loaderPath = loaderPath - self.upserterPath = upserterPath - } -} - -// MARK: - Namespace - -/// Keypath-pair recipes linking each catalogue type's loader factory to its store upsert function. -/// -/// `@Upserter` addresses into this namespace to compose the two seams. The network seam -/// (`\.itemLoaders`) and persistence seam (`\.itemUpserters`) are each independently injectable -/// via the environment; overriding one does not affect the other. -public struct CatalogueItemSyncs: Sendable { - - public let song: CatalogueItemSync - public let album: CatalogueItemSync - public let book: CatalogueItemSync - public let movie: CatalogueItemSync - public let podcast: CatalogueItemSync - public let playlist: CatalogueItemSync - public let orphan: CatalogueItemSync - - public init() { - song = .init("song", loaderPath: \.song, upserterPath: \.song) - album = .init("album", loaderPath: \.album, upserterPath: \.album) - book = .init("book", loaderPath: \.book, upserterPath: \.book) - movie = .init("movie", loaderPath: \.movie, upserterPath: \.movie) - podcast = .init("podcast", loaderPath: \.podcast, upserterPath: \.podcast) - playlist = .init("playlist", loaderPath: \.playlist, upserterPath: \.playlist) - orphan = .init("orphan", loaderPath: \.orphan, upserterPath: \.orphan) - } -} diff --git a/app-core/Sources/AppCore/Catalogue/Sync/CatalogueItemSyncDescriptor.swift b/app-core/Sources/AppCore/Catalogue/Sync/CatalogueItemSyncDescriptor.swift new file mode 100644 index 00000000..5b8aeff0 --- /dev/null +++ b/app-core/Sources/AppCore/Catalogue/Sync/CatalogueItemSyncDescriptor.swift @@ -0,0 +1,65 @@ +import Foundation +import AppPersistence +import AppApi +import TmbrCore + +/// Links the loader factory and store upsert function for one catalogue item type. +/// +/// `@Loader(\.song)` reads `\.itemLoaders` directly (no recipe needed). +/// `@Upserter(\.song)` keys into this recipe to find both `loaderPath` and `upserterPath`, +/// ensuring the loader output type always matches the upserter input type at compile time. +public struct CatalogueItemSyncDescriptor: Sendable { + + public let label: String + + public let loaderPath: KeyPath RequestLoader> & Sendable + + public let upserterPath: KeyPath Void> & Sendable + + public init( + _ label: String, + loaderPath: KeyPath RequestLoader> & Sendable, + upserterPath: KeyPath Void> & Sendable + ) { + self.label = label + self.loaderPath = loaderPath + self.upserterPath = upserterPath + } +} + +// MARK: - Namespace + +extension CatalogueItemSyncDescriptor where ID == Int, Response == SongResponse { + + public static let song = Self("song", loaderPath: \.song, upserterPath: \.song) +} + +extension CatalogueItemSyncDescriptor where ID == Int, Response == AlbumResponse { + + public static let album = Self("album", loaderPath: \.album, upserterPath: \.album) +} + +extension CatalogueItemSyncDescriptor where ID == Int, Response == BookResponse { + + public static let book = Self("book", loaderPath: \.book, upserterPath: \.book) +} + +extension CatalogueItemSyncDescriptor where ID == Int, Response == MovieResponse { + + public static let movie = Self("movie", loaderPath: \.movie, upserterPath: \.movie) +} + +extension CatalogueItemSyncDescriptor where ID == Int, Response == PodcastResponse { + + public static let podcast = Self("podcast", loaderPath: \.podcast, upserterPath: \.podcast) +} + +extension CatalogueItemSyncDescriptor where ID == Int, Response == PlaylistResponse { + + public static let playlist = Self("playlist", loaderPath: \.playlist, upserterPath: \.playlist) +} + +extension CatalogueItemSyncDescriptor where ID == UUID, Response == PreviewResponse { + + public static let orphan = Self( "orphan", loaderPath: \.orphan, upserterPath: \.orphan) +} diff --git a/app-core/Sources/AppCore/Catalogue/Sync/Loader.swift b/app-core/Sources/AppCore/Catalogue/Sync/Loader.swift index 9c8a8cfd..9de1c6e4 100644 --- a/app-core/Sources/AppCore/Catalogue/Sync/Loader.swift +++ b/app-core/Sources/AppCore/Catalogue/Sync/Loader.swift @@ -13,9 +13,14 @@ import AppApi @MainActor @propertyWrapper public struct Loader: DynamicProperty { - @Environment(\.apiBaseURL) private var baseURL - @Environment(\.urlSession) private var session - @Environment(\.itemLoaders) private var loaders + @Environment(\.apiBaseURL) + private var baseURL + + @Environment(\.urlSession) + private var session + + @Environment(\.itemLoaders) + private var loaders private let path: KeyPath RequestLoader> diff --git a/app-core/Sources/AppCore/Catalogue/Sync/Upserter.swift b/app-core/Sources/AppCore/Catalogue/Sync/Upserter.swift index 6096e4b3..75daf309 100644 --- a/app-core/Sources/AppCore/Catalogue/Sync/Upserter.swift +++ b/app-core/Sources/AppCore/Catalogue/Sync/Upserter.swift @@ -7,39 +7,42 @@ import AppApi /// for a single catalogue item type. /// /// Reads `\.apiBaseURL`, `\.urlSession`, `\.itemLoaders`, `\.itemUpserters`, and `\.modelContext` -/// from the environment. The `CatalogueItemSyncs` recipe links the loader factory to its typed +/// from the environment. The `CatalogueItemSyncDescriptor` links the loader factory to its typed /// store upsert function, ensuring their `Response` type always matches. /// Returns a no-op syncer when `apiBaseURL` is not set (Personal app / unconfigured). /// /// Usage: /// ```swift -/// @Upserter(\.song) var syncer // CatalogueItemSyncer -/// @Upserter(\.orphan) var syncer // CatalogueItemSyncer +/// @Upserter(.song) var syncer // CatalogueItemSyncer +/// @Upserter(.orphan) var syncer // CatalogueItemSyncer /// ``` @MainActor @propertyWrapper public struct Upserter: DynamicProperty { @Environment(\.apiBaseURL) private var baseURL + @Environment(\.urlSession) private var session + @Environment(\.itemLoaders) private var loaders + @Environment(\.itemUpserters) private var upserters + @Environment(\.modelContext) private var context - private let path: KeyPath> + private let descriptor: CatalogueItemSyncDescriptor - private static var syncs: CatalogueItemSyncs { .init() } - - public init(_ path: KeyPath>) { - self.path = path + public init( + _ descriptor: CatalogueItemSyncDescriptor + ) { + self.descriptor = descriptor } public var wrappedValue: CatalogueItemSyncer { - guard let baseURL else { return .init() } // Personal / unconfigured → no-op - let recipe = Self.syncs[keyPath: path] - let loader = loaders[keyPath: recipe.loaderPath](baseURL, session) - let upsert = upserters[keyPath: recipe.upserterPath] + guard let baseURL else { return .init() } + let loader = loaders[keyPath: descriptor.loaderPath](baseURL, session) + let upsert = upserters[keyPath: descriptor.upserterPath] let store = CatalogueStore(context: context) - return CatalogueItemSyncer { [label = recipe.label] id in + return CatalogueItemSyncer { [label = descriptor.label] id in try await Syncer(label, loader: loader, from: id) { try await upsert(store, $0) }.run() } } diff --git a/app-core/Sources/AppCore/Catalogue/Views/CatalogueItemStatusLine.swift b/app-core/Sources/AppCore/Catalogue/Views/CatalogueItemStatusLine.swift index 7fdb6fd6..a34e61ba 100644 --- a/app-core/Sources/AppCore/Catalogue/Views/CatalogueItemStatusLine.swift +++ b/app-core/Sources/AppCore/Catalogue/Views/CatalogueItemStatusLine.swift @@ -1,8 +1,8 @@ import SwiftUI /// A non-disruptive one-liner row for a catalogue item detail screen. -/// Parameterised twin of `CatalogueStatusLine` — same visuals, same states, but takes -/// loading/lastError/lastFetched as direct inputs rather than reading from the catalogue model. +/// Twin of `FeedStatusLine` — same visuals, same states, but takes loading/lastError/lastFetched +/// as direct inputs rather than a `FeedModel` (item detail state isn't feed-backed). struct CatalogueItemStatusLine: View { let loading: LoadingState? diff --git a/app-core/Sources/AppCore/Feed/Feed.swift b/app-core/Sources/AppCore/Feed/Feed.swift new file mode 100644 index 00000000..1bd9a51a --- /dev/null +++ b/app-core/Sources/AppCore/Feed/Feed.swift @@ -0,0 +1,31 @@ +import SwiftUI + +/// Reads the nearest feed's `FeedModel` — the counterpart of the parent's `.feed(_:)` call, +/// which creates the model, wires its pipeline, and scopes it into the environment. A missing +/// `.feed(_:)` ancestor fails fast with SwiftUI's missing-environment diagnostic. +/// +/// Feeds are keyed by their `Input` type; name it either through the wrapped type or the +/// initializer — whichever reads better at the site: +/// ```swift +/// // parent +/// PostsList(query: query).feed(.posts) +/// +/// // child — equivalent forms +/// @Feed(PostsQuery.self) private var posts +/// @Feed private var posts: FeedModel +/// +/// .task(id: query) { await posts.fetch(query) } // the input decides browse vs search +/// ``` +@MainActor @propertyWrapper +public struct Feed: DynamicProperty { + + @Environment(FeedModel.self) + private var model + + public init() {} + + /// Inference aid: names the feed by its `Input` type so the property needs no annotation. + public init(_ inputType: Input.Type) {} + + public var wrappedValue: FeedModel { model } +} diff --git a/app-core/Sources/AppCore/Feed/FeedConfiguration.swift b/app-core/Sources/AppCore/Feed/FeedConfiguration.swift new file mode 100644 index 00000000..50bcf205 --- /dev/null +++ b/app-core/Sources/AppCore/Feed/FeedConfiguration.swift @@ -0,0 +1,198 @@ +import SwiftUI +import SwiftData +import AppApi +import AppPersistence +import TmbrCore + +/// The recipe for one feed: its identity (`label`) and how to compose the network loader and +/// store upsert into the fetch pipeline. Everything else the feed needs to drive itself comes +/// from the `Input`'s own `PageableQuery` conformance. +/// +/// The feature's parent view applies it with **one call** — `.feed(.posts)` — which owns the +/// `FeedModel`, wires its pipeline from the environment (`apiBaseURL`, `urlSession`, +/// `modelContext`), persists its freshness stamp (`userDefaults`), and scopes the model, its +/// `FeedState`, and the control actions for the subtree. Children read the model back with +/// `@Feed`, and components like `FeedStatusLine` read `\.feedState` / `\.loadFeed` / +/// `\.retryFeed`. +/// +/// Previews and tests pass a stub recipe instead: +/// +/// ```swift +/// PostsList(languages: [], searchText: "") +/// .feed(FeedConfiguration( +/// label: "posts", +/// loader: { _, _ in PostsLoader { _ in PageResult(items: mockPosts, nextCursor: nil) } }, +/// upsert: { try PostStore(context: $0).upsert($1.items) }, +/// nextCursor: { $0.nextCursor } +/// )) +/// ``` +public struct FeedConfiguration: Sendable { + + /// Persistence key (`feed.