krz/orgstar

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

Commit 2415257464

241525746419a10a1816afce1593ecb62ebea46b

parent: c547244f4d

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 02:50 UTC

Agenda prefix format and log mode

- org-agenda-prefix-format for the agenda, TODO list and tag matches:
  %c %t %s %e %l %b %i with optional ?, widths, .N category limits and
  punctuation, compiled as org-compile-prefix-format. Config table
  [org-agenda-prefix-format]; the Emacs import reads the alist or a
  string. A non-default format draws the prefix as Emacs prints it
  instead of the agenda's columns.
- Log mode (l): closed entries and clock lines on each day, with the
  clocked time and the first line of a note, as org-agenda-get-progress.

Oracle tests against Org 9.8.7.

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +2 −2
@@ -65,9 +65,9 @@ public final class AgendaModel {
65 case .agenda: 65 case .agenda:
66 days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options) 66 days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options)
67 case .todo(let keywords): 67 case .todo(let keywords):
68 list = Agenda.todoList(all, keywords: keywords) 68 list = Agenda.todoList(all, keywords: keywords, options: options)
69 case .match(let match, let todoOnly): 69 case .match(let match, let todoOnly):
70 list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar) 70 list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar, options: options)
71 } 71 }
72 return (days, list) 72 return (days, list)
73 }.value 73 }.value
Sources/OrgApp/EmacsImport.swift +10 −1
@@ -460,6 +460,15 @@ public struct EmacsImport: Sendable {
460 } else { 460 } else {
461 add(name, .bool(truthy)) 461 add(name, .bool(truthy))
462 } 462 }
463 case "org-agenda-prefix-format":
464 if let s = value.string {
465 for view in ["agenda", "todo", "tags"] { add("org-agenda-prefix-format.\(view)", .string(s)) }
466 } else {
467 for entry in value.elements ?? [] {
468 guard case .dotted(let key, let format) = entry, let view = key.first?.symbol, let s = format.string else { continue }
469 if ["agenda", "todo", "tags"].contains(view) { add("org-agenda-prefix-format.\(view)", .string(s)) }
470 }
471 }
463 case "org-log-done": 472 case "org-log-done":
464 switch value.symbol { 473 switch value.symbol {
465 case "nil": add(name, .string("nil")) 474 case "nil": add(name, .string("nil"))
@@ -622,7 +631,7 @@ extension EmacsImport {
622 var problems: [String] = [] 631 var problems: [String] = []
623 for item in items { 632 for item in items {
624 guard case .setting(let key, let value) = item.change, 633 guard case .setting(let key, let value) = item.change,
625 let setting = UserConfig.settings.first(where: { $0.key == key }) else { continue } 634 let setting = UserConfig.settings.first(where: { $0.path == key || $0.key == key }) else { continue }
626 if let problem = UserConfig.store(value, setting, in: defaults) { problems.append(problem) } else { settings += 1 } 635 if let problem = UserConfig.store(value, setting, in: defaults) { problems.append(problem) } else { settings += 1 }
627 } 636 }
628 for item in items { 637 for item in items {
Sources/OrgApp/UserConfig.swift +6
@@ -90,6 +90,12 @@ public enum UserConfig {
90 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"), 90 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
91 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), 91 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
92 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"), 92 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
93 Setting(section: "org-agenda-prefix-format", key: "agenda", defaultsKey: "agendaPrefixAgenda", kind: .string, fallback: .string(PrefixFormat.defaults["agenda"]!),
94 comment: "%c category, %t time, %s scheduled/deadline, %e effort, %l level, %b outline path; %-12 pads", legacy: ""),
95 Setting(section: "org-agenda-prefix-format", key: "todo", defaultsKey: "agendaPrefixTodo", kind: .string, fallback: .string(PrefixFormat.defaults["todo"]!),
96 comment: "the TODO list", legacy: ""),
97 Setting(section: "org-agenda-prefix-format", key: "tags", defaultsKey: "agendaPrefixTags", kind: .string, fallback: .string(PrefixFormat.defaults["tags"]!),
98 comment: "tag and property matches", legacy: ""),
93 Setting(section: "theme", key: "font", defaultsKey: "themeFont", kind: .string, fallback: .string(""), 99 Setting(section: "theme", key: "font", defaultsKey: "themeFont", kind: .string, fallback: .string(""),
94 comment: "a monospaced font family; \"\" for the system's", legacy: ""), 100 comment: "a monospaced font family; \"\" for the system's", legacy: ""),
95 Setting(section: "theme", key: "font-size", defaultsKey: "themeFontSize", kind: .integer, fallback: .integer(13), 101 Setting(section: "theme", key: "font-size", defaultsKey: "themeFontSize", kind: .integer, fallback: .integer(13),
Sources/OrgCore/Agenda/Agenda.swift +84 −14
@@ -12,6 +12,8 @@ public struct AgendaItem: Sendable, Equatable {
12 case currentTime 12 case currentTime
13 case todo 13 case todo
14 case tagsMatch = "tagsmatch" 14 case tagsMatch = "tagsmatch"
15 /// Log mode: a closed entry, a clock line, a state change.
16 case closed, clock, state
15 } 17 }
16 18
17 public let kind: Kind 19 public let kind: Kind
@@ -41,6 +43,8 @@ public struct AgendaItem: Sendable, Equatable {
41 public let habit: Habit? 43 public let habit: Habit?
42 /// The line as org prints it, with one space before the tags. 44 /// The line as org prints it, with one space before the tags.
43 public let line: String 45 public let line: String
46 /// What `org-agenda-prefix-format` puts before the heading text.
47 public var prefix = ""
44} 48}
45 49
46public struct AgendaDay: Sendable, Equatable { 50public struct AgendaDay: Sendable, Equatable {
@@ -58,9 +62,19 @@ public struct AgendaOptions: Sendable, Equatable {
58 public var gridTrailing = " ┄┄┄┄┄ " 62 public var gridTrailing = " ┄┄┄┄┄ "
59 public var gridLine = "┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄" 63 public var gridLine = "┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄"
60 public var currentTimeLine = "← now ───────────────────────────────────────────────" 64 public var currentTimeLine = "← now ───────────────────────────────────────────────"
65 /// `org-agenda-prefix-format`, by view: `agenda`, `todo`, `tags`, `search`.
66 public var prefixFormat = PrefixFormat.defaults
67 /// Log mode (`l`): closed entries and clocked time on each day.
68 public var logMode = false
69 /// `org-agenda-log-mode-items`.
70 public var logItems: Set<String> = ["closed", "clock"]
61 71
62 public init() {} 72 public init() {}
63 73
74 func prefix(_ view: String) -> PrefixFormat {
75 PrefixFormat(prefixFormat[view] ?? PrefixFormat.defaults[view]!)
76 }
77
64 /// What Emacs shows without a graphical display. 78 /// What Emacs shows without a graphical display.
65 public static let terminal: AgendaOptions = { 79 public static let terminal: AgendaOptions = {
66 var options = AgendaOptions() 80 var options = AgendaOptions()
@@ -91,7 +105,7 @@ public enum Agenda {
91 /// `org-agenda-get-day-entries` with the default entry types. 105 /// `org-agenda-get-day-entries` with the default entry types.
92 static func entries(_ source: AgendaSource, day: Int, today: Int, options: AgendaOptions) -> [AgendaItem] { 106 static func entries(_ source: AgendaSource, day: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
93 let deadlines = self.deadlines(source, day: day, today: today, options: options) 107 let deadlines = self.deadlines(source, day: day, today: today, options: options)
94 return deadlines + scheduled(source, day: day, today: today, options: options) + blocks(source, day: day, options: options) 108 return (options.logMode ? progress(source, day: day, options: options) : []) + deadlines + scheduled(source, day: day, today: today, options: options) + blocks(source, day: day, options: options)
95 + timestamps(source, day: day, today: today, options: options) 109 + timestamps(source, day: day, today: today, options: options)
96 } 110 }
97 111
@@ -126,7 +140,7 @@ public enum Agenda {
126 let upcoming = isToday && deadline > today 140 let upcoming = isToday && deadline > today
127 items.append(format( 141 items.append(format(
128 source, heading, kind: upcoming ? .upcomingDeadline : .deadline, marker: offset, extra: extra, 142 source, heading, kind: upcoming ? .upcomingDeadline : .deadline, marker: offset, extra: extra,
129 dotime: time, removing: nil, trailing: options.gridTrailing, urgency: { (isToday ? -diff : 0) + $0 } 143 dotime: time, removing: nil, trailing: options.gridTrailing, prefix: options.prefix("agenda"), urgency: { (isToday ? -diff : 0) + $0 }
130 )) 144 ))
131 } 145 }
132 return items 146 return items
@@ -153,7 +167,7 @@ public enum Agenda {
153 let habit = heading.isHabit ? heading.habit : nil 167 let habit = heading.isHabit ? heading.habit : nil
154 items.append(format( 168 items.append(format(
155 source, heading, kind: past ? .pastScheduled : .scheduled, marker: offset, extra: extra, 169 source, heading, kind: past ? .pastScheduled : .scheduled, marker: offset, extra: extra,
156 dotime: time, removing: nil, trailing: options.gridTrailing, habit: habit, 170 dotime: time, removing: nil, trailing: options.gridTrailing, prefix: options.prefix("agenda"), habit: habit,
157 urgency: { habit?.urgency(today: today) ?? 99 + diff + $0 } 171 urgency: { habit?.urgency(today: today) ?? 99 + diff + $0 }
158 )) 172 ))
159 } 173 }
@@ -177,7 +191,45 @@ public enum Agenda {
177 } else { 191 } else {
178 dotime = nil 192 dotime = nil
179 } 193 }
180 items.append(format(source, heading, kind: .block, marker: block.marker, extra: extra, dotime: dotime, removing: nil, trailing: options.gridTrailing, urgency: { $0 })) 194 items.append(format(source, heading, kind: .block, marker: block.marker, extra: extra, dotime: dotime, removing: nil, trailing: options.gridTrailing, prefix: options.prefix("agenda"), urgency: { $0 }))
195 }
196 return items
197 }
198
199 /// `org-agenda-get-progress`.
200 static func progress(_ source: AgendaSource, day current: Int, options: AgendaOptions) -> [AgendaItem] {
201 let date = Days.date(current)
202 let literal = String(format: "%04d-%02d-%02d", date.year, date.month, date.day)
203 var items: [AgendaItem] = []
204 for entry in source.progress where entry.date == literal {
205 let kind: AgendaItem.Kind
206 let extra: String
207 switch entry.kind {
208 case .closed:
209 guard options.logItems.contains("closed") else { continue }
210 (kind, extra) = (.closed, "Closed: ")
211 case .clock:
212 guard options.logItems.contains("clock") else { continue }
213 (kind, extra) = (.clock, "Clocked: (\(entry.clocked))")
214 case .state:
215 guard options.logItems.contains("state") else { continue }
216 (kind, extra) = (.state, "State: (\(entry.state ?? ""))")
217 }
218 let heading = source.headings[entry.heading]
219 var head = heading.head
220 if let note = entry.note {
221 // Before the tags, as org adds it.
222 if let tags = head.range(of: "([ \\t]+)(:[^ \\n\\t]*?:)[ \\t]*$", options: .regularExpression) {
223 let tagText = head[tags].trimmingCharacters(in: .whitespaces)
224 head = head[..<tags.lowerBound] + " - " + note + " " + tagText
225 } else {
226 head += " - " + note
227 }
228 }
229 items.append(format(
230 source, heading, kind: kind, marker: entry.offset, extra: extra, dotime: .only(entry.timestamp), removing: nil,
231 trailing: options.gridTrailing, prefix: options.prefix("agenda"), head: head, urgency: { _ in 100_000 }
232 ))
181 } 233 }
182 return items 234 return items
183 } 235 }
@@ -210,7 +262,7 @@ public enum Agenda {
210 let heading = source.headings[stamp.heading] 262 let heading = source.headings[stamp.heading]
211 items.append(format( 263 items.append(format(
212 source, heading, kind: .timestamp, marker: stamp.offset, extra: "", 264 source, heading, kind: .timestamp, marker: stamp.offset, extra: "",
213 dotime: .string(timestamp), removing: stampActive, trailing: options.gridTrailing, 265 dotime: .string(timestamp), removing: stampActive, trailing: options.gridTrailing, prefix: options.prefix("agenda"),
214 urgency: { heading.isHabit ? heading.habit?.urgency(today: today) ?? $0 : $0 } 266 urgency: { heading.isHabit ? heading.habit?.urgency(today: today) ?? $0 : $0 }
215 )) 267 ))
216 } 268 }
@@ -229,6 +281,8 @@ public enum Agenda {
229 case string(String) 281 case string(String)
230 /// Look for a time in the heading only. 282 /// Look for a time in the heading only.
231 case headline 283 case headline
284 /// In the string only (`org-agenda-search-headline-for-time` nil).
285 case only(String)
232 } 286 }
233 287
234 static let tagGroup = try! NSRegularExpression(pattern: "[ \\t]+(:([\\p{L}\\p{N}_@#%:]+):)[ \\t]*$") 288 static let tagGroup = try! NSRegularExpression(pattern: "[ \\t]+(:([\\p{L}\\p{N}_@#%:]+):)[ \\t]*$")
@@ -246,10 +300,10 @@ public enum Agenda {
246 /// `org-agenda-highlight-todo`. 300 /// `org-agenda-highlight-todo`.
247 static func format( 301 static func format(
248 _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int, 302 _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int,
249 extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, hasTime: Bool = true, 303 extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, prefix prefixFormat: PrefixFormat,
250 habit: Habit? = nil, urgency: (Int) -> Int 304 head: String? = nil, habit: Habit? = nil, urgency: (Int) -> Int
251 ) -> AgendaItem { 305 ) -> AgendaItem {
252 var txt = heading.head.trimmingCharacters(in: .whitespaces) 306 var txt = (head ?? heading.head).trimmingCharacters(in: .whitespaces)
253 // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list. 307 // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list.
254 let tags = heading.tags 308 let tags = heading.tags
255 if let m = tagGroup.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) { 309 if let m = tagGroup.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
@@ -270,16 +324,17 @@ public enum Agenda {
270 tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":") 324 tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":")
271 } 325 }
272 326
273 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing, removeTimes: hasTime) 327 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing, removeTimes: prefixFormat.hasTime)
274 if let removing { 328 if let removing {
275 while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) { 329 while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
276 txt = (txt as NSString).replacingCharacters(in: m.range, with: "") 330 txt = (txt as NSString).replacingCharacters(in: m.range, with: "")
277 } 331 }
278 } 332 }
279 let category = heading.category 333 let category = heading.category
280 // ` %i %-12:c%?-12t% s` in the agenda, ` %i %-12:c` in TODO and tags lists. 334 let prefix = prefixFormat.render([
281 let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12) 335 "c": category, "t": timed.time, "s": habit == nil ? extra : "", "l": String(repeating: " ", count: heading.level),
282 + (hasTime ? (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ") : "") 336 "e": heading.properties["EFFORT"] ?? "", "b": breadcrumbs(source, heading), "T": tags.last?.name ?? "",
337 ])
283 txt = highlightTodo(txt, keywords: source.keywords) 338 txt = highlightTodo(txt, keywords: source.keywords)
284 let priority = Self.priority(prefix + txt, source.priorities) 339 let priority = Self.priority(prefix + txt, source.priorities)
285 return AgendaItem( 340 return AgendaItem(
@@ -287,10 +342,24 @@ public enum Agenda {
287 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), 342 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
288 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), 343 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority),
289 warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, habit: habit, 344 warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, habit: habit,
290 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString) 345 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString), prefix: prefix
291 ) 346 )
292 } 347 }
293 348
349 /// `%b`: the outline path above the heading, each title followed by `->`.
350 static func breadcrumbs(_ source: AgendaSource, _ heading: AgendaSource.Heading) -> String {
351 guard let index = source.headings.firstIndex(where: { $0.start == heading.start }) else { return "" }
352 var path: [String] = []
353 var level = heading.level
354 for candidate in source.headings[..<index].reversed() where candidate.level < level {
355 let title = candidate.title.replacingOccurrences(of: "\\[[0-9]+%\\]|\\[[0-9]+/[0-9]+\\]", with: "", options: .regularExpression)
356 path.insert(Links.displayFormat(title).trimmingCharacters(in: .whitespaces), at: 0)
357 level = candidate.level
358 if level == 1 { break }
359 }
360 return path.isEmpty ? "" : path.joined(separator: "->") + "->"
361 }
362
294 /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the 363 /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the
295 /// text, and formats the `%t` field. 364 /// text, and formats the `%t` field.
296 static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String, removeTimes: Bool = true) -> (timeOfDay: Int?, time: String) { 365 static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String, removeTimes: Bool = true) -> (timeOfDay: Int?, time: String) {
@@ -301,6 +370,7 @@ public enum Agenda {
301 switch dotime { 370 switch dotime {
302 case .string(let s): containing = s + withoutStamps 371 case .string(let s): containing = s + withoutStamps
303 case .headline: containing = withoutStamps 372 case .headline: containing = withoutStamps
373 case .only(let s): containing = s
304 } 374 }
305 guard let timeOfDay = time(of: containing) else { return (nil, "") } 375 guard let timeOfDay = time(of: containing) else { return (nil, "") }
306 let ns = containing as NSString 376 let ns = containing as NSString
@@ -402,7 +472,7 @@ public enum Agenda {
402 return AgendaItem( 472 return AgendaItem(
403 kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, 473 kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay,
404 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, warntime: nil, habit: nil, 474 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, warntime: nil, habit: nil,
405 line: " " + pad("", 12) + pad(timed.time, 12) + txt 475 line: options.prefix("agenda").render(["t": timed.time]) + txt, prefix: options.prefix("agenda").render(["t": timed.time])
406 ) 476 )
407 } 477 }
408 var new = options.gridTimes.reversed().map { t in 478 var new = options.gridTimes.reversed().map { t in
Sources/OrgCore/Agenda/AgendaSearch.swift +7 −4
@@ -3,7 +3,7 @@ import Foundation
3extension Agenda { 3extension Agenda {
4 /// `org-todo-list`: unfinished entries, or with `keywords` (`WAIT|HOLD`) those with any of 4 /// `org-todo-list`: unfinished entries, or with `keywords` (`WAIT|HOLD`) those with any of
5 /// the keywords. 5 /// the keywords.
6 public static func todoList(_ sources: [AgendaSource], keywords: String? = nil) -> [AgendaItem] { 6 public static func todoList(_ sources: [AgendaSource], keywords: String? = nil, options: AgendaOptions = AgendaOptions()) -> [AgendaItem] {
7 let selected = keywords.map { $0.split(separator: "|").map(String.init) } 7 let selected = keywords.map { $0.split(separator: "|").map(String.init) }
8 var items: [AgendaItem] = [] 8 var items: [AgendaItem] = []
9 for source in sources { 9 for source in sources {
@@ -16,7 +16,7 @@ extension Agenda {
16 } 16 }
17 items.append(format( 17 items.append(format(
18 source, heading, kind: .todo, marker: heading.start, extra: "", dotime: .headline, removing: nil, 18 source, heading, kind: .todo, marker: heading.start, extra: "", dotime: .headline, removing: nil,
19 trailing: "", hasTime: false, urgency: { 1 + $0 } 19 trailing: "", prefix: options.prefix("todo"), urgency: { 1 + $0 }
20 )) 20 ))
21 } 21 }
22 } 22 }
@@ -25,7 +25,10 @@ extension Agenda {
25 25
26 /// `org-tags-view`: entries matching a tags/property/TODO match string. `todoOnly` keeps 26 /// `org-tags-view`: entries matching a tags/property/TODO match string. `todoOnly` keeps
27 /// unfinished TODO entries, as `M` does. 27 /// unfinished TODO entries, as `M` does.
28 public static func tagsMatch(_ sources: [AgendaSource], match: String, todoOnly: Bool = false, now: Date = Date(), calendar: Calendar = .current) -> [AgendaItem] { 28 public static func tagsMatch(
29 _ sources: [AgendaSource], match: String, todoOnly: Bool = false, now: Date = Date(), calendar: Calendar = .current,
30 options: AgendaOptions = AgendaOptions()
31 ) -> [AgendaItem] {
29 let matcher = TagsMatcher(match, todoOnly: todoOnly) 32 let matcher = TagsMatcher(match, todoOnly: todoOnly)
30 var items: [AgendaItem] = [] 33 var items: [AgendaItem] = []
31 for source in sources { 34 for source in sources {
@@ -34,7 +37,7 @@ extension Agenda {
34 guard matcher.matches(heading, in: source, now: now, calendar: calendar), !heading.skipped else { continue } 37 guard matcher.matches(heading, in: source, now: now, calendar: calendar), !heading.skipped else { continue }
35 items.append(format( 38 items.append(format(
36 source, heading, kind: .tagsMatch, marker: heading.start, extra: "", dotime: nil, removing: nil, 39 source, heading, kind: .tagsMatch, marker: heading.start, extra: "", dotime: nil, removing: nil,
37 trailing: "", hasTime: false, urgency: { $0 } 40 trailing: "", prefix: options.prefix("tags"), urgency: { $0 }
38 )) 41 ))
39 } 42 }
40 } 43 }
Sources/OrgCore/Agenda/AgendaSource.swift +61
@@ -56,9 +56,28 @@ public struct AgendaSource: Sendable {
56 let heading: Int 56 let heading: Int
57 } 57 }
58 58
59 /// A `CLOSED:`, `CLOCK:` or `- State "…"` line, for log mode (`org-agenda-get-progress`).
60 struct Progress: Sendable {
61 enum Kind: Sendable { case closed, clock, state }
62 let kind: Kind
63 let offset: Int
64 let heading: Int
65 /// `YYYY-MM-DD` of the first timestamp.
66 let date: String
67 /// The timestamp, from the keyword; a clock's ends with its end time (`[… 10:00-11:30]`).
68 let timestamp: String
69 /// A clock's duration, or `-`.
70 let clocked: String
71 /// The state a note records.
72 let state: String?
73 /// The first line of the note under it.
74 let note: String?
75 }
76
59 let headings: [Heading] 77 let headings: [Heading]
60 let stamps: [Stamp] 78 let stamps: [Stamp]
61 let blocks: [Block] 79 let blocks: [Block]
80 let progress: [Progress]
62 /// The file's TODO keywords, active and done. 81 /// The file's TODO keywords, active and done.
63 let keywords: [String] 82 let keywords: [String]
64 let priorities: Priorities 83 let priorities: Priorities
@@ -243,6 +262,48 @@ public struct AgendaSource: Sendable {
243 blocks.append(Block(start: ns.substring(with: m.range(at: 1)), end: ns.substring(with: m.range(at: 2)), marker: point, heading: owner)) 262 blocks.append(Block(start: ns.substring(with: m.range(at: 1)), end: ns.substring(with: m.range(at: 2)), marker: point, heading: owner))
244 } 263 }
245 self.blocks = blocks 264 self.blocks = blocks
265
266 // Log lines: org searches the raw text, then the heading above each match.
267 let keywordPattern = keywords.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "|")
268 let logLine = try! NSRegularExpression(pattern: "(\\bCLOSED:|\\bCLOCK:|- +State \"(\(keywordPattern.isEmpty ? "(?!)" : keywordPattern))\".*?) *\\[([0-9]{4}-[0-9]{2}-[0-9]{2})")
269 let inactive = try! NSRegularExpression(pattern: "\\[([0-9]{4}-[0-9]{2}-[0-9]{2}( +[^\\]+0-9>\\r\\n -]+)?( +([0-9]{1,2}):([0-9]{2}))?)\\]")
270 let clockEnd = try! NSRegularExpression(pattern: "([0-9]{1,2}:[0-9]{2})\\].*?([0-9]{1,2}:[0-9]{2})")
271 let stateNote = try! NSRegularExpression(pattern: ".*\\\\\\n[ \\t]*([^-\\n \\t].*?)[ \\t]*$", options: .anchorsMatchLines)
272 let clockNote = try! NSRegularExpression(pattern: ".*\\n[ \\t]*-[ \\t]+([^-\\n \\t].*?)[ \\t]*$", options: .anchorsMatchLines)
273 var progress: [Progress] = []
274 for m in logLine.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
275 guard let owner = heading(at: m.range.location), !skipped[owner] else { continue }
276 let keyword = ns.substring(with: m.range(at: 1))
277 let kind: Progress.Kind = keyword == "CLOSED:" ? .closed : keyword == "CLOCK:" ? .clock : .state
278 var timestamp = restOfLine(m.range.location)
279 var clocked = "-"
280 let tns = timestamp as NSString
281 if let ts = inactive.firstMatch(in: timestamp, range: NSRange(location: 0, length: tns.length)) {
282 let rest = tns.substring(from: NSMaxRange(ts.range))
283 timestamp = tns.substring(to: NSMaxRange(ts.range))
284 let rns = rest as NSString
285 if kind == .clock, let end = clockEnd.firstMatch(in: rest, range: NSRange(location: 0, length: rns.length)) {
286 timestamp = String(timestamp.dropLast()) + "-" + rns.substring(with: end.range(at: 1)) + "]"
287 clocked = rns.substring(with: end.range(at: 2))
288 }
289 }
290 // The note: after a state line ending in `\\`, or a list item under a clock line.
291 var note: String?
292 let after = NSMaxRange(m.range)
293 let notePattern: NSRegularExpression? = switch kind {
294 case .state: stateNote
295 case .clock: clockNote
296 case .closed: nil
297 }
298 if let notePattern, let n = notePattern.firstMatch(in: text, options: .anchored, range: NSRange(location: after, length: ns.length - after)) {
299 note = ns.substring(with: n.range(at: 1))
300 }
301 progress.append(Progress(
302 kind: kind, offset: m.range.location, heading: owner, date: ns.substring(with: m.range(at: 3)), timestamp: timestamp,
303 clocked: clocked, state: m.range(at: 2).location == NSNotFound ? nil : ns.substring(with: m.range(at: 2)), note: note
304 ))
305 }
306 self.progress = progress
246 } 307 }
247 308
248 /// `org-ts--internal-regexp`. 309 /// `org-ts--internal-regexp`.
Sources/OrgCore/Agenda/PrefixFormat.swift added +82
@@ -0,0 +1,82 @@
1import Foundation
2
3/// `org-agenda-prefix-format` for one view, compiled as `org-compile-prefix-format` does:
4/// `%c` category, `%t` time, `%l` level, `%s` extra, `%e` effort, `%b` breadcrumbs, `%i` icon
5/// (none in Orgstar), each with an optional `?` (empty when the value is), a `%-12`-style
6/// width with an optional `.N` limit on categories, and a punctuation character added after a
7/// non-empty value. `%(…)` forms run Lisp; they are empty here.
8public struct PrefixFormat: Sendable, Equatable {
9 enum Piece: Sendable, Equatable {
10 case literal(String)
11 case field(key: Character, optional: Bool, width: String, punctuation: String)
12 }
13
14 let pieces: [Piece]
15 /// `org-prefix-has-time`: times move out of the heading into the prefix.
16 let hasTime: Bool
17 /// `.N` in the category width: categories this long or longer are cut to N - 1.
18 let categoryMaxLength: Int?
19
20 public init(_ format: String) {
21 let regex = try! NSRegularExpression(pattern: "%(\\?)?([-+]?[0-9.]*)([ .;,:!?=|/<>]?)([cltseib]|\\(.+?\\))")
22 let ns = format as NSString
23 var pieces: [Piece] = []
24 var hasTime = false
25 var maxLength: Int?
26 var at = 0
27 for m in regex.matches(in: format, range: NSRange(location: 0, length: ns.length)) {
28 if m.range.location > at { pieces.append(.literal(ns.substring(with: NSRange(location: at, length: m.range.location - at)))) }
29 let key = ns.substring(with: m.range(at: 4))
30 let width = ns.substring(with: m.range(at: 2))
31 if key.hasPrefix("(") {
32 pieces.append(.field(key: "(", optional: false, width: width, punctuation: ""))
33 } else {
34 let k = key.first!
35 if k == "t" { hasTime = true }
36 if k == "c", let dot = width.firstIndex(of: ".") { maxLength = Int(width[width.index(after: dot)...].prefix { $0.isNumber }) }
37 pieces.append(.field(key: k, optional: m.range(at: 1).location != NSNotFound, width: width, punctuation: ns.substring(with: m.range(at: 3))))
38 }
39 at = NSMaxRange(m.range)
40 }
41 if at < ns.length { pieces.append(.literal(ns.substring(from: at))) }
42 self.pieces = pieces
43 self.hasTime = hasTime
44 self.categoryMaxLength = maxLength
45 }
46
47 /// The prefix for these values, by field key.
48 func render(_ values: [Character: String]) -> String {
49 var out = ""
50 for piece in pieces {
51 switch piece {
52 case .literal(let s): out += s
53 case .field(let key, let optional, let width, let punctuation):
54 var value = values[key] ?? ""
55 if key == "c", let max = categoryMaxLength, value.count >= max { value = String(value.prefix(max - 1)) }
56 if value.isEmpty {
57 out += optional ? "" : Self.format("", width)
58 } else {
59 out += Self.format(value + punctuation, width)
60 }
61 }
62 }
63 return out
64 }
65
66 /// Emacs `format` with `%WIDTHs` or `%-WIDTH.PRECISIONs`.
67 static func format(_ s: String, _ spec: String) -> String {
68 var spec = Substring(spec)
69 let left = spec.first == "-"
70 if left || spec.first == "+" { spec = spec.dropFirst() }
71 let parts = spec.split(separator: ".", omittingEmptySubsequences: false)
72 let width = Int(parts.first ?? "") ?? 0
73 var value = s
74 if parts.count > 1, let precision = Int(parts[1]) { value = String(value.prefix(precision)) }
75 let padding = String(repeating: " ", count: max(0, width - displayWidth(value)))
76 return left ? value + padding : padding + value
77 }
78
79 public static let defaults: [String: String] = [
80 "agenda": " %i %-12:c%?-12t% s", "todo": " %i %-12:c", "tags": " %i %-12:c", "search": " %i %-12:c",
81 ]
82}
Sources/Orgstar/AgendaView.swift +38 −3
@@ -5,7 +5,7 @@ import SwiftUI
5 5
6/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views. 6/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
7/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: 7/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
8/// f/b/. move the span, g refreshes, t cycles the TODO state, +/- change the priority, : or 8/// f/b/. move the span, g refreshes, l shows the log (closed and clocked), t cycles the TODO state, +/- change the priority, : or
9/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date. 9/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date.
10struct AgendaView: View { 10struct AgendaView: View {
11 let workspace: WorkspaceModel 11 let workspace: WorkspaceModel
@@ -22,9 +22,13 @@ struct AgendaView: View {
22 @State private var prompt: PendingPrompt? 22 @State private var prompt: PendingPrompt?
23 @State private var promptText = "" 23 @State private var promptText = ""
24 @State private var prefix = false 24 @State private var prefix = false
25 @State private var logMode = false
25 @AppStorage("agendaSpan") private var span = 10 26 @AppStorage("agendaSpan") private var span = 10
26 @AppStorage("agendaStartOffset") private var startOffset = -3 27 @AppStorage("agendaStartOffset") private var startOffset = -3
27 @AppStorage("agendaSubfolders") private var includeSubfolders = false 28 @AppStorage("agendaSubfolders") private var includeSubfolders = false
29 @AppStorage("agendaPrefixAgenda") private var prefixAgenda = PrefixFormat.defaults["agenda"]!
30 @AppStorage("agendaPrefixTodo") private var prefixTodo = PrefixFormat.defaults["todo"]!
31 @AppStorage("agendaPrefixTags") private var prefixTags = PrefixFormat.defaults["tags"]!
28 @Environment(\.openWindow) private var openWindow 32 @Environment(\.openWindow) private var openWindow
29 33
30 static let match = "Match…" 34 static let match = "Match…"
@@ -53,6 +57,8 @@ struct AgendaView: View {
53 var span: Int 57 var span: Int
54 var startOffset: Int 58 var startOffset: Int
55 var includeSubfolders: Bool 59 var includeSubfolders: Bool
60 var prefix: [String]
61 var logMode: Bool
56 var files: Int 62 var files: Int
57 var changed: Int? 63 var changed: Int?
58 var edits: Int 64 var edits: Int
@@ -62,7 +68,7 @@ struct AgendaView: View {
62 68
63 private var refreshKey: RefreshKey { 69 private var refreshKey: RefreshKey {
64 RefreshKey( 70 RefreshKey(
65 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, 71 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, prefix: [prefixAgenda, prefixTodo, prefixTags], logMode: logMode,
66 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, 72 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
67 path: session.path, tick: tick 73 path: session.path, tick: tick
68 ) 74 )
@@ -75,6 +81,14 @@ struct AgendaView: View {
75 81
76 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch } 82 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
77 83
84 private var customPrefix: Bool {
85 switch agenda.mode {
86 case .agenda: prefixAgenda != PrefixFormat.defaults["agenda"]
87 case .todo: prefixTodo != PrefixFormat.defaults["todo"]
88 case .match: prefixTags != PrefixFormat.defaults["tags"]
89 }
90 }
91
78 @Environment(\.orgTheme) private var theme 92 @Environment(\.orgTheme) private var theme
79 93
80 var body: some View { 94 var body: some View {
@@ -174,6 +188,8 @@ struct AgendaView: View {
174 agenda.span = span 188 agenda.span = span
175 agenda.startOffset = startOffset 189 agenda.startOffset = startOffset
176 agenda.includeSubfolders = includeSubfolders 190 agenda.includeSubfolders = includeSubfolders
191 agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags]
192 agenda.options.logMode = logMode
177 let open = session.openTexts 193 let open = session.openTexts
178 await agenda.refresh(open: open) 194 await agenda.refresh(open: open)
179 } 195 }
@@ -190,7 +206,7 @@ struct AgendaView: View {
190 @ViewBuilder 206 @ViewBuilder
191 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View { 207 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View {
192 ForEach(Array(items.enumerated()), id: \.offset) { index, item in 208 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
193 AgendaRow(item: item, day: day) 209 AgendaRow(item: item, day: day, customPrefix: customPrefix)
194 .tag("\(prefix):\(index)") 210 .tag("\(prefix):\(index)")
195 .selectionDisabled(item.path == nil) 211 .selectionDisabled(item.path == nil)
196 } 212 }
@@ -281,6 +297,9 @@ struct AgendaView: View {
281 case "f" where isAgenda: agenda.shift(forward: true) 297 case "f" where isAgenda: agenda.shift(forward: true)
282 case "b" where isAgenda: agenda.shift(forward: false) 298 case "b" where isAgenda: agenda.shift(forward: false)
283 case "." where isAgenda: agenda.showToday() 299 case "." where isAgenda: agenda.showToday()
300 case "l" where isAgenda:
301 logMode.toggle()
302 message = logMode ? "Log mode on" : "Log mode off"
284 case "g", "r": 303 case "g", "r":
285 loadViews() 304 loadViews()
286 tick += 1 305 tick += 1
@@ -334,6 +353,8 @@ struct AgendaRow: View {
334 let item: AgendaItem 353 let item: AgendaItem
335 /// The agenda day the row is on, for habit graphs. 354 /// The agenda day the row is on, for habit graphs.
336 var day: Int? 355 var day: Int?
356 /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns.
357 var customPrefix = false
337 @Environment(\.orgTheme) private var theme 358 @Environment(\.orgTheme) private var theme
338 359
339 var body: some View { 360 var body: some View {
@@ -357,6 +378,19 @@ struct AgendaRow: View {
357 .font(.caption) 378 .font(.caption)
358 .foregroundStyle(theme.color("agenda-today") ?? .accentColor) 379 .foregroundStyle(theme.color("agenda-today") ?? .accentColor)
359 default: 380 default:
381 if customPrefix {
382 HStack(spacing: 0) {
383 Text(item.prefix)
384 .font(.body.monospaced())
385 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
386 title.lineLimit(1)
387 Spacer(minLength: 8)
388 if let habit = item.habit, let day { HabitGraph(cells: habit.graph(today: day), theme: theme) }
389 if !item.tags.isEmpty {
390 Text(":" + item.tags.joined(separator: ":") + ":").font(.caption).foregroundStyle(theme.color("tags") ?? .secondary)
391 }
392 }
393 } else {
360 HStack(spacing: 8) { 394 HStack(spacing: 8) {
361 Text(item.category) 395 Text(item.category)
362 .lineLimit(1) 396 .lineLimit(1)
@@ -385,6 +419,7 @@ struct AgendaRow: View {
385 .foregroundStyle(theme.color("tags") ?? .secondary) 419 .foregroundStyle(theme.color("tags") ?? .secondary)
386 } 420 }
387 } 421 }
422 }
388 } 423 }
389 } 424 }
390 425
Tests/OrgAppTests/EmacsImportTests.swift +4 −1
@@ -19,6 +19,7 @@ struct EmacsImportTests {
19 org-tags-column -80)) 19 org-tags-column -80))
20 (setq org-tags-column 4) 20 (setq org-tags-column 4)
21 (setq org-log-done 'note org-log-into-drawer t) 21 (setq org-log-done 'note org-log-into-drawer t)
22 (setq org-agenda-prefix-format '((agenda . " %i %-12:c%?-12t% s%b") (todo . " %-8:c")))
22 (setq fill-column (+ 70 2)) 23 (setq fill-column (+ 70 2))
23 (setq org-capture-templates 24 (setq org-capture-templates
24 '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a") 25 '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a")
@@ -71,6 +72,8 @@ struct EmacsImportTests {
71 #expect(s["org-pretty-entities"] == .bool(true)) 72 #expect(s["org-pretty-entities"] == .bool(true))
72 #expect(s["org-log-done"] == .string("note")) 73 #expect(s["org-log-done"] == .string("note"))
73 #expect(s["org-log-into-drawer"] == .string("LOGBOOK")) 74 #expect(s["org-log-into-drawer"] == .string("LOGBOOK"))
75 #expect(s["org-agenda-prefix-format.agenda"] == .string(" %i %-12:c%?-12t% s%b"))
76 #expect(s["org-agenda-prefix-format.todo"] == .string(" %-8:c"))
74 // after! org runs after the top-level setq. 77 // after! org runs after the top-level setq.
75 #expect(s["org-tags-column"] == .integer(-80)) 78 #expect(s["org-tags-column"] == .integer(-80))
76 #expect(s["org-agenda-span"] == .integer(10)) 79 #expect(s["org-agenda-span"] == .integer(10))
@@ -140,7 +143,7 @@ struct EmacsImportTests {
140 let keymap = folder.appendingPathComponent("keymap.toml") 143 let keymap = folder.appendingPathComponent("keymap.toml")
141 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) 144 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
142 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a")) 145 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a"))
143 #expect(summary.hasPrefix("Imported 14 settings, 3 capture templates, 3 key bindings."), "\(summary)") 146 #expect(summary.hasPrefix("Imported 16 settings, 3 capture templates, 3 key bindings."), "\(summary)")
144 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") 147 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
145 #expect(defaults.integer(forKey: "themeFontSize") == 14) 148 #expect(defaults.integer(forKey: "themeFontSize") == 14)
146 } 149 }
Tests/OrgCoreTests/AgendaTests.swift +98 −3
@@ -107,12 +107,26 @@ enum AgendaOracle {
107 line.replacingOccurrences(of: "[ \\t]+(:[^ \\t]+:)[ \\t]*$", with: " $1", options: .regularExpression) 107 line.replacingOccurrences(of: "[ \\t]+(:[^ \\t]+:)[ \\t]*$", with: " $1", options: .regularExpression)
108 } 108 }
109 109
110 static func compare(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30) throws { 110 /// `org-agenda-prefix-format` as Lisp.
111 static func prefixLisp(_ formats: [String: String]) -> String {
112 "'(" + formats.sorted { $0.key < $1.key }.map { "(\($0.key) . \"\($0.value)\")" }.joined(separator: " ") + ")"
113 }
114
115 static func compare(
116 files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30,
117 prefix: [String: String]? = nil, logMode: Bool = false
118 ) throws {
111 let date = Days.date(start) 119 let date = Days.date(start)
112 let emacs = try run(files: files, form: "(org-agenda-list nil \"\(String(format: "%04d-%02d-%02d", date.year, date.month, date.day))\" \(span))", today: today, hour: hour, minute: minute) 120 var form = "(org-agenda-list nil \"\(String(format: "%04d-%02d-%02d", date.year, date.month, date.day))\" \(span))"
121 if let prefix { form = "(let ((org-agenda-prefix-format \(prefixLisp(prefix)))) \(form))" }
122 if logMode { form = "(let ((org-agenda-start-with-log-mode t)) \(form))" }
123 let emacs = try run(files: files, form: form, today: today, hour: hour, minute: minute)
113 let defaults = OrgSettings.default.withTodoKeywords(keywords)! 124 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
114 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) } 125 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }
115 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: .terminal) 126 var options = AgendaOptions.terminal
127 if let prefix { options.prefixFormat = prefix }
128 options.logMode = logMode
129 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: options)
116 #expect(ours.count == emacs.count) 130 #expect(ours.count == emacs.count)
117 for (o, e) in zip(ours, emacs) { 131 for (o, e) in zip(ours, emacs) {
118 #expect(o.header == e.header) 132 #expect(o.header == e.header)
@@ -378,3 +392,84 @@ struct ReminderTests {
378 #expect(Set(reminders.map(\.id)).count == 6) 392 #expect(Set(reminders.map(\.id)).count == 6)
379 } 393 }
380} 394}
395
396struct AgendaPrefixFormatTests {
397 static let file = """
398 #+TODO: TODO NEXT | DONE
399 * Projects [1/2] :proj:
400 ** TODO [#A] Write report :work:
401 :PROPERTIES:
402 :Effort: 1:30
403 :END:
404 SCHEDULED: <2026-10-05 Mon 10:00>
405 *** NEXT Outline [[https://x.org][the plan]]
406 DEADLINE: <2026-10-06 Tue>
407 * Errands
408 :PROPERTIES:
409 :CATEGORY: averyverylongcategory
410 :END:
411 ** TODO Groceries 14:00
412 SCHEDULED: <2026-10-05 Mon>
413 """
414
415 static let formats = [
416 "agenda": " %i %-12:c%?-12t%-6e% s%l%b", "todo": " %-8.6c %e %?-4l|", "tags": "%c %?-5:l %b", "search": " %i %-12:c",
417 ]
418
419 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
420 func agendaMatchesEmacs() throws {
421 let today = Days.absolute(year: 2026, month: 10, day: 5)
422 try AgendaOracle.compare(files: [("p.org", Self.file)], start: today, span: 2, today: today, prefix: Self.formats)
423 }
424
425 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
426 func listsMatchEmacs() throws {
427 let today = Days.absolute(year: 2026, month: 10, day: 5)
428 var options = AgendaOptions()
429 options.prefixFormat = Self.formats
430 let lisp = AgendaOracle.prefixLisp(Self.formats)
431 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-todo-list))", today: today) {
432 Agenda.todoList($0, options: options)
433 }
434 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-tags-view nil \"work|proj\"))", today: today) {
435 Agenda.tagsMatch($0, match: "work|proj", options: options)
436 }
437 }
438
439 @Test func compilesFields() {
440 let format = PrefixFormat(" %-8.6c|%?-4t%e")
441 #expect(format.render(["c": "category", "e": "1:00"]) == " categ |1:00")
442 #expect(format.render(["c": "cat", "t": "9:00"]) == " cat |9:00")
443 }
444}
445
446struct AgendaLogModeTests {
447 static let file = """
448 #+TODO: TODO NEXT | DONE
449 * DONE Ship release :work:
450 CLOSED: [2026-10-05 Mon 16:20] SCHEDULED: <2026-10-05 Mon>
451 :LOGBOOK:
452 CLOCK: [2026-10-05 Mon 14:00]--[2026-10-05 Mon 15:30] => 1:30
453 - Fixed the build
454 CLOCK: [2026-10-04 Sun 22:00]--[2026-10-05 Mon 01:15] => 3:15
455 :END:
456 * TODO Writing
457 :LOGBOOK:
458 CLOCK: [2026-10-06 Tue 09:00]--[2026-10-06 Tue 09:45] => 0:45
459 CLOCK: [2026-10-06 Tue 11:00]
460 :END:
461 ** DONE Draft [1/1]
462 CLOSED: [2026-10-06 Tue]
463 * Archived :ARCHIVE:
464 CLOSED: [2026-10-05 Mon 10:00]
465 * Plain
466 Mentions CLOSED: [2026-10-05 Mon 09:00] in text.
467 """
468
469 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
470 func logModeMatchesEmacs() throws {
471 let today = Days.absolute(year: 2026, month: 10, day: 5)
472 try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, logMode: true)
473 try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, prefix: ["agenda": " %-8:c%?-12t%s %l"], logMode: true)
474 }
475}