krz/orgstar

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

Commit 0a17595940

0a175959407bce5452ecfa31951f0dcdc41df0d5

parent: 2415257464

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 02:53 UTC

Agenda filters

- / takes org-agenda-filter's string: +cat-tag<0:10-/regexp/, words read
  as tags or categories by what the view shows, ++ to add to the
  current filter. \ filters by tags, < by the selected line's
  category, = by regexp, _ by effort, | clears.
- Matching as org-agenda-filter-make-matcher: tag terms all hold,
  several + categories mean any of them, {regexp} tags, case-folded
  regexps on the heading text, entries without effort count as high.

Oracle tests against Org 9.8.7.

Layout: unified · split

Sources/OrgCore/Agenda/Agenda.swift +3 −1
@@ -45,6 +45,8 @@ public struct AgendaItem: Sendable, Equatable {
4545 public let line: String
4646 /// What `org-agenda-prefix-format` puts before the heading text.
4747 public var prefix = ""
48 /// The entry's `Effort` property.
49 public var effort: String?
4850}
4951
5052public struct AgendaDay: Sendable, Equatable {
@@ -342,7 +344,7 @@ public enum Agenda {
342344 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
343345 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority),
344346 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"]
346348 )
347349 }
348350
Sources/OrgCore/Agenda/AgendaFilter.swift added +123
@@ -0,0 +1,123 @@
1import 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.
6public 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
55
66/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
77/// 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
911/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date.
1012struct AgendaView: View {
1113 let workspace: WorkspaceModel
@@ -23,6 +25,10 @@ struct AgendaView: View {
2325 @State private var promptText = ""
2426 @State private var prefix = false
2527 @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 = ""
2632 @AppStorage("agendaSpan") private var span = 10
2733 @AppStorage("agendaStartOffset") private var startOffset = -3
2834 @AppStorage("agendaSubfolders") private var includeSubfolders = false
@@ -96,7 +102,7 @@ struct AgendaView: View {
96102 if isAgenda {
97103 ForEach(agenda.days, id: \.day) { day in
98104 Section {
99 rows(day.items, prefix: "\(day.day)", day: day.day)
105 rows(day.items.filter(filter.keeps), prefix: "\(day.day)", day: day.day)
100106 } header: {
101107 Text(day.header)
102108 .font(.headline)
@@ -105,7 +111,7 @@ struct AgendaView: View {
105111 }
106112 } else {
107113 Section {
108 rows(agenda.list, prefix: "list")
114 rows(agenda.list.filter(filter.keeps), prefix: "list")
109115 } header: {
110116 Text(listTitle).font(.headline)
111117 }
@@ -118,7 +124,7 @@ struct AgendaView: View {
118124 }
119125 .onKeyPress(phases: .down) { press in handle(press) }
120126 .safeAreaInset(edge: .bottom, spacing: 0) {
121 if let message = message ?? reminderStatus {
127 if let message = message ?? (filter.isEmpty ? reminderStatus : "Filter: " + filter.description) {
122128 Text(message)
123129 .font(.callout)
124130 .frame(maxWidth: .infinity, alignment: .leading)
@@ -182,6 +188,15 @@ struct AgendaView: View {
182188 Text(choices.prefix(12).joined(separator: " "))
183189 }
184190 }
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 }
185200 .navigationTitle(viewName.replacingOccurrences(of: "…", with: ""))
186201 .onChange(of: viewName) { applyView() }
187202 .task(id: refreshKey) {
@@ -254,10 +269,65 @@ struct AgendaView: View {
254269 private func item(_ id: String) -> AgendaItem? {
255270 let parts = id.split(separator: ":")
256271 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)
258273 return index < items.count ? items[index] : nil
259274 }
260275
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
261331 private func jump(_ id: String) {
262332 guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
263333 commands.jump(path: path, offset: offset)
@@ -297,6 +367,11 @@ struct AgendaView: View {
297367 case "f" where isAgenda: agenda.shift(forward: true)
298368 case "b" where isAgenda: agenda.shift(forward: false)
299369 case "." where isAgenda: agenda.showToday()
370 case "/", "\\", "=", "_": askFilter(press.characters.first!)
371 case "<": toggleCategoryFilter()
372 case "|":
373 filter = AgendaFilter()
374 message = "Filters removed"
300375 case "l" where isAgenda:
301376 logMode.toggle()
302377 message = logMode ? "Log mode on" : "Log mode off"
Tests/OrgCoreTests/AgendaTests.swift +64
@@ -45,6 +45,7 @@ enum AgendaOracle {
4545 ((get-text-property p 'org-agenda-date-header)
4646 (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days))
4747 (setq header line items nil))
48 ((get-text-property p 'org-filter-type))
4849 ((or header hd)
4950 (push `((line . ,line)
5051 (type . ,(get-text-property p 'type))
@@ -473,3 +474,66 @@ struct AgendaLogModeTests {
473474 try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, prefix: ["agenda": " %-8:c%?-12t%s %l"], logMode: true)
474475 }
475476}
477
478struct 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}