krz/orgstar

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

Sources/OrgCore/Agenda/AgendaSource.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/OrgCore/Agenda/AgendaSource.swift history · blame · raw

359 lines · 18856 bytes

  1import Foundation
  2
  3/// What the agenda needs from one file, read once from its text: headings with their planning,
  4/// and the active timestamps and date ranges org's agenda would consider. Positions are UTF-16.
  5public struct AgendaSource: Sendable {
  6    public let path: String
  7    /// The file names `#+SETUPFILE`s.
  8    public let readsSetupFiles: Bool
  9
 10    struct Heading: Sendable {
 11        /// Start of the heading line.
 12        let start: Int
 13        let level: Int
 14        /// In an archived or commented tree, which the agenda skips.
 15        let skipped: Bool
 16        /// The title without keyword, priority and tags (`ITEM`).
 17        let title: String
 18        /// The priority cookie's value.
 19        let priority: String?
 20        let localTags: [String]
 21        /// Upper-cased keys of the entry's own drawer.
 22        let properties: [String: String]
 23        /// Planning timestamps with brackets, for property matches.
 24        let planning: [String: String]
 25        /// The first active and inactive timestamps in the entry (`TIMESTAMP`, `TIMESTAMP_IA`).
 26        let firstActive: String?
 27        let firstInactive: String?
 28        /// The heading line without its newline.
 29        let line: String
 30        /// The line after the stars.
 31        let head: String
 32        let todo: String?
 33        let isDone: Bool
 34        /// Inherited tags first; the flag marks inherited ones.
 35        let tags: [(name: String, inherited: Bool)]
 36        let category: String
 37        /// The planning timestamp without brackets, and the position of its `<`.
 38        let scheduled: (raw: String, offset: Int)?
 39        let deadline: (raw: String, offset: Int)?
 40        /// `STYLE: habit`.
 41        let isHabit: Bool
 42        /// `org-habit-parse-todo`, nil where it signals an error.
 43        let habit: Habit?
 44    }
 45
 46    struct Stamp: Sendable {
 47        let offset: Int
 48        let heading: Int
 49        /// From the `<` to the end of the line.
 50        let rest: String
 51    }
 52
 53    struct Block: Sendable {
 54        let start: String
 55        let end: String
 56        /// End of the range; org's marker sits there.
 57        let marker: Int
 58        let heading: Int
 59    }
 60
 61    /// A `CLOSED:`, `CLOCK:` or `- State "…"` line, for log mode (`org-agenda-get-progress`).
 62    struct Progress: Sendable {
 63        enum Kind: Sendable { case closed, clock, state }
 64        let kind: Kind
 65        let offset: Int
 66        let heading: Int
 67        /// `YYYY-MM-DD` of the first timestamp.
 68        let date: String
 69        /// The timestamp, from the keyword; a clock's ends with its end time (`[… 10:00-11:30]`).
 70        let timestamp: String
 71        /// A clock's duration, or `-`.
 72        let clocked: String
 73        /// The state a note records.
 74        let state: String?
 75        /// The first line of the note under it.
 76        let note: String?
 77    }
 78
 79    /// A `%%(…)` line (`org-agenda-get-sexps`).
 80    struct SexpLine: Sendable {
 81        /// Start of the line.
 82        let offset: Int
 83        /// The heading the line is under.
 84        let heading: Int
 85        let sexp: Sexp
 86        /// The rest of the line after the sexp.
 87        let entry: String
 88    }
 89
 90    let headings: [Heading]
 91    let stamps: [Stamp]
 92    let sexps: [SexpLine]
 93    let blocks: [Block]
 94    let progress: [Progress]
 95    /// The file's TODO keywords, active and done.
 96    let keywords: [String]
 97    let priorities: Priorities
 98
 99    public init(path: String, text: String, defaults: OrgSettings = .default) {
100        self.path = path
101        readsSetupFiles = text.range(of: "#+setupfile:", options: .caseInsensitive) != nil
102        let tree = OrgParser.parse(text, defaults: defaults)
103        let model = DocumentModel(tree: tree)
104        let ns = text as NSString
105        let settings = tree.settings
106        keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) }
107        priorities = settings.priorities
108
109        var stampsByHeading: [Int: [String]] = [:]
110        var headingIndex = -1
111        func collect(_ node: SyntaxNode) {
112            for child in node.children {
113                if child.kind == .heading { headingIndex += 1 }
114                if child.kind == .timestamp, headingIndex >= 0 {
115                    stampsByHeading[headingIndex, default: []].append(child.text)
116                } else if child.kind != .block, child.kind != .planning {
117                    collect(child)
118                }
119            }
120        }
121        collect(tree.root)
122        func sectionStamps(_ index: Int) -> [String] { stampsByHeading[index] ?? [] }
123
124        var keywordCategory = SetupFile.keywords(tree.settings.setupKeywords).last { $0.key == "CATEGORY" }?.value.trimmingCharacters(in: .whitespaces)
125        var srcBodies: [Range<Int>] = []
126        var planningLines: [Range<Int>] = []
127        var timestampNodes: [Int] = []
128        var propertyLines: [Range<Int>] = []
129        func walk(_ node: SyntaxNode) {
130            for child in node.children {
131                switch child.kind {
132                case .keyword:
133                    let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines)
134                    if line.uppercased().hasPrefix("#+CATEGORY:") {
135                        keywordCategory = line.dropFirst("#+CATEGORY:".count).trimmingCharacters(in: .whitespaces)
136                    }
137                case .block:
138                    if DocumentModel.isSrcBlock(child) {
139                        let lines = child.range
140                        let firstEnd = ns.range(of: "\n", range: NSRange(location: lines.lowerBound, length: lines.count)).location
141                        let lastStart = ns.lineRange(for: NSRange(location: max(lines.lowerBound, lines.upperBound - 1), length: 0)).location
142                        if firstEnd != NSNotFound, firstEnd + 1 <= lastStart { srcBodies.append((firstEnd + 1)..<lastStart) }
143                    }
144                case .planning:
145                    planningLines.append(child.range)
146                case .clock:
147                    break
148                case .nodeProperty:
149                    propertyLines.append(child.range)
150                case .timestamp:
151                    if child.text.hasPrefix("<") { timestampNodes.append(child.offset) }
152                default:
153                    walk(child)
154                }
155            }
156        }
157        walk(tree.root)
158
159        let fileName = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
160        let fileCategory = keywordCategory ?? model.property("CATEGORY", of: nil)?.value ?? fileName
161        let fileTags = model.fileTags
162        let fileArchived = fileTags.contains("ARCHIVE")
163
164        // Archived and commented trees are skipped whole, as `org-agenda-skip` does.
165        var skipped: [Bool] = []
166        var headings: [Heading] = []
167        for (index, info) in model.headings.enumerated() {
168            let own = info.localTags.contains("ARCHIVE") || Self.isCommented(info.title)
169            skipped.append(fileArchived || own || (info.parent.map { skipped[$0] } ?? false))
170            let line = ns.substring(with: NSRange(location: info.headingRange.lowerBound, length: info.headingRange.count))
171            let head = line.replacingOccurrences(of: "^\\*+[ \\t]+", with: "", options: .regularExpression)
172                .trimmingCharacters(in: .newlines)
173
174            var itags: [String] = fileTags
175            for ancestor in model.ancestors(of: index) { itags += model.headings[ancestor].localTags }
176            var tags: [(String, Bool)] = itags.map { ($0, true) } + info.localTags.map { ($0, false) }
177            // `delete-dups` over the reversed list: the most local copy of a tag stays.
178            var seen: Set<String> = []
179            tags = tags.reversed().filter { seen.insert($0.0).inserted }.reversed()
180
181            var category = fileCategory
182            for layer in ([index] + model.ancestors(of: index).reversed()) {
183                if let value = model.headings[layer].properties.last(where: { $0.key.uppercased() == "CATEGORY" && !$0.additive })?.value {
184                    category = value
185                    break
186                }
187            }
188
189            var scheduled: (String, Int)?
190            var deadline: (String, Int)?
191            var planningValues: [String: String] = [:]
192            let afterHeading = info.headingRange.upperBound
193            if let planning = planningLines.first(where: { $0.lowerBound == afterHeading }) {
194                let planningText = ns.substring(with: NSRange(location: planning.lowerBound, length: planning.count))
195                let regex = try! NSRegularExpression(pattern: "\\b(SCHEDULED|DEADLINE): *<([^>]+)>")
196                let pns = planningText as NSString
197                for m in regex.matches(in: planningText, range: NSRange(location: 0, length: pns.length)) {
198                    let value = (pns.substring(with: m.range(at: 2)), planning.lowerBound + m.range(at: 2).location - 1)
199                    if pns.substring(with: m.range(at: 1)) == "SCHEDULED" { scheduled = scheduled ?? value } else { deadline = deadline ?? value }
200                }
201                let any = try! NSRegularExpression(pattern: "\\b(SCHEDULED|DEADLINE|CLOSED): *([\\[<]\(Self.tsInternal)[\\]>])")
202                for m in any.matches(in: planningText, range: NSRange(location: 0, length: pns.length)) {
203                    let key = pns.substring(with: m.range(at: 1))
204                    if planningValues[key] == nil { planningValues[key] = pns.substring(with: m.range(at: 2)) }
205                }
206            }
207            var properties: [String: String] = [:]
208            for property in info.properties {
209                let key = property.key.uppercased()
210                if property.additive, let value = properties[key] {
211                    properties[key] = value + " " + property.value
212                } else {
213                    properties[key] = property.value
214                }
215            }
216            let ownStamps = sectionStamps(index)
217            let isHabit = properties["STYLE"]?.trimmingCharacters(in: .whitespaces) == "habit"
218            var habit: Habit?
219            if isHabit, let scheduled {
220                let end = index + 1 < model.headings.count ? model.headings[index + 1].headingRange.lowerBound : ns.length
221                let entry = ns.substring(with: NSRange(location: info.headingRange.lowerBound, length: end - info.headingRange.lowerBound))
222                habit = Habit.parse(entry: entry, scheduled: scheduled.0, doneKeywords: settings.todoSequences.flatMap { $0.done.map(\.name) })
223            }
224            headings.append(Heading(
225                start: info.headingRange.lowerBound, level: info.level, skipped: skipped[index],
226                title: info.title.trimmingCharacters(in: .whitespaces), priority: info.priority, localTags: info.localTags,
227                properties: properties, planning: planningValues,
228                firstActive: ownStamps.first { $0.hasPrefix("<") }, firstInactive: ownStamps.first { $0.hasPrefix("[") },
229                line: line.trimmingCharacters(in: .newlines), head: head, todo: info.todo, isDone: info.isDone,
230                tags: tags, category: category, scheduled: scheduled, deadline: deadline, isHabit: isHabit, habit: habit
231            ))
232        }
233        self.headings = headings
234
235        let starts = model.headings.map(\.headingRange.lowerBound)
236        func heading(at offset: Int) -> Int? {
237            var low = 0
238            var high = starts.count
239            while low < high {
240                let mid = (low + high) / 2
241                if starts[mid] <= offset { low = mid + 1 } else { high = mid }
242            }
243            return low == 0 ? nil : low - 1
244        }
245        func restOfLine(_ offset: Int) -> String {
246            let line = ns.lineRange(for: NSRange(location: offset, length: 0))
247            return ns.substring(with: NSRange(location: offset, length: NSMaxRange(line) - offset)).trimmingCharacters(in: .newlines)
248        }
249
250        // Timestamps: those the parser sees as timestamp objects, and dates in property lines.
251        var stampOffsets = timestampNodes
252        let datePattern = try! NSRegularExpression(pattern: "<[0-9]{4}-[0-9]{2}-[0-9]{2}")
253        for line in propertyLines {
254            for m in datePattern.matches(in: text, range: NSRange(location: line.lowerBound, length: line.count)) {
255                stampOffsets.append(m.range.location)
256            }
257        }
258        var stamps: [Stamp] = []
259        for offset in stampOffsets.sorted() {
260            guard let owner = heading(at: offset), !skipped[owner] else { continue }
261            let rest = restOfLine(offset)
262            guard rest.range(of: "^<([0-9]{4}-[0-9]{2}-[0-9]{2}|%%\\()", options: .regularExpression) != nil else { continue }
263            if Self.inDateRange(offset, ns) { continue }
264            stamps.append(Stamp(offset: offset, heading: owner, rest: rest))
265        }
266        self.stamps = stamps
267
268        // Sexp lines: org searches the raw text for `%%(` at the start of a line. Before the
269        // first heading org's agenda signals an error; they're left out.
270        var sexps: [SexpLine] = []
271        let sexpLine = try! NSRegularExpression(pattern: "^&?%%\\(", options: .anchorsMatchLines)
272        for m in sexpLine.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
273            guard let owner = heading(at: m.range.location), !skipped[owner] else { continue }
274            let open = NSMaxRange(m.range) - 1
275            let tail = ns.substring(with: NSRange(location: open, length: min(ns.length - open, 4096)))
276            guard let (sexp, length) = try? LispReader.readFirst(tail) else { continue }
277            let afterSexp = String(tail.dropFirst(length))
278            let lineRest = afterSexp.prefix { $0 != "\n" && $0 != "\r" }
279            let entry = lineRest.trimmingCharacters(in: CharacterSet(charactersIn: " \t"))
280            sexps.append(SexpLine(offset: m.range.location, heading: owner, sexp: sexp, entry: entry))
281        }
282        self.sexps = sexps
283
284        // Ranges: org scans the raw text, skipping comment lines, skipped trees and src blocks.
285        var blocks: [Block] = []
286        let range = try! NSRegularExpression(pattern: "<(\(Self.tsInternal))>--?-?<(\(Self.tsInternal))>")
287        for m in range.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
288            let point = NSMaxRange(m.range)
289            let line = ns.substring(with: ns.lineRange(for: NSRange(location: point, length: 0)))
290            if line.range(of: "^\\s*#( |$|\\n)", options: .regularExpression) != nil { continue }
291            guard let owner = heading(at: max(0, point - 1)), !skipped[owner] else { continue }
292            if srcBodies.contains(where: { $0.contains(point) }) { continue }
293            blocks.append(Block(start: ns.substring(with: m.range(at: 1)), end: ns.substring(with: m.range(at: 2)), marker: point, heading: owner))
294        }
295        self.blocks = blocks
296
297        // Log lines: org searches the raw text, then the heading above each match.
298        let keywordPattern = keywords.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "|")
299        let logLine = try! NSRegularExpression(pattern: "(\\bCLOSED:|\\bCLOCK:|- +State \"(\(keywordPattern.isEmpty ? "(?!)" : keywordPattern))\".*?) *\\[([0-9]{4}-[0-9]{2}-[0-9]{2})")
300        let inactive = try! NSRegularExpression(pattern: "\\[([0-9]{4}-[0-9]{2}-[0-9]{2}( +[^\\]+0-9>\\r\\n -]+)?( +([0-9]{1,2}):([0-9]{2}))?)\\]")
301        let clockEnd = try! NSRegularExpression(pattern: "([0-9]{1,2}:[0-9]{2})\\].*?([0-9]{1,2}:[0-9]{2})")
302        let stateNote = try! NSRegularExpression(pattern: ".*\\\\\\n[ \\t]*([^-\\n \\t].*?)[ \\t]*$", options: .anchorsMatchLines)
303        let clockNote = try! NSRegularExpression(pattern: ".*\\n[ \\t]*-[ \\t]+([^-\\n \\t].*?)[ \\t]*$", options: .anchorsMatchLines)
304        var progress: [Progress] = []
305        for m in logLine.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
306            guard let owner = heading(at: m.range.location), !skipped[owner] else { continue }
307            let keyword = ns.substring(with: m.range(at: 1))
308            let kind: Progress.Kind = keyword == "CLOSED:" ? .closed : keyword == "CLOCK:" ? .clock : .state
309            var timestamp = restOfLine(m.range.location)
310            var clocked = "-"
311            let tns = timestamp as NSString
312            if let ts = inactive.firstMatch(in: timestamp, range: NSRange(location: 0, length: tns.length)) {
313                let rest = tns.substring(from: NSMaxRange(ts.range))
314                timestamp = tns.substring(to: NSMaxRange(ts.range))
315                let rns = rest as NSString
316                if kind == .clock, let end = clockEnd.firstMatch(in: rest, range: NSRange(location: 0, length: rns.length)) {
317                    timestamp = String(timestamp.dropLast()) + "-" + rns.substring(with: end.range(at: 1)) + "]"
318                    clocked = rns.substring(with: end.range(at: 2))
319                }
320            }
321            // The note: after a state line ending in `\\`, or a list item under a clock line.
322            var note: String?
323            let after = NSMaxRange(m.range)
324            let notePattern: NSRegularExpression? = switch kind {
325            case .state: stateNote
326            case .clock: clockNote
327            case .closed: nil
328            }
329            if let notePattern, let n = notePattern.firstMatch(in: text, options: .anchored, range: NSRange(location: after, length: ns.length - after)) {
330                note = ns.substring(with: n.range(at: 1))
331            }
332            progress.append(Progress(
333                kind: kind, offset: m.range.location, heading: owner, date: ns.substring(with: m.range(at: 3)), timestamp: timestamp,
334                clocked: clocked, state: m.range(at: 2).location == NSNotFound ? nil : ns.substring(with: m.range(at: 2)), note: note
335            ))
336        }
337        self.progress = progress
338    }
339
340    /// `org-ts--internal-regexp`.
341    static let tsInternal = "[0-9]{4}-[0-9]{2}-[0-9]{2}(?: .*?)?"
342
343    /// `org-element` reads a title starting with `COMMENT` as a commented heading.
344    static func isCommented(_ title: String) -> Bool {
345        title == "COMMENT" || title.hasPrefix("COMMENT ")
346    }
347
348    /// `org-at-date-range-p` with point just after the date that starts at `offset`.
349    static func inDateRange(_ offset: Int, _ ns: NSString) -> Bool {
350        let point = offset + 11
351        let line = ns.lineRange(for: NSRange(location: offset, length: 0))
352        let regex = try! NSRegularExpression(pattern: "[\\[<](\(tsInternal))[\\]>]--?-?[\\[<](\(tsInternal))[\\]>]")
353        for m in regex.matches(in: ns as String, range: line) {
354            if m.range.location > point { break }
355            if NSMaxRange(m.range) >= point { return true }
356        }
357        return false
358    }
359}