krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Capture/Capture.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgCore/Capture/Capture.swift history · blame · raw

550 lines · 25422 bytes

  1import 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
  6public 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.
 40public 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
 65public 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}