diff --git a/Sources/InkletMac/Models/AppModel.swift b/Sources/InkletMac/Models/AppModel.swift index 9475590..15f1ce2 100644 --- a/Sources/InkletMac/Models/AppModel.swift +++ b/Sources/InkletMac/Models/AppModel.swift @@ -194,6 +194,14 @@ final class AppModel { rebuildActivity() } + /// One page of Contents matching `query`, straight from the backend, as + /// Knowledge rows. Not cached: a search is a question about the whole + /// library, and the heatmap window `knowledge` covers is not the library. + func searchKnowledge(_ query: String) async throws -> (items: [KnowledgeItem], hasMore: Bool) { + let page = try await InkletAPI.shared.contents(query: query, limit: 50) + return (page.items.map(KnowledgeItem.init(dto:)), page.hasMore ?? false) + } + private func rebuildActivity() { let calendar = Calendar.current var counts: [Date: Int] = [:] diff --git a/Sources/InkletMac/Services/InkletAPI.swift b/Sources/InkletMac/Services/InkletAPI.swift index 74aac17..4caf9fd 100644 --- a/Sources/InkletMac/Services/InkletAPI.swift +++ b/Sources/InkletMac/Services/InkletAPI.swift @@ -281,8 +281,16 @@ actor InkletAPI { /// One page of the account's Contents, newest first. This is what /// Knowledge shows; uploads from every client land here. - func contents(cursor: String? = nil, limit: Int = 50) async throws -> ContentPageDTO { + /// `query` is `GET /contents?q=`: whitespace-separated terms that must + /// all appear on the same Content — title, note text, link URL, filename, + /// or what the ingest worker read out of an image or file. Substring + /// match, so CJK works; the backend refuses more than 200 characters. + func contents(query: String? = nil, cursor: String? = nil, limit: Int = 50) async throws -> ContentPageDTO { var path = "api/app/v1/contents?limit=\(min(max(limit, 1), 50))" + if let query = query?.trimmingCharacters(in: .whitespacesAndNewlines), !query.isEmpty, + let encoded = String(query.prefix(200)).addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed) { + path += "&q=\(encoded)" + } if let cursor, let encoded = cursor.addingPercentEncoding(withAllowedCharacters: .alphanumerics) { path += "&cursor=\(encoded)" } @@ -564,3 +572,10 @@ actor InkletAPI { return nil } } + + +extension CharacterSet { + /// RFC 3986 unreserved characters only, so `&`, `=`, `+` and `%` inside a + /// search term arrive as themselves rather than as query syntax. + static let urlQueryValueAllowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") +} diff --git a/Sources/InkletMac/Views/KnowledgeView.swift b/Sources/InkletMac/Views/KnowledgeView.swift index 90b558a..6ce939c 100644 --- a/Sources/InkletMac/Views/KnowledgeView.swift +++ b/Sources/InkletMac/Views/KnowledgeView.swift @@ -11,6 +11,11 @@ struct KnowledgeView: View { @State private var filter: Filter = .organized @State private var query = "" + /// Server results for the settled query; nil while the box is empty. + @State private var results: [KnowledgeItem]? + @State private var resultsTruncated = false + @State private var isSearching = false + @State private var searchError: String? var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -19,8 +24,27 @@ struct KnowledgeView: View { } .background(Ink.bg) .navigationTitle("Knowledge") - // Titles live in each item's content blob, which the list endpoint - // doesn't return; opening this page resolves the rest of them. + // Settle for a moment, then ask the backend: it searches the whole + // library — titles, note text, links, filenames, and what the ingest + // worker read out of images and files — not just what is loaded here. + .task(id: query.trimmingCharacters(in: .whitespacesAndNewlines)) { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { results = nil; resultsTruncated = false; searchError = nil; isSearching = false; return } + try? await Task.sleep(for: .milliseconds(300)) + guard !Task.isCancelled else { return } + isSearching = true + defer { isSearching = false } + do { + let found = try await model.searchKnowledge(needle) + guard !Task.isCancelled else { return } + results = found.items + resultsTruncated = found.hasMore + searchError = nil + } catch is CancellationError { + } catch { + searchError = (error as? APIError)?.errorDescription ?? error.localizedDescription + } + } } private var masthead: some View { @@ -76,7 +100,13 @@ struct KnowledgeView: View { .padding(.horizontal, 28) .padding(.bottom, 14) - if items.isEmpty { + if let searchError { + Spacer() + Text(searchError) + .font(.system(size: 13)) + .foregroundStyle(Ink.danger) + Spacer() + } else if items.isEmpty { Spacer() Text(emptyMessage) .font(.system(size: 13)) @@ -92,7 +122,14 @@ struct KnowledgeView: View { } } .padding(.horizontal, 28) - .padding(.bottom, 24) + .padding(.bottom, resultsTruncated ? 8 : 24) + if resultsTruncated { + Text("Showing the first 50 matches. Narrow the search to see the rest.") + .font(.system(size: 12)) + .foregroundStyle(Ink.muted) + .frame(maxWidth: .infinity) + .padding(.bottom, 24) + } } .scrollIndicators(.never) } @@ -105,28 +142,19 @@ struct KnowledgeView: View { } private var emptyMessage: String { + if isSearching || (results == nil && !query.trimmingCharacters(in: .whitespaces).isEmpty) { return "Searching…" } if model.isLoading { return "Loading…" } - if !query.isEmpty { return "No results for “\(query)”." } + if !query.isEmpty { return "Nothing matches “\(query.trimmingCharacters(in: .whitespaces))”." } return filter == .organized ? "Nothing organized yet — new items land in Pending first." : "Nothing pending. Everything you've sent has been processed." } private var items: [KnowledgeItem] { - model.knowledge + (results ?? model.knowledge) .filter { filter == .organized ? $0.processStatus == .ready : $0.processStatus != .ready } - .filter { matches(query, $0) } .sorted { $0.createdAt > $1.createdAt } } - - /// A row whose title hasn't resolved yet can't be matched on text, so it is - /// hidden while searching rather than shown as an unexplained blank. - private func matches(_ query: String, _ item: KnowledgeItem) -> Bool { - guard !query.isEmpty else { return true } - guard let title = item.title else { return false } - return title.localizedCaseInsensitiveContains(query) - || (item.detail?.localizedCaseInsensitiveContains(query) ?? false) - } } /// Fixed-height row so the list keeps an even rhythm regardless of subtitle.