Commit eba8a09f88
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +3 −1
| @@ -11,7 +11,7 @@ import OrgPresentation | |||
| 11 | @Observable | 11 | @Observable |
| 12 | public final class AgendaModel { | 12 | public final class AgendaModel { |
| 13 | public private(set) var days: [AgendaDay] = [] | 13 | 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. |
| 15 | public private(set) var list: [AgendaItem] = [] | 15 | public private(set) var list: [AgendaItem] = [] |
| 16 | public var mode: AgendaMode = .agenda(span: nil, startOffset: nil) | 16 | public var mode: AgendaMode = .agenda(span: nil, startOffset: nil) |
| 17 | /// The first day shown, as `Days` numbers it; nil follows today. | 17 | /// The first day shown, as `Days` numbers it; nil follows today. |
| @@ -84,6 +84,8 @@ public final class AgendaModel { | |||
| 84 | list = Agenda.todoList(all, keywords: keywords, options: options) | 84 | list = Agenda.todoList(all, keywords: keywords, options: options) |
| 85 | case .match(let match, let todoOnly): | 85 | case .match(let match, let todoOnly): |
| 86 | list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar, options: options) | 86 | 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) | ||
| 87 | } | 89 | } |
| 88 | return (days, list, events) | 90 | return (days, list, events) |
| 89 | }.value | 91 | }.value |
Sources/OrgApp/AgendaViews.swift +10 −2
| @@ -9,6 +9,8 @@ public enum AgendaMode: Hashable, Sendable { | |||
| 9 | case todo(keywords: String?) | 9 | case todo(keywords: String?) |
| 10 | /// `org-tags-view`. | 10 | /// `org-tags-view`. |
| 11 | case match(String, todoOnly: Bool) | 11 | case match(String, todoOnly: Bool) |
| 12 | /// `org-search-view`. | ||
| 13 | case search(String, todoOnly: Bool) | ||
| 12 | } | 14 | } |
| 13 | 15 | ||
| 14 | /// A named view: the built-in ones and those in `views.toml`. | 16 | /// A named view: the built-in ones and those in `views.toml`. |
| @@ -28,12 +30,12 @@ public struct AgendaViewDefinition: Hashable, Sendable, Identifiable { | |||
| 28 | /// ```toml | 30 | /// ```toml |
| 29 | /// [[view]] | 31 | /// [[view]] |
| 30 | /// name = "Work" | 32 | /// name = "Work" |
| 31 | /// type = "match" # agenda, todo, match or todo-match | 33 | /// type = "match" # agenda, todo, match, todo-match, search or todo-search |
| 32 | /// match = "+work-boss" | 34 | /// match = "+work-boss" |
| 33 | /// ``` | 35 | /// ``` |
| 34 | /// | 36 | /// |
| 35 | /// `agenda` views take `span` and `start` (days from today), `todo` views `keywords` | 37 | /// `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. |
| 37 | @MainActor | 39 | @MainActor |
| 38 | public enum AgendaViewLoader { | 40 | public enum AgendaViewLoader { |
| 39 | public static var userFile: URL { UserConfig.file("views.toml") } | 41 | public static var userFile: URL { UserConfig.file("views.toml") } |
| @@ -76,6 +78,12 @@ public enum AgendaViewLoader { | |||
| 76 | continue | 78 | continue |
| 77 | } | 79 | } |
| 78 | mode = .match(match, todoOnly: table.values["type"]?.string == "todo-match") | 80 | 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") | ||
| 79 | case let other: | 87 | case let other: |
| 80 | problems.append("views.toml line \(table.line): unknown type \(other)") | 88 | problems.append("views.toml line \(table.line): unknown type \(other)") |
| 81 | continue | 89 | continue |
Sources/OrgApp/OrgPreferences.swift +5 −1
| @@ -54,11 +54,15 @@ public enum OrgPreferences { | |||
| 54 | public static func agendaShowAllDates(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaShowAllDates", defaults) } | 54 | public static func agendaShowAllDates(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaShowAllDates", defaults) } |
| 55 | public static func agendaSkipScheduledIfDone(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSkipScheduledIfDone", defaults) } | 55 | public static func agendaSkipScheduledIfDone(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSkipScheduledIfDone", defaults) } |
| 56 | public static func agendaSkipDeadlineIfDone(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSkipDeadlineIfDone", defaults) } | 56 | 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. | ||
| 58 | public static func agendaOptions(_ options: AgendaOptions, _ defaults: UserDefaults = .standard) -> AgendaOptions { | 60 | public static func agendaOptions(_ options: AgendaOptions, _ defaults: UserDefaults = .standard) -> AgendaOptions { |
| 59 | var options = options | 61 | var options = options |
| 60 | options.skipScheduledIfDone = agendaSkipScheduledIfDone(defaults) | 62 | options.skipScheduledIfDone = agendaSkipScheduledIfDone(defaults) |
| 61 | options.skipDeadlineIfDone = agendaSkipDeadlineIfDone(defaults) | 63 | options.skipDeadlineIfDone = agendaSkipDeadlineIfDone(defaults) |
| 64 | options.searchAlwaysBoolean = agendaSearchAlwaysBoolean(defaults) | ||
| 65 | options.searchForceFullWords = agendaSearchForceFullWords(defaults) | ||
| 62 | return options | 66 | return options |
| 63 | } | 67 | } |
| 64 | /// `calendar-events`: Calendar's events show in the agenda. | 68 | /// `calendar-events`: Calendar's events show in the agenda. |
Sources/OrgApp/UserConfig.swift +4
| @@ -147,6 +147,10 @@ public enum UserConfig { | |||
| 147 | comment: "leave done entries out of the agenda even on the day they are scheduled", legacy: ""), | 147 | comment: "leave done entries out of the agenda even on the day they are scheduled", legacy: ""), |
| 148 | Setting(section: "", key: "org-agenda-skip-deadline-if-done", defaultsKey: "agendaSkipDeadlineIfDone", kind: .bool, fallback: .bool(false), | 148 | Setting(section: "", key: "org-agenda-skip-deadline-if-done", defaultsKey: "agendaSkipDeadlineIfDone", kind: .bool, fallback: .bool(false), |
| 149 | comment: "leave done entries out of the agenda even on the day they are due", legacy: ""), | 149 | 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: ""), | ||
| 150 | Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), | 154 | Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), |
| 151 | comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"), | 155 | comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"), |
| 152 | Setting(section: "", key: "org-clock-idle-time", defaultsKey: "clockIdleTime", kind: .integer, fallback: .integer(0), | 156 | 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 { | |||
| 13 | case currentTime | 13 | case currentTime |
| 14 | case todo | 14 | case todo |
| 15 | case tagsMatch = "tagsmatch" | 15 | case tagsMatch = "tagsmatch" |
| 16 | case search | ||
| 16 | /// Log mode: a closed entry, a clock line, a state change. | 17 | /// Log mode: a closed entry, a clock line, a state change. |
| 17 | case closed, clock, state | 18 | case closed, clock, state |
| 18 | } | 19 | } |
| @@ -79,6 +80,10 @@ public struct AgendaOptions: Sendable, Equatable { | |||
| 79 | public var skipScheduledIfDone = false | 80 | public var skipScheduledIfDone = false |
| 80 | /// `org-agenda-skip-deadline-if-done`: done entries' deadlines are left out even on the day due. | 81 | /// `org-agenda-skip-deadline-if-done`: done entries' deadlines are left out even on the day due. |
| 81 | public var skipDeadlineIfDone = false | 82 | 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 | ||
| 82 | 87 | ||
| 83 | public init() {} | 88 | public init() {} |
| 84 | 89 | ||
Sources/OrgCore/Agenda/AgendaSearch.swift +185
| @@ -44,6 +44,185 @@ extension Agenda { | |||
| 44 | return sortByUrgency(items) | 44 | return sortByUrgency(items) |
| 45 | } | 45 | } |
| 46 | 46 | ||
| 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 | |||
| 47 | /// `(urgency-down category-keep)`, stably. | 226 | /// `(urgency-down category-keep)`, stably. |
| 48 | static func sortByUrgency(_ items: [AgendaItem]) -> [AgendaItem] { | 227 | static func sortByUrgency(_ items: [AgendaItem]) -> [AgendaItem] { |
| 49 | items.enumerated().sorted { a, b in | 228 | items.enumerated().sorted { a, b in |
| @@ -337,6 +516,12 @@ enum EmacsRegex { | |||
| 337 | case "|": out += "|" | 516 | case "|": out += "|" |
| 338 | case "{": out += "{" | 517 | case "{": out += "{" |
| 339 | case "}": out += "}" | 518 | 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 | ||
| 340 | case "<", ">": out += "\\b" | 525 | case "<", ">": out += "\\b" |
| 341 | case "`": out += "^" | 526 | case "`": out += "^" |
| 342 | case "'": out += "$" | 527 | case "'": out += "$" |
Sources/OrgCore/Agenda/AgendaSource.swift +6
| @@ -94,6 +94,10 @@ public struct AgendaSource: Sendable { | |||
| 94 | let progress: [Progress] | 94 | let progress: [Progress] |
| 95 | /// The file's TODO keywords, active and done. | 95 | /// The file's TODO keywords, active and done. |
| 96 | let keywords: [String] | 96 | 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 | ||
| 97 | let priorities: Priorities | 101 | let priorities: Priorities |
| 98 | 102 | ||
| 99 | public init(path: String, text: String, defaults: OrgSettings = .default) { | 103 | public init(path: String, text: String, defaults: OrgSettings = .default) { |
| @@ -104,6 +108,8 @@ public struct AgendaSource: Sendable { | |||
| 104 | let ns = text as NSString | 108 | let ns = text as NSString |
| 105 | let settings = tree.settings | 109 | let settings = tree.settings |
| 106 | keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) } | 110 | 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 | ||
| 107 | priorities = settings.priorities | 113 | priorities = settings.priorities |
| 108 | 114 | ||
| 109 | var stampsByHeading: [Int: [String]] = [:] | 115 | var stampsByHeading: [Int: [String]] = [:] |
Sources/OrgPresentation/AgendaRows.swift +1 −1
| @@ -134,7 +134,7 @@ public struct AgendaRow: Sendable, Equatable, Identifiable { | |||
| 134 | case .closed: (icon, untimed) = (.timestamp, "Closed") | 134 | case .closed: (icon, untimed) = (.timestamp, "Closed") |
| 135 | case .clock: (icon, untimed) = (.timestamp, "Clocked") | 135 | case .clock: (icon, untimed) = (.timestamp, "Clocked") |
| 136 | case .state: (icon, untimed) = (.timestamp, "State") | 136 | case .state: (icon, untimed) = (.timestamp, "State") |
| 137 | case .todo, .tagsMatch, .timeGrid, .currentTime: break | 137 | case .todo, .tagsMatch, .search, .timeGrid, .currentTime: break |
| 138 | } | 138 | } |
| 139 | if item.isDone { status = .done } | 139 | if item.isDone { status = .done } |
| 140 | self.id = [item.path ?? "", "\(item.headingOffset ?? -1)", "\(item.markerOffset ?? -1)", item.kind.rawValue, item.text].joined(separator: "\u{1f}") | 140 | 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 { | |||
| 16 | static let events = CalendarEvents() | 16 | static let events = CalendarEvents() |
| 17 | } | 17 | } |
| 18 | 18 | ||
| 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. |
| 20 | /// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: | 20 | /// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: |
| 21 | /// n/p (or the arrows) move between lines, f/b/. move the span, j picks a date, v d/v w/v m (or | 21 | /// n/p (or the arrows) move between lines, f/b/. move the span, j picks a date, v d/v w/v m (or |
| 22 | /// d/w) show a day, week or month, g refreshes, l shows the log (closed and clocked), / filters | 22 | /// 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 { | |||
| 80 | 80 | ||
| 81 | static let match = "Match…" | 81 | static let match = "Match…" |
| 82 | static let todoMatch = "TODO Match…" | 82 | static let todoMatch = "TODO Match…" |
| 83 | static let search = "Search…" | ||
| 84 | static let todoSearch = "TODO Search…" | ||
| 83 | 85 | ||
| 84 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?, clock: ClockModel) { | 86 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?, clock: ClockModel) { |
| 85 | self.workspace = workspace | 87 | self.workspace = workspace |
| @@ -143,6 +145,7 @@ struct AgendaView: View { | |||
| 143 | } | 145 | } |
| 144 | 146 | ||
| 145 | private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch } | 147 | private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch } |
| 148 | private var isSearch: Bool { viewName == Self.search || viewName == Self.todoSearch } | ||
| 146 | 149 | ||
| 147 | @Environment(\.orgTheme) private var theme | 150 | @Environment(\.orgTheme) private var theme |
| 148 | 151 | ||
| @@ -466,12 +469,14 @@ struct AgendaView: View { | |||
| 466 | Divider() | 469 | Divider() |
| 467 | Button(Self.match) { viewName = Self.match } | 470 | Button(Self.match) { viewName = Self.match } |
| 468 | Button(Self.todoMatch) { viewName = Self.todoMatch } | 471 | Button(Self.todoMatch) { viewName = Self.todoMatch } |
| 472 | Button(Self.search) { viewName = Self.search } | ||
| 473 | Button(Self.todoSearch) { viewName = Self.todoSearch } | ||
| 469 | } label: { | 474 | } label: { |
| 470 | Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon) | 475 | Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon) |
| 471 | } | 476 | } |
| 472 | .help("What the agenda shows") | 477 | .help("What the agenda shows") |
| 473 | if isMatch { | 478 | if isMatch || isSearch { |
| 474 | TextField("+work-boss|LEVEL>2/!TODO", text: $matchText) | 479 | TextField(isSearch ? "Phrase or +word -word {regexp}" : "+work-boss|LEVEL>2/!TODO", text: $matchText) |
| 475 | .frame(minWidth: 220) | 480 | .frame(minWidth: 220) |
| 476 | .onSubmit { applyView() } | 481 | .onSubmit { applyView() } |
| 477 | } | 482 | } |
| @@ -633,6 +638,7 @@ struct AgendaView: View { | |||
| 633 | switch agenda.mode { | 638 | switch agenda.mode { |
| 634 | case .todo(let keywords): "TODO items: \(keywords ?? "all")" | 639 | case .todo(let keywords): "TODO items: \(keywords ?? "all")" |
| 635 | case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)" | 640 | 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)" | ||
| 636 | case .agenda: "" | 642 | case .agenda: "" |
| 637 | } | 643 | } |
| 638 | } | 644 | } |
| @@ -667,6 +673,9 @@ struct AgendaView: View { | |||
| 667 | let match = matchText.trimmingCharacters(in: .whitespaces) | 673 | let match = matchText.trimmingCharacters(in: .whitespaces) |
| 668 | guard !match.isEmpty else { return } | 674 | guard !match.isEmpty else { return } |
| 669 | agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch) | 675 | 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) | ||
| 670 | default: | 679 | default: |
| 671 | if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode } | 680 | if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode } |
| 672 | } | 681 | } |
Sources/OrgstarMobile/AgendaScreen.swift +21 −11
| @@ -6,8 +6,8 @@ import OrgPresentation | |||
| 6 | import SwiftUI | 6 | import SwiftUI |
| 7 | 7 | ||
| 8 | /// The agenda over the workspace's agenda files: the day/week view, the TODO list, tag and | 8 | /// 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 | 9 | /// property matches, text search and the views in `views.toml`, with Org's filters and the |
| 10 | /// actions on each entry. | 10 | /// agenda's actions on each entry. |
| 11 | struct AgendaScreen: View { | 11 | struct AgendaScreen: View { |
| 12 | let workspace: WorkspaceModel | 12 | let workspace: WorkspaceModel |
| 13 | let session: DocumentSession | 13 | let session: DocumentSession |
| @@ -39,10 +39,10 @@ struct AgendaScreen: View { | |||
| 39 | @State private var done = 0 | 39 | @State private var done = 0 |
| 40 | @State private var moved = 0 | 40 | @State private var moved = 0 |
| 41 | 41 | ||
| 42 | /// A match typed for the tag and property view. | 42 | /// A match typed for the tag and property view, or a search string. |
| 43 | private enum Asking: Identifiable { | 43 | private enum Asking: Identifiable { |
| 44 | case match | 44 | case match, search |
| 45 | var id: Int { 0 } | 45 | var id: Self { self } |
| 46 | } | 46 | } |
| 47 | 47 | ||
| 48 | enum SpanRange: String, CaseIterable, Identifiable { | 48 | enum SpanRange: String, CaseIterable, Identifiable { |
| @@ -119,6 +119,7 @@ struct AgendaScreen: View { | |||
| 119 | Button(definition.name) { show(definition) } | 119 | Button(definition.name) { show(definition) } |
| 120 | } | 120 | } |
| 121 | Button("Tags and Properties…") { asking = .match } | 121 | Button("Tags and Properties…") { asking = .match } |
| 122 | Button("Search…") { asking = .search } | ||
| 122 | } label: { | 123 | } label: { |
| 123 | Label("Views", systemImage: "list.bullet.rectangle") | 124 | Label("Views", systemImage: "list.bullet.rectangle") |
| 124 | } | 125 | } |
| @@ -139,12 +140,21 @@ struct AgendaScreen: View { | |||
| 139 | if let picked, let item = row.item { reschedule(row, item, to: Days.absolute(picked, calendar: .current)) } | 140 | if let picked, let item = row.item { reschedule(row, item, to: Days.absolute(picked, calendar: .current)) } |
| 140 | } | 141 | } |
| 141 | } | 142 | } |
| 142 | .sheet(item: $asking) { _ in | 143 | .sheet(item: $asking) { kind in |
| 143 | PromptSheet(prompt: Prompt(key: "match", message: "Match: tags and properties, as +work-home or TODO=\"WAIT\"")) { answer in | 144 | if kind == .search { |
| 144 | asking = nil | 145 | PromptSheet(prompt: Prompt(key: "search", message: "Search: a phrase, or +word -word {regexp}; * for headlines, ! for TODO entries")) { answer in |
| 145 | guard let answer, !answer.isEmpty else { return } | 146 | asking = nil |
| 146 | agenda.mode = .match(answer, todoOnly: false) | 147 | guard let answer, answer.contains(where: { !$0.isWhitespace }) else { return } |
| 147 | viewName = answer | 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 | } | ||
| 148 | } | 158 | } |
| 149 | } | 159 | } |
| 150 | .sheet(item: $prompt) { request in | 160 | .sheet(item: $prompt) { request in |
Tests/OrgCoreTests/AgendaSearchTests.swift added +115
| @@ -0,0 +1,115 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// `org-search-view` against Emacs. | ||
| 6 | struct 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 | |||
| 689 | | =C-c C-w= | Refile. | | 689 | | =C-c C-w= | Refile. | |
| 690 | | =C-c $=, =C-c C-x C-s= | Archive. | | 690 | | =C-c $=, =C-c C-x C-s= | Archive. | |
| 691 | 691 | ||
| 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 | |||
| 692 | See [[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]]. | 694 | See [[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]]. |
| 693 | 695 | ||
| 694 | * iOS and iPadOS | 696 | * 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 | |||
| 339 | 339 | ||
| 340 | * Text search | 340 | * Text search |
| 341 | 341 | ||
| 342 | The agenda has no =org-search-view= (=s=). To search the text of your files, use Search Notes (=⇧⌘F=). | 342 | Choose 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 | |||
| 344 | By 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 | |||
| 354 | Phrases 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 | |||
| 364 | Two 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 | |||
| 366 | The 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=). | ||
| 343 | 367 | ||
| 344 | * Views | 368 | * Views |
| 345 | 369 | ||
| 346 | The 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. | 370 | The 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. |
| 347 | 371 | ||
| 348 | =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: | 372 | =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: |
| 349 | 373 | ||
| @@ -368,20 +392,25 @@ match = "+work-boss" | |||
| 368 | name = "Next at work" | 392 | name = "Next at work" |
| 369 | type = "todo-match" | 393 | type = "todo-match" |
| 370 | match = "+work/NEXT" | 394 | match = "+work/NEXT" |
| 395 | |||
| 396 | [[view]] | ||
| 397 | name = "Invoices" | ||
| 398 | type = "search" | ||
| 399 | match = "+invoice -paid" | ||
| 371 | #+END_SRC | 400 | #+END_SRC |
| 372 | 401 | ||
| 373 | | Key | Applies to | Meaning | | 402 | | Key | Applies to | Meaning | |
| 374 | |------------+--------------------+----------------------------------------------------------------------------| | 403 | |------------+--------------------+----------------------------------------------------------------------------| |
| 375 | | =name= | all, required | The name in the view menu | | 404 | | =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= | |
| 377 | | =span= | =agenda= | Days shown; the setting when omitted | | 406 | | =span= | =agenda= | Days shown; the setting when omitted | |
| 378 | | =start= | =agenda= | First day as an integer number of days from today (=-3=, =0=); the setting when omitted | | 407 | | =start= | =agenda= | First day as an integer number of days from today (=-3=, =0=); the setting when omitted | |
| 379 | | =keywords= | =todo= | Keywords separated by the vertical bar; all open TODOs when omitted | | 408 | | =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 | |
| 381 | 410 | ||
| 382 | Note that =start= is a plain integer here, while =org-agenda-start-day= in =config.toml= is a string such as ="-3d"=. | 411 | Note that =start= is a plain integer here, while =org-agenda-start-day= in =config.toml= is a string such as ="-3d"=. |
| 383 | 412 | ||
| 384 | Not 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. | 413 | Not 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. |
| 385 | 414 | ||
| 386 | * Filters | 415 | * Filters |
| 387 | 416 | ||
| @@ -529,7 +558,7 @@ The board is not available on iOS. | |||
| 529 | 558 | ||
| 530 | The 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: | 559 | The 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: |
| 531 | 560 | ||
| 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. |
| 533 | - 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. | 562 | - 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. |
| 534 | - The title names the days shown: =Thu 8 Oct=, =Week of 5 Oct=, =October 2026=, or =5 Oct – 14 Oct=. | 563 | - The title names the days shown: =Thu 8 Oct=, =Week of 5 Oct=, =October 2026=, or =5 Oct – 14 Oct=. |
| 535 | - 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. | 564 | - 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 | |||
| 152 | | =ignored-folders= | | | 152 | | =ignored-folders= | | |
| 153 | | =org-agenda-skip-scheduled-if-done= | | | 153 | | =org-agenda-skip-scheduled-if-done= | | |
| 154 | | =org-agenda-skip-deadline-if-done= | | | 154 | | =org-agenda-skip-deadline-if-done= | | |
| 155 | | =org-agenda-search-view-always-boolean= | | | ||
| 156 | | =org-agenda-search-view-force-full-words= | | | ||
| 155 | | =[org-agenda-prefix-format]= | | | 157 | | =[org-agenda-prefix-format]= | | |
| 156 | | =theme-file= | | | 158 | | =theme-file= | | |
| 157 | 159 | ||
| @@ -193,6 +195,8 @@ A key you remove from the file goes back to its default. A key that isn't in the | |||
| 193 | | =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=. | | 195 | | =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=. | |
| 194 | | =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=. | | 196 | | =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=. | |
| 195 | | =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=. | | 197 | | =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=. | | ||
| 196 | | =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=. | | 200 | | =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=. | |
| 197 | | =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=. | | 201 | | =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=. | |
| 198 | | =org-clock-history-length= | integer | =5= | How many recently clocked entries Orgstar remembers. Mirrors =org-clock-history-length=. | | 202 | | =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. | |||
| 154 | 154 | ||
| 155 | The 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. | 155 | The 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. |
| 156 | 156 | ||
| 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=). |
| 158 | - The bar at the bottom :: Earlier, Filter, Today, Go to Date, the span (Day, Week, Month or the configured span) and Later. | 158 | - The bar at the bottom :: Earlier, Filter, Today, Go to Date, the span (Day, Week, Month or the configured span) and Later. |
| 159 | - 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. | 159 | - 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. |
| 160 | - + on a day's header :: captures an entry scheduled on that day (see [[file:08-capture.org][Capture]]). | 160 | - + 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: | |||
| 331 | - =org-log-done=, =org-log-reschedule=, =org-log-redeadline=, =org-log-into-drawer= | 331 | - =org-log-done=, =org-log-reschedule=, =org-log-redeadline=, =org-log-into-drawer= |
| 332 | - =org-use-speed-commands=, with a hardware keyboard | 332 | - =org-use-speed-commands=, with a hardware keyboard |
| 333 | - =electric-pair-mode=, =spell-check= | 333 | - =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= |
| 335 | - =calendar-events=, =calendar-event-calendars= | 335 | - =calendar-events=, =calendar-event-calendars= |
| 336 | - =reminders=, =appt-message-warning-time= | 336 | - =reminders=, =appt-message-warning-time= |
| 337 | - =org-clock-history-length= | 337 | - =org-clock-history-length= |
docs/manual/guide/15-alongside-emacs.org +1
| @@ -246,3 +246,4 @@ Other: | |||
| 246 | - =#+SETUPFILE= URLs aren't fetched. | 246 | - =#+SETUPFILE= URLs aren't fetched. |
| 247 | - Most =#+STARTUP= options for footnotes, entities, LaTeX previews, numbering and odd levels are accepted and ignored; see [[file:13-configuration.org][Configuration]]. | 247 | - Most =#+STARTUP= options for footnotes, entities, LaTeX previews, numbering and odd levels are accepted and ignored; see [[file:13-configuration.org][Configuration]]. |
| 248 | - =display-line-numbers-type= has no relative or visual mode. | 248 | - =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. | ||