Capture !27
14 files changed, +1095 −17
Layout: unified · split
Sources/OrgApp/CaptureModel.swift added +119
| @@ -0,0 +1,119 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | import OrgCore | |
| 4 | import OrgDocument | |
| 5 | ||
| 6 | /// Capture templates from `capture.toml`, and capturing into their targets: | |
| 7 | /// | |
| 8 | /// ```toml | |
| 9 | /// [[template]] | |
| 10 | /// key = "t" | |
| 11 | /// name = "Personal todo" | |
| 12 | /// type = "entry" # entry, item, checkitem or plain | |
| 13 | /// file = "todo.org" # relative to the first folder, or ~/… or /… | |
| 14 | /// headline = "Inbox" # or olp = "Projects/Work"; neither for the end of the file | |
| 15 | /// template = "* TODO %?\n%i\n%a" | |
| 16 | /// prepend = false | |
| 17 | /// ``` | |
| 18 | @MainActor | |
| 19 | public enum CaptureTemplates { | |
| 20 | public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("capture.toml") } | |
| 21 | ||
| 22 | /// The templates in your Doom configuration. | |
| 23 | public static let defaults = [ | |
| 24 | CaptureTemplate(key: "t", name: "Personal todo", target: .headline(file: "todo.org", headline: "Inbox"), template: "* TODO %?\n%i\n%a"), | |
| 25 | CaptureTemplate(key: "n", name: "Personal notes", target: .headline(file: "notes.org", headline: "Inbox"), template: "* %u %?\n%i\n%a"), | |
| 26 | ] | |
| 27 | ||
| 28 | public static func load(userFile: URL = userFile) -> (templates: [CaptureTemplate], problems: [String]) { | |
| 29 | guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (defaults, []) } | |
| 30 | let tables: [TOML.Table] | |
| 31 | do { | |
| 32 | tables = try TOML.parse(text) | |
| 33 | } catch { | |
| 34 | return (defaults, ["capture.toml: \(error)"]) | |
| 35 | } | |
| 36 | var templates: [CaptureTemplate] = [] | |
| 37 | var problems: [String] = [] | |
| 38 | for table in tables where !(table.name.isEmpty && table.values.isEmpty) { | |
| 39 | guard table.name == "template", table.isArrayElement else { | |
| 40 | problems.append("capture.toml line \(table.line): expected [[template]]") | |
| 41 | continue | |
| 42 | } | |
| 43 | guard let key = table.values["key"]?.string, let file = table.values["file"]?.string, let body = table.values["template"]?.string else { | |
| 44 | problems.append("capture.toml line \(table.line): a template needs key, file and template") | |
| 45 | continue | |
| 46 | } | |
| 47 | guard let kind = CaptureTemplate.Kind(rawValue: table.values["type"]?.string ?? "entry") else { | |
| 48 | problems.append("capture.toml line \(table.line): unknown type") | |
| 49 | continue | |
| 50 | } | |
| 51 | let target: CaptureTemplate.Target | |
| 52 | if let headline = table.values["headline"]?.string { | |
| 53 | target = .headline(file: file, headline: headline) | |
| 54 | } else if let olp = table.values["olp"]?.string { | |
| 55 | target = .outline(file: file, path: olp.split(separator: "/").map(String.init)) | |
| 56 | } else { | |
| 57 | target = .file(file) | |
| 58 | } | |
| 59 | templates.append(CaptureTemplate( | |
| 60 | key: key, name: table.values["name"]?.string ?? key, kind: kind, target: target, template: body, | |
| 61 | prepend: table.values["prepend"]?.bool ?? false | |
| 62 | )) | |
| 63 | } | |
| 64 | return (templates.isEmpty ? defaults : templates, problems) | |
| 65 | } | |
| 66 | } | |
| 67 | ||
| 68 | extension DocumentSession { | |
| 69 | /// Places captured `text` with `template`: through the buffer when the target is open, | |
| 70 | /// otherwise in the file, created if missing, through the save path. | |
| 71 | public func capture(_ text: String, template: CaptureTemplate, root: String?) -> String? { | |
| 72 | let path = Self.resolve(template.target.file, root: root) | |
| 73 | if path != self.path, !FileManager.default.fileExists(atPath: path) { | |
| 74 | do { | |
| 75 | try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true) | |
| 76 | try Data().write(to: URL(fileURLWithPath: path)) | |
| 77 | } catch { | |
| 78 | return "Can't create \(path): \(error.localizedDescription)" | |
| 79 | } | |
| 80 | } | |
| 81 | switch perform(CapturePlacement(text: text, template: template), path: path, at: 0, expect: nil) { | |
| 82 | case .commit: return nil | |
| 83 | case .failed(let message): return message | |
| 84 | default: return "Can't capture there." | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | /// A template's file: absolute, `~/…`, or relative to `root`. | |
| 89 | public static func resolve(_ file: String, root: String?) -> String { | |
| 90 | if file.hasPrefix("/") { return file } | |
| 91 | if file.hasPrefix("~") { return (file as NSString).expandingTildeInPath } | |
| 92 | return ((root ?? FileManager.default.homeDirectoryForCurrentUser.path) as NSString).appendingPathComponent(file) | |
| 93 | } | |
| 94 | ||
| 95 | /// `%a`: a link to the heading at the caret, as `org-store-link` makes one. | |
| 96 | public func captureAnnotation() -> String { | |
| 97 | guard let path, self.document != nil else { return "" } | |
| 98 | let abbreviated = path.hasPrefix(NSHomeDirectory()) ? "~" + path.dropFirst(NSHomeDirectory().count) : Substring(path) | |
| 99 | guard let entry = outline.last(where: { $0.start <= caret }) else { return "[[file:\(abbreviated)]]" } | |
| 100 | return "[[file:\(abbreviated)::*\(entry.title)][\(entry.title)]]" | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | /// The edit `Capture.place` makes, as a command so the save path applies it. | |
| 105 | struct CapturePlacement: OrgCommand { | |
| 106 | let text: String | |
| 107 | let template: CaptureTemplate | |
| 108 | var id: String { "internal.capture" } | |
| 109 | var title: String { "Capture" } | |
| 110 | func applies(in context: EditContext) -> Bool { true } | |
| 111 | func run(in context: EditContext) -> CommandStep { | |
| 112 | switch Capture.place(text, template: template, in: context.text, settings: context.tree.settings) { | |
| 113 | case .failure(.message(let message)): | |
| 114 | return .failed(message) | |
| 115 | case .success(let new): | |
| 116 | return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, new))) | |
| 117 | } | |
| 118 | } | |
| 119 | } | |
Sources/OrgApp/DocumentSession.swift +2
| @@ -87,6 +87,8 @@ public final class DocumentSession { | ||
| 87 | 87 | } |
| 88 | 88 | |
| 89 | 89 | private var url: URL? { path.map { URL(fileURLWithPath: $0) } } |
| 90 | /// The caret in the open file. | |
| 91 | public var caret: Int { buffer?.selection.first?.lowerBound ?? 0 } | |
| 90 | 92 | private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } |
| 91 | 93 | |
| 92 | 94 | public func open(_ path: String) { |
Sources/OrgCore/Capture/Capture.swift added +550
| @@ -0,0 +1,550 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // Capture (org-capture.el, Org 9.8.7): filling a template and placing the result in its | |
| 4 | // target, as `org-capture` followed by `org-capture-finalize` leaves the target file. | |
| 5 | ||
| 6 | public struct CaptureTemplate: Sendable, Equatable, Identifiable { | |
| 7 | public enum Kind: String, Sendable, Equatable { case entry, item, checkitem, plain } | |
| 8 | ||
| 9 | public enum Target: Sendable, Equatable { | |
| 10 | case file(String) | |
| 11 | case headline(file: String, headline: String) | |
| 12 | case outline(file: String, path: [String]) | |
| 13 | ||
| 14 | public var file: String { | |
| 15 | switch self { | |
| 16 | case .file(let f), .headline(let f, _), .outline(let f, _): f | |
| 17 | } | |
| 18 | } | |
| 19 | } | |
| 20 | ||
| 21 | public let key: String | |
| 22 | public let name: String | |
| 23 | public let kind: Kind | |
| 24 | public let target: Target | |
| 25 | public let template: String | |
| 26 | public let prepend: Bool | |
| 27 | public var id: String { key } | |
| 28 | ||
| 29 | public init(key: String, name: String, kind: Kind = .entry, target: Target, template: String, prepend: Bool = false) { | |
| 30 | self.key = key | |
| 31 | self.name = name | |
| 32 | self.kind = kind | |
| 33 | self.target = target | |
| 34 | self.template = template | |
| 35 | self.prepend = prepend | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | /// What the escapes in a template stand for. | |
| 40 | public struct CaptureContext: Sendable { | |
| 41 | public var now: Date | |
| 42 | public var calendar: Calendar | |
| 43 | /// `%i`: the selected text. | |
| 44 | public var initial: String | |
| 45 | /// `%a`: a link to where capture started, `[[target][description]]`, or empty. | |
| 46 | public var annotation: String | |
| 47 | /// `%c`: the clipboard. | |
| 48 | public var clipboard: String | |
| 49 | /// `%f` and `%F`: the file capture started from. | |
| 50 | public var file: String | |
| 51 | /// `%n`. | |
| 52 | public var userName: String | |
| 53 | ||
| 54 | public init(now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "", file: String = "", userName: String = "") { | |
| 55 | self.now = now | |
| 56 | self.calendar = calendar | |
| 57 | self.initial = initial | |
| 58 | self.annotation = annotation | |
| 59 | self.clipboard = clipboard | |
| 60 | self.file = file | |
| 61 | self.userName = userName | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | public enum Capture { | |
| 66 | public enum Failure: Error, Equatable { | |
| 67 | case message(String) | |
| 68 | } | |
| 69 | ||
| 70 | /// A `%^{prompt|default|choice…}` the template asks before it can be filled. | |
| 71 | public struct Question: Sendable, Equatable { | |
| 72 | public let prompt: String | |
| 73 | public let defaultValue: String | |
| 74 | public let choices: [String] | |
| 75 | } | |
| 76 | ||
| 77 | /// The prompts in `template`, in order. | |
| 78 | public static func questions(_ template: String) -> [Question] { | |
| 79 | let regex = try! NSRegularExpression(pattern: "(?<!%)%\\^\\{([^}]*)\\}") | |
| 80 | let ns = template as NSString | |
| 81 | return regex.matches(in: template, range: NSRange(location: 0, length: ns.length)).map { m in | |
| 82 | let items = ns.substring(with: m.range(at: 1)).components(separatedBy: "|") | |
| 83 | return Question(prompt: items[0], defaultValue: items.count > 1 ? items[1] : "", choices: Array(items.dropFirst(2))) | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | /// `org-capture-fill-template`: the text and where `%?` puts the cursor. `answers` holds | |
| 88 | /// the replies to `questions(template)` in order. | |
| 89 | public static func fill(_ template: String, context: CaptureContext, answers: [String] = []) -> Result<(text: String, cursor: Int), Failure> { | |
| 90 | func stamp(_ active: Bool, _ withTime: Bool) -> String { | |
| 91 | let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute], from: context.now) | |
| 92 | let day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][(c.weekday ?? 1) - 1] | |
| 93 | let body = String(format: "%04d-%02d-%02d %@", c.year!, c.month!, c.day!, day) + (withTime ? String(format: " %02d:%02d", c.hour!, c.minute!) : "") | |
| 94 | return active ? "<\(body)>" : "[\(body)]" | |
| 95 | } | |
| 96 | let link = try! NSRegularExpression(pattern: "\\[\\[(.*?)\\](\\[.*?\\])?\\]") | |
| 97 | func linkForm(_ template: String) -> String { | |
| 98 | let ns = context.annotation as NSString | |
| 99 | guard let m = link.firstMatch(in: context.annotation, range: NSRange(location: 0, length: ns.length)) else { return context.annotation } | |
| 100 | return template.replacingOccurrences(of: "$1", with: ns.substring(with: m.range(at: 1))) | |
| 101 | } | |
| 102 | var text = "" | |
| 103 | var answerIndex = 0 | |
| 104 | var answered: [String] = [] | |
| 105 | var cursor: Int? | |
| 106 | let chars = Array(template) | |
| 107 | var i = 0 | |
| 108 | while i < chars.count { | |
| 109 | let c = chars[i] | |
| 110 | guard c == "%", i + 1 < chars.count else { | |
| 111 | text.append(c) | |
| 112 | i += 1 | |
| 113 | continue | |
| 114 | } | |
| 115 | let next = chars[i + 1] | |
| 116 | if next == "%" { | |
| 117 | text.append("%") | |
| 118 | i += 2 | |
| 119 | continue | |
| 120 | } | |
| 121 | var consumed = 2 | |
| 122 | switch next { | |
| 123 | case "?": cursor = text.utf16.count | |
| 124 | case "i": | |
| 125 | // Continuation lines keep the indentation of the line `%i` is on. | |
| 126 | let lead = String(text.split(separator: "\n", omittingEmptySubsequences: false).last ?? "").prefix { $0 == " " || $0 == "\t" } | |
| 127 | text += context.initial.replacingOccurrences(of: "\n", with: "\n" + lead) | |
| 128 | case "a": text += context.annotation == "[[]]" ? "" : context.annotation | |
| 129 | case "l": text += linkForm("[[$1]]") | |
| 130 | case "L": text += linkForm("$1") | |
| 131 | case "c": text += context.clipboard | |
| 132 | case "f": text += (context.file as NSString).lastPathComponent | |
| 133 | case "F": text += context.file | |
| 134 | case "n": text += context.userName | |
| 135 | case "t": text += stamp(true, false) | |
| 136 | case "T": text += stamp(true, true) | |
| 137 | case "u": text += stamp(false, false) | |
| 138 | case "U": text += stamp(false, true) | |
| 139 | case "<": | |
| 140 | guard let close = chars[(i + 2)...].firstIndex(of: ">") else { return .failure(.message("Unclosed %< in the template")) } | |
| 141 | text += strftime(String(chars[(i + 2)..<close]), context) | |
| 142 | consumed = close - i + 1 | |
| 143 | case "^": | |
| 144 | guard i + 2 < chars.count, chars[i + 2] == "{", let close = chars[(i + 3)...].firstIndex(of: "}") else { | |
| 145 | return .failure(.message("Template escapes like %^\(i + 2 < chars.count ? String(chars[i + 2]) : "") aren't supported yet.")) | |
| 146 | } | |
| 147 | let after = close + 1 < chars.count ? chars[close + 1] : " " | |
| 148 | if "CgGLptTuU".contains(after) { return .failure(.message("Template escapes like %^{…}\(after) aren't supported yet.")) } | |
| 149 | let items = String(chars[(i + 3)..<close]).components(separatedBy: "|") | |
| 150 | var answer = answerIndex < answers.count ? answers[answerIndex] : "" | |
| 151 | if answer.isEmpty, items.count > 1 { answer = items[1] } | |
| 152 | answerIndex += 1 | |
| 153 | answered.append(answer) | |
| 154 | text += answer | |
| 155 | consumed = close - i + 1 | |
| 156 | case "\\": | |
| 157 | var j = i + 2 | |
| 158 | var digits = "" | |
| 159 | while j < chars.count, chars[j].isNumber { | |
| 160 | digits.append(chars[j]) | |
| 161 | j += 1 | |
| 162 | } | |
| 163 | if let n = Int(digits), n >= 1, n <= answered.count { | |
| 164 | text += answered[n - 1] | |
| 165 | consumed = j - i | |
| 166 | } else { | |
| 167 | text.append(c) | |
| 168 | consumed = 1 | |
| 169 | } | |
| 170 | case "(", "[", "k", "K", "x", "A", ":": | |
| 171 | return .failure(.message("Template escapes like %\(next) aren't supported yet.")) | |
| 172 | default: | |
| 173 | text.append(c) | |
| 174 | consumed = 1 | |
| 175 | } | |
| 176 | i += consumed | |
| 177 | } | |
| 178 | // No blank lines before the text; it ends with one newline, or is empty. | |
| 179 | var marked = text | |
| 180 | let marker = "\u{0}CURSOR\u{0}" | |
| 181 | if let cursor { | |
| 182 | let index = String.Index(utf16Offset: cursor, in: marked) | |
| 183 | marked.insert(contentsOf: marker, at: index) | |
| 184 | } | |
| 185 | var lines = marked.components(separatedBy: "\n") | |
| 186 | while let first = lines.first, first.trimmingCharacters(in: .whitespaces).isEmpty, lines.count > 1 { lines.removeFirst() } | |
| 187 | marked = lines.joined(separator: "\n") | |
| 188 | let trimmed = marked.replacingOccurrences(of: "[ \\t\\n]+$", with: "", options: .regularExpression) | |
| 189 | marked = trimmed.isEmpty ? "" : trimmed + "\n" | |
| 190 | marked = marked.replacingOccurrences(of: "\t", with: " ") | |
| 191 | guard let range = marked.range(of: marker) else { return .success((marked, marked.utf16.count > 0 ? marked.utf16.count - 1 : 0)) } | |
| 192 | let offset = marked[..<range.lowerBound].utf16.count | |
| 193 | marked.removeSubrange(range) | |
| 194 | return .success((marked, offset)) | |
| 195 | } | |
| 196 | ||
| 197 | static func strftime(_ format: String, _ context: CaptureContext) -> String { | |
| 198 | let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute, .second], from: context.now) | |
| 199 | let days = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] | |
| 200 | let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] | |
| 201 | var out = "" | |
| 202 | var chars = format.makeIterator() | |
| 203 | while let ch = chars.next() { | |
| 204 | guard ch == "%", let f = chars.next() else { | |
| 205 | out.append(ch) | |
| 206 | continue | |
| 207 | } | |
| 208 | switch f { | |
| 209 | case "Y": out += String(format: "%04d", c.year!) | |
| 210 | case "m": out += String(format: "%02d", c.month!) | |
| 211 | case "d": out += String(format: "%02d", c.day!) | |
| 212 | case "e": out += String(format: "%2d", c.day!) | |
| 213 | case "H": out += String(format: "%02d", c.hour!) | |
| 214 | case "M": out += String(format: "%02d", c.minute!) | |
| 215 | case "S": out += String(format: "%02d", c.second!) | |
| 216 | case "a": out += String(days[c.weekday! - 1].prefix(3)) | |
| 217 | case "A": out += days[c.weekday! - 1] | |
| 218 | case "b": out += String(months[c.month! - 1].prefix(3)) | |
| 219 | case "B": out += months[c.month! - 1] | |
| 220 | case "F": out += String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) | |
| 221 | case "R": out += String(format: "%02d:%02d", c.hour!, c.minute!) | |
| 222 | case "%": out += "%" | |
| 223 | default: out += "%" + String(f) | |
| 224 | } | |
| 225 | } | |
| 226 | return out | |
| 227 | } | |
| 228 | ||
| 229 | // MARK: - Placing | |
| 230 | ||
| 231 | /// The target file's text after capturing `text`, as `org-capture-finalize` leaves it. | |
| 232 | public static func place(_ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default) -> Result<String, Failure> { | |
| 233 | let buffer = EmacsBuffer(fileText, point: 0, settings: settings) | |
| 234 | do { | |
| 235 | let entryHeading = try locate(template.target, buffer) | |
| 236 | switch template.kind { | |
| 237 | case .entry: try placeEntry(text, prepend: template.prepend, heading: entryHeading, buffer) | |
| 238 | case .plain: placePlain(text, prepend: template.prepend, heading: entryHeading, buffer) | |
| 239 | case .item, .checkitem: try placeItem(text, checkbox: template.kind == .checkitem, prepend: template.prepend, heading: entryHeading, buffer) | |
| 240 | } | |
| 241 | } catch let error as Failure { | |
| 242 | return .failure(error) | |
| 243 | } catch let error as EmacsBuffer.UserError { | |
| 244 | return .failure(.message(error.message)) | |
| 245 | } catch { | |
| 246 | return .failure(.message("\(error)")) | |
| 247 | } | |
| 248 | // Saving the target adds a final newline, as `require-final-newline` does in Org. | |
| 249 | var result = buffer.text as String | |
| 250 | if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" } | |
| 251 | return .success(result) | |
| 252 | } | |
| 253 | ||
| 254 | /// `org-capture-set-target-location`: point at the target heading, or nil for a file. | |
| 255 | static func locate(_ target: CaptureTemplate.Target, _ buffer: EmacsBuffer) throws -> Int? { | |
| 256 | switch target { | |
| 257 | case .file: | |
| 258 | return nil | |
| 259 | case .headline(_, let headline): | |
| 260 | buffer.point = 0 | |
| 261 | let pattern = "^\\*+[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: headline) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$" | |
| 262 | if let match = buffer.searchForward(pattern, caseInsensitive: false) { | |
| 263 | return buffer.lineStart(match[0]!.lowerBound) | |
| 264 | } | |
| 265 | buffer.point = buffer.length | |
| 266 | if !buffer.bolp { buffer.insert("\n") } | |
| 267 | let start = buffer.point | |
| 268 | buffer.insert("* " + headline + "\n") | |
| 269 | return start | |
| 270 | case .outline(_, let path): | |
| 271 | var start = 0 | |
| 272 | var end = buffer.length | |
| 273 | var level = 0 | |
| 274 | var found: Int? | |
| 275 | for title in path { | |
| 276 | buffer.point = start | |
| 277 | let pattern = "^(\\*+)[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: title) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$" | |
| 278 | var hit: Int? | |
| 279 | while let match = buffer.searchForward(pattern, bound: end, caseInsensitive: false) { | |
| 280 | let stars = buffer.substring(match[1]!.lowerBound, match[1]!.upperBound).count | |
| 281 | if stars > level, level == 0 || stars == level + 1 || found != nil { | |
| 282 | hit = buffer.lineStart(match[0]!.lowerBound) | |
| 283 | level = stars | |
| 284 | break | |
| 285 | } | |
| 286 | } | |
| 287 | guard let hit else { throw Failure.message("Heading not found on outline path \(path.joined(separator: "/"))") } | |
| 288 | found = hit | |
| 289 | buffer.point = hit | |
| 290 | start = hit | |
| 291 | end = buffer.saveExcursion { () -> Int in | |
| 292 | endOfSubtree(buffer, heading: hit) | |
| 293 | return buffer.point | |
| 294 | } | |
| 295 | } | |
| 296 | return found | |
| 297 | } | |
| 298 | } | |
| 299 | ||
| 300 | static func headingLevel(_ buffer: EmacsBuffer, at line: Int) -> Int { | |
| 301 | buffer.substring(line, buffer.lineEnd(line)).prefix { $0 == "*" }.count | |
| 302 | } | |
| 303 | ||
| 304 | static func atHeading(_ buffer: EmacsBuffer) -> Bool { | |
| 305 | buffer.lookingAtLine("^\\*+ ") | |
| 306 | } | |
| 307 | ||
| 308 | /// `outline-next-heading`. | |
| 309 | static func nextHeading(_ buffer: EmacsBuffer) { | |
| 310 | if let match = buffer.searchForward("\\n\\*+ ", caseInsensitive: false) { | |
| 311 | buffer.point = match[0]!.lowerBound + 1 | |
| 312 | } else { | |
| 313 | buffer.point = buffer.length | |
| 314 | } | |
| 315 | } | |
| 316 | ||
| 317 | /// `org-end-of-subtree t t`: the start of the next heading at the same level or higher. | |
| 318 | static func endOfSubtree(_ buffer: EmacsBuffer, heading: Int) { | |
| 319 | let level = headingLevel(buffer, at: heading) | |
| 320 | buffer.point = heading | |
| 321 | while true { | |
| 322 | guard let match = buffer.searchForward("\\n(\\*+) ", caseInsensitive: false) else { | |
| 323 | buffer.point = buffer.length | |
| 324 | return | |
| 325 | } | |
| 326 | let stars = match[1]!.count | |
| 327 | if stars <= level { | |
| 328 | buffer.point = match[0]!.lowerBound + 1 | |
| 329 | return | |
| 330 | } | |
| 331 | buffer.point = match[0]!.upperBound | |
| 332 | } | |
| 333 | } | |
| 334 | ||
| 335 | static func previousLineEmpty(_ buffer: EmacsBuffer) -> Bool { | |
| 336 | let start = buffer.lineStart(buffer.point) | |
| 337 | guard start > 0 else { return false } | |
| 338 | let previous = buffer.lineStart(start - 1) | |
| 339 | return buffer.substring(previous, start - 1).trimmingCharacters(in: .whitespaces).isEmpty | |
| 340 | } | |
| 341 | ||
| 342 | /// `org--blank-before-heading-p` with `org-blank-before-new-entry` `(heading . auto)`. | |
| 343 | static func blankBeforeHeading(_ buffer: EmacsBuffer) -> Bool { | |
| 344 | buffer.saveExcursion { | |
| 345 | let before = buffer.searchBackward("^\\*+ ", caseInsensitive: false) == nil && !atHeading(buffer) | |
| 346 | if before { | |
| 347 | nextHeading(buffer) | |
| 348 | if buffer.point >= buffer.length, !atHeading(buffer) { return false } | |
| 349 | } | |
| 350 | // `org-back-to-heading`. | |
| 351 | if !atHeading(buffer) { | |
| 352 | buffer.point = buffer.lineStart(buffer.point) | |
| 353 | if !atHeading(buffer), let match = buffer.searchBackward("^\\*+ ", caseInsensitive: false) { | |
| 354 | buffer.point = match[0]!.lowerBound | |
| 355 | } | |
| 356 | } else { | |
| 357 | buffer.point = buffer.lineStart(buffer.point) | |
| 358 | } | |
| 359 | if buffer.point > 0 { return previousLineEmpty(buffer) } | |
| 360 | let here = buffer.point | |
| 361 | nextHeading(buffer) | |
| 362 | if buffer.point < buffer.length || atHeading(buffer) { return previousLineEmpty(buffer) } | |
| 363 | buffer.point = here | |
| 364 | return false | |
| 365 | } | |
| 366 | } | |
| 367 | ||
| 368 | /// `org-back-over-empty-lines`, deleting them. | |
| 369 | static func deleteEmptyLinesBefore(_ buffer: EmacsBuffer) { | |
| 370 | let pos = buffer.point | |
| 371 | var start = pos | |
| 372 | while start > 0 { | |
| 373 | let lineStart = buffer.lineStart(start - 1) | |
| 374 | guard buffer.substring(lineStart, start - 1).trimmingCharacters(in: .whitespaces).isEmpty, start - 1 >= lineStart else { break } | |
| 375 | if lineStart == start - 1 || buffer.substring(lineStart, start).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | |
| 376 | // A blank line precedes; but keep the newline that ends the previous text line. | |
| 377 | if lineStart == 0 { start = 0; break } | |
| 378 | start = lineStart | |
| 379 | } else { | |
| 380 | break | |
| 381 | } | |
| 382 | } | |
| 383 | if start < pos { buffer.deleteRegion(start, pos) } | |
| 384 | buffer.point = start | |
| 385 | } | |
| 386 | ||
| 387 | /// `org-capture-empty-lines-after` with no lines: blank lines after point go. | |
| 388 | static func deleteEmptyLinesAfter(_ buffer: EmacsBuffer) { | |
| 389 | deleteEmptyLinesBefore(buffer) | |
| 390 | while buffer.looking("[ \\t]*\\n", caseInsensitive: false, setsMatch: false) != nil { | |
| 391 | let end = buffer.lineEnd(buffer.point) + 1 | |
| 392 | buffer.deleteRegion(buffer.point, end) | |
| 393 | } | |
| 394 | } | |
| 395 | ||
| 396 | /// `org-capture-place-entry`. | |
| 397 | static func placeEntry(_ entry: String, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) throws { | |
| 398 | var template = entry | |
| 399 | if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template } | |
| 400 | // `org-capture-verify-tree`: the first heading has the lowest level. | |
| 401 | let levels = template.components(separatedBy: "\n").compactMap { line -> Int? in | |
| 402 | let stars = line.prefix { $0 == "*" }.count | |
| 403 | return stars > 0 && line.dropFirst(stars).first == " " ? stars : nil | |
| 404 | } | |
| 405 | if let first = levels.first, levels.contains(where: { $0 < first }) || !template.hasPrefix("*") { | |
| 406 | throw Failure.message("Template is not a valid Org entry or tree") | |
| 407 | } | |
| 408 | var level = 1 | |
| 409 | if let heading { | |
| 410 | level = headingLevel(buffer, at: heading) + 1 | |
| 411 | buffer.point = heading | |
| 412 | if prepend { | |
| 413 | buffer.point = buffer.lineEnd(heading) | |
| 414 | nextHeading(buffer) | |
| 415 | } else { | |
| 416 | endOfSubtree(buffer, heading: heading) | |
| 417 | } | |
| 418 | } else if prepend { | |
| 419 | buffer.point = 0 | |
| 420 | if !atHeading(buffer) { nextHeading(buffer) } | |
| 421 | } else { | |
| 422 | buffer.point = buffer.length | |
| 423 | } | |
| 424 | if !buffer.bolp { buffer.insert("\n") } | |
| 425 | let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer) | |
| 426 | deleteEmptyLinesBefore(buffer) | |
| 427 | if blank { buffer.insert("\n") } | |
| 428 | // `org-paste-subtree` at `level`. | |
| 429 | let oldLevel = levels.first ?? 1 | |
| 430 | let shift = level - oldLevel | |
| 431 | var shifted = template | |
| 432 | if shift != 0 { | |
| 433 | shifted = template.components(separatedBy: "\n").map { line in | |
| 434 | let stars = line.prefix { $0 == "*" }.count | |
| 435 | guard stars > 0, line.dropFirst(stars).first == " " else { return line } | |
| 436 | return String(repeating: "*", count: max(1, stars + shift)) + line.dropFirst(stars) | |
| 437 | }.joined(separator: "\n") | |
| 438 | } | |
| 439 | if !shifted.hasSuffix("\n") { shifted += "\n" } | |
| 440 | buffer.insert(shifted) | |
| 441 | deleteEmptyLinesAfter(buffer) | |
| 442 | } | |
| 443 | ||
| 444 | /// `org-capture-place-plain-text`. | |
| 445 | static func placePlain(_ text: String, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) { | |
| 446 | if let heading { | |
| 447 | buffer.point = heading | |
| 448 | if prepend { | |
| 449 | buffer.point = buffer.lineEnd(heading) | |
| 450 | if buffer.point < buffer.length { buffer.point += 1 } | |
| 451 | skipMetaData(buffer) | |
| 452 | } else { | |
| 453 | buffer.point = buffer.lineEnd(heading) | |
| 454 | nextHeading(buffer) | |
| 455 | } | |
| 456 | } else { | |
| 457 | buffer.point = prepend ? 0 : buffer.length | |
| 458 | } | |
| 459 | if !buffer.bolp { buffer.insert("\n") } | |
| 460 | deleteEmptyLinesBefore(buffer) | |
| 461 | buffer.insert(text.hasSuffix("\n") || text.isEmpty ? text : text + "\n") | |
| 462 | deleteEmptyLinesAfter(buffer) | |
| 463 | } | |
| 464 | ||
| 465 | /// `org-end-of-meta-data t`: past planning, drawers and the property drawer. | |
| 466 | static func skipMetaData(_ buffer: EmacsBuffer) { | |
| 467 | while buffer.point < buffer.length { | |
| 468 | let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point)) | |
| 469 | let trimmed = line.trimmingCharacters(in: .whitespaces) | |
| 470 | if trimmed.range(of: "^(SCHEDULED|DEADLINE|CLOSED):", options: .regularExpression) != nil { | |
| 471 | buffer.forwardLine(1) | |
| 472 | } else if trimmed.range(of: "^:[\\w-]+:$", options: .regularExpression) != nil { | |
| 473 | while buffer.forwardLine(1) == 0 { | |
| 474 | if buffer.substring(buffer.point, buffer.lineEnd(buffer.point)).trimmingCharacters(in: .whitespaces).uppercased() == ":END:" { | |
| 475 | buffer.forwardLine(1) | |
| 476 | break | |
| 477 | } | |
| 478 | } | |
| 479 | } else { | |
| 480 | break | |
| 481 | } | |
| 482 | } | |
| 483 | } | |
| 484 | ||
| 485 | /// `org-capture-place-item`: after the last item of the first list in the entry, or at the | |
| 486 | /// end of the entry's text. | |
| 487 | static func placeItem(_ text: String, checkbox: Bool, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) throws { | |
| 488 | var item = text.trimmingCharacters(in: .newlines) | |
| 489 | if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil { | |
| 490 | let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil | |
| 491 | item = "- " + (checkbox && !boxed ? "[ ] " : "") + item | |
| 492 | } else if checkbox, item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]+\\[[ X-]\\]", options: .regularExpression) == nil { | |
| 493 | item = item.replacingOccurrences(of: "^([ \\t]*([-+*]|[0-9]+[.)])[ \\t]+)", with: "$1[ ] ", options: .regularExpression) | |
| 494 | } | |
| 495 | let start: Int | |
| 496 | let end: Int | |
| 497 | if let heading { | |
| 498 | start = min(buffer.length, buffer.lineEnd(heading) + 1) | |
| 499 | buffer.point = buffer.lineEnd(heading) | |
| 500 | nextHeading(buffer) | |
| 501 | end = buffer.point | |
| 502 | } else { | |
| 503 | start = 0 | |
| 504 | buffer.point = 0 | |
| 505 | if !atHeading(buffer) { nextHeading(buffer) } | |
| 506 | end = buffer.point | |
| 507 | } | |
| 508 | // The first list's items in the range. | |
| 509 | buffer.point = start | |
| 510 | var items: [(start: Int, end: Int, indent: String)] = [] | |
| 511 | while buffer.point < end { | |
| 512 | let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point)) | |
| 513 | if let m = line.firstMatch(of: /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]/) { | |
| 514 | if let first = items.first, String(m.1) != first.indent { | |
| 515 | // A nested item belongs to the current one. | |
| 516 | } else { | |
| 517 | items.append((buffer.point, buffer.point, String(m.1))) | |
| 518 | } | |
| 519 | } else if !items.isEmpty, !line.trimmingCharacters(in: .whitespaces).isEmpty, | |
| 520 | line.prefix(while: { $0 == " " || $0 == "\t" }).count <= (items.first?.indent.count ?? 0) { | |
| 521 | break | |
| 522 | } | |
| 523 | if buffer.forwardLine(1) != 0 { break } | |
| 524 | if !items.isEmpty { items[items.count - 1].end = buffer.point } | |
| 525 | } | |
| 526 | if let first = items.first { | |
| 527 | let indent = first.indent | |
| 528 | let indented = item.components(separatedBy: "\n").enumerated().map { $0.offset == 0 ? indent + $0.element : indent + $0.element }.joined(separator: "\n") | |
| 529 | if prepend { | |
| 530 | buffer.point = first.start | |
| 531 | } else { | |
| 532 | buffer.point = items.last!.end | |
| 533 | // Before blank lines that end the list. | |
| 534 | while buffer.point > first.start { | |
| 535 | let previous = buffer.lineStart(buffer.point - 1) | |
| 536 | guard buffer.substring(previous, buffer.point - 1).trimmingCharacters(in: .whitespaces).isEmpty else { break } | |
| 537 | buffer.point = previous | |
| 538 | } | |
| 539 | } | |
| 540 | if !buffer.bolp { buffer.insert("\n") } | |
| 541 | buffer.insert(indented + "\n") | |
| 542 | } else { | |
| 543 | buffer.point = prepend ? start : end | |
| 544 | if !buffer.bolp { buffer.insert("\n") } | |
| 545 | deleteEmptyLinesBefore(buffer) | |
| 546 | buffer.insert(item + "\n") | |
| 547 | deleteEmptyLinesAfter(buffer) | |
| 548 | } | |
| 549 | } | |
| 550 | } | |
Sources/OrgCore/Compute/Babel.swift +1 −17
| @@ -530,23 +530,7 @@ public enum Babel { | ||
| 530 | 530 | if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) } |
| 531 | 531 | let buffer = EmacsBuffer(text, point: 0, settings: settings) |
| 532 | 532 | insertResult(value, job.finish, block: blockRange, buffer: buffer) |
| 533 | return .success(singleEdit(from: text, to: buffer.text as String)) | |
| 534 | } | |
| 535 | ||
| 536 | /// The one edit between two texts: everything between their common prefix and suffix. | |
| 537 | static func singleEdit(from old: String, to new: String) -> [TextEdit] { | |
| 538 | let a = Array(old.utf16) | |
| 539 | let b = Array(new.utf16) | |
| 540 | var prefix = 0 | |
| 541 | while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 } | |
| 542 | var suffix = 0 | |
| 543 | while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] { suffix += 1 } | |
| 544 | // Keep the edit on scalar boundaries. | |
| 545 | while prefix > 0, (prefix < a.count && UTF16.isTrailSurrogate(a[prefix])) || (prefix < b.count && UTF16.isTrailSurrogate(b[prefix])) { prefix -= 1 } | |
| 546 | while suffix > 0, suffix < a.count, UTF16.isLeadSurrogate(a[a.count - suffix - 1]) { suffix -= 1 } | |
| 547 | if prefix == a.count, prefix == b.count { return [] } | |
| 548 | let replacement = String(utf16CodeUnits: Array(b[prefix..<(b.count - suffix)]), count: b.count - suffix - prefix) | |
| 549 | return [TextEdit(range: prefix..<(a.count - suffix), replacement: replacement)] | |
| 533 | return .success(TextEdit.between(text, buffer.text as String)) | |
| 550 | 534 | } |
| 551 | 535 | |
| 552 | 536 | /// The language's result handling: `org-babel-result-cond` and the table import. |
Sources/OrgCore/Keymap/Presets.swift +3
| @@ -154,6 +154,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 154 | 154 | keys = "C-c a" |
| 155 | 155 | command = "app.agenda" |
| 156 | 156 | [[bind]] |
| 157 | keys = "C-c c" | |
| 158 | command = "app.capture" | |
| 159 | [[bind]] | |
| 157 | 160 | keys = "C-c C-e h h" |
| 158 | 161 | command = "app.export.html" |
| 159 | 162 | [[bind]] |
Sources/OrgCore/Parser/Incremental.swift +19
| @@ -392,3 +392,22 @@ extension Parser { | ||
| 392 | 392 | return green.kind == kind ? green : nil |
| 393 | 393 | } |
| 394 | 394 | } |
| 395 | ||
| 396 | extension TextEdit { | |
| 397 | /// The one edit between two texts: everything between their common prefix and suffix. | |
| 398 | public static func between(_ old: String, _ new: String) -> [TextEdit] { | |
| 399 | let a = Array(old.utf16) | |
| 400 | let b = Array(new.utf16) | |
| 401 | var prefix = 0 | |
| 402 | while prefix < a.count, prefix < b.count, a[prefix] == b[prefix] { prefix += 1 } | |
| 403 | var suffix = 0 | |
| 404 | while suffix < a.count - prefix, suffix < b.count - prefix, a[a.count - 1 - suffix] == b[b.count - 1 - suffix] { suffix += 1 } | |
| 405 | // Keep the edit on scalar boundaries. | |
| 406 | while prefix > 0, (prefix < a.count && UTF16.isTrailSurrogate(a[prefix])) || (prefix < b.count && UTF16.isTrailSurrogate(b[prefix])) { prefix -= 1 } | |
| 407 | while suffix > 0, suffix < a.count, UTF16.isLeadSurrogate(a[a.count - suffix - 1]) { suffix -= 1 } | |
| 408 | if prefix == a.count, prefix == b.count { return [] } | |
| 409 | let replacement = String(utf16CodeUnits: Array(b[prefix..<(b.count - suffix)]), count: b.count - suffix - prefix) | |
| 410 | return [TextEdit(range: prefix..<(a.count - suffix), replacement: replacement)] | |
| 411 | } | |
| 412 | ||
| 413 | } | |
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -43,6 +43,7 @@ public enum EditorCommands { | ||
| 43 | 43 | EditorCommand(id: "app.refile", title: "Refile…", action: .app), |
| 44 | 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), |
| 45 | 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), |
| 46 | EditorCommand(id: "app.capture", title: "Capture…", action: .app), | |
| 46 | 47 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), |
| 47 | 48 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), |
| 48 | 49 | EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), |
Sources/OrgEditorAppKit/OrgEditor.swift +7
| @@ -598,6 +598,13 @@ public final class OrgEditor: NSObject { | ||
| 598 | 598 | } |
| 599 | 599 | |
| 600 | 600 | extension OrgEditor: DocumentBuffer { |
| 601 | /// The selected text, for capture's `%i`. | |
| 602 | public var selectedText: String { | |
| 603 | let range = textView.selectedRange() | |
| 604 | guard range.length > 0 else { return "" } | |
| 605 | return (textView.string as NSString).substring(with: range) | |
| 606 | } | |
| 607 | ||
| 601 | 608 | public var selection: [Range<Int>] { |
| 602 | 609 | let selected = textView.selectedRange() |
| 603 | 610 | return [selected.location..<NSMaxRange(selected)] |
Sources/Orgstar/CaptureView.swift added +173
| @@ -0,0 +1,173 @@ | ||
| 1 | import AppKit | |
| 2 | import Carbon.HIToolbox | |
| 3 | import OrgApp | |
| 4 | import OrgCore | |
| 5 | import SwiftUI | |
| 6 | ||
| 7 | /// The capture window: pick a template by its key, answer its prompts, edit the entry, and | |
| 8 | /// ⌘Return files it where the template says, as `org-capture` does. | |
| 9 | struct CaptureView: View { | |
| 10 | let workspace: WorkspaceModel | |
| 11 | let session: DocumentSession | |
| 12 | let commands: AppCommands | |
| 13 | @State private var templates = CaptureTemplates.defaults | |
| 14 | @State private var problem: String? | |
| 15 | @State private var template: CaptureTemplate? | |
| 16 | @State private var questions: [Capture.Question] = [] | |
| 17 | @State private var answers: [String] = [] | |
| 18 | @State private var text = "" | |
| 19 | @State private var selection: TextSelection? | |
| 20 | @State private var context = CaptureContext() | |
| 21 | @State private var editing = false | |
| 22 | @FocusState private var focused: Bool | |
| 23 | @Environment(\.dismissWindow) private var dismissWindow | |
| 24 | ||
| 25 | var body: some View { | |
| 26 | VStack(alignment: .leading, spacing: 12) { | |
| 27 | if let template, editing { | |
| 28 | Text("\(template.name) → \(target(template))").font(.headline) | |
| 29 | TextEditor(text: $text, selection: $selection) | |
| 30 | .font(.body.monospaced()) | |
| 31 | .focused($focused) | |
| 32 | .frame(minHeight: 140) | |
| 33 | HStack { | |
| 34 | Spacer() | |
| 35 | Button("Cancel", role: .cancel) { close() } | |
| 36 | .keyboardShortcut(.cancelAction) | |
| 37 | Button("File It") { finish() } | |
| 38 | .keyboardShortcut(.return, modifiers: .command) | |
| 39 | } | |
| 40 | } else if let template, !questions.isEmpty { | |
| 41 | Text(template.name).font(.headline) | |
| 42 | Form { | |
| 43 | ForEach(questions.indices, id: \.self) { index in | |
| 44 | TextField(questions[index].prompt, text: $answers[index], prompt: Text(questions[index].defaultValue)) | |
| 45 | } | |
| 46 | } | |
| 47 | HStack { | |
| 48 | Spacer() | |
| 49 | Button("Cancel", role: .cancel) { close() } | |
| 50 | .keyboardShortcut(.cancelAction) | |
| 51 | Button("Continue") { expand() } | |
| 52 | .keyboardShortcut(.defaultAction) | |
| 53 | } | |
| 54 | } else { | |
| 55 | Text("Capture").font(.headline) | |
| 56 | ForEach(templates) { template in | |
| 57 | Button { | |
| 58 | choose(template) | |
| 59 | } label: { | |
| 60 | HStack { | |
| 61 | Text("[\(template.key)]").monospaced().foregroundStyle(.secondary) | |
| 62 | Text(template.name) | |
| 63 | Spacer() | |
| 64 | Text(target(template)).foregroundStyle(.secondary).font(.caption) | |
| 65 | } | |
| 66 | } | |
| 67 | .buttonStyle(.plain) | |
| 68 | } | |
| 69 | if let problem { Text(problem).font(.caption).foregroundStyle(.red) } | |
| 70 | } | |
| 71 | } | |
| 72 | .padding(16) | |
| 73 | .frame(width: 520) | |
| 74 | .onKeyPress(phases: .down) { press in | |
| 75 | guard template == nil, let match = templates.first(where: { $0.key == press.characters }) else { return .ignored } | |
| 76 | choose(match) | |
| 77 | return .handled | |
| 78 | } | |
| 79 | .onAppear(perform: reset) | |
| 80 | } | |
| 81 | ||
| 82 | private func target(_ template: CaptureTemplate) -> String { | |
| 83 | let file = (template.target.file as NSString).lastPathComponent | |
| 84 | switch template.target { | |
| 85 | case .file: return file | |
| 86 | case .headline(_, let headline): return "\(file) / \(headline)" | |
| 87 | case .outline(_, let path): return ([file] + path).joined(separator: " / ") | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | private func reset() { | |
| 92 | let loaded = CaptureTemplates.load() | |
| 93 | templates = loaded.templates | |
| 94 | problem = loaded.problems.first | |
| 95 | template = nil | |
| 96 | editing = false | |
| 97 | context = CaptureContext( | |
| 98 | initial: commands.editor?.selectedText ?? "", | |
| 99 | annotation: session.captureAnnotation(), | |
| 100 | clipboard: NSPasteboard.general.string(forType: .string) ?? "", | |
| 101 | file: session.path ?? "", | |
| 102 | userName: NSFullUserName() | |
| 103 | ) | |
| 104 | } | |
| 105 | ||
| 106 | private func choose(_ chosen: CaptureTemplate) { | |
| 107 | template = chosen | |
| 108 | questions = Capture.questions(chosen.template) | |
| 109 | answers = questions.map { _ in "" } | |
| 110 | if questions.isEmpty { expand() } | |
| 111 | } | |
| 112 | ||
| 113 | private func expand() { | |
| 114 | guard let template else { return } | |
| 115 | context.now = Date() | |
| 116 | switch Capture.fill(template.template, context: context, answers: answers) { | |
| 117 | case .failure(.message(let message)): | |
| 118 | problem = message | |
| 119 | self.template = nil | |
| 120 | case .success(let filled): | |
| 121 | text = filled.text | |
| 122 | let index = String.Index(utf16Offset: filled.cursor, in: text) | |
| 123 | selection = TextSelection(insertionPoint: index) | |
| 124 | editing = true | |
| 125 | focused = true | |
| 126 | } | |
| 127 | } | |
| 128 | ||
| 129 | private func finish() { | |
| 130 | guard let template else { return } | |
| 131 | if let message = session.capture(text, template: template, root: workspace.rootPaths.first) { | |
| 132 | problem = message | |
| 133 | return | |
| 134 | } | |
| 135 | commands.show("Captured to \(target(template))") | |
| 136 | close() | |
| 137 | } | |
| 138 | ||
| 139 | private func close() { | |
| 140 | reset() | |
| 141 | dismissWindow(id: "capture") | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | /// A system-wide shortcut, registered with Carbon so it needs no accessibility permission. | |
| 146 | @MainActor | |
| 147 | final class GlobalHotKey { | |
| 148 | private var reference: EventHotKeyRef? | |
| 149 | private var handler: EventHandlerRef? | |
| 150 | private let action: () -> Void | |
| 151 | ||
| 152 | /// ⌃⌥Space. | |
| 153 | init(action: @escaping () -> Void) { | |
| 154 | self.action = action | |
| 155 | var spec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) | |
| 156 | let me = Unmanaged.passUnretained(self).toOpaque() | |
| 157 | InstallEventHandler(GetApplicationEventTarget(), { _, _, data in | |
| 158 | guard let data else { return noErr } | |
| 159 | let hotKey = Unmanaged<GlobalHotKey>.fromOpaque(data).takeUnretainedValue() | |
| 160 | MainActor.assumeIsolated { hotKey.action() } | |
| 161 | return noErr | |
| 162 | }, 1, &spec, me, &handler) | |
| 163 | let id = EventHotKeyID(signature: OSType(0x4F52_4753), id: 1) | |
| 164 | RegisterEventHotKey(UInt32(kVK_Space), UInt32(controlKey | optionKey), id, GetApplicationEventTarget(), 0, &reference) | |
| 165 | } | |
| 166 | ||
| 167 | func unregister() { | |
| 168 | if let reference { UnregisterEventHotKey(reference) } | |
| 169 | if let handler { RemoveEventHandler(handler) } | |
| 170 | reference = nil | |
| 171 | handler = nil | |
| 172 | } | |
| 173 | } | |
Sources/Orgstar/ContentView.swift +9
| @@ -15,6 +15,8 @@ struct ContentView: View { | ||
| 15 | 15 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 16 | 16 | @State private var reminderAgenda: AgendaModel? |
| 17 | 17 | @State private var trust = TrustStore() |
| 18 | @State private var hotKey: GlobalHotKey? | |
| 19 | @AppStorage("globalCapture") private var globalCapture = true | |
| 18 | 20 | @State private var hour = 0 |
| 19 | 21 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 20 | 22 | @AppStorage("keymap") private var preset = KeymapPreset.emacs |
| @@ -114,6 +116,10 @@ struct ContentView: View { | ||
| 114 | 116 | |
| 115 | 117 | var body: some View { |
| 116 | 118 | main |
| 119 | .onChange(of: globalCapture, initial: true) { _, enabled in | |
| 120 | hotKey?.unregister() | |
| 121 | hotKey = enabled ? GlobalHotKey { perform("app.capture") } : nil | |
| 122 | } | |
| 117 | 123 | .onChange(of: commands.runRequest?.serial) { |
| 118 | 124 | if let id = commands.runRequest?.id { perform(id) } |
| 119 | 125 | } |
| @@ -309,6 +315,9 @@ struct ContentView: View { | ||
| 309 | 315 | if let message = session.archive() { commands.show(message) } |
| 310 | 316 | case "app.agenda": |
| 311 | 317 | openWindow(id: "agenda") |
| 318 | case "app.capture": | |
| 319 | NSApp.activate() | |
| 320 | openWindow(id: "capture") | |
| 312 | 321 | case let id where id.hasPrefix("app.export."): |
| 313 | 322 | export(String(id.dropFirst("app.export.".count))) |
| 314 | 323 | default: |
Sources/Orgstar/OrgstarApp.swift +17
| @@ -161,6 +161,13 @@ struct OrgstarApp: App { | ||
| 161 | 161 | } |
| 162 | 162 | } |
| 163 | 163 | .defaultSize(width: 760, height: 720) |
| 164 | Window("Capture", id: "capture") { | |
| 165 | if case .success(let model) = workspace { | |
| 166 | CaptureView(workspace: model, session: session, commands: commands) | |
| 167 | } | |
| 168 | } | |
| 169 | .windowResizability(.contentSize) | |
| 170 | .windowLevel(.floating) | |
| 164 | 171 | .commands { |
| 165 | 172 | CommandGroup(after: .newItem) { |
| 166 | 173 | Button("Add Folder…") { commands.showingFolderPicker = true } |
| @@ -192,6 +199,10 @@ struct OrgstarApp: App { | ||
| 192 | 199 | CommandGroup(before: .windowList) { |
| 193 | 200 | AgendaMenuItem() |
| 194 | 201 | } |
| 202 | CommandGroup(after: .newItem) { | |
| 203 | Button("Capture…") { commands.run("app.capture") } | |
| 204 | .keyboardShortcut("n", modifiers: [.command, .shift]) | |
| 205 | } | |
| 195 | 206 | } |
| 196 | 207 | .commands { |
| 197 | 208 | CommandMenu("Org") { |
| @@ -234,6 +245,7 @@ struct SettingsView: View { | ||
| 234 | 245 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 235 | 246 | @AppStorage("reminders") private var reminders = true |
| 236 | 247 | @AppStorage("reminderLead") private var reminderLead = 12 |
| 248 | @AppStorage("globalCapture") private var globalCapture = true | |
| 237 | 249 | |
| 238 | 250 | var body: some View { |
| 239 | 251 | Form { |
| @@ -280,6 +292,11 @@ struct SettingsView: View { | ||
| 280 | 292 | Toggle("Notify before timed agenda entries", isOn: $reminders) |
| 281 | 293 | Stepper("\(reminderLead) minutes before (APPT_WARNTIME overrides)", value: $reminderLead, in: 0...120) |
| 282 | 294 | .disabled(!reminders) |
| 295 | Toggle("⌃⌥Space opens Capture from any app", isOn: $globalCapture) | |
| 296 | Text("Templates go in \(CaptureTemplates.userFile.path).") | |
| 297 | .font(.caption) | |
| 298 | .foregroundStyle(.secondary) | |
| 299 | .textSelection(.enabled) | |
| 283 | 300 | } |
| 284 | 301 | .padding(20) |
| 285 | 302 | .frame(width: 480) |
Tests/OrgAppTests/AppTests.swift +44
| @@ -473,3 +473,47 @@ struct EmacsExportTests { | ||
| 473 | 473 | #expect(try String(contentsOf: file, encoding: .utf8) == "#+TITLE: On disk\n") |
| 474 | 474 | } |
| 475 | 475 | } |
| 476 | ||
| 477 | @MainActor | |
| 478 | struct CaptureAppTests { | |
| 479 | @Test func loadsTemplates() throws { | |
| 480 | let folder = try Folder() | |
| 481 | let file = try folder.write("capture.toml", """ | |
| 482 | [[template]] | |
| 483 | key = "w" | |
| 484 | name = "Work" | |
| 485 | file = "work.org" | |
| 486 | olp = "Projects/Work" | |
| 487 | template = "* TODO %?\\n%a" | |
| 488 | [[template]] | |
| 489 | key = "j" | |
| 490 | type = "plain" | |
| 491 | file = "~/journal.org" | |
| 492 | template = "%U %?" | |
| 493 | prepend = true | |
| 494 | """) | |
| 495 | let loaded = CaptureTemplates.load(userFile: file) | |
| 496 | #expect(loaded.problems.isEmpty) | |
| 497 | #expect(loaded.templates.map(\.key) == ["w", "j"]) | |
| 498 | #expect(loaded.templates[0].target == .outline(file: "work.org", path: ["Projects", "Work"])) | |
| 499 | #expect(loaded.templates[0].template == "* TODO %?\n%a") | |
| 500 | #expect(loaded.templates[1].kind == .plain && loaded.templates[1].prepend) | |
| 501 | #expect(CaptureTemplates.load(userFile: folder.url.appendingPathComponent("none.toml")).templates == CaptureTemplates.defaults) | |
| 502 | } | |
| 503 | ||
| 504 | @Test func capturesIntoClosedMissingAndOpenFiles() throws { | |
| 505 | let notes = try Folder() | |
| 506 | let todo = try notes.write("todo.org", "* Inbox\n** TODO Old\n* Later\n") | |
| 507 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | |
| 508 | let template = CaptureTemplates.defaults[0] | |
| 509 | #expect(session.capture("* TODO New\n", template: template, root: notes.url.path) == nil) | |
| 510 | #expect(try String(contentsOf: todo, encoding: .utf8) == "* Inbox\n** TODO Old\n** TODO New\n* Later\n") | |
| 511 | ||
| 512 | #expect(session.capture("* Note\n", template: CaptureTemplates.defaults[1], root: notes.url.path) == nil) | |
| 513 | #expect(try String(contentsOf: notes.url.appendingPathComponent("notes.org"), encoding: .utf8) == "* Inbox\n** Note\n") | |
| 514 | ||
| 515 | session.open(todo.path) | |
| 516 | #expect(session.capture("* TODO Third\n", template: template, root: notes.url.path) == nil) | |
| 517 | #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n") | |
| 518 | } | |
| 519 | } | |
Tests/OrgCoreTests/CaptureTests.swift added +127
| @@ -0,0 +1,127 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct CaptureTests { | |
| 6 | struct Case { | |
| 7 | var file: String | |
| 8 | var template: CaptureTemplate | |
| 9 | var typed: String | |
| 10 | } | |
| 11 | ||
| 12 | static func lisp(_ s: String) -> String { | |
| 13 | "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" | |
| 14 | } | |
| 15 | ||
| 16 | /// Captures each case in `emacs --batch`: the target file afterwards. | |
| 17 | static func emacs(_ cases: [Case], seconds: Int) throws -> [String] { | |
| 18 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-capture-\(UUID().uuidString)") | |
| 19 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | |
| 20 | defer { try? FileManager.default.removeItem(at: folder) } | |
| 21 | var forms: [String] = [] | |
| 22 | for (i, c) in cases.enumerated() { | |
| 23 | let path = folder.appendingPathComponent("target\(i).org").path | |
| 24 | try c.file.write(toFile: path, atomically: true, encoding: .utf8) | |
| 25 | let target: String | |
| 26 | switch c.template.target { | |
| 27 | case .file: target = "(file \(lisp(path)))" | |
| 28 | case .headline(_, let h): target = "(file+headline \(lisp(path)) \(lisp(h)))" | |
| 29 | case .outline(_, let p): target = "(file+olp \(lisp(path)) \(p.map(lisp).joined(separator: " ")))" | |
| 30 | } | |
| 31 | forms.append(""" | |
| 32 | (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(c.template.prepend ? " :prepend t" : ""))))) | |
| 33 | (org-capture nil "x") | |
| 34 | (insert \(lisp(c.typed))) | |
| 35 | (org-capture-finalize) | |
| 36 | (with-current-buffer (find-buffer-visiting \(lisp(path))) | |
| 37 | (push (buffer-substring-no-properties (point-min) (point-max)) results))) | |
| 38 | """) | |
| 39 | } | |
| 40 | let output = folder.appendingPathComponent("out.json") | |
| 41 | let script = folder.appendingPathComponent("capture.el") | |
| 42 | try """ | |
| 43 | ;;; -*- lexical-binding: t -*- | |
| 44 | (require 'org) | |
| 45 | (require 'org-capture) | |
| 46 | (require 'json) | |
| 47 | (setq org-adapt-indentation nil) | |
| 48 | (defvar results nil) | |
| 49 | \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")")) | |
| 50 | (with-temp-file \(lisp(output.path)) (insert (json-encode (vconcat (nreverse results))))) | |
| 51 | """.write(to: script, atomically: true, encoding: .utf8) | |
| 52 | _ = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"]) | |
| 53 | return try JSONDecoder().decode([String].self, from: Data(contentsOf: output)) | |
| 54 | } | |
| 55 | ||
| 56 | static func ours(_ c: Case, now: Date) -> String { | |
| 57 | var utc = Calendar(identifier: .gregorian) | |
| 58 | utc.timeZone = TimeZone(identifier: "UTC")! | |
| 59 | guard case .success(let filled) = Capture.fill(c.template.template, context: CaptureContext(now: now, calendar: utc)) else { return "fill failed" } | |
| 60 | var text = filled.text | |
| 61 | let index = String.Index(utf16Offset: filled.cursor, in: text) | |
| 62 | text.insert(contentsOf: c.typed, at: index) | |
| 63 | switch Capture.place(text, template: c.template, in: c.file) { | |
| 64 | case .success(let result): return result | |
| 65 | case .failure(.message(let message)): return "failed: \(message)" | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | static let files = [ | |
| 70 | "#+TITLE: Todo\n* Inbox\n** TODO Old\n* Later\nText\n", | |
| 71 | "* Inbox\n\n** TODO Old\n\n* Later\n", | |
| 72 | "* Inbox\n", | |
| 73 | "* Inbox", | |
| 74 | "", | |
| 75 | "Preamble\n* Other\n", | |
| 76 | "* Inbox\nSome notes.\n- one\n- two\n\n** Child\n", | |
| 77 | "* Projects\n** Work\n*** Active\n** Home\n", | |
| 78 | "* Inbox\n:PROPERTIES:\n:ID: x\n:END:\nBody\n", | |
| 79 | ] | |
| 80 | ||
| 81 | static let templates: [CaptureTemplate] = [ | |
| 82 | CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* TODO %?\n%i\n%a"), | |
| 83 | CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* %u %?\n%i\n%a"), | |
| 84 | CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* TODO %?", prepend: true), | |
| 85 | CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Missing"), template: "* %?"), | |
| 86 | CaptureTemplate(key: "x", name: "", target: .file(""), template: "* Note %U\n%?"), | |
| 87 | CaptureTemplate(key: "x", name: "", target: .file(""), template: "** Deep %?", prepend: true), | |
| 88 | CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "Plain %? text", ), | |
| 89 | CaptureTemplate(key: "x", name: "", kind: .plain, target: .headline(file: "", headline: "Inbox"), template: "Plain line %?"), | |
| 90 | CaptureTemplate(key: "x", name: "", kind: .plain, target: .file(""), template: "%<%Y-%m-%d>: %?"), | |
| 91 | CaptureTemplate(key: "x", name: "", kind: .item, target: .headline(file: "", headline: "Inbox"), template: "%?"), | |
| 92 | CaptureTemplate(key: "x", name: "", kind: .checkitem, target: .headline(file: "", headline: "Inbox"), template: "[ ] %?"), | |
| 93 | CaptureTemplate(key: "x", name: "", target: .outline(file: "", path: ["Projects", "Work"]), template: "* TODO %?\n Added %T"), | |
| 94 | ] | |
| 95 | ||
| 96 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 97 | func matchesEmacs() throws { | |
| 98 | let seconds = 1_789_000_000 | |
| 99 | var cases: [Case] = [] | |
| 100 | for template in Self.templates { | |
| 101 | for file in Self.files { | |
| 102 | if case .outline = template.target, !file.contains("Projects") { continue } | |
| 103 | if template.kind == .item || template.kind == .checkitem, !file.contains("Inbox") { continue } | |
| 104 | cases.append(Case(file: file, template: template, typed: "Buy milk")) | |
| 105 | } | |
| 106 | } | |
| 107 | let emacs = try Self.emacs(cases, seconds: seconds) | |
| 108 | let now = Date(timeIntervalSince1970: Double(seconds)) | |
| 109 | for (c, expected) in zip(cases, emacs) { | |
| 110 | let mine = Self.ours(c, now: now) | |
| 111 | #expect(mine == expected, "\(c.template.template.debugDescription) \(c.template.kind) prepend \(c.template.prepend) into \(c.file.debugDescription)\nours:\n\(mine.debugDescription)\nemacs:\n\(expected.debugDescription)") | |
| 112 | } | |
| 113 | } | |
| 114 | ||
| 115 | @Test func fillsPrompts() { | |
| 116 | let template = "* %^{Title|untitled} for %^{Who}\n%\\1 again" | |
| 117 | #expect(Capture.questions(template) == [ | |
| 118 | Capture.Question(prompt: "Title", defaultValue: "untitled", choices: []), | |
| 119 | Capture.Question(prompt: "Who", defaultValue: "", choices: []), | |
| 120 | ]) | |
| 121 | guard case .success(let filled) = Capture.fill(template, context: CaptureContext(), answers: ["", "me"]) else { | |
| 122 | Issue.record("fill failed") | |
| 123 | return | |
| 124 | } | |
| 125 | #expect(filled.text == "* untitled for me\nuntitled again\n") | |
| 126 | } | |
| 127 | } | |
docs/plans/2026-10-05-capture.md added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | # Capture Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** `org-capture`: templates, filling them, filing the result where Emacs would, from inside the app and from a system-wide shortcut. | |
| 6 | ||
| 7 | **Architecture:** `OrgCore/Capture/Capture.swift` ports `org-capture-fill-template` (`%?`, `%i`, `%a`, `%l`, `%L`, `%c`, `%f`, `%F`, `%n`, `%t`, `%T`, `%u`, `%U`, `%<…>`, `%^{prompt|default}`, `%\N`; the text trimmed and ending in one newline) and placement for `entry`, `plain`, `item` and `checkitem` into `file`, `file+headline` (created when missing) and `file+olp` targets, with `org-capture-place-entry`'s blank-line handling (`org--blank-before-heading-p` with `auto`, empty lines after removed) and level shifting, and a final newline as saving adds. Templates come from `capture.toml` beside `keymap.toml`, defaulting to the two in your Doom config, files relative to the first folder. The capture window picks a template by key, asks the prompts, and files the edited text through the open buffer or the save path. ⌃⌥Space opens it from any app (Carbon hot key, no accessibility permission), as do `C-c c` and ⇧⌘N. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle (`org-capture` + `org-capture-finalize` in batch). | |
| 10 | ||
| 11 | **Spec:** `docs/design.md`, "Phase 5". | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - Oracle: 12 templates × target files (existing and missing headlines, blank lines, prepend, nested outline paths, property drawers, lists, files without a final newline). | |
| 16 | - Not yet: datetree targets, `%^g`/`%^t`/`%^C` and other typed prompts, `%(sexp)`, `%[file]`, `%k`, `%K`, `%x`; such templates show a message instead of filing something different. | |
| 17 | ||
| 18 | --- | |
| 19 | ||
| 20 | - [x] Fill, questions, placement; oracle. | |
| 21 | - [x] `CaptureTemplates`, `DocumentSession.capture`, `captureAnnotation`; tests. | |
| 22 | - [x] Capture window, global shortcut, menu, keys, settings. | |
| 23 | - [x] Commit "Capture". | |