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