Commit 042e2f8aa2
Verified · cmc
Layout: unified · split
Sources/OrgCore/Agenda/Agenda.swift +13 −8
| @@ -10,6 +10,8 @@ public struct AgendaItem: Sendable, Equatable { | ||
| 10 | 10 | case block |
| 11 | 11 | case timeGrid |
| 12 | 12 | case currentTime |
| 13 | case todo | |
| 14 | case tagsMatch = "tagsmatch" | |
| 13 | 15 | } |
| 14 | 16 | |
| 15 | 17 | public let kind: Kind |
| @@ -93,7 +95,7 @@ public enum Agenda { | ||
| 93 | 95 | let isToday = current == today |
| 94 | 96 | var items: [AgendaItem] = [] |
| 95 | 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 | 99 | let repeat_ = current <= today ? deadline : (Days.closest(s, to: current, prefer: .future) ?? deadline) |
| 98 | 100 | let diff = deadline - current |
| 99 | 101 | let warningDays = Days.warningDays(s, delay: false, defaultDays: options.deadlineWarningDays) |
| @@ -128,7 +130,7 @@ public enum Agenda { | ||
| 128 | 130 | let isToday = current == today |
| 129 | 131 | var items: [AgendaItem] = [] |
| 130 | 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 | 134 | let repeat_ = current <= today ? schedule : (Days.closest(s, to: current, prefer: .future) ?? schedule) |
| 133 | 135 | let diff = current - schedule |
| 134 | 136 | let past = schedule < today |
| @@ -233,7 +235,8 @@ public enum Agenda { | ||
| 233 | 235 | /// `org-agenda-highlight-todo`. |
| 234 | 236 | static func format( |
| 235 | 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 | 240 | ) -> AgendaItem { |
| 238 | 241 | var txt = heading.head.trimmingCharacters(in: .whitespaces) |
| 239 | 242 | // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list. |
| @@ -256,14 +259,16 @@ public enum Agenda { | ||
| 256 | 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 | 263 | if let removing { |
| 261 | 264 | while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) { |
| 262 | 265 | txt = (txt as NSString).replacingCharacters(in: m.range, with: "") |
| 263 | 266 | } |
| 264 | 267 | } |
| 265 | 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 | 272 | txt = highlightTodo(txt, keywords: source.keywords) |
| 268 | 273 | let priority = Self.priority(prefix + txt, source.priorities) |
| 269 | 274 | return AgendaItem( |
| @@ -276,7 +281,7 @@ public enum Agenda { | ||
| 276 | 281 | |
| 277 | 282 | /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the |
| 278 | 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 | 285 | guard let dotime else { return (nil, "") } |
| 281 | 286 | let full = tagString.isEmpty ? txt : txt + " " + tagString |
| 282 | 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 | 299 | start = ns.substring(with: m.range(at: 2)) |
| 295 | 300 | let endGroup = m.range(at: 3).location != NSNotFound ? 4 : 6 |
| 296 | 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 | 303 | } else if let m = plainTime.firstMatch(in: containing, range: all) { |
| 299 | 304 | start = ns.substring(with: m.range(at: 1)) |
| 300 | 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 | 308 | let startText = start.flatMap(formattedTime).map { String(repeating: " ", count: max(0, 5 - $0.count)) + $0 } |
| 304 | 309 | let endText = end.flatMap(formattedTime) |
Sources/OrgCore/Agenda/AgendaSearch.swift added +364
| @@ -0,0 +1,364 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | extension Agenda { | |
| 4 | /// `org-todo-list`: unfinished entries, or with `keywords` (`WAIT|HOLD`) those with any of | |
| 5 | /// the keywords. | |
| 6 | public static func todoList(_ sources: [AgendaSource], keywords: String? = nil) -> [AgendaItem] { | |
| 7 | let selected = keywords.map { $0.split(separator: "|").map(String.init) } | |
| 8 | var items: [AgendaItem] = [] | |
| 9 | for source in sources { | |
| 10 | for heading in source.headings where !heading.skipped { | |
| 11 | guard let todo = heading.todo else { continue } | |
| 12 | if let selected { | |
| 13 | guard selected.contains(todo) else { continue } | |
| 14 | } else if heading.isDone { | |
| 15 | continue | |
| 16 | } | |
| 17 | items.append(format( | |
| 18 | source, heading, kind: .todo, marker: heading.start, extra: "", dotime: .headline, removing: nil, | |
| 19 | trailing: "", hasTime: false, urgency: { 1 + $0 } | |
| 20 | )) | |
| 21 | } | |
| 22 | } | |
| 23 | return sortByUrgency(items) | |
| 24 | } | |
| 25 | ||
| 26 | /// `org-tags-view`: entries matching a tags/property/TODO match string. `todoOnly` keeps | |
| 27 | /// unfinished TODO entries, as `M` does. | |
| 28 | public static func tagsMatch(_ sources: [AgendaSource], match: String, todoOnly: Bool = false, now: Date = Date(), calendar: Calendar = .current) -> [AgendaItem] { | |
| 29 | let matcher = TagsMatcher(match, todoOnly: todoOnly) | |
| 30 | var items: [AgendaItem] = [] | |
| 31 | for source in sources { | |
| 32 | for heading in source.headings { | |
| 33 | if matcher.todoOnly, heading.todo == nil { continue } | |
| 34 | guard matcher.matches(heading, in: source, now: now, calendar: calendar), !heading.skipped else { continue } | |
| 35 | items.append(format( | |
| 36 | source, heading, kind: .tagsMatch, marker: heading.start, extra: "", dotime: nil, removing: nil, | |
| 37 | trailing: "", hasTime: false, urgency: { $0 } | |
| 38 | )) | |
| 39 | } | |
| 40 | } | |
| 41 | return sortByUrgency(items) | |
| 42 | } | |
| 43 | ||
| 44 | /// `(urgency-down category-keep)`, stably. | |
| 45 | static func sortByUrgency(_ items: [AgendaItem]) -> [AgendaItem] { | |
| 46 | items.enumerated().sorted { a, b in | |
| 47 | a.element.urgency != b.element.urgency ? a.element.urgency > b.element.urgency : a.offset < b.offset | |
| 48 | }.map(\.element) | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | /// `org-make-tags-matcher`: `+work-boss|LEVEL>2+TODO="WAIT"/!NEXT`. | |
| 53 | public struct TagsMatcher: Sendable { | |
| 54 | indirect enum Term: Sendable { | |
| 55 | case tag(String) | |
| 56 | case tagRegex(String) | |
| 57 | case property(name: String, op: String, value: Value, needsValue: Bool) | |
| 58 | case not(Term) | |
| 59 | } | |
| 60 | ||
| 61 | enum Value: Sendable { | |
| 62 | case regex(String) | |
| 63 | case string(String) | |
| 64 | case time(String) | |
| 65 | case number(Double) | |
| 66 | } | |
| 67 | ||
| 68 | /// Alternatives of conjunctions; nil matches everything. | |
| 69 | let tags: [[Term]]? | |
| 70 | let todo: [[(regex: Bool, keyword: String, negated: Bool)]]? | |
| 71 | let todoOnly: Bool | |
| 72 | ||
| 73 | static let term = try! NSRegularExpression(pattern: | |
| 74 | "^&?([-+:])?(\\{[^}]+\\}|(?:((?:[\\p{L}\\p{N}_]|\\\\[^\\s])+)([<=>]=?|[!/]=|<>)(\\*)?(\\{[^}]+\\}|\"[^\"]*\"|-?[.0-9]+(?:[eE][-+]?[0-9]+)?))|[\\p{L}\\p{N}_@#%]+)" | |
| 75 | ) | |
| 76 | static let timeValue = try! NSRegularExpression(pattern: "^\"[\\[<](?:[0-9]+|now|today|tomorrow|[+-][0-9]+[dmwy]).*[\\]>]\"$") | |
| 77 | ||
| 78 | public init(_ match: String, todoOnly: Bool = false) { | |
| 79 | var only = todoOnly | |
| 80 | var tagsPart = match | |
| 81 | var todoPart: String? | |
| 82 | let ns = match as NSString | |
| 83 | let slashes = try! NSRegularExpression(pattern: "/+") | |
| 84 | if let last = slashes.matches(in: match, range: NSRange(location: 0, length: ns.length)).last, | |
| 85 | !ns.substring(from: last.range.location).contains("\"") { | |
| 86 | tagsPart = ns.substring(to: last.range.location) | |
| 87 | var rest = ns.substring(from: NSMaxRange(last.range)) | |
| 88 | if rest.hasPrefix("!") { | |
| 89 | only = true | |
| 90 | rest.removeFirst() | |
| 91 | } | |
| 92 | todoPart = rest.trimmingCharacters(in: .whitespaces).isEmpty ? nil : rest | |
| 93 | } | |
| 94 | self.todoOnly = only | |
| 95 | ||
| 96 | func terms(_ s: String) -> [(sign: String?, match: NSTextCheckingResult, text: NSString)] { | |
| 97 | var result: [(String?, NSTextCheckingResult, NSString)] = [] | |
| 98 | var rest = s | |
| 99 | while let m = Self.term.firstMatch(in: rest, range: NSRange(location: 0, length: (rest as NSString).length)) { | |
| 100 | let text = rest as NSString | |
| 101 | let sign = m.range(at: 1).location != NSNotFound ? text.substring(with: m.range(at: 1)) : nil | |
| 102 | result.append((sign, m, text)) | |
| 103 | rest = text.substring(from: NSMaxRange(m.range)) | |
| 104 | if m.range.length == 0 { break } | |
| 105 | } | |
| 106 | return result | |
| 107 | } | |
| 108 | ||
| 109 | if tagsPart.contains(where: { !$0.isWhitespace }) { | |
| 110 | var alternatives: [[Term]] = [] | |
| 111 | var parts = Self.split(tagsPart) | |
| 112 | var i = 0 | |
| 113 | while i < parts.count { | |
| 114 | var part = parts[i] | |
| 115 | // A `\|` inside a term is not a separator. | |
| 116 | while part.hasSuffix("\\"), i + 1 < parts.count { | |
| 117 | i += 1 | |
| 118 | part += "|" + parts[i] | |
| 119 | } | |
| 120 | var conjunction: [Term] = [] | |
| 121 | for (sign, m, text) in terms(part) { | |
| 122 | let whole = text.substring(with: m.range(at: 2)) | |
| 123 | var term: Term | |
| 124 | if whole.hasPrefix("{") { | |
| 125 | term = .tagRegex(String(whole.dropFirst().dropLast())) | |
| 126 | } else if m.range(at: 3).location != NSNotFound { | |
| 127 | let name = text.substring(with: m.range(at: 3)) | |
| 128 | .replacingOccurrences(of: "\\\\(.)", with: "$1", options: .regularExpression).uppercased() | |
| 129 | let raw = text.substring(with: m.range(at: 6)) | |
| 130 | let value: Value | |
| 131 | if raw.hasPrefix("{") { | |
| 132 | value = .regex(String(raw.dropFirst().dropLast())) | |
| 133 | } else if raw.hasPrefix("\"") { | |
| 134 | let inner = String(raw.dropFirst().dropLast()) | |
| 135 | let isTime = Self.timeValue.firstMatch(in: raw, range: NSRange(location: 0, length: (raw as NSString).length)) != nil | |
| 136 | value = isTime ? .time(inner) : .string(inner) | |
| 137 | } else { | |
| 138 | value = .number(Self.number(raw)) | |
| 139 | } | |
| 140 | term = .property(name: name, op: text.substring(with: m.range(at: 4)), value: value, needsValue: m.range(at: 5).location != NSNotFound) | |
| 141 | } else { | |
| 142 | term = .tag(whole) | |
| 143 | } | |
| 144 | if sign == "-" { term = .not(term) } | |
| 145 | conjunction.append(term) | |
| 146 | } | |
| 147 | alternatives.append(conjunction) | |
| 148 | i += 1 | |
| 149 | } | |
| 150 | tags = alternatives | |
| 151 | } else { | |
| 152 | tags = nil | |
| 153 | } | |
| 154 | ||
| 155 | if let todoPart { | |
| 156 | todo = Self.split(todoPart).map { part in | |
| 157 | terms(part).map { sign, m, text in | |
| 158 | let keyword = text.substring(with: m.range(at: 2)) | |
| 159 | return keyword.hasPrefix("{") | |
| 160 | ? (true, String(keyword.dropFirst().dropLast()), sign == "-") | |
| 161 | : (false, keyword, sign == "-") | |
| 162 | } | |
| 163 | } | |
| 164 | } else { | |
| 165 | todo = nil | |
| 166 | } | |
| 167 | } | |
| 168 | ||
| 169 | /// `org-split-string` on `|`: empty strings between separators stay. | |
| 170 | static func split(_ s: String) -> [String] { | |
| 171 | var parts = s.components(separatedBy: "|") | |
| 172 | if parts.first == "" { parts.removeFirst() } | |
| 173 | if parts.last == "", !parts.isEmpty { parts.removeLast() } | |
| 174 | return parts.isEmpty ? [""] : parts | |
| 175 | } | |
| 176 | ||
| 177 | /// `string-to-number`: the leading number, or 0. | |
| 178 | static func number(_ s: String) -> Double { | |
| 179 | let trimmed = s.drop { $0 == " " || $0 == "\t" } | |
| 180 | guard let r = trimmed.range(of: "^[-+]?([0-9]+\\.?[0-9]*|\\.[0-9]+)([eE][-+]?[0-9]+)?", options: .regularExpression) else { return 0 } | |
| 181 | return Double(trimmed[r]) ?? 0 | |
| 182 | } | |
| 183 | ||
| 184 | func matches(_ heading: AgendaSource.Heading, in source: AgendaSource, now: Date, calendar: Calendar) -> Bool { | |
| 185 | let todoKeyword = heading.todo | |
| 186 | if todoOnly { | |
| 187 | guard let todoKeyword, !heading.isDone else { return false } | |
| 188 | _ = todoKeyword | |
| 189 | } | |
| 190 | let tagList = heading.tags.map(\.name) | |
| 191 | func value(_ name: String) -> String? { | |
| 192 | switch name { | |
| 193 | case "LEVEL": return String(heading.level) | |
| 194 | case "CATEGORY": return heading.category | |
| 195 | case "TODO": return heading.todo | |
| 196 | case "ITEM": return heading.title | |
| 197 | case "PRIORITY": return heading.priority ?? source.priorities.default | |
| 198 | case "FILE": return source.path | |
| 199 | case "TAGS": return heading.localTags.isEmpty ? nil : ":" + heading.localTags.joined(separator: ":") + ":" | |
| 200 | case "ALLTAGS": return tagList.isEmpty ? nil : ":" + tagList.joined(separator: ":") + ":" | |
| 201 | case "SCHEDULED", "DEADLINE", "CLOSED": return heading.planning[name] | |
| 202 | case "TIMESTAMP": return heading.firstActive | |
| 203 | case "TIMESTAMP_IA": return heading.firstInactive | |
| 204 | default: return heading.properties[name] | |
| 205 | } | |
| 206 | } | |
| 207 | func evaluate(_ term: Term) -> Bool { | |
| 208 | switch term { | |
| 209 | case .not(let inner): | |
| 210 | return !evaluate(inner) | |
| 211 | case .tag(let tag): | |
| 212 | return tagList.contains(tag) | |
| 213 | case .tagRegex(let pattern): | |
| 214 | return tagList.contains { EmacsRegex.matches(pattern, $0) } | |
| 215 | case .property(let name, let op, let expected, let needsValue): | |
| 216 | let found = value(name) | |
| 217 | if needsValue, found == nil { return false } | |
| 218 | let actual = found ?? "" | |
| 219 | switch expected { | |
| 220 | case .regex(let pattern): | |
| 221 | let hit = EmacsRegex.matches(pattern, actual) | |
| 222 | return op == "/=" ? !hit : hit | |
| 223 | case .string(let string): | |
| 224 | return Self.compare(op, actual, string) | |
| 225 | case .number(let number): | |
| 226 | return Self.compare(op, Self.number(actual), number) | |
| 227 | case .time(let time): | |
| 228 | let a = Self.seconds(actual, now: now, calendar: calendar) | |
| 229 | let b = Self.matcherTime(time, now: now, calendar: calendar) | |
| 230 | guard a > 0, b > 0 else { return false } | |
| 231 | // `org-time<>` compares with `=`. | |
| 232 | return op == "<>" || op == "!=" || op == "/=" ? a == b : Self.compare(op, a, b) | |
| 233 | } | |
| 234 | } | |
| 235 | } | |
| 236 | if let tags, !tags.contains(where: { $0.allSatisfy(evaluate) }) { return false } | |
| 237 | if let todo { | |
| 238 | let ok = todo.contains { conjunction in | |
| 239 | conjunction.allSatisfy { term in | |
| 240 | let hit = term.regex | |
| 241 | ? todoKeyword.map { EmacsRegex.matches(term.keyword, $0) } ?? false | |
| 242 | : todoKeyword == term.keyword | |
| 243 | return term.negated ? !hit : hit | |
| 244 | } | |
| 245 | } | |
| 246 | if !ok { return false } | |
| 247 | } | |
| 248 | return true | |
| 249 | } | |
| 250 | ||
| 251 | static func compare<T: Comparable>(_ op: String, _ a: T, _ b: T) -> Bool { | |
| 252 | switch op { | |
| 253 | case "<": a < b | |
| 254 | case ">": a > b | |
| 255 | case "<=", "=<": a <= b | |
| 256 | case ">=", "=>": a >= b | |
| 257 | case "=", "==": a == b | |
| 258 | default: a != b | |
| 259 | } | |
| 260 | } | |
| 261 | ||
| 262 | /// `org-2ft`: a timestamp string as seconds, or 0. | |
| 263 | static func seconds(_ s: String, now: Date, calendar: Calendar) -> Double { | |
| 264 | let ns = s as NSString | |
| 265 | guard let m = Days.dateRegex.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return 0 } | |
| 266 | var c = DateComponents(year: Int(ns.substring(with: m.range(at: 1))), month: Int(ns.substring(with: m.range(at: 2))), day: Int(ns.substring(with: m.range(at: 3)))) | |
| 267 | let hour = Days.hour(of: s) | |
| 268 | c.hour = hour | |
| 269 | if let time = try? NSRegularExpression(pattern: " ([0-9]{1,2}):([0-9]{2})").firstMatch(in: s, range: NSRange(location: NSMaxRange(m.range), length: ns.length - NSMaxRange(m.range))), Int(ns.substring(with: time.range(at: 1))) == hour { | |
| 270 | c.minute = Int(ns.substring(with: time.range(at: 2))) | |
| 271 | } | |
| 272 | return calendar.date(from: c)?.timeIntervalSince1970 ?? 0 | |
| 273 | } | |
| 274 | ||
| 275 | /// `org-matcher-time`. | |
| 276 | static func matcherTime(_ s: String, now: Date, calendar: Calendar) -> Double { | |
| 277 | let today = calendar.startOfDay(for: now).timeIntervalSince1970 | |
| 278 | switch s { | |
| 279 | case "<now>": return now.timeIntervalSince1970 | |
| 280 | case "<today>": return today | |
| 281 | case "<tomorrow>": return today + 86400 | |
| 282 | case "<yesterday>": return today - 86400 | |
| 283 | default: break | |
| 284 | } | |
| 285 | let ns = s as NSString | |
| 286 | if let m = try! NSRegularExpression(pattern: "^<([-+][0-9]+)([hdwmy])>$").firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) { | |
| 287 | let n = Double(ns.substring(with: m.range(at: 1)))! | |
| 288 | let unit = ns.substring(with: m.range(at: 2)) | |
| 289 | let size: Double = ["h": 3600, "d": 86400, "w": 604800, "m": 2678400, "y": 31557600][unit]! | |
| 290 | return (unit == "h" ? now.timeIntervalSince1970 : today) + n * size | |
| 291 | } | |
| 292 | return seconds(s, now: now, calendar: calendar) | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 296 | /// Emacs regular expressions run through ICU, case-insensitively as the matcher binds | |
| 297 | /// `case-fold-search`. | |
| 298 | enum EmacsRegex { | |
| 299 | static func translate(_ pattern: String) -> String { | |
| 300 | var out = "" | |
| 301 | var chars = Array(pattern) | |
| 302 | var i = 0 | |
| 303 | var inClass = false | |
| 304 | while i < chars.count { | |
| 305 | let c = chars[i] | |
| 306 | if inClass { | |
| 307 | if c == "]" { inClass = false } | |
| 308 | if c == "[" , i + 1 < chars.count, chars[i + 1] == ":" { | |
| 309 | // `[:alpha:]` passes through. | |
| 310 | if let end = String(chars[i...]).range(of: ":]") { | |
| 311 | let length = String(chars[i...]).distance(from: String(chars[i...]).startIndex, to: end.upperBound) | |
| 312 | out += String(chars[i..<(i + length)]) | |
| 313 | i += length | |
| 314 | continue | |
| 315 | } | |
| 316 | } | |
| 317 | out += c == "\\" ? "\\\\" : String(c) | |
| 318 | i += 1 | |
| 319 | continue | |
| 320 | } | |
| 321 | if c == "\\", i + 1 < chars.count { | |
| 322 | let n = chars[i + 1] | |
| 323 | switch n { | |
| 324 | case "(": | |
| 325 | if i + 3 < chars.count, chars[i + 2] == "?", chars[i + 3] == ":" { | |
| 326 | out += "(?:" | |
| 327 | i += 4 | |
| 328 | continue | |
| 329 | } | |
| 330 | out += "(" | |
| 331 | case ")": out += ")" | |
| 332 | case "|": out += "|" | |
| 333 | case "{": out += "{" | |
| 334 | case "}": out += "}" | |
| 335 | case "<", ">": out += "\\b" | |
| 336 | case "`": out += "^" | |
| 337 | case "'": out += "$" | |
| 338 | case "w": out += "[\\p{L}\\p{N}]" | |
| 339 | case "W": out += "[^\\p{L}\\p{N}]" | |
| 340 | default: out += "\\" + String(n) | |
| 341 | } | |
| 342 | i += 2 | |
| 343 | continue | |
| 344 | } | |
| 345 | switch c { | |
| 346 | case "(", ")", "|", "{", "}": out += "\\" + String(c) | |
| 347 | case "[": | |
| 348 | inClass = true | |
| 349 | out += "[" | |
| 350 | if i + 1 < chars.count, chars[i + 1] == "^" { out += "^"; i += 1 } | |
| 351 | if i + 1 < chars.count, chars[i + 1] == "]" { out += "\\]"; i += 1 } | |
| 352 | default: out += String(c) | |
| 353 | } | |
| 354 | i += 1 | |
| 355 | } | |
| 356 | chars = [] | |
| 357 | return out | |
| 358 | } | |
| 359 | ||
| 360 | static func matches(_ pattern: String, _ string: String) -> Bool { | |
| 361 | guard let regex = try? NSRegularExpression(pattern: translate(pattern), options: .caseInsensitive) else { return false } | |
| 362 | return regex.firstMatch(in: string, range: NSRange(location: 0, length: (string as NSString).length)) != nil | |
| 363 | } | |
| 364 | } | |
Sources/OrgCore/Agenda/AgendaSource.swift +51 −1
| @@ -8,6 +8,21 @@ public struct AgendaSource: Sendable { | ||
| 8 | 8 | struct Heading: Sendable { |
| 9 | 9 | /// Start of the heading line. |
| 10 | 10 | let start: Int |
| 11 | let level: Int | |
| 12 | /// In an archived or commented tree, which the agenda skips. | |
| 13 | let skipped: Bool | |
| 14 | /// The title without keyword, priority and tags (`ITEM`). | |
| 15 | let title: String | |
| 16 | /// The priority cookie's value. | |
| 17 | let priority: String? | |
| 18 | let localTags: [String] | |
| 19 | /// Upper-cased keys of the entry's own drawer. | |
| 20 | let properties: [String: String] | |
| 21 | /// Planning timestamps with brackets, for property matches. | |
| 22 | let planning: [String: String] | |
| 23 | /// The first active and inactive timestamps in the entry (`TIMESTAMP`, `TIMESTAMP_IA`). | |
| 24 | let firstActive: String? | |
| 25 | let firstInactive: String? | |
| 11 | 26 | /// The line after the stars. |
| 12 | 27 | let head: String |
| 13 | 28 | let todo: String? |
| @@ -51,6 +66,21 @@ public struct AgendaSource: Sendable { | ||
| 51 | 66 | keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) } |
| 52 | 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 | 84 | var keywordCategory: String? |
| 55 | 85 | var srcBodies: [Range<Int>] = [] |
| 56 | 86 | var planningLines: [Range<Int>] = [] |
| @@ -118,6 +148,7 @@ public struct AgendaSource: Sendable { | ||
| 118 | 148 | |
| 119 | 149 | var scheduled: (String, Int)? |
| 120 | 150 | var deadline: (String, Int)? |
| 151 | var planningValues: [String: String] = [:] | |
| 121 | 152 | let afterHeading = info.headingRange.upperBound |
| 122 | 153 | if let planning = planningLines.first(where: { $0.lowerBound == afterHeading }) { |
| 123 | 154 | let planningText = ns.substring(with: NSRange(location: planning.lowerBound, length: planning.count)) |
| @@ -127,9 +158,28 @@ public struct AgendaSource: Sendable { | ||
| 127 | 158 | let value = (pns.substring(with: m.range(at: 2)), planning.lowerBound + m.range(at: 2).location - 1) |
| 128 | 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 | 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 | 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 | 23 | static let keywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL" |
| 24 | 24 | |
| 25 | static func script(files: [String], start: String, span: Int, seconds: Int) -> String { | |
| 25 | static func script(files: [String], form: String, seconds: Int) -> String { | |
| 26 | 26 | let fileList = files.map { "\"\($0)\"" }.joined(separator: " ") |
| 27 | 27 | let words = keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ") |
| 28 | 28 | return #""" |
| @@ -44,7 +44,7 @@ enum AgendaOracle { | ||
| 44 | 44 | ((get-text-property p 'org-agenda-date-header) |
| 45 | 45 | (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) |
| 46 | 46 | (setq header line items nil)) |
| 47 | (header | |
| 47 | ((or header hd) | |
| 48 | 48 | (push `((line . ,line) |
| 49 | 49 | (type . ,(get-text-property p 'type)) |
| 50 | 50 | (category . ,(get-text-property p 'org-category)) |
| @@ -55,11 +55,11 @@ enum AgendaOracle { | ||
| 55 | 55 | (pos . ,(and hd (marker-position hd)))) |
| 56 | 56 | items)))) |
| 57 | 57 | (forward-line 1)) |
| 58 | (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) | |
| 58 | (when (or header items) (push `((header . ,(or header "")) (items . ,(vconcat (nreverse items)))) days)) | |
| 59 | 59 | (vconcat (nreverse days)))) |
| 60 | 60 | \#(frozenClock(seconds, """ |
| 61 | 61 | (progn |
| 62 | (org-agenda-list nil "\(start)" \(span)) | |
| 62 | \(form) | |
| 63 | 63 | (let ((result (with-current-buffer org-agenda-buffer-name (orgstar-dump)))) |
| 64 | 64 | (let ((coding-system-for-write 'utf-8-unix)) |
| 65 | 65 | (with-temp-file (getenv "ORACLE_OUTPUT") |
| @@ -68,7 +68,7 @@ enum AgendaOracle { | ||
| 68 | 68 | """# |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | static func run(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int, minute: Int) throws -> [Day] { | |
| 71 | static func run(files: [(name: String, text: String)], form: String, today: Int, hour: Int, minute: Int) throws -> [Day] { | |
| 72 | 72 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-agenda-\(UUID().uuidString)") |
| 73 | 73 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| 74 | 74 | defer { try? FileManager.default.removeItem(at: folder) } |
| @@ -78,11 +78,10 @@ enum AgendaOracle { | ||
| 78 | 78 | try file.text.write(to: url, atomically: true, encoding: .utf8) |
| 79 | 79 | paths.append(url.resolvingSymlinksInPath().path) |
| 80 | 80 | } |
| 81 | let date = Days.date(start) | |
| 82 | 81 | let seconds = (today - 719163) * 86400 + hour * 3600 + minute * 60 |
| 83 | 82 | let scriptURL = folder.appendingPathComponent("agenda.el") |
| 84 | 83 | let output = folder.appendingPathComponent("output.json") |
| 85 | try script(files: paths, start: String(format: "%04d-%02d-%02d", date.year, date.month, date.day), span: span, seconds: seconds) | |
| 84 | try script(files: paths, form: form, seconds: seconds) | |
| 86 | 85 | .write(to: scriptURL, atomically: true, encoding: .utf8) |
| 87 | 86 | let log = try EmacsOracle.run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"]) |
| 88 | 87 | guard let data = try? Data(contentsOf: output) else { throw OracleError(log: log) } |
| @@ -108,26 +107,41 @@ enum AgendaOracle { | ||
| 108 | 107 | } |
| 109 | 108 | |
| 110 | 109 | static func compare(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30) throws { |
| 111 | let emacs = try run(files: files, start: start, span: span, today: today, hour: hour, minute: minute) | |
| 110 | let date = Days.date(start) | |
| 111 | let emacs = try run(files: files, form: "(org-agenda-list nil \"\(String(format: "%04d-%02d-%02d", date.year, date.month, date.day))\" \(span))", today: today, hour: hour, minute: minute) | |
| 112 | 112 | let defaults = OrgSettings.default.withTodoKeywords(keywords)! |
| 113 | 113 | let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) } |
| 114 | 114 | let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: .terminal) |
| 115 | 115 | #expect(ours.count == emacs.count) |
| 116 | 116 | for (o, e) in zip(ours, emacs) { |
| 117 | 117 | #expect(o.header == e.header) |
| 118 | let theirs = e.items.map { item in | |
| 119 | "\(normalize(item.line)) | \(item.type ?? "-") \(item.category ?? "") \(item.tod.map(String.init) ?? "-") [\(item.extra ?? "")] \(item.urgency ?? 0) \(item.file ?? "-")@\(item.pos.map(String.init) ?? "-")" | |
| 120 | } | |
| 121 | let mine = o.items.map { item in | |
| 122 | let type: String = switch item.kind { | |
| 123 | case .timeGrid, .currentTime: "-" | |
| 124 | default: item.kind.rawValue | |
| 125 | } | |
| 126 | return "\(normalize(item.line)) | \(type) \(item.category) \(item.timeOfDay.map(String.init) ?? "-") [\(item.extra)] \(item.urgency) \(item.path ?? "-")@\(item.headingOffset.map(String.init) ?? "-")" | |
| 118 | #expect(describe(o.items) == describe(e.items), "\(e.header) today \(Days.date(today))") | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 122 | static func describe(_ items: [Line]) -> [String] { | |
| 123 | items.map { item in | |
| 124 | "\(normalize(item.line)) | \(item.type ?? "-") \(item.category ?? "") \(item.tod.map(String.init) ?? "-") [\(item.extra ?? "")] \(item.urgency ?? 0) \(item.file ?? "-")@\(item.pos.map(String.init) ?? "-")" | |
| 125 | } | |
| 126 | } | |
| 127 | ||
| 128 | static func describe(_ items: [AgendaItem]) -> [String] { | |
| 129 | items.map { item in | |
| 130 | let type: String = switch item.kind { | |
| 131 | case .timeGrid, .currentTime: "-" | |
| 132 | default: item.kind.rawValue | |
| 127 | 133 | } |
| 128 | #expect(mine == theirs, "\(e.header) today \(Days.date(today))") | |
| 134 | return "\(normalize(item.line)) | \(type) \(item.category) \(item.timeOfDay.map(String.init) ?? "-") [\(item.extra)] \(item.urgency) \(item.path ?? "-")@\(item.headingOffset.map(String.init) ?? "-")" | |
| 129 | 135 | } |
| 130 | 136 | } |
| 137 | ||
| 138 | /// A TODO list or tags view: `form` runs it in Emacs, `ours` builds ours from the sources. | |
| 139 | static func compareList(files: [(name: String, text: String)], form: String, today: Int, _ ours: ([AgendaSource]) -> [AgendaItem]) throws { | |
| 140 | let emacs = try run(files: files, form: form, today: today, hour: 9, minute: 30) | |
| 141 | let defaults = OrgSettings.default.withTodoKeywords(keywords)! | |
| 142 | let mine = ours(files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }) | |
| 143 | #expect(describe(mine) == describe(emacs.first?.items ?? []), "\(form)") | |
| 144 | } | |
| 131 | 145 | } |
| 132 | 146 | |
| 133 | 147 | struct AgendaTests { |
| @@ -181,8 +195,12 @@ struct AgendaTests { | ||
| 181 | 195 | * TODO Delayed |
| 182 | 196 | SCHEDULED: <2026-10-02 Fri -2d> |
| 183 | 197 | * Archived :ARCHIVE: |
| 198 | SCHEDULED: <2026-10-05 Mon> | |
| 184 | 199 | <2026-10-05 Mon> |
| 200 | ** TODO Under archived | |
| 201 | DEADLINE: <2026-10-05 Mon> | |
| 185 | 202 | * COMMENT Commented |
| 203 | DEADLINE: <2026-10-05 Mon> | |
| 186 | 204 | <2026-10-05 Mon> |
| 187 | 205 | * Blocks |
| 188 | 206 | #+begin_src sh |
| @@ -214,6 +232,67 @@ struct AgendaTests { | ||
| 214 | 232 | <2026-10-05 Mon 13:00> and <2026-10-06 Tue> |
| 215 | 233 | """ |
| 216 | 234 | |
| 235 | static let third = """ | |
| 236 | #+FILETAGS: :proj: | |
| 237 | * TODO Alpha :work:urgent: | |
| 238 | :PROPERTIES: | |
| 239 | :EFFORT: 2:00 | |
| 240 | :SIZE: 3 | |
| 241 | :OWNER: alice | |
| 242 | :DUE: <2026-10-10 Sat> | |
| 243 | :END: | |
| 244 | ** NEXT Beta :boss: | |
| 245 | :PROPERTIES: | |
| 246 | :SIZE: 10 | |
| 247 | :END: | |
| 248 | *** Gamma :work: | |
| 249 | * DONE Delta :home: | |
| 250 | CLOSED: [2026-10-01 Thu 10:00] SCHEDULED: <2026-09-30 Wed> | |
| 251 | * WAIT [#A] Epsilon 10:30 | |
| 252 | :PROPERTIES: | |
| 253 | :OWNER: Bob | |
| 254 | :END: | |
| 255 | <2026-10-03 Sat> [2026-10-02 Fri] | |
| 256 | * HOLD Zeta :x@y: | |
| 257 | * Eta with category | |
| 258 | :PROPERTIES: | |
| 259 | :CATEGORY: cat | |
| 260 | :END: | |
| 261 | ** KILL Theta | |
| 262 | * IDEA [#C] Iota | |
| 263 | * Archived :ARCHIVE:work: | |
| 264 | ** TODO Kappa :work: | |
| 265 | """ | |
| 266 | ||
| 267 | static let matches = [ | |
| 268 | "work", "+work-urgent", "work|home", "proj", "{^w}", "-work", "LEVEL>1", "LEVEL=2+boss", "SIZE>5", "SIZE<5", | |
| 269 | "OWNER=\"alice\"", "OWNER={^b}", "OWNER<>\"alice\"", "OWNER<\"b\"", "TODO=\"WAIT\"", "PRIORITY=\"A\"", | |
| 270 | "CATEGORY=\"cat\"", "DUE<\"<2026-10-15>\"", "DUE>\"<today>\"", "SCHEDULED<\"<today>\"", "TIMESTAMP=\"<2026-10-03 Sat>\"", | |
| 271 | "ITEM={lph}", "work/TODO", "/!", "+work/!TODO|WAIT", "EFFORT>\"1:00\"", "ALLTAGS={proj}", "TAGS=\":work:urgent:\"", | |
| 272 | "x@y", "/-TODO", "/!{^[HW]}", "work&urgent", "SIZE>=3+SIZE<=3", "LEVEL<>1", | |
| 273 | ] | |
| 274 | ||
| 275 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 276 | func listsMatchEmacs() throws { | |
| 277 | let files = [("a.org", Self.synthetic), ("b.org", Self.second), ("c.org", Self.third)] | |
| 278 | let today = Days.absolute(year: 2026, month: 10, day: 5) | |
| 279 | var utc = Calendar(identifier: .gregorian) | |
| 280 | utc.timeZone = TimeZone(identifier: "UTC")! | |
| 281 | let now = Date(timeIntervalSince1970: Double((today - 719163) * 86400 + 9 * 3600 + 30 * 60)) | |
| 282 | func lisp(_ s: String) -> String { "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" } | |
| 283 | try AgendaOracle.compareList(files: files, form: "(org-todo-list nil)", today: today) { Agenda.todoList($0) } | |
| 284 | for keywords in ["WAIT|HOLD", "DONE", "KILL|TODO"] { | |
| 285 | try AgendaOracle.compareList(files: files, form: "(org-todo-list \(lisp(keywords)))", today: today) { Agenda.todoList($0, keywords: keywords) } | |
| 286 | } | |
| 287 | for match in Self.matches { | |
| 288 | for todoOnly in [false, true] { | |
| 289 | try AgendaOracle.compareList(files: files, form: "(org-tags-view \(todoOnly ? "t" : "nil") \(lisp(match)))", today: today) { | |
| 290 | Agenda.tagsMatch($0, match: match, todoOnly: todoOnly, now: now, calendar: utc) | |
| 291 | } | |
| 292 | } | |
| 293 | } | |
| 294 | } | |
| 295 | ||
| 217 | 296 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 218 | 297 | func matchesEmacs() throws { |
| 219 | 298 | let files = [("a.org", Self.synthetic), ("b.org", Self.second)] |
| @@ -237,5 +316,11 @@ struct AgendaTests { | ||
| 237 | 316 | for today in [Days.absolute(Date(), calendar: .current), Days.absolute(year: 2025, month: 3, day: 14)] { |
| 238 | 317 | try AgendaOracle.compare(files: files, start: today - 3, span: 10, today: today) |
| 239 | 318 | } |
| 319 | let today = Days.absolute(Date(), calendar: .current) | |
| 320 | try AgendaOracle.compareList(files: files, form: "(org-todo-list nil)", today: today) { Agenda.todoList($0) } | |
| 321 | for match in ["finance|home", "LEVEL=2", "/!", "PRIORITY=\"A\""] { | |
| 322 | let lisp = "\"" + match.replacingOccurrences(of: "\"", with: "\\\"") + "\"" | |
| 323 | try AgendaOracle.compareList(files: files, form: "(org-tags-view nil \(lisp))", today: today) { Agenda.tagsMatch($0, match: match) } | |
| 324 | } | |
| 240 | 325 | } |
| 241 | 326 | } |
docs/plans/2026-10-05-agenda-search.md added +41
| @@ -0,0 +1,41 @@ | ||
| 1 | # Agenda Search and Actions Implementation Plan | |
| 2 | ||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | ||
| 5 | **Goal:** The TODO list (`org-todo-list`), tag/property/TODO match views (`org-tags-view`, `m` and `M`), saved views, and agenda commands that change entries (TODO state, priority, schedule, deadline, tags, date shift) from the agenda window. | |
| 6 | ||
| 7 | **Architecture:** `OrgCore/Agenda/AgendaSearch.swift` ports `org-agenda-get-todos`, `org-make-tags-matcher` and `org-scan-tags` over `AgendaSource`, which now carries levels, own properties, planning and first timestamps for the special properties. Emacs regexps in `{...}` terms are translated to ICU. Saved views are read from `views.toml` beside `keymap.toml`. Agenda commands run the existing editor commands at the entry: through the open buffer when the file is open, otherwise on a `DocumentState` read from disk and written back through `Saver`, so a file changed on disk in between is merged or refused. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle. | |
| 10 | ||
| 11 | **Spec:** `docs/design.md`, "Phase 3". | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - Oracle: `org-todo-list` and `org-tags-view` (with and without TODO-only) over the agenda test files and the corpora, compared line by line as for the agenda. | |
| 16 | - `/{regexp}` against a heading without a keyword is an error in Emacs; it is no match here. | |
| 17 | - String comparisons in matches use code point order (`string-collate-lessp` in the C locale). | |
| 18 | ||
| 19 | ## Defaults chosen (user may change) | |
| 20 | ||
| 21 | - Views: Agenda (`a`), TODO list (`t`), Match (`m`) and TODO match (`M`) are built in; `views.toml` adds named views. | |
| 22 | - Agenda keys follow Emacs: `t` TODO cycle, `+`/`-` priority, `,` priority prompt, `:` tags, `C-c C-s`/`C-c C-d` (⌘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". | |