diff --git a/app-api/Sources/AppApi/Sync/SyncLoaders.swift b/app-api/Sources/AppApi/Sync/SyncLoaders.swift index c792a69a..c51f07d1 100644 --- a/app-api/Sources/AppApi/Sync/SyncLoaders.swift +++ b/app-api/Sources/AppApi/Sync/SyncLoaders.swift @@ -136,6 +136,26 @@ 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. + +public typealias PostSearchLoader = 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 CatalogueSearchLoader { + static func searchCatalogue(baseURL: URL, session: URLSession = .shared) -> Self { + CatalogueSearchLoader(request: CatalogueSearchRequest.catalogueSearch(baseURL: baseURL), session: session) + } +} + // MARK: - Single-item loaders (unauthenticated — Reader app) // MARK: - Single-item loaders diff --git a/app-api/Sources/AppApi/Sync/SyncRequests.swift b/app-api/Sources/AppApi/Sync/SyncRequests.swift index c2531ef0..761e035b 100644 --- a/app-api/Sources/AppApi/Sync/SyncRequests.swift +++ b/app-api/Sources/AppApi/Sync/SyncRequests.swift @@ -60,6 +60,22 @@ public extension Request where Self == DeletionsRequest { static func deletionQuery(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/sync/deletions") } } +// 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`. + +public typealias PostSearchRequest = BasicRequest> +public extension Request where Self == PostSearchRequest { + static func postSearch(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/posts") } +} + +public typealias CatalogueSearchRequest = BasicRequest +public extension Request where Self == CatalogueSearchRequest { + static func catalogueSearch(baseURL: URL) -> Self { .query(baseURL: baseURL, path: "api/catalogue/search") } +} + // MARK: - Single-item requests // // The item id is the `Input` type (not baked into the URL), so one loader instance can serve 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..19abc4d8 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,22 @@ public final class BlogModel { } } + // MARK: - Search + + /// Fires the injected search closure with `term`. Sets `loading = .refresh` while in flight + /// so the tab can show a spinner alongside the `@Query`-driven local results. + public func search(term: String) async { + guard loading == nil else { return } + loading = .refresh + defer { loading = nil } + do { + try await _search(term) + } catch { + Logger.blog.error("Blog search failed: \(error)") + lastError = LoadError(error) + } + } + // 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..00f0323c 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,51 @@ struct BlogTab: View { var body: some View { NavigationStack { - PostsList(selectedLanguages: selectedLanguages) - .navigationTitle("Blog") - .toolbar { + Group { + if searchText.isEmpty { + PostsList(selectedLanguages: selectedLanguages) + } else { + PostsSearchList(term: 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() } + // Fire the network search whenever the term changes (debounced 300 ms). + .task(id: searchText) { + guard !searchText.isEmpty else { 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 +81,8 @@ struct BlogTab: View { } } +// MARK: - Browse list (no search term) + private struct PostsList: View { @Query private var posts: [PostRecord] @@ -109,3 +131,44 @@ private struct PostsList: View { .refreshable { await refreshBlog() } } } + +// MARK: - Search list (active search term) + +private struct PostsSearchList: 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 { + List { + ForEach(posts) { post in + NavigationLink(value: post) { + PostCell( + title: post.title, + date: post.publishedAt ?? post.createdAt + ) + } + } + } + .overlay { + if posts.isEmpty { + ContentUnavailableView.search + } + } + .navigationDestination(for: PostRecord.self) { post in + PostReaderView( + title: post.title, + content: post.content, + created: post.createdAt, + published: post.publishedAt + ) + } + } +} 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/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..1c1f358e 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueEnvironment.swift @@ -4,6 +4,7 @@ import Foundation public extension EnvironmentValues { @Entry var refreshCatalogue: CatalogueRefreshAction = CatalogueRefreshAction() @Entry var selectCategories: SelectCategoriesAction = SelectCategoriesAction() + @Entry var searchCatalogue: CatalogueSearchAction = CatalogueSearchAction() // 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..40c40b5c 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueModel.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueModel.swift @@ -26,8 +26,16 @@ public final class CatalogueModel { 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 + + public init( + refresh: @escaping @Sendable () async throws -> Void = {}, + search: @escaping @Sendable (String) async throws -> Void = { _ in } + ) { self._refresh = refresh + self._search = search } // MARK: - Refresh @@ -45,4 +53,19 @@ 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 + defer { loading = nil } + do { + try await _search(term) + } catch { + Logger.catalogue.error("Catalogue search 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..a51d2bb6 100644 --- a/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift +++ b/app-core/Sources/AppCore/Catalogue/CatalogueTab.swift @@ -8,6 +8,12 @@ struct CatalogueTab: View { @Environment(\.refreshCatalogue) private var refreshCatalogue + @Environment(\.searchCatalogue) + private var searchCatalogue + + @Environment(\.modelContext) + private var modelContext + @Environment(\.canAuthor) private var canAuthor @@ -17,6 +23,9 @@ struct CatalogueTab: View { @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 +33,18 @@ struct CatalogueTab: View { var body: some View { NavigationStack { - CatalogueItemsList( - selectedCategories: selectedCategories, - selectedLanguages: selectedLanguages - ) + Group { + if searchText.isEmpty { + CatalogueItemsList( + selectedCategories: selectedCategories, + selectedLanguages: selectedLanguages + ) + } else { + CatalogueSearchList(engine: searchEngine) + } + } .navigationTitle("Catalogue") + .searchable(text: $searchText, prompt: "Search catalogue…") .toolbar { #if os(iOS) ToolbarItem(placement: .topBarLeading) { @@ -58,6 +74,29 @@ struct CatalogueTab: View { } } .task { await refreshCatalogue() } + // Fire local search immediately, then network search, then re-run local (sees new upserts). + .task(id: searchText) { + guard !searchText.isEmpty else { + searchEngine.clear() + return + } + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + let term = searchText + searchEngine.search(term: term, in: modelContext, categories: selectedCategories, languages: selectedLanguages) + 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) + } #if os(iOS) .sheet(isPresented: $showFilter) { CatalogueFilterView() @@ -79,6 +118,8 @@ struct CatalogueTab: View { } } +// MARK: - Browse list (no search term) + private struct CatalogueItemsList: View { @Query private var items: [PreviewRecord] @@ -104,8 +145,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) } @@ -122,3 +163,41 @@ private struct CatalogueItemsList: View { .refreshable { await refreshCatalogue() } } } + +// MARK: - Search list (active search term, driven by CatalogueSearchEngine) + +private struct CatalogueSearchList: View { + + let engine: CatalogueSearchEngine + + var body: some View { + List { + 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 { + // Note-body match: show a snippet of the matched note. + Text(snippet) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } else if !result.preview.secondaryInfo.isEmpty { + Text(result.preview.secondaryInfo) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + } + .overlay { + if engine.results.isEmpty { + ContentUnavailableView.search + } + } + .navigationDestination(for: CatalogueItemNavigation.self) { CatalogueItemNavigation.destination($0) } + } +} 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..026e0b68 100644 --- a/app-core/Sources/AppCore/Catalogue/View+Catalogue.swift +++ b/app-core/Sources/AppCore/Catalogue/View+Catalogue.swift @@ -1,10 +1,11 @@ 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)) } } diff --git a/app-core/Sources/AppCore/Quotes/QuotesFilterEnvironment.swift b/app-core/Sources/AppCore/Quotes/QuotesFilterEnvironment.swift index 7120a46d..3236e6f5 100644 --- a/app-core/Sources/AppCore/Quotes/QuotesFilterEnvironment.swift +++ b/app-core/Sources/AppCore/Quotes/QuotesFilterEnvironment.swift @@ -2,4 +2,5 @@ import SwiftUI public extension EnvironmentValues { @Entry var selectQuoteSources: SelectQuoteSourcesAction = SelectQuoteSourcesAction() + @Entry var searchQuotes: QuotesSearchAction = QuotesSearchAction() } diff --git a/app-core/Sources/AppCore/Quotes/QuotesModel.swift b/app-core/Sources/AppCore/Quotes/QuotesModel.swift index cb0478bc..58c9dc5d 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,34 @@ 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 + + // 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 + + public init(search: @escaping @Sendable (String) async throws -> Void = { _ in }) { + self._search = search + } + + // MARK: - Search + + public func search(term: String) async { + guard !isSearching else { return } + isSearching = true + defer { isSearching = false } + do { + try await _search(term) + } catch { + Logger.quotes.error("Quotes search failed: \(error)") + } + } } 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..4df4f241 100644 --- a/app-core/Sources/AppCore/Quotes/QuotesTab.swift +++ b/app-core/Sources/AppCore/Quotes/QuotesTab.swift @@ -11,23 +11,38 @@ 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( - selectedSources: selectedSources, - selectedLanguages: selectedLanguages, - showRandom: $showRandom - ) + Group { + if searchText.isEmpty { + QuotesList( + selectedSources: selectedSources, + selectedLanguages: selectedLanguages, + showRandom: $showRandom + ) + } else { + QuotesSearchList( + term: searchText, + selectedSources: selectedSources, + selectedLanguages: selectedLanguages + ) + } + } .navigationTitle("Quotes") + .searchable(text: $searchText, prompt: "Search quotes…") .toolbar { #if os(iOS) ToolbarItem(placement: .topBarLeading) { @@ -64,6 +79,13 @@ struct QuotesTab: View { await refreshCatalogue() await refreshBlog() } + // Fire network search on term changes (debounced 300 ms). + .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 +94,8 @@ struct QuotesTab: View { } } +// MARK: - Browse list (no search term) + private struct QuotesList: View { @Query private var quotes: [QuoteRecord] @@ -114,3 +138,32 @@ private struct QuotesList: View { } } } + +// MARK: - Search list (active search term) + +private struct QuotesSearchList: View { + + @Query private var quotes: [QuoteRecord] + + init(term: String, selectedSources: Set, selectedLanguages: Set) { + _quotes = Query( + filter: QuoteRecord.search(term: term, sources: selectedSources, languages: selectedLanguages), + sort: \QuoteRecord.createdAt, + order: .reverse + ) + } + + var body: some View { + List { + ForEach(quotes) { quote in + QuoteCell(quote: quote) + } + } + .listStyle(.plain) + .overlay { + if quotes.isEmpty { + ContentUnavailableView.search + } + } + } +} diff --git a/app-core/Sources/AppCore/Quotes/View+Quotes.swift b/app-core/Sources/AppCore/Quotes/View+Quotes.swift index b18cc14b..9ae70e21 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,6 @@ public extension View { func quotes(_ model: QuotesModel) -> some View { environment(model) .environment(\.selectQuoteSources, SelectQuoteSourcesAction(model: model)) + .environment(\.searchQuotes, QuotesSearchAction(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/app-persistence/Sources/AppPersistence/CatalogueRecord+Sync.swift b/app-persistence/Sources/AppPersistence/CatalogueRecord+Sync.swift index b0b1f52b..d76b8f14 100644 --- a/app-persistence/Sources/AppPersistence/CatalogueRecord+Sync.swift +++ b/app-persistence/Sources/AppPersistence/CatalogueRecord+Sync.swift @@ -12,7 +12,7 @@ public extension PreviewRecord { categoryType = preview.category ?? preview.source.type sourceID = preview.source.id primaryInfo = preview.primaryInfo - secondaryInfo = preview.secondaryInfo + secondaryInfo = preview.secondaryInfo ?? "" imageURL = preview.image?.url externalLinks = preview.resources self.access = access diff --git a/app-persistence/Sources/AppPersistence/CatalogueSearchResult.swift b/app-persistence/Sources/AppPersistence/CatalogueSearchResult.swift new file mode 100644 index 00000000..4e14955e --- /dev/null +++ b/app-persistence/Sources/AppPersistence/CatalogueSearchResult.swift @@ -0,0 +1,23 @@ +import Foundation +import SwiftData + +/// A single result row for the catalogue search surface. +/// +/// `preview` is the matching catalogue item. `noteSnippet` is present when the match came +/// from a note body (not the item's own title/subtitle) — it holds the full note body so +/// the row can render a snippet. +public struct CatalogueSearchResult: Identifiable { + + public var id: UUID { preview.id } + + public let preview: PreviewRecord + + /// The full note body that matched, when this result came from a note-body search. + /// `nil` when the match was on the item's own `primaryInfo` or `secondaryInfo`. + public let noteSnippet: String? + + public init(preview: PreviewRecord, noteSnippet: String? = nil) { + self.preview = preview + self.noteSnippet = noteSnippet + } +} diff --git a/app-persistence/Sources/AppPersistence/CatalogueStore.swift b/app-persistence/Sources/AppPersistence/CatalogueStore.swift index d0793f5c..841f7dd5 100644 --- a/app-persistence/Sources/AppPersistence/CatalogueStore.swift +++ b/app-persistence/Sources/AppPersistence/CatalogueStore.swift @@ -144,6 +144,118 @@ public struct CatalogueStore { try context.save() } + /// Upserts a `CatalogueSearchResponse` — the combined result of a server-side catalogue search. + /// + /// `items` (title/subtitle matches) are upserted as `PreviewRecord`s without touching their + /// notes. `notes` (body matches) are upserted along with their attachment preview, letting the + /// app find note-body matches in future offline searches. Existing quotes for those notes are + /// preserved — this path does not delete quotes absent from the search response. + public func upsertSearch(_ response: CatalogueSearchResponse) throws { + var previews = try previewsByID() + // Upsert item matches (preview metadata only, no embedded notes from this path). + for preview in response.items { + upsertPreview(preview, access: .public, into: &previews) + } + // Upsert note matches with their attachment previews. + var noteIndex: [UUID: NoteRecord] = [:] + for record in try context.fetch(FetchDescriptor()) { + if let sid = record.serverID { noteIndex[sid] = record } + } + for note in response.notes { + // Ensure the note's attachment preview is stored. + upsertPreview(note.attachment, access: .public, into: &previews) + // Upsert the note record itself without touching its existing quotes. + let record = noteIndex[note.id] ?? { + let new = NoteRecord(serverID: note.id) + context.insert(new) + noteIndex[note.id] = new + return new + }() + record.update(from: note, preview: note.attachment) + } + try context.save() + } + + /// Upserts standalone `QuoteResponse`s (e.g. from a quotes search result) into `QuoteRecord`s. + /// + /// Language is set to unknown (empty string) for search results since the response carries no + /// language field. It will be corrected on the next full catalogue/blog sync. + /// Existing quote records are updated in place; new server IDs create fresh records. + public func upsert(_ responses: [QuoteResponse]) throws { + var byServerID: [UUID: QuoteRecord] = [:] + for record in try context.fetch(FetchDescriptor()) { + byServerID[record.serverID] = record + } + for response in responses { + let record = byServerID[response.id] ?? { + let new = QuoteRecord() + context.insert(new) + byServerID[response.id] = new + return new + }() + record.update(from: response) + // Preserve existing language — `QuoteResponse` carries no language field. + // `languageRaw` already has the right value if the quote was previously synced. + } + try context.save() + } + + /// Searches the local SwiftData store for catalogue items and notes matching `term`. + /// + /// All filtering and text matching is done in the DB: + /// - Previews: `PreviewRecord.search(term:categories:languages:)` — a composed predicate that + /// applies the text match on `primaryInfo`/`secondaryInfo` plus category and language filters. + /// - Notes: `NoteRecord.search(term:languages:)` — body text + language filter. + /// - Note→preview join is resolved via a targeted predicate fetch of only the IDs needed. + /// + /// Returns an empty array when `term` is blank. + public func search( + term: String, + categories: Set, + languages: Set + ) throws -> [CatalogueSearchResult] { + let trimmed = term.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return [] } + + // 1. Fetch preview matches — text + category + language all applied in DB. + let previewMatches = try context.fetch(FetchDescriptor( + predicate: PreviewRecord.search(term: trimmed, categories: categories, languages: languages), + sortBy: [SortDescriptor(\.primaryInfo)] + )) + + // 2. Fetch note-body matches — body text + language filter applied in DB. + let noteMatches = try context.fetch(FetchDescriptor( + predicate: NoteRecord.search(term: trimmed, languages: languages) + )) + + // Seed results from preview matches. + var seen = Set(previewMatches.map(\.id)) + var results = previewMatches.map { CatalogueSearchResult(preview: $0) } + + // 3. Resolve note matches to their PreviewRecord via a targeted predicate fetch of needed IDs. + let neededPreviewIDs = Set(noteMatches.compactMap(\.attachmentPreviewID)).subtracting(seen) + guard !neededPreviewIDs.isEmpty else { return results } + + let ids = Array(neededPreviewIDs) + let additionalPreviews = try context.fetch(FetchDescriptor( + predicate: #Predicate { ids.contains($0.id) } + )) + let previewByID = Dictionary(uniqueKeysWithValues: additionalPreviews.map { ($0.id, $0) }) + + let slugs = Array(categories.map(\.rawValue)) + for note in noteMatches { + guard let previewID = note.attachmentPreviewID, + !seen.contains(previewID), + let preview = previewByID[previewID] else { continue } + // Category filter applied here (cross-model join — not expressible in DB predicate). + guard slugs.isEmpty || slugs.contains(preview.categoryType) else { continue } + seen.insert(previewID) + results.append(CatalogueSearchResult(preview: preview, noteSnippet: note.body)) + } + + return results + } + /// Full-replaces the catalogue category lookup table from the server's authoritative list. /// /// Upserts each incoming category by `slug` (the natural key); deletes any locally-stored diff --git a/app-persistence/Sources/AppPersistence/NoteRecord.swift b/app-persistence/Sources/AppPersistence/NoteRecord.swift index 16a3dcf6..10e32e55 100644 --- a/app-persistence/Sources/AppPersistence/NoteRecord.swift +++ b/app-persistence/Sources/AppPersistence/NoteRecord.swift @@ -86,4 +86,15 @@ public extension NoteRecord { get { SyncState(rawValue: syncStateRaw) ?? .synced } set { syncStateRaw = newValue.rawValue } } + + /// Predicate for note-body text search. Matches notes whose `body` contains `term` + /// (case-insensitive, locale-aware), filtered by the active language selection. + /// Notes with an unknown language (empty `languageRaw`) are always included. + static func search(term: String, languages: Set) -> Predicate { + let codes = Array(languages.map(\.rawValue)) + return #Predicate { note in + note.body.localizedStandardContains(term) && + (codes.isEmpty || note.languageRaw.isEmpty || codes.contains(note.languageRaw)) + } + } } diff --git a/app-persistence/Sources/AppPersistence/PostRecord.swift b/app-persistence/Sources/AppPersistence/PostRecord.swift index ab1f3456..225e5781 100644 --- a/app-persistence/Sources/AppPersistence/PostRecord.swift +++ b/app-persistence/Sources/AppPersistence/PostRecord.swift @@ -82,4 +82,14 @@ public extension PostRecord { codes.isEmpty || post.languageRaw.isEmpty || codes.contains(post.languageRaw) } } + + /// Predicate for in-tab text search. Matches posts whose title or content contains `term` + /// (case-insensitive, locale-aware), filtered by the active language selection. + static func search(term: String, languages: Set) -> Predicate { + let codes = languages.map(\.rawValue) + return #Predicate { post in + (post.title.localizedStandardContains(term) || post.content.localizedStandardContains(term)) && + (codes.isEmpty || post.languageRaw.isEmpty || codes.contains(post.languageRaw)) + } + } } diff --git a/app-persistence/Sources/AppPersistence/PreviewRecord.swift b/app-persistence/Sources/AppPersistence/PreviewRecord.swift index 16b4dfda..e34a9906 100644 --- a/app-persistence/Sources/AppPersistence/PreviewRecord.swift +++ b/app-persistence/Sources/AppPersistence/PreviewRecord.swift @@ -32,7 +32,11 @@ public final class PreviewRecord { public var primaryInfo: String = "" /// Display subtitle. Mirrors `PreviewResponse.secondaryInfo`. - public var secondaryInfo: String? + /// Stored as a non-optional `String` (empty = no subtitle) so SwiftData can generate SQL + /// `CONTAINS[cd]` predicates for catalogue text search — the DB cannot handle TERNARY/optional- + /// chain on the LHS of a CONTAINS expression. The DTO (`PreviewResponse.secondaryInfo: String?`) + /// is coalesced to `""` at the sync boundary in `CatalogueRecord+Sync.swift`. + public var secondaryInfo: String = "" public var imageURL: String? @@ -57,7 +61,7 @@ public final class PreviewRecord { categoryType: String = "", sourceID: Int? = nil, primaryInfo: String = "", - secondaryInfo: String? = nil, + secondaryInfo: String = "", imageURL: String? = nil, externalLinks: [String] = [], access: Access = .private, @@ -115,4 +119,21 @@ public extension PreviewRecord { (wantsHu && item.languagesJoined.contains("hu"))) } } + + /// Predicate for catalogue text search. Matches items whose `primaryInfo` or `secondaryInfo` + /// contains `term` (case-insensitive, locale-aware), filtered by active category and language. + /// + /// Decomposed into two sub-predicates composed via `evaluate` to avoid the `#Predicate` macro's + /// compile-time type-checker limit (~8 boolean sub-expressions in a flat body causes + /// "unable to type-check this expression in reasonable time"). + static func search(term: String, categories: Set, languages: Set) -> Predicate { + let textMatch = #Predicate { item in + item.primaryInfo.localizedStandardContains(term) || + item.secondaryInfo.localizedStandardContains(term) + } + let filterMatch = catalogue(categories: categories, languages: languages) + return #Predicate { item in + textMatch.evaluate(item) && filterMatch.evaluate(item) + } + } } diff --git a/app-persistence/Sources/AppPersistence/QuoteRecord.swift b/app-persistence/Sources/AppPersistence/QuoteRecord.swift index 9c1c6ba6..295659fc 100644 --- a/app-persistence/Sources/AppPersistence/QuoteRecord.swift +++ b/app-persistence/Sources/AppPersistence/QuoteRecord.swift @@ -106,6 +106,23 @@ public extension QuoteRecord { (codes.isEmpty || quote.languageRaw.isEmpty || codes.contains(quote.languageRaw)) } } + + /// Predicate for in-tab text search. Matches quotes whose body contains `term` + /// (case-insensitive, locale-aware), filtered by active source and language selections. + static func search(term: String, sources: Set, languages: Set) -> Predicate { + let sourcesArray = Array(sources.map { filter -> String in + switch filter { + case .category(let category): return category.rawValue + case .blogPosts: return blogPostsSource + } + }) + let codes = languages.map(\.rawValue) + return #Predicate { quote in + quote.body.localizedStandardContains(term) && + (sourcesArray.isEmpty || sourcesArray.contains(quote.sourceType)) && + (codes.isEmpty || quote.languageRaw.isEmpty || codes.contains(quote.languageRaw)) + } + } } // MARK: - SyncState diff --git a/tmbr-app/Reader/ReaderApp.swift b/tmbr-app/Reader/ReaderApp.swift index d8d884f1..73864733 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,11 +24,12 @@ 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" + let postSearchLoader = PostSearchLoader.searchPosts(baseURL: Self.apiBaseURL) blog = BlogModel( refresh: { try await posts.refreshPosts() @@ -36,12 +38,29 @@ struct ReaderApp: App { return now }, loadMore: { try await posts.loadMore() }, + search: { term in + let results = try await postSearchLoader.load(from: PostSearchQuery(term: term)) + try await postStore.upsert(results.items) + }, 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) + catalogue = CatalogueModel( + refresh: { try await catalogueSync.run() }, + search: { term in + let response = try await catalogueSearchLoader.load(from: CatalogueSearchQuery(term: term)) + try await catalogueStore.upsertSearch(response) + } + ) + + let quoteSearchLoader = QuoteSearchLoader.searchQuotes(baseURL: Self.apiBaseURL) + quotes = QuotesModel(search: { term in + let results = try await quoteSearchLoader.load(from: QuoteSearchQuery(term: term)) + try await catalogueStore.upsert(results) + }) } var body: some Scene { diff --git a/tmbr-core/Sources/TmbrCore/Search/PostSearchQuery.swift b/tmbr-core/Sources/TmbrCore/Search/PostSearchQuery.swift new file mode 100644 index 00000000..46024d96 --- /dev/null +++ b/tmbr-core/Sources/TmbrCore/Search/PostSearchQuery.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Query parameters for the `GET /api/posts?term=` search path. +public struct PostSearchQuery: Codable, Sendable { + + public let term: String + + public init(term: String) { + self.term = term + } +}