Commit 10ef90cc41
Verified · cmc
Layout: unified · split
Sources/OrgCore/Agenda/Agenda.swift +18 −6
| @@ -48,6 +48,9 @@ public struct AgendaItem: Sendable, Equatable { | ||
| 48 | 48 | public var prefix = "" |
| 49 | 49 | /// The entry's `Effort` property. |
| 50 | 50 | 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? | |
| 51 | 54 | } |
| 52 | 55 | |
| 53 | 56 | public struct AgendaDay: Sendable, Equatable { |
| @@ -71,6 +74,11 @@ public struct AgendaOptions: Sendable, Equatable { | ||
| 71 | 74 | public var logMode = false |
| 72 | 75 | /// `org-agenda-log-mode-items`. |
| 73 | 76 | 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 | |
| 74 | 82 | |
| 75 | 83 | public init() {} |
| 76 | 84 | |
| @@ -139,7 +147,7 @@ public enum Agenda { | ||
| 139 | 147 | continue |
| 140 | 148 | } |
| 141 | 149 | } |
| 142 | if heading.isDone, deadline != current { continue } | |
| 150 | if heading.isDone, options.skipDeadlineIfDone || deadline != current { continue } | |
| 143 | 151 | let time: Dotime? = current != deadline && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline) |
| 144 | 152 | let extra: String |
| 145 | 153 | if isToday, deadline < today { |
| @@ -150,10 +158,12 @@ public enum Agenda { | ||
| 150 | 158 | extra = "Deadline: " |
| 151 | 159 | } |
| 152 | 160 | let upcoming = isToday && deadline > today |
| 153 | items.append(format( | |
| 161 | var item = format( | |
| 154 | 162 | source, heading, kind: upcoming ? .upcomingDeadline : .deadline, marker: offset, extra: extra, |
| 155 | 163 | 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) | |
| 157 | 167 | } |
| 158 | 168 | return items |
| 159 | 169 | } |
| @@ -171,17 +181,19 @@ public enum Agenda { | ||
| 171 | 181 | || (current != schedule && current != today && current != repeat_) { |
| 172 | 182 | continue |
| 173 | 183 | } |
| 174 | if heading.isDone, schedule != current { continue } | |
| 184 | if heading.isDone, options.skipScheduledIfDone || schedule != current { continue } | |
| 175 | 185 | // org-habit: habits only on today's agenda, and not when done. |
| 176 | 186 | if heading.isHabit, heading.isDone || !isToday { continue } |
| 177 | 187 | let time: Dotime? = !heading.isHabit && current != schedule && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline) |
| 178 | 188 | let extra = heading.isHabit ? "" : isToday && past ? String(format: "Sched.%2dx: ", diff) : "Scheduled: " |
| 179 | 189 | let habit = heading.isHabit ? heading.habit : nil |
| 180 | items.append(format( | |
| 190 | var item = format( | |
| 181 | 191 | source, heading, kind: past ? .pastScheduled : .scheduled, marker: offset, extra: extra, |
| 182 | 192 | dotime: time, removing: nil, trailing: options.gridTrailing, prefix: options.prefix("agenda"), habit: habit, |
| 183 | 193 | urgency: { habit?.urgency(today: today) ?? 99 + diff + $0 } |
| 184 | )) | |
| 194 | ) | |
| 195 | item.planningDay = current != schedule && current == repeat_ ? repeat_ : schedule | |
| 196 | items.append(item) | |
| 185 | 197 | } |
| 186 | 198 | return items |
| 187 | 199 | } |
Sources/OrgCore/Agenda/AgendaItemText.swift added +61
| @@ -0,0 +1,61 @@ | ||
| 1 | import 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. | |
| 5 | extension 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. | |
| 52 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 { | ||
| 115 | 115 | |
| 116 | 116 | static func compare( |
| 117 | 117 | 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 | |
| 119 | 119 | ) throws { |
| 120 | 120 | let date = Days.date(start) |
| 121 | 121 | var form = "(org-agenda-list nil \"\(String(format: "%04d-%02d-%02d", date.year, date.month, date.day))\" \(span))" |
| 122 | 122 | if let prefix { form = "(let ((org-agenda-prefix-format \(prefixLisp(prefix)))) \(form))" } |
| 123 | 123 | 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))" } | |
| 124 | 125 | let emacs = try run(files: files, form: form, today: today, hour: hour, minute: minute) |
| 125 | 126 | let defaults = OrgSettings.default.withTodoKeywords(keywords)! |
| 126 | 127 | let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) } |
| 127 | 128 | var options = AgendaOptions.terminal |
| 128 | 129 | if let prefix { options.prefixFormat = prefix } |
| 129 | 130 | options.logMode = logMode |
| 131 | options.skipScheduledIfDone = skipIfDone | |
| 132 | options.skipDeadlineIfDone = skipIfDone | |
| 130 | 133 | let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: options) |
| 131 | 134 | #expect(ours.count == emacs.count) |
| 132 | 135 | for (o, e) in zip(ours, emacs) { |
| @@ -325,6 +328,27 @@ struct AgendaTests { | ||
| 325 | 328 | try AgendaOracle.compare(files: files, start: day + 1, span: 1, today: day) |
| 326 | 329 | } |
| 327 | 330 | |
| 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 | ||
| 328 | 352 | /// `ORGSTAR_ORACLE_CORPUS=<folder>`: the top-level files of the folder, ten days around |
| 329 | 353 | /// two dates. |
| 330 | 354 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) |