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