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 | 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 | ||
| 46 | public struct AgendaDay: Sendable, Equatable { | 50 | public 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 | |||
| 3 | extension Agenda { | 3 | extension 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 @@ | |||
| 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 | /// 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. |
| 10 | struct AgendaView: View { | 10 | struct 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 | |||
| 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 | } | ||