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/Sources/AppCore/Blog/Actions/BlogSearchAction.swift b/app-core/Sources/AppCore/Blog/Actions/BlogSearchAction.swift new file mode 100644 index 00000000..8878bac9 --- /dev/null +++ b/app-core/Sources/AppCore/Blog/Actions/BlogSearchAction.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Fires a blog search for `term` — the env-injected seam for per-tab text search. +/// +/// One key, a different body per app (Reader fetch+upsert, Author no-op, Personal no-op), +/// all funnelled through `BlogModel.search(term:)`. +@MainActor +public struct BlogSearchAction: Sendable { + + private let body: @MainActor (String) async -> Void + + nonisolated public init(_ body: @escaping @MainActor (String) async -> Void = { _ in }) { + self.body = body + } + + public init(model: BlogModel) { + self.init { term in await model.search(term: term) } + } + + public func callAsFunction(term: String) async { await body(term) } +} diff --git a/app-core/Sources/AppCore/Blog/BlogEnvironment.swift b/app-core/Sources/AppCore/Blog/BlogEnvironment.swift index 4afd45d4..d7e2c3c1 100644 --- a/app-core/Sources/AppCore/Blog/BlogEnvironment.swift +++ b/app-core/Sources/AppCore/Blog/BlogEnvironment.swift @@ -3,7 +3,10 @@ import SwiftUI public extension EnvironmentValues { @Entry var refreshBlog: BlogRefreshAction = BlogRefreshAction() - + @Entry var loadBlog: BlogPageLoadAction = BlogPageLoadAction() + + @Entry + var searchBlog: BlogSearchAction = BlogSearchAction() } diff --git a/app-core/Sources/AppCore/Blog/BlogModel.swift b/app-core/Sources/AppCore/Blog/BlogModel.swift index 4d73203b..c5f27dc9 100644 --- a/app-core/Sources/AppCore/Blog/BlogModel.swift +++ b/app-core/Sources/AppCore/Blog/BlogModel.swift @@ -38,16 +38,22 @@ public final class BlogModel { // MARK: - Dependencies private let _refresh: @Sendable () async throws -> Date? - + private let _loadMore: @Sendable () async throws -> Bool + /// Per-app seam for blog search. Called with the current search term; should fetch + /// matching posts from the server and upsert them into the local store. + private let _search: @Sendable (String) async throws -> Void + public init( refresh: @escaping @Sendable () async throws -> Date? = { nil }, loadMore: @escaping @Sendable () async throws -> Bool = { false }, + search: @escaping @Sendable (String) async throws -> Void = { _ in }, lastFetched: Date? = nil ) { self._refresh = refresh self._loadMore = loadMore + self._search = search self.lastFetched = lastFetched } @@ -69,6 +75,26 @@ public final class BlogModel { } } + // MARK: - Search + + /// Fires the injected search closure with `term`. Sets `loading = .refresh` while in flight + /// so the status line shows activity alongside the `@Query`-driven local results. + /// Resets `hasMore` optimistically so `BlogLoadMoreCell` can page through search results. + public func search(term: String) async { + guard loading == nil else { return } + loading = .refresh + hasMore = true // reset — load-more will settle it after the first search page + defer { loading = nil } + do { + try await _search(term) + lastError = nil + } catch { + Logger.blog.error("Blog search failed: \(error)") + lastError = LoadError(error) + hasMore = false + } + } + // MARK: - Load more (subsequent pages) /// Fetches the next page if one is available. Never surfaces its error to the UI — diff --git a/app-core/Sources/AppCore/Blog/BlogTab.swift b/app-core/Sources/AppCore/Blog/BlogTab.swift index d13ada2a..43c171d2 100644 --- a/app-core/Sources/AppCore/Blog/BlogTab.swift +++ b/app-core/Sources/AppCore/Blog/BlogTab.swift @@ -8,9 +8,15 @@ struct BlogTab: View { @Environment(\.refreshBlog) private var refreshBlog + @Environment(\.searchBlog) + private var searchBlog + @Preferences(\.selectedLanguages) private var selectedLanguages + @State + private var searchText = "" + @State private var showEditor = false @@ -19,37 +25,52 @@ struct BlogTab: View { var body: some View { NavigationStack { - PostsList(selectedLanguages: selectedLanguages) - .navigationTitle("Blog") - .toolbar { + PostsList( + term: searchText.isEmpty ? nil : searchText, + selectedLanguages: selectedLanguages + ) + .navigationTitle("Blog") + .searchable(text: $searchText, prompt: "Search posts…") + .toolbar { #if os(iOS) - ToolbarItem(placement: .topBarLeading) { - Button { showLanguageFilter = true } label: { - Image(systemName: "line.3.horizontal.decrease") - } - } - ToolbarItem(placement: .topBarLeading) { - AuthoringButton(systemImage: "square.and.pencil") { showEditor = true } + ToolbarItem(placement: .topBarLeading) { + Button { showLanguageFilter = true } label: { + Image(systemName: "line.3.horizontal.decrease") } + } + ToolbarItem(placement: .topBarLeading) { + AuthoringButton(systemImage: "square.and.pencil") { showEditor = true } + } #else - ToolbarItem(placement: .automatic) { - Button { showLanguageFilter = true } label: { - Image(systemName: "line.3.horizontal.decrease") - } - .popover(isPresented: $showLanguageFilter) { - LanguageFilterView() - } + ToolbarItem(placement: .automatic) { + Button { showLanguageFilter = true } label: { + Image(systemName: "line.3.horizontal.decrease") } - ToolbarItem(placement: .automatic) { - AuthoringButton(systemImage: "square.and.pencil") { showEditor = true } + .popover(isPresented: $showLanguageFilter) { + LanguageFilterView() } + } + ToolbarItem(placement: .automatic) { + AuthoringButton(systemImage: "square.and.pencil") { showEditor = true } + } #endif - ToolbarItem(placement: .primaryAction) { - AccountButton() - } + ToolbarItem(placement: .primaryAction) { + AccountButton() } + } } .task { await refreshBlog() } + // Network search is debounced 300 ms. When the term is cleared, fire an immediate + // refresh so browse pagination resets cleanly (no stale search cursor). + .task(id: searchText) { + if searchText.isEmpty { + await refreshBlog() + return + } + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + await searchBlog(term: searchText) + } #if os(iOS) .sheet(isPresented: $showLanguageFilter) { LanguageFilterView() @@ -61,6 +82,8 @@ struct BlogTab: View { } } +// MARK: - Unified list (browse and search) + private struct PostsList: View { @Query private var posts: [PostRecord] @@ -68,9 +91,13 @@ private struct PostsList: View { @Environment(\.refreshBlog) private var refreshBlog - init(selectedLanguages: Set) { + let isSearching: Bool + + init(term: String?, selectedLanguages: Set) { + isSearching = term != nil _posts = Query( - filter: PostRecord.list(languages: selectedLanguages), + filter: term.map { PostRecord.search(term: $0, languages: selectedLanguages) } + ?? PostRecord.list(languages: selectedLanguages), sort: \PostRecord.createdAt, order: .reverse ) @@ -95,7 +122,11 @@ private struct PostsList: View { } .overlay { if posts.isEmpty { - BlogEmptyView() + if isSearching { + ContentUnavailableView.search + } else { + BlogEmptyView() + } } } .navigationDestination(for: PostRecord.self) { post in @@ -106,6 +137,10 @@ private struct PostsList: View { published: post.publishedAt ) } - .refreshable { await refreshBlog() } + .refreshable { + if !isSearching { + await refreshBlog() + } + } } } diff --git a/app-core/Sources/AppCore/Blog/View+Blog.swift b/app-core/Sources/AppCore/Blog/View+Blog.swift index 85762f11..53be6e67 100644 --- a/app-core/Sources/AppCore/Blog/View+Blog.swift +++ b/app-core/Sources/AppCore/Blog/View+Blog.swift @@ -1,10 +1,11 @@ import SwiftUI public extension View { - /// Injects the Blog model + its refresh action (the seam) for the tab subtree. + /// Injects the Blog model + its refresh and search actions for the tab subtree. func blog(_ model: BlogModel) -> some View { environment(model) .environment(\.refreshBlog, BlogRefreshAction(model: model)) .environment(\.loadBlog, BlogPageLoadAction(model: model)) + .environment(\.searchBlog, BlogSearchAction(model: model)) } } diff --git a/app-core/Sources/AppCore/Catalogue/Actions/CataloguePageLoadAction.swift b/app-core/Sources/AppCore/Catalogue/Actions/CataloguePageLoadAction.swift new file mode 100644 index 00000000..45ccbf83 --- /dev/null +++ b/app-core/Sources/AppCore/Catalogue/Actions/CataloguePageLoadAction.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Loads the next page of catalogue search results — the env-injected seam for search pagination. +@MainActor +public struct CataloguePageLoadAction: Sendable { + + private let body: @MainActor () async -> Void + + nonisolated public init(_ body: @escaping @MainActor () async -> Void = {}) { + self.body = body + } + + public init(model: CatalogueModel) { + self.init { await model.loadMore() } + } + + public func callAsFunction() async { await body() } +} diff --git a/app-core/Sources/AppCore/Catalogue/Actions/CatalogueSearchAction.swift b/app-core/Sources/AppCore/Catalogue/Actions/CatalogueSearchAction.swift new file mode 100644 index 00000000..f99fbb1c --- /dev/null +++ b/app-core/Sources/AppCore/Catalogue/Actions/CatalogueSearchAction.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Fires a catalogue search for `term` — the env-injected seam for per-tab text search. +@MainActor +public struct CatalogueSearchAction: Sendable { + + private let body: @MainActor (String) async -> Void + + nonisolated public init(_ body: @escaping @MainActor (String) async -> Void = { _ in }) { + self.body = body + } + + public init(model: CatalogueModel) { + self.init { term in await model.search(term: term) } + } + + public func callAsFunction(term: String) async { await body(term) } +} diff --git a/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift b/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift index 1651a10b..e147b7db 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift @@ -4,6 +4,8 @@ import Foundation public extension EnvironmentValues { @Entry var refreshCatalogue: CatalogueRefreshAction = CatalogueRefreshAction() @Entry var selectCategories: SelectCategoriesAction = SelectCategoriesAction() + @Entry var searchCatalogue: CatalogueSearchAction = CatalogueSearchAction() + @Entry var loadCatalogue: CataloguePageLoadAction = CataloguePageLoadAction() // Networking config — `apiBaseURL == nil` is the on/off gate: Personal injects nothing // → @Loader returns nil, @Upserter returns a no-op syncer. 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/CatalogueModel.swift b/app-core/Sources/AppCore/Catalogue/CatalogueModel.swift index 8f51f495..4e9ca6c4 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueModel.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueModel.swift @@ -22,12 +22,35 @@ public final class CatalogueModel { /// Categories currently active in the filter. Empty = no filter (show all). public var selectedCategories: Set = [] + /// Non-nil when a load-more network request is in flight. + public private(set) var isLoadingMore: Bool = false + + /// Whether more pages are available for the current search. + public private(set) var hasMore: Bool = true + + /// Increments after each successful network search or load-more, so the tab can re-run + /// local search to pick up upserted records. + public private(set) var loadMoreRevision: Int = 0 + // MARK: - Dependencies private let _refresh: @Sendable () async throws -> Void - public init(refresh: @escaping @Sendable () async throws -> Void = {}) { + /// Per-app seam for catalogue search. Called with the current search term; should fetch + /// matching catalogue items and notes from the server and upsert them into the local store. + private let _search: @Sendable (String) async throws -> Void + + /// Per-app seam for load-more. Returns `true` if more pages remain. + private let _loadMore: @Sendable () async throws -> Bool + + public init( + refresh: @escaping @Sendable () async throws -> Void = {}, + search: @escaping @Sendable (String) async throws -> Void = { _ in }, + loadMore: @escaping @Sendable () async throws -> Bool = { false } + ) { self._refresh = refresh + self._search = search + self._loadMore = loadMore } // MARK: - Refresh @@ -45,4 +68,40 @@ public final class CatalogueModel { lastError = LoadError(error) } } + + // MARK: - Search + + /// Fires the injected search closure with `term`. Sets `loading = .refresh` while in flight. + public func search(term: String) async { + guard loading == nil else { return } + loading = .refresh + hasMore = true + defer { loading = nil } + do { + try await _search(term) + loadMoreRevision += 1 + } catch { + Logger.catalogue.error("Catalogue search failed: \(error)") + lastError = LoadError(error) + hasMore = false + } + } + + // MARK: - Load more + + /// Fetches the next page of search results and upserts them. Increments `loadMoreRevision` + /// so the tab can re-run local search to pick up the newly upserted records. + public func loadMore() async { + guard !isLoadingMore, hasMore else { return } + isLoadingMore = true + defer { isLoadingMore = false } + do { + let more = try await _loadMore() + hasMore = more + loadMoreRevision += 1 + } catch { + Logger.catalogue.error("Catalogue load-more failed: \(error)") + lastError = LoadError(error) + } + } } diff --git a/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift b/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift index 6be5e002..04a9ab47 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift @@ -8,15 +8,27 @@ struct CatalogueTab: View { @Environment(\.refreshCatalogue) private var refreshCatalogue + @Environment(\.searchCatalogue) + private var searchCatalogue + + @Environment(\.modelContext) + private var modelContext + @Environment(\.canAuthor) private var canAuthor @Catalogue(\.selectedCategories) private var selectedCategories + @Catalogue(\.loadMoreRevision) + private var loadMoreRevision + @Preferences(\.selectedLanguages) private var selectedLanguages + @State private var searchText = "" + @State private var searchEngine = CatalogueSearchEngine() + @State private var showFilter = false @State private var showTypePicker = false @State private var selectedType: CatalogueItemType? @@ -24,11 +36,14 @@ struct CatalogueTab: View { var body: some View { NavigationStack { - CatalogueItemsList( + CatalogueList( + term: searchText.isEmpty ? nil : searchText, selectedCategories: selectedCategories, - selectedLanguages: selectedLanguages + selectedLanguages: selectedLanguages, + engine: searchEngine ) .navigationTitle("Catalogue") + .searchable(text: $searchText, prompt: "Search catalogue…") .toolbar { #if os(iOS) ToolbarItem(placement: .topBarLeading) { @@ -58,6 +73,36 @@ struct CatalogueTab: View { } } .task { await refreshCatalogue() } + // Flash fix: run local search IMMEDIATELY for instant results, then debounce the + // network call (300 ms). A second local search after the network call picks up upserted + // records. On clear, the browse list re-appears automatically via the query predicate. + .task(id: searchText) { + guard !searchText.isEmpty else { + searchEngine.clear() + return + } + let term = searchText + searchEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + await searchCatalogue(term: term) + guard !Task.isCancelled else { return } + searchEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + } + // Re-run local search if filters change while search is active. + .onChange(of: selectedCategories) { _, categories in + guard !searchText.isEmpty else { return } + searchEngine.search(term: searchText, in: modelContext, categories: categories, languages: selectedLanguages) + } + .onChange(of: selectedLanguages) { _, languages in + guard !searchText.isEmpty else { return } + searchEngine.search(term: searchText, in: modelContext, categories: selectedCategories, languages: languages) + } + // Re-run local search after a load-more upsert so new records appear immediately. + .onChange(of: loadMoreRevision) { _, _ in + guard !searchText.isEmpty else { return } + searchEngine.search(term: searchText, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + } #if os(iOS) .sheet(isPresented: $showFilter) { CatalogueFilterView() @@ -79,46 +124,139 @@ struct CatalogueTab: View { } } -private struct CatalogueItemsList: View { +// MARK: - Unified list - @Query private var items: [PreviewRecord] +/// A single list view that handles both browse (term == nil) and search (term != nil). +/// +/// Browse: `@Query` over `PreviewRecord`s filtered by category + language — auto-reactive to +/// SwiftData upserts. Search: `CatalogueSearchEngine.results` (manual fetch, driven by the +/// parent tab's `.task(id: searchText)` and `.onChange(of: loadMoreRevision)` handlers). +private struct CatalogueList: View { + + @Query private var browseItems: [PreviewRecord] @Environment(\.refreshCatalogue) private var refreshCatalogue - init(selectedCategories: Set, selectedLanguages: Set) { - _items = Query( + let term: String? + let engine: CatalogueSearchEngine + + init( + term: String?, + selectedCategories: Set, + selectedLanguages: Set, + engine: CatalogueSearchEngine + ) { + self.term = term + self.engine = engine + _browseItems = Query( filter: PreviewRecord.catalogue(categories: selectedCategories, languages: selectedLanguages), sort: \PreviewRecord.primaryInfo ) } + private var isSearching: Bool { term != nil } + + private var hasResults: Bool { + isSearching ? !engine.results.isEmpty : !browseItems.isEmpty + } + var body: some View { List { - if !items.isEmpty { - CatalogueStatusLine() + if hasResults { CatalogueStatusLine() } + if isSearching { + searchRows + } else { + browseRows + } + } + .listStyle(.plain) + .overlay { + if isSearching { + if engine.results.isEmpty { ContentUnavailableView.search } + } else { + if browseItems.isEmpty { CatalogueEmptyView() } + } + } + .navigationDestination(for: CatalogueItemNavigation.self) { CatalogueItemNavigation.destination($0) } + .refreshable { + if !isSearching { await refreshCatalogue() } + } + } + + @ViewBuilder + private var browseRows: some View { + ForEach(browseItems) { item in + NavigationLink { + CatalogueItemDetailView(item: item) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(item.primaryInfo) + if !item.secondaryInfo.isEmpty { + Text(item.secondaryInfo) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } } - ForEach(items) { item in - NavigationLink { - CatalogueItemDetailView(item: item) - } label: { - VStack(alignment: .leading, spacing: 2) { - Text(item.primaryInfo) - if let subtitle = item.secondaryInfo { - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - } + } + } + + @ViewBuilder + private var searchRows: some View { + ForEach(engine.results) { result in + NavigationLink { + CatalogueItemDetailView(item: result.preview) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(result.preview.primaryInfo) + if let snippet = result.noteSnippet { + Text(snippet) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if !result.preview.secondaryInfo.isEmpty { + Text(result.preview.secondaryInfo) + .font(.subheadline) + .foregroundStyle(.secondary) } } } } - .overlay { - if items.isEmpty { - CatalogueEmptyView() + if !engine.results.isEmpty { + CatalogueLoadMoreCell() + } + } +} + +// MARK: - Load more (search pagination) + +private struct CatalogueLoadMoreCell: View { + + @Catalogue(\.isLoadingMore) + private var isLoadingMore + + @Catalogue(\.hasMore) + private var hasMore + + @Environment(\.loadCatalogue) + private var loadCatalogue + + var body: some View { + Group { + if isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else { + Color.clear.frame(height: 0) } } - .navigationDestination(for: CatalogueItemNavigation.self) { CatalogueItemNavigation.destination($0) } - .refreshable { await refreshCatalogue() } + .listRowSeparator(.hidden) + .onAppear { + Task { await loadCatalogue() } + } } } diff --git a/app-core/Sources/AppCore/Catalogue/Detail/AlbumDetailSection.swift b/app-core/Sources/AppCore/Catalogue/Detail/AlbumDetailSection.swift index cd9216d4..7d1ae1b2 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/AlbumDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/AlbumDetailSection.swift @@ -20,35 +20,42 @@ private struct AlbumInfoLine: View { struct AlbumDetailSection: View { - let previewID: UUID + let item: PreviewRecord @Query private var records: [AlbumRecord] @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..d7381abe 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/BookDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/BookDetailSection.swift @@ -20,31 +20,36 @@ private struct BookInfoLine: View { struct BookDetailSection: View { - let previewID: UUID + let item: PreviewRecord @Query private var records: [BookRecord] @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..0c78a009 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 - 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..6f44fc1e 100644 --- a/app-core/Sources/AppCore/Catalogue/Detail/OrphanDetailSection.swift +++ b/app-core/Sources/AppCore/Catalogue/Detail/OrphanDetailSection.swift @@ -1,18 +1,6 @@ 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 @@ -20,16 +8,9 @@ struct OrphanDetailSection: View { @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..9df68f7b 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 - 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..6e710e78 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 - 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..6168b475 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 - 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/View+Catalogue.swift b/app-core/Sources/AppCore/Catalogue/View+Catalogue.swift index 048d840c..269755ce 100644 --- a/app-core/Sources/AppCore/Catalogue/View+Catalogue.swift +++ b/app-core/Sources/AppCore/Catalogue/View+Catalogue.swift @@ -1,10 +1,12 @@ import SwiftUI public extension View { - /// Injects the Catalogue model + its refresh action for the tab subtree. + /// Injects the Catalogue model + its refresh and search actions for the tab subtree. func catalogue(_ model: CatalogueModel) -> some View { environment(model) .environment(\.refreshCatalogue, CatalogueRefreshAction(model: model)) .environment(\.selectCategories, SelectCategoriesAction(model: model)) + .environment(\.searchCatalogue, CatalogueSearchAction(model: model)) + .environment(\.loadCatalogue, CataloguePageLoadAction(model: model)) } } diff --git a/app-core/Sources/AppCore/Quotes/QuotesFilterEnvironment.swift b/app-core/Sources/AppCore/Quotes/QuotesFilterEnvironment.swift index 7120a46d..27a82c4b 100644 --- a/app-core/Sources/AppCore/Quotes/QuotesFilterEnvironment.swift +++ b/app-core/Sources/AppCore/Quotes/QuotesFilterEnvironment.swift @@ -2,4 +2,6 @@ import SwiftUI public extension EnvironmentValues { @Entry var selectQuoteSources: SelectQuoteSourcesAction = SelectQuoteSourcesAction() + @Entry var searchQuotes: QuotesSearchAction = QuotesSearchAction() + @Entry var loadQuotes: QuotesPageLoadAction = QuotesPageLoadAction() } diff --git a/app-core/Sources/AppCore/Quotes/QuotesModel.swift b/app-core/Sources/AppCore/Quotes/QuotesModel.swift index cb0478bc..e140e011 100644 --- a/app-core/Sources/AppCore/Quotes/QuotesModel.swift +++ b/app-core/Sources/AppCore/Quotes/QuotesModel.swift @@ -1,7 +1,9 @@ import Foundation +import OSLog import AppPersistence -/// Headless model for the Quotes tab — tracks the active source filter. +/// Headless model for the Quotes tab — tracks the active source filter and wires the +/// per-app search seam. /// /// `selectedSources` is a unified set of **source buckets**: catalogue category slugs /// (for note-sourced quotes) plus `.blogPosts` (for post-sourced quotes). @@ -14,8 +16,63 @@ import AppPersistence @Observable public final class QuotesModel { + // MARK: - Public state + /// Source buckets currently active in the filter. Empty = no filter (show all). public var selectedSources: Set = [] - public init() {} + /// Non-nil while a search network request is in flight. + public private(set) var isSearching = false + + /// `false` once a search load-more returns no cursor, meaning no more search pages. + public private(set) var hasMore = true + + /// Non-nil while a load-more network request is in flight. + public private(set) var isLoadingMore = false + + // MARK: - Dependencies + + /// Per-app seam for quotes search. Called with the current search term; should fetch + /// matching quotes from the server and upsert them into the local store. + private let _search: @Sendable (String) async throws -> Void + + /// Per-app seam for loading the next page of search results. + private let _loadMore: @Sendable () async throws -> Bool + + public init( + search: @escaping @Sendable (String) async throws -> Void = { _ in }, + loadMore: @escaping @Sendable () async throws -> Bool = { false } + ) { + self._search = search + self._loadMore = loadMore + } + + // MARK: - Search + + /// Fires the injected search closure with `term`. Resets `hasMore` optimistically. + public func search(term: String) async { + guard !isSearching else { return } + isSearching = true + hasMore = true // reset — load-more will settle it after the first search page + defer { isSearching = false } + do { + try await _search(term) + } catch { + Logger.quotes.error("Quotes search failed: \(error)") + hasMore = false + } + } + + // MARK: - Load more (subsequent search pages) + + public func loadMore() async { + guard hasMore, !isLoadingMore, !isSearching else { return } + isLoadingMore = true + defer { isLoadingMore = false } + do { + hasMore = try await _loadMore() + } catch { + Logger.quotes.error("Quotes load-more failed: \(error)") + } + } } diff --git a/app-core/Sources/AppCore/Quotes/QuotesPageLoadAction.swift b/app-core/Sources/AppCore/Quotes/QuotesPageLoadAction.swift new file mode 100644 index 00000000..0266abda --- /dev/null +++ b/app-core/Sources/AppCore/Quotes/QuotesPageLoadAction.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Triggers loading the next page of quote search results. +@MainActor +public struct QuotesPageLoadAction: Sendable { + + private let body: @MainActor () async -> Void + + nonisolated public init(_ body: @escaping @MainActor () async -> Void = {}) { + self.body = body + } + + public init(model: QuotesModel) { + self.init { await model.loadMore() } + } + + public func callAsFunction() async { await body() } +} diff --git a/app-core/Sources/AppCore/Quotes/QuotesSearchAction.swift b/app-core/Sources/AppCore/Quotes/QuotesSearchAction.swift new file mode 100644 index 00000000..8030731b --- /dev/null +++ b/app-core/Sources/AppCore/Quotes/QuotesSearchAction.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Fires a quotes search for `term` — the env-injected seam for per-tab text search. +@MainActor +public struct QuotesSearchAction: Sendable { + + private let body: @MainActor (String) async -> Void + + nonisolated public init(_ body: @escaping @MainActor (String) async -> Void = { _ in }) { + self.body = body + } + + public init(model: QuotesModel) { + self.init { term in await model.search(term: term) } + } + + public func callAsFunction(term: String) async { await body(term) } +} diff --git a/app-core/Sources/AppCore/Quotes/QuotesTab.swift b/app-core/Sources/AppCore/Quotes/QuotesTab.swift index c8eff726..61690b0a 100644 --- a/app-core/Sources/AppCore/Quotes/QuotesTab.swift +++ b/app-core/Sources/AppCore/Quotes/QuotesTab.swift @@ -11,23 +11,29 @@ struct QuotesTab: View { @Environment(\.refreshBlog) private var refreshBlog + @Environment(\.searchQuotes) + private var searchQuotes + @Quotes(\.selectedSources) private var selectedSources @Preferences(\.selectedLanguages) private var selectedLanguages + @State private var searchText = "" @State private var showFilter = false @State private var showRandom = false var body: some View { NavigationStack { QuotesList( + term: searchText.isEmpty ? nil : searchText, selectedSources: selectedSources, selectedLanguages: selectedLanguages, showRandom: $showRandom ) .navigationTitle("Quotes") + .searchable(text: $searchText, prompt: "Search quotes…") .toolbar { #if os(iOS) ToolbarItem(placement: .topBarLeading) { @@ -64,6 +70,14 @@ struct QuotesTab: View { await refreshCatalogue() await refreshBlog() } + // Debounce the network search 300 ms. No refresh on clear — quote browse is + // populated via catalogue/blog sync, not a standalone quotes refresh. + .task(id: searchText) { + guard !searchText.isEmpty else { return } + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + await searchQuotes(term: searchText) + } #if os(iOS) .sheet(isPresented: $showFilter) { QuotesFilterView() @@ -72,6 +86,8 @@ struct QuotesTab: View { } } +// MARK: - Unified list (browse and search) + private struct QuotesList: View { @Query private var quotes: [QuoteRecord] @@ -84,9 +100,18 @@ private struct QuotesList: View { @Binding private var showRandom: Bool - init(selectedSources: Set, selectedLanguages: Set, showRandom: Binding) { + let isSearching: Bool + + init( + term: String?, + selectedSources: Set, + selectedLanguages: Set, + showRandom: Binding + ) { + isSearching = term != nil _quotes = Query( - filter: QuoteRecord.list(sources: selectedSources, languages: selectedLanguages), + filter: term.map { QuoteRecord.search(term: $0, sources: selectedSources, languages: selectedLanguages) } + ?? QuoteRecord.list(sources: selectedSources, languages: selectedLanguages), sort: \QuoteRecord.createdAt, order: .reverse ) @@ -98,19 +123,60 @@ private struct QuotesList: View { ForEach(quotes) { quote in QuoteCell(quote: quote) } + if isSearching && !quotes.isEmpty { + QuotesLoadMoreCell() + } } .listStyle(.plain) .overlay { if quotes.isEmpty { - QuotesEmptyView() + if isSearching { + ContentUnavailableView.search + } else { + QuotesEmptyView() + } } } .refreshable { - await refreshCatalogue() - await refreshBlog() + if !isSearching { + await refreshCatalogue() + await refreshBlog() + } } .sheet(isPresented: $showRandom) { RandomQuoteSheet(quotes: quotes) } } } + +// MARK: - Load more (search pagination) + +private struct QuotesLoadMoreCell: View { + + @Quotes(\.isLoadingMore) + private var isLoadingMore + + @Quotes(\.hasMore) + private var hasMore + + @Environment(\.loadQuotes) + private var loadQuotes + + var body: some View { + Group { + if isLoadingMore { + HStack { + Spacer() + ProgressView() + Spacer() + } + } else { + Color.clear.frame(height: 0) + } + } + .listRowSeparator(.hidden) + .onAppear { + Task { await loadQuotes() } + } + } +} diff --git a/app-core/Sources/AppCore/Quotes/View+Quotes.swift b/app-core/Sources/AppCore/Quotes/View+Quotes.swift index b18cc14b..5ad0ee93 100644 --- a/app-core/Sources/AppCore/Quotes/View+Quotes.swift +++ b/app-core/Sources/AppCore/Quotes/View+Quotes.swift @@ -1,7 +1,7 @@ import SwiftUI public extension View { - /// Injects the Quotes model + its source-selection action for the tab subtree. + /// Injects the Quotes model + its source-selection and search actions for the tab subtree. /// /// Call this once at the composition root alongside `.blog(_:)` and `.catalogue(_:)`: /// @@ -12,5 +12,7 @@ public extension View { func quotes(_ model: QuotesModel) -> some View { environment(model) .environment(\.selectQuoteSources, SelectQuoteSourcesAction(model: model)) + .environment(\.searchQuotes, QuotesSearchAction(model: model)) + .environment(\.loadQuotes, QuotesPageLoadAction(model: model)) } } diff --git a/app-core/Sources/AppCore/Search/SearchTab.swift b/app-core/Sources/AppCore/Search/SearchTab.swift index 04db1e45..3847387d 100644 --- a/app-core/Sources/AppCore/Search/SearchTab.swift +++ b/app-core/Sources/AppCore/Search/SearchTab.swift @@ -1,7 +1,42 @@ import SwiftUI +import SwiftData +import AppPersistence +import TmbrCore + +/// Scope for the dedicated Search tab's segmented scope bar. +enum SearchScope: String, CaseIterable { + case all = "All" + case blog = "Blog" + case catalogue = "Catalogue" + case quotes = "Quotes" +} public struct SearchTab: View { + + @Environment(\.searchBlog) + private var searchBlog + + @Environment(\.searchCatalogue) + private var searchCatalogue + + @Environment(\.searchQuotes) + private var searchQuotes + + @Environment(\.modelContext) + private var modelContext + + @Preferences(\.selectedLanguages) + private var selectedLanguages + + @Catalogue(\.selectedCategories) + private var selectedCategories + + @Quotes(\.selectedSources) + private var selectedSources + @State private var searchText = "" + @State private var selectedScope: SearchScope = .all + @State private var catalogueEngine = CatalogueSearchEngine() public init() {} @@ -15,11 +50,139 @@ public struct SearchTab: View { description: Text("Posts, songs, books, movies, and more.") ) } else { - List(placeholderResults(for: searchText)) { result in + SearchResultsView( + term: searchText, + scope: selectedScope, + catalogueEngine: catalogueEngine, + selectedLanguages: selectedLanguages, + selectedCategories: selectedCategories, + selectedSources: selectedSources + ) + } + } + .navigationTitle("Search") + .searchable(text: $searchText, prompt: "Posts, songs, books…") + .searchScopes($selectedScope, activation: .onSearchPresentation) { + ForEach(SearchScope.allCases, id: \.self) { scope in + Text(scope.rawValue).tag(scope) + } + } + .navigationDestination(for: PostRecord.self) { post in + PostReaderView( + title: post.title, + content: post.content, + created: post.createdAt, + published: post.publishedAt + ) + } + .navigationDestination(for: CatalogueItemNavigation.self) { CatalogueItemNavigation.destination($0) } + } + // Fire all network searches in parallel when term changes (debounced 300 ms). + .task(id: searchText) { + guard !searchText.isEmpty else { + catalogueEngine.clear() + return + } + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + let term = searchText + // Local catalogue search runs immediately. + catalogueEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + // All three network searches fire concurrently. + await withTaskGroup(of: Void.self) { group in + group.addTask { await searchBlog(term: term) } + group.addTask { await searchCatalogue(term: term) } + group.addTask { await searchQuotes(term: term) } + } + guard !Task.isCancelled else { return } + // Re-run local catalogue search to pick up newly upserted items/notes. + catalogueEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + } + } +} + +// MARK: - Results view (switches on scope) + +private struct SearchResultsView: View { + + let term: String + let scope: SearchScope + let catalogueEngine: CatalogueSearchEngine + let selectedLanguages: Set + let selectedCategories: Set + let selectedSources: Set + + var body: some View { + List { + switch scope { + case .all: + PostsSection(term: term, selectedLanguages: selectedLanguages) + CatalogueSection(engine: catalogueEngine) + QuotesSection(term: term, selectedSources: selectedSources, selectedLanguages: selectedLanguages) + case .blog: + PostsSection(term: term, selectedLanguages: selectedLanguages) + case .catalogue: + CatalogueSection(engine: catalogueEngine) + case .quotes: + QuotesSection(term: term, selectedSources: selectedSources, selectedLanguages: selectedLanguages) + } + } + .listStyle(.plain) + } +} + +// MARK: - Blog section + +private struct PostsSection: View { + + @Query private var posts: [PostRecord] + + init(term: String, selectedLanguages: Set) { + _posts = Query( + filter: PostRecord.search(term: term, languages: selectedLanguages), + sort: \PostRecord.createdAt, + order: .reverse + ) + } + + var body: some View { + if !posts.isEmpty { + Section("Blog") { + ForEach(posts) { post in + NavigationLink(value: post) { + PostCell( + title: post.title, + date: post.publishedAt ?? post.createdAt + ) + } + } + } + } + } +} + +// MARK: - Catalogue section + +private struct CatalogueSection: View { + + let engine: CatalogueSearchEngine + + var body: some View { + if !engine.results.isEmpty { + Section("Catalogue") { + ForEach(engine.results) { result in + NavigationLink { + CatalogueItemDetailView(item: result.preview) + } label: { VStack(alignment: .leading, spacing: 2) { - Text(result.primaryInfo) - if let secondary = result.secondaryInfo { - Text(secondary) + Text(result.preview.primaryInfo) + if let snippet = result.noteSnippet { + Text(snippet) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if !result.preview.secondaryInfo.isEmpty { + Text(result.preview.secondaryInfo) .font(.subheadline) .foregroundStyle(.secondary) } @@ -27,23 +190,31 @@ public struct SearchTab: View { } } } - .navigationTitle("Search") - .searchable(text: $searchText, prompt: "Posts, songs, books…") } } +} + +// MARK: - Quotes section + +private struct QuotesSection: View { + + @Query private var quotes: [QuoteRecord] - private struct SearchResult: Identifiable { - let id = UUID() - let primaryInfo: String - let secondaryInfo: String? + init(term: String, selectedSources: Set, selectedLanguages: Set) { + _quotes = Query( + filter: QuoteRecord.search(term: term, sources: selectedSources, languages: selectedLanguages), + sort: \QuoteRecord.createdAt, + order: .reverse + ) } - private func placeholderResults(for query: String) -> [SearchResult] { - [ - .init(primaryInfo: "The Glow Pt. 2", secondaryInfo: "The Microphones · Album"), - .init(primaryInfo: "On keeping a reading journal", secondaryInfo: "Post"), - .init(primaryInfo: "Stranger in the Alps", secondaryInfo: "Phoebe Bridgers · Album"), - .init(primaryInfo: "Normal People", secondaryInfo: "Sally Rooney · Book"), - ] + var body: some View { + if !quotes.isEmpty { + Section("Quotes") { + ForEach(quotes) { quote in + QuoteCell(quote: quote) + } + } + } } } diff --git a/app-core/Sources/AppCore/Shared/Log.swift b/app-core/Sources/AppCore/Shared/Log.swift index 782b22e0..61d8daf9 100644 --- a/app-core/Sources/AppCore/Shared/Log.swift +++ b/app-core/Sources/AppCore/Shared/Log.swift @@ -3,4 +3,5 @@ import OSLog extension Logger { static let blog = Logger(subsystem: "me.tmbr", category: "blog") static let catalogue = Logger(subsystem: "me.tmbr", category: "catalogue") + static let quotes = Logger(subsystem: "me.tmbr", category: "quotes") } diff --git a/tmbr-app/Reader/ReaderApp.swift b/tmbr-app/Reader/ReaderApp.swift index d8d884f1..81c995cf 100644 --- a/tmbr-app/Reader/ReaderApp.swift +++ b/tmbr-app/Reader/ReaderApp.swift @@ -3,6 +3,7 @@ import SwiftData import AppApi import AppCore import AppPersistence +import TmbrCore /// Reader — public, read-only. Plain on-disk SwiftData cache; no auth, no account. /// Data enters lazily: the Blog tab's `refresh` fetches public posts + upserts (`ReaderPosts`). @@ -14,7 +15,7 @@ struct ReaderApp: App { private let blog: BlogModel private let catalogue: CatalogueModel - private let quotes = QuotesModel() + private let quotes: QuotesModel private let preferences = PreferencesModel() init() { @@ -23,9 +24,9 @@ struct ReaderApp: App { } catch { fatalError("Failed to create Reader ModelContainer: \(error)") } + let postStore = PostStore(context: container.mainContext) let loader = PostsLoader.posts(baseURL: Self.apiBaseURL) - let store = PostStore(context: container.mainContext) - let posts = ReaderPosts(loader: loader, store: store) + let posts = ReaderPosts(loader: loader, store: postStore) let userDefaults = UserDefaults.standard let lastFetchedKey = "reader.blog.lastFetched" blog = BlogModel( @@ -36,12 +37,26 @@ struct ReaderApp: App { return now }, loadMore: { try await posts.loadMore() }, + search: { term in try await posts.search(term: term) }, lastFetched: userDefaults.object(forKey: lastFetchedKey) as? Date ) let catalogueStore = CatalogueStore(context: container.mainContext) let catalogueSync = SyncGroup.catalogue(baseURL: Self.apiBaseURL, store: catalogueStore) - catalogue = CatalogueModel(refresh: { try await catalogueSync.run() }) + let catalogueSearchLoader = CatalogueSearchLoader.searchCatalogue(baseURL: Self.apiBaseURL) + let readerCatalogue = ReaderCatalogue(loader: catalogueSearchLoader, store: catalogueStore) + catalogue = CatalogueModel( + refresh: { try await catalogueSync.run() }, + search: { term in try await readerCatalogue.search(term: term) }, + loadMore: { try await readerCatalogue.loadMore() } + ) + + let quotesLoader = QuotesLoader.quotes(baseURL: Self.apiBaseURL) + let readerQuotes = ReaderQuotes(loader: quotesLoader, store: catalogueStore) + quotes = QuotesModel( + search: { term in try await readerQuotes.search(term: term) }, + loadMore: { try await readerQuotes.loadMore() } + ) } var body: some Scene { diff --git a/tmbr-app/Reader/ReaderCatalogue.swift b/tmbr-app/Reader/ReaderCatalogue.swift new file mode 100644 index 00000000..d2ee77e3 --- /dev/null +++ b/tmbr-app/Reader/ReaderCatalogue.swift @@ -0,0 +1,61 @@ +import Foundation +import OSLog +import AppApi +import TmbrCore +import AppPersistence + +/// Reader's lazy data-in for catalogue search: fetch matching items and notes (unauthenticated) +/// and upsert them into the local store. `CatalogueSearchEngine` in `CatalogueTab` then re-runs +/// the local search to pick up the new records. +/// +/// `search(term:)` resets the cursor so `loadMore()` always continues the active search. +@MainActor +final class ReaderCatalogue { + + private let loader: CatalogueSearchLoader + + private let store: CatalogueStore + + private let logger = Logger(subsystem: "me.tmbr", category: "sync") + + private var currentTerm: String? + + private var nextCursor: String? + + init(loader: CatalogueSearchLoader, store: CatalogueStore) { + self.loader = loader + self.store = store + } + + // MARK: - Search + + /// Fetches the first page of search results for `term` and upserts them. Resets the cursor + /// so subsequent `loadMore()` calls continue this search. + func search(term: String) async throws { + currentTerm = term + nextCursor = nil + let response = try await fetch(term: term, cursor: nil) + nextCursor = response.nextCursor + try store.upsertSearch(response) + logger.info("Catalogue search '\(term)': \(response.items.count) items, \(response.notes.count) notes, nextCursor=\(response.nextCursor ?? "nil")") + } + + // MARK: - Load more + + /// Fetches the next cursor page and upserts it. Returns `true` if more pages remain. + func loadMore() async throws -> Bool { + guard let term = currentTerm, let cursor = nextCursor else { return false } + let response = try await fetch(term: term, cursor: cursor) + nextCursor = response.nextCursor + try store.upsertSearch(response) + logger.info("Catalogue load-more '\(term)': \(response.items.count) items, \(response.notes.count) notes, nextCursor=\(response.nextCursor ?? "nil")") + return response.nextCursor != nil + } + + // MARK: - Private + + private func fetch(term: String, cursor: String?) async throws -> CatalogueSearchResponse { + let query = CatalogueSearchQuery(term: term, cursor: cursor, limit: 20) + return try await loader.load(from: query) + } +} diff --git a/tmbr-app/Reader/ReaderPosts.swift b/tmbr-app/Reader/ReaderPosts.swift index c39a73ec..9ee35ff1 100644 --- a/tmbr-app/Reader/ReaderPosts.swift +++ b/tmbr-app/Reader/ReaderPosts.swift @@ -5,13 +5,19 @@ import AppCore import TmbrCore import AppPersistence -/// Reader's lazy data-in for the blog: fetch the public posts list (unauthenticated) and upsert -/// into the local store. `@Query` in `BlogTab` renders the result reactively. +/// Reader's lazy data-in for the blog: fetch posts (unauthenticated) and upsert into the local +/// store. `@Query` in `BlogTab` renders the result reactively. /// -/// Pagination is cursor-based: `refreshPosts()` loads the first page and stores a `nextCursor` -/// (if the server returned one); `loadMore()` continues from that cursor. `nextCursor` is reset -/// on every full refresh so pull-to-refresh always starts clean. +/// The loader hits a single unified endpoint (`GET /api/posts`) that accepts both pagination +/// cursors and an optional search `term`. Browse and search share the same fetch path; the +/// caller supplies the term. /// +/// **Cursor strategy** — two independent cursors are kept so browse and search pagination +/// are independent: clearing a search returns to the browse page where the user left off. +/// +/// - `browseCursor` — tracks the next browse page; reset by `refreshPosts()`. +/// - `searchCursor` — tracks the next search page; reset by each new `search(term:)` call. +/// - `currentTerm` — nil means browse mode; non-nil means search mode. @MainActor final class ReaderPosts { @@ -21,41 +27,69 @@ final class ReaderPosts { private let logger = Logger(subsystem: "me.tmbr", category: "sync") - private var nextCursor: String? + private var browseCursor: String? + + private var searchCursor: String? + + private var currentTerm: String? init(loader: PostsLoader, store: PostStore) { self.loader = loader self.store = store } - // MARK: - Refresh (first page) + // MARK: - Browse - /// Fetches the first page and upserts it. Resets the cursor so subsequent `loadMore()` calls - /// continue from the right position. + /// Fetches the first browse page and upserts it. Resets the browse cursor and clears any + /// active search term so subsequent `loadMore()` calls continue the browse list. func refreshPosts() async throws { - let page = try await fetch(cursor: nil) - nextCursor = page.nextCursor + let page = try await fetch(term: nil, cursor: nil) + browseCursor = page.nextCursor + currentTerm = nil + searchCursor = nil try store.upsert(page.items) logger.info("Blog refresh: loaded \(page.items.count) posts, nextCursor=\(page.nextCursor ?? "nil")") } - // MARK: - Load more (subsequent pages) + // MARK: - Search - /// Fetches the next cursor page and upserts it. Returns `true` if more pages remain. - /// Returns `false` immediately (without a network call) when there is no cursor. - func loadMore() async throws -> Bool { - guard let cursor = nextCursor else { return false } - let page = try await fetch(cursor: cursor) - nextCursor = page.nextCursor + /// Fetches the first page of search results for `term` and upserts them. Resets the search + /// cursor so subsequent `loadMore()` calls continue the search, not the browse list. + func search(term: String) async throws { + currentTerm = term + searchCursor = nil + let page = try await fetch(term: term, cursor: nil) + searchCursor = page.nextCursor try store.upsert(page.items) - logger.info("Blog load-more: loaded \(page.items.count) posts, nextCursor=\(page.nextCursor ?? "nil")") - return page.nextCursor != nil + logger.info("Blog search '\(term)': loaded \(page.items.count) posts, nextCursor=\(page.nextCursor ?? "nil")") + } + + // MARK: - Load more + + /// Fetches the next cursor page (browse or search, depending on `currentTerm`) and upserts + /// it. Returns `true` if more pages remain. + func loadMore() async throws -> Bool { + if let term = currentTerm { + guard let cursor = searchCursor else { return false } + let page = try await fetch(term: term, cursor: cursor) + searchCursor = page.nextCursor + try store.upsert(page.items) + logger.info("Blog search load-more '\(term)': loaded \(page.items.count) posts, nextCursor=\(page.nextCursor ?? "nil")") + return page.nextCursor != nil + } else { + guard let cursor = browseCursor else { return false } + let page = try await fetch(term: nil, cursor: cursor) + browseCursor = page.nextCursor + try store.upsert(page.items) + logger.info("Blog load-more: loaded \(page.items.count) posts, nextCursor=\(page.nextCursor ?? "nil")") + return page.nextCursor != nil + } } // MARK: - Private - private func fetch(cursor: String?) async throws -> PageResult { - let query = PageQuery(cursor: cursor, limit: 50) + private func fetch(term: String?, cursor: String?) async throws -> PageResult { + let query = PostsQuery(term: term, cursor: cursor, limit: 50) do { return try await loader.load(from: query) } catch let error as RequestError { diff --git a/tmbr-app/Reader/ReaderQuotes.swift b/tmbr-app/Reader/ReaderQuotes.swift new file mode 100644 index 00000000..413046a1 --- /dev/null +++ b/tmbr-app/Reader/ReaderQuotes.swift @@ -0,0 +1,64 @@ +import Foundation +import OSLog +import AppApi +import TmbrCore +import AppPersistence + +/// Reader's lazy data-in for quotes search: fetch matching quotes (unauthenticated) and upsert +/// into the local store. `@Query` in `QuotesTab` renders the result reactively. +/// +/// Unlike the blog, quotes don't have a standalone browse refresh — the browse list is +/// populated via the catalogue/blog sync. This helper only manages **search** pagination. +/// +/// The search cursor is reset by each new `search(term:)` call, so `loadMore()` always +/// continues the active search. +@MainActor +final class ReaderQuotes { + + private let loader: QuotesLoader + + private let store: CatalogueStore + + private let logger = Logger(subsystem: "me.tmbr", category: "sync") + + private var currentTerm: String? + + private var nextCursor: String? + + init(loader: QuotesLoader, store: CatalogueStore) { + self.loader = loader + self.store = store + } + + // MARK: - Search + + /// Fetches the first page of search results for `term` and upserts them. Resets the + /// cursor so subsequent `loadMore()` calls continue this search. + func search(term: String) async throws { + currentTerm = term + nextCursor = nil + let page = try await fetch(term: term, cursor: nil) + nextCursor = page.nextCursor + try store.upsert(page.items) + logger.info("Quotes search '\(term)': loaded \(page.items.count) quotes, nextCursor=\(page.nextCursor ?? "nil")") + } + + // MARK: - Load more + + /// Fetches the next cursor page and upserts it. Returns `true` if more pages remain. + func loadMore() async throws -> Bool { + guard let term = currentTerm, let cursor = nextCursor else { return false } + let page = try await fetch(term: term, cursor: cursor) + nextCursor = page.nextCursor + try store.upsert(page.items) + logger.info("Quotes search load-more '\(term)': loaded \(page.items.count) quotes, nextCursor=\(page.nextCursor ?? "nil")") + return page.nextCursor != nil + } + + // MARK: - Private + + private func fetch(term: String, cursor: String?) async throws -> PageResult { + let query = QuotesQuery(term: term, cursor: cursor, limit: 50) + return try await loader.load(from: query) + } +} diff --git a/tmbr-app/tmbr-app.xcodeproj/project.pbxproj b/tmbr-app/tmbr-app.xcodeproj/project.pbxproj index c5576ee2..4981df80 100644 --- a/tmbr-app/tmbr-app.xcodeproj/project.pbxproj +++ b/tmbr-app/tmbr-app.xcodeproj/project.pbxproj @@ -10,11 +10,11 @@ 03297F0053BB73A1328B6378 /* AppCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8229BF76A2BB18E12999E69F /* AppCore */; }; 109CB8C24233693E8E8EF54C /* TmbrCore in Frameworks */ = {isa = PBXBuildFile; productRef = F193199EDBC5D787AC73097C /* TmbrCore */; }; 2549866CD08843ECFBA90108 /* TmbrCore in Frameworks */ = {isa = PBXBuildFile; productRef = D47E8BCA7C0DA4C5D5F9513D /* TmbrCore */; }; - AA000004BB000004CC000004 /* AppPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = AA000002BB000002CC000002 /* AppPersistence */; }; - AA000005BB000005CC000005 /* AppPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = AA000003BB000003CC000003 /* AppPersistence */; }; 707239A243CF738549160865 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E173FBA3F6594B28E84BDB5C /* Cocoa.framework */; }; 9692272AC826634604D10C7E /* AppCore in Frameworks */ = {isa = PBXBuildFile; productRef = 4359256FA09B2AD131BFF498 /* AppCore */; }; A838BCBD3A5F97E873E1C206 /* AppApi in Frameworks */ = {isa = PBXBuildFile; productRef = CD234EC0DAF2EA0B8DE0C637 /* AppApi */; }; + AA000004BB000004CC000004 /* AppPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = AA000002BB000002CC000002 /* AppPersistence */; }; + AA000005BB000005CC000005 /* AppPersistence in Frameworks */ = {isa = PBXBuildFile; productRef = AA000003BB000003CC000003 /* AppPersistence */; }; E2497B602FE1302F00D1B325 /* AppCore in Frameworks */ = {isa = PBXBuildFile; productRef = E2497B5F2FE1302F00D1B325 /* AppCore */; }; E27B48712FC58690006866D7 /* TmbrCore in Frameworks */ = {isa = PBXBuildFile; productRef = E27B48722FC58690006866D7 /* TmbrCore */; }; E27C0B542FC5FE480055C952 /* AppApi in Frameworks */ = {isa = PBXBuildFile; productRef = E27C0B532FC5FE480055C952 /* AppApi */; }; @@ -1074,6 +1074,10 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ + AA000001BB000001CC000001 /* XCLocalSwiftPackageReference "../app-persistence" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = "../app-persistence"; + }; E2497BB72FE13F5400D1B325 /* XCLocalSwiftPackageReference "../app-core" */ = { isa = XCLocalSwiftPackageReference; relativePath = "../app-core"; @@ -1086,10 +1090,6 @@ isa = XCLocalSwiftPackageReference; relativePath = "../app-api"; }; - AA000001BB000001CC000001 /* XCLocalSwiftPackageReference "../app-persistence" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = "../app-persistence"; - }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1103,6 +1103,16 @@ package = E2497BB72FE13F5400D1B325 /* XCLocalSwiftPackageReference "../app-core" */; productName = AppCore; }; + AA000002BB000002CC000002 /* AppPersistence */ = { + isa = XCSwiftPackageProductDependency; + package = AA000001BB000001CC000001 /* XCLocalSwiftPackageReference "../app-persistence" */; + productName = AppPersistence; + }; + AA000003BB000003CC000003 /* AppPersistence */ = { + isa = XCSwiftPackageProductDependency; + package = AA000001BB000001CC000001 /* XCLocalSwiftPackageReference "../app-persistence" */; + productName = AppPersistence; + }; CD234EC0DAF2EA0B8DE0C637 /* AppApi */ = { isa = XCSwiftPackageProductDependency; package = E27C0B512FC5FE100055C952 /* XCLocalSwiftPackageReference "../app-api" */; @@ -1133,16 +1143,6 @@ package = E27B48702FC58689006866D7 /* XCLocalSwiftPackageReference "../tmbr-core" */; productName = TmbrCore; }; - AA000002BB000002CC000002 /* AppPersistence */ = { - isa = XCSwiftPackageProductDependency; - package = AA000001BB000001CC000001 /* XCLocalSwiftPackageReference "../app-persistence" */; - productName = AppPersistence; - }; - AA000003BB000003CC000003 /* AppPersistence */ = { - isa = XCSwiftPackageProductDependency; - package = AA000001BB000001CC000001 /* XCLocalSwiftPackageReference "../app-persistence" */; - productName = AppPersistence; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = E27B47802FC4E864006866D7 /* Project object */;