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