krz/orgstar

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

Sources/OrgCore/Agenda/AgendaSource.swift

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

267 lines · 13431 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
  8    struct Heading: Sendable {
  9        /// Start of the heading line.
 10        let start: Int
 11        let level: Int
 12        /// In an archived or commented tree, which the agenda skips.
 13        let skipped: Bool
 14        /// The title without keyword, priority and tags (`ITEM`).
 15        let title: String
 16        /// The priority cookie's value.
 17        let priority: String?
 18        let localTags: [String]
 19        /// Upper-cased keys of the entry's own drawer.
 20        let properties: [String: String]
 21        /// Planning timestamps with brackets, for property matches.
 22        let planning: [String: String]
 23        /// The first active and inactive timestamps in the entry (`TIMESTAMP`, `TIMESTAMP_IA`).
 24        let firstActive: String?
 25        let firstInactive: String?
 26        /// The heading line without its newline.
 27        let line: String
 28        /// The line after the stars.
 29        let head: String
 30        let todo: String?
 31        let isDone: Bool
 32        /// Inherited tags first; the flag marks inherited ones.
 33        let tags: [(name: String, inherited: Bool)]
 34        let category: String
 35        /// The planning timestamp without brackets, and the position of its `<`.
 36        let scheduled: (raw: String, offset: Int)?
 37        let deadline: (raw: String, offset: Int)?
 38        /// `STYLE: habit`.
 39        let isHabit: Bool
 40        /// `org-habit-parse-todo`, nil where it signals an error.
 41        let habit: Habit?
 42    }
 43
 44    struct Stamp: Sendable {
 45        let offset: Int
 46        let heading: Int
 47        /// From the `<` to the end of the line.
 48        let rest: String
 49    }
 50
 51    struct Block: Sendable {
 52        let start: String
 53        let end: String
 54        /// End of the range; org's marker sits there.
 55        let marker: Int
 56        let heading: Int
 57    }
 58
 59    let headings: [Heading]
 60    let stamps: [Stamp]
 61    let blocks: [Block]
 62    /// The file's TODO keywords, active and done.
 63    let keywords: [String]
 64    let priorities: Priorities
 65
 66    public init(path: String, text: String, defaults: OrgSettings = .default) {
 67        self.path = path
 68        let tree = OrgParser.parse(text, defaults: defaults)
 69        let model = DocumentModel(tree: tree)
 70        let ns = text as NSString
 71        let settings = tree.settings
 72        keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) }
 73        priorities = settings.priorities
 74
 75        var stampsByHeading: [Int: [String]] = [:]
 76        var headingIndex = -1
 77        func collect(_ node: SyntaxNode) {
 78            for child in node.children {
 79                if child.kind == .heading { headingIndex += 1 }
 80                if child.kind == .timestamp, headingIndex >= 0 {
 81                    stampsByHeading[headingIndex, default: []].append(child.text)
 82                } else if child.kind != .block, child.kind != .planning {
 83                    collect(child)
 84                }
 85            }
 86        }
 87        collect(tree.root)
 88        func sectionStamps(_ index: Int) -> [String] { stampsByHeading[index] ?? [] }
 89
 90        var keywordCategory: String?
 91        var srcBodies: [Range<Int>] = []
 92        var planningLines: [Range<Int>] = []
 93        var timestampNodes: [Int] = []
 94        var propertyLines: [Range<Int>] = []
 95        func walk(_ node: SyntaxNode) {
 96            for child in node.children {
 97                switch child.kind {
 98                case .keyword:
 99                    let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines)
100                    if line.uppercased().hasPrefix("#+CATEGORY:") {
101                        keywordCategory = line.dropFirst("#+CATEGORY:".count).trimmingCharacters(in: .whitespaces)
102                    }
103                case .block:
104                    if DocumentModel.isSrcBlock(child) {
105                        let lines = child.range
106                        let firstEnd = ns.range(of: "\n", range: NSRange(location: lines.lowerBound, length: lines.count)).location
107                        let lastStart = ns.lineRange(for: NSRange(location: max(lines.lowerBound, lines.upperBound - 1), length: 0)).location
108                        if firstEnd != NSNotFound, firstEnd + 1 <= lastStart { srcBodies.append((firstEnd + 1)..<lastStart) }
109                    }
110                case .planning:
111                    planningLines.append(child.range)
112                case .clock:
113                    break
114                case .nodeProperty:
115                    propertyLines.append(child.range)
116                case .timestamp:
117                    if child.text.hasPrefix("<") { timestampNodes.append(child.offset) }
118                default:
119                    walk(child)
120                }
121            }
122        }
123        walk(tree.root)
124
125        let fileName = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
126        let fileCategory = keywordCategory ?? model.property("CATEGORY", of: nil)?.value ?? fileName
127        let fileTags = model.fileTags
128        let fileArchived = fileTags.contains("ARCHIVE")
129
130        // Archived and commented trees are skipped whole, as `org-agenda-skip` does.
131        var skipped: [Bool] = []
132        var headings: [Heading] = []
133        for (index, info) in model.headings.enumerated() {
134            let own = info.localTags.contains("ARCHIVE") || Self.isCommented(info.title)
135            skipped.append(fileArchived || own || (info.parent.map { skipped[$0] } ?? false))
136            let line = ns.substring(with: NSRange(location: info.headingRange.lowerBound, length: info.headingRange.count))
137            let head = line.replacingOccurrences(of: "^\\*+[ \\t]+", with: "", options: .regularExpression)
138                .trimmingCharacters(in: .newlines)
139
140            var itags: [String] = fileTags
141            for ancestor in model.ancestors(of: index) { itags += model.headings[ancestor].localTags }
142            var tags: [(String, Bool)] = itags.map { ($0, true) } + info.localTags.map { ($0, false) }
143            // `delete-dups` over the reversed list: the most local copy of a tag stays.
144            var seen: Set<String> = []
145            tags = tags.reversed().filter { seen.insert($0.0).inserted }.reversed()
146
147            var category = fileCategory
148            for layer in ([index] + model.ancestors(of: index).reversed()) {
149                if let value = model.headings[layer].properties.last(where: { $0.key.uppercased() == "CATEGORY" && !$0.additive })?.value {
150                    category = value
151                    break
152                }
153            }
154
155            var scheduled: (String, Int)?
156            var deadline: (String, Int)?
157            var planningValues: [String: String] = [:]
158            let afterHeading = info.headingRange.upperBound
159            if let planning = planningLines.first(where: { $0.lowerBound == afterHeading }) {
160                let planningText = ns.substring(with: NSRange(location: planning.lowerBound, length: planning.count))
161                let regex = try! NSRegularExpression(pattern: "\\b(SCHEDULED|DEADLINE): *<([^>]+)>")
162                let pns = planningText as NSString
163                for m in regex.matches(in: planningText, range: NSRange(location: 0, length: pns.length)) {
164                    let value = (pns.substring(with: m.range(at: 2)), planning.lowerBound + m.range(at: 2).location - 1)
165                    if pns.substring(with: m.range(at: 1)) == "SCHEDULED" { scheduled = scheduled ?? value } else { deadline = deadline ?? value }
166                }
167                let any = try! NSRegularExpression(pattern: "\\b(SCHEDULED|DEADLINE|CLOSED): *([\\[<]\(Self.tsInternal)[\\]>])")
168                for m in any.matches(in: planningText, range: NSRange(location: 0, length: pns.length)) {
169                    let key = pns.substring(with: m.range(at: 1))
170                    if planningValues[key] == nil { planningValues[key] = pns.substring(with: m.range(at: 2)) }
171                }
172            }
173            var properties: [String: String] = [:]
174            for property in info.properties {
175                let key = property.key.uppercased()
176                if property.additive, let value = properties[key] {
177                    properties[key] = value + " " + property.value
178                } else {
179                    properties[key] = property.value
180                }
181            }
182            let ownStamps = sectionStamps(index)
183            let isHabit = properties["STYLE"]?.trimmingCharacters(in: .whitespaces) == "habit"
184            var habit: Habit?
185            if isHabit, let scheduled {
186                let end = index + 1 < model.headings.count ? model.headings[index + 1].headingRange.lowerBound : ns.length
187                let entry = ns.substring(with: NSRange(location: info.headingRange.lowerBound, length: end - info.headingRange.lowerBound))
188                habit = Habit.parse(entry: entry, scheduled: scheduled.0, doneKeywords: settings.todoSequences.flatMap { $0.done.map(\.name) })
189            }
190            headings.append(Heading(
191                start: info.headingRange.lowerBound, level: info.level, skipped: skipped[index],
192                title: info.title.trimmingCharacters(in: .whitespaces), priority: info.priority, localTags: info.localTags,
193                properties: properties, planning: planningValues,
194                firstActive: ownStamps.first { $0.hasPrefix("<") }, firstInactive: ownStamps.first { $0.hasPrefix("[") },
195                line: line.trimmingCharacters(in: .newlines), head: head, todo: info.todo, isDone: info.isDone,
196                tags: tags, category: category, scheduled: scheduled, deadline: deadline, isHabit: isHabit, habit: habit
197            ))
198        }
199        self.headings = headings
200
201        let starts = model.headings.map(\.headingRange.lowerBound)
202        func heading(at offset: Int) -> Int? {
203            var low = 0
204            var high = starts.count
205            while low < high {
206                let mid = (low + high) / 2
207                if starts[mid] <= offset { low = mid + 1 } else { high = mid }
208            }
209            return low == 0 ? nil : low - 1
210        }
211        func restOfLine(_ offset: Int) -> String {
212            let line = ns.lineRange(for: NSRange(location: offset, length: 0))
213            return ns.substring(with: NSRange(location: offset, length: NSMaxRange(line) - offset)).trimmingCharacters(in: .newlines)
214        }
215
216        // Timestamps: those the parser sees as timestamp objects, and dates in property lines.
217        var stampOffsets = timestampNodes
218        let datePattern = try! NSRegularExpression(pattern: "<[0-9]{4}-[0-9]{2}-[0-9]{2}")
219        for line in propertyLines {
220            for m in datePattern.matches(in: text, range: NSRange(location: line.lowerBound, length: line.count)) {
221                stampOffsets.append(m.range.location)
222            }
223        }
224        var stamps: [Stamp] = []
225        for offset in stampOffsets.sorted() {
226            guard let owner = heading(at: offset), !skipped[owner] else { continue }
227            let rest = restOfLine(offset)
228            guard rest.range(of: "^<[0-9]{4}-[0-9]{2}-[0-9]{2}", options: .regularExpression) != nil else { continue }
229            if Self.inDateRange(offset, ns) { continue }
230            stamps.append(Stamp(offset: offset, heading: owner, rest: rest))
231        }
232        self.stamps = stamps
233
234        // Ranges: org scans the raw text, skipping comment lines, skipped trees and src blocks.
235        var blocks: [Block] = []
236        let range = try! NSRegularExpression(pattern: "<(\(Self.tsInternal))>--?-?<(\(Self.tsInternal))>")
237        for m in range.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
238            let point = NSMaxRange(m.range)
239            let line = ns.substring(with: ns.lineRange(for: NSRange(location: point, length: 0)))
240            if line.range(of: "^\\s*#( |$|\\n)", options: .regularExpression) != nil { continue }
241            guard let owner = heading(at: max(0, point - 1)), !skipped[owner] else { continue }
242            if srcBodies.contains(where: { $0.contains(point) }) { continue }
243            blocks.append(Block(start: ns.substring(with: m.range(at: 1)), end: ns.substring(with: m.range(at: 2)), marker: point, heading: owner))
244        }
245        self.blocks = blocks
246    }
247
248    /// `org-ts--internal-regexp`.
249    static let tsInternal = "[0-9]{4}-[0-9]{2}-[0-9]{2}(?: .*?)?"
250
251    /// `org-element` reads a title starting with `COMMENT` as a commented heading.
252    static func isCommented(_ title: String) -> Bool {
253        title == "COMMENT" || title.hasPrefix("COMMENT ")
254    }
255
256    /// `org-at-date-range-p` with point just after the date that starts at `offset`.
257    static func inDateRange(_ offset: Int, _ ns: NSString) -> Bool {
258        let point = offset + 11
259        let line = ns.lineRange(for: NSRange(location: offset, length: 0))
260        let regex = try! NSRegularExpression(pattern: "[\\[<](\(tsInternal))[\\]>]--?-?[\\[<](\(tsInternal))[\\]>]")
261        for m in regex.matches(in: ns as String, range: line) {
262            if m.range.location > point { break }
263            if NSMaxRange(m.range) >= point { return true }
264        }
265        return false
266    }
267}