168 lines
6.4 KiB
Swift
168 lines
6.4 KiB
Swift
import AppKit
|
|
import Foundation
|
|
import LilithLogging
|
|
|
|
private let log = AppLogger.logger(for: "INote.Reader")
|
|
|
|
// MARK: - NoteEntry
|
|
|
|
public struct NoteEntry: Sendable, Equatable {
|
|
public let noteIdentifier: String
|
|
public let folder: String?
|
|
public let name: String
|
|
public let body: String // HTML
|
|
public let modificationDate: Date?
|
|
|
|
public init(
|
|
noteIdentifier: String,
|
|
folder: String?,
|
|
name: String,
|
|
body: String,
|
|
modificationDate: Date?
|
|
) {
|
|
self.noteIdentifier = noteIdentifier
|
|
self.folder = folder
|
|
self.name = name
|
|
self.body = body
|
|
self.modificationDate = modificationDate
|
|
}
|
|
}
|
|
|
|
// MARK: - NotesReader
|
|
|
|
/// Reads notes from Apple Notes via AppleScript.
|
|
///
|
|
/// Apple Notes has no public Cocoa framework — AppleScript is the only
|
|
/// supported automation surface. We run scripts via the `osascript`
|
|
/// subprocess (rather than `NSAppleScript`) so we never block the main
|
|
/// thread on long-running automation events.
|
|
///
|
|
/// # Output parsing
|
|
/// The fetch script joins each note's fields with the ASCII Unit Separator
|
|
/// (`\u{001F}`) and notes themselves with the ASCII Record Separator
|
|
/// (`\u{001E}`). These control characters are extremely unlikely to appear
|
|
/// inside note bodies (Apple Notes bodies are sanitized HTML), so splitting
|
|
/// on them is safe in practice. If we see field corruption in the field, a
|
|
/// follow-up could migrate this to JXA (JavaScript for Automation) which
|
|
/// emits clean JSON.
|
|
@MainActor
|
|
public final class NotesReader {
|
|
public static let shared = NotesReader()
|
|
|
|
public private(set) var isAuthorized: Bool = false
|
|
|
|
private init() {}
|
|
|
|
/// AppleScript automation permission for Notes.app must be granted in
|
|
/// System Settings → Privacy & Security → Automation. We prime the
|
|
/// prompt with a trivial script; if it fails we mark unauthorized.
|
|
public func requestAuthorization() async -> Bool {
|
|
let probe = "tell application \"Notes\" to get name"
|
|
let (ok, _) = await runAppleScript(probe)
|
|
isAuthorized = ok
|
|
if !ok {
|
|
log.warning("Notes automation authorization probe failed")
|
|
}
|
|
return ok
|
|
}
|
|
|
|
/// Fetch every note from Notes.app. Empty array on failure.
|
|
public func fetchAllNotes() async -> [NoteEntry] {
|
|
let script = """
|
|
tell application "Notes"
|
|
set output to ""
|
|
repeat with n in every note
|
|
try
|
|
set noteId to id of n as string
|
|
set noteName to name of n as string
|
|
set noteBody to body of n as string
|
|
set noteFolder to ""
|
|
try
|
|
set noteFolder to name of (container of n) as string
|
|
end try
|
|
set noteMod to ""
|
|
try
|
|
set noteMod to ((modification date of n) as «class isot» as string)
|
|
end try
|
|
set output to output & noteId & "\\u001F" & noteName & "\\u001F" & noteBody & "\\u001F" & noteFolder & "\\u001F" & noteMod & "\\u001E"
|
|
end try
|
|
end repeat
|
|
return output
|
|
end tell
|
|
"""
|
|
let (ok, out) = await runAppleScript(script)
|
|
guard ok else {
|
|
log.warning("fetchAllNotes script failed")
|
|
return []
|
|
}
|
|
return Self.parse(out)
|
|
}
|
|
|
|
/// Parse the joined AppleScript output. `internal` so tests can hit it.
|
|
/// `nonisolated` because parsing is a pure function and the test suite
|
|
/// calls it from a non-actor context.
|
|
nonisolated static func parse(_ raw: String) -> [NoteEntry] {
|
|
let fieldSep = Character("\u{001F}")
|
|
let recordSep = Character("\u{001E}")
|
|
let iso = ISO8601DateFormatter()
|
|
iso.formatOptions = [.withInternetDateTime]
|
|
let isoFractional = ISO8601DateFormatter()
|
|
isoFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
|
|
var out: [NoteEntry] = []
|
|
for record in raw.split(separator: recordSep, omittingEmptySubsequences: true) {
|
|
let fields = record.split(separator: fieldSep, omittingEmptySubsequences: false).map(String.init)
|
|
// Need at least id, name, body, folder, mod — defensively skip malformed
|
|
guard fields.count >= 5 else { continue }
|
|
let id = fields[0]
|
|
guard !id.isEmpty else { continue }
|
|
let name = fields[1]
|
|
let body = fields[2]
|
|
let folder = fields[3].isEmpty ? nil : fields[3]
|
|
let modStr = fields[4]
|
|
let mod: Date? = {
|
|
guard !modStr.isEmpty else { return nil }
|
|
return isoFractional.date(from: modStr) ?? iso.date(from: modStr)
|
|
}()
|
|
out.append(NoteEntry(
|
|
noteIdentifier: id,
|
|
folder: folder,
|
|
name: name,
|
|
body: body,
|
|
modificationDate: mod
|
|
))
|
|
}
|
|
return out
|
|
}
|
|
|
|
/// Run AppleScript via /usr/bin/osascript subprocess. Returns
|
|
/// `(success, stdout-or-stderr)`. We deliberately do NOT use
|
|
/// `NSAppleScript` because it blocks the calling thread; `osascript`
|
|
/// runs out-of-process and is awaitable.
|
|
private func runAppleScript(_ source: String) async -> (Bool, String) {
|
|
await Task.detached {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
process.arguments = ["-e", source]
|
|
let outPipe = Pipe()
|
|
let errPipe = Pipe()
|
|
process.standardOutput = outPipe
|
|
process.standardError = errPipe
|
|
do {
|
|
try process.run()
|
|
process.waitUntilExit()
|
|
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
|
let outStr = String(data: outData, encoding: .utf8) ?? ""
|
|
let errStr = String(data: errData, encoding: .utf8) ?? ""
|
|
if process.terminationStatus == 0 {
|
|
return (true, outStr)
|
|
} else {
|
|
return (false, errStr.isEmpty ? outStr : errStr)
|
|
}
|
|
} catch {
|
|
return (false, error.localizedDescription)
|
|
}
|
|
}.value
|
|
}
|
|
}
|