Agenda !21

merged merged by cmc on 2026-10-05 06:17 UTC · krz/orgstar:phase3-agenda into main

16 files changed, +1462 −4

Layout: unified · split

Sources/OrgApp/AgendaModel.swift added +93
@@ -0,0 +1,93 @@
1import Foundation
2import Observation
3import OrgCore
4import OrgIndex
5
6/// The day/week agenda over the workspace's agenda files. Sources are read from disk and
7/// cached by modification date and size; the open file's come from its buffer, so the agenda
8/// shows unsaved edits.
9@MainActor
10@Observable
11public final class AgendaModel {
12 public private(set) var days: [AgendaDay] = []
13 /// The first day shown, as `Days` numbers it; nil follows today.
14 public var start: Int?
15 public var span = 10
16 /// Where the view starts relative to today, as Doom's `org-agenda-start-day "-3d"`.
17 public var startOffset = -3
18 /// Files below the top level of each folder are agenda files too.
19 public var includeSubfolders = false
20 public var options = AgendaOptions()
21 public private(set) var isComputing = false
22
23 private let workspace: WorkspaceModel
24 @ObservationIgnored private var cache: [String: (mtime: Date, size: Int, source: AgendaSource)] = [:]
25 @ObservationIgnored private var generation = 0
26
27 public init(workspace: WorkspaceModel) {
28 self.workspace = workspace
29 }
30
31 /// Org files directly in each folder (or below it, with `includeSubfolders`), by path, as
32 /// Emacs lists a directory in `org-agenda-files`.
33 public var files: [String] {
34 workspace.files
35 .filter { $0.kind == .org && (includeSubfolders || !$0.relativePath.contains("/")) }
36 .map(\.path)
37 .sorted()
38 }
39
40 /// Recomputes the days. `open` is the open file's path and text.
41 public func refresh(open: (path: String, text: String)?, now: Date = Date(), calendar: Calendar = .current) async {
42 generation += 1
43 let id = generation
44 isComputing = true
45 let today = Days.absolute(now, calendar: calendar)
46 let clock = calendar.dateComponents([.hour, .minute], from: now)
47 let first = start ?? today + startOffset
48 let defaults = workspace.settings.org
49 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
50 for path in files {
51 if let open, open.path == path {
52 reads.append((path, nil, .distantPast, -1, open.text))
53 continue
54 }
55 let attributes = try? FileManager.default.attributesOfItem(atPath: path)
56 let mtime = attributes?[.modificationDate] as? Date ?? .distantPast
57 let size = (attributes?[.size] as? NSNumber)?.intValue ?? -1
58 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil }
59 reads.append((path, cached, mtime, size, nil))
60 }
61 let span = self.span
62 let options = self.options
63 let result = await Task.detached {
64 var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = []
65 for read in reads {
66 if let cached = read.cached {
67 sources.append((read.path, read.mtime, read.size, cached, true))
68 } else if let text = read.text {
69 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), false))
70 } else if let data = FileManager.default.contents(atPath: read.path) {
71 let text = SourceText(bytes: [UInt8](data)).text
72 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true))
73 }
74 }
75 let days = Agenda.list(sources.map(\.source), start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options)
76 return (sources, days)
77 }.value
78 guard id == generation else { return }
79 for source in result.0 where source.fromDisk {
80 cache[source.path] = (source.mtime, source.size, source.source)
81 }
82 days = result.1
83 isComputing = false
84 }
85
86 public func showToday() { start = nil }
87
88 /// Moves the view by a whole span, forward or back.
89 public func shift(forward: Bool, now: Date = Date(), calendar: Calendar = .current) {
90 let current = start ?? Days.absolute(now, calendar: calendar) + startOffset
91 start = current + (forward ? span : -span)
92 }
93}
Sources/OrgApp/DocumentSession.swift +8 −1
@@ -53,6 +53,9 @@ public final class DocumentSession {
53 /// keystroke; views follow `path`, `isDirty` and `outline`. 53 /// keystroke; views follow `path`, `isDirty` and `outline`.
54 @ObservationIgnored public private(set) var document: DocumentState? 54 @ObservationIgnored public private(set) var document: DocumentState?
55 public private(set) var outline: [OutlineEntry] = [] 55 public private(set) var outline: [OutlineEntry] = []
56 /// Bumped when the buffer settles after edits, and when a file opens: views that read
57 /// `document` follow this.
58 public private(set) var editVersion = 0
56 public private(set) var isDirty = false 59 public private(set) var isDirty = false
57 /// The file isn't UTF-8, so it can't be edited or saved. 60 /// The file isn't UTF-8, so it can't be edited or saved.
58 public private(set) var isReadOnly = false 61 public private(set) var isReadOnly = false
@@ -117,6 +120,7 @@ public final class DocumentSession {
117 try? await Task.sleep(for: .milliseconds(300)) 120 try? await Task.sleep(for: .milliseconds(300))
118 guard !Task.isCancelled, let self, let document = self.document else { return } 121 guard !Task.isCancelled, let self, let document = self.document else { return }
119 self.outline = Presentation.outline(document.tree) 122 self.outline = Presentation.outline(document.tree)
123 self.editVersion += 1
120 } 124 }
121 scheduleAutosave() 125 scheduleAutosave()
122 } 126 }
@@ -127,7 +131,10 @@ public final class DocumentSession {
127 document = state 131 document = state
128 if isDirty != state.isDirty { isDirty = state.isDirty } 132 if isDirty != state.isDirty { isDirty = state.isDirty }
129 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } 133 if isReadOnly == state.isEditable { isReadOnly = !state.isEditable }
130 if updateOutline { outline = Presentation.outline(state.tree) } 134 if updateOutline {
135 outline = Presentation.outline(state.tree)
136 editVersion += 1
137 }
131 } 138 }
132 139
133 // MARK: - Saving 140 // MARK: - Saving
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/Keymap/Presets.swift +3
@@ -150,6 +150,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
150 [[bind]] 150 [[bind]]
151 keys = "M-x" 151 keys = "M-x"
152 command = "app.palette" 152 command = "app.palette"
153 [[bind]]
154 keys = "C-c a"
155 command = "app.agenda"
153 156
154 # Org 157 # Org
155 [[bind]] 158 [[bind]]
Sources/OrgCore/Parser/Inline.swift +2 −2
@@ -177,10 +177,10 @@ struct InlineScanner {
177 case "[": 177 case "[":
178 if !inLink, let link = bracketLink(i, limit) { return link } 178 if !inLink, let link = bracketLink(i, limit) { return link }
179 if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) } 179 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) }
181 if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) } 181 if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) }
182 case "<": 182 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) }
184 if let end = target(i, limit) { return .object(.target, i..<end) } 184 if let end = target(i, limit) { return .object(.target, i..<end) }
185 if !inLink, let end = angleLink(i, limit) { return .object(.link, i..<end) } 185 if !inLink, let end = angleLink(i, limit) { return .object(.link, i..<end) }
186 case "{": 186 case "{":
Sources/OrgCore/Timestamp.swift +31
@@ -61,6 +61,37 @@ func scanTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (sta
61 return first 61 return first
62} 62}
63 63
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
64/// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive. 95/// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive.
65private func scanSingleTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? { 96private func scanSingleTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? {
66 guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil } 97 guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil }
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -42,6 +42,7 @@ public enum EditorCommands {
42 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app), 42 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app),
43 EditorCommand(id: "app.refile", title: "Refile…", action: .app), 43 EditorCommand(id: "app.refile", title: "Refile…", action: .app),
44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), 44 EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app),
45 EditorCommand(id: "app.agenda", title: "Agenda", action: .app),
45 46
46 movement("forward-char", "Forward Character", "moveForward:"), 47 movement("forward-char", "Forward Character", "moveForward:"),
47 movement("backward-char", "Backward Character", "moveBackward:"), 48 movement("backward-char", "Backward Character", "moveBackward:"),
Sources/Orgstar/AgendaView.swift added +190
@@ -0,0 +1,190 @@
1import OrgApp
2import OrgCore
3import SwiftUI
4
5/// The agenda window: `org-agenda-list` over the agenda files. Return or a double click jumps
6/// to the entry in the main window; f/b move a span, `.` returns to today, g refreshes.
7struct AgendaView: View {
8 let workspace: WorkspaceModel
9 let session: DocumentSession
10 let commands: AppCommands
11 @State private var agenda: AgendaModel
12 @State private var selection: String?
13 @State private var tick = 0
14 @AppStorage("agendaSpan") private var span = 10
15 @AppStorage("agendaStartOffset") private var startOffset = -3
16 @AppStorage("agendaSubfolders") private var includeSubfolders = false
17 @Environment(\.openWindow) private var openWindow
18
19 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
20 self.workspace = workspace
21 self.session = session
22 self.commands = commands
23 _agenda = State(initialValue: AgendaModel(workspace: workspace))
24 }
25
26 private struct RefreshKey: Hashable {
27 var start: Int?
28 var span: Int
29 var startOffset: Int
30 var includeSubfolders: Bool
31 var files: Int
32 var changed: Int?
33 var edits: Int
34 var path: String?
35 var tick: Int
36 }
37
38 private var refreshKey: RefreshKey {
39 RefreshKey(
40 start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders,
41 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
42 path: session.path, tick: tick
43 )
44 }
45
46 var body: some View {
47 List(selection: $selection) {
48 ForEach(agenda.days, id: \.day) { day in
49 Section {
50 ForEach(Array(day.items.enumerated()), id: \.offset) { index, item in
51 AgendaRow(item: item)
52 .tag(Self.id(day, index))
53 .selectionDisabled(item.path == nil)
54 }
55 } header: {
56 Text(day.header)
57 .font(.headline)
58 .foregroundStyle(day.isToday ? Color.accentColor : .primary)
59 }
60 }
61 }
62 .contextMenu(forSelectionType: String.self) { _ in
63 } primaryAction: { ids in
64 if let id = ids.first { jump(id) }
65 }
66 .onKeyPress(.return) {
67 guard let selection else { return .ignored }
68 jump(selection)
69 return .handled
70 }
71 .onKeyPress(characters: CharacterSet(charactersIn: "fb.gr")) { press in
72 switch press.characters {
73 case "f": agenda.shift(forward: true)
74 case "b": agenda.shift(forward: false)
75 case ".": agenda.showToday()
76 default: tick += 1
77 }
78 return .handled
79 }
80 .toolbar {
81 ToolbarItemGroup {
82 if agenda.isComputing { ProgressView().controlSize(.small) }
83 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
84 .help("Previous \(span) days (b)")
85 Button("Today") { agenda.showToday() }
86 .help("Back to today (.)")
87 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
88 .help("Next \(span) days (f)")
89 }
90 }
91 .overlay {
92 if agenda.files.isEmpty {
93 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
94 }
95 }
96 .navigationTitle("Agenda")
97 .task(id: refreshKey) {
98 agenda.span = span
99 agenda.startOffset = startOffset
100 agenda.includeSubfolders = includeSubfolders
101 let open = session.path.flatMap { path in session.document.map { (path, $0.text) } }
102 await agenda.refresh(open: open)
103 }
104 .task {
105 // The time grid's "now" line and the day itself move on.
106 while !Task.isCancelled {
107 try? await Task.sleep(for: .seconds(60))
108 tick += 1
109 }
110 }
111 }
112
113 private static func id(_ day: AgendaDay, _ index: Int) -> String { "\(day.day):\(index)" }
114
115 private func jump(_ id: String) {
116 let parts = id.split(separator: ":").compactMap { Int($0) }
117 guard parts.count == 2, let day = agenda.days.first(where: { $0.day == parts[0] }), parts[1] < day.items.count else { return }
118 let item = day.items[parts[1]]
119 guard let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
120 commands.jump(path: path, offset: offset)
121 openWindow(id: "main")
122 }
123}
124
125struct AgendaRow: View {
126 let item: AgendaItem
127
128 var body: some View {
129 switch item.kind {
130 case .timeGrid:
131 HStack(spacing: 8) {
132 Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")))
133 .monospacedDigit()
134 .frame(width: 140, alignment: .trailing)
135 VStack { Divider() }
136 }
137 .font(.caption)
138 .foregroundStyle(.tertiary)
139 case .currentTime:
140 HStack(spacing: 8) {
141 Text("now " + item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")))
142 .monospacedDigit()
143 .frame(width: 140, alignment: .trailing)
144 Rectangle().fill(Color.accentColor).frame(height: 1)
145 }
146 .font(.caption)
147 .foregroundStyle(Color.accentColor)
148 default:
149 HStack(spacing: 8) {
150 Text(item.category)
151 .lineLimit(1)
152 .foregroundStyle(.secondary)
153 .frame(width: 80, alignment: .leading)
154 Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")))
155 .monospacedDigit()
156 .frame(width: 80, alignment: .leading)
157 if !item.extra.isEmpty {
158 Text(item.extra.trimmingCharacters(in: .whitespaces))
159 .monospacedDigit()
160 .foregroundStyle(leaderColor)
161 }
162 title
163 .lineLimit(1)
164 Spacer(minLength: 8)
165 if !item.tags.isEmpty {
166 Text(":" + item.tags.joined(separator: ":") + ":")
167 .font(.caption)
168 .foregroundStyle(.secondary)
169 }
170 }
171 }
172 }
173
174 private var leaderColor: Color {
175 switch item.kind {
176 case .deadline: .red
177 case .upcomingDeadline: .orange
178 case .pastScheduled: .brown
179 default: .secondary
180 }
181 }
182
183 /// The TODO keyword colored as in the editor.
184 private var title: Text {
185 guard let todo = item.todo, item.text.hasPrefix(todo + " ") else { return Text(item.text.trimmingCharacters(in: .whitespaces)) }
186 let text = item.text.trimmingCharacters(in: .whitespaces)
187 let rest = String(text.dropFirst(todo.count))
188 return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary)
189 }
190}
Sources/Orgstar/ContentView.swift +14
@@ -29,6 +29,7 @@ struct ContentView: View {
29 @State private var selectedFile: String? 29 @State private var selectedFile: String?
30 @State private var query = "" 30 @State private var query = ""
31 @FocusState private var searchFocused: Bool 31 @FocusState private var searchFocused: Bool
32 @Environment(\.openWindow) private var openWindow
32 33
33 var body: some View { 34 var body: some View {
34 NavigationSplitView { 35 NavigationSplitView {
@@ -101,6 +102,15 @@ struct ContentView: View {
101 .onChange(of: workspace.changedFile?.version) { 102 .onChange(of: workspace.changedFile?.version) {
102 if let path = workspace.changedFile?.path { session.fileChanged(path) } 103 if let path = workspace.changedFile?.path { session.fileChanged(path) }
103 } 104 }
105 .onChange(of: commands.jumpRequest?.id) {
106 guard let request = commands.jumpRequest else { return }
107 if request.path != session.path {
108 guard UnsavedChanges.resolve(session) else { return }
109 session.open(request.path)
110 selectedFile = request.path
111 }
112 session.reveal(request.offset)
113 }
104 .onChange(of: commands.focusSearch) { _, focus in 114 .onChange(of: commands.focusSearch) { _, focus in
105 if focus { 115 if focus {
106 searchFocused = true 116 searchFocused = true
@@ -130,6 +140,8 @@ struct ContentView: View {
130 .task { 140 .task {
131 DevelopmentHooks.scheduleSnapshot() 141 DevelopmentHooks.scheduleSnapshot()
132 if let path = DevelopmentHooks.fileToOpen { selectedFile = path } 142 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
143 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
144 if let id = DevelopmentHooks.windowToOpen { openWindow(id: id) }
133 await workspace.refresh() 145 await workspace.refresh()
134 workspace.startWatching() 146 workspace.startWatching()
135 } 147 }
@@ -163,6 +175,8 @@ struct ContentView: View {
163 } 175 }
164 case "app.archive": 176 case "app.archive":
165 if let message = session.archive() { commands.show(message) } 177 if let message = session.archive() { commands.show(message) }
178 case "app.agenda":
179 openWindow(id: "agenda")
166 default: 180 default:
167 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } 181 if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") }
168 } 182 }
Sources/Orgstar/DevelopmentHooks.swift +7 −1
@@ -3,11 +3,15 @@ import AppKit
3/// Switches for checking the app from scripts, read from the environment: 3/// Switches for checking the app from scripts, read from the environment:
4/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`). 4/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`).
5/// - `ORGSTAR_OPEN`: a file to select at launch. 5/// - `ORGSTAR_OPEN`: a file to select at launch.
6/// - `ORGSTAR_ROOT`: a folder to add at launch.
7/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`); the snapshot renders it.
6/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main 8/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
7/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording 9/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
8/// permission. 10/// permission.
9enum DevelopmentHooks { 11enum DevelopmentHooks {
10 static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] } 12 static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] }
13 static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] }
14 static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] }
11 15
12 @MainActor 16 @MainActor
13 static func scheduleSnapshot() { 17 static func scheduleSnapshot() {
@@ -15,7 +19,9 @@ enum DevelopmentHooks {
15 guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } 19 guard let path = environment["ORGSTAR_SNAPSHOT"] else { return }
16 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 20 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
17 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 21 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
18 if let view = NSApp.windows.first(where: \.isVisible)?.contentView?.superview, 22 let visible = NSApp.windows.filter(\.isVisible)
23 let window = windowToOpen.flatMap { id in visible.first { $0.identifier?.rawValue.hasPrefix(id) == true } } ?? visible.first
24 if let view = window?.contentView?.superview,
19 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { 25 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
20 view.cacheDisplay(in: view.bounds, to: bitmap) 26 view.cacheDisplay(in: view.bounds, to: bitmap)
21 try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) 27 try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
Sources/Orgstar/OrgstarApp.swift +34
@@ -13,6 +13,9 @@ final class AppCommands {
13 var focusSearch = false 13 var focusSearch = false
14 var showingConflict = false 14 var showingConflict = false
15 var showingPalette = false 15 var showingPalette = false
16 /// A location another window asked the main window to show.
17 private(set) var jumpRequest: (path: String, offset: Int, id: Int)?
18 private var jumpID = 0
16 /// The editor showing the open file, for menus and the palette. 19 /// The editor showing the open file, for menus and the palette.
17 var editor: OrgEditor? 20 var editor: OrgEditor?
18 /// The pending key prefix, and what can follow it. 21 /// The pending key prefix, and what can follow it.
@@ -36,6 +39,11 @@ final class AppCommands {
36 request.reply(text) 39 request.reply(text)
37 } 40 }
38 41
42 func jump(path: String, offset: Int) {
43 jumpID += 1
44 jumpRequest = (path, offset, jumpID)
45 }
46
39 func show(_ text: String) { 47 func show(_ text: String) {
40 messageID += 1 48 messageID += 1
41 let id = messageID 49 let id = messageID
@@ -111,6 +119,12 @@ struct OrgstarApp: App {
111 } 119 }
112 } 120 }
113 .defaultSize(width: 1280, height: 860) 121 .defaultSize(width: 1280, height: 860)
122 Window("Agenda", id: "agenda") {
123 if case .success(let model) = workspace {
124 AgendaView(workspace: model, session: session, commands: commands)
125 }
126 }
127 .defaultSize(width: 760, height: 720)
114 .commands { 128 .commands {
115 CommandGroup(after: .newItem) { 129 CommandGroup(after: .newItem) {
116 Button("Add Folder…") { commands.showingFolderPicker = true } 130 Button("Add Folder…") { commands.showingFolderPicker = true }
@@ -131,6 +145,9 @@ struct OrgstarApp: App {
131 Button("Search Notes") { commands.focusSearch = true } 145 Button("Search Notes") { commands.focusSearch = true }
132 .keyboardShortcut("f", modifiers: [.command, .shift]) 146 .keyboardShortcut("f", modifiers: [.command, .shift])
133 } 147 }
148 CommandGroup(before: .windowList) {
149 AgendaMenuItem()
150 }
134 } 151 }
135 .commands { 152 .commands {
136 CommandMenu("Org") { 153 CommandMenu("Org") {
@@ -146,6 +163,16 @@ struct OrgstarApp: App {
146 } 163 }
147} 164}
148 165
166/// Window ▸ Agenda.
167struct AgendaMenuItem: View {
168 @Environment(\.openWindow) private var openWindow
169
170 var body: some View {
171 Button("Agenda") { openWindow(id: "agenda") }
172 .keyboardShortcut("a", modifiers: [.command, .shift])
173 }
174}
175
149struct SettingsView: View { 176struct SettingsView: View {
150 @AppStorage("saveMode") private var saveMode = SaveMode.automatic 177 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
151 @AppStorage("keymap") private var preset = KeymapPreset.emacs 178 @AppStorage("keymap") private var preset = KeymapPreset.emacs
@@ -158,6 +185,9 @@ struct SettingsView: View {
158 @AppStorage("hiddenMarkers") private var hiddenMarkers = true 185 @AppStorage("hiddenMarkers") private var hiddenMarkers = true
159 @AppStorage("prettyEntities") private var prettyEntities = true 186 @AppStorage("prettyEntities") private var prettyEntities = true
160 @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords 187 @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords
188 @AppStorage("agendaSpan") private var agendaSpan = 10
189 @AppStorage("agendaStartOffset") private var agendaStartOffset = -3
190 @AppStorage("agendaSubfolders") private var agendaSubfolders = false
161 191
162 var body: some View { 192 var body: some View {
163 Form { 193 Form {
@@ -197,6 +227,10 @@ struct SettingsView: View {
197 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.") 227 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.")
198 .font(.caption) 228 .font(.caption)
199 .foregroundStyle(.secondary) 229 .foregroundStyle(.secondary)
230 Divider()
231 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
232 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
233 Toggle("Agenda includes files in subfolders", isOn: $agendaSubfolders)
200 } 234 }
201 .padding(20) 235 .padding(20)
202 .frame(width: 480) 236 .frame(width: 480)
Tests/OrgAppTests/AppTests.swift +33
@@ -273,3 +273,36 @@ enum StateBufferCaret {
273 session.attach(buffer) 273 session.attach(buffer)
274 } 274 }
275} 275}
276
277@MainActor
278struct AgendaModelTests {
279 @Test func topLevelFilesAndTheOpenBuffer() async throws {
280 let notes = try Folder()
281 try notes.write("a.org", "* TODO Pay rent\nSCHEDULED: <2026-10-05 Mon>\n")
282 try notes.write("sub/b.org", "* Hidden <2026-10-05 Mon>\n")
283 try notes.write("old.org_archive", "* Archived <2026-10-05 Mon>\n")
284 let model = try WorkspaceModel(directory: nil)
285 try await model.addRoot(notes.url)
286 let agenda = AgendaModel(workspace: model)
287 var utc = Calendar(identifier: .gregorian)
288 utc.timeZone = TimeZone(identifier: "UTC")!
289 let now = try #require(utc.date(from: DateComponents(year: 2026, month: 10, day: 5, hour: 9)))
290
291 await agenda.refresh(open: nil, now: now, calendar: utc)
292 #expect(agenda.days.count == 10)
293 #expect(agenda.days[3].isToday)
294 #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent"])
295
296 agenda.includeSubfolders = true
297 await agenda.refresh(open: nil, now: now, calendar: utc)
298 #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent", "Hidden "])
299
300 let path = try #require(agenda.files.first { $0.hasSuffix("/a.org") })
301 await agenda.refresh(open: (path, "* DONE Pay rent\nSCHEDULED: <2026-10-05 Mon>\n"), now: now, calendar: utc)
302 #expect(agenda.days[3].items.first?.text == "DONE Pay rent")
303
304 agenda.shift(forward: true, now: now, calendar: utc)
305 await agenda.refresh(open: nil, now: now, calendar: utc)
306 #expect(agenda.days.first?.day == Days.absolute(year: 2026, month: 10, day: 12))
307 }
308}
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- [x] Absolute days, ISO weeks; `org-closest-date`; `org-get-wdays`.
28- [x] `AgendaSource` from text; collectors; formatting; sorting; time grid; day headers.
29- [x] 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- [x] Commit "Agenda engine".
31
32### Task 2: App
33
34- [x] 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- [x] Settings: span, start offset, agenda folders.
36- [x] Commit "Agenda view".