krz/orgstar

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

Commit eba8a09f88

eba8a09f88f681c8e9a6bb7aeab5fbdc303c4e16

parent: 2fa201330f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 06:40 UTC

Add the agenda text search view (org-search-view)

Agenda.textSearch: phrase and boolean (+word, -word, {regexp}, "quoted")
searches over entries, with the *, ! and : prefixes, todo-only, and
org-agenda-search-view-always-boolean / -force-full-words settings.
search and todo-search views in views.toml; Search… and TODO Search… in
the Mac view menu, Search… in the iOS Views menu. Oracle tests against
Org 9.8.7.

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +3 −1
@@ -11,7 +11,7 @@ import OrgPresentation
1111@Observable
1212public final class AgendaModel {
1313 public private(set) var days: [AgendaDay] = []
14 /// The entries of a TODO list or match view.
14 /// The entries of a TODO list, match or search view.
1515 public private(set) var list: [AgendaItem] = []
1616 public var mode: AgendaMode = .agenda(span: nil, startOffset: nil)
1717 /// The first day shown, as `Days` numbers it; nil follows today.
@@ -84,6 +84,8 @@ public final class AgendaModel {
8484 list = Agenda.todoList(all, keywords: keywords, options: options)
8585 case .match(let match, let todoOnly):
8686 list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar, options: options)
87 case .search(let string, let todoOnly):
88 list = Agenda.textSearch(all, string: string, todoOnly: todoOnly, options: options)
8789 }
8890 return (days, list, events)
8991 }.value
Sources/OrgApp/AgendaViews.swift +10 −2
@@ -9,6 +9,8 @@ public enum AgendaMode: Hashable, Sendable {
99 case todo(keywords: String?)
1010 /// `org-tags-view`.
1111 case match(String, todoOnly: Bool)
12 /// `org-search-view`.
13 case search(String, todoOnly: Bool)
1214}
1315
1416/// A named view: the built-in ones and those in `views.toml`.
@@ -28,12 +30,12 @@ public struct AgendaViewDefinition: Hashable, Sendable, Identifiable {
2830/// ```toml
2931/// [[view]]
3032/// name = "Work"
31/// type = "match" # agenda, todo, match or todo-match
33/// type = "match" # agenda, todo, match, todo-match, search or todo-search
3234/// match = "+work-boss"
3335/// ```
3436///
3537/// `agenda` views take `span` and `start` (days from today), `todo` views `keywords`
36/// (`WAIT|HOLD`).
38/// (`WAIT|HOLD`); a `search` view's `match` is its search string.
3739@MainActor
3840public enum AgendaViewLoader {
3941 public static var userFile: URL { UserConfig.file("views.toml") }
@@ -76,6 +78,12 @@ public enum AgendaViewLoader {
7678 continue
7779 }
7880 mode = .match(match, todoOnly: table.values["type"]?.string == "todo-match")
81 case "search", "todo-search":
82 guard let match else {
83 problems.append("views.toml line \(table.line): \(name) needs a match")
84 continue
85 }
86 mode = .search(match, todoOnly: table.values["type"]?.string == "todo-search")
7987 case let other:
8088 problems.append("views.toml line \(table.line): unknown type \(other)")
8189 continue
Sources/OrgApp/OrgPreferences.swift +5 −1
@@ -54,11 +54,15 @@ public enum OrgPreferences {
5454 public static func agendaShowAllDates(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaShowAllDates", defaults) }
5555 public static func agendaSkipScheduledIfDone(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSkipScheduledIfDone", defaults) }
5656 public static func agendaSkipDeadlineIfDone(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSkipDeadlineIfDone", defaults) }
57 /// `options` with the skip-if-done settings.
57 public static func agendaSearchAlwaysBoolean(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSearchAlwaysBoolean", defaults) }
58 public static func agendaSearchForceFullWords(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSearchForceFullWords", defaults) }
59 /// `options` with the skip-if-done and search settings.
5860 public static func agendaOptions(_ options: AgendaOptions, _ defaults: UserDefaults = .standard) -> AgendaOptions {
5961 var options = options
6062 options.skipScheduledIfDone = agendaSkipScheduledIfDone(defaults)
6163 options.skipDeadlineIfDone = agendaSkipDeadlineIfDone(defaults)
64 options.searchAlwaysBoolean = agendaSearchAlwaysBoolean(defaults)
65 options.searchForceFullWords = agendaSearchForceFullWords(defaults)
6266 return options
6367 }
6468 /// `calendar-events`: Calendar's events show in the agenda.
Sources/OrgApp/UserConfig.swift +4
@@ -147,6 +147,10 @@ public enum UserConfig {
147147 comment: "leave done entries out of the agenda even on the day they are scheduled", legacy: ""),
148148 Setting(section: "", key: "org-agenda-skip-deadline-if-done", defaultsKey: "agendaSkipDeadlineIfDone", kind: .bool, fallback: .bool(false),
149149 comment: "leave done entries out of the agenda even on the day they are due", legacy: ""),
150 Setting(section: "", key: "org-agenda-search-view-always-boolean", defaultsKey: "agendaSearchAlwaysBoolean", kind: .bool, fallback: .bool(false),
151 comment: "search strings are always +word -word {regexp} snippets, never a phrase", legacy: ""),
152 Setting(section: "", key: "org-agenda-search-view-force-full-words", defaultsKey: "agendaSearchForceFullWords", kind: .bool, fallback: .bool(false),
153 comment: "search snippets match whole words only", legacy: ""),
150154 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
151155 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
152156 Setting(section: "", key: "org-clock-idle-time", defaultsKey: "clockIdleTime", kind: .integer, fallback: .integer(0),
Sources/OrgCore/Agenda/Agenda.swift +5
@@ -13,6 +13,7 @@ public struct AgendaItem: Sendable, Equatable {
1313 case currentTime
1414 case todo
1515 case tagsMatch = "tagsmatch"
16 case search
1617 /// Log mode: a closed entry, a clock line, a state change.
1718 case closed, clock, state
1819 }
@@ -79,6 +80,10 @@ public struct AgendaOptions: Sendable, Equatable {
7980 public var skipScheduledIfDone = false
8081 /// `org-agenda-skip-deadline-if-done`: done entries' deadlines are left out even on the day due.
8182 public var skipDeadlineIfDone = false
83 /// `org-agenda-search-view-always-boolean`: search strings are always `+word -word` snippets.
84 public var searchAlwaysBoolean = false
85 /// `org-agenda-search-view-force-full-words`: search snippets match whole words only.
86 public var searchForceFullWords = false
8287
8388 public init() {}
8489
Sources/OrgCore/Agenda/AgendaSearch.swift +185
@@ -44,6 +44,185 @@ extension Agenda {
4444 return sortByUrgency(items)
4545 }
4646
47 /// `org-search-view`: entries, heading and body, containing `string` as a phrase, or with
48 /// a leading `+`, `-` or `{` (or `searchAlwaysBoolean`) matching each of its snippets. A
49 /// leading `*` searches headlines only, `!` keeps unfinished TODO entries as `todoOnly`
50 /// does, and `:` matches whole words. Text before the first heading is not searched.
51 public static func textSearch(_ sources: [AgendaSource], string: String, todoOnly: Bool = false, options: AgendaOptions = AgendaOptions()) -> [AgendaItem] {
52 let query = SearchQuery(string, todoOnly: todoOnly, options: options)
53 // Org searches for the longest positive snippet, then checks the entry it is in.
54 var positive = query.positive
55 let first = positive.isEmpty ? nil : positive.removeFirst()
56 let patterns = positive.compactMap(\.regex)
57 let negative = query.negative.compactMap(\.regex)
58 guard let search = SearchQuery.compile(first.map { (query.headlinesOnly ? "^\\*+ .*?" : "") + $0.emacs } ?? "^\\*+ "),
59 patterns.count == positive.count, negative.count == query.negative.count else { return [] }
60 let headingLine = try! NSRegularExpression(pattern: "^\\*+ ", options: .anchorsMatchLines)
61 var items: [AgendaItem] = []
62 for source in sources {
63 let text = source.text
64 let ns = text as NSString
65 let length = ns.length
66 let starts = headingLine.matches(in: text, range: NSRange(location: 0, length: length)).map(\.range.location)
67 guard let firstHeading = starts.first else { continue }
68 let byStart = Dictionary(source.headings.map { ($0.start, $0) }, uniquingKeysWith: { a, _ in a })
69 var required = patterns
70 if query.todoOnly {
71 guard !source.notDoneKeywords.isEmpty else { continue }
72 let keywords = source.notDoneKeywords.map(SearchQuery.quote).joined(separator: "\\|")
73 required.insert(SearchQuery.compile("^\\*+[ \\t]+\\(" + keywords + "\\)")!, at: 0)
74 }
75 func lineStart(_ at: Int) -> Int {
76 let newline = ns.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: at))
77 return newline.location == NSNotFound ? 0 : newline.location + 1
78 }
79 var at = max(0, firstHeading - 1)
80 while at <= length, let m = search.firstMatch(in: text, options: [.withoutAnchoringBounds, .withTransparentBounds], range: NSRange(location: at, length: length - at)) {
81 let matchEnd = NSMaxRange(m.range)
82 // `org-back-to-heading`, then `outline-next-heading`.
83 guard let index = starts.lastIndex(where: { $0 <= lineStart(matchEnd) }) else { break }
84 let beg = starts[index]
85 let end = index + 1 < starts.count ? starts[index + 1] : length
86 let next = max(end - 1, at + 1)
87 guard let entry = byStart[beg] else {
88 at = next
89 continue
90 }
91 if entry.skipped {
92 // `org-agenda-skip` leaves point at the end of the subtree, before blank lines.
93 var stop = starts[(index + 1)...].first { byStart[$0].map { $0.level <= entry.level } ?? false } ?? length
94 if stop > 0, [10, 13].contains(ns.character(at: stop - 1)) {
95 stop -= 1
96 while stop > beg, [9, 10, 13, 32].contains(ns.character(at: stop - 1)) { stop -= 1 }
97 }
98 at = max(stop, at + 1)
99 continue
100 }
101 let lineEnd = ns.range(of: "\n", range: NSRange(location: beg, length: length - beg)).location
102 let stop = query.headlinesOnly && lineEnd != NSNotFound ? lineEnd : end
103 let entryText = ns.substring(with: NSRange(location: beg, length: stop - beg))
104 func found(_ regex: NSRegularExpression) -> Bool {
105 regex.firstMatch(in: entryText, range: NSRange(location: 0, length: (entryText as NSString).length)) != nil
106 }
107 if !negative.contains(where: found), required.allSatisfy(found) {
108 items.append(format(
109 source, entry, kind: .search, marker: beg, extra: "", dotime: .headline, removing: nil,
110 trailing: "", prefix: options.prefix("search"), urgency: { _ in 1000 }
111 ))
112 at = max(next, matchEnd)
113 } else {
114 at = next
115 }
116 }
117 }
118 return items
119 }
120
121 /// An `org-search-view` string read into regexps, in Emacs syntax and compiled.
122 struct SearchQuery {
123 struct Pattern {
124 let emacs: String
125 let regex: NSRegularExpression?
126 }
127
128 let headlinesOnly: Bool
129 let todoOnly: Bool
130 let boolean: Bool
131 /// Longest first, as Org sorts them.
132 let positive: [Pattern]
133 let negative: [Pattern]
134
135 init(_ string: String, todoOnly: Bool, options: AgendaOptions) {
136 var words = Substring(string)
137 headlinesOnly = words.first == "*"
138 if headlinesOnly { words.removeFirst() }
139 var todo = todoOnly
140 if words.first == "!" {
141 todo = true
142 words.removeFirst()
143 }
144 self.todoOnly = todo
145 var fullWords = options.searchForceFullWords
146 if words.first == ":" {
147 fullWords = true
148 words.removeFirst()
149 }
150 boolean = options.searchAlwaysBoolean || ["-", "+", "{"].contains(words.first)
151
152 // `split-string`, then words ending in `\` joined to the next, and `{…}` kept whole.
153 var pending = words.split(whereSeparator: { " \u{0C}\t\n\r\u{0B}".contains($0) }).map(String.init)
154 var joined: [String] = []
155 while !pending.isEmpty {
156 var w = pending.removeFirst()
157 while w.hasSuffix("\\"), !pending.isEmpty { w = String(w.dropLast()) + " " + pending.removeFirst() }
158 joined.append(w)
159 }
160 pending = joined
161 joined = []
162 while !pending.isEmpty {
163 var w = pending.removeFirst()
164 if w.range(of: "^[-+]?\\{", options: .regularExpression) != nil, !w.hasSuffix("}") {
165 while let next = pending.first, !next.hasSuffix("}") { w += " " + pending.removeFirst() }
166 w += " " + (pending.isEmpty ? "" : pending.removeFirst())
167 }
168 joined.append(w)
169 }
170 if boolean {
171 pending = joined
172 joined = []
173 while !pending.isEmpty {
174 var w = pending.removeFirst()
175 let chars = Array(w)
176 if chars[0] == "\"" || (chars.count > 1 && "+-".contains(chars[0]) && chars[1] == "\"") {
177 while !pending.isEmpty, !w.hasSuffix("\"") { w += " " + pending.removeFirst() }
178 }
179 w = w.replacingOccurrences(of: "^([-+]?)\"", with: "$1", options: .regularExpression)
180 if w.hasSuffix("\"") { w.removeLast() }
181 joined.append(w)
182 }
183 }
184
185 var positive: [String] = []
186 var negative: [String] = []
187 if boolean {
188 for word in joined {
189 var w = Substring(word)
190 let negated = w.first == "-"
191 if negated || w.first == "+" { w = w.dropFirst() }
192 let re: String
193 if w.count >= 2, w.first == "{", w.last == "}" {
194 re = String(w.dropFirst().dropLast())
195 } else {
196 re = fullWords ? "\\<" + Self.quote(w.lowercased()) + "\\>" : Self.quote(w.lowercased())
197 }
198 if negated { negative.insert(re, at: 0) } else { positive.insert(re, at: 0) }
199 }
200 } else {
201 positive = [joined.map(Self.quote).joined(separator: "\\s-+")]
202 }
203 // `sort` is stable; the snippets were pushed, so the last comes first among equals.
204 self.positive = positive.enumerated().sorted { a, b in
205 a.element.count != b.element.count ? a.element.count > b.element.count : a.offset < b.offset
206 }.map { Pattern(emacs: $0.element, regex: Self.compile($0.element)) }
207 self.negative = negative.map { Pattern(emacs: $0, regex: Self.compile($0)) }
208 }
209
210 /// `regexp-quote`.
211 static func quote(_ s: String) -> String {
212 var out = ""
213 for c in s {
214 if "[*.\\?+^$".contains(c) { out.append("\\") }
215 out.append(c)
216 }
217 return out
218 }
219
220 /// Case-insensitive, as Org binds `case-fold-search`, with `^` and `$` at each line.
221 static func compile(_ emacs: String) -> NSRegularExpression? {
222 try? NSRegularExpression(pattern: EmacsRegex.translate(emacs), options: [.caseInsensitive, .anchorsMatchLines])
223 }
224 }
225
47226 /// `(urgency-down category-keep)`, stably.
48227 static func sortByUrgency(_ items: [AgendaItem]) -> [AgendaItem] {
49228 items.enumerated().sorted { a, b in
@@ -337,6 +516,12 @@ enum EmacsRegex {
337516 case "|": out += "|"
338517 case "{": out += "{"
339518 case "}": out += "}"
519 case "s" where i + 2 < chars.count && chars[i + 2] == "-",
520 "S" where i + 2 < chars.count && chars[i + 2] == "-":
521 // Whitespace syntax in Org buffers.
522 out += n == "s" ? "[\\t\\n\\f\\r \\u00a0]" : "[^\\t\\n\\f\\r \\u00a0]"
523 i += 3
524 continue
340525 case "<", ">": out += "\\b"
341526 case "`": out += "^"
342527 case "'": out += "$"
Sources/OrgCore/Agenda/AgendaSource.swift +6
@@ -94,6 +94,10 @@ public struct AgendaSource: Sendable {
9494 let progress: [Progress]
9595 /// The file's TODO keywords, active and done.
9696 let keywords: [String]
97 /// The active ones (`org-not-done-keywords`).
98 let notDoneKeywords: [String]
99 /// The file's text, for the search view.
100 let text: String
97101 let priorities: Priorities
98102
99103 public init(path: String, text: String, defaults: OrgSettings = .default) {
@@ -104,6 +108,8 @@ public struct AgendaSource: Sendable {
104108 let ns = text as NSString
105109 let settings = tree.settings
106110 keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) }
111 notDoneKeywords = settings.todoSequences.flatMap { $0.active.map(\.name) }
112 self.text = text
107113 priorities = settings.priorities
108114
109115 var stampsByHeading: [Int: [String]] = [:]
Sources/OrgPresentation/AgendaRows.swift +1 −1
@@ -134,7 +134,7 @@ public struct AgendaRow: Sendable, Equatable, Identifiable {
134134 case .closed: (icon, untimed) = (.timestamp, "Closed")
135135 case .clock: (icon, untimed) = (.timestamp, "Clocked")
136136 case .state: (icon, untimed) = (.timestamp, "State")
137 case .todo, .tagsMatch, .timeGrid, .currentTime: break
137 case .todo, .tagsMatch, .search, .timeGrid, .currentTime: break
138138 }
139139 if item.isDone { status = .done }
140140 self.id = [item.path ?? "", "\(item.headingOffset ?? -1)", "\(item.markerOffset ?? -1)", item.kind.rawValue, item.text].joined(separator: "\u{1f}")
Sources/Orgstar/AgendaView.swift +12 −3
@@ -16,7 +16,7 @@ enum CalendarAccess {
1616 static let events = CalendarEvents()
1717}
1818
19/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
19/// The agenda window: the day/week agenda, the TODO list, tag matches, text search and saved views.
2020/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
2121/// n/p (or the arrows) move between lines, f/b/. move the span, j picks a date, v d/v w/v m (or
2222/// d/w) show a day, week or month, g refreshes, l shows the log (closed and clocked), / filters
@@ -80,6 +80,8 @@ struct AgendaView: View {
8080
8181 static let match = "Match…"
8282 static let todoMatch = "TODO Match…"
83 static let search = "Search…"
84 static let todoSearch = "TODO Search…"
8385
8486 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?, clock: ClockModel) {
8587 self.workspace = workspace
@@ -143,6 +145,7 @@ struct AgendaView: View {
143145 }
144146
145147 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
148 private var isSearch: Bool { viewName == Self.search || viewName == Self.todoSearch }
146149
147150 @Environment(\.orgTheme) private var theme
148151
@@ -466,12 +469,14 @@ struct AgendaView: View {
466469 Divider()
467470 Button(Self.match) { viewName = Self.match }
468471 Button(Self.todoMatch) { viewName = Self.todoMatch }
472 Button(Self.search) { viewName = Self.search }
473 Button(Self.todoSearch) { viewName = Self.todoSearch }
469474 } label: {
470475 Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon)
471476 }
472477 .help("What the agenda shows")
473 if isMatch {
474 TextField("+work-boss|LEVEL>2/!TODO", text: $matchText)
478 if isMatch || isSearch {
479 TextField(isSearch ? "Phrase or +word -word {regexp}" : "+work-boss|LEVEL>2/!TODO", text: $matchText)
475480 .frame(minWidth: 220)
476481 .onSubmit { applyView() }
477482 }
@@ -633,6 +638,7 @@ struct AgendaView: View {
633638 switch agenda.mode {
634639 case .todo(let keywords): "TODO items: \(keywords ?? "all")"
635640 case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)"
641 case .search(let string, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " containing \(string)"
636642 case .agenda: ""
637643 }
638644 }
@@ -667,6 +673,9 @@ struct AgendaView: View {
667673 let match = matchText.trimmingCharacters(in: .whitespaces)
668674 guard !match.isEmpty else { return }
669675 agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch)
676 case Self.search, Self.todoSearch:
677 guard matchText.contains(where: { !$0.isWhitespace }) else { return }
678 agenda.mode = .search(matchText, todoOnly: viewName == Self.todoSearch)
670679 default:
671680 if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode }
672681 }
Sources/OrgstarMobile/AgendaScreen.swift +21 −11
@@ -6,8 +6,8 @@ import OrgPresentation
66import SwiftUI
77
88/// The agenda over the workspace's agenda files: the day/week view, the TODO list, tag and
9/// property matches and the views in `views.toml`, with Org's filters and the agenda's
10/// actions on each entry.
9/// property matches, text search and the views in `views.toml`, with Org's filters and the
10/// agenda's actions on each entry.
1111struct AgendaScreen: View {
1212 let workspace: WorkspaceModel
1313 let session: DocumentSession
@@ -39,10 +39,10 @@ struct AgendaScreen: View {
3939 @State private var done = 0
4040 @State private var moved = 0
4141
42 /// A match typed for the tag and property view.
42 /// A match typed for the tag and property view, or a search string.
4343 private enum Asking: Identifiable {
44 case match
45 var id: Int { 0 }
44 case match, search
45 var id: Self { self }
4646 }
4747
4848 enum SpanRange: String, CaseIterable, Identifiable {
@@ -119,6 +119,7 @@ struct AgendaScreen: View {
119119 Button(definition.name) { show(definition) }
120120 }
121121 Button("Tags and Properties…") { asking = .match }
122 Button("Search…") { asking = .search }
122123 } label: {
123124 Label("Views", systemImage: "list.bullet.rectangle")
124125 }
@@ -139,12 +140,21 @@ struct AgendaScreen: View {
139140 if let picked, let item = row.item { reschedule(row, item, to: Days.absolute(picked, calendar: .current)) }
140141 }
141142 }
142 .sheet(item: $asking) { _ in
143 PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in
144 asking = nil
145 guard let answer, !answer.isEmpty else { return }
146 agenda.mode = .match(answer, todoOnly: false)
147 viewName = answer
143 .sheet(item: $asking) { kind in
144 if kind == .search {
145 PromptSheet(prompt: Prompt(key: "search", message: "Search: a phrase, or +word -word {regexp}; * for headlines, ! for TODO entries")) { answer in
146 asking = nil
147 guard let answer, answer.contains(where: { !$0.isWhitespace }) else { return }
148 agenda.mode = .search(answer, todoOnly: false)
149 viewName = answer
150 }
151 } else {
152 PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in
153 asking = nil
154 guard let answer, !answer.isEmpty else { return }
155 agenda.mode = .match(answer, todoOnly: false)
156 viewName = answer
157 }
148158 }
149159 }
150160 .sheet(item: $prompt) { request in
Tests/OrgCoreTests/AgendaSearchTests.swift added +115
@@ -0,0 +1,115 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5/// `org-search-view` against Emacs.
6struct AgendaSearchTests {
7 static let first = """
8 #+TODO: TODO NEXT | DONE
9 Rent before the first heading.
10 * TODO [#A] Pay rent :home:
11 SCHEDULED: <2026-10-01 Thu>
12 Ask the landlord about the boiler.
13 * Meeting 10:00 with Bob
14 Notes about the end of
15 the line, and Bob's plan.
16 * DONE Paid the rent
17 * NEXT Report :work:
18 ** Section with alpha and beta
19 Alpha only here, and a boilerplate.
20 ** Section with alpha
21 Gamma.
22 * todo list of chores
23 Water the plants, alpha.
24 * Archived :ARCHIVE:
25 The rent is here too.
26 ** TODO Under archived rent
27 * COMMENT Commented rent
28 * Two words
29 The phrase two words
30 spans here; foo_bar and foo-bar.
31 * The last
32 word ends this entry with last
33 * word starts the next heading
34 * Ünïcode tïtle :tag:
35 Straße and STRASSE.
36 """
37
38 static let second = """
39 #+CATEGORY: other
40 * WAIT Second file rent
41 * Plans
42 alpha, beta, gamma in the body.
43 ** HOLD Nested beta
44 * Headline alpha
45 """
46
47 static let strings = [
48 // Phrases.
49 "rent", "RENT", "pay rent", "end of the line", "end of\nthe", "two words", "last word",
50 // A match ending in the next heading's line selects that entry.
51 "last * word","Bob's", "boiler",
52 "straße", "foo_bar", "10:00", "nothing at all", "alpha, beta",
53 // Boolean.
54 "+alpha", "+alpha -beta", "+alpha +gamma", "-alpha", "+beta -nested", "{alph?a}", "+{^Gam}", "-{b[eo]}",
55 "+{\\<rent\\>}", "+\"two words\"", "+\"two words\"", "+\"the line\"", "{boiler\\b}", "+alpha +\"in the body\"",
56 "+{foo\\s-bar}", "+boiler -plate", "+{a b}", "+{alpha} -{x y}",
57 // Headlines only.
58 "*rent", "*alpha", "*+alpha -beta", "*+section", "*-alpha", "*pay rent", "*with alpha and",
59 // TODO only and whole words.
60 "!rent", "!+alpha", "*!section", ":+boiler", ":+alpha", "+:boiler", ":boiler", "*!:+rent",
61 ]
62
63 static func lisp(_ s: String) -> String {
64 "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"").replacingOccurrences(of: "\n", with: "\\n") + "\""
65 }
66
67 static let files = [("a.org", first), ("b.org", second)]
68 static let today = Days.absolute(year: 2026, month: 10, day: 5)
69
70 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
71 func searchMatchesEmacs() throws {
72 for string in Self.strings {
73 for todoOnly in [false, true] {
74 try AgendaOracle.compareList(files: Self.files, form: "(org-search-view \(todoOnly ? "t" : "nil") \(Self.lisp(string)))", today: Self.today) {
75 Agenda.textSearch($0, string: string, todoOnly: todoOnly)
76 }
77 }
78 }
79 }
80
81 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
82 func searchOptionsMatchEmacs() throws {
83 for string in ["boiler", "+boiler", "alpha beta", "+alpha -beta", "{^Gam}", "two words", "*rent", "rent -paid", "+\"two words\""] {
84 for (boolean, full) in [(true, false), (false, true), (true, true)] {
85 var options = AgendaOptions()
86 options.searchAlwaysBoolean = boolean
87 options.searchForceFullWords = full
88 let form = "(let ((org-agenda-search-view-always-boolean \(boolean ? "t" : "nil")) (org-agenda-search-view-force-full-words \(full ? "t" : "nil"))) (org-search-view nil \(Self.lisp(string))))"
89 try AgendaOracle.compareList(files: Self.files, form: form, today: Self.today) {
90 Agenda.textSearch($0, string: string, options: options)
91 }
92 }
93 }
94 }
95
96 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
97 func searchPrefixMatchesEmacs() throws {
98 let formats = ["search": "%c %?-5:l %b", "agenda": " %i %-12:c%?-12t% s", "todo": " %i %-12:c", "tags": " %i %-12:c"]
99 var options = AgendaOptions()
100 options.prefixFormat = formats
101 let form = "(let ((org-agenda-prefix-format \(AgendaOracle.prefixLisp(formats)))) (org-search-view nil \"alpha\"))"
102 try AgendaOracle.compareList(files: Self.files, form: form, today: Self.today) {
103 Agenda.textSearch($0, string: "alpha", options: options)
104 }
105 }
106
107 @Test func parsesSnippets() {
108 let query = Agenda.SearchQuery("*!:+alpha -\"two words\" {a b} back\\ slash", todoOnly: false, options: AgendaOptions())
109 #expect(query.headlinesOnly && query.todoOnly && query.boolean)
110 #expect(query.positive.map(\.emacs) == ["\\<back slash\\>","\\<alpha\\>", "a b"])
111 #expect(query.negative.map(\.emacs) == ["\\<two words\\>"])
112 let phrase = Agenda.SearchQuery("pay rent.", todoOnly: false, options: AgendaOptions())
113 #expect(!phrase.boolean && phrase.positive.map(\.emacs) == ["pay\\s-+rent\\."])
114 }
115}
docs/manual/guide/03-keys.org +2
@@ -689,6 +689,8 @@ The Agenda window has its own keys, modelled on =org-agenda-mode=, which =keymap
689689| =C-c C-w= | Refile. |
690690| =C-c $=, =C-c C-x C-s= | Archive. |
691691
692=s= saves, as in =org-agenda-mode=. The text search that =s= starts from Org's agenda dispatcher is Search… in the view menu, and =S= is TODO Search….
693
692694See [[file:07-agenda.org][Agenda]] for what these do. In the Capture window, typing a template's key picks it, =⌘↩= files the entry and =Esc= cancels; see [[file:08-capture.org][Capture]].
693695
694696* iOS and iPadOS
docs/manual/guide/07-agenda.org +35 −6
@@ -339,11 +339,35 @@ After the last =/= (one not followed by a quote), the match restricts TODO keywo
339339
340340* Text search
341341
342The agenda has no =org-search-view= (=s=). To search the text of your files, use Search Notes (=⇧⌘F=).
342Choose Search… from the view menu to list entries containing some text, as =org-search-view= (=s=) does, or TODO Search… to list only entries with an open TODO keyword (=S=, or =C-u s=). Type the search string in the toolbar field and press Return. An entry is its heading and the text up to the next heading; text before the first heading of a file is not searched. Entries show in file order, with the rows of the TODO list.
343
344By default the string is a phrase: =pay rent= finds those words in that order, case-insensitively, and each space matches any run of spaces, tabs and line breaks, so a phrase can span lines. A string that starts with =+=, =-= or ={= is a list of snippets separated by spaces instead, all of which must hold:
345
346| Snippet | Entries that |
347|------------------+-----------------------------------------------------------------|
348| =+word=, =word= | contain the text |
349| =-word= | don't contain it |
350| ={regexp}= | match the Emacs regular expression, without case |
351| =-{regexp}= | don't match it |
352| ="two words"= | contain the words with one space between them |
353
354Phrases and snippets match parts of words: =+rent= finds =parent=. These may come first in the string, in this order:
355
356| Prefix | Effect |
357|--------+---------------------------------------------------------------------|
358| =*= | Search headlines only, not the text below them |
359| =!= | Only entries with an open TODO keyword, as TODO Search… |
360| =:= | Snippets match whole words (regexps are unchanged) |
361
362=*!:+rent -paid= lists open TODO headlines with the word =rent= and without =paid=.
363
364Two settings in =config.toml= change the defaults: =org-agenda-search-view-always-boolean= reads every string as snippets, and =org-agenda-search-view-force-full-words= makes snippets match whole words (see [[file:13-configuration.org][Configuration]]).
365
366The search covers the agenda files only, not =org-agenda-text-search-extra-files= or archive files. Entries in archived or commented trees are left out, as in the other views. To search every file in the folders, use Search Notes (=⇧⌘F=).
343367
344368* Views
345369
346The view menu lists the built-in views, Agenda and TODO List, then the views in =views.toml=, then Match… and TODO Match…. Saved views mirror =org-agenda-custom-commands=, with fewer options.
370The view menu lists the built-in views, Agenda and TODO List, then the views in =views.toml=, then Match…, TODO Match…, Search… and TODO Search…. Saved views mirror =org-agenda-custom-commands=, with fewer options.
347371
348372=views.toml= lives in the configuration folder beside =config.toml= (=~/.config/orgstar/= by default; see [[file:13-configuration.org][Configuration]]). Each view is a =[[view]]= table:
349373
@@ -368,20 +392,25 @@ match = "+work-boss"
368392name = "Next at work"
369393type = "todo-match"
370394match = "+work/NEXT"
395
396[[view]]
397name = "Invoices"
398type = "search"
399match = "+invoice -paid"
371400#+END_SRC
372401
373402| Key | Applies to | Meaning |
374403|------------+--------------------+----------------------------------------------------------------------------|
375404| =name= | all, required | The name in the view menu |
376| =type= | all | =agenda= (the default), =todo=, =match= or =todo-match= |
405| =type= | all | =agenda= (the default), =todo=, =match=, =todo-match=, =search= or =todo-search= |
377406| =span= | =agenda= | Days shown; the setting when omitted |
378407| =start= | =agenda= | First day as an integer number of days from today (=-3=, =0=); the setting when omitted |
379408| =keywords= | =todo= | Keywords separated by the vertical bar; all open TODOs when omitted |
380| =match= | =match=, =todo-match=, required | A match string as in [[*Tag and property matches][Tag and property matches]]; =todo-match= keeps only open TODO entries |
409| =match= | =match=, =todo-match=, =search=, =todo-search=, required | A match string as in [[*Tag and property matches][Tag and property matches]], or for =search= a search string as in [[*Text search][Text search]]; the =todo-= types keep only open TODO entries |
381410
382411Note that =start= is a plain integer here, while =org-agenda-start-day= in =config.toml= is a string such as ="-3d"=.
383412
384Not supported: block agendas (several views in one), per-view settings such as =org-agenda-skip-function= or a different prefix format, =stuck= projects, and search views. The views load when the window opens and again on =g= or =r=. A problem in the file, such as a missing =name= or an unknown =type=, shows in the status line at the bottom of the window, and the remaining views still load.
413Not supported: block agendas (several views in one), per-view settings such as =org-agenda-skip-function= or a different prefix format, and =stuck= projects. The views load when the window opens and again on =g= or =r=. A problem in the file, such as a missing =name= or an unknown =type=, shows in the status line at the bottom of the window, and the remaining views still load.
385414
386415* Filters
387416
@@ -529,7 +558,7 @@ The board is not available on iOS.
529558
530559The Agenda tab shows the same views from the same files, using the settings in the synced =config.toml= and the views in =views.toml= (see [[file:14-ios.org][iOS]]). Differences from the Mac:
531560
532- The Views menu, at the top left, lists the built-in views, your saved views, and Tags and Properties…, which asks for a match string. There is no TODO-only match from the menu; use =/!= in the match or a =todo-match= view.
561- The Views menu, at the top left, lists the built-in views, your saved views, Tags and Properties…, which asks for a match string, and Search…, which asks for a search string. There is no TODO-only match or search from the menu; use =/!= in the match, =!= at the start of the search string, or a =todo-match= or =todo-search= view.
533562- A bar floats at the bottom of the screen with Earlier, Filter, Today, Go to Date, the span and Later. The span menu switches between Day, Week, Month and the configured span (=10 Days= by default). Weeks start on Monday, as on the Mac. In the TODO list and match views the bar has Filter and Agenda, which returns to the day view. Messages show above the bar; tap one to dismiss it.
534563- The title names the days shown: =Thu 8 Oct=, =Week of 5 Oct=, =October 2026=, or =5 Oct – 14 Oct=.
535564- Day headers read =Today · Thu 8 Oct=, =Tomorrow · Fri 9 Oct= or =Mon 12 Oct=, with a + that captures onto that day. Days before today are grey.
docs/manual/guide/13-configuration.org +4
@@ -152,6 +152,8 @@ These settings have no control in the Settings window. Some are in the View menu
152152| =ignored-folders= | |
153153| =org-agenda-skip-scheduled-if-done= | |
154154| =org-agenda-skip-deadline-if-done= | |
155| =org-agenda-search-view-always-boolean= | |
156| =org-agenda-search-view-force-full-words= | |
155157| =[org-agenda-prefix-format]= | |
156158| =theme-file= | |
157159
@@ -193,6 +195,8 @@ A key you remove from the file goes back to its default. A key that isn't in the
193195| =org-agenda-show-all-dates= | boolean | =false= | Show days without entries in the agenda; today always shows. Emacs's default is =t=. Mirrors =org-agenda-show-all-dates=. |
194196| =org-agenda-skip-scheduled-if-done= | boolean | =false= | Leave a done entry's scheduled date out of the agenda, even on its own day. Mirrors =org-agenda-skip-scheduled-if-done=. |
195197| =org-agenda-skip-deadline-if-done= | boolean | =false= | Leave a done entry's deadline out of the agenda, even on its own day. Mirrors =org-agenda-skip-deadline-if-done=. |
198| =org-agenda-search-view-always-boolean= | boolean | =false= | The agenda's text search reads every string as =+word -word {regexp}= snippets, never as a phrase. Mirrors =org-agenda-search-view-always-boolean=. |
199| =org-agenda-search-view-force-full-words= | boolean | =false= | Text search snippets match whole words only, as a leading =:= does. Mirrors =org-agenda-search-view-force-full-words=. |
196200| =appt-message-warning-time= | integer | =12= | Minutes of warning before timed entries. An entry's =APPT_WARNTIME= property overrides it. Mirrors =appt-message-warning-time=. |
197201| =org-clock-idle-time= | integer | =0= | Minutes without keyboard or mouse input, with a clock running, before Orgstar asks what to do with the idle time. =0= never asks (Emacs's =nil=). Mac only. Mirrors =org-clock-idle-time=. |
198202| =org-clock-history-length= | integer | =5= | How many recently clocked entries Orgstar remembers. Mirrors =org-clock-history-length=. |
docs/manual/guide/14-ios.org +2 −2
@@ -154,7 +154,7 @@ Tap a result to open it in the reader. A heading result opens at that heading.
154154
155155The Agenda tab shows the agenda over all your folders, with the span and start day from the settings (10 days from 3 days ago by default). Each day lists its entries in rows: the time or a status such as =Due today= or =3d late= on the left, the category and the keyword above the title. Days without entries are hidden, except today. Today's overdue entries are in a group of their own, and a line marks the current time between timed entries. Pull down to refresh.
156156
157- Views (top left) :: the built-in views and those in =views.toml=, and Tags and Properties… (a match such as =+work-home= or ~TODO="WAIT"~).
157- Views (top left) :: the built-in views and those in =views.toml=, Tags and Properties… (a match such as =+work-home= or ~TODO="WAIT"~), and Search… (a phrase, or snippets such as =+invoice -paid=).
158158- The bar at the bottom :: Earlier, Filter, Today, Go to Date, the span (Day, Week, Month or the configured span) and Later.
159159- Filter :: keep or leave out tags and categories of the entries shown, or type a filter as Org's =/= takes it: =+keep= and =-drop= tags or categories, =<0:30= for effort, =/regexp/=. The filter in use shows above the bar.
160160- + on a day's header :: captures an entry scheduled on that day (see [[file:08-capture.org][Capture]]).
@@ -331,7 +331,7 @@ These settings from =config.toml= apply on iOS:
331331- =org-log-done=, =org-log-reschedule=, =org-log-redeadline=, =org-log-into-drawer=
332332- =org-use-speed-commands=, with a hardware keyboard
333333- =electric-pair-mode=, =spell-check=
334- =org-agenda-span=, =org-agenda-start-day=, =agenda-include-subfolders=, =org-agenda-show-all-dates=, =org-agenda-skip-scheduled-if-done=, =org-agenda-skip-deadline-if-done=
334- =org-agenda-span=, =org-agenda-start-day=, =agenda-include-subfolders=, =org-agenda-show-all-dates=, =org-agenda-skip-scheduled-if-done=, =org-agenda-skip-deadline-if-done=, =org-agenda-search-view-always-boolean=, =org-agenda-search-view-force-full-words=
335335- =calendar-events=, =calendar-event-calendars=
336336- =reminders=, =appt-message-warning-time=
337337- =org-clock-history-length=
docs/manual/guide/15-alongside-emacs.org +1
@@ -246,3 +246,4 @@ Other:
246246- =#+SETUPFILE= URLs aren't fetched.
247247- Most =#+STARTUP= options for footnotes, entities, LaTeX previews, numbering and odd levels are accepted and ignored; see [[file:13-configuration.org][Configuration]].
248248- =display-line-numbers-type= has no relative or visual mode.
249- The agenda's text search (see [[file:07-agenda.org][The agenda]]) covers the agenda files only: =org-agenda-text-search-extra-files= and =org-agenda-search-view-max-outline-level= aren't supported. It runs from the view menu rather than the dispatcher's =s= and =S=, and the search view's =[=, =]=, ={= and =}= keys, which add words and regexps to the search, aren't supported.