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 | private var url: URL? { path.map { URL(fileURLWithPath: $0) } } | 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 | private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } | 92 | private var name: String { path.map { ($0 as NSString).lastPathComponent } ?? "" } |
| 91 | 93 | ||
| 92 | public func open(_ path: String) { | 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 | if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) } | 530 | if job.finish.params.contains("silent") || job.finish.params.contains("none") || job.finish.params.contains("discard") { return .success([]) } |
| 531 | let buffer = EmacsBuffer(text, point: 0, settings: settings) | 531 | let buffer = EmacsBuffer(text, point: 0, settings: settings) |
| 532 | insertResult(value, job.finish, block: blockRange, buffer: buffer) | 532 | insertResult(value, job.finish, block: blockRange, buffer: buffer) |
| 533 | return .success(singleEdit(from: text, to: buffer.text as String)) | 533 | return .success(TextEdit.between(text, 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)] | ||
| 550 | } | 534 | } |
| 551 | 535 | ||
| 552 | /// The language's result handling: `org-babel-result-cond` and the table import. | 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 | keys = "C-c a" | 154 | keys = "C-c a" |
| 155 | command = "app.agenda" | 155 | command = "app.agenda" |
| 156 | [[bind]] | 156 | [[bind]] |
| 157 | keys = "C-c c" | ||
| 158 | command = "app.capture" | ||
| 159 | [[bind]] | ||
| 157 | keys = "C-c C-e h h" | 160 | keys = "C-c C-e h h" |
| 158 | command = "app.export.html" | 161 | command = "app.export.html" |
| 159 | [[bind]] | 162 | [[bind]] |
Sources/OrgCore/Parser/Incremental.swift +19
| @@ -392,3 +392,22 @@ extension Parser { | |||
| 392 | return green.kind == kind ? green : nil | 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 | EditorCommand(id: "app.refile", title: "Refile…", action: .app), | 43 | EditorCommand(id: "app.refile", title: "Refile…", action: .app), |
| 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), | 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), |
| 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), | 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), |
| 46 | EditorCommand(id: "app.capture", title: "Capture…", action: .app), | ||
| 46 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), | 47 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), |
| 47 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), | 48 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), |
| 48 | EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), | 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 | extension OrgEditor: DocumentBuffer { | 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 | public var selection: [Range<Int>] { | 608 | public var selection: [Range<Int>] { |
| 602 | let selected = textView.selectedRange() | 609 | let selected = textView.selectedRange() |
| 603 | return [selected.location..<NSMaxRange(selected)] | 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 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false | 15 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 16 | @State private var reminderAgenda: AgendaModel? | 16 | @State private var reminderAgenda: AgendaModel? |
| 17 | @State private var trust = TrustStore() | 17 | @State private var trust = TrustStore() |
| 18 | @State private var hotKey: GlobalHotKey? | ||
| 19 | @AppStorage("globalCapture") private var globalCapture = true | ||
| 18 | @State private var hour = 0 | 20 | @State private var hour = 0 |
| 19 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | 21 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 20 | @AppStorage("keymap") private var preset = KeymapPreset.emacs | 22 | @AppStorage("keymap") private var preset = KeymapPreset.emacs |
| @@ -114,6 +116,10 @@ struct ContentView: View { | |||
| 114 | 116 | ||
| 115 | var body: some View { | 117 | var body: some View { |
| 116 | main | 118 | main |
| 119 | .onChange(of: globalCapture, initial: true) { _, enabled in | ||
| 120 | hotKey?.unregister() | ||
| 121 | hotKey = enabled ? GlobalHotKey { perform("app.capture") } : nil | ||
| 122 | } | ||
| 117 | .onChange(of: commands.runRequest?.serial) { | 123 | .onChange(of: commands.runRequest?.serial) { |
| 118 | if let id = commands.runRequest?.id { perform(id) } | 124 | if let id = commands.runRequest?.id { perform(id) } |
| 119 | } | 125 | } |
| @@ -309,6 +315,9 @@ struct ContentView: View { | |||
| 309 | if let message = session.archive() { commands.show(message) } | 315 | if let message = session.archive() { commands.show(message) } |
| 310 | case "app.agenda": | 316 | case "app.agenda": |
| 311 | openWindow(id: "agenda") | 317 | openWindow(id: "agenda") |
| 318 | case "app.capture": | ||
| 319 | NSApp.activate() | ||
| 320 | openWindow(id: "capture") | ||
| 312 | case let id where id.hasPrefix("app.export."): | 321 | case let id where id.hasPrefix("app.export."): |
| 313 | export(String(id.dropFirst("app.export.".count))) | 322 | export(String(id.dropFirst("app.export.".count))) |
| 314 | default: | 323 | default: |
Sources/Orgstar/OrgstarApp.swift +17
| @@ -161,6 +161,13 @@ struct OrgstarApp: App { | |||
| 161 | } | 161 | } |
| 162 | } | 162 | } |
| 163 | .defaultSize(width: 760, height: 720) | 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 | .commands { | 171 | .commands { |
| 165 | CommandGroup(after: .newItem) { | 172 | CommandGroup(after: .newItem) { |
| 166 | Button("Add Folder…") { commands.showingFolderPicker = true } | 173 | Button("Add Folder…") { commands.showingFolderPicker = true } |
| @@ -192,6 +199,10 @@ struct OrgstarApp: App { | |||
| 192 | CommandGroup(before: .windowList) { | 199 | CommandGroup(before: .windowList) { |
| 193 | AgendaMenuItem() | 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 | .commands { | 207 | .commands { |
| 197 | CommandMenu("Org") { | 208 | CommandMenu("Org") { |
| @@ -234,6 +245,7 @@ struct SettingsView: View { | |||
| 234 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false | 245 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 235 | @AppStorage("reminders") private var reminders = true | 246 | @AppStorage("reminders") private var reminders = true |
| 236 | @AppStorage("reminderLead") private var reminderLead = 12 | 247 | @AppStorage("reminderLead") private var reminderLead = 12 |
| 248 | @AppStorage("globalCapture") private var globalCapture = true | ||
| 237 | 249 | ||
| 238 | var body: some View { | 250 | var body: some View { |
| 239 | Form { | 251 | Form { |
| @@ -280,6 +292,11 @@ struct SettingsView: View { | |||
| 280 | Toggle("Notify before timed agenda entries", isOn: $reminders) | 292 | Toggle("Notify before timed agenda entries", isOn: $reminders) |
| 281 | Stepper("\(reminderLead) minutes before (APPT_WARNTIME overrides)", value: $reminderLead, in: 0...120) | 293 | Stepper("\(reminderLead) minutes before (APPT_WARNTIME overrides)", value: $reminderLead, in: 0...120) |
| 282 | .disabled(!reminders) | 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 | .padding(20) | 301 | .padding(20) |
| 285 | .frame(width: 480) | 302 | .frame(width: 480) |
Tests/OrgAppTests/AppTests.swift +44
| @@ -473,3 +473,47 @@ struct EmacsExportTests { | |||
| 473 | #expect(try String(contentsOf: file, encoding: .utf8) == "#+TITLE: On disk\n") | 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". | ||