Agenda filters !74
4 files changed, +270 −6
Layout: unified · split
Sources/OrgCore/Agenda/Agenda.swift +3 −1
| @@ -45,6 +45,8 @@ public struct AgendaItem: Sendable, Equatable { | |||
| 45 | public let line: String | 45 | public let line: String |
| 46 | /// What `org-agenda-prefix-format` puts before the heading text. | 46 | /// What `org-agenda-prefix-format` puts before the heading text. |
| 47 | public var prefix = "" | 47 | public var prefix = "" |
| 48 | /// The entry's `Effort` property. | ||
| 49 | public var effort: String? | ||
| 48 | } | 50 | } |
| 49 | 51 | ||
| 50 | public struct AgendaDay: Sendable, Equatable { | 52 | public struct AgendaDay: Sendable, Equatable { |
| @@ -342,7 +344,7 @@ public enum Agenda { | |||
| 342 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), | 344 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), |
| 343 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), | 345 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), |
| 344 | warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, habit: habit, | 346 | warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, habit: habit, |
| 345 | line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString), prefix: prefix | 347 | line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString), prefix: prefix, effort: heading.properties["EFFORT"] |
| 346 | ) | 348 | ) |
| 347 | } | 349 | } |
| 348 | 350 | ||
Sources/OrgCore/Agenda/AgendaFilter.swift added +123
| @@ -0,0 +1,123 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Agenda filters (`org-agenda-filter`, Org 9.8.7): categories, tags, effort and regexps, | ||
| 4 | /// each a list of `+x` (keep) and `-x` (drop) terms. Lines without an entry (the time grid) | ||
| 5 | /// always show. | ||
| 6 | public struct AgendaFilter: Sendable, Equatable { | ||
| 7 | public var categories: [String] = [] | ||
| 8 | public var tags: [String] = [] | ||
| 9 | public var efforts: [String] = [] | ||
| 10 | public var regexps: [String] = [] | ||
| 11 | |||
| 12 | public init() {} | ||
| 13 | |||
| 14 | public var isEmpty: Bool { categories.isEmpty && tags.isEmpty && efforts.isEmpty && regexps.isEmpty } | ||
| 15 | |||
| 16 | /// The filter as `/` shows it for editing. | ||
| 17 | public var description: String { | ||
| 18 | let regexp = regexps.first.map { "/" + $0.dropFirst().replacingOccurrences(of: "^\\+", with: "", options: .regularExpression) + "/" } ?? "" | ||
| 19 | return categories.joined() + tags.joined() + (efforts.first.map { String($0.dropFirst()) } ?? "") + regexp | ||
| 20 | } | ||
| 21 | |||
| 22 | /// Reads `+cat-tag<0:10-/regexp/`: a word is a tag if a shown line has it, else a category | ||
| 23 | /// if a shown line has it, else ignored. A leading `+` before `+` or `-` keeps the current | ||
| 24 | /// filter and adds to it. Returns the filter and the words it ignored. | ||
| 25 | public func parse(_ input: String, tags shownTags: Set<String>, categories shownCategories: Set<String>) -> (filter: AgendaFilter, ignored: [String]) { | ||
| 26 | var s = input | ||
| 27 | var keep = false | ||
| 28 | if s.range(of: "^\\+[+-]", options: .regularExpression) != nil { | ||
| 29 | s.removeFirst() | ||
| 30 | keep = true | ||
| 31 | } | ||
| 32 | // Quoted categories may hold `-`. | ||
| 33 | s = s.replacingOccurrences(of: "\"([^\"]*)-([^\"]*)\"", with: "\"$1~~~$2\"", options: .regularExpression) | ||
| 34 | var result = keep ? self : AgendaFilter() | ||
| 35 | var ignored: [String] = [] | ||
| 36 | let regex = try! NSRegularExpression(pattern: "^[ \\t]*([-+])?(([^-+<>=/ \\t]+)|([<>=][0-9:]+)|(/([^/]+)/?))") | ||
| 37 | func append(_ term: String, to list: inout [String]) { if !list.contains(term) { list.append(term) } } | ||
| 38 | while true { | ||
| 39 | let ns = s as NSString | ||
| 40 | guard let m = regex.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { break } | ||
| 41 | let sign = m.range(at: 1).location == NSNotFound ? "+" : ns.substring(with: m.range(at: 1)) | ||
| 42 | if m.range(at: 3).location != NSNotFound { | ||
| 43 | let word = ns.substring(with: m.range(at: 3)).replacingOccurrences(of: "~~~", with: "-") | ||
| 44 | if shownTags.contains(word) { | ||
| 45 | append(sign + word, to: &result.tags) | ||
| 46 | } else if shownCategories.contains(word) { | ||
| 47 | append(sign + word.replacingOccurrences(of: "^\"(.*)\"$", with: "$1", options: .regularExpression), to: &result.categories) | ||
| 48 | } else { | ||
| 49 | ignored.append(sign + word) | ||
| 50 | } | ||
| 51 | } else if m.range(at: 4).location != NSNotFound { | ||
| 52 | append(sign + ns.substring(with: m.range(at: 4)), to: &result.efforts) | ||
| 53 | } else { | ||
| 54 | append(sign + ns.substring(with: m.range(at: 6)), to: &result.regexps) | ||
| 55 | } | ||
| 56 | s = ns.substring(from: NSMaxRange(m.range)) | ||
| 57 | } | ||
| 58 | return (result, ignored) | ||
| 59 | } | ||
| 60 | |||
| 61 | /// Whether `item` stays visible. | ||
| 62 | public func keeps(_ item: AgendaItem) -> Bool { | ||
| 63 | guard item.path != nil else { return true } | ||
| 64 | // Two or more `+` categories: any of them. | ||
| 65 | let positive = categories.filter { $0.hasPrefix("+") } | ||
| 66 | let categoryOK: Bool | ||
| 67 | if positive.count > 1 { | ||
| 68 | categoryOK = categories.contains { term($0, item.category == String($0.dropFirst())) } | ||
| 69 | } else { | ||
| 70 | categoryOK = categories.allSatisfy { term($0, item.category == String($0.dropFirst())) } | ||
| 71 | } | ||
| 72 | let tagsOK = tags.allSatisfy { term($0, Self.hasTag(String($0.dropFirst()), item.tags)) } | ||
| 73 | let effortOK = efforts.allSatisfy { Self.compareEffort($0, item.effort) } | ||
| 74 | // `case-fold-search` is on in the agenda. | ||
| 75 | let regexpOK = regexps.allSatisfy { term($0, item.text.range(of: String($0.dropFirst()), options: [.regularExpression, .caseInsensitive]) != nil) } | ||
| 76 | return categoryOK && tagsOK && effortOK && regexpOK | ||
| 77 | } | ||
| 78 | |||
| 79 | private func term(_ term: String, _ holds: Bool) -> Bool { term.hasPrefix("-") ? !holds : holds } | ||
| 80 | |||
| 81 | /// `{regexp}` matches any tag; an empty tag matches lines with tags. | ||
| 82 | static func hasTag(_ tag: String, _ tags: [String]) -> Bool { | ||
| 83 | if tag.isEmpty { return !tags.isEmpty } | ||
| 84 | if tag.hasPrefix("{"), tag.hasSuffix("}"), tag.count >= 2 { | ||
| 85 | let pattern = String(tag.dropFirst().dropLast()) | ||
| 86 | return tags.contains { $0.range(of: pattern, options: .regularExpression) != nil } | ||
| 87 | } | ||
| 88 | return tags.contains(tag) | ||
| 89 | } | ||
| 90 | |||
| 91 | /// `org-agenda-filter-effort-form` and `org-agenda-compare-effort`, with | ||
| 92 | /// `org-agenda-sort-noeffort-is-high` t. `-` before the comparison negates nothing, as in org. | ||
| 93 | static func compareEffort(_ term: String, _ effort: String?) -> Bool { | ||
| 94 | var e = term.dropFirst() | ||
| 95 | let op = e.first | ||
| 96 | e = e.dropFirst() | ||
| 97 | let value = durationMinutes(String(e)) ?? 0 | ||
| 98 | let minutes = effort.flatMap(durationMinutes) ?? Double(Int.max) | ||
| 99 | switch op { | ||
| 100 | case "<": return minutes <= value | ||
| 101 | case ">": return minutes >= value | ||
| 102 | default: return minutes == value | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | /// `org-duration-to-minutes` for `H:MM`, `H:MM:SS`, plain minutes and unit durations | ||
| 107 | /// (`1h 30min`, `2d`). | ||
| 108 | public static func durationMinutes(_ s: String) -> Double? { | ||
| 109 | let t = s.trimmingCharacters(in: .whitespaces) | ||
| 110 | if let m = t.wholeMatch(of: /([0-9]+):([0-9]{2})(?::([0-9]{2}))?/) { | ||
| 111 | return Double(m.1)! * 60 + Double(m.2)! + (m.3.map { Double($0)! / 60 } ?? 0) | ||
| 112 | } | ||
| 113 | if let n = Double(t) { return n } | ||
| 114 | let units: [String: Double] = ["min": 1, "h": 60, "d": 1440, "w": 10080, "m": 43200, "y": 525960] | ||
| 115 | var total = 0.0 | ||
| 116 | var matched = false | ||
| 117 | for m in t.matches(of: /([0-9]+(?:\.[0-9]*)?)[ \t]*(min|h|d|w|m|y)\b/) { | ||
| 118 | total += Double(m.1)! * units[String(m.2)]! | ||
| 119 | matched = true | ||
| 120 | } | ||
| 121 | return matched ? total : nil | ||
| 122 | } | ||
| 123 | } | ||
Sources/Orgstar/AgendaView.swift +80 −5
| @@ -5,7 +5,9 @@ import SwiftUI | |||
| 5 | 5 | ||
| 6 | /// The agenda window: the day/week agenda, the TODO list, tag matches and saved views. | 6 | /// The agenda window: the day/week agenda, the TODO list, tag matches and saved views. |
| 7 | /// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: | 7 | /// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: |
| 8 | /// f/b/. move the span, g refreshes, l shows the log (closed and clocked), t cycles the TODO state, +/- change the priority, : or | 8 | /// f/b/. move the span, g refreshes, l shows the log (closed and clocked), / filters |
| 9 | /// (`+cat-tag<0:10-/regexp/`), \ by tag, < by the line's category, = by regexp, _ by effort, | ||
| 10 | /// | clears filters, t cycles the TODO state, +/- change the priority, : or | ||
| 9 | /// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date. | 11 | /// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date. |
| 10 | struct AgendaView: View { | 12 | struct AgendaView: View { |
| 11 | let workspace: WorkspaceModel | 13 | let workspace: WorkspaceModel |
| @@ -23,6 +25,10 @@ struct AgendaView: View { | |||
| 23 | @State private var promptText = "" | 25 | @State private var promptText = "" |
| 24 | @State private var prefix = false | 26 | @State private var prefix = false |
| 25 | @State private var logMode = false | 27 | @State private var logMode = false |
| 28 | @State private var filter = AgendaFilter() | ||
| 29 | /// A filter question: its title, the text it starts with, and how the answer applies. | ||
| 30 | @State private var filterPrompt: (title: String, kind: Character)? | ||
| 31 | @State private var filterText = "" | ||
| 26 | @AppStorage("agendaSpan") private var span = 10 | 32 | @AppStorage("agendaSpan") private var span = 10 |
| 27 | @AppStorage("agendaStartOffset") private var startOffset = -3 | 33 | @AppStorage("agendaStartOffset") private var startOffset = -3 |
| 28 | @AppStorage("agendaSubfolders") private var includeSubfolders = false | 34 | @AppStorage("agendaSubfolders") private var includeSubfolders = false |
| @@ -96,7 +102,7 @@ struct AgendaView: View { | |||
| 96 | if isAgenda { | 102 | if isAgenda { |
| 97 | ForEach(agenda.days, id: \.day) { day in | 103 | ForEach(agenda.days, id: \.day) { day in |
| 98 | Section { | 104 | Section { |
| 99 | rows(day.items, prefix: "\(day.day)", day: day.day) | 105 | rows(day.items.filter(filter.keeps), prefix: "\(day.day)", day: day.day) |
| 100 | } header: { | 106 | } header: { |
| 101 | Text(day.header) | 107 | Text(day.header) |
| 102 | .font(.headline) | 108 | .font(.headline) |
| @@ -105,7 +111,7 @@ struct AgendaView: View { | |||
| 105 | } | 111 | } |
| 106 | } else { | 112 | } else { |
| 107 | Section { | 113 | Section { |
| 108 | rows(agenda.list, prefix: "list") | 114 | rows(agenda.list.filter(filter.keeps), prefix: "list") |
| 109 | } header: { | 115 | } header: { |
| 110 | Text(listTitle).font(.headline) | 116 | Text(listTitle).font(.headline) |
| 111 | } | 117 | } |
| @@ -118,7 +124,7 @@ struct AgendaView: View { | |||
| 118 | } | 124 | } |
| 119 | .onKeyPress(phases: .down) { press in handle(press) } | 125 | .onKeyPress(phases: .down) { press in handle(press) } |
| 120 | .safeAreaInset(edge: .bottom, spacing: 0) { | 126 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 121 | if let message = message ?? reminderStatus { | 127 | if let message = message ?? (filter.isEmpty ? reminderStatus : "Filter: " + filter.description) { |
| 122 | Text(message) | 128 | Text(message) |
| 123 | .font(.callout) | 129 | .font(.callout) |
| 124 | .frame(maxWidth: .infinity, alignment: .leading) | 130 | .frame(maxWidth: .infinity, alignment: .leading) |
| @@ -182,6 +188,15 @@ struct AgendaView: View { | |||
| 182 | Text(choices.prefix(12).joined(separator: " ")) | 188 | Text(choices.prefix(12).joined(separator: " ")) |
| 183 | } | 189 | } |
| 184 | } | 190 | } |
| 191 | .alert(filterPrompt?.title ?? "", isPresented: Binding(get: { filterPrompt != nil }, set: { if !$0 { filterPrompt = nil } })) { | ||
| 192 | TextField("", text: $filterText) | ||
| 193 | Button("Filter") { applyFilter() } | ||
| 194 | Button("Cancel", role: .cancel) { filterPrompt = nil } | ||
| 195 | } message: { | ||
| 196 | if filterPrompt?.kind == "/" || filterPrompt?.kind == "\\" { | ||
| 197 | Text(shown.tags.sorted().prefix(20).joined(separator: " ")) | ||
| 198 | } | ||
| 199 | } | ||
| 185 | .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) | 200 | .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) |
| 186 | .onChange(of: viewName) { applyView() } | 201 | .onChange(of: viewName) { applyView() } |
| 187 | .task(id: refreshKey) { | 202 | .task(id: refreshKey) { |
| @@ -254,10 +269,65 @@ struct AgendaView: View { | |||
| 254 | private func item(_ id: String) -> AgendaItem? { | 269 | private func item(_ id: String) -> AgendaItem? { |
| 255 | let parts = id.split(separator: ":") | 270 | let parts = id.split(separator: ":") |
| 256 | guard parts.count == 2, let index = Int(parts[1]) else { return nil } | 271 | guard parts.count == 2, let index = Int(parts[1]) else { return nil } |
| 257 | let items = parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? [] | 272 | let items = (parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? []).filter(filter.keeps) |
| 258 | return index < items.count ? items[index] : nil | 273 | return index < items.count ? items[index] : nil |
| 259 | } | 274 | } |
| 260 | 275 | ||
| 276 | // MARK: - Filters | ||
| 277 | |||
| 278 | /// The tags and categories on the view's lines, which filter words are read against. | ||
| 279 | private var shown: (tags: Set<String>, categories: Set<String>) { | ||
| 280 | let items = (isAgenda ? agenda.days.flatMap(\.items) : agenda.list).filter { $0.path != nil } | ||
| 281 | return (Set(items.flatMap(\.tags)), Set(items.map(\.category))) | ||
| 282 | } | ||
| 283 | |||
| 284 | private func askFilter(_ kind: Character) { | ||
| 285 | switch kind { | ||
| 286 | case "/": filterText = filter.description | ||
| 287 | default: filterText = "" | ||
| 288 | } | ||
| 289 | let title = switch kind { | ||
| 290 | case "/": "Filter [+cat-tag<0:10-/regexp/]:" | ||
| 291 | case "\\": "Tag filter (+tag-tag):" | ||
| 292 | case "=": "Regexp filter (-re to drop):" | ||
| 293 | default: "Effort filter (<0:30, >1:00, =1:00):" | ||
| 294 | } | ||
| 295 | filterPrompt = (title, kind) | ||
| 296 | } | ||
| 297 | |||
| 298 | private func applyFilter() { | ||
| 299 | guard let kind = filterPrompt?.kind else { return } | ||
| 300 | filterPrompt = nil | ||
| 301 | let text = filterText.trimmingCharacters(in: .whitespaces) | ||
| 302 | var current = filter | ||
| 303 | switch kind { | ||
| 304 | case "/": | ||
| 305 | let (parsed, ignored) = AgendaFilter().parse(text, tags: shown.tags, categories: shown.categories) | ||
| 306 | current = parsed | ||
| 307 | if !ignored.isEmpty { message = "\(ignored.joined(separator: " ")) ignored: no line has that tag or category" } | ||
| 308 | case "\\": | ||
| 309 | // Every word as a tag. | ||
| 310 | current.tags = AgendaFilter().parse(text, tags: Set(text.split(whereSeparator: { "+- ".contains($0) }).map(String.init)), categories: []).filter.tags | ||
| 311 | case "=": | ||
| 312 | current.regexps = text.isEmpty ? [] : [text.hasPrefix("-") || text.hasPrefix("+") ? text : "+" + text] | ||
| 313 | default: | ||
| 314 | current.efforts = text.isEmpty ? [] : ["+" + text] | ||
| 315 | } | ||
| 316 | filter = current | ||
| 317 | selection = nil | ||
| 318 | } | ||
| 319 | |||
| 320 | /// `<`: keep only the selected line's category, or drop that filter. | ||
| 321 | private func toggleCategoryFilter() { | ||
| 322 | if !filter.categories.isEmpty { | ||
| 323 | filter.categories = [] | ||
| 324 | } else if let selection, let item = item(selection) { | ||
| 325 | filter.categories = ["+" + item.category] | ||
| 326 | } else { | ||
| 327 | message = "No entry selected" | ||
| 328 | } | ||
| 329 | } | ||
| 330 | |||
| 261 | private func jump(_ id: String) { | 331 | private func jump(_ id: String) { |
| 262 | guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return } | 332 | guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return } |
| 263 | commands.jump(path: path, offset: offset) | 333 | commands.jump(path: path, offset: offset) |
| @@ -297,6 +367,11 @@ struct AgendaView: View { | |||
| 297 | case "f" where isAgenda: agenda.shift(forward: true) | 367 | case "f" where isAgenda: agenda.shift(forward: true) |
| 298 | case "b" where isAgenda: agenda.shift(forward: false) | 368 | case "b" where isAgenda: agenda.shift(forward: false) |
| 299 | case "." where isAgenda: agenda.showToday() | 369 | case "." where isAgenda: agenda.showToday() |
| 370 | case "/", "\\", "=", "_": askFilter(press.characters.first!) | ||
| 371 | case "<": toggleCategoryFilter() | ||
| 372 | case "|": | ||
| 373 | filter = AgendaFilter() | ||
| 374 | message = "Filters removed" | ||
| 300 | case "l" where isAgenda: | 375 | case "l" where isAgenda: |
| 301 | logMode.toggle() | 376 | logMode.toggle() |
| 302 | message = logMode ? "Log mode on" : "Log mode off" | 377 | message = logMode ? "Log mode on" : "Log mode off" |
Tests/OrgCoreTests/AgendaTests.swift +64
| @@ -45,6 +45,7 @@ enum AgendaOracle { | |||
| 45 | ((get-text-property p 'org-agenda-date-header) | 45 | ((get-text-property p 'org-agenda-date-header) |
| 46 | (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) | 46 | (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) |
| 47 | (setq header line items nil)) | 47 | (setq header line items nil)) |
| 48 | ((get-text-property p 'org-filter-type)) | ||
| 48 | ((or header hd) | 49 | ((or header hd) |
| 49 | (push `((line . ,line) | 50 | (push `((line . ,line) |
| 50 | (type . ,(get-text-property p 'type)) | 51 | (type . ,(get-text-property p 'type)) |
| @@ -473,3 +474,66 @@ struct AgendaLogModeTests { | |||
| 473 | try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, prefix: ["agenda": " %-8:c%?-12t%s %l"], logMode: true) | 474 | try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, prefix: ["agenda": " %-8:c%?-12t%s %l"], logMode: true) |
| 474 | } | 475 | } |
| 475 | } | 476 | } |
| 477 | |||
| 478 | struct AgendaFilterTests { | ||
| 479 | static let file = """ | ||
| 480 | #+TODO: TODO NEXT | DONE | ||
| 481 | #+CATEGORY: home | ||
| 482 | * TODO Groceries :errand: | ||
| 483 | :PROPERTIES: | ||
| 484 | :Effort: 0:20 | ||
| 485 | :END: | ||
| 486 | SCHEDULED: <2026-10-05 Mon> | ||
| 487 | * Work stuff | ||
| 488 | :PROPERTIES: | ||
| 489 | :CATEGORY: work | ||
| 490 | :END: | ||
| 491 | ** TODO Write Report :writing:urgent: | ||
| 492 | :PROPERTIES: | ||
| 493 | :Effort: 2:00 | ||
| 494 | :END: | ||
| 495 | SCHEDULED: <2026-10-05 Mon 10:00> | ||
| 496 | ** NEXT Call Bob :phone: | ||
| 497 | DEADLINE: <2026-10-06 Tue> | ||
| 498 | ** TODO Review PR :urgent: | ||
| 499 | :PROPERTIES: | ||
| 500 | :Effort: 1h | ||
| 501 | :END: | ||
| 502 | SCHEDULED: <2026-10-05 Mon> | ||
| 503 | """ | ||
| 504 | |||
| 505 | static let filters = ["+urgent", "-urgent", "work", "-work", "+home+work", "<0:30", ">1:00", "=1:00", "/report/", "-/call/", "+work-phone<2:00", "nonexistent", "urgent-writing/review/"] | ||
| 506 | |||
| 507 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 508 | func filtersLikeOrgAgendaFilter() throws { | ||
| 509 | let today = Days.absolute(year: 2026, month: 10, day: 5) | ||
| 510 | let defaults = OrgSettings.default.withTodoKeywords(AgendaOracle.keywords)! | ||
| 511 | let sources = [AgendaSource(path: "f.org", text: Self.file, defaults: defaults)] | ||
| 512 | let days = Agenda.list(sources, start: today, days: 2, today: today, now: (9, 30), options: .terminal) | ||
| 513 | let all = days.flatMap(\.items) | ||
| 514 | let tags = Set(all.flatMap(\.tags)) | ||
| 515 | let categories = Set(all.filter { $0.path != nil }.map(\.category)) | ||
| 516 | for filterText in Self.filters { | ||
| 517 | let emacs = try AgendaOracle.run(files: [("f.org", Self.file)], form: """ | ||
| 518 | (progn | ||
| 519 | (org-agenda-list nil "2026-10-05" 2) | ||
| 520 | (with-current-buffer org-agenda-buffer-name | ||
| 521 | (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) \(lispString(filterText))))) | ||
| 522 | (org-agenda-filter)))) | ||
| 523 | """, today: today, hour: 9, minute: 30) | ||
| 524 | let filter = AgendaFilter().parse(filterText, tags: tags, categories: categories).filter | ||
| 525 | let ours = days.map { day in day.items.filter(filter.keeps) } | ||
| 526 | #expect(ours.map { AgendaOracle.describe($0, today: today) } == emacs.map { AgendaOracle.describe($0.items) }, "\(filterText)") | ||
| 527 | } | ||
| 528 | } | ||
| 529 | |||
| 530 | @Test func parsesAndDescribes() { | ||
| 531 | let (filter, ignored) = AgendaFilter().parse("+work-phone<2:00/rep/ zzz", tags: ["phone"], categories: ["work"]) | ||
| 532 | #expect(filter.categories == ["+work"] && filter.tags == ["-phone"] && filter.efforts == ["+<2:00"] && filter.regexps == ["+rep"]) | ||
| 533 | #expect(ignored == ["+zzz"]) | ||
| 534 | #expect(filter.description == "+work-phone<2:00/rep/") | ||
| 535 | #expect(AgendaFilter.durationMinutes("1h 30min") == 90 && AgendaFilter.durationMinutes("0:45") == 45) | ||
| 536 | let kept = filter.parse("++urgent", tags: ["urgent"], categories: []).filter | ||
| 537 | #expect(kept.tags == ["-phone", "+urgent"]) | ||
| 538 | } | ||
| 539 | } | ||