krz/orgstar

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

Commit 10ef90cc41

10ef90cc418b7247f032161a68fdc0be32bf5fb2

parent: cb78d74dc4

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 04:50 UTC

Agenda item titles, planning days and skip-if-done

AgendaItem gains planningDay and title, priorityLetter and progress
parsed from its text. AgendaAge formats day counts compactly.
AgendaOptions gains skipScheduledIfDone and skipDeadlineIfDone,
compared against Emacs.

Layout: unified · split

Sources/OrgCore/Agenda/Agenda.swift +18 −6
@@ -48,6 +48,9 @@ public struct AgendaItem: Sendable, Equatable {
4848 public var prefix = ""
4949 /// The entry's `Effort` property.
5050 public var effort: String?
51 /// Deadlines and scheduled items: the day (as `Days` numbers it) of the timestamp the item
52 /// is for, the repeater's occurrence when the item shows on it.
53 public var planningDay: Int?
5154}
5255
5356public struct AgendaDay: Sendable, Equatable {
@@ -71,6 +74,11 @@ public struct AgendaOptions: Sendable, Equatable {
7174 public var logMode = false
7275 /// `org-agenda-log-mode-items`.
7376 public var logItems: Set<String> = ["closed", "clock"]
77 /// `org-agenda-skip-scheduled-if-done`: done entries' scheduled items are left out even
78 /// on the day they are scheduled.
79 public var skipScheduledIfDone = false
80 /// `org-agenda-skip-deadline-if-done`: done entries' deadlines are left out even on the day due.
81 public var skipDeadlineIfDone = false
7482
7583 public init() {}
7684
@@ -139,7 +147,7 @@ public enum Agenda {
139147 continue
140148 }
141149 }
142 if heading.isDone, deadline != current { continue }
150 if heading.isDone, options.skipDeadlineIfDone || deadline != current { continue }
143151 let time: Dotime? = current != deadline && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline)
144152 let extra: String
145153 if isToday, deadline < today {
@@ -150,10 +158,12 @@ public enum Agenda {
150158 extra = "Deadline: "
151159 }
152160 let upcoming = isToday && deadline > today
153 items.append(format(
161 var item = format(
154162 source, heading, kind: upcoming ? .upcomingDeadline : .deadline, marker: offset, extra: extra,
155163 dotime: time, removing: nil, trailing: options.gridTrailing, prefix: options.prefix("agenda"), urgency: { (isToday ? -diff : 0) + $0 }
156 ))
164 )
165 item.planningDay = current != deadline && current == repeat_ ? repeat_ : deadline
166 items.append(item)
157167 }
158168 return items
159169 }
@@ -171,17 +181,19 @@ public enum Agenda {
171181 || (current != schedule && current != today && current != repeat_) {
172182 continue
173183 }
174 if heading.isDone, schedule != current { continue }
184 if heading.isDone, options.skipScheduledIfDone || schedule != current { continue }
175185 // org-habit: habits only on today's agenda, and not when done.
176186 if heading.isHabit, heading.isDone || !isToday { continue }
177187 let time: Dotime? = !heading.isHabit && current != schedule && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline)
178188 let extra = heading.isHabit ? "" : isToday && past ? String(format: "Sched.%2dx: ", diff) : "Scheduled: "
179189 let habit = heading.isHabit ? heading.habit : nil
180 items.append(format(
190 var item = format(
181191 source, heading, kind: past ? .pastScheduled : .scheduled, marker: offset, extra: extra,
182192 dotime: time, removing: nil, trailing: options.gridTrailing, prefix: options.prefix("agenda"), habit: habit,
183193 urgency: { habit?.urgency(today: today) ?? 99 + diff + $0 }
184 ))
194 )
195 item.planningDay = current != schedule && current == repeat_ ? repeat_ : schedule
196 items.append(item)
185197 }
186198 return items
187199 }
Sources/OrgCore/Agenda/AgendaItemText.swift added +61
@@ -0,0 +1,61 @@
1import Foundation
2
3/// The parts of an item's heading text the agenda rows show apart: the title alone, the
4/// priority letter and the statistics cookie.
5extension AgendaItem {
6 static let priorityCookie = try! NSRegularExpression(pattern: "^\\[#([A-Z]|[0-9]+)\\][ \\t]*")
7 static let statisticsCookie = try! NSRegularExpression(pattern: "\\[([0-9]*(?:%|/[0-9]*))\\]")
8 static let anyTimestamp = try! NSRegularExpression(
9 pattern: "<%%\\([^>\\n]*>|<[0-9]{4}-[0-9]{2}-[0-9]{2}[^>\\n]*>(?:--<[0-9]{4}-[0-9]{2}-[0-9]{2}[^>\\n]*>)?|\\[[0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]\\n]*\\](?:--\\[[0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]\\n]*\\])?"
10 )
11
12 /// `text` after the TODO keyword.
13 private var afterKeyword: String {
14 var t = Substring(text.trimmingCharacters(in: .whitespaces))
15 if let todo, t.hasPrefix(todo), t.dropFirst(todo.count).first.map({ $0 == " " || $0 == "\t" }) ?? true {
16 t = t.dropFirst(todo.count)
17 }
18 return t.trimmingCharacters(in: .whitespaces)
19 }
20
21 /// The heading's title without TODO keyword, priority cookie, statistics cookies,
22 /// timestamps or tags, links as their descriptions, runs of spaces made one. The text itself
23 /// when nothing is left.
24 public var title: String {
25 var t = afterKeyword
26 t = Self.priorityCookie.stringByReplacingMatches(in: t, range: NSRange(location: 0, length: (t as NSString).length), withTemplate: "")
27 t = Self.statisticsCookie.stringByReplacingMatches(in: t, range: NSRange(location: 0, length: (t as NSString).length), withTemplate: "")
28 t = Self.anyTimestamp.stringByReplacingMatches(in: t, range: NSRange(location: 0, length: (t as NSString).length), withTemplate: "")
29 t = Links.displayFormat(t).replacingOccurrences(of: "[ \\t]+", with: " ", options: .regularExpression)
30 .trimmingCharacters(in: .whitespaces)
31 return t.isEmpty ? text.trimmingCharacters(in: .whitespaces) : t
32 }
33
34 /// The letter or number of the priority cookie (`[#A]`), when the heading has one.
35 public var priorityLetter: String? {
36 let t = afterKeyword
37 guard let m = Self.priorityCookie.firstMatch(in: t, range: NSRange(location: 0, length: (t as NSString).length)) else { return nil }
38 return (t as NSString).substring(with: m.range(at: 1))
39 }
40
41 /// The first statistics cookie's value: `1/3` or `50%`.
42 public var progress: String? {
43 let t = afterKeyword
44 guard let m = Self.statisticsCookie.firstMatch(in: t, range: NSRange(location: 0, length: (t as NSString).length)) else { return nil }
45 return (t as NSString).substring(with: m.range(at: 1))
46 }
47}
48
49/// Ages and distances in days, compactly: under 14 days in days (`3d`), under 60 in whole
50/// weeks (`2w`), under a year in 30-day months (`4mo`, at most 11), then years of 365 days with
51/// any remaining months (`1y`, `1y 4mo`). Values round down; the sign is ignored.
52public enum AgendaAge {
53 public static func compact(_ days: Int) -> String {
54 let d = abs(days)
55 if d < 14 { return "\(d)d" }
56 if d < 60 { return "\(d / 7)w" }
57 if d < 365 { return "\(min(11, d / 30))mo" }
58 let months = min(11, (d % 365) / 30)
59 return months == 0 ? "\(d / 365)y" : "\(d / 365)y \(months)mo"
60 }
61}
Tests/OrgCoreTests/AgendaItemTextTests.swift added +69
@@ -0,0 +1,69 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct AgendaItemTextTests {
6 static let file = """
7 * TODO [#A] Write the report [1/3] :work:urgent:
8 SCHEDULED: <2026-10-01 Thu>
9 * WAIT Call [[https://example.com][the bank]] about <2026-10-05 Mon> fees [50%]
10 DEADLINE: <2026-10-05 Mon>
11 * Plain heading with no keyword :home:
12 SCHEDULED: <2026-10-05 Mon 08:00-09:30>
13 * DONE [#C] Spaced out title
14 SCHEDULED: <2026-10-05 Mon>
15 * TODO Renew passport
16 DEADLINE: <2026-10-09 Fri>
17 * TODO Weekly review
18 SCHEDULED: <2026-09-28 Mon +1w>
19 """
20
21 static let today = Days.absolute(year: 2026, month: 10, day: 5)
22
23 static func items(on day: Int = today) -> [AgendaItem] {
24 let source = AgendaSource(path: "t.org", text: file, defaults: OrgSettings.default.withTodoKeywords("TODO WAIT | DONE KILL")!)
25 return Agenda.list([source], start: day, days: 1, today: today, now: (9, 0)).flatMap(\.items).filter { $0.path != nil }
26 }
27
28 static func item(_ title: String, on day: Int = today) -> AgendaItem {
29 items(on: day).first { $0.title == title && $0.kind != .timestamp }!
30 }
31
32 @Test func titlesLeaveOutKeywordPriorityCookiesTimestampsAndTags() {
33 let titles = Set(Self.items().map(\.title))
34 #expect(titles == ["Write the report", "Call the bank about fees", "Plain heading with no keyword", "Spaced out title", "Renew passport", "Weekly review"])
35 let report = Self.item("Write the report")
36 #expect(report.extra.hasPrefix("Sched."))
37 #expect(report.priorityLetter == "A" && report.progress == "1/3" && report.tags == ["work", "urgent"] && report.todo == "TODO")
38 let call = Self.item("Call the bank about fees")
39 #expect(call.priorityLetter == nil && call.progress == "50%" && call.todo == "WAIT")
40 let done = Self.item("Spaced out title")
41 #expect(done.isDone && done.priorityLetter == "C")
42 #expect(Self.item("Plain heading with no keyword").todo == nil)
43 }
44
45 @Test func planningDaysAreTheTimestampsDays() {
46 #expect(Self.item("Write the report").planningDay == Self.today - 4)
47 #expect(Self.item("Call the bank about fees").planningDay == Self.today)
48 #expect(Self.item("Renew passport").planningDay == Self.today + 4)
49 // A repeater's occurrence on a later day.
50 #expect(Self.item("Weekly review", on: Self.today + 7).planningDay == Self.today + 7)
51 #expect(Self.items().filter { $0.kind == .timestamp }.allSatisfy { $0.planningDay == nil })
52 }
53
54 @Test func skipIfDoneLeavesDoneEntriesOutOnTheirDay() {
55 let source = AgendaSource(path: "t.org", text: Self.file, defaults: OrgSettings.default.withTodoKeywords("TODO WAIT | DONE KILL")!)
56 var options = AgendaOptions()
57 #expect(Agenda.list([source], start: Self.today, days: 1, today: Self.today, now: (9, 0), options: options)[0].items.contains { $0.isDone })
58 options.skipScheduledIfDone = true
59 #expect(!Agenda.list([source], start: Self.today, days: 1, today: Self.today, now: (9, 0), options: options)[0].items.contains { $0.isDone })
60 }
61
62 @Test(arguments: [
63 (0, "0d"), (3, "3d"), (13, "13d"), (14, "2w"), (59, "8w"), (60, "2mo"), (120, "4mo"), (364, "11mo"),
64 (365, "1y"), (499, "1y 4mo"), (730, "2y"), (729, "1y 11mo"), (-3, "3d"),
65 ])
66 func compactAges(days: Int, text: String) {
67 #expect(AgendaAge.compact(days) == text)
68 }
69}
Tests/OrgCoreTests/AgendaTests.swift +25 −1
@@ -115,18 +115,21 @@ enum AgendaOracle {
115115
116116 static func compare(
117117 files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30,
118 prefix: [String: String]? = nil, logMode: Bool = false
118 prefix: [String: String]? = nil, logMode: Bool = false, skipIfDone: Bool = false
119119 ) throws {
120120 let date = Days.date(start)
121121 var form = "(org-agenda-list nil \"\(String(format: "%04d-%02d-%02d", date.year, date.month, date.day))\" \(span))"
122122 if let prefix { form = "(let ((org-agenda-prefix-format \(prefixLisp(prefix)))) \(form))" }
123123 if logMode { form = "(let ((org-agenda-start-with-log-mode t)) \(form))" }
124 if skipIfDone { form = "(let ((org-agenda-skip-scheduled-if-done t) (org-agenda-skip-deadline-if-done t)) \(form))" }
124125 let emacs = try run(files: files, form: form, today: today, hour: hour, minute: minute)
125126 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
126127 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }
127128 var options = AgendaOptions.terminal
128129 if let prefix { options.prefixFormat = prefix }
129130 options.logMode = logMode
131 options.skipScheduledIfDone = skipIfDone
132 options.skipDeadlineIfDone = skipIfDone
130133 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: options)
131134 #expect(ours.count == emacs.count)
132135 for (o, e) in zip(ours, emacs) {
@@ -325,6 +328,27 @@ struct AgendaTests {
325328 try AgendaOracle.compare(files: files, start: day + 1, span: 1, today: day)
326329 }
327330
331 static let doneEntries = """
332 * DONE Scheduled today
333 SCHEDULED: <2026-10-05 Mon>
334 * DONE Due today
335 DEADLINE: <2026-10-05 Mon>
336 * KILL Scheduled earlier
337 SCHEDULED: <2026-10-01 Thu>
338 * DONE Due later
339 DEADLINE: <2026-10-07 Wed>
340 * TODO Still open
341 SCHEDULED: <2026-10-05 Mon> DEADLINE: <2026-10-06 Tue>
342 """
343
344 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
345 func skipsDoneEntriesLikeEmacs() throws {
346 let today = Days.absolute(year: 2026, month: 10, day: 5)
347 for skip in [false, true] {
348 try AgendaOracle.compare(files: [("done.org", Self.doneEntries)], start: today - 4, span: 8, today: today, skipIfDone: skip)
349 }
350 }
351
328352 /// `ORGSTAR_ORACLE_CORPUS=<folder>`: the top-level files of the folder, ten days around
329353 /// two dates.
330354 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))