Agenda search, views and commands !22

merged merged by cmc on 2026-10-05 06:28 UTC · krz/orgstar:phase3-search into main

11 files changed, +1037 −74

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +29 −3
@@ -10,6 +10,9 @@ import OrgIndex
1010@Observable
1111public final class AgendaModel {
1212 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)
1316 /// The first day shown, as `Days` numbers it; nil follows today.
1417 public var start: Int?
1518 public var span = 10
@@ -44,6 +47,12 @@ public final class AgendaModel {
4447 isComputing = true
4548 let today = Days.absolute(now, calendar: calendar)
4649 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 }
4756 let first = start ?? today + startOffset
4857 let defaults = workspace.settings.org
4958 var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = []
@@ -58,8 +67,8 @@ public final class AgendaModel {
5867 let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil }
5968 reads.append((path, cached, mtime, size, nil))
6069 }
61 let span = self.span
6270 let options = self.options
71 let mode = self.mode
6372 let result = await Task.detached {
6473 var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = []
6574 for read in reads {
@@ -72,14 +81,25 @@ public final class AgendaModel {
7281 sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true))
7382 }
7483 }
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)
7796 }.value
7897 guard id == generation else { return }
7998 for source in result.0 where source.fromDisk {
8099 cache[source.path] = (source.mtime, source.size, source.source)
81100 }
82101 days = result.1
102 list = result.2
83103 isComputing = false
84104 }
85105
@@ -87,6 +107,12 @@ public final class AgendaModel {
87107
88108 /// Moves the view by a whole span, forward or back.
89109 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 }
90116 let current = start ?? Days.absolute(now, calendar: calendar) + startOffset
91117 start = current + (forward ? span : -span)
92118 }
Sources/OrgApp/AgendaViews.swift added +87
@@ -0,0 +1,87 @@
1import Foundation
2import OrgCore
3
4/// What the agenda window shows.
5public 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`.
15public 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
38public 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 {
271271 }
272272 }
273273
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
274319 // MARK: - Locations
275320
276321 /// 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 {
1010 case block
1111 case timeGrid
1212 case currentTime
13 case todo
14 case tagsMatch = "tagsmatch"
1315 }
1416
1517 public let kind: Kind
1618 public let path: String?
1719 /// Start of the entry's heading line.
1820 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?
1923 /// Where org's marker points: the timestamp, or the end of a range.
2024 public let markerOffset: Int?
2125 public let category: String
@@ -93,7 +97,7 @@ public enum Agenda {
9397 let isToday = current == today
9498 var items: [AgendaItem] = []
9599 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 }
97101 let repeat_ = current <= today ? deadline : (Days.closest(s, to: current, prefer: .future) ?? deadline)
98102 let diff = deadline - current
99103 let warningDays = Days.warningDays(s, delay: false, defaultDays: options.deadlineWarningDays)
@@ -128,7 +132,7 @@ public enum Agenda {
128132 let isToday = current == today
129133 var items: [AgendaItem] = []
130134 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 }
132136 let repeat_ = current <= today ? schedule : (Days.closest(s, to: current, prefer: .future) ?? schedule)
133137 let diff = current - schedule
134138 let past = schedule < today
@@ -233,7 +237,8 @@ public enum Agenda {
233237 /// `org-agenda-highlight-todo`.
234238 static func format(
235239 _ 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
237242 ) -> AgendaItem {
238243 var txt = heading.head.trimmingCharacters(in: .whitespaces)
239244 // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list.
@@ -256,18 +261,20 @@ public enum Agenda {
256261 tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":")
257262 }
258263
259 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing)
264 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing, removeTimes: hasTime)
260265 if let removing {
261266 while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
262267 txt = (txt as NSString).replacingCharacters(in: m.range, with: "")
263268 }
264269 }
265270 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 + " ") : "")
267274 txt = highlightTodo(txt, keywords: source.keywords)
268275 let priority = Self.priority(prefix + txt, source.priorities)
269276 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,
271278 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
272279 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority),
273280 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString)
@@ -276,7 +283,7 @@ public enum Agenda {
276283
277284 /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the
278285 /// 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) {
280287 guard let dotime else { return (nil, "") }
281288 let full = tagString.isEmpty ? txt : txt + " " + tagString
282289 let withoutStamps = stampAny.stringByReplacingMatches(in: full, range: NSRange(location: 0, length: (full as NSString).length), withTemplate: "")
@@ -294,11 +301,11 @@ public enum Agenda {
294301 start = ns.substring(with: m.range(at: 2))
295302 let endGroup = m.range(at: 3).location != NSNotFound ? 4 : 6
296303 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) }
298305 } else if let m = plainTime.firstMatch(in: containing, range: all) {
299306 start = ns.substring(with: m.range(at: 1))
300307 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) }
302309 }
303310 let startText = start.flatMap(formattedTime).map { String(repeating: " ", count: max(0, 5 - $0.count)) + $0 }
304311 let endText = end.flatMap(formattedTime)
@@ -383,7 +390,7 @@ public enum Agenda {
383390 var txt = text
384391 let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing)
385392 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,
387394 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0,
388395 line: " " + pad("", 12) + pad(timed.time, 12) + txt
389396 )
Sources/OrgCore/Agenda/AgendaSearch.swift added +364
@@ -0,0 +1,364 @@
1import Foundation
2
3extension 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`.
53public 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`.
298enum 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 {
88 struct Heading: Sendable {
99 /// Start of the heading line.
1010 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
1128 /// The line after the stars.
1229 let head: String
1330 let todo: String?
@@ -51,6 +68,21 @@ public struct AgendaSource: Sendable {
5168 keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) }
5269 priorities = settings.priorities
5370
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
5486 var keywordCategory: String?
5587 var srcBodies: [Range<Int>] = []
5688 var planningLines: [Range<Int>] = []
@@ -118,6 +150,7 @@ public struct AgendaSource: Sendable {
118150
119151 var scheduled: (String, Int)?
120152 var deadline: (String, Int)?
153 var planningValues: [String: String] = [:]
121154 let afterHeading = info.headingRange.upperBound
122155 if let planning = planningLines.first(where: { $0.lowerBound == afterHeading }) {
123156 let planningText = ns.substring(with: NSRange(location: planning.lowerBound, length: planning.count))
@@ -127,9 +160,28 @@ public struct AgendaSource: Sendable {
127160 let value = (pns.substring(with: m.range(at: 2)), planning.lowerBound + m.range(at: 2).location - 1)
128161 if pns.substring(with: m.range(at: 1)) == "SCHEDULED" { scheduled = scheduled ?? value } else { deadline = deadline ?? value }
129162 }
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 }
130177 }
178 let ownStamps = sectionStamps(index)
131179 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,
133185 tags: tags, category: category, scheduled: scheduled, deadline: deadline
134186 ))
135187 }
Sources/OrgCore/Keymap/TOML.swift +5
@@ -18,6 +18,11 @@ public enum TOML {
1818 if case .bool(let value) = self { return value }
1919 return nil
2020 }
21
22 public var integer: Int? {
23 if case .integer(let value) = self { return value }
24 return nil
25 }
2126 }
2227
2328 public struct Table: Sendable, Equatable {
Sources/Orgstar/AgendaView.swift +226 −42
@@ -2,20 +2,32 @@ import OrgApp
22import OrgCore
33import SwiftUI
44
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.
79struct AgendaView: View {
810 let workspace: WorkspaceModel
911 let session: DocumentSession
1012 let commands: AppCommands
1113 @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 = ""
1217 @State private var selection: String?
1318 @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
1423 @AppStorage("agendaSpan") private var span = 10
1524 @AppStorage("agendaStartOffset") private var startOffset = -3
1625 @AppStorage("agendaSubfolders") private var includeSubfolders = false
1726 @Environment(\.openWindow) private var openWindow
1827
28 static let match = "Match…"
29 static let todoMatch = "TODO Match…"
30
1931 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
2032 self.workspace = workspace
2133 self.session = session
@@ -23,7 +35,17 @@ struct AgendaView: View {
2335 _agenda = State(initialValue: AgendaModel(workspace: workspace))
2436 }
2537
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
2647 private struct RefreshKey: Hashable {
48 var mode: AgendaMode
2749 var start: Int?
2850 var span: Int
2951 var startOffset: Int
@@ -37,25 +59,36 @@ struct AgendaView: View {
3759
3860 private var refreshKey: RefreshKey {
3961 RefreshKey(
40 start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders,
62 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders,
4163 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
4264 path: session.path, tick: tick
4365 )
4466 }
4567
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
4675 var body: some View {
4776 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)
5485 }
86 }
87 } else {
88 Section {
89 rows(agenda.list, prefix: "list")
5590 } header: {
56 Text(day.header)
57 .font(.headline)
58 .foregroundStyle(day.isToday ? Color.accentColor : .primary)
91 Text(listTitle).font(.headline)
5992 }
6093 }
6194 }
@@ -63,29 +96,44 @@ struct AgendaView: View {
6396 } primaryAction: { ids in
6497 if let id = ids.first { jump(id) }
6598 }
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)
77108 }
78 return .handled
79109 }
80110 .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 }
81127 ToolbarItemGroup {
82128 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 }
89137 }
90138 }
91139 .overlay {
@@ -93,7 +141,17 @@ struct AgendaView: View {
93141 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
94142 }
95143 }
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() }
97155 .task(id: refreshKey) {
98156 agenda.span = span
99157 agenda.startOffset = startOffset
@@ -102,6 +160,7 @@ struct AgendaView: View {
102160 await agenda.refresh(open: open)
103161 }
104162 .task {
163 loadViews()
105164 // The time grid's "now" line and the day itself move on.
106165 while !Task.isCancelled {
107166 try? await Task.sleep(for: .seconds(60))
@@ -110,16 +169,135 @@ struct AgendaView: View {
110169 }
111170 }
112171
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 }
114216
115217 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 }
120219 commands.jump(path: path, offset: offset)
121220 openWindow(id: "main")
122221 }
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 }
123301}
124302
125303struct AgendaRow: View {
@@ -129,7 +307,7 @@ struct AgendaRow: View {
129307 switch item.kind {
130308 case .timeGrid:
131309 HStack(spacing: 8) {
132 Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")))
310 Text(Self.trim(item.time))
133311 .monospacedDigit()
134312 .frame(width: 140, alignment: .trailing)
135313 VStack { Divider() }
@@ -138,7 +316,7 @@ struct AgendaRow: View {
138316 .foregroundStyle(.tertiary)
139317 case .currentTime:
140318 HStack(spacing: 8) {
141 Text("now " + item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")))
319 Text("now " + Self.trim(item.time))
142320 .monospacedDigit()
143321 .frame(width: 140, alignment: .trailing)
144322 Rectangle().fill(Color.accentColor).frame(height: 1)
@@ -151,9 +329,11 @@ struct AgendaRow: View {
151329 .lineLimit(1)
152330 .foregroundStyle(.secondary)
153331 .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 }
157337 if !item.extra.isEmpty {
158338 Text(item.extra.trimmingCharacters(in: .whitespaces))
159339 .monospacedDigit()
@@ -171,6 +351,10 @@ struct AgendaRow: View {
171351 }
172352 }
173353
354 static func trim(_ time: String) -> String {
355 time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))
356 }
357
174358 private var leaderColor: Color {
175359 switch item.kind {
176360 case .deadline: .red
@@ -182,8 +366,8 @@ struct AgendaRow: View {
182366
183367 /// The TODO keyword colored as in the editor.
184368 private var title: Text {
185 guard let todo = item.todo, item.text.hasPrefix(todo + " ") else { return Text(item.text.trimmingCharacters(in: .whitespaces)) }
186369 let text = item.text.trimmingCharacters(in: .whitespaces)
370 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
187371 let rest = String(text.dropFirst(todo.count))
188372 return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary)
189373 }
Tests/OrgAppTests/AppTests.swift +67
@@ -306,3 +306,70 @@ struct AgendaModelTests {
306306 #expect(agenda.days.first?.day == Days.absolute(year: 2026, month: 10, day: 12))
307307 }
308308}
309
310@MainActor
311struct 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 {
2222
2323 static let keywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"
2424
25 static func script(files: [String], start: String, span: Int, seconds: Int) -> String {
25 static func script(files: [String], form: String, seconds: Int) -> String {
2626 let fileList = files.map { "\"\($0)\"" }.joined(separator: " ")
2727 let words = keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ")
2828 return #"""
@@ -44,7 +44,7 @@ enum AgendaOracle {
4444 ((get-text-property p 'org-agenda-date-header)
4545 (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days))
4646 (setq header line items nil))
47 (header
47 ((or header hd)
4848 (push `((line . ,line)
4949 (type . ,(get-text-property p 'type))
5050 (category . ,(get-text-property p 'org-category))
@@ -55,11 +55,11 @@ enum AgendaOracle {
5555 (pos . ,(and hd (marker-position hd))))
5656 items))))
5757 (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))
5959 (vconcat (nreverse days))))
6060 \#(frozenClock(seconds, """
6161 (progn
62 (org-agenda-list nil "\(start)" \(span))
62 \(form)
6363 (let ((result (with-current-buffer org-agenda-buffer-name (orgstar-dump))))
6464 (let ((coding-system-for-write 'utf-8-unix))
6565 (with-temp-file (getenv "ORACLE_OUTPUT")
@@ -68,7 +68,7 @@ enum AgendaOracle {
6868 """#
6969 }
7070
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] {
7272 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-agenda-\(UUID().uuidString)")
7373 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
7474 defer { try? FileManager.default.removeItem(at: folder) }
@@ -78,11 +78,10 @@ enum AgendaOracle {
7878 try file.text.write(to: url, atomically: true, encoding: .utf8)
7979 paths.append(url.resolvingSymlinksInPath().path)
8080 }
81 let date = Days.date(start)
8281 let seconds = (today - 719163) * 86400 + hour * 3600 + minute * 60
8382 let scriptURL = folder.appendingPathComponent("agenda.el")
8483 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)
8685 .write(to: scriptURL, atomically: true, encoding: .utf8)
8786 let log = try EmacsOracle.run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"])
8887 guard let data = try? Data(contentsOf: output) else { throw OracleError(log: log) }
@@ -108,26 +107,41 @@ enum AgendaOracle {
108107 }
109108
110109 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)
112112 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
113113 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }
114114 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: .terminal)
115115 #expect(ours.count == emacs.count)
116116 for (o, e) in zip(ours, emacs) {
117117 #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
127133 }
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) ?? "-")"
129135 }
130136 }
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 }
131145}
132146
133147struct AgendaTests {
@@ -181,8 +195,12 @@ struct AgendaTests {
181195 * TODO Delayed
182196 SCHEDULED: <2026-10-02 Fri -2d>
183197 * Archived :ARCHIVE:
198 SCHEDULED: <2026-10-05 Mon>
184199 <2026-10-05 Mon>
200 ** TODO Under archived
201 DEADLINE: <2026-10-05 Mon>
185202 * COMMENT Commented
203 DEADLINE: <2026-10-05 Mon>
186204 <2026-10-05 Mon>
187205 * Blocks
188206 #+begin_src sh
@@ -214,6 +232,67 @@ struct AgendaTests {
214232 <2026-10-05 Mon 13:00> and <2026-10-06 Tue>
215233 """
216234
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
217296 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
218297 func matchesEmacs() throws {
219298 let files = [("a.org", Self.synthetic), ("b.org", Self.second)]
@@ -237,5 +316,11 @@ struct AgendaTests {
237316 for today in [Days.absolute(Date(), calendar: .current), Days.absolute(year: 2025, month: 3, day: 14)] {
238317 try AgendaOracle.compare(files: files, start: today - 3, span: 10, today: today)
239318 }
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 }
240325 }
241326}
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".