| @@ -100,14 +100,26 @@ public struct CaptureContext: Sendable { |
| 100 | 100 | public var initial: String |
| 101 | 101 | /// `%a`: a link to where capture started, `[[target][description]]`, or empty. |
| 102 | 102 | public var annotation: String |
| 103 | | /// `%c`: the clipboard. |
| 103 | /// `%c` and `%x`: the clipboard. |
| 104 | 104 | public var clipboard: String |
| 105 | 105 | /// `%f` and `%F`: the file capture started from. |
| 106 | 106 | public var file: String |
| 107 | 107 | /// `%n`. |
| 108 | 108 | public var userName: String |
| 109 | | |
| 110 | | public init(now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "", file: String = "", userName: String = "") { |
| 109 | /// `%k` and `%K`: the clocked-in entry's title and a link to it, or empty. |
| 110 | public var clockHeading: String |
| 111 | public var clockLink: String |
| 112 | /// `%^g` and `%^G` offer these: the target file's tags, and every file's. |
| 113 | public var fileTags: [String] |
| 114 | public var allTags: [String] |
| 115 | /// `org-tags-column`, for aligning tags `%^g` adds to a heading. |
| 116 | public var tagsColumn: Int |
| 117 | |
| 118 | public init( |
| 119 | now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "", |
| 120 | file: String = "", userName: String = "", clockHeading: String = "", clockLink: String = "", |
| 121 | fileTags: [String] = [], allTags: [String] = [], tagsColumn: Int = -77 |
| 122 | ) { |
| 111 | 123 | self.now = now |
| 112 | 124 | self.calendar = calendar |
| 113 | 125 | self.initial = initial |
| @@ -115,6 +127,11 @@ public struct CaptureContext: Sendable { |
| 115 | 127 | self.clipboard = clipboard |
| 116 | 128 | self.file = file |
| 117 | 129 | self.userName = userName |
| 130 | self.clockHeading = clockHeading |
| 131 | self.clockLink = clockLink |
| 132 | self.fileTags = fileTags |
| 133 | self.allTags = allTags |
| 134 | self.tagsColumn = tagsColumn |
| 118 | 135 | } |
| 119 | 136 | } |
| 120 | 137 | |
| @@ -123,26 +140,181 @@ public enum Capture { |
| 123 | 140 | case message(String) |
| 124 | 141 | } |
| 125 | 142 | |
| 126 | | /// A `%^{prompt|default|choice…}` the template asks before it can be filled. |
| 143 | /// A question the template asks before it can be filled: `%^{prompt|default|choice…}`, |
| 144 | /// or one of `%^g`, `%^G`, `%^t`, `%^T`, `%^u`, `%^U`, `%^C`, `%^L`, `%^{prop}p`. |
| 127 | 145 | public struct Question: Sendable, Equatable { |
| 146 | public enum Kind: Sendable, Equatable { |
| 147 | case text |
| 148 | /// Tags, separated by colons. |
| 149 | case tags |
| 150 | /// A date as `org-read-date` reads it (`+2d`, `fri 14:00`); empty is today. |
| 151 | case date(withTime: Bool, inactive: Bool) |
| 152 | /// `%^C` and `%^L`: text from the clipboard, inserted as a link with `link`. |
| 153 | case clipboard(link: Bool) |
| 154 | /// `%^{NAME}p`: the value of property `NAME`. |
| 155 | case property |
| 156 | } |
| 157 | |
| 128 | 158 | public let prompt: String |
| 129 | 159 | public let defaultValue: String |
| 130 | 160 | public let choices: [String] |
| 161 | public let kind: Kind |
| 162 | |
| 163 | public init(prompt: String, defaultValue: String, choices: [String], kind: Kind = .text) { |
| 164 | self.prompt = prompt |
| 165 | self.defaultValue = defaultValue |
| 166 | self.choices = choices |
| 167 | self.kind = kind |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | static let promptPattern = "%\\^(?:\\{([^}]*)\\})?([CgGLptTuU])?" |
| 172 | |
| 173 | /// The questions `template` asks, in order. |
| 174 | public static func questions(_ template: String, context: CaptureContext = CaptureContext()) -> [Question] { |
| 175 | guard case .success(let expanded) = expand(template, context) else { return [] } |
| 176 | return prompts(in: expanded).map { question($0.items, $0.key, context) } |
| 177 | } |
| 178 | |
| 179 | static func question(_ items: [String]?, _ key: String?, _ context: CaptureContext) -> Question { |
| 180 | let prompt = items?.first |
| 181 | let defaultValue = items.map { $0.count > 1 ? $0[1] : "" } ?? "" |
| 182 | switch key { |
| 183 | case "g", "G": return Question(prompt: prompt ?? "Tags", defaultValue: "", choices: key == "g" ? context.fileTags : context.allTags, kind: .tags) |
| 184 | case "t", "T", "u", "U": |
| 185 | let upper = key == key?.uppercased() |
| 186 | return Question(prompt: prompt ?? "Date", defaultValue: "", choices: [], kind: .date(withTime: upper, inactive: key == "u" || key == "U")) |
| 187 | case "C", "L": |
| 188 | let values = [context.initial, context.clipboard].filter { !$0.isEmpty } |
| 189 | return Question(prompt: "Clipboard/kill value", defaultValue: context.initial, choices: values, kind: .clipboard(link: key == "L")) |
| 190 | case "p": return Question(prompt: prompt ?? "", defaultValue: defaultValue, choices: [], kind: .property) |
| 191 | default: return Question(prompt: prompt ?? "Enter string", defaultValue: defaultValue, choices: items.map { Array($0.dropFirst(2)) } ?? []) |
| 192 | } |
| 131 | 193 | } |
| 132 | 194 | |
| 133 | | /// The prompts in `template`, in order. |
| 134 | | public static func questions(_ template: String) -> [Question] { |
| 135 | | let regex = try! NSRegularExpression(pattern: "(?<!%)%\\^\\{([^}]*)\\}") |
| 136 | | let ns = template as NSString |
| 137 | | return regex.matches(in: template, range: NSRange(location: 0, length: ns.length)).map { m in |
| 138 | | let items = ns.substring(with: m.range(at: 1)).components(separatedBy: "|") |
| 139 | | return Question(prompt: items[0], defaultValue: items.count > 1 ? items[1] : "", choices: Array(items.dropFirst(2))) |
| 195 | /// The unescaped `%^` placeholders in `text`. |
| 196 | static func prompts(in text: String) -> [(range: NSRange, items: [String]?, key: String?)] { |
| 197 | let regex = try! NSRegularExpression(pattern: promptPattern) |
| 198 | let ns = text as NSString |
| 199 | return regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).compactMap { m in |
| 200 | guard !escaped(ns, at: m.range.location) else { return nil } |
| 201 | let items = m.range(at: 1).location == NSNotFound ? nil : ns.substring(with: m.range(at: 1)).components(separatedBy: "|") |
| 202 | let key = m.range(at: 2).location == NSNotFound ? nil : ns.substring(with: m.range(at: 2)) |
| 203 | return (m.range, items, key) |
| 140 | 204 | } |
| 141 | 205 | } |
| 142 | 206 | |
| 207 | /// `org-capture-escaped-%` without its unescaping: an odd run of backslashes before `%`. |
| 208 | static func escaped(_ ns: NSString, at index: Int) -> Bool { |
| 209 | var i = index |
| 210 | while i > 0, ns.character(at: i - 1) == 92 { i -= 1 } |
| 211 | return (index - i) % 2 == 1 |
| 212 | } |
| 213 | |
| 214 | /// Where `%?` puts the cursor while the template is filled. |
| 215 | static let cursorMark = "\u{E000}" |
| 216 | |
| 143 | 217 | /// `org-capture-fill-template`: the text and where `%?` puts the cursor. `answers` holds |
| 144 | 218 | /// the replies to `questions(template)` in order. |
| 145 | 219 | public static func fill(_ template: String, context: CaptureContext, answers: [String] = []) -> Result<(text: String, cursor: Int), Failure> { |
| 220 | let expanded: String |
| 221 | switch expand(template, context) { |
| 222 | case .failure(let failure): return .failure(failure) |
| 223 | case .success(let text): expanded = text |
| 224 | } |
| 225 | // Prompts, in order, answered in the text as it stands then, as org does. |
| 226 | var options = EditingOptions.org |
| 227 | options.tagsColumn = context.tagsColumn |
| 228 | let buffer = EmacsBuffer(expanded, point: 0, settings: .default, options: options) |
| 229 | buffer.now = context.now |
| 230 | buffer.calendar = context.calendar |
| 231 | var strings: [String] = [] |
| 232 | var all: [String] = [] |
| 233 | var index = 0 |
| 234 | while let match = buffer.searchForward(promptPattern, caseInsensitive: false) { |
| 235 | let whole = match[0]! |
| 236 | if unescape(buffer, at: whole.lowerBound) { continue } |
| 237 | let start = buffer.point - whole.count |
| 238 | let items = match[1].map { buffer.substring($0.lowerBound, $0.upperBound).components(separatedBy: "|") } |
| 239 | let key = match[2].map { buffer.substring($0.lowerBound, $0.upperBound) } |
| 240 | let question = question(items, key, context) |
| 241 | let answer = index < answers.count ? answers[index] : "" |
| 242 | index += 1 |
| 243 | buffer.deleteRegion(start, buffer.point) |
| 244 | buffer.point = start |
| 245 | switch question.kind { |
| 246 | case .text: |
| 247 | let value = answer.isEmpty ? question.defaultValue : answer |
| 248 | strings.append(value) |
| 249 | all.append(value) |
| 250 | buffer.insert(value) |
| 251 | case .tags: |
| 252 | let tags = answer.split(separator: ":").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } |
| 253 | guard !tags.isEmpty else { continue } |
| 254 | all.append(":" + tags.joined(separator: ":") + ":") |
| 255 | if buffer.point == 0 || buffer.character(at: buffer.point - 1) != 58 { buffer.insert(":") } |
| 256 | buffer.insert(tags.joined(separator: ":")) |
| 257 | if buffer.point >= buffer.length || buffer.character(at: buffer.point) != 58 { buffer.insert(":") } |
| 258 | if buffer.lookingAtLine("^\\*+ ") { buffer.alignTags(onLineAt: buffer.point) } |
| 259 | case .date(let withTime, let inactive): |
| 260 | let result: ReadDate.Answer |
| 261 | if answer.trimmingCharacters(in: .whitespaces).isEmpty { |
| 262 | let c = context.calendar.dateComponents([.year, .month, .day, .hour, .minute], from: context.now) |
| 263 | result = ReadDate.Answer(year: c.year!, month: c.month!, day: c.day!, hour: c.hour!, minute: c.minute!, timeGiven: false) |
| 264 | } else { |
| 265 | result = buffer.readDate(answer, default: context.now) |
| 266 | } |
| 267 | let stamp = buffer.insertTimestamp(result.components(context.calendar), withTime: result.timeGiven || withTime, inactive: inactive, endTime: result.endTime) |
| 268 | all.append(stamp) |
| 269 | case .clipboard(let link): |
| 270 | let value = answer.isEmpty ? question.defaultValue : answer |
| 271 | all.append(value) |
| 272 | buffer.insert(link ? Links.makeString(value, nil) : value) |
| 273 | case .property: |
| 274 | let value = answer.isEmpty ? question.defaultValue : answer |
| 275 | buffer.saveExcursion { buffer.entryPut(question.prompt, value) } |
| 276 | all.append(value) |
| 277 | } |
| 278 | } |
| 279 | // `%\N`: the Nth `%^{…}` answer; `%\*N`: the Nth answer of any prompt. |
| 280 | var text = buffer.string |
| 281 | for (pattern, values) in [("%\\\\\\*([1-9][0-9]*)", all), ("%\\\\([1-9][0-9]*)", strings)] { |
| 282 | let regex = try! NSRegularExpression(pattern: pattern) |
| 283 | let ns = text as NSString |
| 284 | var out = text |
| 285 | for m in regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).reversed() where !escaped(ns, at: m.range.location) { |
| 286 | let n = Int(ns.substring(with: m.range(at: 1)))! |
| 287 | out = (out as NSString).replacingCharacters(in: m.range, with: n <= values.count ? values[n - 1] : "") |
| 288 | } |
| 289 | text = out |
| 290 | } |
| 291 | // No blank lines before the text; it ends with one newline, or is empty. |
| 292 | var lines = text.components(separatedBy: "\n") |
| 293 | while let first = lines.first, first.trimmingCharacters(in: .whitespaces).isEmpty, lines.count > 1 { lines.removeFirst() } |
| 294 | var marked = lines.joined(separator: "\n") |
| 295 | let trimmed = marked.replacingOccurrences(of: "[ \\t\\n]+$", with: "", options: .regularExpression) |
| 296 | marked = trimmed.isEmpty ? "" : trimmed + "\n" |
| 297 | marked = marked.replacingOccurrences(of: "\t", with: " ") |
| 298 | guard let range = marked.range(of: cursorMark) else { return .success((marked, marked.utf16.count > 0 ? marked.utf16.count - 1 : 0)) } |
| 299 | let offset = marked[..<range.lowerBound].utf16.count |
| 300 | marked.removeSubrange(range) |
| 301 | return .success((marked, offset)) |
| 302 | } |
| 303 | |
| 304 | /// `org-capture-escaped-%` at the `%` at `index`: drops half the backslashes before it; |
| 305 | /// true when it was escaped. Point follows the deletion. |
| 306 | static func unescape(_ buffer: EmacsBuffer, at index: Int) -> Bool { |
| 307 | var i = index |
| 308 | while i > 0, buffer.character(at: i - 1) == 92 { i -= 1 } |
| 309 | let n = index - i |
| 310 | guard n > 0 else { return false } |
| 311 | buffer.deleteRegion(i, i + (n + 1) / 2) |
| 312 | return n % 2 == 1 |
| 313 | } |
| 314 | |
| 315 | /// The escapes that need no answer (`%a`, `%t`, `%<…>`, `%?`…), with `%^` prompts and `%\N` |
| 316 | /// left in place. |
| 317 | static func expand(_ template: String, _ context: CaptureContext) -> Result<String, Failure> { |
| 146 | 318 | func stamp(_ active: Bool, _ withTime: Bool) -> String { |
| 147 | 319 | let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute], from: context.now) |
| 148 | 320 | let day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][(c.weekday ?? 1) - 1] |
| @@ -156,9 +328,6 @@ public enum Capture { |
| 156 | 328 | return template.replacingOccurrences(of: "$1", with: ns.substring(with: m.range(at: 1))) |
| 157 | 329 | } |
| 158 | 330 | var text = "" |
| 159 | | var answerIndex = 0 |
| 160 | | var answered: [String] = [] |
| 161 | | var cursor: Int? |
| 162 | 331 | let chars = Array(template) |
| 163 | 332 | var i = 0 |
| 164 | 333 | while i < chars.count { |
| @@ -168,86 +337,80 @@ public enum Capture { |
| 168 | 337 | i += 1 |
| 169 | 338 | continue |
| 170 | 339 | } |
| 340 | // A `%` after an odd run of backslashes stays, with half of them. |
| 341 | var slashes = 0 |
| 342 | while slashes < text.count, text.dropLast(slashes).last == "\\" { slashes += 1 } |
| 171 | 343 | let next = chars[i + 1] |
| 172 | | if next == "%" { |
| 173 | | text.append("%") |
| 174 | | i += 2 |
| 175 | | continue |
| 344 | if slashes > 0, "aAcfFikKlLntTuUx:<?".contains(next) { |
| 345 | text.removeLast((slashes + 1) / 2) |
| 346 | if slashes % 2 == 1 { |
| 347 | text.append(c) |
| 348 | i += 1 |
| 349 | continue |
| 350 | } |
| 176 | 351 | } |
| 177 | 352 | var consumed = 2 |
| 178 | 353 | switch next { |
| 179 | | case "?": cursor = text.utf16.count |
| 354 | case "?": text += cursorMark |
| 180 | 355 | case "i": |
| 181 | 356 | // Continuation lines keep the indentation of the line `%i` is on. |
| 182 | 357 | let lead = String(text.split(separator: "\n", omittingEmptySubsequences: false).last ?? "").prefix { $0 == " " || $0 == "\t" } |
| 183 | 358 | text += context.initial.replacingOccurrences(of: "\n", with: "\n" + lead) |
| 184 | 359 | case "a": text += context.annotation == "[[]]" ? "" : context.annotation |
| 360 | case "A": |
| 361 | // The link, with its description asked for. |
| 362 | let ns = context.annotation as NSString |
| 363 | if let m = link.firstMatch(in: context.annotation, range: NSRange(location: 0, length: ns.length)) { |
| 364 | text += ns.replacingCharacters(in: m.range, with: "[[" + ns.substring(with: m.range(at: 1)) + "][%^{Link description}]]") |
| 365 | } else { |
| 366 | text += context.annotation |
| 367 | } |
| 185 | 368 | case "l": text += linkForm("[[$1]]") |
| 186 | 369 | case "L": text += linkForm("$1") |
| 187 | | case "c": text += context.clipboard |
| 370 | case "c", "x": text += context.clipboard |
| 188 | 371 | case "f": text += (context.file as NSString).lastPathComponent |
| 189 | 372 | case "F": text += context.file |
| 190 | 373 | case "n": text += context.userName |
| 374 | case "k": text += context.clockHeading |
| 375 | case "K": text += context.clockLink |
| 191 | 376 | case "t": text += stamp(true, false) |
| 192 | 377 | case "T": text += stamp(true, true) |
| 193 | 378 | case "u": text += stamp(false, false) |
| 194 | 379 | case "U": text += stamp(false, true) |
| 195 | 380 | case "<": |
| 196 | | guard let close = chars[(i + 2)...].firstIndex(of: ">") else { return .failure(.message("Unclosed %< in the template")) } |
| 197 | | text += strftime(String(chars[(i + 2)..<close]), context) |
| 198 | | consumed = close - i + 1 |
| 199 | | case "^": |
| 200 | | guard i + 2 < chars.count, chars[i + 2] == "{", let close = chars[(i + 3)...].firstIndex(of: "}") else { |
| 201 | | return .failure(.message("Template escapes like %^\(i + 2 < chars.count ? String(chars[i + 2]) : "") aren't supported yet.")) |
| 381 | guard let close = chars[(i + 2)...].firstIndex(of: ">"), !chars[(i + 2)..<close].contains("\n") else { |
| 382 | text.append(c) |
| 383 | consumed = 1 |
| 384 | break |
| 202 | 385 | } |
| 203 | | let after = close + 1 < chars.count ? chars[close + 1] : " " |
| 204 | | if "CgGLptTuU".contains(after) { return .failure(.message("Template escapes like %^{…}\(after) aren't supported yet.")) } |
| 205 | | let items = String(chars[(i + 3)..<close]).components(separatedBy: "|") |
| 206 | | var answer = answerIndex < answers.count ? answers[answerIndex] : "" |
| 207 | | if answer.isEmpty, items.count > 1 { answer = items[1] } |
| 208 | | answerIndex += 1 |
| 209 | | answered.append(answer) |
| 210 | | text += answer |
| 386 | text += strftime(String(chars[(i + 2)..<close]), context) |
| 211 | 387 | consumed = close - i + 1 |
| 212 | | case "\\": |
| 388 | case ":": |
| 389 | // A link property (`%:annotation`); only the capture's own are known. |
| 213 | 390 | var j = i + 2 |
| 214 | | var digits = "" |
| 215 | | while j < chars.count, chars[j].isNumber { |
| 216 | | digits.append(chars[j]) |
| 217 | | j += 1 |
| 218 | | } |
| 219 | | if let n = Int(digits), n >= 1, n <= answered.count { |
| 220 | | text += answered[n - 1] |
| 221 | | consumed = j - i |
| 222 | | } else { |
| 391 | while j < chars.count, chars[j].isLetter || chars[j] == "-" { j += 1 } |
| 392 | guard j > i + 2 else { |
| 223 | 393 | text.append(c) |
| 224 | 394 | consumed = 1 |
| 395 | break |
| 396 | } |
| 397 | switch String(chars[(i + 2)..<j]) { |
| 398 | case "annotation": text += context.annotation |
| 399 | case "initial": text += context.initial |
| 400 | default: break |
| 225 | 401 | } |
| 226 | | case "(", "[", "k", "K", "x", "A", ":": |
| 227 | | return .failure(.message("Template escapes like %\(next) aren't supported yet.")) |
| 402 | consumed = j - i |
| 403 | case "(": |
| 404 | return .failure(.message("Template escapes like %( run Emacs Lisp, which Orgstar doesn't.")) |
| 405 | case "[": |
| 406 | return .failure(.message("Template escapes like %[file] aren't supported yet.")) |
| 228 | 407 | default: |
| 229 | 408 | text.append(c) |
| 230 | 409 | consumed = 1 |
| 231 | 410 | } |
| 232 | 411 | i += consumed |
| 233 | 412 | } |
| 234 | | // No blank lines before the text; it ends with one newline, or is empty. |
| 235 | | var marked = text |
| 236 | | let marker = "\u{0}CURSOR\u{0}" |
| 237 | | if let cursor { |
| 238 | | let index = String.Index(utf16Offset: cursor, in: marked) |
| 239 | | marked.insert(contentsOf: marker, at: index) |
| 240 | | } |
| 241 | | var lines = marked.components(separatedBy: "\n") |
| 242 | | while let first = lines.first, first.trimmingCharacters(in: .whitespaces).isEmpty, lines.count > 1 { lines.removeFirst() } |
| 243 | | marked = lines.joined(separator: "\n") |
| 244 | | let trimmed = marked.replacingOccurrences(of: "[ \\t\\n]+$", with: "", options: .regularExpression) |
| 245 | | marked = trimmed.isEmpty ? "" : trimmed + "\n" |
| 246 | | marked = marked.replacingOccurrences(of: "\t", with: " ") |
| 247 | | guard let range = marked.range(of: marker) else { return .success((marked, marked.utf16.count > 0 ? marked.utf16.count - 1 : 0)) } |
| 248 | | let offset = marked[..<range.lowerBound].utf16.count |
| 249 | | marked.removeSubrange(range) |
| 250 | | return .success((marked, offset)) |
| 413 | return .success(text) |
| 251 | 414 | } |
| 252 | 415 | |
| 253 | 416 | static func strftime(_ format: String, _ context: CaptureContext) -> String { |