krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 042e2f8aa2

042e2f8aa2577831a8a55eeb262d6a3afe61708d

parent: af140c9251

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 06:24 UTC

TODO list and tag matches

Port org-todo-list and org-tags-view: org-agenda-get-todos,
org-make-tags-matcher (tags, regexps, properties with numeric, string
and time comparisons, special properties, TODO terms, /!) and
org-scan-tags. Archived and commented trees no longer show their
planning in the agenda.

Ref plan docs/plans/2026-10-05-agenda-search.md

Layout: unified · split

Sources/OrgCore/Agenda/Agenda.swift +13 −8
@@ -10,6 +10,8 @@ public struct AgendaItem: Sendable, Equatable {
10 case block 10 case block
11 case timeGrid 11 case timeGrid
12 case currentTime 12 case currentTime
13 case todo
14 case tagsMatch = "tagsmatch"
13 } 15 }
14 16
15 public let kind: Kind 17 public let kind: Kind
@@ -93,7 +95,7 @@ public enum Agenda {
93 let isToday = current == today 95 let isToday = current == today
94 var items: [AgendaItem] = [] 96 var items: [AgendaItem] = []
95 for heading in source.headings { 97 for heading in source.headings {
96 guard let (s, offset) = heading.deadline, let deadline = Days.absolute(of: s) else { continue } 98 guard !heading.skipped, let (s, offset) = heading.deadline, let deadline = Days.absolute(of: s) else { continue }
97 let repeat_ = current <= today ? deadline : (Days.closest(s, to: current, prefer: .future) ?? deadline) 99 let repeat_ = current <= today ? deadline : (Days.closest(s, to: current, prefer: .future) ?? deadline)
98 let diff = deadline - current 100 let diff = deadline - current
99 let warningDays = Days.warningDays(s, delay: false, defaultDays: options.deadlineWarningDays) 101 let warningDays = Days.warningDays(s, delay: false, defaultDays: options.deadlineWarningDays)
@@ -128,7 +130,7 @@ public enum Agenda {
128 let isToday = current == today 130 let isToday = current == today
129 var items: [AgendaItem] = [] 131 var items: [AgendaItem] = []
130 for heading in source.headings { 132 for heading in source.headings {
131 guard let (s, offset) = heading.scheduled, let schedule = Days.absolute(of: s) else { continue } 133 guard !heading.skipped, let (s, offset) = heading.scheduled, let schedule = Days.absolute(of: s) else { continue }
132 let repeat_ = current <= today ? schedule : (Days.closest(s, to: current, prefer: .future) ?? schedule) 134 let repeat_ = current <= today ? schedule : (Days.closest(s, to: current, prefer: .future) ?? schedule)
133 let diff = current - schedule 135 let diff = current - schedule
134 let past = schedule < today 136 let past = schedule < today
@@ -233,7 +235,8 @@ public enum Agenda {
233 /// `org-agenda-highlight-todo`. 235 /// `org-agenda-highlight-todo`.
234 static func format( 236 static func format(
235 _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int, 237 _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int,
236 extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, urgency: (Int) -> Int 238 extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, hasTime: Bool = true,
239 urgency: (Int) -> Int
237 ) -> AgendaItem { 240 ) -> AgendaItem {
238 var txt = heading.head.trimmingCharacters(in: .whitespaces) 241 var txt = heading.head.trimmingCharacters(in: .whitespaces)
239 // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list. 242 // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list.
@@ -256,14 +259,16 @@ public enum Agenda {
256 tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":") 259 tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":")
257 } 260 }
258 261
259 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing) 262 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing, removeTimes: hasTime)
260 if let removing { 263 if let removing {
261 while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) { 264 while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
262 txt = (txt as NSString).replacingCharacters(in: m.range, with: "") 265 txt = (txt as NSString).replacingCharacters(in: m.range, with: "")
263 } 266 }
264 } 267 }
265 let category = heading.category 268 let category = heading.category
266 let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12) + (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ") 269 // ` %i %-12:c%?-12t% s` in the agenda, ` %i %-12:c` in TODO and tags lists.
270 let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12)
271 + (hasTime ? (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ") : "")
267 txt = highlightTodo(txt, keywords: source.keywords) 272 txt = highlightTodo(txt, keywords: source.keywords)
268 let priority = Self.priority(prefix + txt, source.priorities) 273 let priority = Self.priority(prefix + txt, source.priorities)
269 return AgendaItem( 274 return AgendaItem(
@@ -276,7 +281,7 @@ public enum Agenda {
276 281
277 /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the 282 /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the
278 /// text, and formats the `%t` field. 283 /// text, and formats the `%t` field.
279 static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String) -> (timeOfDay: Int?, time: String) { 284 static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String, removeTimes: Bool = true) -> (timeOfDay: Int?, time: String) {
280 guard let dotime else { return (nil, "") } 285 guard let dotime else { return (nil, "") }
281 let full = tagString.isEmpty ? txt : txt + " " + tagString 286 let full = tagString.isEmpty ? txt : txt + " " + tagString
282 let withoutStamps = stampAny.stringByReplacingMatches(in: full, range: NSRange(location: 0, length: (full as NSString).length), withTemplate: "") 287 let withoutStamps = stampAny.stringByReplacingMatches(in: full, range: NSRange(location: 0, length: (full as NSString).length), withTemplate: "")
@@ -294,11 +299,11 @@ public enum Agenda {
294 start = ns.substring(with: m.range(at: 2)) 299 start = ns.substring(with: m.range(at: 2))
295 let endGroup = m.range(at: 3).location != NSNotFound ? 4 : 6 300 let endGroup = m.range(at: 3).location != NSNotFound ? 4 : 6
296 if m.range(at: endGroup).location != NSNotFound { end = ns.substring(with: m.range(at: endGroup)) } 301 if m.range(at: endGroup).location != NSNotFound { end = ns.substring(with: m.range(at: endGroup)) }
297 removeTime(ns.substring(with: m.range), from: &txt) 302 if removeTimes { removeTime(ns.substring(with: m.range), from: &txt) }
298 } else if let m = plainTime.firstMatch(in: containing, range: all) { 303 } else if let m = plainTime.firstMatch(in: containing, range: all) {
299 start = ns.substring(with: m.range(at: 1)) 304 start = ns.substring(with: m.range(at: 1))
300 if m.range(at: 8).location != NSNotFound { end = ns.substring(with: m.range(at: 8)) } 305 if m.range(at: 8).location != NSNotFound { end = ns.substring(with: m.range(at: 8)) }
301 removeTime(ns.substring(with: m.range), from: &txt) 306 if removeTimes { removeTime(ns.substring(with: m.range), from: &txt) }
302 } 307 }
303 let startText = start.flatMap(formattedTime).map { String(repeating: " ", count: max(0, 5 - $0.count)) + $0 } 308 let startText = start.flatMap(formattedTime).map { String(repeating: " ", count: max(0, 5 - $0.count)) + $0 }
304 let endText = end.flatMap(formattedTime) 309 let endText = end.flatMap(formattedTime)
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 +51 −1
@@ -8,6 +8,21 @@ public struct AgendaSource: Sendable {
8 struct Heading: Sendable { 8 struct Heading: Sendable {
9 /// Start of the heading line. 9 /// Start of the heading line.
10 let start: Int 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?
11 /// The line after the stars. 26 /// The line after the stars.
12 let head: String 27 let head: String
13 let todo: String? 28 let todo: String?
@@ -51,6 +66,21 @@ public struct AgendaSource: Sendable {
51 keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) } 66 keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) }
52 priorities = settings.priorities 67 priorities = settings.priorities
53 68
69 var stampsByHeading: [Int: [String]] = [:]
70 var headingIndex = -1
71 func collect(_ node: SyntaxNode) {
72 for child in node.children {
73 if child.kind == .heading { headingIndex += 1 }
74 if child.kind == .timestamp, headingIndex >= 0 {
75 stampsByHeading[headingIndex, default: []].append(child.text)
76 } else if child.kind != .block, child.kind != .planning {
77 collect(child)
78 }
79 }
80 }
81 collect(tree.root)
82 func sectionStamps(_ index: Int) -> [String] { stampsByHeading[index] ?? [] }
83
54 var keywordCategory: String? 84 var keywordCategory: String?
55 var srcBodies: [Range<Int>] = [] 85 var srcBodies: [Range<Int>] = []
56 var planningLines: [Range<Int>] = [] 86 var planningLines: [Range<Int>] = []
@@ -118,6 +148,7 @@ public struct AgendaSource: Sendable {
118 148
119 var scheduled: (String, Int)? 149 var scheduled: (String, Int)?
120 var deadline: (String, Int)? 150 var deadline: (String, Int)?
151 var planningValues: [String: String] = [:]
121 let afterHeading = info.headingRange.upperBound 152 let afterHeading = info.headingRange.upperBound
122 if let planning = planningLines.first(where: { $0.lowerBound == afterHeading }) { 153 if let planning = planningLines.first(where: { $0.lowerBound == afterHeading }) {
123 let planningText = ns.substring(with: NSRange(location: planning.lowerBound, length: planning.count)) 154 let planningText = ns.substring(with: NSRange(location: planning.lowerBound, length: planning.count))
@@ -127,9 +158,28 @@ public struct AgendaSource: Sendable {
127 let value = (pns.substring(with: m.range(at: 2)), planning.lowerBound + m.range(at: 2).location - 1) 158 let value = (pns.substring(with: m.range(at: 2)), planning.lowerBound + m.range(at: 2).location - 1)
128 if pns.substring(with: m.range(at: 1)) == "SCHEDULED" { scheduled = scheduled ?? value } else { deadline = deadline ?? value } 159 if pns.substring(with: m.range(at: 1)) == "SCHEDULED" { scheduled = scheduled ?? value } else { deadline = deadline ?? value }
129 } 160 }
161 let any = try! NSRegularExpression(pattern: "\\b(SCHEDULED|DEADLINE|CLOSED): *([\\[<]\(Self.tsInternal)[\\]>])")
162 for m in any.matches(in: planningText, range: NSRange(location: 0, length: pns.length)) {
163 let key = pns.substring(with: m.range(at: 1))
164 if planningValues[key] == nil { planningValues[key] = pns.substring(with: m.range(at: 2)) }
165 }
166 }
167 var properties: [String: String] = [:]
168 for property in info.properties {
169 let key = property.key.uppercased()
170 if property.additive, let value = properties[key] {
171 properties[key] = value + " " + property.value
172 } else {
173 properties[key] = property.value
174 }
130 } 175 }
176 let ownStamps = sectionStamps(index)
131 headings.append(Heading( 177 headings.append(Heading(
132 start: info.headingRange.lowerBound, head: head, todo: info.todo, isDone: info.isDone, 178 start: info.headingRange.lowerBound, level: info.level, skipped: skipped[index],
179 title: info.title.trimmingCharacters(in: .whitespaces), priority: info.priority, localTags: info.localTags,
180 properties: properties, planning: planningValues,
181 firstActive: ownStamps.first { $0.hasPrefix("<") }, firstInactive: ownStamps.first { $0.hasPrefix("[") },
182 head: head, todo: info.todo, isDone: info.isDone,
133 tags: tags, category: category, scheduled: scheduled, deadline: deadline 183 tags: tags, category: category, scheduled: scheduled, deadline: deadline
134 )) 184 ))
135 } 185 }
Tests/OrgCoreTests/AgendaTests.swift +103 −18
@@ -22,7 +22,7 @@ enum AgendaOracle {
22 22
23 static let keywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL" 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 let fileList = files.map { "\"\($0)\"" }.joined(separator: " ") 26 let fileList = files.map { "\"\($0)\"" }.joined(separator: " ")
27 let words = keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ") 27 let words = keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ")
28 return #""" 28 return #"""
@@ -44,7 +44,7 @@ enum AgendaOracle {
44 ((get-text-property p 'org-agenda-date-header) 44 ((get-text-property p 'org-agenda-date-header)
45 (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) 45 (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days))
46 (setq header line items nil)) 46 (setq header line items nil))
47 (header 47 ((or header hd)
48 (push `((line . ,line) 48 (push `((line . ,line)
49 (type . ,(get-text-property p 'type)) 49 (type . ,(get-text-property p 'type))
50 (category . ,(get-text-property p 'org-category)) 50 (category . ,(get-text-property p 'org-category))
@@ -55,11 +55,11 @@ enum AgendaOracle {
55 (pos . ,(and hd (marker-position hd)))) 55 (pos . ,(and hd (marker-position hd))))
56 items)))) 56 items))))
57 (forward-line 1)) 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 (vconcat (nreverse days)))) 59 (vconcat (nreverse days))))
60 \#(frozenClock(seconds, """ 60 \#(frozenClock(seconds, """
61 (progn 61 (progn
62 (org-agenda-list nil "\(start)" \(span)) 62 \(form)
63 (let ((result (with-current-buffer org-agenda-buffer-name (orgstar-dump)))) 63 (let ((result (with-current-buffer org-agenda-buffer-name (orgstar-dump))))
64 (let ((coding-system-for-write 'utf-8-unix)) 64 (let ((coding-system-for-write 'utf-8-unix))
65 (with-temp-file (getenv "ORACLE_OUTPUT") 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 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-agenda-\(UUID().uuidString)") 72 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-agenda-\(UUID().uuidString)")
73 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) 73 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
74 defer { try? FileManager.default.removeItem(at: folder) } 74 defer { try? FileManager.default.removeItem(at: folder) }
@@ -78,11 +78,10 @@ enum AgendaOracle {
78 try file.text.write(to: url, atomically: true, encoding: .utf8) 78 try file.text.write(to: url, atomically: true, encoding: .utf8)
79 paths.append(url.resolvingSymlinksInPath().path) 79 paths.append(url.resolvingSymlinksInPath().path)
80 } 80 }
81 let date = Days.date(start)
82 let seconds = (today - 719163) * 86400 + hour * 3600 + minute * 60 81 let seconds = (today - 719163) * 86400 + hour * 3600 + minute * 60
83 let scriptURL = folder.appendingPathComponent("agenda.el") 82 let scriptURL = folder.appendingPathComponent("agenda.el")
84 let output = folder.appendingPathComponent("output.json") 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 .write(to: scriptURL, atomically: true, encoding: .utf8) 85 .write(to: scriptURL, atomically: true, encoding: .utf8)
87 let log = try EmacsOracle.run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"]) 86 let log = try EmacsOracle.run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"])
88 guard let data = try? Data(contentsOf: output) else { throw OracleError(log: log) } 87 guard let data = try? Data(contentsOf: output) else { throw OracleError(log: log) }
@@ -108,26 +107,41 @@ enum AgendaOracle {
108 } 107 }
109 108
110 static func compare(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30) throws { 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 let defaults = OrgSettings.default.withTodoKeywords(keywords)! 112 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
113 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) } 113 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }
114 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: .terminal) 114 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: .terminal)
115 #expect(ours.count == emacs.count) 115 #expect(ours.count == emacs.count)
116 for (o, e) in zip(ours, emacs) { 116 for (o, e) in zip(ours, emacs) {
117 #expect(o.header == e.header) 117 #expect(o.header == e.header)
118 let theirs = e.items.map { item in 118 #expect(describe(o.items) == describe(e.items), "\(e.header) today \(Days.date(today))")
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) ?? "-")" 119 }
120 } 120 }
121 let mine = o.items.map { item in 121
122 let type: String = switch item.kind { 122 static func describe(_ items: [Line]) -> [String] {
123 case .timeGrid, .currentTime: "-" 123 items.map { item in
124 default: item.kind.rawValue 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 } 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) ?? "-")" 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
133struct AgendaTests { 147struct AgendaTests {
@@ -181,8 +195,12 @@ struct AgendaTests {
181 * TODO Delayed 195 * TODO Delayed
182 SCHEDULED: <2026-10-02 Fri -2d> 196 SCHEDULED: <2026-10-02 Fri -2d>
183 * Archived :ARCHIVE: 197 * Archived :ARCHIVE:
198 SCHEDULED: <2026-10-05 Mon>
184 <2026-10-05 Mon> 199 <2026-10-05 Mon>
200 ** TODO Under archived
201 DEADLINE: <2026-10-05 Mon>
185 * COMMENT Commented 202 * COMMENT Commented
203 DEADLINE: <2026-10-05 Mon>
186 <2026-10-05 Mon> 204 <2026-10-05 Mon>
187 * Blocks 205 * Blocks
188 #+begin_src sh 206 #+begin_src sh
@@ -214,6 +232,67 @@ struct AgendaTests {
214 <2026-10-05 Mon 13:00> and <2026-10-06 Tue> 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 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) 296 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
218 func matchesEmacs() throws { 297 func matchesEmacs() throws {
219 let files = [("a.org", Self.synthetic), ("b.org", Self.second)] 298 let files = [("a.org", Self.synthetic), ("b.org", Self.second)]
@@ -237,5 +316,11 @@ struct AgendaTests {
237 for today in [Days.absolute(Date(), calendar: .current), Days.absolute(year: 2025, month: 3, day: 14)] { 316 for today in [Days.absolute(Date(), calendar: .current), Days.absolute(year: 2025, month: 3, day: 14)] {
238 try AgendaOracle.compare(files: files, start: today - 3, span: 10, today: today) 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` (⌘S-free: `s`/`d` too), `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- [ ] `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- [ ] Commit "Agenda views".
36
37### Task 3: Agenda commands
38
39- [ ] `AgendaEditor`: run a command at a file offset, through the session or through `Saver`.
40- [ ] Keys in the agenda window; prompts for dates, tags and priority; the agenda refreshes after each.
41- [ ] Commit "Agenda commands".