| @@ -100,14 +100,26 @@ public struct CaptureContext: Sendable { |
| 100 | public var initial: String |
100 | public var initial: String |
| 101 | /// `%a`: a link to where capture started, `[[target][description]]`, or empty. |
101 | /// `%a`: a link to where capture started, `[[target][description]]`, or empty. |
| 102 | public var annotation: String |
102 | public var annotation: String |
| 103 | /// `%c`: the clipboard. |
103 | /// `%c` and `%x`: the clipboard. |
| 104 | public var clipboard: String |
104 | public var clipboard: String |
| 105 | /// `%f` and `%F`: the file capture started from. |
105 | /// `%f` and `%F`: the file capture started from. |
| 106 | public var file: String |
106 | public var file: String |
| 107 | /// `%n`. |
107 | /// `%n`. |
| 108 | public var userName: String |
108 | public var userName: String |
| 109 | |
109 | /// `%k` and `%K`: the clocked-in entry's title and a link to it, or empty. |
| 110 | public init(now: Date = Date(), calendar: Calendar = .current, initial: String = "", annotation: String = "", clipboard: String = "", file: String = "", userName: String = "") { |
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 | self.now = now |
123 | self.now = now |
| 112 | self.calendar = calendar |
124 | self.calendar = calendar |
| 113 | self.initial = initial |
125 | self.initial = initial |
| @@ -115,6 +127,11 @@ public struct CaptureContext: Sendable { |
| 115 | self.clipboard = clipboard |
127 | self.clipboard = clipboard |
| 116 | self.file = file |
128 | self.file = file |
| 117 | self.userName = userName |
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 | case message(String) |
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 | public struct Question: Sendable, Equatable { |
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 | public let prompt: String |
158 | public let prompt: String |
| 129 | public let defaultValue: String |
159 | public let defaultValue: String |
| 130 | public let choices: [String] |
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. |
195 | /// The unescaped `%^` placeholders in `text`. |
| 134 | public static func questions(_ template: String) -> [Question] { |
196 | static func prompts(in text: String) -> [(range: NSRange, items: [String]?, key: String?)] { |
| 135 | let regex = try! NSRegularExpression(pattern: "(?<!%)%\\^\\{([^}]*)\\}") |
197 | let regex = try! NSRegularExpression(pattern: promptPattern) |
| 136 | let ns = template as NSString |
198 | let ns = text as NSString |
| 137 | return regex.matches(in: template, range: NSRange(location: 0, length: ns.length)).map { m in |
199 | return regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).compactMap { m in |
| 138 | let items = ns.substring(with: m.range(at: 1)).components(separatedBy: "|") |
200 | guard !escaped(ns, at: m.range.location) else { return nil } |
| 139 | return Question(prompt: items[0], defaultValue: items.count > 1 ? items[1] : "", choices: Array(items.dropFirst(2))) |
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 | /// `org-capture-fill-template`: the text and where `%?` puts the cursor. `answers` holds |
217 | /// `org-capture-fill-template`: the text and where `%?` puts the cursor. `answers` holds |
| 144 | /// the replies to `questions(template)` in order. |
218 | /// the replies to `questions(template)` in order. |
| 145 | public static func fill(_ template: String, context: CaptureContext, answers: [String] = []) -> Result<(text: String, cursor: Int), Failure> { |
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 | func stamp(_ active: Bool, _ withTime: Bool) -> String { |
318 | func stamp(_ active: Bool, _ withTime: Bool) -> String { |
| 147 | let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute], from: context.now) |
319 | let c = context.calendar.dateComponents([.year, .month, .day, .weekday, .hour, .minute], from: context.now) |
| 148 | let day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][(c.weekday ?? 1) - 1] |
320 | let day = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][(c.weekday ?? 1) - 1] |
| @@ -156,9 +328,6 @@ public enum Capture { |
| 156 | return template.replacingOccurrences(of: "$1", with: ns.substring(with: m.range(at: 1))) |
328 | return template.replacingOccurrences(of: "$1", with: ns.substring(with: m.range(at: 1))) |
| 157 | } |
329 | } |
| 158 | var text = "" |
330 | var text = "" |
| 159 | var answerIndex = 0 |
| |
| 160 | var answered: [String] = [] |
| |
| 161 | var cursor: Int? |
| |
| 162 | let chars = Array(template) |
331 | let chars = Array(template) |
| 163 | var i = 0 |
332 | var i = 0 |
| 164 | while i < chars.count { |
333 | while i < chars.count { |
| @@ -168,86 +337,80 @@ public enum Capture { |
| 168 | i += 1 |
337 | i += 1 |
| 169 | continue |
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 | let next = chars[i + 1] |
343 | let next = chars[i + 1] |
| 172 | if next == "%" { |
344 | if slashes > 0, "aAcfFikKlLntTuUx:<?".contains(next) { |
| 173 | text.append("%") |
345 | text.removeLast((slashes + 1) / 2) |
| 174 | i += 2 |
346 | if slashes % 2 == 1 { |
| 175 | continue |
347 | text.append(c) |
| |
348 | i += 1 |
| |
349 | continue |
| |
350 | } |
| 176 | } |
351 | } |
| 177 | var consumed = 2 |
352 | var consumed = 2 |
| 178 | switch next { |
353 | switch next { |
| 179 | case "?": cursor = text.utf16.count |
354 | case "?": text += cursorMark |
| 180 | case "i": |
355 | case "i": |
| 181 | // Continuation lines keep the indentation of the line `%i` is on. |
356 | // Continuation lines keep the indentation of the line `%i` is on. |
| 182 | let lead = String(text.split(separator: "\n", omittingEmptySubsequences: false).last ?? "").prefix { $0 == " " || $0 == "\t" } |
357 | let lead = String(text.split(separator: "\n", omittingEmptySubsequences: false).last ?? "").prefix { $0 == " " || $0 == "\t" } |
| 183 | text += context.initial.replacingOccurrences(of: "\n", with: "\n" + lead) |
358 | text += context.initial.replacingOccurrences(of: "\n", with: "\n" + lead) |
| 184 | case "a": text += context.annotation == "[[]]" ? "" : context.annotation |
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 | case "l": text += linkForm("[[$1]]") |
368 | case "l": text += linkForm("[[$1]]") |
| 186 | case "L": text += linkForm("$1") |
369 | case "L": text += linkForm("$1") |
| 187 | case "c": text += context.clipboard |
370 | case "c", "x": text += context.clipboard |
| 188 | case "f": text += (context.file as NSString).lastPathComponent |
371 | case "f": text += (context.file as NSString).lastPathComponent |
| 189 | case "F": text += context.file |
372 | case "F": text += context.file |
| 190 | case "n": text += context.userName |
373 | case "n": text += context.userName |
| |
374 | case "k": text += context.clockHeading |
| |
375 | case "K": text += context.clockLink |
| 191 | case "t": text += stamp(true, false) |
376 | case "t": text += stamp(true, false) |
| 192 | case "T": text += stamp(true, true) |
377 | case "T": text += stamp(true, true) |
| 193 | case "u": text += stamp(false, false) |
378 | case "u": text += stamp(false, false) |
| 194 | case "U": text += stamp(false, true) |
379 | case "U": text += stamp(false, true) |
| 195 | case "<": |
380 | case "<": |
| 196 | guard let close = chars[(i + 2)...].firstIndex(of: ">") else { return .failure(.message("Unclosed %< in the template")) } |
381 | guard let close = chars[(i + 2)...].firstIndex(of: ">"), !chars[(i + 2)..<close].contains("\n") else { |
| 197 | text += strftime(String(chars[(i + 2)..<close]), context) |
382 | text.append(c) |
| 198 | consumed = close - i + 1 |
383 | consumed = 1 |
| 199 | case "^": |
384 | break |
| 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.")) |
| |
| 202 | } |
385 | } |
| 203 | let after = close + 1 < chars.count ? chars[close + 1] : " " |
386 | text += strftime(String(chars[(i + 2)..<close]), context) |
| 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 |
| |
| 211 | consumed = close - i + 1 |
387 | consumed = close - i + 1 |
| 212 | case "\\": |
388 | case ":": |
| |
389 | // A link property (`%:annotation`); only the capture's own are known. |
| 213 | var j = i + 2 |
390 | var j = i + 2 |
| 214 | var digits = "" |
391 | while j < chars.count, chars[j].isLetter || chars[j] == "-" { j += 1 } |
| 215 | while j < chars.count, chars[j].isNumber { |
392 | guard j > i + 2 else { |
| 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 { |
| |
| 223 | text.append(c) |
393 | text.append(c) |
| 224 | consumed = 1 |
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", ":": |
402 | consumed = j - i |
| 227 | return .failure(.message("Template escapes like %\(next) aren't supported yet.")) |
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 | default: |
407 | default: |
| 229 | text.append(c) |
408 | text.append(c) |
| 230 | consumed = 1 |
409 | consumed = 1 |
| 231 | } |
410 | } |
| 232 | i += consumed |
411 | i += consumed |
| 233 | } |
412 | } |
| 234 | // No blank lines before the text; it ends with one newline, or is empty. |
413 | return .success(text) |
| 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)) |
| |
| 251 | } |
414 | } |
| 252 | |
415 | |
| 253 | static func strftime(_ format: String, _ context: CaptureContext) -> String { |
416 | static func strftime(_ format: String, _ context: CaptureContext) -> String { |