krz/orgstar

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

Commit bd4320d167

bd4320d1678845713018fe747fe48d811914e731

parent: 2068474b3a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 06:13 UTC

Agenda engine

Port org-agenda-list's day entries: deadlines, scheduled items,
timestamps and ranges, item formatting, sorting, the time grid and
day headers. Timestamp objects follow org-element: any
org-ts-regexp-both match, and none inside link descriptions.

Ref plan docs/plans/2026-10-05-agenda.md

Layout: unified · split

Sources/OrgCore/Agenda/Agenda.swift added +421
@@ -0,0 +1,421 @@
1import Foundation
2
3public struct AgendaItem: Sendable, Equatable {
4 public enum Kind: String, Sendable {
5 case deadline
6 case upcomingDeadline = "upcoming-deadline"
7 case scheduled
8 case pastScheduled = "past-scheduled"
9 case timestamp
10 case block
11 case timeGrid
12 case currentTime
13 }
14
15 public let kind: Kind
16 public let path: String?
17 /// Start of the entry's heading line.
18 public let headingOffset: Int?
19 /// Where org's marker points: the timestamp, or the end of a range.
20 public let markerOffset: Int?
21 public let category: String
22 /// `HHMM`, when the item has a time of day.
23 public let timeOfDay: Int?
24 /// `14:00-15:00`, ` 9:00......`, or empty.
25 public let time: String
26 /// The leader: `Deadline: `, `Sched. 2x: `, `(1/3): `.
27 public let extra: String
28 /// The heading text as shown: TODO keyword, priority, title, without tags.
29 public let text: String
30 public let tags: [String]
31 public let todo: String?
32 public let isDone: Bool
33 public let urgency: Int
34 /// The line as org prints it, with one space before the tags.
35 public let line: String
36}
37
38public struct AgendaDay: Sendable, Equatable {
39 /// Emacs's absolute day number (`Days`).
40 public let day: Int
41 public let isToday: Bool
42 /// `Monday 5 October 2026 W41`.
43 public let header: String
44 public let items: [AgendaItem]
45}
46
47public struct AgendaOptions: Sendable, Equatable {
48 public var deadlineWarningDays = 14
49 public var gridTimes = [800, 1000, 1200, 1400, 1600, 1800, 2000]
50 public var gridTrailing = " ┄┄┄┄┄ "
51 public var gridLine = "┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄"
52 public var currentTimeLine = "← now ───────────────────────────────────────────────"
53
54 public init() {}
55
56 /// What Emacs shows without a graphical display.
57 public static let terminal: AgendaOptions = {
58 var options = AgendaOptions()
59 options.gridTrailing = "......"
60 options.gridLine = "----------------"
61 options.currentTimeLine = "now - - - - - - - - - - - - - - - - - - - - - - - - -"
62 return options
63 }()
64}
65
66/// `org-agenda-list`.
67public enum Agenda {
68 public static func list(
69 _ sources: [AgendaSource], start: Int, days: Int, today: Int, now: (hour: Int, minute: Int),
70 options: AgendaOptions = AgendaOptions()
71 ) -> [AgendaDay] {
72 (start..<(start + days)).map { day in
73 var items: [AgendaItem] = []
74 for source in sources { items += entries(source, day: day, today: today, options: options) }
75 let isToday = day == today
76 if (isToday || days == 1), items.contains(where: { $0.timeOfDay != nil }) {
77 items = grid(day: day, isToday: isToday, now: now, options: options) + items
78 }
79 return AgendaDay(day: day, isToday: isToday, header: header(day), items: sort(items))
80 }
81 }
82
83 /// `org-agenda-get-day-entries` with the default entry types.
84 static func entries(_ source: AgendaSource, day: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
85 let deadlines = self.deadlines(source, day: day, today: today, options: options)
86 return deadlines + scheduled(source, day: day, today: today, options: options) + blocks(source, day: day, options: options)
87 + timestamps(source, day: day, today: today, options: options)
88 }
89
90 // MARK: - Collectors
91
92 static func deadlines(_ source: AgendaSource, day current: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
93 let isToday = current == today
94 var items: [AgendaItem] = []
95 for heading in source.headings {
96 guard let (s, offset) = heading.deadline, let deadline = Days.absolute(of: s) else { continue }
97 let repeat_ = current <= today ? deadline : (Days.closest(s, to: current, prefer: .future) ?? deadline)
98 let diff = deadline - current
99 let warningDays = Days.warningDays(s, delay: false, defaultDays: options.deadlineWarningDays)
100 if current != deadline, current != repeat_ {
101 if !isToday { continue }
102 if deadline > current {
103 if diff > warningDays { continue }
104 } else if 10000 < -diff {
105 continue
106 }
107 }
108 if heading.isDone, deadline != current { continue }
109 let time: Dotime? = current != deadline && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline)
110 let extra: String
111 if isToday, deadline < today {
112 extra = String(format: "%2d d. ago: ", -diff)
113 } else if isToday, deadline > today {
114 extra = String(format: "In %3d d.: ", diff)
115 } else {
116 extra = "Deadline: "
117 }
118 let upcoming = isToday && deadline > today
119 items.append(format(
120 source, heading, kind: upcoming ? .upcomingDeadline : .deadline, marker: offset, extra: extra,
121 dotime: time, removing: nil, trailing: options.gridTrailing, urgency: { (isToday ? -diff : 0) + $0 }
122 ))
123 }
124 return items
125 }
126
127 static func scheduled(_ source: AgendaSource, day current: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
128 let isToday = current == today
129 var items: [AgendaItem] = []
130 for heading in source.headings {
131 guard let (s, offset) = heading.scheduled, let schedule = Days.absolute(of: s) else { continue }
132 let repeat_ = current <= today ? schedule : (Days.closest(s, to: current, prefer: .future) ?? schedule)
133 let diff = current - schedule
134 let past = schedule < today
135 let delay = Days.warningDays(s, delay: true, defaultDays: 0)
136 if (delay > 0 && diff < delay) || diff > 10000 || schedule > current
137 || (current != schedule && current != today && current != repeat_) {
138 continue
139 }
140 if heading.isDone, schedule != current { continue }
141 let time: Dotime? = current != schedule && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline)
142 let extra = isToday && past ? String(format: "Sched.%2dx: ", diff) : "Scheduled: "
143 items.append(format(
144 source, heading, kind: past ? .pastScheduled : .scheduled, marker: offset, extra: extra,
145 dotime: time, removing: nil, trailing: options.gridTrailing, urgency: { 99 + diff + $0 }
146 ))
147 }
148 return items
149 }
150
151 static func blocks(_ source: AgendaSource, day current: Int, options: AgendaOptions) -> [AgendaItem] {
152 var items: [AgendaItem] = []
153 for block in source.blocks {
154 guard let startDay = Days.absolute(of: block.start), let endDay = Days.absolute(of: block.end),
155 current >= startDay, endDay >= current else { continue }
156 let heading = source.headings[block.heading]
157 let extra = startDay == endDay ? "" : "(\(current - startDay + 1)/\(endDay - startDay + 1)): "
158 let dotime: Dotime?
159 if startDay == current, endDay == current {
160 dotime = .string("<\(block.start)>--<\(block.end)>")
161 } else if startDay == current {
162 dotime = .string("<\(block.start)>")
163 } else if endDay == current {
164 dotime = .string("<\(block.end)>")
165 } else {
166 dotime = nil
167 }
168 items.append(format(source, heading, kind: .block, marker: block.marker, extra: extra, dotime: dotime, removing: nil, trailing: options.gridTrailing, urgency: { $0 }))
169 }
170 return items
171 }
172
173 static let repeating = try! NSRegularExpression(pattern: "^<[0-9]+-[0-9]+-[0-9]+[^>\\n]+?\\+[0-9]+[hdwmy]>")
174 static let stampBoth = try! NSRegularExpression(pattern: "^[\\[<](\(AgendaSource.tsInternal))[\\]>]")
175 static let stampActive = try! NSRegularExpression(pattern: "<(\(AgendaSource.tsInternal))>")
176 static let stampAny = try! NSRegularExpression(pattern: "[\\[<](\(AgendaSource.tsInternal))[\\]>]")
177
178 static func timestamps(_ source: AgendaSource, day current: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
179 let date = Days.date(current)
180 let literal = String(format: "<%04d-%02d-%02d", date.year, date.month, date.day)
181 var items: [AgendaItem] = []
182 for stamp in source.stamps {
183 let rest = stamp.rest as NSString
184 let all = NSRange(location: 0, length: rest.length)
185 let timestamp: String
186 if stamp.rest.hasPrefix(literal) {
187 guard let m = stampBoth.firstMatch(in: stamp.rest, range: all) else { continue }
188 timestamp = rest.substring(with: m.range)
189 } else if let m = repeating.firstMatch(in: stamp.rest, range: all) {
190 let repeat_ = rest.substring(with: m.range)
191 guard let past = Days.closest(repeat_, to: current > today ? today : current, prefer: .past) else { continue }
192 let future = current <= today ? past : (Days.closest(repeat_, to: current, prefer: .future) ?? past)
193 if current != past, current != future { continue }
194 timestamp = repeat_
195 } else {
196 continue
197 }
198 let heading = source.headings[stamp.heading]
199 items.append(format(
200 source, heading, kind: .timestamp, marker: stamp.offset, extra: "",
201 dotime: .string(timestamp), removing: stampActive, trailing: options.gridTrailing, urgency: { $0 }
202 ))
203 }
204 return items
205 }
206
207 /// `" HH:MM"` in a planning timestamp: the time and everything after it.
208 static func timeIn(_ s: String) -> String? {
209 guard let r = s.range(of: " [012]?[0-9]:[0-9][0-9]", options: .regularExpression) else { return nil }
210 return String(s[s.index(after: r.lowerBound)...]) + " "
211 }
212
213 // MARK: - Formatting
214
215 enum Dotime {
216 case string(String)
217 /// Look for a time in the heading only.
218 case headline
219 }
220
221 static let tagGroup = try! NSRegularExpression(pattern: "[ \\t]+(:([\\p{L}\\p{N}_@#%:]+):)[ \\t]*$")
222 static let stampTime = try! NSRegularExpression(
223 pattern: "<([0-9]{4}-[0-9]{2}-[0-9]{2} +[\\p{L}\\p{N}_]+ +)([012][0-9]:[0-5][0-9])(-([012][0-9]:[0-5][0-9]))?[^\\n\\r>]*?>(--?<\\1([012][0-9]:[0-5][0-9])>)?"
224 )
225 static let plainTimePart = "((?<![\\p{L}\\p{N}_])[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))(?![\\p{L}\\p{N}_]))"
226 static let plainTime = try! NSRegularExpression(pattern: "\(plainTimePart)(--?\(plainTimePart))?")
227 static let timeOfDay = try! NSRegularExpression(
228 pattern: "(?<![\\p{L}\\p{N}_])([012]?[0-9])(?::([0-5][0-9])(am|pm)?|(am|pm))(?![\\p{L}\\p{N}_])",
229 options: .caseInsensitive
230 )
231
232 /// `org-agenda-format-item` with the default agenda prefix ` %i %-12:c%?-12t% s`, then
233 /// `org-agenda-highlight-todo`.
234 static func format(
235 _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int,
236 extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, urgency: (Int) -> Int
237 ) -> AgendaItem {
238 var txt = heading.head.trimmingCharacters(in: .whitespaces)
239 // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list.
240 let tags = heading.tags
241 if let m = tagGroup.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
242 txt = (txt as NSString).substring(to: m.range.location)
243 }
244 var tagString = ""
245 if !tags.isEmpty {
246 var haveInherited = tags[0].inherited
247 var parts: [String] = []
248 for tag in tags {
249 if haveInherited, !tag.inherited {
250 haveInherited = false
251 parts.append(":" + tag.name)
252 } else {
253 parts.append(tag.name)
254 }
255 }
256 tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":")
257 }
258
259 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing)
260 if let removing {
261 while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
262 txt = (txt as NSString).replacingCharacters(in: m.range, with: "")
263 }
264 }
265 let category = heading.category
266 let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12) + (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ")
267 txt = highlightTodo(txt, keywords: source.keywords)
268 let priority = Self.priority(prefix + txt, source.priorities)
269 return AgendaItem(
270 kind: kind, path: source.path, headingOffset: heading.start, markerOffset: marker, category: category,
271 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
272 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority),
273 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString)
274 )
275 }
276
277 /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the
278 /// text, and formats the `%t` field.
279 static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String) -> (timeOfDay: Int?, time: String) {
280 guard let dotime else { return (nil, "") }
281 let full = tagString.isEmpty ? txt : txt + " " + tagString
282 let withoutStamps = stampAny.stringByReplacingMatches(in: full, range: NSRange(location: 0, length: (full as NSString).length), withTemplate: "")
283 let containing: String
284 switch dotime {
285 case .string(let s): containing = s + withoutStamps
286 case .headline: containing = withoutStamps
287 }
288 guard let timeOfDay = time(of: containing) else { return (nil, "") }
289 let ns = containing as NSString
290 let all = NSRange(location: 0, length: ns.length)
291 var start: String?
292 var end: String?
293 if let m = stampTime.firstMatch(in: containing, range: all) {
294 start = ns.substring(with: m.range(at: 2))
295 let endGroup = m.range(at: 3).location != NSNotFound ? 4 : 6
296 if m.range(at: endGroup).location != NSNotFound { end = ns.substring(with: m.range(at: endGroup)) }
297 removeTime(ns.substring(with: m.range), from: &txt)
298 } else if let m = plainTime.firstMatch(in: containing, range: all) {
299 start = ns.substring(with: m.range(at: 1))
300 if m.range(at: 8).location != NSNotFound { end = ns.substring(with: m.range(at: 8)) }
301 removeTime(ns.substring(with: m.range), from: &txt)
302 }
303 let startText = start.flatMap(formattedTime).map { String(repeating: " ", count: max(0, 5 - $0.count)) + $0 }
304 let endText = end.flatMap(formattedTime)
305 let time: String
306 if let startText, let endText {
307 time = startText + "-" + endText
308 } else if let startText {
309 time = startText + trailing
310 } else {
311 time = ""
312 }
313 return (timeOfDay, time)
314 }
315
316 /// `org-agenda-remove-times-when-in-prefix`.
317 static func removeTime(_ timeString: String, from txt: inout String) {
318 let ns = txt as NSString
319 let regex = try! NSRegularExpression(pattern: NSRegularExpression.escapedPattern(for: timeString) + " *")
320 guard let m = regex.firstMatch(in: txt, range: NSRange(location: 0, length: ns.length)) else { return }
321 if NSMaxRange(m.range) < ns.length, ns.character(at: NSMaxRange(m.range)) == 93 { return }
322 txt = ns.replacingCharacters(in: m.range, with: "")
323 }
324
325 /// `org-get-time-of-day` as a number.
326 static func time(of s: String) -> Int? {
327 let ns = s as NSString
328 guard let m = timeOfDay.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return nil }
329 var hours = Int(ns.substring(with: m.range(at: 1)))!
330 let minutes = m.range(at: 2).location != NSNotFound ? Int(ns.substring(with: m.range(at: 2)))! : 0
331 let ampmRange = m.range(at: 3).location != NSNotFound ? m.range(at: 3) : m.range(at: 4)
332 if ampmRange.location != NSNotFound {
333 let am = ns.substring(with: ampmRange).lowercased() == "am"
334 hours = hours == 12 ? (am ? 0 : 12) : hours + (am ? 0 : 12)
335 }
336 return hours * 100 + minutes
337 }
338
339 /// `org-get-time-of-day` with `overtime`: `H:MM`, or `+H:MM` past midnight.
340 static func formattedTime(_ s: String) -> String? {
341 guard let value = time(of: s) else { return nil }
342 let hours = value / 100
343 let minutes = value % 100
344 if hours > 24 || (hours == 24 && minutes > 0) { return String(format: "+%d:%02d", hours - 24, minutes) }
345 return String(format: "%d:%02d", hours, minutes)
346 }
347
348 /// `format "%-Ns"`: pads to a display width.
349 static func pad(_ s: String, _ width: Int) -> String {
350 s + String(repeating: " ", count: max(0, width - displayWidth(s)))
351 }
352
353 /// `org-agenda-highlight-todo` with `org-agenda-todo-keyword-format` `%-1s`: the spaces
354 /// after a leading TODO keyword become one.
355 static func highlightTodo(_ txt: String, keywords: [String]) -> String {
356 let dots = txt.prefix { $0 == "." }
357 let rest = txt.dropFirst(dots.count)
358 for keyword in keywords.sorted(by: { $0.count > $1.count }) where rest.hasPrefix(keyword) {
359 let after = rest.dropFirst(keyword.count)
360 guard after.first == " " else { continue }
361 return dots + keyword + " " + after.drop { $0 == " " }
362 }
363 return txt
364 }
365
366 /// `org-get-priority`.
367 static func priority(_ s: String, _ priorities: Priorities) -> Int {
368 func value(_ p: String) -> Int {
369 p.allSatisfy(\.isNumber) ? Int(p)! : Int(p.unicodeScalars.first!.value)
370 }
371 let lowest = value(priorities.lowest)
372 if let r = s.range(of: "\\[#([A-Z]|[0-9]|[1-5][0-9]|6[0-4])\\]", options: .regularExpression) {
373 return 1000 * (lowest - value(String(s[r].dropFirst(2).dropLast())))
374 }
375 return 1000 * (lowest - value(priorities.default))
376 }
377
378 // MARK: - Grid, sorting, headers
379
380 /// `org-agenda-add-time-grid-maybe`.
381 static func grid(day: Int, isToday: Bool, now: (hour: Int, minute: Int), options: AgendaOptions) -> [AgendaItem] {
382 func item(_ kind: AgendaItem.Kind, _ text: String, _ dotime: String) -> AgendaItem {
383 var txt = text
384 let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing)
385 return AgendaItem(
386 kind: kind, path: nil, headingOffset: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay,
387 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0,
388 line: " " + pad("", 12) + pad(timed.time, 12) + txt
389 )
390 }
391 var new = options.gridTimes.reversed().map { t in
392 item(.timeGrid, options.gridLine, String(format: "%02d:%02d", t / 100, t % 100))
393 }
394 if isToday {
395 new.insert(item(.currentTime, options.currentTimeLine, String(format: "%02d:%02d ", now.hour, now.minute)), at: 0)
396 }
397 return new
398 }
399
400 /// `org-entries-lessp` with `(habit-down time-up urgency-down category-keep)`, stably.
401 static func sort(_ items: [AgendaItem]) -> [AgendaItem] {
402 items.enumerated().sorted { a, b in
403 let ta = a.element.timeOfDay ?? 9901
404 let tb = b.element.timeOfDay ?? 9901
405 if ta != tb { return ta < tb }
406 if a.element.urgency != b.element.urgency { return a.element.urgency > b.element.urgency }
407 return a.offset < b.offset
408 }.map(\.element)
409 }
410
411 static let dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]
412 static let monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
413
414 /// `org-agenda-format-date-aligned`.
415 public static func header(_ day: Int) -> String {
416 let date = Days.date(day)
417 let weekday = Days.weekday(day)
418 let week = weekday == 1 ? String(format: " W%02d", Days.isoWeek(day)) : ""
419 return pad(dayNames[weekday], 10) + String(format: " %2d ", date.day) + monthNames[date.month - 1] + String(format: " %4d", date.year) + week
420 }
421}
Sources/OrgCore/Agenda/AgendaSource.swift added +204
@@ -0,0 +1,204 @@
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 /// The line after the stars.
12 let head: String
13 let todo: String?
14 let isDone: Bool
15 /// Inherited tags first; the flag marks inherited ones.
16 let tags: [(name: String, inherited: Bool)]
17 let category: String
18 /// The planning timestamp without brackets, and the position of its `<`.
19 let scheduled: (raw: String, offset: Int)?
20 let deadline: (raw: String, offset: Int)?
21 }
22
23 struct Stamp: Sendable {
24 let offset: Int
25 let heading: Int
26 /// From the `<` to the end of the line.
27 let rest: String
28 }
29
30 struct Block: Sendable {
31 let start: String
32 let end: String
33 /// End of the range; org's marker sits there.
34 let marker: Int
35 let heading: Int
36 }
37
38 let headings: [Heading]
39 let stamps: [Stamp]
40 let blocks: [Block]
41 /// The file's TODO keywords, active and done.
42 let keywords: [String]
43 let priorities: Priorities
44
45 public init(path: String, text: String, defaults: OrgSettings = .default) {
46 self.path = path
47 let tree = OrgParser.parse(text, defaults: defaults)
48 let model = DocumentModel(tree: tree)
49 let ns = text as NSString
50 let settings = tree.settings
51 keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) }
52 priorities = settings.priorities
53
54 var keywordCategory: String?
55 var srcBodies: [Range<Int>] = []
56 var planningLines: [Range<Int>] = []
57 var timestampNodes: [Int] = []
58 var propertyLines: [Range<Int>] = []
59 func walk(_ node: SyntaxNode) {
60 for child in node.children {
61 switch child.kind {
62 case .keyword:
63 let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines)
64 if line.uppercased().hasPrefix("#+CATEGORY:") {
65 keywordCategory = line.dropFirst("#+CATEGORY:".count).trimmingCharacters(in: .whitespaces)
66 }
67 case .block:
68 if DocumentModel.isSrcBlock(child) {
69 let lines = child.range
70 let firstEnd = ns.range(of: "\n", range: NSRange(location: lines.lowerBound, length: lines.count)).location
71 let lastStart = ns.lineRange(for: NSRange(location: max(lines.lowerBound, lines.upperBound - 1), length: 0)).location
72 if firstEnd != NSNotFound, firstEnd + 1 <= lastStart { srcBodies.append((firstEnd + 1)..<lastStart) }
73 }
74 case .planning:
75 planningLines.append(child.range)
76 case .clock:
77 break
78 case .nodeProperty:
79 propertyLines.append(child.range)
80 case .timestamp:
81 if child.text.hasPrefix("<") { timestampNodes.append(child.offset) }
82 default:
83 walk(child)
84 }
85 }
86 }
87 walk(tree.root)
88
89 let fileName = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
90 let fileCategory = keywordCategory ?? model.property("CATEGORY", of: nil)?.value ?? fileName
91 let fileTags = model.fileTags
92 let fileArchived = fileTags.contains("ARCHIVE")
93
94 // Archived and commented trees are skipped whole, as `org-agenda-skip` does.
95 var skipped: [Bool] = []
96 var headings: [Heading] = []
97 for (index, info) in model.headings.enumerated() {
98 let own = info.localTags.contains("ARCHIVE") || Self.isCommented(info.title)
99 skipped.append(fileArchived || own || (info.parent.map { skipped[$0] } ?? false))
100 let line = ns.substring(with: NSRange(location: info.headingRange.lowerBound, length: info.headingRange.count))
101 let head = line.replacingOccurrences(of: "^\\*+[ \\t]+", with: "", options: .regularExpression)
102 .trimmingCharacters(in: .newlines)
103
104 var itags: [String] = fileTags
105 for ancestor in model.ancestors(of: index) { itags += model.headings[ancestor].localTags }
106 var tags: [(String, Bool)] = itags.map { ($0, true) } + info.localTags.map { ($0, false) }
107 // `delete-dups` over the reversed list: the most local copy of a tag stays.
108 var seen: Set<String> = []
109 tags = tags.reversed().filter { seen.insert($0.0).inserted }.reversed()
110
111 var category = fileCategory
112 for layer in ([index] + model.ancestors(of: index).reversed()) {
113 if let value = model.headings[layer].properties.last(where: { $0.key.uppercased() == "CATEGORY" && !$0.additive })?.value {
114 category = value
115 break
116 }
117 }
118
119 var scheduled: (String, Int)?
120 var deadline: (String, Int)?
121 let afterHeading = info.headingRange.upperBound
122 if let planning = planningLines.first(where: { $0.lowerBound == afterHeading }) {
123 let planningText = ns.substring(with: NSRange(location: planning.lowerBound, length: planning.count))
124 let regex = try! NSRegularExpression(pattern: "\\b(SCHEDULED|DEADLINE): *<([^>]+)>")
125 let pns = planningText as NSString
126 for m in regex.matches(in: planningText, range: NSRange(location: 0, length: pns.length)) {
127 let value = (pns.substring(with: m.range(at: 2)), planning.lowerBound + m.range(at: 2).location - 1)
128 if pns.substring(with: m.range(at: 1)) == "SCHEDULED" { scheduled = scheduled ?? value } else { deadline = deadline ?? value }
129 }
130 }
131 headings.append(Heading(
132 start: info.headingRange.lowerBound, head: head, todo: info.todo, isDone: info.isDone,
133 tags: tags, category: category, scheduled: scheduled, deadline: deadline
134 ))
135 }
136 self.headings = headings
137
138 let starts = model.headings.map(\.headingRange.lowerBound)
139 func heading(at offset: Int) -> Int? {
140 var low = 0
141 var high = starts.count
142 while low < high {
143 let mid = (low + high) / 2
144 if starts[mid] <= offset { low = mid + 1 } else { high = mid }
145 }
146 return low == 0 ? nil : low - 1
147 }
148 func restOfLine(_ offset: Int) -> String {
149 let line = ns.lineRange(for: NSRange(location: offset, length: 0))
150 return ns.substring(with: NSRange(location: offset, length: NSMaxRange(line) - offset)).trimmingCharacters(in: .newlines)
151 }
152
153 // Timestamps: those the parser sees as timestamp objects, and dates in property lines.
154 var stampOffsets = timestampNodes
155 let datePattern = try! NSRegularExpression(pattern: "<[0-9]{4}-[0-9]{2}-[0-9]{2}")
156 for line in propertyLines {
157 for m in datePattern.matches(in: text, range: NSRange(location: line.lowerBound, length: line.count)) {
158 stampOffsets.append(m.range.location)
159 }
160 }
161 var stamps: [Stamp] = []
162 for offset in stampOffsets.sorted() {
163 guard let owner = heading(at: offset), !skipped[owner] else { continue }
164 let rest = restOfLine(offset)
165 guard rest.range(of: "^<[0-9]{4}-[0-9]{2}-[0-9]{2}", options: .regularExpression) != nil else { continue }
166 if Self.inDateRange(offset, ns) { continue }
167 stamps.append(Stamp(offset: offset, heading: owner, rest: rest))
168 }
169 self.stamps = stamps
170
171 // Ranges: org scans the raw text, skipping comment lines, skipped trees and src blocks.
172 var blocks: [Block] = []
173 let range = try! NSRegularExpression(pattern: "<(\(Self.tsInternal))>--?-?<(\(Self.tsInternal))>")
174 for m in range.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
175 let point = NSMaxRange(m.range)
176 let line = ns.substring(with: ns.lineRange(for: NSRange(location: point, length: 0)))
177 if line.range(of: "^\\s*#( |$|\\n)", options: .regularExpression) != nil { continue }
178 guard let owner = heading(at: max(0, point - 1)), !skipped[owner] else { continue }
179 if srcBodies.contains(where: { $0.contains(point) }) { continue }
180 blocks.append(Block(start: ns.substring(with: m.range(at: 1)), end: ns.substring(with: m.range(at: 2)), marker: point, heading: owner))
181 }
182 self.blocks = blocks
183 }
184
185 /// `org-ts--internal-regexp`.
186 static let tsInternal = "[0-9]{4}-[0-9]{2}-[0-9]{2}(?: .*?)?"
187
188 /// `org-element` reads a title starting with `COMMENT` as a commented heading.
189 static func isCommented(_ title: String) -> Bool {
190 title == "COMMENT" || title.hasPrefix("COMMENT ")
191 }
192
193 /// `org-at-date-range-p` with point just after the date that starts at `offset`.
194 static func inDateRange(_ offset: Int, _ ns: NSString) -> Bool {
195 let point = offset + 11
196 let line = ns.lineRange(for: NSRange(location: offset, length: 0))
197 let regex = try! NSRegularExpression(pattern: "[\\[<](\(tsInternal))[\\]>]--?-?[\\[<](\(tsInternal))[\\]>]")
198 for m in regex.matches(in: ns as String, range: line) {
199 if m.range.location > point { break }
200 if NSMaxRange(m.range) >= point { return true }
201 }
202 return false
203 }
204}
Sources/OrgCore/Agenda/Days.swift added +144
@@ -0,0 +1,144 @@
1import Foundation
2
3/// Calendar days as Emacs numbers them (`calendar-absolute-from-gregorian`): 1 is
4/// 1 January of year 1, proleptic Gregorian.
5public enum Days {
6 /// Out-of-range days and months roll over, as with `encode-time`.
7 public static func absolute(year: Int, month: Int, day: Int) -> Int {
8 let monthIndex = month - 1
9 let y = year + Int((Double(monthIndex) / 12).rounded(.down))
10 let m = monthIndex - 12 * Int((Double(monthIndex) / 12).rounded(.down)) + 1
11 // Days from civil (Howard Hinnant), with 1970-01-01 = 0.
12 let yy = m <= 2 ? y - 1 : y
13 let era = (yy >= 0 ? yy : yy - 399) / 400
14 let yoe = yy - era * 400
15 let doy = (153 * (m > 2 ? m - 3 : m + 9) + 2) / 5
16 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy
17 return era * 146097 + doe - 719468 + 719163 + day - 1
18 }
19
20 public static func date(_ absolute: Int) -> (year: Int, month: Int, day: Int) {
21 let z = absolute - 719163 + 719468
22 let era = (z >= 0 ? z : z - 146096) / 146097
23 let doe = z - era * 146097
24 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365
25 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100)
26 let mp = (5 * doy + 2) / 153
27 let day = doy - (153 * mp + 2) / 5 + 1
28 let month = mp < 10 ? mp + 3 : mp - 9
29 return (yoe + era * 400 + (month <= 2 ? 1 : 0), month, day)
30 }
31
32 /// 0 is Sunday, as `calendar-day-of-week`.
33 public static func weekday(_ absolute: Int) -> Int {
34 ((absolute % 7) + 7) % 7
35 }
36
37 /// `org-days-to-iso-week`.
38 public static func isoWeek(_ absolute: Int) -> Int {
39 let monday = absolute - (weekday(absolute) + 6) % 7
40 let thursday = monday + 3
41 let year = date(thursday).year
42 return (thursday - Days.absolute(year: year, month: 1, day: 1)) / 7 + 1
43 }
44
45 /// The day of a `Date` in `calendar`'s time zone.
46 public static func absolute(_ date: Date, calendar: Calendar) -> Int {
47 let c = calendar.dateComponents([.year, .month, .day], from: date)
48 return absolute(year: c.year!, month: c.month!, day: c.day!)
49 }
50
51 static let dateRegex = try! NSRegularExpression(pattern: "([0-9]{4})-([0-9]{2})-([0-9]{2})")
52 static let timeRegex = try! NSRegularExpression(pattern: "([0-9]{1,2}):([0-9]{2})")
53
54 /// `time-to-days` of `org-time-string-to-time`: the first date in the string.
55 static func absolute(of string: String) -> Int? {
56 let ns = string as NSString
57 guard let m = dateRegex.firstMatch(in: string, range: NSRange(location: 0, length: ns.length)) else { return nil }
58 func int(_ i: Int) -> Int { Int(ns.substring(with: m.range(at: i)))! }
59 return absolute(year: int(1), month: int(2), day: int(3))
60 }
61
62 /// The hour of `org-parse-time-string`, 0 when there is none.
63 static func hour(of string: String) -> Int {
64 let ns = string as NSString
65 guard let date = dateRegex.firstMatch(in: string, range: NSRange(location: 0, length: ns.length)) else { return 0 }
66 let rest = NSRange(location: NSMaxRange(date.range), length: ns.length - NSMaxRange(date.range))
67 // org-ts-regexp0: a day name may sit between the date and the time.
68 let pattern = try! NSRegularExpression(pattern: "^( +[^\\]+0-9>\\r\\n -]+)?( +([0-9]{1,2}):([0-9]{2}))")
69 guard let m = pattern.firstMatch(in: string, range: rest) else { return 0 }
70 return Int(ns.substring(with: m.range(at: 3)))!
71 }
72
73 public enum Prefer { case past, future, nearest }
74
75 static let repeatRegex = try! NSRegularExpression(pattern: "\\+([0-9]+)([hdwmy])")
76
77 /// `org-closest-date`: the occurrence of a repeating timestamp `start` closest to `current`.
78 /// Timestamps without a repeater give their own day.
79 static func closest(_ start: String, to current: Int, prefer: Prefer) -> Int? {
80 guard let sday = absolute(of: start) else { return nil }
81 let ns = start as NSString
82 guard let m = repeatRegex.firstMatch(in: start, range: NSRange(location: 0, length: ns.length)) else { return sday }
83 let value = Int(ns.substring(with: m.range(at: 1)))!
84 let unit = ns.substring(with: m.range(at: 2))
85 if value == 0 { return sday }
86 let cday = current
87 if cday <= sday { return sday }
88 let base = date(sday)
89 let target = date(cday)
90 var n1: Int
91 var n2: Int
92 switch unit {
93 case "h":
94 let missing = mod(24 * (cday - sday) - hour(of: start), value)
95 n1 = missing == 0 ? cday : cday - (1 + missing / 24)
96 n2 = cday + (value - missing) / 24
97 case "d", "w":
98 let step = unit == "w" ? 7 * value : value
99 n1 = sday + step * ((cday - sday) / step)
100 n2 = n1 + step
101 case "m":
102 func add(_ d: (year: Int, month: Int, day: Int), _ n: Int) -> (year: Int, month: Int, day: Int) {
103 (d.year, d.month + n, d.day)
104 }
105 let months = ((12 * (target.year - base.year) + (target.month - base.month) + (target.day > base.day ? 0 : -1)) / value) * value
106 let before = add(base, months)
107 n1 = absolute(year: before.year, month: before.month, day: before.day)
108 let after = add(before, value)
109 n2 = absolute(year: after.year, month: after.month, day: after.day)
110 default:
111 let later = target.month > base.month || (target.month == base.month && target.day > base.day)
112 let years = ((target.year - base.year - (later ? 0 : 1)) / value) * value
113 n1 = absolute(year: base.year + years, month: base.month, day: base.day)
114 n2 = absolute(year: base.year + years + value, month: base.month, day: base.day)
115 }
116 switch prefer {
117 case .past: return cday == n2 ? n2 : n1
118 case .future: return cday == n1 ? n1 : n2
119 case .nearest: return abs(cday - n1) > abs(cday - n2) ? n2 : n1
120 }
121 }
122
123 private static func mod(_ a: Int, _ b: Int) -> Int { ((a % b) + b) % b }
124
125 static let wdaysRegex = try! NSRegularExpression(pattern: "-([0-9]+)([hdwmy])(\\z|>| )")
126
127 /// `org-get-wdays`: the warning days of a deadline, or with `delay` the delay days of a
128 /// scheduled timestamp.
129 static func warningDays(_ stamp: String, delay: Bool, defaultDays: Int) -> Int {
130 let tv = defaultDays
131 if (delay && tv < 0) || (!delay && tv <= 0) { return -tv }
132 let ns = stamp as NSString
133 guard let m = wdaysRegex.firstMatch(in: stamp, range: NSRange(location: 0, length: ns.length)) else { return tv }
134 let n = Double(ns.substring(with: m.range(at: 1)))!
135 let factor: Double = switch ns.substring(with: m.range(at: 2)) {
136 case "d": 1
137 case "w": 7
138 case "m": 30.4
139 case "y": 365.25
140 default: 0.041667
141 }
142 return Int((n * factor).rounded(.down))
143 }
144}
Sources/OrgCore/Parser/Inline.swift +2 −2
@@ -177,10 +177,10 @@ struct InlineScanner {
177177 case "[":
178178 if !inLink, let link = bracketLink(i, limit) { return link }
179179 if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) }
180 if let end = scanTimestamp(chars, at: i, limit: limit)?.end { return .object(.timestamp, i..<end) }
180 if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) }
181181 if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) }
182182 case "<":
183 if let end = scanTimestamp(chars, at: i, limit: limit)?.end { return .object(.timestamp, i..<end) }
183 if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) }
184184 if let end = target(i, limit) { return .object(.target, i..<end) }
185185 if !inLink, let end = angleLink(i, limit) { return .object(.link, i..<end) }
186186 case "{":
Sources/OrgCore/Timestamp.swift +31
@@ -61,6 +61,37 @@ func scanTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (sta
6161 return first
6262}
6363
64/// The end of the timestamp object at `start`. Org reads anything matching
65/// `org-ts-regexp-both` as one, `<2026-10-07 Wed 9am>` included, so a timestamp `parse` rejects
66/// is still an object.
67func timestampEnd(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> Int? {
68 if let strict = scanTimestamp(chars, at: start, limit: limit) { return strict.end }
69 guard let first = lenientTimestampEnd(chars, at: start, limit: limit) else { return nil }
70 if first + 2 < limit, chars[first] == "-", chars[first + 1] == "-",
71 let second = lenientTimestampEnd(chars, at: first + 2, limit: limit) {
72 return second
73 }
74 return first
75}
76
77/// `[[<]YYYY-MM-DD( .*?)?[]>]`.
78private func lenientTimestampEnd(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> Int? {
79 guard start + 11 < limit, chars[start] == "<" || chars[start] == "[" else { return nil }
80 for (k, c) in "dddd-dd-dd".unicodeScalars.enumerated() {
81 let actual = chars[start + 1 + k]
82 guard c == "d" ? isASCIIDigit(actual) : actual == c else { return nil }
83 }
84 var j = start + 11
85 if chars[j] == "]" || chars[j] == ">" { return j + 1 }
86 guard chars[j] == " " else { return nil }
87 j += 1
88 while j < limit, chars[j] != "\n" {
89 if chars[j] == "]" || chars[j] == ">" { return j + 1 }
90 j += 1
91 }
92 return nil
93}
94
6495/// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive.
6596private func scanSingleTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
6697 guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil }
Tests/OrgCoreTests/AgendaTests.swift added +241
@@ -0,0 +1,241 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// Runs `org-agenda-list` over files in `emacs -Q --batch` with the clock frozen.
6enum AgendaOracle {
7 struct Line: Codable {
8 var line: String
9 var type: String?
10 var category: String?
11 var tod: Int?
12 var extra: String?
13 var urgency: Int?
14 var file: String?
15 var pos: Int?
16 }
17
18 struct Day: Codable {
19 var header: String
20 var items: [Line]
21 }
22
23 static let keywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"
24
25 static func script(files: [String], start: String, span: Int, seconds: Int) -> String {
26 let fileList = files.map { "\"\($0)\"" }.joined(separator: " ")
27 let words = keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ")
28 return #"""
29 ;;; -*- lexical-binding: t -*-
30 (require 'org)
31 (require 'org-agenda)
32 (require 'json)
33 (setq org-todo-keywords '((sequence \#(words)))
34 org-agenda-files '(\#(fileList)))
35 (add-to-list 'auto-mode-alist '("\\.org_archive\\'" . org-mode))
36 (defun orgstar-dump ()
37 (let (days header items)
38 (goto-char (point-min))
39 (while (not (eobp))
40 (let* ((p (line-beginning-position))
41 (line (buffer-substring-no-properties p (line-end-position)))
42 (hd (get-text-property p 'org-hd-marker)))
43 (cond
44 ((get-text-property p 'org-agenda-date-header)
45 (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days))
46 (setq header line items nil))
47 (header
48 (push `((line . ,line)
49 (type . ,(get-text-property p 'type))
50 (category . ,(get-text-property p 'org-category))
51 (tod . ,(get-text-property p 'time-of-day))
52 (extra . ,(get-text-property p 'extra))
53 (urgency . ,(get-text-property p 'urgency))
54 (file . ,(and hd (buffer-file-name (marker-buffer hd))))
55 (pos . ,(and hd (marker-position hd))))
56 items))))
57 (forward-line 1))
58 (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days))
59 (vconcat (nreverse days))))
60 \#(frozenClock(seconds, """
61 (progn
62 (org-agenda-list nil "\(start)" \(span))
63 (let ((result (with-current-buffer org-agenda-buffer-name (orgstar-dump))))
64 (let ((coding-system-for-write 'utf-8-unix))
65 (with-temp-file (getenv "ORACLE_OUTPUT")
66 (insert (json-encode result))))))
67 """))
68 """#
69 }
70
71 static func run(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int, minute: Int) throws -> [Day] {
72 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-agenda-\(UUID().uuidString)")
73 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
74 defer { try? FileManager.default.removeItem(at: folder) }
75 var paths: [String] = []
76 for file in files {
77 let url = folder.appendingPathComponent(file.name)
78 try file.text.write(to: url, atomically: true, encoding: .utf8)
79 paths.append(url.resolvingSymlinksInPath().path)
80 }
81 let date = Days.date(start)
82 let seconds = (today - 719163) * 86400 + hour * 3600 + minute * 60
83 let scriptURL = folder.appendingPathComponent("agenda.el")
84 let output = folder.appendingPathComponent("output.json")
85 try script(files: paths, start: String(format: "%04d-%02d-%02d", date.year, date.month, date.day), span: span, seconds: seconds)
86 .write(to: scriptURL, atomically: true, encoding: .utf8)
87 let log = try EmacsOracle.run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"])
88 guard let data = try? Data(contentsOf: output) else { throw OracleError(log: log) }
89 let days = try JSONDecoder().decode([Day].self, from: data)
90 // Positions as UTF-16 offsets, files by name.
91 return days.map { day in
92 Day(header: day.header, items: day.items.map { line in
93 var line = line
94 if let file = line.file, let pos = line.pos, let index = paths.firstIndex(of: URL(fileURLWithPath: file).resolvingSymlinksInPath().path) {
95 line.file = files[index].name
96 line.pos = EmacsOracle.offset(pos, in: files[index].text)
97 }
98 return line
99 })
100 }
101 }
102
103 struct OracleError: Error { let log: String }
104
105 /// Tags realigned to one space, as their column depends on the window.
106 static func normalize(_ line: String) -> String {
107 line.replacingOccurrences(of: "[ \\t]+(:[^ \\t]+:)[ \\t]*$", with: " $1", options: .regularExpression)
108 }
109
110 static func compare(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30) throws {
111 let emacs = try run(files: files, start: start, span: span, today: today, hour: hour, minute: minute)
112 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
113 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }
114 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: .terminal)
115 #expect(ours.count == emacs.count)
116 for (o, e) in zip(ours, emacs) {
117 #expect(o.header == e.header)
118 let theirs = e.items.map { item in
119 "\(normalize(item.line)) | \(item.type ?? "-") \(item.category ?? "") \(item.tod.map(String.init) ?? "-") [\(item.extra ?? "")] \(item.urgency ?? 0) \(item.file ?? "-")@\(item.pos.map(String.init) ?? "-")"
120 }
121 let mine = o.items.map { item in
122 let type: String = switch item.kind {
123 case .timeGrid, .currentTime: "-"
124 default: item.kind.rawValue
125 }
126 return "\(normalize(item.line)) | \(type) \(item.category) \(item.timeOfDay.map(String.init) ?? "-") [\(item.extra)] \(item.urgency) \(item.path ?? "-")@\(item.headingOffset.map(String.init) ?? "-")"
127 }
128 #expect(mine == theirs, "\(e.header) today \(Days.date(today))")
129 }
130 }
131}
132
133struct AgendaTests {
134 @Test func days() {
135 #expect(Days.absolute(year: 1970, month: 1, day: 1) == 719163)
136 #expect(Days.absolute(year: 2026, month: 10, day: 5) - Days.absolute(year: 2026, month: 9, day: 30) == 5)
137 #expect(Days.weekday(Days.absolute(year: 2026, month: 10, day: 5)) == 1)
138 #expect(Days.isoWeek(Days.absolute(year: 2026, month: 10, day: 5)) == 41)
139 #expect(Days.isoWeek(Days.absolute(year: 2027, month: 1, day: 1)) == 53)
140 let d = Days.date(Days.absolute(year: 2024, month: 2, day: 29))
141 #expect(d.year == 2024 && d.month == 2 && d.day == 29)
142 #expect(Days.date(Days.absolute(year: 2026, month: 2, day: 31)) == (2026, 3, 3))
143 #expect(Days.date(Days.absolute(year: 2026, month: 13, day: 1)) == (2027, 1, 1))
144 }
145
146 static let synthetic = """
147 #+TODO: TODO NEXT | DONE
148 #+FILETAGS: :ft:
149 Before the first heading <2026-10-05 Mon>.
150 * TODO [#A] Pay rent :home:
151 SCHEDULED: <2026-10-01 Thu>
152 * Meeting 10:00 with Bob
153 <2026-10-05 Mon 14:00-15:00>
154 * DONE Old
155 DEADLINE: <2026-10-03 Sat>
156 * NEXT Report :work:
157 DEADLINE: <2026-10-12 Mon -3d>
158 * NEXT Soon
159 DEADLINE: <2026-10-09 Fri>
160 * TODO Overdue
161 DEADLINE: <2026-09-20 Sun 08:15>
162 * Trip
163 <2026-10-04 Sun>--<2026-10-07 Wed>
164 * Same day range
165 <2026-10-06 Tue 10:00>--<2026-10-06 Tue 12:30>
166 * Gym
167 <2026-09-01 Tue 07:00 +1w>
168 ** Child
169 :PROPERTIES:
170 :CATEGORY: kid
171 :END:
172 SCHEDULED: <2026-10-06 Tue>
173 *** Grandchild :x:
174 <2026-10-07 Wed 9am>
175 * TODO Water plants
176 SCHEDULED: <2026-09-28 Mon .+2d>
177 * TODO Monthly
178 SCHEDULED: <2026-08-31 Mon +1m>
179 * TODO Yearly
180 DEADLINE: <2025-10-08 Wed +1y>
181 * TODO Delayed
182 SCHEDULED: <2026-10-02 Fri -2d>
183 * Archived :ARCHIVE:
184 <2026-10-05 Mon>
185 * COMMENT Commented
186 <2026-10-05 Mon>
187 * Blocks
188 #+begin_src sh
189 echo <2026-10-05 Mon>
190 #+end_src
191 # <2026-10-05 Mon>
192 =<2026-10-06 Tue>= and [[https://example.com][<2026-10-06 Tue>]]
193 * [#C] Low priority <2026-10-06 Tue>
194 * TODO Late meeting 23:30
195 SCHEDULED: <2026-10-05 Mon>
196 * Props
197 :PROPERTIES:
198 :WHEN: <2026-10-06 Tue 16:00>
199 :END:
200 * TODO Spaces :a:b:
201 <2026-10-06 Tue 11:00>
202 * Ünïcode tïtle :tag:
203 <2026-10-05 Mon 08:00>
204 * Range with times <2026-10-05 Mon 10:00-11:00>
205 * Hourly
206 <2026-10-04 Sun 22:00 +12h>
207 """
208
209 static let second = """
210 #+CATEGORY: other
211 * WAIT Second file
212 DEADLINE: <2026-10-05 Mon>
213 * Two stamps
214 <2026-10-05 Mon 13:00> and <2026-10-06 Tue>
215 """
216
217 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
218 func matchesEmacs() throws {
219 let files = [("a.org", Self.synthetic), ("b.org", Self.second)]
220 for today in [(2026, 10, 5), (2026, 10, 1), (2026, 10, 8), (2026, 9, 29)] {
221 let day = Days.absolute(year: today.0, month: today.1, day: today.2)
222 try AgendaOracle.compare(files: files, start: day - 3, span: 10, today: day)
223 }
224 let day = Days.absolute(year: 2026, month: 10, day: 6)
225 try AgendaOracle.compare(files: files, start: day, span: 1, today: day)
226 try AgendaOracle.compare(files: files, start: day + 1, span: 1, today: day)
227 }
228
229 /// `ORGSTAR_ORACLE_CORPUS=<folder>`: the top-level files of the folder, ten days around
230 /// two dates.
231 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
232 func matchesEmacsOnACorpus() throws {
233 let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!)
234 let files = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)
235 .filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path }
236 .compactMap { url in (try? String(contentsOf: url, encoding: .utf8)).map { (url.lastPathComponent, $0) } }
237 for today in [Days.absolute(Date(), calendar: .current), Days.absolute(year: 2025, month: 3, day: 14)] {
238 try AgendaOracle.compare(files: files, start: today - 3, span: 10, today: today)
239 }
240 }
241}
docs/plans/2026-10-05-agenda.md added +36
@@ -0,0 +1,36 @@
1# Agenda Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** The day/week agenda (`org-agenda-list`): which entries appear on which day, with org's leaders, times, categories, tags, sort order and time grid, checked line by line against Emacs; an agenda window in the app that jumps to entries.
6
7**Architecture:** `OrgCore/Agenda` ports the collectors of org-agenda.el: `org-agenda-get-deadlines`, `-get-scheduled`, `-get-timestamps`, `-get-blocks`, `org-agenda-format-item` with the default prefix ` %i %-12:c%?-12t% s`, `org-agenda-highlight-todo`, `org-entries-lessp` with `(habit-down time-up urgency-down category-keep)`, `org-agenda-add-time-grid-maybe`, and the day headers of `org-agenda-format-date-aligned`. Repeaters go through `org-closest-date`. An `AgendaSource` is built per file from its text and tree (headings, planning, timestamps with positions, categories, skip ranges); the app caches sources by file hash and replaces them with the open session's text, which is the overlay the design asks of the index. The index rows don't carry positions or raw timestamp text, so the agenda reads sources rather than SQL.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle.
10
11**Spec:** `docs/design.md`, "Phase 3", "Workspace, storage and index".
12
13## Global Constraints
14
15- Oracle: `org-agenda-list` over temp files with the clock frozen; each line compared as text (tags realigned to one space) plus `type`, `org-category`, `time-of-day`, `extra`, `urgency` and the heading marker.
16- Org defaults otherwise: 14 warning days, deadlines and scheduled items shown when done, future repeats shown, overdue items only on today, no habits (phase 6), no diary or diary sexps.
17
18## Defaults chosen (user may change)
19
20- Doom's view: 10 days starting 3 days before today.
21- Agenda files: the top-level `.org` files of each workspace root, as Doom's `org-agenda-files` is the org directory.
22
23---
24
25### Task 1: Engine
26
27- [ ] Absolute days, ISO weeks; `org-closest-date`; `org-get-wdays`.
28- [ ] `AgendaSource` from text; collectors; formatting; sorting; time grid; day headers.
29- [ ] Oracle over synthetic files (repeaters of every unit, warnings, delays, ranges, times in headings, priorities, categories, inherited tags, archived and commented trees, src blocks, done items) for several "today"s, and over the three corpora for a 10-day span.
30- [ ] Commit "Agenda engine".
31
32### Task 2: App
33
34- [ ] Agenda window (⌘⇧A, palette): day headers, items with category, time, leader, TODO, title, tags; previous/next span, today; click or RET jumps to the entry; refreshes on saves and open-document edits.
35- [ ] Settings: span, start offset, agenda folders.
36- [ ] Commit "Agenda view".