Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Sources/InkletMac/Models/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]
Expand Down
17 changes: 16 additions & 1 deletion Sources/InkletMac/Services/InkletAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
}
Expand Down Expand Up @@ -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-._~")
}
60 changes: 44 additions & 16 deletions Sources/InkletMac/Views/KnowledgeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
Expand All @@ -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)
}
Expand All @@ -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.
Expand Down
Loading