Agenda prefix format and log mode !73
10 files changed, +392 −28
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +2 −2
| @@ -65,9 +65,9 @@ public final class AgendaModel { | ||
| 65 | 65 | case .agenda: |
| 66 | 66 | days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options) |
| 67 | 67 | case .todo(let keywords): |
| 68 | list = Agenda.todoList(all, keywords: keywords) | |
| 68 | list = Agenda.todoList(all, keywords: keywords, options: options) | |
| 69 | 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 | 72 | return (days, list) |
| 73 | 73 | }.value |
Sources/OrgApp/EmacsImport.swift +10 −1
| @@ -460,6 +460,15 @@ public struct EmacsImport: Sendable { | ||
| 460 | 460 | } else { |
| 461 | 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 | 472 | case "org-log-done": |
| 464 | 473 | switch value.symbol { |
| 465 | 474 | case "nil": add(name, .string("nil")) |
| @@ -622,7 +631,7 @@ extension EmacsImport { | ||
| 622 | 631 | var problems: [String] = [] |
| 623 | 632 | for item in items { |
| 624 | 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 | 635 | if let problem = UserConfig.store(value, setting, in: defaults) { problems.append(problem) } else { settings += 1 } |
| 627 | 636 | } |
| 628 | 637 | for item in items { |
Sources/OrgApp/UserConfig.swift +6
| @@ -90,6 +90,12 @@ public enum UserConfig { | ||
| 90 | 90 | comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"), |
| 91 | 91 | Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), |
| 92 | 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 | 99 | Setting(section: "theme", key: "font", defaultsKey: "themeFont", kind: .string, fallback: .string(""), |
| 94 | 100 | comment: "a monospaced font family; \"\" for the system's", legacy: ""), |
| 95 | 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 | 12 | case currentTime |
| 13 | 13 | case todo |
| 14 | 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 | 19 | public let kind: Kind |
| @@ -41,6 +43,8 @@ public struct AgendaItem: Sendable, Equatable { | ||
| 41 | 43 | public let habit: Habit? |
| 42 | 44 | /// The line as org prints it, with one space before the tags. |
| 43 | 45 | public let line: String |
| 46 | /// What `org-agenda-prefix-format` puts before the heading text. | |
| 47 | public var prefix = "" | |
| 44 | 48 | } |
| 45 | 49 | |
| 46 | 50 | public struct AgendaDay: Sendable, Equatable { |
| @@ -58,9 +62,19 @@ public struct AgendaOptions: Sendable, Equatable { | ||
| 58 | 62 | public var gridTrailing = " ┄┄┄┄┄ " |
| 59 | 63 | public var gridLine = "┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄" |
| 60 | 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 | 72 | public init() {} |
| 63 | 73 | |
| 74 | func prefix(_ view: String) -> PrefixFormat { | |
| 75 | PrefixFormat(prefixFormat[view] ?? PrefixFormat.defaults[view]!) | |
| 76 | } | |
| 77 | ||
| 64 | 78 | /// What Emacs shows without a graphical display. |
| 65 | 79 | public static let terminal: AgendaOptions = { |
| 66 | 80 | var options = AgendaOptions() |
| @@ -91,7 +105,7 @@ public enum Agenda { | ||
| 91 | 105 | /// `org-agenda-get-day-entries` with the default entry types. |
| 92 | 106 | static func entries(_ source: AgendaSource, day: Int, today: Int, options: AgendaOptions) -> [AgendaItem] { |
| 93 | 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 | 109 | + timestamps(source, day: day, today: today, options: options) |
| 96 | 110 | } |
| 97 | 111 | |
| @@ -126,7 +140,7 @@ public enum Agenda { | ||
| 126 | 140 | let upcoming = isToday && deadline > today |
| 127 | 141 | items.append(format( |
| 128 | 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 | 146 | return items |
| @@ -153,7 +167,7 @@ public enum Agenda { | ||
| 153 | 167 | let habit = heading.isHabit ? heading.habit : nil |
| 154 | 168 | items.append(format( |
| 155 | 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 | 171 | urgency: { habit?.urgency(today: today) ?? 99 + diff + $0 } |
| 158 | 172 | )) |
| 159 | 173 | } |
| @@ -177,7 +191,45 @@ public enum Agenda { | ||
| 177 | 191 | } else { |
| 178 | 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 | 234 | return items |
| 183 | 235 | } |
| @@ -210,7 +262,7 @@ public enum Agenda { | ||
| 210 | 262 | let heading = source.headings[stamp.heading] |
| 211 | 263 | items.append(format( |
| 212 | 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 | 266 | urgency: { heading.isHabit ? heading.habit?.urgency(today: today) ?? $0 : $0 } |
| 215 | 267 | )) |
| 216 | 268 | } |
| @@ -229,6 +281,8 @@ public enum Agenda { | ||
| 229 | 281 | case string(String) |
| 230 | 282 | /// Look for a time in the heading only. |
| 231 | 283 | case headline |
| 284 | /// In the string only (`org-agenda-search-headline-for-time` nil). | |
| 285 | case only(String) | |
| 232 | 286 | } |
| 233 | 287 | |
| 234 | 288 | static let tagGroup = try! NSRegularExpression(pattern: "[ \\t]+(:([\\p{L}\\p{N}_@#%:]+):)[ \\t]*$") |
| @@ -246,10 +300,10 @@ public enum Agenda { | ||
| 246 | 300 | /// `org-agenda-highlight-todo`. |
| 247 | 301 | static func format( |
| 248 | 302 | _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int, |
| 249 | extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, hasTime: Bool = true, | |
| 250 | habit: Habit? = nil, urgency: (Int) -> Int | |
| 303 | extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, prefix prefixFormat: PrefixFormat, | |
| 304 | head: String? = nil, habit: Habit? = nil, urgency: (Int) -> Int | |
| 251 | 305 | ) -> AgendaItem { |
| 252 | var txt = heading.head.trimmingCharacters(in: .whitespaces) | |
| 306 | var txt = (head ?? heading.head).trimmingCharacters(in: .whitespaces) | |
| 253 | 307 | // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list. |
| 254 | 308 | let tags = heading.tags |
| 255 | 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 | 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 | 328 | if let removing { |
| 275 | 329 | while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) { |
| 276 | 330 | txt = (txt as NSString).replacingCharacters(in: m.range, with: "") |
| 277 | 331 | } |
| 278 | 332 | } |
| 279 | 333 | let category = heading.category |
| 280 | // ` %i %-12:c%?-12t% s` in the agenda, ` %i %-12:c` in TODO and tags lists. | |
| 281 | let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12) | |
| 282 | + (hasTime ? (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ") : "") | |
| 334 | let prefix = prefixFormat.render([ | |
| 335 | "c": category, "t": timed.time, "s": habit == nil ? extra : "", "l": String(repeating: " ", count: heading.level), | |
| 336 | "e": heading.properties["EFFORT"] ?? "", "b": breadcrumbs(source, heading), "T": tags.last?.name ?? "", | |
| 337 | ]) | |
| 283 | 338 | txt = highlightTodo(txt, keywords: source.keywords) |
| 284 | 339 | let priority = Self.priority(prefix + txt, source.priorities) |
| 285 | 340 | return AgendaItem( |
| @@ -287,10 +342,24 @@ public enum Agenda { | ||
| 287 | 342 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), |
| 288 | 343 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), |
| 289 | 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 | 363 | /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the |
| 295 | 364 | /// text, and formats the `%t` field. |
| 296 | 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 | 370 | switch dotime { |
| 302 | 371 | case .string(let s): containing = s + withoutStamps |
| 303 | 372 | case .headline: containing = withoutStamps |
| 373 | case .only(let s): containing = s | |
| 304 | 374 | } |
| 305 | 375 | guard let timeOfDay = time(of: containing) else { return (nil, "") } |
| 306 | 376 | let ns = containing as NSString |
| @@ -402,7 +472,7 @@ public enum Agenda { | ||
| 402 | 472 | return AgendaItem( |
| 403 | 473 | kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, |
| 404 | 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 | 478 | var new = options.gridTimes.reversed().map { t in |
Sources/OrgCore/Agenda/AgendaSearch.swift +7 −4
| @@ -3,7 +3,7 @@ import Foundation | ||
| 3 | 3 | extension Agenda { |
| 4 | 4 | /// `org-todo-list`: unfinished entries, or with `keywords` (`WAIT|HOLD`) those with any of |
| 5 | 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 | 7 | let selected = keywords.map { $0.split(separator: "|").map(String.init) } |
| 8 | 8 | var items: [AgendaItem] = [] |
| 9 | 9 | for source in sources { |
| @@ -16,7 +16,7 @@ extension Agenda { | ||
| 16 | 16 | } |
| 17 | 17 | items.append(format( |
| 18 | 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 | 26 | /// `org-tags-view`: entries matching a tags/property/TODO match string. `todoOnly` keeps |
| 27 | 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 | 32 | let matcher = TagsMatcher(match, todoOnly: todoOnly) |
| 30 | 33 | var items: [AgendaItem] = [] |
| 31 | 34 | for source in sources { |
| @@ -34,7 +37,7 @@ extension Agenda { | ||
| 34 | 37 | guard matcher.matches(heading, in: source, now: now, calendar: calendar), !heading.skipped else { continue } |
| 35 | 38 | items.append(format( |
| 36 | 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 | 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 | 77 | let headings: [Heading] |
| 60 | 78 | let stamps: [Stamp] |
| 61 | 79 | let blocks: [Block] |
| 80 | let progress: [Progress] | |
| 62 | 81 | /// The file's TODO keywords, active and done. |
| 63 | 82 | let keywords: [String] |
| 64 | 83 | let priorities: Priorities |
| @@ -243,6 +262,48 @@ public struct AgendaSource: Sendable { | ||
| 243 | 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 | 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 | 309 | /// `org-ts--internal-regexp`. |
Sources/OrgCore/Agenda/PrefixFormat.swift added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | import 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. | |
| 8 | public 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 | 6 | /// The agenda window: the day/week agenda, the TODO list, tag matches and saved views. |
| 7 | 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 | 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. |
| 10 | 10 | struct AgendaView: View { |
| 11 | 11 | let workspace: WorkspaceModel |
| @@ -22,9 +22,13 @@ struct AgendaView: View { | ||
| 22 | 22 | @State private var prompt: PendingPrompt? |
| 23 | 23 | @State private var promptText = "" |
| 24 | 24 | @State private var prefix = false |
| 25 | @State private var logMode = false | |
| 25 | 26 | @AppStorage("agendaSpan") private var span = 10 |
| 26 | 27 | @AppStorage("agendaStartOffset") private var startOffset = -3 |
| 27 | 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 | 32 | @Environment(\.openWindow) private var openWindow |
| 29 | 33 | |
| 30 | 34 | static let match = "Match…" |
| @@ -53,6 +57,8 @@ struct AgendaView: View { | ||
| 53 | 57 | var span: Int |
| 54 | 58 | var startOffset: Int |
| 55 | 59 | var includeSubfolders: Bool |
| 60 | var prefix: [String] | |
| 61 | var logMode: Bool | |
| 56 | 62 | var files: Int |
| 57 | 63 | var changed: Int? |
| 58 | 64 | var edits: Int |
| @@ -62,7 +68,7 @@ struct AgendaView: View { | ||
| 62 | 68 | |
| 63 | 69 | private var refreshKey: RefreshKey { |
| 64 | 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 | 72 | files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, |
| 67 | 73 | path: session.path, tick: tick |
| 68 | 74 | ) |
| @@ -75,6 +81,14 @@ struct AgendaView: View { | ||
| 75 | 81 | |
| 76 | 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 | 92 | @Environment(\.orgTheme) private var theme |
| 79 | 93 | |
| 80 | 94 | var body: some View { |
| @@ -174,6 +188,8 @@ struct AgendaView: View { | ||
| 174 | 188 | agenda.span = span |
| 175 | 189 | agenda.startOffset = startOffset |
| 176 | 190 | agenda.includeSubfolders = includeSubfolders |
| 191 | agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags] | |
| 192 | agenda.options.logMode = logMode | |
| 177 | 193 | let open = session.openTexts |
| 178 | 194 | await agenda.refresh(open: open) |
| 179 | 195 | } |
| @@ -190,7 +206,7 @@ struct AgendaView: View { | ||
| 190 | 206 | @ViewBuilder |
| 191 | 207 | private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View { |
| 192 | 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 | 210 | .tag("\(prefix):\(index)") |
| 195 | 211 | .selectionDisabled(item.path == nil) |
| 196 | 212 | } |
| @@ -281,6 +297,9 @@ struct AgendaView: View { | ||
| 281 | 297 | case "f" where isAgenda: agenda.shift(forward: true) |
| 282 | 298 | case "b" where isAgenda: agenda.shift(forward: false) |
| 283 | 299 | case "." where isAgenda: agenda.showToday() |
| 300 | case "l" where isAgenda: | |
| 301 | logMode.toggle() | |
| 302 | message = logMode ? "Log mode on" : "Log mode off" | |
| 284 | 303 | case "g", "r": |
| 285 | 304 | loadViews() |
| 286 | 305 | tick += 1 |
| @@ -334,6 +353,8 @@ struct AgendaRow: View { | ||
| 334 | 353 | let item: AgendaItem |
| 335 | 354 | /// The agenda day the row is on, for habit graphs. |
| 336 | 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 | 358 | @Environment(\.orgTheme) private var theme |
| 338 | 359 | |
| 339 | 360 | var body: some View { |
| @@ -357,6 +378,19 @@ struct AgendaRow: View { | ||
| 357 | 378 | .font(.caption) |
| 358 | 379 | .foregroundStyle(theme.color("agenda-today") ?? .accentColor) |
| 359 | 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 | 394 | HStack(spacing: 8) { |
| 361 | 395 | Text(item.category) |
| 362 | 396 | .lineLimit(1) |
| @@ -385,6 +419,7 @@ struct AgendaRow: View { | ||
| 385 | 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 | 19 | org-tags-column -80)) |
| 20 | 20 | (setq org-tags-column 4) |
| 21 | 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 | 23 | (setq fill-column (+ 70 2)) |
| 23 | 24 | (setq org-capture-templates |
| 24 | 25 | '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a") |
| @@ -71,6 +72,8 @@ struct EmacsImportTests { | ||
| 71 | 72 | #expect(s["org-pretty-entities"] == .bool(true)) |
| 72 | 73 | #expect(s["org-log-done"] == .string("note")) |
| 73 | 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 | 77 | // after! org runs after the top-level setq. |
| 75 | 78 | #expect(s["org-tags-column"] == .integer(-80)) |
| 76 | 79 | #expect(s["org-agenda-span"] == .integer(10)) |
| @@ -140,7 +143,7 @@ struct EmacsImportTests { | ||
| 140 | 143 | let keymap = folder.appendingPathComponent("keymap.toml") |
| 141 | 144 | #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) |
| 142 | 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 | 147 | #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") |
| 145 | 148 | #expect(defaults.integer(forKey: "themeFontSize") == 14) |
| 146 | 149 | } |
Tests/OrgCoreTests/AgendaTests.swift +98 −3
| @@ -107,12 +107,26 @@ enum AgendaOracle { | ||
| 107 | 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 | 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 | 124 | let defaults = OrgSettings.default.withTodoKeywords(keywords)! |
| 114 | 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 | 130 | #expect(ours.count == emacs.count) |
| 117 | 131 | for (o, e) in zip(ours, emacs) { |
| 118 | 132 | #expect(o.header == e.header) |
| @@ -378,3 +392,84 @@ struct ReminderTests { | ||
| 378 | 392 | #expect(Set(reminders.map(\.id)).count == 6) |
| 379 | 393 | } |
| 380 | 394 | } |
| 395 | ||
| 396 | struct 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 | ||
| 446 | struct 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 | } | |