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 | 45 | public let line: String |
| 46 | 46 | /// What `org-agenda-prefix-format` puts before the heading text. |
| 47 | 47 | public var prefix = "" |
| 48 | /// The entry's `Effort` property. | |
| 49 | public var effort: String? | |
| 48 | 50 | } |
| 49 | 51 | |
| 50 | 52 | public struct AgendaDay: Sendable, Equatable { |
| @@ -342,7 +344,7 @@ public enum Agenda { | ||
| 342 | 344 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), |
| 343 | 345 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), |
| 344 | 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 | 6 | /// The agenda window: the day/week agenda, the TODO list, tag matches and saved views. |
| 7 | 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 | 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 | 12 | struct AgendaView: View { |
| 11 | 13 | let workspace: WorkspaceModel |
| @@ -23,6 +25,10 @@ struct AgendaView: View { | ||
| 23 | 25 | @State private var promptText = "" |
| 24 | 26 | @State private var prefix = false |
| 25 | 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 | 32 | @AppStorage("agendaSpan") private var span = 10 |
| 27 | 33 | @AppStorage("agendaStartOffset") private var startOffset = -3 |
| 28 | 34 | @AppStorage("agendaSubfolders") private var includeSubfolders = false |
| @@ -96,7 +102,7 @@ struct AgendaView: View { | ||
| 96 | 102 | if isAgenda { |
| 97 | 103 | ForEach(agenda.days, id: \.day) { day in |
| 98 | 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 | 106 | } header: { |
| 101 | 107 | Text(day.header) |
| 102 | 108 | .font(.headline) |
| @@ -105,7 +111,7 @@ struct AgendaView: View { | ||
| 105 | 111 | } |
| 106 | 112 | } else { |
| 107 | 113 | Section { |
| 108 | rows(agenda.list, prefix: "list") | |
| 114 | rows(agenda.list.filter(filter.keeps), prefix: "list") | |
| 109 | 115 | } header: { |
| 110 | 116 | Text(listTitle).font(.headline) |
| 111 | 117 | } |
| @@ -118,7 +124,7 @@ struct AgendaView: View { | ||
| 118 | 124 | } |
| 119 | 125 | .onKeyPress(phases: .down) { press in handle(press) } |
| 120 | 126 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 121 | if let message = message ?? reminderStatus { | |
| 127 | if let message = message ?? (filter.isEmpty ? reminderStatus : "Filter: " + filter.description) { | |
| 122 | 128 | Text(message) |
| 123 | 129 | .font(.callout) |
| 124 | 130 | .frame(maxWidth: .infinity, alignment: .leading) |
| @@ -182,6 +188,15 @@ struct AgendaView: View { | ||
| 182 | 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 | 200 | .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) |
| 186 | 201 | .onChange(of: viewName) { applyView() } |
| 187 | 202 | .task(id: refreshKey) { |
| @@ -254,10 +269,65 @@ struct AgendaView: View { | ||
| 254 | 269 | private func item(_ id: String) -> AgendaItem? { |
| 255 | 270 | let parts = id.split(separator: ":") |
| 256 | 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 | 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 | 331 | private func jump(_ id: String) { |
| 262 | 332 | guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return } |
| 263 | 333 | commands.jump(path: path, offset: offset) |
| @@ -297,6 +367,11 @@ struct AgendaView: View { | ||
| 297 | 367 | case "f" where isAgenda: agenda.shift(forward: true) |
| 298 | 368 | case "b" where isAgenda: agenda.shift(forward: false) |
| 299 | 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 | 375 | case "l" where isAgenda: |
| 301 | 376 | logMode.toggle() |
| 302 | 377 | message = logMode ? "Log mode on" : "Log mode off" |
Tests/OrgCoreTests/AgendaTests.swift +64
| @@ -45,6 +45,7 @@ enum AgendaOracle { | ||
| 45 | 45 | ((get-text-property p 'org-agenda-date-header) |
| 46 | 46 | (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) |
| 47 | 47 | (setq header line items nil)) |
| 48 | ((get-text-property p 'org-filter-type)) | |
| 48 | 49 | ((or header hd) |
| 49 | 50 | (push `((line . ,line) |
| 50 | 51 | (type . ,(get-text-property p 'type)) |
| @@ -473,3 +474,66 @@ struct AgendaLogModeTests { | ||
| 473 | 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 | } | |