Commit 042e2f8aa2
Verified · cmc
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 @@ | |||
| 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 | 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 | ||
| 133 | struct AgendaTests { | 147 | struct 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". | ||