Agenda search, views and commands !22
11 files changed, +1037 −74
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +29 −3
| @@ -10,6 +10,9 @@ import OrgIndex | ||
| 10 | 10 | @Observable |
| 11 | 11 | public final class AgendaModel { |
| 12 | 12 | public private(set) var days: [AgendaDay] = [] |
| 13 | /// The entries of a TODO list or match view. | |
| 14 | public private(set) var list: [AgendaItem] = [] | |
| 15 | public var mode: AgendaMode = .agenda(span: nil, startOffset: nil) | |
| 13 | 16 | /// The first day shown, as `Days` numbers it; nil follows today. |
| 14 | 17 | public var start: Int? |
| 15 | 18 | public var span = 10 |
| @@ -44,6 +47,12 @@ public final class AgendaModel { | ||
| 44 | 47 | isComputing = true |
| 45 | 48 | let today = Days.absolute(now, calendar: calendar) |
| 46 | 49 | let clock = calendar.dateComponents([.hour, .minute], from: now) |
| 50 | var span = self.span | |
| 51 | var startOffset = self.startOffset | |
| 52 | if case .agenda(let viewSpan, let viewStart) = mode { | |
| 53 | span = viewSpan ?? span | |
| 54 | startOffset = viewStart ?? startOffset | |
| 55 | } | |
| 47 | 56 | let first = start ?? today + startOffset |
| 48 | 57 | let defaults = workspace.settings.org |
| 49 | 58 | var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = [] |
| @@ -58,8 +67,8 @@ public final class AgendaModel { | ||
| 58 | 67 | let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil } |
| 59 | 68 | reads.append((path, cached, mtime, size, nil)) |
| 60 | 69 | } |
| 61 | let span = self.span | |
| 62 | 70 | let options = self.options |
| 71 | let mode = self.mode | |
| 63 | 72 | let result = await Task.detached { |
| 64 | 73 | var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = [] |
| 65 | 74 | for read in reads { |
| @@ -72,14 +81,25 @@ public final class AgendaModel { | ||
| 72 | 81 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true)) |
| 73 | 82 | } |
| 74 | 83 | } |
| 75 | let days = Agenda.list(sources.map(\.source), start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options) | |
| 76 | return (sources, days) | |
| 84 | let all = sources.map(\.source) | |
| 85 | var days: [AgendaDay] = [] | |
| 86 | var list: [AgendaItem] = [] | |
| 87 | switch mode { | |
| 88 | case .agenda: | |
| 89 | days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options) | |
| 90 | case .todo(let keywords): | |
| 91 | list = Agenda.todoList(all, keywords: keywords) | |
| 92 | case .match(let match, let todoOnly): | |
| 93 | list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar) | |
| 94 | } | |
| 95 | return (sources, days, list) | |
| 77 | 96 | }.value |
| 78 | 97 | guard id == generation else { return } |
| 79 | 98 | for source in result.0 where source.fromDisk { |
| 80 | 99 | cache[source.path] = (source.mtime, source.size, source.source) |
| 81 | 100 | } |
| 82 | 101 | days = result.1 |
| 102 | list = result.2 | |
| 83 | 103 | isComputing = false |
| 84 | 104 | } |
| 85 | 105 | |
| @@ -87,6 +107,12 @@ public final class AgendaModel { | ||
| 87 | 107 | |
| 88 | 108 | /// Moves the view by a whole span, forward or back. |
| 89 | 109 | public func shift(forward: Bool, now: Date = Date(), calendar: Calendar = .current) { |
| 110 | var span = self.span | |
| 111 | var startOffset = self.startOffset | |
| 112 | if case .agenda(let viewSpan, let viewStart) = mode { | |
| 113 | span = viewSpan ?? span | |
| 114 | startOffset = viewStart ?? startOffset | |
| 115 | } | |
| 90 | 116 | let current = start ?? Days.absolute(now, calendar: calendar) + startOffset |
| 91 | 117 | start = current + (forward ? span : -span) |
| 92 | 118 | } |
Sources/OrgApp/AgendaViews.swift added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | ||
| 4 | /// What the agenda window shows. | |
| 5 | public enum AgendaMode: Hashable, Sendable { | |
| 6 | /// `org-agenda-list`; nil span and start use the settings. | |
| 7 | case agenda(span: Int?, startOffset: Int?) | |
| 8 | /// `org-todo-list`, optionally for `KWD1|KWD2`. | |
| 9 | case todo(keywords: String?) | |
| 10 | /// `org-tags-view`. | |
| 11 | case match(String, todoOnly: Bool) | |
| 12 | } | |
| 13 | ||
| 14 | /// A named view: the built-in ones and those in `views.toml`. | |
| 15 | public struct AgendaViewDefinition: Hashable, Sendable, Identifiable { | |
| 16 | public let name: String | |
| 17 | public let mode: AgendaMode | |
| 18 | public var id: String { name } | |
| 19 | ||
| 20 | public init(name: String, mode: AgendaMode) { | |
| 21 | self.name = name | |
| 22 | self.mode = mode | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | /// Reads `views.toml`: | |
| 27 | /// | |
| 28 | /// ```toml | |
| 29 | /// [[view]] | |
| 30 | /// name = "Work" | |
| 31 | /// type = "match" # agenda, todo, match or todo-match | |
| 32 | /// match = "+work-boss" | |
| 33 | /// ``` | |
| 34 | /// | |
| 35 | /// `agenda` views take `span` and `start` (days from today), `todo` views `keywords` | |
| 36 | /// (`WAIT|HOLD`). | |
| 37 | @MainActor | |
| 38 | public enum AgendaViewLoader { | |
| 39 | public static var userFile: URL { WorkspaceModel.defaultDirectory.appendingPathComponent("views.toml") } | |
| 40 | ||
| 41 | public static let builtIn = [ | |
| 42 | AgendaViewDefinition(name: "Agenda", mode: .agenda(span: nil, startOffset: nil)), | |
| 43 | AgendaViewDefinition(name: "TODO List", mode: .todo(keywords: nil)), | |
| 44 | ] | |
| 45 | ||
| 46 | /// The built-in views followed by the user's, and the problems found in the file. | |
| 47 | public static func load(userFile: URL = userFile) -> (views: [AgendaViewDefinition], problems: [String]) { | |
| 48 | guard let text = try? String(contentsOf: userFile, encoding: .utf8) else { return (builtIn, []) } | |
| 49 | let tables: [TOML.Table] | |
| 50 | do { | |
| 51 | tables = try TOML.parse(text) | |
| 52 | } catch { | |
| 53 | return (builtIn, ["views.toml: \(error)"]) | |
| 54 | } | |
| 55 | var views = builtIn | |
| 56 | var problems: [String] = [] | |
| 57 | for table in tables where !(table.name.isEmpty && table.values.isEmpty) { | |
| 58 | guard table.name == "view", table.isArrayElement else { | |
| 59 | problems.append("views.toml line \(table.line): expected [[view]]") | |
| 60 | continue | |
| 61 | } | |
| 62 | guard let name = table.values["name"]?.string else { | |
| 63 | problems.append("views.toml line \(table.line): a view needs a name") | |
| 64 | continue | |
| 65 | } | |
| 66 | let match = table.values["match"]?.string | |
| 67 | let mode: AgendaMode | |
| 68 | switch table.values["type"]?.string ?? "agenda" { | |
| 69 | case "agenda": | |
| 70 | mode = .agenda(span: table.values["span"]?.integer, startOffset: table.values["start"]?.integer) | |
| 71 | case "todo": | |
| 72 | mode = .todo(keywords: table.values["keywords"]?.string) | |
| 73 | case "match", "todo-match": | |
| 74 | guard let match else { | |
| 75 | problems.append("views.toml line \(table.line): \(name) needs a match") | |
| 76 | continue | |
| 77 | } | |
| 78 | mode = .match(match, todoOnly: table.values["type"]?.string == "todo-match") | |
| 79 | case let other: | |
| 80 | problems.append("views.toml line \(table.line): unknown type \(other)") | |
| 81 | continue | |
| 82 | } | |
| 83 | views.append(AgendaViewDefinition(name: name, mode: mode)) | |
| 84 | } | |
| 85 | return (views, problems) | |
| 86 | } | |
| 87 | } | |
Sources/OrgApp/DocumentSession.swift +45
| @@ -271,6 +271,51 @@ public final class DocumentSession { | ||
| 271 | 271 | } |
| 272 | 272 | } |
| 273 | 273 | |
| 274 | // MARK: - Commands from other views | |
| 275 | ||
| 276 | /// Runs `command` with the caret at `offset` in the file at `path`, as the agenda does: | |
| 277 | /// through the buffer when the file is open, leaving its caret alone; otherwise on the | |
| 278 | /// file, written back at once through the save path. `expect` is the heading line the | |
| 279 | /// caller saw at a position; if the text there changed, nothing runs. | |
| 280 | public func perform( | |
| 281 | _ command: any OrgCommand, path: String, at offset: Int, expect: (start: Int, line: String)?, | |
| 282 | answers: [String: String] = [:], now: Date = Date() | |
| 283 | ) -> CommandStep { | |
| 284 | let stale = CommandStep.failed("The entry changed; the agenda has been refreshed.") | |
| 285 | func matches(_ text: String) -> Bool { | |
| 286 | guard let expect else { return true } | |
| 287 | let ns = text as NSString | |
| 288 | guard expect.start + (expect.line as NSString).length <= ns.length else { return false } | |
| 289 | return ns.substring(with: NSRange(location: expect.start, length: (expect.line as NSString).length)) == expect.line | |
| 290 | } | |
| 291 | if path == self.path, let buffer { | |
| 292 | guard matches(buffer.document.text) else { return stale } | |
| 293 | do { | |
| 294 | let options = self.options | |
| 295 | let step = try buffer.update { try $0.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) } | |
| 296 | if case .commit = step { bufferChanged() } | |
| 297 | return step | |
| 298 | } catch { | |
| 299 | return .failed("\(error)") | |
| 300 | } | |
| 301 | } | |
| 302 | let url = URL(fileURLWithPath: path) | |
| 303 | let name = url.lastPathComponent | |
| 304 | do { | |
| 305 | guard let bytes = try fileSystem.read(url) else { return .failed("\(name) no longer exists.") } | |
| 306 | var state = DocumentState(bytes: bytes, defaults: defaults) | |
| 307 | guard state.isEditable else { return .failed("\(name) isn't UTF-8, so it can't be changed.") } | |
| 308 | guard matches(state.text) else { return stale } | |
| 309 | let step = try state.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) | |
| 310 | if case .commit = step, state.isDirty, case .conflict = try saver.save(&state, to: url) { | |
| 311 | return .failed("\(name) changed on disk; nothing was changed.") | |
| 312 | } | |
| 313 | return step | |
| 314 | } catch { | |
| 315 | return .failed("Can't change \(name): \(error.localizedDescription)") | |
| 316 | } | |
| 317 | } | |
| 318 | ||
| 274 | 319 | // MARK: - Locations |
| 275 | 320 | |
| 276 | 321 | /// Opens the file of `location` if needed and asks the editor to show the heading. Offsets |
Sources/OrgCore/Agenda/Agenda.swift +17 −10
| @@ -10,12 +10,16 @@ public struct AgendaItem: Sendable, Equatable { | ||
| 10 | 10 | case block |
| 11 | 11 | case timeGrid |
| 12 | 12 | case currentTime |
| 13 | case todo | |
| 14 | case tagsMatch = "tagsmatch" | |
| 13 | 15 | } |
| 14 | 16 | |
| 15 | 17 | public let kind: Kind |
| 16 | 18 | public let path: String? |
| 17 | 19 | /// Start of the entry's heading line. |
| 18 | 20 | public let headingOffset: Int? |
| 21 | /// The heading line as it was read, from its stars, to check the offsets still hold. | |
| 22 | public let headingLine: String? | |
| 19 | 23 | /// Where org's marker points: the timestamp, or the end of a range. |
| 20 | 24 | public let markerOffset: Int? |
| 21 | 25 | public let category: String |
| @@ -93,7 +97,7 @@ public enum Agenda { | ||
| 93 | 97 | let isToday = current == today |
| 94 | 98 | var items: [AgendaItem] = [] |
| 95 | 99 | for heading in source.headings { |
| 96 | guard let (s, offset) = heading.deadline, let deadline = Days.absolute(of: s) else { continue } | |
| 100 | guard !heading.skipped, let (s, offset) = heading.deadline, let deadline = Days.absolute(of: s) else { continue } | |
| 97 | 101 | let repeat_ = current <= today ? deadline : (Days.closest(s, to: current, prefer: .future) ?? deadline) |
| 98 | 102 | let diff = deadline - current |
| 99 | 103 | let warningDays = Days.warningDays(s, delay: false, defaultDays: options.deadlineWarningDays) |
| @@ -128,7 +132,7 @@ public enum Agenda { | ||
| 128 | 132 | let isToday = current == today |
| 129 | 133 | var items: [AgendaItem] = [] |
| 130 | 134 | for heading in source.headings { |
| 131 | guard let (s, offset) = heading.scheduled, let schedule = Days.absolute(of: s) else { continue } | |
| 135 | guard !heading.skipped, let (s, offset) = heading.scheduled, let schedule = Days.absolute(of: s) else { continue } | |
| 132 | 136 | let repeat_ = current <= today ? schedule : (Days.closest(s, to: current, prefer: .future) ?? schedule) |
| 133 | 137 | let diff = current - schedule |
| 134 | 138 | let past = schedule < today |
| @@ -233,7 +237,8 @@ public enum Agenda { | ||
| 233 | 237 | /// `org-agenda-highlight-todo`. |
| 234 | 238 | static func format( |
| 235 | 239 | _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int, |
| 236 | extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, urgency: (Int) -> Int | |
| 240 | extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, hasTime: Bool = true, | |
| 241 | urgency: (Int) -> Int | |
| 237 | 242 | ) -> AgendaItem { |
| 238 | 243 | var txt = heading.head.trimmingCharacters(in: .whitespaces) |
| 239 | 244 | // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list. |
| @@ -256,18 +261,20 @@ public enum Agenda { | ||
| 256 | 261 | tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":") |
| 257 | 262 | } |
| 258 | 263 | |
| 259 | let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing) | |
| 264 | let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing, removeTimes: hasTime) | |
| 260 | 265 | if let removing { |
| 261 | 266 | while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) { |
| 262 | 267 | txt = (txt as NSString).replacingCharacters(in: m.range, with: "") |
| 263 | 268 | } |
| 264 | 269 | } |
| 265 | 270 | let category = heading.category |
| 266 | let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12) + (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ") | |
| 271 | // ` %i %-12:c%?-12t% s` in the agenda, ` %i %-12:c` in TODO and tags lists. | |
| 272 | let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12) | |
| 273 | + (hasTime ? (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ") : "") | |
| 267 | 274 | txt = highlightTodo(txt, keywords: source.keywords) |
| 268 | 275 | let priority = Self.priority(prefix + txt, source.priorities) |
| 269 | 276 | return AgendaItem( |
| 270 | kind: kind, path: source.path, headingOffset: heading.start, markerOffset: marker, category: category, | |
| 277 | kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category, | |
| 271 | 278 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), |
| 272 | 279 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), |
| 273 | 280 | line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString) |
| @@ -276,7 +283,7 @@ public enum Agenda { | ||
| 276 | 283 | |
| 277 | 284 | /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the |
| 278 | 285 | /// text, and formats the `%t` field. |
| 279 | static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String) -> (timeOfDay: Int?, time: String) { | |
| 286 | static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String, removeTimes: Bool = true) -> (timeOfDay: Int?, time: String) { | |
| 280 | 287 | guard let dotime else { return (nil, "") } |
| 281 | 288 | let full = tagString.isEmpty ? txt : txt + " " + tagString |
| 282 | 289 | let withoutStamps = stampAny.stringByReplacingMatches(in: full, range: NSRange(location: 0, length: (full as NSString).length), withTemplate: "") |
| @@ -294,11 +301,11 @@ public enum Agenda { | ||
| 294 | 301 | start = ns.substring(with: m.range(at: 2)) |
| 295 | 302 | let endGroup = m.range(at: 3).location != NSNotFound ? 4 : 6 |
| 296 | 303 | if m.range(at: endGroup).location != NSNotFound { end = ns.substring(with: m.range(at: endGroup)) } |
| 297 | removeTime(ns.substring(with: m.range), from: &txt) | |
| 304 | if removeTimes { removeTime(ns.substring(with: m.range), from: &txt) } | |
| 298 | 305 | } else if let m = plainTime.firstMatch(in: containing, range: all) { |
| 299 | 306 | start = ns.substring(with: m.range(at: 1)) |
| 300 | 307 | if m.range(at: 8).location != NSNotFound { end = ns.substring(with: m.range(at: 8)) } |
| 301 | removeTime(ns.substring(with: m.range), from: &txt) | |
| 308 | if removeTimes { removeTime(ns.substring(with: m.range), from: &txt) } | |
| 302 | 309 | } |
| 303 | 310 | let startText = start.flatMap(formattedTime).map { String(repeating: " ", count: max(0, 5 - $0.count)) + $0 } |
| 304 | 311 | let endText = end.flatMap(formattedTime) |
| @@ -383,7 +390,7 @@ public enum Agenda { | ||
| 383 | 390 | var txt = text |
| 384 | 391 | let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing) |
| 385 | 392 | return AgendaItem( |
| 386 | kind: kind, path: nil, headingOffset: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, | |
| 393 | kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, | |
| 387 | 394 | time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, |
| 388 | 395 | line: " " + pad("", 12) + pad(timed.time, 12) + txt |
| 389 | 396 | ) |
Sources/OrgCore/Agenda/AgendaSearch.swift added +364
| @@ -0,0 +1,364 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | extension Agenda { | |
| 4 | /// `org-todo-list`: unfinished entries, or with `keywords` (`WAIT|HOLD`) those with any of | |
| 5 | /// the keywords. | |
| 6 | public static func todoList(_ sources: [AgendaSource], keywords: String? = nil) -> [AgendaItem] { | |
| 7 | let selected = keywords.map { $0.split(separator: "|").map(String.init) } | |
| 8 | var items: [AgendaItem] = [] | |
| 9 | for source in sources { | |
| 10 | for heading in source.headings where !heading.skipped { | |
| 11 | guard let todo = heading.todo else { continue } | |
| 12 | if let selected { | |
| 13 | guard selected.contains(todo) else { continue } | |
| 14 | } else if heading.isDone { | |
| 15 | continue | |
| 16 | } | |
| 17 | items.append(format( | |
| 18 | source, heading, kind: .todo, marker: heading.start, extra: "", dotime: .headline, removing: nil, | |
| 19 | trailing: "", hasTime: false, urgency: { 1 + $0 } | |
| 20 | )) | |
| 21 | } | |
| 22 | } | |
| 23 | return sortByUrgency(items) | |
| 24 | } | |
| 25 | ||
| 26 | /// `org-tags-view`: entries matching a tags/property/TODO match string. `todoOnly` keeps | |
| 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] { | |
| 29 | let matcher = TagsMatcher(match, todoOnly: todoOnly) | |
| 30 | var items: [AgendaItem] = [] | |
| 31 | for source in sources { | |
| 32 | for heading in source.headings { | |
| 33 | if matcher.todoOnly, heading.todo == nil { continue } | |
| 34 | guard matcher.matches(heading, in: source, now: now, calendar: calendar), !heading.skipped else { continue } | |
| 35 | items.append(format( | |
| 36 | source, heading, kind: .tagsMatch, marker: heading.start, extra: "", dotime: nil, removing: nil, | |
| 37 | trailing: "", hasTime: false, urgency: { $0 } | |
| 38 | )) | |
| 39 | } | |
| 40 | } | |
| 41 | return sortByUrgency(items) | |
| 42 | } | |
| 43 | ||
| 44 | /// `(urgency-down category-keep)`, stably. | |
| 45 | static func sortByUrgency(_ items: [AgendaItem]) -> [AgendaItem] { | |
| 46 | items.enumerated().sorted { a, b in | |
| 47 | a.element.urgency != b.element.urgency ? a.element.urgency > b.element.urgency : a.offset < b.offset | |
| 48 | }.map(\.element) | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | /// `org-make-tags-matcher`: `+work-boss|LEVEL>2+TODO="WAIT"/!NEXT`. | |
| 53 | public struct TagsMatcher: Sendable { | |
| 54 | indirect enum Term: Sendable { | |
| 55 | case tag(String) | |
| 56 | case tagRegex(String) | |
| 57 | case property(name: String, op: String, value: Value, needsValue: Bool) | |
| 58 | case not(Term) | |
| 59 | } | |
| 60 | ||
| 61 | enum Value: Sendable { | |
| 62 | case regex(String) | |
| 63 | case string(String) | |
| 64 | case time(String) | |
| 65 | case number(Double) | |
| 66 | } | |
| 67 | ||
| 68 | /// Alternatives of conjunctions; nil matches everything. | |
| 69 | let tags: [[Term]]? | |
| 70 | let todo: [[(regex: Bool, keyword: String, negated: Bool)]]? | |
| 71 | let todoOnly: Bool | |
| 72 | ||
| 73 | static let term = try! NSRegularExpression(pattern: | |
| 74 | "^&?([-+:])?(\\{[^}]+\\}|(?:((?:[\\p{L}\\p{N}_]|\\\\[^\\s])+)([<=>]=?|[!/]=|<>)(\\*)?(\\{[^}]+\\}|\"[^\"]*\"|-?[.0-9]+(?:[eE][-+]?[0-9]+)?))|[\\p{L}\\p{N}_@#%]+)" | |
| 75 | ) | |
| 76 | static let timeValue = try! NSRegularExpression(pattern: "^\"[\\[<](?:[0-9]+|now|today|tomorrow|[+-][0-9]+[dmwy]).*[\\]>]\"$") | |
| 77 | ||
| 78 | public init(_ match: String, todoOnly: Bool = false) { | |
| 79 | var only = todoOnly | |
| 80 | var tagsPart = match | |
| 81 | var todoPart: String? | |
| 82 | let ns = match as NSString | |
| 83 | let slashes = try! NSRegularExpression(pattern: "/+") | |
| 84 | if let last = slashes.matches(in: match, range: NSRange(location: 0, length: ns.length)).last, | |
| 85 | !ns.substring(from: last.range.location).contains("\"") { | |
| 86 | tagsPart = ns.substring(to: last.range.location) | |
| 87 | var rest = ns.substring(from: NSMaxRange(last.range)) | |
| 88 | if rest.hasPrefix("!") { | |
| 89 | only = true | |
| 90 | rest.removeFirst() | |
| 91 | } | |
| 92 | todoPart = rest.trimmingCharacters(in: .whitespaces).isEmpty ? nil : rest | |
| 93 | } | |
| 94 | self.todoOnly = only | |
| 95 | ||
| 96 | func terms(_ s: String) -> [(sign: String?, match: NSTextCheckingResult, text: NSString)] { | |
| 97 | var result: [(String?, NSTextCheckingResult, NSString)] = [] | |
| 98 | var rest = s | |
| 99 | while let m = Self.term.firstMatch(in: rest, range: NSRange(location: 0, length: (rest as NSString).length)) { | |
| 100 | let text = rest as NSString | |
| 101 | let sign = m.range(at: 1).location != NSNotFound ? text.substring(with: m.range(at: 1)) : nil | |
| 102 | result.append((sign, m, text)) | |
| 103 | rest = text.substring(from: NSMaxRange(m.range)) | |
| 104 | if m.range.length == 0 { break } | |
| 105 | } | |
| 106 | return result | |
| 107 | } | |
| 108 | ||
| 109 | if tagsPart.contains(where: { !$0.isWhitespace }) { | |
| 110 | var alternatives: [[Term]] = [] | |
| 111 | var parts = Self.split(tagsPart) | |
| 112 | var i = 0 | |
| 113 | while i < parts.count { | |
| 114 | var part = parts[i] | |
| 115 | // A `\|` inside a term is not a separator. | |
| 116 | while part.hasSuffix("\\"), i + 1 < parts.count { | |
| 117 | i += 1 | |
| 118 | part += "|" + parts[i] | |
| 119 | } | |
| 120 | var conjunction: [Term] = [] | |
| 121 | for (sign, m, text) in terms(part) { | |
| 122 | let whole = text.substring(with: m.range(at: 2)) | |
| 123 | var term: Term | |
| 124 | if whole.hasPrefix("{") { | |
| 125 | term = .tagRegex(String(whole.dropFirst().dropLast())) | |
| 126 | } else if m.range(at: 3).location != NSNotFound { | |
| 127 | let name = text.substring(with: m.range(at: 3)) | |
| 128 | .replacingOccurrences(of: "\\\\(.)", with: "$1", options: .regularExpression).uppercased() | |
| 129 | let raw = text.substring(with: m.range(at: 6)) | |
| 130 | let value: Value | |
| 131 | if raw.hasPrefix("{") { | |
| 132 | value = .regex(String(raw.dropFirst().dropLast())) | |
| 133 | } else if raw.hasPrefix("\"") { | |
| 134 | let inner = String(raw.dropFirst().dropLast()) | |
| 135 | let isTime = Self.timeValue.firstMatch(in: raw, range: NSRange(location: 0, length: (raw as NSString).length)) != nil | |
| 136 | value = isTime ? .time(inner) : .string(inner) | |
| 137 | } else { | |
| 138 | value = .number(Self.number(raw)) | |
| 139 | } | |
| 140 | term = .property(name: name, op: text.substring(with: m.range(at: 4)), value: value, needsValue: m.range(at: 5).location != NSNotFound) | |
| 141 | } else { | |
| 142 | term = .tag(whole) | |
| 143 | } | |
| 144 | if sign == "-" { term = .not(term) } | |
| 145 | conjunction.append(term) | |
| 146 | } | |
| 147 | alternatives.append(conjunction) | |
| 148 | i += 1 | |
| 149 | } | |
| 150 | tags = alternatives | |
| 151 | } else { | |
| 152 | tags = nil | |
| 153 | } | |
| 154 | ||
| 155 | if let todoPart { | |
| 156 | todo = Self.split(todoPart).map { part in | |
| 157 | terms(part).map { sign, m, text in | |
| 158 | let keyword = text.substring(with: m.range(at: 2)) | |
| 159 | return keyword.hasPrefix("{") | |
| 160 | ? (true, String(keyword.dropFirst().dropLast()), sign == "-") | |
| 161 | : (false, keyword, sign == "-") | |
| 162 | } | |
| 163 | } | |
| 164 | } else { | |
| 165 | todo = nil | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | /// `org-split-string` on `|`: empty strings between separators stay. | |
| 170 | static func split(_ s: String) -> [String] { | |
| 171 | var parts = s.components(separatedBy: "|") | |
| 172 | if parts.first == "" { parts.removeFirst() } | |
| 173 | if parts.last == "", !parts.isEmpty { parts.removeLast() } | |
| 174 | return parts.isEmpty ? [""] : parts | |
| 175 | } | |
| 176 | ||
| 177 | /// `string-to-number`: the leading number, or 0. | |
| 178 | static func number(_ s: String) -> Double { | |
| 179 | let trimmed = s.drop { $0 == " " || $0 == "\t" } | |
| 180 | guard let r = trimmed.range(of: "^[-+]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][-+]?[0-9]+)?", options: .regularExpression) else { return 0 } | |
| 181 | return Double(trimmed[r]) ?? 0 | |
| 182 | } | |
| 183 | ||
| 184 | func matches(_ heading: AgendaSource.Heading, in source: AgendaSource, now: Date, calendar: Calendar) -> Bool { | |
| 185 | let todoKeyword = heading.todo | |
| 186 | if todoOnly { | |
| 187 | guard let todoKeyword, !heading.isDone else { return false } | |
| 188 | _ = todoKeyword | |
| 189 | } | |
| 190 | let tagList = heading.tags.map(\.name) | |
| 191 | func value(_ name: String) -> String? { | |
| 192 | switch name { | |
| 193 | case "LEVEL": return String(heading.level) | |
| 194 | case "CATEGORY": return heading.category | |
| 195 | case "TODO": return heading.todo | |
| 196 | case "ITEM": return heading.title | |
| 197 | case "PRIORITY": return heading.priority ?? source.priorities.default | |
| 198 | case "FILE": return source.path | |
| 199 | case "TAGS": return heading.localTags.isEmpty ? nil : ":" + heading.localTags.joined(separator: ":") + ":" | |
| 200 | case "ALLTAGS": return tagList.isEmpty ? nil : ":" + tagList.joined(separator: ":") + ":" | |
| 201 | case "SCHEDULED", "DEADLINE", "CLOSED": return heading.planning[name] | |
| 202 | case "TIMESTAMP": return heading.firstActive | |
| 203 | case "TIMESTAMP_IA": return heading.firstInactive | |
| 204 | default: return heading.properties[name] | |
| 205 | } | |
| 206 | } | |
| 207 | func evaluate(_ term: Term) -> Bool { | |
| 208 | switch term { | |
| 209 | case .not(let inner): | |
| 210 | return !evaluate(inner) | |
| 211 | case .tag(let tag): | |
| 212 | return tagList.contains(tag) | |
| 213 | case .tagRegex(let pattern): | |
| 214 | return tagList.contains { EmacsRegex.matches(pattern, $0) } | |
| 215 | case .property(let name, let op, let expected, let needsValue): | |
| 216 | let found = value(name) | |
| 217 | if needsValue, found == nil { return false } | |
| 218 | let actual = found ?? "" | |
| 219 | switch expected { | |
| 220 | case .regex(let pattern): | |
| 221 | let hit = EmacsRegex.matches(pattern, actual) | |
| 222 | return op == "/=" ? !hit : hit | |
| 223 | case .string(let string): | |
| 224 | return Self.compare(op, actual, string) | |
| 225 | case .number(let number): | |
| 226 | return Self.compare(op, Self.number(actual), number) | |
| 227 | case .time(let time): | |
| 228 | let a = Self.seconds(actual, now: now, calendar: calendar) | |
| 229 | let b = Self.matcherTime(time, now: now, calendar: calendar) | |
| 230 | guard a > 0, b > 0 else { return false } | |
| 231 | // `org-time<>` compares with `=`. | |
| 232 | return op == "<>" || op == "!=" || op == "/=" ? a == b : Self.compare(op, a, b) | |
| 233 | } | |
| 234 | } | |
| 235 | } | |
| 236 | if let tags, !tags.contains(where: { $0.allSatisfy(evaluate) }) { return false } | |
| 237 | if let todo { | |
| 238 | let ok = todo.contains { conjunction in | |
| 239 | conjunction.allSatisfy { term in | |
| 240 | let hit = term.regex | |
| 241 | ? todoKeyword.map { EmacsRegex.matches(term.keyword, $0) } ?? false | |
| 242 | : todoKeyword == term.keyword | |
| 243 | return term.negated ? !hit : hit | |
| 244 | } | |
| 245 | } | |
| 246 | if !ok { return false } | |
| 247 | } | |
| 248 | return true | |
| 249 | } | |
| 250 | ||
| 251 | static func compare<T: Comparable>(_ op: String, _ a: T, _ b: T) -> Bool { | |
| 252 | switch op { | |
| 253 | case "<": a < b | |
| 254 | case ">": a > b | |
| 255 | case "<=", "=<": a <= b | |
| 256 | case ">=", "=>": a >= b | |
| 257 | case "=", "==": a == b | |
| 258 | default: a != b | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 262 | /// `org-2ft`: a timestamp string as seconds, or 0. | |
| 263 | static func seconds(_ s: String, now: Date, calendar: Calendar) -> Double { | |
| 264 | let ns = s as NSString | |
| 265 | guard let m = Days.dateRegex.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return 0 } | |
| 266 | var c = DateComponents(year: Int(ns.substring(with: m.range(at: 1))), month: Int(ns.substring(with: m.range(at: 2))), day: Int(ns.substring(with: m.range(at: 3)))) | |
| 267 | let hour = Days.hour(of: s) | |
| 268 | c.hour = hour | |
| 269 | if let time = try? NSRegularExpression(pattern: " ([0-9]{1,2}):([0-9]{2})").firstMatch(in: s, range: NSRange(location: NSMaxRange(m.range), length: ns.length - NSMaxRange(m.range))), Int(ns.substring(with: time.range(at: 1))) == hour { | |
| 270 | c.minute = Int(ns.substring(with: time.range(at: 2))) | |
| 271 | } | |
| 272 | return calendar.date(from: c)?.timeIntervalSince1970 ?? 0 | |
| 273 | } | |
| 274 | ||
| 275 | /// `org-matcher-time`. | |
| 276 | static func matcherTime(_ s: String, now: Date, calendar: Calendar) -> Double { | |
| 277 | let today = calendar.startOfDay(for: now).timeIntervalSince1970 | |
| 278 | switch s { | |
| 279 | case "<now>": return now.timeIntervalSince1970 | |
| 280 | case "<today>": return today | |
| 281 | case "<tomorrow>": return today + 86400 | |
| 282 | case "<yesterday>": return today - 86400 | |
| 283 | default: break | |
| 284 | } | |
| 285 | let ns = s as NSString | |
| 286 | if let m = try! NSRegularExpression(pattern: "^<([-+][0-9]+)([hdwmy])>$").firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) { | |
| 287 | let n = Double(ns.substring(with: m.range(at: 1)))! | |
| 288 | let unit = ns.substring(with: m.range(at: 2)) | |
| 289 | let size: Double = ["h": 3600, "d": 86400, "w": 604800, "m": 2678400, "y": 31557600][unit]! | |
| 290 | return (unit == "h" ? now.timeIntervalSince1970 : today) + n * size | |
| 291 | } | |
| 292 | return seconds(s, now: now, calendar: calendar) | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | /// Emacs regular expressions run through ICU, case-insensitively as the matcher binds | |
| 297 | /// `case-fold-search`. | |
| 298 | enum EmacsRegex { | |
| 299 | static func translate(_ pattern: String) -> String { | |
| 300 | var out = "" | |
| 301 | var chars = Array(pattern) | |
| 302 | var i = 0 | |
| 303 | var inClass = false | |
| 304 | while i < chars.count { | |
| 305 | let c = chars[i] | |
| 306 | if inClass { | |
| 307 | if c == "]" { inClass = false } | |
| 308 | if c == "[" , i + 1 < chars.count, chars[i + 1] == ":" { | |
| 309 | // `[:alpha:]` passes through. | |
| 310 | if let end = String(chars[i...]).range(of: ":]") { | |
| 311 | let length = String(chars[i...]).distance(from: String(chars[i...]).startIndex, to: end.upperBound) | |
| 312 | out += String(chars[i..<(i + length)]) | |
| 313 | i += length | |
| 314 | continue | |
| 315 | } | |
| 316 | } | |
| 317 | out += c == "\\" ? "\\\\" : String(c) | |
| 318 | i += 1 | |
| 319 | continue | |
| 320 | } | |
| 321 | if c == "\\", i + 1 < chars.count { | |
| 322 | let n = chars[i + 1] | |
| 323 | switch n { | |
| 324 | case "(": | |
| 325 | if i + 3 < chars.count, chars[i + 2] == "?", chars[i + 3] == ":" { | |
| 326 | out += "(?:" | |
| 327 | i += 4 | |
| 328 | continue | |
| 329 | } | |
| 330 | out += "(" | |
| 331 | case ")": out += ")" | |
| 332 | case "|": out += "|" | |
| 333 | case "{": out += "{" | |
| 334 | case "}": out += "}" | |
| 335 | case "<", ">": out += "\\b" | |
| 336 | case "`": out += "^" | |
| 337 | case "'": out += "$" | |
| 338 | case "w": out += "[\\p{L}\\p{N}]" | |
| 339 | case "W": out += "[^\\p{L}\\p{N}]" | |
| 340 | default: out += "\\" + String(n) | |
| 341 | } | |
| 342 | i += 2 | |
| 343 | continue | |
| 344 | } | |
| 345 | switch c { | |
| 346 | case "(", ")", "|", "{", "}": out += "\\" + String(c) | |
| 347 | case "[": | |
| 348 | inClass = true | |
| 349 | out += "[" | |
| 350 | if i + 1 < chars.count, chars[i + 1] == "^" { out += "^"; i += 1 } | |
| 351 | if i + 1 < chars.count, chars[i + 1] == "]" { out += "\\]"; i += 1 } | |
| 352 | default: out += String(c) | |
| 353 | } | |
| 354 | i += 1 | |
| 355 | } | |
| 356 | chars = [] | |
| 357 | return out | |
| 358 | } | |
| 359 | ||
| 360 | static func matches(_ pattern: String, _ string: String) -> Bool { | |
| 361 | guard let regex = try? NSRegularExpression(pattern: translate(pattern), options: .caseInsensitive) else { return false } | |
| 362 | return regex.firstMatch(in: string, range: NSRange(location: 0, length: (string as NSString).length)) != nil | |
| 363 | } | |
| 364 | } | |
Sources/OrgCore/Agenda/AgendaSource.swift +53 −1
| @@ -8,6 +8,23 @@ public struct AgendaSource: Sendable { | ||
| 8 | 8 | struct Heading: Sendable { |
| 9 | 9 | /// Start of the heading line. |
| 10 | 10 | let start: Int |
| 11 | let level: Int | |
| 12 | /// In an archived or commented tree, which the agenda skips. | |
| 13 | let skipped: Bool | |
| 14 | /// The title without keyword, priority and tags (`ITEM`). | |
| 15 | let title: String | |
| 16 | /// The priority cookie's value. | |
| 17 | let priority: String? | |
| 18 | let localTags: [String] | |
| 19 | /// Upper-cased keys of the entry's own drawer. | |
| 20 | let properties: [String: String] | |
| 21 | /// Planning timestamps with brackets, for property matches. | |
| 22 | let planning: [String: String] | |
| 23 | /// The first active and inactive timestamps in the entry (`TIMESTAMP`, `TIMESTAMP_IA`). | |
| 24 | let firstActive: String? | |
| 25 | let firstInactive: String? | |
| 26 | /// The heading line without its newline. | |
| 27 | let line: String | |
| 11 | 28 | /// The line after the stars. |
| 12 | 29 | let head: String |
| 13 | 30 | let todo: String? |
| @@ -51,6 +68,21 @@ public struct AgendaSource: Sendable { | ||
| 51 | 68 | keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) } |
| 52 | 69 | priorities = settings.priorities |
| 53 | 70 | |
| 71 | var stampsByHeading: [Int: [String]] = [:] | |
| 72 | var headingIndex = -1 | |
| 73 | func collect(_ node: SyntaxNode) { | |
| 74 | for child in node.children { | |
| 75 | if child.kind == .heading { headingIndex += 1 } | |
| 76 | if child.kind == .timestamp, headingIndex >= 0 { | |
| 77 | stampsByHeading[headingIndex, default: []].append(child.text) | |
| 78 | } else if child.kind != .block, child.kind != .planning { | |
| 79 | collect(child) | |
| 80 | } | |
| 81 | } | |
| 82 | } | |
| 83 | collect(tree.root) | |
| 84 | func sectionStamps(_ index: Int) -> [String] { stampsByHeading[index] ?? [] } | |
| 85 | ||
| 54 | 86 | var keywordCategory: String? |
| 55 | 87 | var srcBodies: [Range<Int>] = [] |
| 56 | 88 | var planningLines: [Range<Int>] = [] |
| @@ -118,6 +150,7 @@ public struct AgendaSource: Sendable { | ||
| 118 | 150 | |
| 119 | 151 | var scheduled: (String, Int)? |
| 120 | 152 | var deadline: (String, Int)? |
| 153 | var planningValues: [String: String] = [:] | |
| 121 | 154 | let afterHeading = info.headingRange.upperBound |
| 122 | 155 | if let planning = planningLines.first(where: { $0.lowerBound == afterHeading }) { |
| 123 | 156 | let planningText = ns.substring(with: NSRange(location: planning.lowerBound, length: planning.count)) |
| @@ -127,9 +160,28 @@ public struct AgendaSource: Sendable { | ||
| 127 | 160 | let value = (pns.substring(with: m.range(at: 2)), planning.lowerBound + m.range(at: 2).location - 1) |
| 128 | 161 | if pns.substring(with: m.range(at: 1)) == "SCHEDULED" { scheduled = scheduled ?? value } else { deadline = deadline ?? value } |
| 129 | 162 | } |
| 163 | let any = try! NSRegularExpression(pattern: "\\b(SCHEDULED|DEADLINE|CLOSED): *([\\[<]\(Self.tsInternal)[\\]>])") | |
| 164 | for m in any.matches(in: planningText, range: NSRange(location: 0, length: pns.length)) { | |
| 165 | let key = pns.substring(with: m.range(at: 1)) | |
| 166 | if planningValues[key] == nil { planningValues[key] = pns.substring(with: m.range(at: 2)) } | |
| 167 | } | |
| 168 | } | |
| 169 | var properties: [String: String] = [:] | |
| 170 | for property in info.properties { | |
| 171 | let key = property.key.uppercased() | |
| 172 | if property.additive, let value = properties[key] { | |
| 173 | properties[key] = value + " " + property.value | |
| 174 | } else { | |
| 175 | properties[key] = property.value | |
| 176 | } | |
| 130 | 177 | } |
| 178 | let ownStamps = sectionStamps(index) | |
| 131 | 179 | headings.append(Heading( |
| 132 | start: info.headingRange.lowerBound, head: head, todo: info.todo, isDone: info.isDone, | |
| 180 | start: info.headingRange.lowerBound, level: info.level, skipped: skipped[index], | |
| 181 | title: info.title.trimmingCharacters(in: .whitespaces), priority: info.priority, localTags: info.localTags, | |
| 182 | properties: properties, planning: planningValues, | |
| 183 | firstActive: ownStamps.first { $0.hasPrefix("<") }, firstInactive: ownStamps.first { $0.hasPrefix("[") }, | |
| 184 | line: line.trimmingCharacters(in: .newlines), head: head, todo: info.todo, isDone: info.isDone, | |
| 133 | 185 | tags: tags, category: category, scheduled: scheduled, deadline: deadline |
| 134 | 186 | )) |
| 135 | 187 | } |
Sources/OrgCore/Keymap/TOML.swift +5
| @@ -18,6 +18,11 @@ public enum TOML { | ||
| 18 | 18 | if case .bool(let value) = self { return value } |
| 19 | 19 | return nil |
| 20 | 20 | } |
| 21 | ||
| 22 | public var integer: Int? { | |
| 23 | if case .integer(let value) = self { return value } | |
| 24 | return nil | |
| 25 | } | |
| 21 | 26 | } |
| 22 | 27 | |
| 23 | 28 | public struct Table: Sendable, Equatable { |
Sources/Orgstar/AgendaView.swift +226 −42
| @@ -2,20 +2,32 @@ import OrgApp | ||
| 2 | 2 | import OrgCore |
| 3 | 3 | import SwiftUI |
| 4 | 4 | |
| 5 | /// The agenda window: `org-agenda-list` over the agenda files. Return or a double click jumps | |
| 6 | /// to the entry in the main window; f/b move a span, `.` returns to today, g refreshes. | |
| 5 | /// The agenda window: the day/week agenda, the TODO list, tag matches and saved views. | |
| 6 | /// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: | |
| 7 | /// f/b/. move the span, g refreshes, t cycles the TODO state, +/- change the priority, : or | |
| 8 | /// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date. | |
| 7 | 9 | struct AgendaView: View { |
| 8 | 10 | let workspace: WorkspaceModel |
| 9 | 11 | let session: DocumentSession |
| 10 | 12 | let commands: AppCommands |
| 11 | 13 | @State private var agenda: AgendaModel |
| 14 | @State private var views = AgendaViewLoader.builtIn | |
| 15 | @State private var viewName = AgendaViewLoader.builtIn[0].name | |
| 16 | @State private var matchText = "" | |
| 12 | 17 | @State private var selection: String? |
| 13 | 18 | @State private var tick = 0 |
| 19 | @State private var message: String? | |
| 20 | @State private var prompt: PendingPrompt? | |
| 21 | @State private var promptText = "" | |
| 22 | @State private var prefix = false | |
| 14 | 23 | @AppStorage("agendaSpan") private var span = 10 |
| 15 | 24 | @AppStorage("agendaStartOffset") private var startOffset = -3 |
| 16 | 25 | @AppStorage("agendaSubfolders") private var includeSubfolders = false |
| 17 | 26 | @Environment(\.openWindow) private var openWindow |
| 18 | 27 | |
| 28 | static let match = "Match…" | |
| 29 | static let todoMatch = "TODO Match…" | |
| 30 | ||
| 19 | 31 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) { |
| 20 | 32 | self.workspace = workspace |
| 21 | 33 | self.session = session |
| @@ -23,7 +35,17 @@ struct AgendaView: View { | ||
| 23 | 35 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) |
| 24 | 36 | } |
| 25 | 37 | |
| 38 | struct PendingPrompt: Identifiable { | |
| 39 | let id = UUID() | |
| 40 | let prompt: Prompt | |
| 41 | let command: any OrgCommand | |
| 42 | let atMarker: Bool | |
| 43 | let answers: [String: String] | |
| 44 | let itemID: String | |
| 45 | } | |
| 46 | ||
| 26 | 47 | private struct RefreshKey: Hashable { |
| 48 | var mode: AgendaMode | |
| 27 | 49 | var start: Int? |
| 28 | 50 | var span: Int |
| 29 | 51 | var startOffset: Int |
| @@ -37,25 +59,36 @@ struct AgendaView: View { | ||
| 37 | 59 | |
| 38 | 60 | private var refreshKey: RefreshKey { |
| 39 | 61 | RefreshKey( |
| 40 | start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, | |
| 62 | mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, | |
| 41 | 63 | files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, |
| 42 | 64 | path: session.path, tick: tick |
| 43 | 65 | ) |
| 44 | 66 | } |
| 45 | 67 | |
| 68 | private var isAgenda: Bool { | |
| 69 | if case .agenda = agenda.mode { return true } | |
| 70 | return false | |
| 71 | } | |
| 72 | ||
| 73 | private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch } | |
| 74 | ||
| 46 | 75 | var body: some View { |
| 47 | 76 | List(selection: $selection) { |
| 48 | ForEach(agenda.days, id: \.day) { day in | |
| 49 | Section { | |
| 50 | ForEach(Array(day.items.enumerated()), id: \.offset) { index, item in | |
| 51 | AgendaRow(item: item) | |
| 52 | .tag(Self.id(day, index)) | |
| 53 | .selectionDisabled(item.path == nil) | |
| 77 | if isAgenda { | |
| 78 | ForEach(agenda.days, id: \.day) { day in | |
| 79 | Section { | |
| 80 | rows(day.items, prefix: "\(day.day)") | |
| 81 | } header: { | |
| 82 | Text(day.header) | |
| 83 | .font(.headline) | |
| 84 | .foregroundStyle(day.isToday ? Color.accentColor : .primary) | |
| 54 | 85 | } |
| 86 | } | |
| 87 | } else { | |
| 88 | Section { | |
| 89 | rows(agenda.list, prefix: "list") | |
| 55 | 90 | } header: { |
| 56 | Text(day.header) | |
| 57 | .font(.headline) | |
| 58 | .foregroundStyle(day.isToday ? Color.accentColor : .primary) | |
| 91 | Text(listTitle).font(.headline) | |
| 59 | 92 | } |
| 60 | 93 | } |
| 61 | 94 | } |
| @@ -63,29 +96,44 @@ struct AgendaView: View { | ||
| 63 | 96 | } primaryAction: { ids in |
| 64 | 97 | if let id = ids.first { jump(id) } |
| 65 | 98 | } |
| 66 | .onKeyPress(.return) { | |
| 67 | guard let selection else { return .ignored } | |
| 68 | jump(selection) | |
| 69 | return .handled | |
| 70 | } | |
| 71 | .onKeyPress(characters: CharacterSet(charactersIn: "fb.gr")) { press in | |
| 72 | switch press.characters { | |
| 73 | case "f": agenda.shift(forward: true) | |
| 74 | case "b": agenda.shift(forward: false) | |
| 75 | case ".": agenda.showToday() | |
| 76 | default: tick += 1 | |
| 99 | .onKeyPress(phases: .down) { press in handle(press) } | |
| 100 | .safeAreaInset(edge: .bottom, spacing: 0) { | |
| 101 | if let message { | |
| 102 | Text(message) | |
| 103 | .font(.callout) | |
| 104 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 105 | .padding(.horizontal, 12) | |
| 106 | .padding(.vertical, 6) | |
| 107 | .background(.bar) | |
| 77 | 108 | } |
| 78 | return .handled | |
| 79 | 109 | } |
| 80 | 110 | .toolbar { |
| 111 | ToolbarItemGroup(placement: .navigation) { | |
| 112 | Menu { | |
| 113 | ForEach(views) { view in Button(view.name) { viewName = view.name } } | |
| 114 | Divider() | |
| 115 | Button(Self.match) { viewName = Self.match } | |
| 116 | Button(Self.todoMatch) { viewName = Self.todoMatch } | |
| 117 | } label: { | |
| 118 | Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon) | |
| 119 | } | |
| 120 | .help("What the agenda shows") | |
| 121 | if isMatch { | |
| 122 | TextField("+work-boss|LEVEL>2/!TODO", text: $matchText) | |
| 123 | .frame(minWidth: 220) | |
| 124 | .onSubmit { applyView() } | |
| 125 | } | |
| 126 | } | |
| 81 | 127 | ToolbarItemGroup { |
| 82 | 128 | if agenda.isComputing { ProgressView().controlSize(.small) } |
| 83 | Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) } | |
| 84 | .help("Previous \(span) days (b)") | |
| 85 | Button("Today") { agenda.showToday() } | |
| 86 | .help("Back to today (.)") | |
| 87 | Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) } | |
| 88 | .help("Next \(span) days (f)") | |
| 129 | if isAgenda { | |
| 130 | Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) } | |
| 131 | .help("Previous days (b)") | |
| 132 | Button("Today") { agenda.showToday() } | |
| 133 | .help("Back to today (.)") | |
| 134 | Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) } | |
| 135 | .help("Next days (f)") | |
| 136 | } | |
| 89 | 137 | } |
| 90 | 138 | } |
| 91 | 139 | .overlay { |
| @@ -93,7 +141,17 @@ struct AgendaView: View { | ||
| 93 | 141 | ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add.")) |
| 94 | 142 | } |
| 95 | 143 | } |
| 96 | .navigationTitle("Agenda") | |
| 144 | .alert(prompt?.prompt.message ?? "", isPresented: Binding(get: { prompt != nil }, set: { if !$0 { prompt = nil } })) { | |
| 145 | TextField("", text: $promptText) | |
| 146 | Button("OK") { answerPrompt() } | |
| 147 | Button("Cancel", role: .cancel) { prompt = nil } | |
| 148 | } message: { | |
| 149 | if let choices = prompt?.prompt.choices, !choices.isEmpty { | |
| 150 | Text(choices.prefix(12).joined(separator: " ")) | |
| 151 | } | |
| 152 | } | |
| 153 | .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) | |
| 154 | .onChange(of: viewName) { applyView() } | |
| 97 | 155 | .task(id: refreshKey) { |
| 98 | 156 | agenda.span = span |
| 99 | 157 | agenda.startOffset = startOffset |
| @@ -102,6 +160,7 @@ struct AgendaView: View { | ||
| 102 | 160 | await agenda.refresh(open: open) |
| 103 | 161 | } |
| 104 | 162 | .task { |
| 163 | loadViews() | |
| 105 | 164 | // The time grid's "now" line and the day itself move on. |
| 106 | 165 | while !Task.isCancelled { |
| 107 | 166 | try? await Task.sleep(for: .seconds(60)) |
| @@ -110,16 +169,135 @@ struct AgendaView: View { | ||
| 110 | 169 | } |
| 111 | 170 | } |
| 112 | 171 | |
| 113 | private static func id(_ day: AgendaDay, _ index: Int) -> String { "\(day.day):\(index)" } | |
| 172 | @ViewBuilder | |
| 173 | private func rows(_ items: [AgendaItem], prefix: String) -> some View { | |
| 174 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in | |
| 175 | AgendaRow(item: item) | |
| 176 | .tag("\(prefix):\(index)") | |
| 177 | .selectionDisabled(item.path == nil) | |
| 178 | } | |
| 179 | } | |
| 180 | ||
| 181 | private var listTitle: String { | |
| 182 | switch agenda.mode { | |
| 183 | case .todo(let keywords): "TODO items: \(keywords ?? "all")" | |
| 184 | case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)" | |
| 185 | case .agenda: "" | |
| 186 | } | |
| 187 | } | |
| 188 | ||
| 189 | private func loadViews() { | |
| 190 | let loaded = AgendaViewLoader.load() | |
| 191 | views = loaded.views | |
| 192 | message = loaded.problems.first | |
| 193 | } | |
| 194 | ||
| 195 | private func applyView() { | |
| 196 | selection = nil | |
| 197 | agenda.start = nil | |
| 198 | switch viewName { | |
| 199 | case Self.match, Self.todoMatch: | |
| 200 | let match = matchText.trimmingCharacters(in: .whitespaces) | |
| 201 | guard !match.isEmpty else { return } | |
| 202 | agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch) | |
| 203 | default: | |
| 204 | if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode } | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | // MARK: - Items | |
| 209 | ||
| 210 | private func item(_ id: String) -> AgendaItem? { | |
| 211 | let parts = id.split(separator: ":") | |
| 212 | guard parts.count == 2, let index = Int(parts[1]) else { return nil } | |
| 213 | let items = parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? [] | |
| 214 | return index < items.count ? items[index] : nil | |
| 215 | } | |
| 114 | 216 | |
| 115 | 217 | private func jump(_ id: String) { |
| 116 | let parts = id.split(separator: ":").compactMap { Int($0) } | |
| 117 | guard parts.count == 2, let day = agenda.days.first(where: { $0.day == parts[0] }), parts[1] < day.items.count else { return } | |
| 118 | let item = day.items[parts[1]] | |
| 119 | guard let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return } | |
| 218 | guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return } | |
| 120 | 219 | commands.jump(path: path, offset: offset) |
| 121 | 220 | openWindow(id: "main") |
| 122 | 221 | } |
| 222 | ||
| 223 | // MARK: - Commands | |
| 224 | ||
| 225 | private func handle(_ press: KeyPress) -> KeyPress.Result { | |
| 226 | let control = press.modifiers.contains(.control) | |
| 227 | if prefix { | |
| 228 | prefix = false | |
| 229 | switch (control, press.characters) { | |
| 230 | case (true, "\u{13}"), (true, "s"): run(SetPlanning(deadline: false)) | |
| 231 | case (true, "\u{04}"), (true, "d"): run(SetPlanning(deadline: true)) | |
| 232 | case (true, "\u{11}"), (true, "q"): run(SetTags()) | |
| 233 | case (true, "\u{14}"), (true, "t"): run(TodoCycle()) | |
| 234 | default: message = "C-c \(press.characters) is undefined" | |
| 235 | } | |
| 236 | return .handled | |
| 237 | } | |
| 238 | if control, press.characters == "c" || press.characters == "\u{03}" { | |
| 239 | prefix = true | |
| 240 | message = "C-c-" | |
| 241 | return .handled | |
| 242 | } | |
| 243 | if press.key == .return, let selection { | |
| 244 | jump(selection) | |
| 245 | return .handled | |
| 246 | } | |
| 247 | if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow { | |
| 248 | run(ShiftTimestampDay(later: press.key == .rightArrow), atMarker: true) | |
| 249 | return .handled | |
| 250 | } | |
| 251 | guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored } | |
| 252 | switch press.characters { | |
| 253 | case "f" where isAgenda: agenda.shift(forward: true) | |
| 254 | case "b" where isAgenda: agenda.shift(forward: false) | |
| 255 | case "." where isAgenda: agenda.showToday() | |
| 256 | case "g", "r": | |
| 257 | loadViews() | |
| 258 | tick += 1 | |
| 259 | case "t": run(TodoCycle()) | |
| 260 | case "+": run(PriorityUp()) | |
| 261 | case "-": run(PriorityDown()) | |
| 262 | case ":": run(SetTags()) | |
| 263 | default: return .ignored | |
| 264 | } | |
| 265 | return .handled | |
| 266 | } | |
| 267 | ||
| 268 | /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the | |
| 269 | /// timestamp the entry is listed for. | |
| 270 | private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) { | |
| 271 | guard let id = itemID ?? selection, let item = item(id), let path = item.path, let start = item.headingOffset else { | |
| 272 | message = "No entry selected" | |
| 273 | return | |
| 274 | } | |
| 275 | if atMarker, ![.deadline, .upcomingDeadline, .scheduled, .pastScheduled, .timestamp].contains(item.kind) { | |
| 276 | message = "Only dated entries can move" | |
| 277 | return | |
| 278 | } | |
| 279 | let offset = atMarker ? (item.markerOffset ?? start) : start | |
| 280 | let step = session.perform(command, path: path, at: offset, expect: item.headingLine.map { (start, $0) }, answers: answers) | |
| 281 | switch step { | |
| 282 | case .prompt(let request): | |
| 283 | promptText = request.initial | |
| 284 | prompt = PendingPrompt(prompt: request, command: command, atMarker: atMarker, answers: answers, itemID: id) | |
| 285 | case .failed(let text): | |
| 286 | message = text | |
| 287 | tick += 1 | |
| 288 | case .commit: | |
| 289 | message = nil | |
| 290 | tick += 1 | |
| 291 | } | |
| 292 | } | |
| 293 | ||
| 294 | private func answerPrompt() { | |
| 295 | guard let pending = prompt else { return } | |
| 296 | prompt = nil | |
| 297 | var answers = pending.answers | |
| 298 | answers[pending.prompt.key] = promptText | |
| 299 | run(pending.command, atMarker: pending.atMarker, answers: answers, itemID: pending.itemID) | |
| 300 | } | |
| 123 | 301 | } |
| 124 | 302 | |
| 125 | 303 | struct AgendaRow: View { |
| @@ -129,7 +307,7 @@ struct AgendaRow: View { | ||
| 129 | 307 | switch item.kind { |
| 130 | 308 | case .timeGrid: |
| 131 | 309 | HStack(spacing: 8) { |
| 132 | Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) | |
| 310 | Text(Self.trim(item.time)) | |
| 133 | 311 | .monospacedDigit() |
| 134 | 312 | .frame(width: 140, alignment: .trailing) |
| 135 | 313 | VStack { Divider() } |
| @@ -138,7 +316,7 @@ struct AgendaRow: View { | ||
| 138 | 316 | .foregroundStyle(.tertiary) |
| 139 | 317 | case .currentTime: |
| 140 | 318 | HStack(spacing: 8) { |
| 141 | Text("now " + item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) | |
| 319 | Text("now " + Self.trim(item.time)) | |
| 142 | 320 | .monospacedDigit() |
| 143 | 321 | .frame(width: 140, alignment: .trailing) |
| 144 | 322 | Rectangle().fill(Color.accentColor).frame(height: 1) |
| @@ -151,9 +329,11 @@ struct AgendaRow: View { | ||
| 151 | 329 | .lineLimit(1) |
| 152 | 330 | .foregroundStyle(.secondary) |
| 153 | 331 | .frame(width: 80, alignment: .leading) |
| 154 | Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) | |
| 155 | .monospacedDigit() | |
| 156 | .frame(width: 80, alignment: .leading) | |
| 332 | if item.kind != .todo, item.kind != .tagsMatch { | |
| 333 | Text(Self.trim(item.time)) | |
| 334 | .monospacedDigit() | |
| 335 | .frame(width: 80, alignment: .leading) | |
| 336 | } | |
| 157 | 337 | if !item.extra.isEmpty { |
| 158 | 338 | Text(item.extra.trimmingCharacters(in: .whitespaces)) |
| 159 | 339 | .monospacedDigit() |
| @@ -171,6 +351,10 @@ struct AgendaRow: View { | ||
| 171 | 351 | } |
| 172 | 352 | } |
| 173 | 353 | |
| 354 | static func trim(_ time: String) -> String { | |
| 355 | time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")) | |
| 356 | } | |
| 357 | ||
| 174 | 358 | private var leaderColor: Color { |
| 175 | 359 | switch item.kind { |
| 176 | 360 | case .deadline: .red |
| @@ -182,8 +366,8 @@ struct AgendaRow: View { | ||
| 182 | 366 | |
| 183 | 367 | /// The TODO keyword colored as in the editor. |
| 184 | 368 | private var title: Text { |
| 185 | guard let todo = item.todo, item.text.hasPrefix(todo + " ") else { return Text(item.text.trimmingCharacters(in: .whitespaces)) } | |
| 186 | 369 | let text = item.text.trimmingCharacters(in: .whitespaces) |
| 370 | guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) } | |
| 187 | 371 | let rest = String(text.dropFirst(todo.count)) |
| 188 | 372 | return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary) |
| 189 | 373 | } |
Tests/OrgAppTests/AppTests.swift +67
| @@ -306,3 +306,70 @@ struct AgendaModelTests { | ||
| 306 | 306 | #expect(agenda.days.first?.day == Days.absolute(year: 2026, month: 10, day: 12)) |
| 307 | 307 | } |
| 308 | 308 | } |
| 309 | ||
| 310 | @MainActor | |
| 311 | struct AgendaCommandTests { | |
| 312 | @Test func loadsViews() throws { | |
| 313 | let folder = try Folder() | |
| 314 | let file = try folder.write("views.toml", """ | |
| 315 | [[view]] | |
| 316 | name = "Work" | |
| 317 | type = "match" | |
| 318 | match = "+work" | |
| 319 | [[view]] | |
| 320 | name = "Week" | |
| 321 | span = 7 | |
| 322 | start = -1 | |
| 323 | [[view]] | |
| 324 | name = "Waiting" | |
| 325 | type = "todo" | |
| 326 | keywords = "WAIT|HOLD" | |
| 327 | [[view]] | |
| 328 | name = "Broken" | |
| 329 | type = "match" | |
| 330 | """) | |
| 331 | let loaded = AgendaViewLoader.load(userFile: file) | |
| 332 | #expect(loaded.views.map(\.name) == ["Agenda", "TODO List", "Work", "Week", "Waiting"]) | |
| 333 | #expect(loaded.views[2].mode == .match("+work", todoOnly: false)) | |
| 334 | #expect(loaded.views[3].mode == .agenda(span: 7, startOffset: -1)) | |
| 335 | #expect(loaded.views[4].mode == .todo(keywords: "WAIT|HOLD")) | |
| 336 | #expect(loaded.problems == ["views.toml line 13: Broken needs a match"]) | |
| 337 | } | |
| 338 | ||
| 339 | @Test func todoListMode() async throws { | |
| 340 | let notes = try Folder() | |
| 341 | try notes.write("a.org", "* TODO One :work:\n* DONE Two\n* TODO Three\n") | |
| 342 | let model = try WorkspaceModel(directory: nil) | |
| 343 | try await model.addRoot(notes.url) | |
| 344 | let agenda = AgendaModel(workspace: model) | |
| 345 | agenda.mode = .todo(keywords: nil) | |
| 346 | await agenda.refresh(open: nil) | |
| 347 | #expect(agenda.list.map(\.text) == ["TODO One", "TODO Three"]) | |
| 348 | agenda.mode = .match("work", todoOnly: false) | |
| 349 | await agenda.refresh(open: nil) | |
| 350 | #expect(agenda.list.map(\.text) == ["TODO One"]) | |
| 351 | } | |
| 352 | ||
| 353 | @Test func runsCommandsOnClosedAndOpenFiles() throws { | |
| 354 | let notes = try Folder() | |
| 355 | let a = try notes.write("a.org", "* TODO One\n* TODO Two\n") | |
| 356 | let b = try notes.write("b.org", "* TODO Three\n") | |
| 357 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | |
| 358 | session.open(a.path) | |
| 359 | ||
| 360 | // A closed file is changed on disk. | |
| 361 | let closed = session.perform(TodoCycle(), path: b.path, at: 0, expect: (0, "* TODO Three")) | |
| 362 | guard case .commit = closed else { Issue.record("\(closed)"); return } | |
| 363 | #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n") | |
| 364 | ||
| 365 | // The open file changes in its buffer. | |
| 366 | let open = session.perform(TodoCycle(), path: a.path, at: 11, expect: (11, "* TODO Two")) | |
| 367 | guard case .commit = open else { Issue.record("\(open)"); return } | |
| 368 | #expect(session.document?.text == "* TODO One\n* DONE Two\n") | |
| 369 | ||
| 370 | // A heading that moved is refused. | |
| 371 | let stale = session.perform(TodoCycle(), path: b.path, at: 0, expect: (0, "* TODO Three")) | |
| 372 | guard case .failed = stale else { Issue.record("\(stale)"); return } | |
| 373 | #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n") | |
| 374 | } | |
| 375 | } | |
Tests/OrgCoreTests/AgendaTests.swift +103 −18
| @@ -22,7 +22,7 @@ enum AgendaOracle { | ||
| 22 | 22 | |
| 23 | 23 | static let keywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL" |
| 24 | 24 | |
| 25 | static func script(files: [String], start: String, span: Int, seconds: Int) -> String { | |
| 25 | static func script(files: [String], form: String, seconds: Int) -> String { | |
| 26 | 26 | let fileList = files.map { "\"\($0)\"" }.joined(separator: " ") |
| 27 | 27 | let words = keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ") |
| 28 | 28 | return #""" |
| @@ -44,7 +44,7 @@ enum AgendaOracle { | ||
| 44 | 44 | ((get-text-property p 'org-agenda-date-header) |
| 45 | 45 | (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) |
| 46 | 46 | (setq header line items nil)) |
| 47 | (header | |
| 47 | ((or header hd) | |
| 48 | 48 | (push `((line . ,line) |
| 49 | 49 | (type . ,(get-text-property p 'type)) |
| 50 | 50 | (category . ,(get-text-property p 'org-category)) |
| @@ -55,11 +55,11 @@ enum AgendaOracle { | ||
| 55 | 55 | (pos . ,(and hd (marker-position hd)))) |
| 56 | 56 | items)))) |
| 57 | 57 | (forward-line 1)) |
| 58 | (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) | |
| 58 | (when (or header items) (push `((header . ,(or header "")) (items . ,(vconcat (nreverse items)))) days)) | |
| 59 | 59 | (vconcat (nreverse days)))) |
| 60 | 60 | \#(frozenClock(seconds, """ |
| 61 | 61 | (progn |
| 62 | (org-agenda-list nil "\(start)" \(span)) | |
| 62 | \(form) | |
| 63 | 63 | (let ((result (with-current-buffer org-agenda-buffer-name (orgstar-dump)))) |
| 64 | 64 | (let ((coding-system-for-write 'utf-8-unix)) |
| 65 | 65 | (with-temp-file (getenv "ORACLE_OUTPUT") |
| @@ -68,7 +68,7 @@ enum AgendaOracle { | ||
| 68 | 68 | """# |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | static func run(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int, minute: Int) throws -> [Day] { | |
| 71 | static func run(files: [(name: String, text: String)], form: String, today: Int, hour: Int, minute: Int) throws -> [Day] { | |
| 72 | 72 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-agenda-\(UUID().uuidString)") |
| 73 | 73 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 74 | 74 | defer { try? FileManager.default.removeItem(at: folder) } |
| @@ -78,11 +78,10 @@ enum AgendaOracle { | ||
| 78 | 78 | try file.text.write(to: url, atomically: true, encoding: .utf8) |
| 79 | 79 | paths.append(url.resolvingSymlinksInPath().path) |
| 80 | 80 | } |
| 81 | let date = Days.date(start) | |
| 82 | 81 | let seconds = (today - 719163) * 86400 + hour * 3600 + minute * 60 |
| 83 | 82 | let scriptURL = folder.appendingPathComponent("agenda.el") |
| 84 | 83 | let output = folder.appendingPathComponent("output.json") |
| 85 | try script(files: paths, start: String(format: "%04d-%02d-%02d", date.year, date.month, date.day), span: span, seconds: seconds) | |
| 84 | try script(files: paths, form: form, seconds: seconds) | |
| 86 | 85 | .write(to: scriptURL, atomically: true, encoding: .utf8) |
| 87 | 86 | let log = try EmacsOracle.run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"]) |
| 88 | 87 | guard let data = try? Data(contentsOf: output) else { throw OracleError(log: log) } |
| @@ -108,26 +107,41 @@ enum AgendaOracle { | ||
| 108 | 107 | } |
| 109 | 108 | |
| 110 | 109 | static func compare(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30) throws { |
| 111 | let emacs = try run(files: files, start: start, span: span, today: today, hour: hour, minute: minute) | |
| 110 | let date = Days.date(start) | |
| 111 | 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) | |
| 112 | 112 | let defaults = OrgSettings.default.withTodoKeywords(keywords)! |
| 113 | 113 | let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) } |
| 114 | 114 | let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: .terminal) |
| 115 | 115 | #expect(ours.count == emacs.count) |
| 116 | 116 | for (o, e) in zip(ours, emacs) { |
| 117 | 117 | #expect(o.header == e.header) |
| 118 | let theirs = e.items.map { item in | |
| 119 | "\(normalize(item.line)) | \(item.type ?? "-") \(item.category ?? "") \(item.tod.map(String.init) ?? "-") [\(item.extra ?? "")] \(item.urgency ?? 0) \(item.file ?? "-")@\(item.pos.map(String.init) ?? "-")" | |
| 120 | } | |
| 121 | let mine = o.items.map { item in | |
| 122 | let type: String = switch item.kind { | |
| 123 | case .timeGrid, .currentTime: "-" | |
| 124 | default: item.kind.rawValue | |
| 125 | } | |
| 126 | return "\(normalize(item.line)) | \(type) \(item.category) \(item.timeOfDay.map(String.init) ?? "-") [\(item.extra)] \(item.urgency) \(item.path ?? "-")@\(item.headingOffset.map(String.init) ?? "-")" | |
| 118 | #expect(describe(o.items) == describe(e.items), "\(e.header) today \(Days.date(today))") | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | static func describe(_ items: [Line]) -> [String] { | |
| 123 | items.map { item in | |
| 124 | "\(normalize(item.line)) | \(item.type ?? "-") \(item.category ?? "") \(item.tod.map(String.init) ?? "-") [\(item.extra ?? "")] \(item.urgency ?? 0) \(item.file ?? "-")@\(item.pos.map(String.init) ?? "-")" | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | static func describe(_ items: [AgendaItem]) -> [String] { | |
| 129 | items.map { item in | |
| 130 | let type: String = switch item.kind { | |
| 131 | case .timeGrid, .currentTime: "-" | |
| 132 | default: item.kind.rawValue | |
| 127 | 133 | } |
| 128 | #expect(mine == theirs, "\(e.header) today \(Days.date(today))") | |
| 134 | return "\(normalize(item.line)) | \(type) \(item.category) \(item.timeOfDay.map(String.init) ?? "-") [\(item.extra)] \(item.urgency) \(item.path ?? "-")@\(item.headingOffset.map(String.init) ?? "-")" | |
| 129 | 135 | } |
| 130 | 136 | } |
| 137 | ||
| 138 | /// A TODO list or tags view: `form` runs it in Emacs, `ours` builds ours from the sources. | |
| 139 | static func compareList(files: [(name: String, text: String)], form: String, today: Int, _ ours: ([AgendaSource]) -> [AgendaItem]) throws { | |
| 140 | let emacs = try run(files: files, form: form, today: today, hour: 9, minute: 30) | |
| 141 | let defaults = OrgSettings.default.withTodoKeywords(keywords)! | |
| 142 | let mine = ours(files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }) | |
| 143 | #expect(describe(mine) == describe(emacs.first?.items ?? []), "\(form)") | |
| 144 | } | |
| 131 | 145 | } |
| 132 | 146 | |
| 133 | 147 | struct AgendaTests { |
| @@ -181,8 +195,12 @@ struct AgendaTests { | ||
| 181 | 195 | * TODO Delayed |
| 182 | 196 | SCHEDULED: <2026-10-02 Fri -2d> |
| 183 | 197 | * Archived :ARCHIVE: |
| 198 | SCHEDULED: <2026-10-05 Mon> | |
| 184 | 199 | <2026-10-05 Mon> |
| 200 | ** TODO Under archived | |
| 201 | DEADLINE: <2026-10-05 Mon> | |
| 185 | 202 | * COMMENT Commented |
| 203 | DEADLINE: <2026-10-05 Mon> | |
| 186 | 204 | <2026-10-05 Mon> |
| 187 | 205 | * Blocks |
| 188 | 206 | #+begin_src sh |
| @@ -214,6 +232,67 @@ struct AgendaTests { | ||
| 214 | 232 | <2026-10-05 Mon 13:00> and <2026-10-06 Tue> |
| 215 | 233 | """ |
| 216 | 234 | |
| 235 | static let third = """ | |
| 236 | #+FILETAGS: :proj: | |
| 237 | * TODO Alpha :work:urgent: | |
| 238 | :PROPERTIES: | |
| 239 | :EFFORT: 2:00 | |
| 240 | :SIZE: 3 | |
| 241 | :OWNER: alice | |
| 242 | :DUE: <2026-10-10 Sat> | |
| 243 | :END: | |
| 244 | ** NEXT Beta :boss: | |
| 245 | :PROPERTIES: | |
| 246 | :SIZE: 10 | |
| 247 | :END: | |
| 248 | *** Gamma :work: | |
| 249 | * DONE Delta :home: | |
| 250 | CLOSED: [2026-10-01 Thu 10:00] SCHEDULED: <2026-09-30 Wed> | |
| 251 | * WAIT [#A] Epsilon 10:30 | |
| 252 | :PROPERTIES: | |
| 253 | :OWNER: Bob | |
| 254 | :END: | |
| 255 | <2026-10-03 Sat> [2026-10-02 Fri] | |
| 256 | * HOLD Zeta :x@y: | |
| 257 | * Eta with category | |
| 258 | :PROPERTIES: | |
| 259 | :CATEGORY: cat | |
| 260 | :END: | |
| 261 | ** KILL Theta | |
| 262 | * IDEA [#C] Iota | |
| 263 | * Archived :ARCHIVE:work: | |
| 264 | ** TODO Kappa :work: | |
| 265 | """ | |
| 266 | ||
| 267 | static let matches = [ | |
| 268 | "work", "+work-urgent", "work|home", "proj", "{^w}", "-work", "LEVEL>1", "LEVEL=2+boss", "SIZE>5", "SIZE<5", | |
| 269 | "OWNER=\"alice\"", "OWNER={^b}", "OWNER<>\"alice\"", "OWNER<\"b\"", "TODO=\"WAIT\"", "PRIORITY=\"A\"", | |
| 270 | "CATEGORY=\"cat\"", "DUE<\"<2026-10-15>\"", "DUE>\"<today>\"", "SCHEDULED<\"<today>\"", "TIMESTAMP=\"<2026-10-03 Sat>\"", | |
| 271 | "ITEM={lph}", "work/TODO", "/!", "+work/!TODO|WAIT", "EFFORT>\"1:00\"", "ALLTAGS={proj}", "TAGS=\":work:urgent:\"", | |
| 272 | "x@y", "/-TODO", "/!{^[HW]}", "work&urgent", "SIZE>=3+SIZE<=3", "LEVEL<>1", | |
| 273 | ] | |
| 274 | ||
| 275 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 276 | func listsMatchEmacs() throws { | |
| 277 | let files = [("a.org", Self.synthetic), ("b.org", Self.second), ("c.org", Self.third)] | |
| 278 | let today = Days.absolute(year: 2026, month: 10, day: 5) | |
| 279 | var utc = Calendar(identifier: .gregorian) | |
| 280 | utc.timeZone = TimeZone(identifier: "UTC")! | |
| 281 | let now = Date(timeIntervalSince1970: Double((today - 719163) * 86400 + 9 * 3600 + 30 * 60)) | |
| 282 | func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" } | |
| 283 | try AgendaOracle.compareList(files: files, form: "(org-todo-list nil)", today: today) { Agenda.todoList($0) } | |
| 284 | for keywords in ["WAIT|HOLD", "DONE", "KILL|TODO"] { | |
| 285 | try AgendaOracle.compareList(files: files, form: "(org-todo-list \(lisp(keywords)))", today: today) { Agenda.todoList($0, keywords: keywords) } | |
| 286 | } | |
| 287 | for match in Self.matches { | |
| 288 | for todoOnly in [false, true] { | |
| 289 | try AgendaOracle.compareList(files: files, form: "(org-tags-view \(todoOnly ? "t" : "nil") \(lisp(match)))", today: today) { | |
| 290 | Agenda.tagsMatch($0, match: match, todoOnly: todoOnly, now: now, calendar: utc) | |
| 291 | } | |
| 292 | } | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 217 | 296 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 218 | 297 | func matchesEmacs() throws { |
| 219 | 298 | let files = [("a.org", Self.synthetic), ("b.org", Self.second)] |
| @@ -237,5 +316,11 @@ struct AgendaTests { | ||
| 237 | 316 | for today in [Days.absolute(Date(), calendar: .current), Days.absolute(year: 2025, month: 3, day: 14)] { |
| 238 | 317 | try AgendaOracle.compare(files: files, start: today - 3, span: 10, today: today) |
| 239 | 318 | } |
| 319 | let today = Days.absolute(Date(), calendar: .current) | |
| 320 | try AgendaOracle.compareList(files: files, form: "(org-todo-list nil)", today: today) { Agenda.todoList($0) } | |
| 321 | for match in ["finance|home", "LEVEL=2", "/!", "PRIORITY=\"A\""] { | |
| 322 | let lisp = "\"" + match.replacingOccurrences(of: "\"", with: "\\\"") + "\"" | |
| 323 | try AgendaOracle.compareList(files: files, form: "(org-tags-view nil \(lisp))", today: today) { Agenda.tagsMatch($0, match: match) } | |
| 324 | } | |
| 240 | 325 | } |
| 241 | 326 | } |
docs/plans/2026-10-05-agenda-search.md added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | # Agenda Search and Actions Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** The TODO list (`org-todo-list`), tag/property/TODO match views (`org-tags-view`, `m` and `M`), saved views, and agenda commands that change entries (TODO state, priority, schedule, deadline, tags, date shift) from the agenda window. | |
| 6 | ||
| 7 | **Architecture:** `OrgCore/Agenda/AgendaSearch.swift` ports `org-agenda-get-todos`, `org-make-tags-matcher` and `org-scan-tags` over `AgendaSource`, which now carries levels, own properties, planning and first timestamps for the special properties. Emacs regexps in `{...}` terms are translated to ICU. Saved views are read from `views.toml` beside `keymap.toml`. Agenda commands run the existing editor commands at the entry: through the open buffer when the file is open, otherwise on a `DocumentState` read from disk and written back through `Saver`, so a file changed on disk in between is merged or refused. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle. | |
| 10 | ||
| 11 | **Spec:** `docs/design.md`, "Phase 3". | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - Oracle: `org-todo-list` and `org-tags-view` (with and without TODO-only) over the agenda test files and the corpora, compared line by line as for the agenda. | |
| 16 | - `/{regexp}` against a heading without a keyword is an error in Emacs; it is no match here. | |
| 17 | - String comparisons in matches use code point order (`string-collate-lessp` in the C locale). | |
| 18 | ||
| 19 | ## Defaults chosen (user may change) | |
| 20 | ||
| 21 | - Views: Agenda (`a`), TODO list (`t`), Match (`m`) and TODO match (`M`) are built in; `views.toml` adds named views. | |
| 22 | - Agenda keys follow Emacs: `t` TODO cycle, `+`/`-` priority, `,` priority prompt, `:` tags, `C-c C-s`/`C-c C-d`, `C-c C-q`, `S-→`/`S-←` date shift. | |
| 23 | ||
| 24 | --- | |
| 25 | ||
| 26 | ### Task 1: TODO list and matcher | |
| 27 | ||
| 28 | - [x] `Agenda.todoList`, `Agenda.tagsMatch`, `TagsMatcher`, `EmacsRegex`. | |
| 29 | - [x] Oracle: 4 TODO lists and 34 match strings × TODO-only on three synthetic files; corpora. | |
| 30 | - [x] Commit "TODO list and tag matches". | |
| 31 | ||
| 32 | ### Task 2: Views in the app | |
| 33 | ||
| 34 | - [x] `AgendaModel.mode` (agenda, TODO list, match); view picker and match field in the agenda window; `views.toml` loader with errors in the echo area. | |
| 35 | - [x] Commit "Agenda views". | |
| 36 | ||
| 37 | ### Task 3: Agenda commands | |
| 38 | ||
| 39 | - [x] `DocumentSession.perform(_:path:at:expect:)`: run a command at a file offset, through the buffer or through `Saver`. | |
| 40 | - [x] Keys in the agenda window; prompts for dates, tags and priority; the agenda refreshes after each. | |
| 41 | - [x] Commit "Agenda commands". | |