Skip to content
Open
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
12 changes: 7 additions & 5 deletions Sources/ComposerApp/Services/FinderService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,11 @@ struct FinderService {
.map(\.result)
}

func render(_ reference: FinderReference) async -> String {
/// `heading` labels the rendered block — "Finder" for the `@finder` chip, "iCloud Drive" when the
/// `@icloud` connector reuses this same file/folder rendering.
func render(_ reference: FinderReference, heading: String = "Finder") async -> String {
await Task.detached(priority: .userInitiated) {
Self.renderSync(reference)
Self.renderSync(reference, heading: heading)
}.value
}

Expand Down Expand Up @@ -253,16 +255,16 @@ struct FinderService {
private static let maxFolderEntries = 90
private static let maxFolderDepth = 3

private static func renderSync(_ reference: FinderReference) -> String {
private static func renderSync(_ reference: FinderReference, heading: String = "Finder") -> String {
let url = URL(fileURLWithPath: reference.path).standardizedFileURL
let path = url.path
var isDirectory = ObjCBool(false)
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else {
return "## Finder — \(displayName(url))\nPath: \(path)\nStatus: Not found on this Mac."
return "## \(heading) — \(displayName(url))\nPath: \(path)\nStatus: Not found on this Mac."
}

var lines = [
"## Finder — \(displayName(url))",
"## \(heading) — \(displayName(url))",
"Path: \(path)",
"File URL: \(url.absoluteString)",
"Kind: \(isDirectory.boolValue ? "Folder" : "File")",
Expand Down
119 changes: 119 additions & 0 deletions Sources/ComposerApp/Services/ICloudService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import Foundation

/// iCloud Drive connector (`@icloud`). Searches the user's iCloud Drive by filename via Spotlight
/// (`mdfind`, scoped to the CloudDocs container) and reuses `FinderService` to render the chosen file
/// or folder at copy time. `@finder` deliberately excludes `~/Library`, where iCloud Drive's local
/// mirror lives, so this is a distinct connector rather than a Finder search root. The app isn't
/// sandboxed, so the container is read as an ordinary path — no entitlement required.
struct ICloudService {
private let maxRows = 10

/// iCloud Drive's local mirror, or nil when the user hasn't turned iCloud Drive on.
static var container: URL? {
let url = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs", isDirectory: true)
return FileManager.default.fileExists(atPath: url.path) ? url : nil
}

func search(_ query: String) async throws -> [AppSearchResult] {
let trimmed = query.trimmed
guard trimmed.count >= 2 else { return [] }
guard let root = Self.container else {
throw AppSearchError.message(
"iCloud Drive isn’t set up on this Mac — turn it on in System Settings → [your name] → iCloud → iCloud Drive.")
}
let needle = spotlightNeedle(trimmed)
guard !needle.isEmpty else { return [] }

// Spotlight indexes iCloud Drive (including files not yet downloaded), so a filename match works
// even for placeholders. `cd` = case- and diacritic-insensitive.
let result = try await Shell.run([
"mdfind", "-onlyin", root.path, "kMDItemFSName == '*\(needle)*'cd",
])
guard result.status == 0 else {
throw AppSearchError.message(UserFacingError.commandFailure(command: "iCloud Drive search", result: result))
}
let paths = result.stdout.split(separator: "\n", omittingEmptySubsequences: true).map(String.init)

var seen = Set<String>()
let hits = paths.compactMap { raw -> (score: Int, result: AppSearchResult)? in
let path = URL(fileURLWithPath: raw).standardizedFileURL.path
guard !seen.contains(path), !Self.isNoise(path) else { return nil }
seen.insert(path)
return hit(for: path, query: trimmed, root: root)
}
return hits
.sorted { lhs, rhs in
lhs.score != rhs.score
? lhs.score > rhs.score
: lhs.result.title.localizedCaseInsensitiveCompare(rhs.result.title) == .orderedAscending
}
.prefix(maxRows)
.map(\.result)
}

func render(_ reference: FinderReference) async -> String {
await FinderService().render(reference, heading: "iCloud Drive")
}

// MARK: - Helpers

/// Dev/system cruft that syncs into iCloud Drive when a code folder (or Desktop & Documents) is
/// stored there — the same noise `@finder` excludes. Keeps results to real documents.
private static let excludedComponents: Set<String> = [
"node_modules", ".git", ".build", ".cache", ".Trash", "DerivedData", ".next", "Pods", ".venv",
]

private static func isNoise(_ path: String) -> Bool {
for component in path.split(separator: "/") {
if excludedComponents.contains(String(component)) { return true }
if component.hasSuffix(".photoslibrary") || component.hasSuffix(".movpkg") { return true }
}
return false
}

private func hit(for path: String, query: String, root: URL) -> (score: Int, result: AppSearchResult)? {
var isDirectory = ObjCBool(false)
guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory) else { return nil }
let url = URL(fileURLWithPath: path)
let name = url.lastPathComponent
let relative = relativePath(path, under: root.path)
let subtitle = [(isDirectory.boolValue ? "Folder" : "File"), relative]
.filter { !$0.isEmpty }.joined(separator: " · ")
return (
score: score(name: name, query: query),
result: AppSearchResult(
id: path,
title: name.isEmpty ? path : name,
subtitle: subtitle,
selection: .icloud(FinderReference(path: path, isDirectory: isDirectory.boolValue))))
}

/// mdfind already filtered to a filename match, so this just ranks: exact name > prefix > earlier
/// substring, shorter names first. Never nil — an unranked hit still shows (with a small base).
private func score(name: String, query: String) -> Int {
let haystack = name.lowercased()
let needle = query.lowercased().filter { !$0.isWhitespace }
guard !needle.isEmpty else { return 1_000 }
if haystack == needle { return 10_000 - haystack.count }
if haystack.hasPrefix(needle) { return 8_000 - haystack.count }
if let range = haystack.range(of: needle) {
return 6_000 - haystack.distance(from: haystack.startIndex, to: range.lowerBound) * 8 - haystack.count
}
return 1_000
}

/// `…/CloudDocs/Screens/shot.png` → `Screens/shot.png`; the container itself → its name.
private func relativePath(_ path: String, under root: String) -> String {
guard path.hasPrefix(root) else { return path }
let tail = String(path.dropFirst(root.count)).drop { $0 == "/" }
return tail.isEmpty ? "iCloud Drive" : String(tail)
}

/// Keep only characters safe to drop into an `mdfind` single-quoted literal, so a query can't break
/// the query expression. Ranking on the raw query still handles the full text.
private func spotlightNeedle(_ query: String) -> String {
query.filter { $0.isLetter || $0.isNumber || $0 == " " || $0 == "." || $0 == "_" || $0 == "-" }
.trimmingCharacters(in: .whitespaces)
}
}
235 changes: 235 additions & 0 deletions Sources/ComposerApp/Services/NotesService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
import Foundation

/// Apple Notes connector for the `@notes` chip. Notes.app ships no public framework, so this drives
/// it over Apple Events (JXA) through `osascript` — the same shell-out path the Browser and Finder
/// connectors use. Shelling out keeps it working under the release build's hardened runtime without
/// an apple-events entitlement (the Apple-Event sender is the Apple-signed `osascript` child, not the
/// app), and the first use trips the one-time Automation permission prompt for Notes.
///
/// Search matches note *titles* only — scanning every note's body would mean reading all their HTML,
/// which is slow on large accounts. Render re-reads the selected note's body at copy time and
/// flattens its HTML to plain text so the compiled prompt carries the note's content.
struct NotesService {
private static let maxRows = 12
private let textCap = 10_000

func search(_ query: String) async throws -> [AppSearchResult] {
let result = try await Shell.run(["osascript", "-l", "JavaScript", "-e", Self.searchScript(query: query)])
guard result.status == 0 else { throw notesError(result) }
let text = result.stdout.trimmed
guard !text.isEmpty else { return [] }
let rows = (try? JSONDecoder().decode([SearchRow].self, from: Data(text.utf8))) ?? []
return rows
.filter { !$0.id.isEmpty }
.sorted { ($0.modified ?? "") > ($1.modified ?? "") } // ISO-8601/UTC → lexicographic = chronological
.prefix(Self.maxRows)
.map { row in
AppSearchResult(
id: row.id,
title: row.name.isEmpty ? "Untitled note" : row.name,
subtitle: Self.subtitle(folder: row.folder, modified: row.modified),
selection: .notes(NotesReference(id: row.id, title: row.name)))
}
}

func render(_ reference: NotesReference) async throws -> String {
let result = try await Shell.run(["osascript", "-l", "JavaScript", "-e", Self.renderScript(id: reference.id)])
guard result.status == 0 else { throw notesError(result) }
let fallbackTitle = reference.title.isEmpty ? "note" : reference.title
let text = result.stdout.trimmed
guard !text.isEmpty, let note = try? JSONDecoder().decode(RenderRow.self, from: Data(text.utf8)) else {
return "## Apple Notes — \(fallbackTitle)\n_(could not read this note — it may have been deleted or moved.)_"
}
if note.error == "not-found" {
return "## Apple Notes — \(fallbackTitle)\n_(this note is no longer available — it may have been deleted or renamed.)_"
}

let title = (note.name?.isEmpty == false) ? note.name! : fallbackTitle
var lines = ["## Apple Notes — \(title)"]
if let folder = note.folder, !folder.isEmpty { lines.append("Folder: \(folder)") }
if let modified = note.modified, !modified.isEmpty { lines.append("Modified: \(String(modified.prefix(10)))") }
lines.append("")
let body = Self.htmlToPlainText(note.body ?? "").trimmed
lines.append(body.isEmpty ? "_(this note has no text.)_" : truncate(body))
return lines.joined(separator: "\n")
}

// MARK: - Decoding

private struct SearchRow: Decodable { let id: String; let name: String; let folder: String?; let modified: String? }
private struct RenderRow: Decodable { let name: String?; let body: String?; let folder: String?; let modified: String?; let error: String? }

private static func subtitle(folder: String?, modified: String?) -> String {
var bits = ["Apple Notes"]
if let folder, !folder.isEmpty { bits.append(folder) }
if let modified, !modified.isEmpty { bits.append(String(modified.prefix(10))) }
return bits.joined(separator: " · ")
}

private func truncate(_ text: String) -> String {
guard text.count > textCap else { return text }
return String(text.prefix(textCap)) + "\n\n…(truncated)"
}

private func notesError(_ result: Shell.Result) -> AppSearchError {
let text = result.diagnostic
if text.contains("-1743") || text.localizedCaseInsensitiveContains("not authorized") {
return .message("Allow BonsAI to control Notes in System Settings → Privacy & Security → Automation.")
}
if text.contains("-1728") { // AppleScript "can't get" — the note/object no longer exists
return .message("That note is no longer available in Apple Notes.")
}
if text.localizedCaseInsensitiveContains("execution error") {
return .message(String(text.prefix(160)))
}
return .message(UserFacingError.commandFailure(command: "Reading Apple Notes", result: result))
}

// MARK: - JXA

/// Bulk-reads every note's title/id/modification date in three Apple Events (far cheaper than
/// per-note round trips), filters by title substring, then sorts + caps — and only *then* looks up
/// each surviving note's folder name, so the folder round trips number in the handful, not the
/// thousands. The folder travels into the result subtitle so a note sitting in "Recently Deleted"
/// reads as such (the trash folder's name is localized, so there's no locale-proof way to exclude
/// it — showing it is the honest option). User text is embedded as a JSON string literal — see
/// `jsString` — so a note query can't break out of the script.
private static func searchScript(query: String) -> String {
"""
function run() {
const query = \(jsString(query));
const Notes = Application('Notes');
const q = query.toLowerCase();
let names = [], ids = [], mods = [];
try { names = Notes.notes.name(); } catch (e) { names = []; }
try { ids = Notes.notes.id(); } catch (e) { ids = []; }
try { mods = Notes.notes.modificationDate(); } catch (e) { mods = []; }
const hits = [];
for (let i = 0; i < names.length; i++) {
const name = names[i] || '';
if (q === '' || name.toLowerCase().indexOf(q) !== -1) {
let modified = '';
try { if (mods[i]) { modified = mods[i].toISOString(); } } catch (e) {}
hits.push({ i: i, id: String(ids[i] || ''), name: name, modified: modified });
}
}
hits.sort(function(a, b) { return a.modified < b.modified ? 1 : (a.modified > b.modified ? -1 : 0); });
const top = hits.slice(0, \(maxRows));
let containers = [];
try { containers = Notes.notes.container(); } catch (e) { containers = []; }
const out = top.map(function(h) {
let folder = '';
try { if (containers[h.i]) { folder = containers[h.i].name() || ''; } } catch (e) {}
return { id: h.id, name: h.name, folder: folder, modified: h.modified };
});
return JSON.stringify(out);
}
"""
}

/// Fetches one note's title/body/folder by id, falling back to a linear id scan if `byId` can't
/// resolve the specifier. The id is embedded as a JSON string literal (injection-safe).
private static func renderScript(id: String) -> String {
"""
function run() {
const noteId = \(jsString(id));
const Notes = Application('Notes');
let note = null;
try { note = Notes.notes.byId(noteId); note.name(); } catch (e) { note = null; }
if (note === null) {
try {
const ids = Notes.notes.id();
let idx = -1;
for (let i = 0; i < ids.length; i++) { if (String(ids[i]) === noteId) { idx = i; break; } }
if (idx !== -1) { note = Notes.notes[idx]; }
} catch (e) { note = null; }
}
if (note === null) { return JSON.stringify({ error: 'not-found' }); }
let name = '', body = '', folder = '', modified = '';
try { name = note.name() || ''; } catch (e) {}
try { body = note.body() || ''; } catch (e) {}
try { folder = note.container().name() || ''; } catch (e) {}
try { const d = note.modificationDate(); if (d) { modified = d.toISOString(); } } catch (e) {}
return JSON.stringify({ name: name, body: body, folder: folder, modified: modified });
}
"""
}

/// A JSON string literal is also a valid JS string literal, so encoding user text this way embeds
/// it into the JXA source with no quote/newline able to escape the string (JSONEncoder escapes
/// `"`, `\`, and control characters). Encoding a one-element array sidesteps top-level-fragment
/// support: `["…"]` → drop the brackets → `"…"`.
private static func jsString(_ value: String) -> String {
guard let data = try? JSONEncoder().encode([value]),
let json = String(data: data, encoding: .utf8), json.count >= 2 else { return "\"\"" }
return String(json.dropFirst().dropLast())
}

// MARK: - HTML → text

/// Flattens a Notes HTML body to readable plain text without WebKit (`NSAttributedString(html:)`
/// forces main-thread work and is far heavier than this needs). Line-breaking and list tags become
/// newlines/bullets, every other tag is dropped, and the common entities are decoded.
static func htmlToPlainText(_ html: String) -> String {
guard !html.isEmpty else { return "" }
var s = html
let newlineTags = ["<br>", "<br/>", "<br />", "</div>", "</p>", "</h1>", "</h2>", "</h3>",
"</h4>", "</h5>", "</h6>", "</ul>", "</ol>", "</tr>", "</blockquote>"]
for tag in newlineTags {
s = s.replacingOccurrences(of: tag, with: "\n", options: .caseInsensitive)
}
s = s.replacingOccurrences(of: "<li>", with: "\n- ", options: .caseInsensitive)
s = s.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
s = decodeHTMLEntities(s)

// Trim each line and collapse runs of blank lines to a single separator.
var out: [String] = []
var sawBlank = false
for rawLine in s.components(separatedBy: "\n") {
let line = rawLine.trimmingCharacters(in: .whitespaces)
if line.isEmpty {
if !sawBlank, !out.isEmpty { out.append("") }
sawBlank = true
} else {
out.append(line)
sawBlank = false
}
}
return out.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
}

private static func decodeHTMLEntities(_ text: String) -> String {
var s = text
let named: [(String, String)] = [
("&lt;", "<"), ("&gt;", ">"), ("&quot;", "\""),
("&#39;", "'"), ("&apos;", "'"), ("&nbsp;", " "), ("&hellip;", "…"),
("&mdash;", "—"), ("&ndash;", "–"),
("&rsquo;", "\u{2019}"), ("&lsquo;", "\u{2018}"),
("&ldquo;", "\u{201C}"), ("&rdquo;", "\u{201D}"),
]
for (entity, value) in named { s = s.replacingOccurrences(of: entity, with: value) }
s = decodeNumericEntities(s)
s = s.replacingOccurrences(of: "&amp;", with: "&") // last: so "&amp;lt;" decodes to "&lt;", not "<"
return s
}

private static func decodeNumericEntities(_ text: String) -> String {
guard text.contains("&#"), let regex = try? NSRegularExpression(pattern: "&#(x?)([0-9A-Fa-f]+);") else { return text }
let ns = text as NSString
var result = ""
var cursor = 0
for match in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
result += ns.substring(with: NSRange(location: cursor, length: match.range.location - cursor))
let isHex = ns.substring(with: match.range(at: 1)).lowercased() == "x"
let digits = ns.substring(with: match.range(at: 2))
if let code = UInt32(digits, radix: isHex ? 16 : 10), let scalar = Unicode.Scalar(code) {
result += String(scalar)
} else {
result += ns.substring(with: match.range)
}
cursor = match.range.location + match.range.length
}
result += ns.substring(from: cursor)
return result
}
}
Loading