Sources/OrgCore/Agenda/AgendaSource.swift
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}