Agenda, date and TODO fixes from the manual review !159

merged merged by cmc on 2026-10-08 03:59 UTC · krz/orgstar:agenda-date-fixes into main

24 files changed, +163 −64

Layout: unified · split

Sources/OrgCore/Agenda/AgendaFilter.swift +13 −4
@@ -13,10 +13,19 @@ public struct AgendaFilter: Sendable, Equatable {
13 13
14 public var isEmpty: Bool { categories.isEmpty && tags.isEmpty && efforts.isEmpty && regexps.isEmpty } 14 public var isEmpty: Bool { categories.isEmpty && tags.isEmpty && efforts.isEmpty && regexps.isEmpty }
15 15
16 /// The filter as `/` shows it for editing. 16 /// The filter as `/` shows it for editing; `parse` reads it back to the same filter.
17 /// Categories holding `-` are quoted.
17 public var description: String { 18 public var description: String {
18 let regexp = regexps.first.map { "/" + $0.dropFirst().replacingOccurrences(of: "^\\+", with: "", options: .regularExpression) + "/" } ?? "" 19 func unsigned(_ term: String) -> (sign: String, body: String) {
19 return categories.joined() + tags.joined() + (efforts.first.map { String($0.dropFirst()) } ?? "") + regexp 20 (term.hasPrefix("-") ? "-" : "", String(term.dropFirst()))
21 }
22 let cats = categories.map { term -> String in
23 let (sign, body) = unsigned(term)
24 return (sign.isEmpty ? "+" : sign) + (body.contains("-") ? "\"\(body)\"" : body)
25 }
26 let efforts = efforts.map { unsigned($0).sign + unsigned($0).body }
27 let regexps = regexps.map { unsigned($0).sign + "/" + unsigned($0).body + "/" }
28 return (cats + tags + efforts + regexps).joined()
20 } 29 }
21 30
22 /// Reads `+cat-tag<0:10-/regexp/`: a word is a tag if a shown line has it, else a category 31 /// Reads `+cat-tag<0:10-/regexp/`: a word is a tag if a shown line has it, else a category
@@ -43,7 +52,7 @@ public struct AgendaFilter: Sendable, Equatable {
43 let word = ns.substring(with: m.range(at: 3)).replacingOccurrences(of: "~~~", with: "-") 52 let word = ns.substring(with: m.range(at: 3)).replacingOccurrences(of: "~~~", with: "-")
44 if shownTags.contains(word) { 53 if shownTags.contains(word) {
45 append(sign + word, to: &result.tags) 54 append(sign + word, to: &result.tags)
46 } else if shownCategories.contains(word) { 55 } else if shownCategories.contains(word.replacingOccurrences(of: "^\"(.*)\"$", with: "$1", options: .regularExpression)) {
47 append(sign + word.replacingOccurrences(of: "^\"(.*)\"$", with: "$1", options: .regularExpression), to: &result.categories) 56 append(sign + word.replacingOccurrences(of: "^\"(.*)\"$", with: "$1", options: .regularExpression), to: &result.categories)
48 } else { 57 } else {
49 ignored.append(sign + word) 58 ignored.append(sign + word)
Sources/OrgCore/Agenda/AgendaSearch.swift +3 −1
@@ -231,7 +231,9 @@ public struct TagsMatcher: Sendable {
231 let a = Self.seconds(actual, now: now, calendar: calendar) 231 let a = Self.seconds(actual, now: now, calendar: calendar)
232 let b = Self.matcherTime(time, now: now, calendar: calendar) 232 let b = Self.matcherTime(time, now: now, calendar: calendar)
233 guard a > 0, b > 0 else { return false } 233 guard a > 0, b > 0 else { return false }
234 // `org-time<>` compares with `=`. 234 // `org-time<>` in Org 9.8.7 (org-macs.el) is `(\= a b)`, which reads as `=`:
235 // `DEADLINE<>"<2026-10-07>"` keeps the entries due that day.
236 // AgendaTests.listsMatchEmacs checks this against Emacs.
235 return op == "<>" || op == "!=" || op == "/=" ? a == b : Self.compare(op, a, b) 237 return op == "<>" || op == "!=" || op == "/=" ? a == b : Self.compare(op, a, b)
236 } 238 }
237 } 239 }
Sources/OrgCore/Agenda/PrefixFormat.swift +4 −3
@@ -1,8 +1,9 @@
1import Foundation 1import Foundation
2 2
3/// `org-agenda-prefix-format` for one view, compiled as `org-compile-prefix-format` does: 3/// `org-agenda-prefix-format` for one view, compiled as `org-compile-prefix-format` does:
4/// `%c` category, `%t` time, `%l` level, `%s` extra, `%e` effort, `%b` breadcrumbs, `%i` icon 4/// `%c` category, `%t` time, `%l` level, `%s` extra, `%e` effort, `%b` breadcrumbs, `%T` last
5/// (none in Orgstar), each with an optional `?` (empty when the value is), a `%-12`-style 5/// tag (Org's regexp lists only lower-case keys but matches `T` under `case-fold-search`),
6/// `%i` icon (none in Orgstar), each with an optional `?` (empty when the value is), a `%-12`-style
6/// width with an optional `.N` limit on categories, and a punctuation character added after a 7/// width with an optional `.N` limit on categories, and a punctuation character added after a
7/// non-empty value. `%(…)` forms run Lisp; they are empty here. 8/// non-empty value. `%(…)` forms run Lisp; they are empty here.
8public struct PrefixFormat: Sendable, Equatable { 9public struct PrefixFormat: Sendable, Equatable {
@@ -18,7 +19,7 @@ public struct PrefixFormat: Sendable, Equatable {
18 let categoryMaxLength: Int? 19 let categoryMaxLength: Int?
19 20
20 public init(_ format: String) { 21 public init(_ format: String) {
21 let regex = try! NSRegularExpression(pattern: "%(\\?)?([-+]?[0-9.]*)([ .;,:!?=|/<>]?)([cltseib]|\\(.+?\\))") 22 let regex = try! NSRegularExpression(pattern: "%(\\?)?([-+]?[0-9.]*)([ .;,:!?=|/<>]?)([cltseibT]|\\(.+?\\))")
22 let ns = format as NSString 23 let ns = format as NSString
23 var pieces: [Piece] = [] 24 var pieces: [Piece] = []
24 var hasTime = false 25 var hasTime = false
Sources/OrgCore/Commands/ClockReport.swift +3 −3
@@ -70,9 +70,9 @@ public enum ClockReport {
70 return String(format: "%04d-W%02d", Days.date(thursday).year, Days.isoWeek(day)) 70 return String(format: "%04d-W%02d", Days.date(thursday).year, Days.isoWeek(day))
71 } 71 }
72 72
73 /// The time report your Doom configuration writes (`tr/generate-report`): minutes per 73 /// An Org table of clocked time per date and heading between `start` and `end`
74 /// date and heading between `start` and `end` (inclusive, `YYYY-MM-DD`), with a total 74 /// (inclusive, `YYYY-MM-DD`), with a total after each ISO week. The heading column is
75 /// after each ISO week. 75 /// titled Code, for headings named by charge code.
76 public static func timeReport(_ entries: [Entry], start: String? = nil, end: String? = nil) -> String { 76 public static func timeReport(_ entries: [Entry], start: String? = nil, end: String? = nil) -> String {
77 var totals: [String: [String: Int]] = [:] 77 var totals: [String: [String: Int]] = [:]
78 for entry in entries { 78 for entry in entries {
Sources/OrgCore/Commands/CtrlCCtrlC.swift +4 −4
@@ -2,8 +2,8 @@ import Foundation
2 2
3// C-c C-c (`org-ctrl-c-ctrl-c`, Org 9.8.7) for the contexts no more specific binding takes: 3// C-c C-c (`org-ctrl-c-ctrl-c`, Org 9.8.7) for the contexts no more specific binding takes:
4// headings set tags, clock lines get their duration again, timestamps their weekday, cookies 4// headings set tags, clock lines get their duration again, timestamps their weekday, cookies
5// update, items without a checkbox have their list repaired, setup keywords refresh, and 5// update, items toggle their checkbox, or without one have their list repaired, setup keywords
6// elsewhere there's nothing to do. 6// refresh, and elsewhere there's nothing to do.
7 7
8/// `org-ctrl-c-ctrl-c`. 8/// `org-ctrl-c-ctrl-c`.
9public struct CtrlCCtrlC: OrgCommand { 9public struct CtrlCCtrlC: OrgCommand {
@@ -93,12 +93,12 @@ extension EmacsBuffer {
93 match[2] != nil, let first = match[1] else { return } 93 match[2] != nil, let first = match[1] else { return }
94 saveExcursion { 94 saveExcursion {
95 point = first.lowerBound 95 point = first.lowerBound
96 try? changeTimestamp(0, what: .day) 96 try? changeTimestamp(0, what: .day, updateClock: false)
97 } 97 }
98 guard let again = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"), let second = again[3] else { return } 98 guard let again = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"), let second = again[3] else { return }
99 saveExcursion { 99 saveExcursion {
100 point = second.lowerBound 100 point = second.lowerBound
101 try? changeTimestamp(0, what: .day) 101 try? changeTimestamp(0, what: .day, updateClock: false)
102 } 102 }
103 guard let final = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"), 103 guard let final = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"),
104 let start = final[1], let end = final[3] else { return } 104 let start = final[1], let end = final[3] else { return }
Sources/OrgCore/Commands/Footnotes.swift +1 −1
@@ -513,7 +513,7 @@ public struct FootnoteAction: OrgCommand {
513 return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, effects: result.effects + [.message(message)])) 513 return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, effects: result.effects + [.message(message)]))
514 } 514 }
515 515
516 static let definitionMessage = "Edit definition and go back with `C-c &' or, if unique, with `C-c C-c'." 516 static let definitionMessage = "Edit definition and go back with `C-c C-c' or `C-c C-x f' on its label."
517 517
518 private func move(_ context: EditContext, to offset: Int, message: String? = nil) -> CommandStep { 518 private func move(_ context: EditContext, to offset: Int, message: String? = nil) -> CommandStep {
519 .commit(EditResult(baseRevision: context.revision, edits: [], selection: [offset..<offset], effects: message.map { [.message($0)] } ?? [])) 519 .commit(EditResult(baseRevision: context.revision, edits: [], selection: [offset..<offset], effects: message.map { [.message($0)] } ?? []))
Sources/OrgCore/Commands/HeadingCommands.swift +7 −4
@@ -186,8 +186,10 @@ public struct TodoCycle: OrgCommand {
186 entryHeading(at: context.caret, in: context.tree) != nil 186 entryHeading(at: context.caret, in: context.tree) != nil
187 } 187 }
188 188
189 /// No keyword, then each keyword of its sequence in order, then no keyword again. 189 /// No keyword, then each keyword of its sequence in order, then no keyword again. A
190 static func next(after current: String?, in settings: OrgSettings) -> String? { 190 /// `#+TYP_TODO` keyword goes to the sequence's first done keyword unless `repeated` (the
191 /// previous command was also this one, `this-command` eq `last-command` in `org-todo`).
192 static func next(after current: String?, in settings: OrgSettings, repeated: Bool) -> String? {
191 guard let current else { 193 guard let current else {
192 guard let first = settings.todoSequences.first else { return nil } 194 guard let first = settings.todoSequences.first else { return nil }
193 return (first.active + first.done).first?.name 195 return (first.active + first.done).first?.name
@@ -195,7 +197,8 @@ public struct TodoCycle: OrgCommand {
195 for sequence in settings.todoSequences { 197 for sequence in settings.todoSequences {
196 let names = (sequence.active + sequence.done).map(\.name) 198 let names = (sequence.active + sequence.done).map(\.name)
197 if let index = names.firstIndex(of: current) { 199 if let index = names.firstIndex(of: current) {
198 return index + 1 < names.count ? names[index + 1] : nil 200 guard index + 1 < names.count else { return nil }
201 return sequence.kind == .type && !repeated ? sequence.done.first?.name : names[index + 1]
199 } 202 }
200 } 203 }
201 return nil 204 return nil
@@ -229,7 +232,7 @@ public struct TodoCycle: OrgCommand {
229 return .failed("Before first headline") 232 return .failed("Before first headline")
230 } 233 }
231 let line = HeadingLine(heading) 234 let line = HeadingLine(heading)
232 let next = Self.next(after: line.todo?.text, in: context.tree.settings) 235 let next = Self.next(after: line.todo?.text, in: context.tree.settings, repeated: context.lastCommand == id)
233 return line.commit(context) { buffer in 236 return line.commit(context) { buffer in
234 let text = buffer.string as NSString 237 let text = buffer.string as NSString
235 let regionStart = line.stars.upperBound - line.start 238 let regionStart = line.stars.upperBound - line.start
Sources/OrgCore/Commands/InlineImages.swift +3 −5
@@ -34,11 +34,9 @@ public enum InlineImages {
34 /// `#+STARTUP: inlineimages` or `noinlineimages`, if the file says. 34 /// `#+STARTUP: inlineimages` or `noinlineimages`, if the file says.
35 public static func startup(_ tree: OrgTree) -> Bool? { 35 public static func startup(_ tree: OrgTree) -> Bool? {
36 var shown: Bool? 36 var shown: Bool?
37 for m in (tree.settings.setupKeywords + tree.root.text).matches(of: #/(?mi)^[ \t]*#\+STARTUP:(.*)$/#) { 37 for word in tree.settings.startup {
38 for word in m.1.split(whereSeparator: \.isWhitespace) { 38 if word.lowercased() == "inlineimages" { shown = true }
39 if word.lowercased() == "inlineimages" { shown = true } 39 if word.lowercased() == "noinlineimages" { shown = false }
40 if word.lowercased() == "noinlineimages" { shown = false }
41 }
42 } 40 }
43 return shown 41 return shown
44 } 42 }
Sources/OrgCore/Commands/Repeat.swift +3 −1
@@ -81,7 +81,9 @@ extension EmacsBuffer {
81 if let kind = parts.1 { 81 if let kind = parts.1 {
82 let time = Self.parseTimeString(stamp).map(date)! 82 let time = Self.parseTimeString(stamp).map(date)!
83 if kind == "." { 83 if kind == "." {
84 if what != "h" { 84 if what == "h" {
85 try changeTimestamp(Int((now.timeIntervalSince(time) / 60).rounded(.down)), what: .minute)
86 } else {
85 let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: time), to: today).day! 87 let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: time), to: today).day!
86 try changeTimestamp(days, what: .day) 88 try changeTimestamp(days, what: .day)
87 } 89 }
Sources/OrgCore/Commands/Timestamps.swift +8 −2
@@ -149,8 +149,9 @@ extension EmacsBuffer {
149 149
150 enum TimestampUnit { case minute, hour, day, month, year } 150 enum TimestampUnit { case minute, hour, day, month, year }
151 151
152 /// `org-timestamp-change`. `what` nil changes the part at point. 152 /// `org-timestamp-change`. `what` nil changes the part at point. On a CLOCK line the
153 func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false) throws { 153 /// duration is then updated, unless `updateClock` is false.
154 func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false, updateClock: Bool = true) throws {
154 let origin = point 155 let origin = point
155 guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") } 156 guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") }
156 if what == nil, category == .bracket { 157 if what == nil, category == .bracket {
@@ -215,6 +216,11 @@ extension EmacsBuffer {
215 case .after: point = after[0]!.upperBound 216 case .after: point = after[0]!.upperBound
216 default: point = origin 217 default: point = origin
217 } 218 }
219 if updateClock {
220 let before = point
221 clockUpdateTime()
222 if point >= before { point = before }
223 }
218 } 224 }
219 225
220 /// `org-toggle-timestamp-type`. 226 /// `org-toggle-timestamp-type`.
Sources/OrgCore/Commands/TodoLogging.swift +1 −9
@@ -82,15 +82,7 @@ extension EmacsBuffer {
82 if enter != nil || leave != nil, log.states[keyword.name] == nil { log.states[keyword.name] = (enter, leave) } 82 if enter != nil || leave != nil, log.states[keyword.name] == nil { log.states[keyword.name] = (enter, leave) }
83 } 83 }
84 } 84 }
85 for (key, value) in SetupFile.keywords(settings.setupKeywords) where key == "STARTUP" { 85 for word in settings.startup { log.apply(startup: word) }
86 for word in value.split(whereSeparator: \.isWhitespace) { log.apply(startup: String(word)) }
87 }
88 let startup = try! NSRegularExpression(pattern: "^[ \\t]*#\\+STARTUP:(.*)$", options: [.anchorsMatchLines, .caseInsensitive])
89 for match in startup.matches(in: string, range: NSRange(location: 0, length: length)) {
90 for word in substring(match.range(at: 1).location, NSMaxRange(match.range(at: 1))).split(whereSeparator: \.isWhitespace) {
91 log.apply(startup: String(word))
92 }
93 }
94 if let logging = inheritedProperty("LOGGING")?.value { 86 if let logging = inheritedProperty("LOGGING")?.value {
95 // `org-local-logging`. 87 // `org-local-logging`.
96 log.done = nil 88 log.done = nil
Sources/OrgCore/Commands/ToggleCommands.swift +1 −1
@@ -326,7 +326,7 @@ extension EmacsBuffer {
326 } 326 }
327} 327}
328 328
329/// C-c @ and M-h on a heading: `org-mark-subtree`, the subtree selected from its heading. 329/// C-c @ (and the speed key @) on a heading: `org-mark-subtree`, the subtree selected from its heading.
330public struct MarkSubtree: OrgCommand { 330public struct MarkSubtree: OrgCommand {
331 public init() {} 331 public init() {}
332 public var id: String { "org.subtree.mark" } 332 public var id: String { "org.subtree.mark" }
Sources/OrgCore/Parser/Incremental.swift +1 −1
@@ -58,7 +58,7 @@ private struct ReparseContext {
58 /// Change in UTF-16 length. 58 /// Change in UTF-16 length.
59 var delta: Int { edit.replacement.utf16.count - edit.range.count } 59 var delta: Int { edit.replacement.utf16.count - edit.range.count }
60 60
61 static let settingsKeys: Set<String> = ["TODO", "SEQ_TODO", "TYP_TODO", "PRIORITIES"] 61 static let settingsKeys: Set<String> = ["TODO", "SEQ_TODO", "TYP_TODO", "PRIORITIES", "STARTUP"]
62 62
63 /// Elements that parse the same in isolation as in place, as long as their lines keep 63 /// Elements that parse the same in isolation as in place, as long as their lines keep
64 /// their classes. 64 /// their classes.
Sources/OrgCore/Parser/Settings.swift +7 −1
@@ -50,6 +50,8 @@ public struct OrgSettings: Sendable, Equatable {
50 /// Keyword lines from the file's `#+SETUPFILE`s, as `#+KEY: value` lines: they count as 50 /// Keyword lines from the file's `#+SETUPFILE`s, as `#+KEY: value` lines: they count as
51 /// the file's own, before them. 51 /// the file's own, before them.
52 public var setupKeywords = "" 52 public var setupKeywords = ""
53 /// Words of the `#+STARTUP` keyword lines outside blocks, setup files' first.
54 public var startup: [String] = []
53 55
54 public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) { 56 public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) {
55 self.todoSequences = todoSequences 57 self.todoSequences = todoSequences
@@ -82,12 +84,13 @@ public struct OrgSettings: Sendable, Equatable {
82} 84}
83 85
84enum SettingsScanner { 86enum SettingsScanner {
85 /// Reads `#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO` and `#+PRIORITIES` outside blocks. Any TODO 87 /// Reads `#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO`, `#+PRIORITIES` and `#+STARTUP` outside blocks. Any TODO
86 /// line replaces the default sequences; as in org, `TYP_TODO` sequences come first, then 88 /// line replaces the default sequences; as in org, `TYP_TODO` sequences come first, then
87 /// `TODO`, then `SEQ_TODO`. 89 /// `TODO`, then `SEQ_TODO`.
88 static func scan(lines: [RawLine], info: [ClassifiedLine], blockEnds: [Int: Int], defaults: OrgSettings) -> OrgSettings { 90 static func scan(lines: [RawLine], info: [ClassifiedLine], blockEnds: [Int: Int], defaults: OrgSettings) -> OrgSettings {
89 var types: [TodoSequence] = [], todos: [TodoSequence] = [], seqs: [TodoSequence] = [] 91 var types: [TodoSequence] = [], todos: [TodoSequence] = [], seqs: [TodoSequence] = []
90 var priorities = defaults.priorities 92 var priorities = defaults.priorities
93 var startup: [String] = []
91 func apply(_ key: String, _ value: Substring) { 94 func apply(_ key: String, _ value: Substring) {
92 switch key { 95 switch key {
93 case "TODO": 96 case "TODO":
@@ -96,6 +99,8 @@ enum SettingsScanner {
96 if let s = todoSequence(value, kind: .sequence) { seqs.append(s) } 99 if let s = todoSequence(value, kind: .sequence) { seqs.append(s) }
97 case "TYP_TODO": 100 case "TYP_TODO":
98 if let s = todoSequence(value, kind: .type) { types.append(s) } 101 if let s = todoSequence(value, kind: .type) { types.append(s) }
102 case "STARTUP":
103 startup += value.split(whereSeparator: \.isWhitespace).map(String.init)
99 case "PRIORITIES": 104 case "PRIORITIES":
100 let words = value.split(whereSeparator: \.isWhitespace) 105 let words = value.split(whereSeparator: \.isWhitespace)
101 if words.count == 3 { 106 if words.count == 3 {
@@ -129,6 +134,7 @@ enum SettingsScanner {
129 radioTargets: radio.sorted { $0.count > $1.count }) 134 radioTargets: radio.sorted { $0.count > $1.count })
130 settings.listAllowAlphabetical = defaults.listAllowAlphabetical 135 settings.listAllowAlphabetical = defaults.listAllowAlphabetical
131 settings.setupKeywords = defaults.setupKeywords 136 settings.setupKeywords = defaults.setupKeywords
137 settings.startup = startup
132 return settings 138 return settings
133 } 139 }
134 140
Sources/OrgPresentation/Folding.swift +3 −22
@@ -217,28 +217,9 @@ extension Presentation {
217 return result 217 return result
218 } 218 }
219 219
220 /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside 220 /// Words of every `#+STARTUP:` keyword line (`OrgSettings.startup`); lines inside blocks
221 /// blocks and paragraphs is never read as a setting. 221 /// are not settings.
222 public static func startupOptions(_ tree: OrgTree) -> [String] { 222 public static func startupOptions(_ tree: OrgTree) -> [String] {
223 var options = tree.settings.setupKeywords.split(separator: "\n").filter { $0.uppercased().hasPrefix("#+STARTUP:") } 223 tree.settings.startup
224 .flatMap { $0.dropFirst("#+STARTUP:".count).split(whereSeparator: \.isWhitespace).map(String.init) }
225 func visit(_ node: GreenNode) {
226 for case .node(let child) in node.children {
227 switch child.kind {
228 case .keyword:
229 let text = child.text
230 if text.uppercased().drop(while: { $0 == " " || $0 == "\t" }).hasPrefix("#+STARTUP:"),
231 let colon = text.firstIndex(of: ":") {
232 options += text[text.index(after: colon)...].split(whereSeparator: \.isWhitespace).map(String.init)
233 }
234 case .document, .zerothSection, .section, .drawer:
235 visit(child)
236 default:
237 break
238 }
239 }
240 }
241 visit(tree.green)
242 return options
243 } 224 }
244} 225}
Sources/Orgstar/ClockViews.swift +1 −2
@@ -62,8 +62,7 @@ final class ClockStatusItem: NSObject {
62 } 62 }
63} 63}
64 64
65/// Time per day and heading, as your `tr/generate-report` writes it, for chosen files and 65/// Clocked time per day and heading (`ClockReport.timeReport`) for chosen files and dates.
66/// dates.
67struct ClockReportView: View { 66struct ClockReportView: View {
68 let workspace: WorkspaceModel 67 let workspace: WorkspaceModel
69 @State private var start = Calendar.current.date(byAdding: .day, value: -6, to: Date()) ?? Date() 68 @State private var start = Calendar.current.date(byAdding: .day, value: -6, to: Date()) ?? Date()
Tests/OrgCoreTests/AgendaTests.swift +31
@@ -287,6 +287,9 @@ struct AgendaTests {
287 "CATEGORY=\"cat\"", "DUE<\"<2026-10-15>\"", "DUE>\"<today>\"", "SCHEDULED<\"<today>\"", "TIMESTAMP=\"<2026-10-03 Sat>\"", 287 "CATEGORY=\"cat\"", "DUE<\"<2026-10-15>\"", "DUE>\"<today>\"", "SCHEDULED<\"<today>\"", "TIMESTAMP=\"<2026-10-03 Sat>\"",
288 "ITEM={lph}", "work/TODO", "/!", "+work/!TODO|WAIT", "EFFORT>\"1:00\"", "ALLTAGS={proj}", "TAGS=\":work:urgent:\"", 288 "ITEM={lph}", "work/TODO", "/!", "+work/!TODO|WAIT", "EFFORT>\"1:00\"", "ALLTAGS={proj}", "TAGS=\":work:urgent:\"",
289 "x@y", "/-TODO", "/!{^[HW]}", "work&urgent", "SIZE>=3+SIZE<=3", "LEVEL<>1", 289 "x@y", "/-TODO", "/!{^[HW]}", "work&urgent", "SIZE>=3+SIZE<=3", "LEVEL<>1",
290 // `org-time<>` is `=` in Org 9.8.7.
291 "DUE<>\"<2026-10-10>\"", "DUE<>\"<2026-10-11>\"", "SCHEDULED<>\"<2026-09-30>\"", "TIMESTAMP!=\"<2026-10-03 Sat>\"",
292 "DEADLINE/=\"<2026-10-05>\"", "DEADLINE<>\"<2026-10-07>\"",
290 ] 293 ]
291 294
292 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) 295 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
@@ -437,6 +440,24 @@ struct AgendaPrefixFormatTests {
437 } 440 }
438 } 441 }
439 442
443 /// `%T` is the last tag: Org's field regexp lists only lower-case keys, but matches it
444 /// under `case-fold-search`.
445 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
446 func lastTagMatchesEmacs() throws {
447 let today = Days.absolute(year: 2026, month: 10, day: 5)
448 let formats = ["agenda": " %-10:c%-8T%?-12t% s", "todo": " %?-6T|", "tags": "%c %-6:T", "search": " %i %-12:c"]
449 try AgendaOracle.compare(files: [("p.org", Self.file)], start: today, span: 2, today: today, prefix: formats)
450 var options = AgendaOptions()
451 options.prefixFormat = formats
452 let lisp = AgendaOracle.prefixLisp(formats)
453 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-todo-list))", today: today) {
454 Agenda.todoList($0, options: options)
455 }
456 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-tags-view nil \"work|proj\"))", today: today) {
457 Agenda.tagsMatch($0, match: "work|proj", options: options)
458 }
459 }
460
440 @Test func compilesFields() { 461 @Test func compilesFields() {
441 let format = PrefixFormat(" %-8.6c|%?-4t%e") 462 let format = PrefixFormat(" %-8.6c|%?-4t%e")
442 #expect(format.render(["c": "category", "e": "1:00"]) == " categ |1:00") 463 #expect(format.render(["c": "category", "e": "1:00"]) == " categ |1:00")
@@ -536,4 +557,14 @@ struct AgendaFilterTests {
536 let kept = filter.parse("++urgent", tags: ["urgent"], categories: []).filter 557 let kept = filter.parse("++urgent", tags: ["urgent"], categories: []).filter
537 #expect(kept.tags == ["-phone", "+urgent"]) 558 #expect(kept.tags == ["-phone", "+urgent"])
538 } 559 }
560
561 @Test func descriptionRoundTrips() {
562 let tags: Set<String> = ["phone", "urgent"], categories: Set<String> = ["work", "my-cat"]
563 for text in ["-/call/", "-work+phone-<0:10>1:00/a/-/b/", "\"my-cat\"-urgent", "-\"my-cat\""] {
564 let filter = AgendaFilter().parse(text, tags: tags, categories: categories).filter
565 #expect(AgendaFilter().parse(filter.description, tags: tags, categories: categories).filter == filter, "\(text)")
566 }
567 let negative = AgendaFilter().parse("-/call/", tags: [], categories: []).filter
568 #expect(negative.description == "-/call/")
569 }
539} 570}
Tests/OrgCoreTests/ClockTableTests.swift +1
@@ -31,6 +31,7 @@ struct ClockTableTests {
31 ":block today :maxlevel 3", ":block 2026-09", ":tstart \"<2026-09-08>\" :tend \"<2026-09-10>\"", 31 ":block today :maxlevel 3", ":block 2026-09", ":tstart \"<2026-09-08>\" :tend \"<2026-09-10>\"",
32 ":link t :maxlevel 3", ":narrow 20! :maxlevel 3", ":tags t :level t :maxlevel 3", ":properties (\"Effort\") :formula %", 32 ":link t :maxlevel 3", ":narrow 20! :maxlevel 3", ":tags t :level t :maxlevel 3", ":properties (\"Effort\") :formula %",
33 ":indent nil :maxlevel 3", ":block yesterday", ":block 2026-W36 :maxlevel 3", ":fileskip0 t :block 2025", 33 ":indent nil :maxlevel 3", ":block yesterday", ":block 2026-W36 :maxlevel 3", ":fileskip0 t :block 2025",
34 ":maxlevel nil",
34 ] 35 ]
35 36
36 static func ours(_ text: String, caret: Int, command: any OrgCommand, answers: [String: String] = [:]) -> String { 37 static func ours(_ text: String, caret: Int, command: any OrgCommand, answers: [String: String] = [:]) -> String {
Tests/OrgCoreTests/HeadingCommandTests.swift +29
@@ -13,6 +13,35 @@ struct HeadingCommandTests {
13 #expect(runCommand(cycle, "text\n", caret: 1).failure != nil) 13 #expect(runCommand(cycle, "text\n", caret: 1).failure != nil)
14 } 14 }
15 15
16 /// `#+TYP_TODO`: a first C-c C-t goes to the done keyword, a repeated one to the next type.
17 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
18 func typeKeywordsMatchEmacs() throws {
19 let texts = [
20 "#+TYP_TODO: Fred Sara Lucy | DONE\n* Fred a\n", "#+TYP_TODO: Fred Sara Lucy | DONE\n* Sara a\n",
21 "#+TYP_TODO: Fred Sara Lucy | DONE\n* Lucy a\n", "#+TYP_TODO: Fred Sara Lucy | DONE\n* DONE a\n",
22 "#+TYP_TODO: Fred Sara | DONE GONE\n* Sara a\n", "#+TYP_TODO: Fred Sara | DONE GONE\n* DONE a\n",
23 "#+TYP_TODO: Fred Sara | DONE\n* a\n", "#+TODO: TODO | DONE\n#+TYP_TODO: Fred Sara | OK\n* Fred a\n",
24 ]
25 var cases: [EmacsOracle.Case] = []
26 var ours: [String] = []
27 for text in texts {
28 let caret = (text as NSString).range(of: "\n*").location + 2
29 for repeated in [false, true] {
30 let last = repeated ? "'org-todo" : "'other"
31 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: "(let ((this-command 'org-todo) (last-command \(last))) (org-todo))"))
32 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], lastCommand: repeated ? TodoCycle().id : "org.other")
33 guard case .commit(let result) = TodoCycle().run(in: context) else { ours.append("failed"); continue }
34 ours.append(result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) })
35 }
36 }
37 let emacs = try EmacsOracle.run(cases)
38 #expect(emacs.count == ours.count)
39 for (mine, emacs) in zip(ours, emacs) {
40 #expect(mine == emacs.text)
41 }
42 #expect(ours[0].hasSuffix("* DONE a\n") && ours[1].hasSuffix("* Sara a\n"))
43 }
44
16 @Test func caretMovesToTheTitleWhenBeforeIt() { 45 @Test func caretMovesToTheTitleWhenBeforeIt() {
17 #expect(runCommand(TodoCycle(), "* a\n", caret: 2).caret == 7) 46 #expect(runCommand(TodoCycle(), "* a\n", caret: 2).caret == 7)
18 #expect(runCommand(TodoCycle(), "* TODO abc\nbody\n", caret: 13).caret == 13) 47 #expect(runCommand(TodoCycle(), "* TODO abc\nbody\n", caret: 13).caret == 13)
Tests/OrgCoreTests/InlineImagesTests.swift +1
@@ -12,5 +12,6 @@ struct InlineImagesTests {
12 #expect(InlineImages.startup(OrgParser.parse(text as String)) == true) 12 #expect(InlineImages.startup(OrgParser.parse(text as String)) == true)
13 #expect(InlineImages.startup(OrgParser.parse("#+STARTUP: overview noinlineimages\n")) == false) 13 #expect(InlineImages.startup(OrgParser.parse("#+STARTUP: overview noinlineimages\n")) == false)
14 #expect(InlineImages.startup(OrgParser.parse("* x\n")) == nil) 14 #expect(InlineImages.startup(OrgParser.parse("* x\n")) == nil)
15 #expect(InlineImages.startup(OrgParser.parse("#+begin_src org\n#+STARTUP: inlineimages\n#+end_src\n")) == nil)
15 } 16 }
16} 17}
Tests/OrgCoreTests/ReadDateTests.swift +2
@@ -11,11 +11,13 @@ func frozenClock(_ seconds: Int, _ body: String) -> String {
11 (otl (symbol-function 'time-less-p)) 11 (otl (symbol-function 'time-less-p))
12 (ots (symbol-function 'time-subtract)) 12 (ots (symbol-function 'time-subtract))
13 (ota (symbol-function 'time-add)) 13 (ota (symbol-function 'time-add))
14 (oflt (symbol-function 'float-time))
14 ((symbol-function 'current-time) (lambda () frozen)) 15 ((symbol-function 'current-time) (lambda () frozen))
15 ((symbol-function 'org-today) (lambda () (time-to-days frozen))) 16 ((symbol-function 'org-today) (lambda () (time-to-days frozen)))
16 ((symbol-function 'time-less-p) (lambda (a b) (funcall otl (or a frozen) (or b frozen)))) 17 ((symbol-function 'time-less-p) (lambda (a b) (funcall otl (or a frozen) (or b frozen))))
17 ((symbol-function 'time-subtract) (lambda (a b) (funcall ots (or a frozen) (or b frozen)))) 18 ((symbol-function 'time-subtract) (lambda (a b) (funcall ots (or a frozen) (or b frozen))))
18 ((symbol-function 'time-add) (lambda (a b) (funcall ota (or a frozen) (or b frozen)))) 19 ((symbol-function 'time-add) (lambda (a b) (funcall ota (or a frozen) (or b frozen))))
20 ((symbol-function 'float-time) (lambda (&optional time) (funcall oflt (or time frozen))))
19 ((symbol-function 'decode-time) (lambda (&optional time zone form) (funcall odt (or time frozen) zone form))) 21 ((symbol-function 'decode-time) (lambda (&optional time zone form) (funcall odt (or time frozen) zone form)))
20 ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall oft f (or time frozen) zone)))) 22 ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall oft f (or time frozen) zone))))
21 \(body)) 23 \(body))
Tests/OrgCoreTests/TimestampCommandTests.swift +34
@@ -100,6 +100,37 @@ struct TimestampCommandTests {
100 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") 100 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
101 } 101 }
102 102
103 /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`).
104 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
105 func clockShiftsMatchEmacs() throws {
106 let text = "* a\n:LOGBOOK:\nCLOCK: [2026-09-01 Tue 10:00]--[2026-09-01 Tue 11:30] => 1:30\n CLOCK: [2026-09-02 Wed 23:58]--[2026-09-03 Thu 00:02]\nCLOCK: [2026-09-04 Fri 09:00]\n:END:\n"
107 let commands: [(any OrgCommand, String)] = [
108 (ShiftTimestamp(up: true), "(org-timestamp-up)"), (ShiftTimestamp(up: false), "(org-timestamp-down)"),
109 (ShiftTimestampDay(later: true), "(org-timestamp-up-day)"), (ShiftTimestampDay(later: false), "(org-timestamp-down-day)"),
110 ]
111 var cases: [EmacsOracle.Case] = []
112 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
113 for caret in EmacsOracle.positions(text) where EmacsBuffer(text, point: caret).atTimestamp() != nil {
114 for (command, form) in commands {
115 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: frozenClock(1_789_000_000, form)))
116 let result = run(command, text, caret: caret)
117 ours.append(("\(command.id) at \(caret)", result.text, result.caret, result.failure != nil))
118 }
119 }
120 var mismatches = 0
121 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
122 let failed = !emacs.error.isEmpty
123 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
124 guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
125 mismatches += 1
126 if mismatches <= 12 {
127 Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
128 }
129 }
130 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
131 #expect(run(ShiftTimestamp(up: true), text, caret: (text as NSString).range(of: "11:30").location + 4).text.contains("11:35] => 1:35\n"))
132 }
133
103 static let repeating = [ 134 static let repeating = [
104 "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n", 135 "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n",
105 "* TODO a\nDEADLINE: <2026-09-01 Tue .+1d>\n", 136 "* TODO a\nDEADLINE: <2026-09-01 Tue .+1d>\n",
@@ -111,6 +142,9 @@ struct TimestampCommandTests {
111 "* P [0/1]\n** TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n* Q\n", 142 "* P [0/1]\n** TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n* Q\n",
112 "* TODO no repeat\nSCHEDULED: <2026-09-01 Tue>\n", 143 "* TODO no repeat\nSCHEDULED: <2026-09-01 Tue>\n",
113 "* TODO hourly\nSCHEDULED: <2026-09-10 Thu 00:00 +1h>\n", 144 "* TODO hourly\nSCHEDULED: <2026-09-10 Thu 00:00 +1h>\n",
145 "* TODO from now\nSCHEDULED: <2026-09-08 Tue 10:00 .+2h>\n",
146 "* TODO from now\n<2026-09-10 Thu 03:15-04:00 .+1h>\n",
147 "* TODO catch up\nSCHEDULED: <2026-09-09 Wed 23:00 ++2h>\n",
114 ] 148 ]
115 149
116 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) 150 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
Tests/OrgCoreTests/TodoLoggingTests.swift +1
@@ -9,6 +9,7 @@ struct TodoLoggingTests {
9 "* TODO Task\n:LOGBOOK:\n- State \"TODO\" from [2026-01-01 Thu 10:00]\n:END:\n", 9 "* TODO Task\n:LOGBOOK:\n- State \"TODO\" from [2026-01-01 Thu 10:00]\n:END:\n",
10 "* DONE Task\nCLOSED: [2026-01-01 Thu 10:00]\n", 10 "* DONE Task\nCLOSED: [2026-01-01 Thu 10:00]\n",
11 "#+STARTUP: lognotedone logdrawer\n* TODO Task\n", 11 "#+STARTUP: lognotedone logdrawer\n* TODO Task\n",
12 "#+begin_example\n#+STARTUP: lognotedone logdrawer\n#+end_example\n* TODO Task\n",
12 "* TODO Task\n:PROPERTIES:\n:LOGGING: DONE(!) logrepeat\n:LOG_INTO_DRAWER: NOTES\n:END:\n", 13 "* TODO Task\n:PROPERTIES:\n:LOGGING: DONE(!) logrepeat\n:LOG_INTO_DRAWER: NOTES\n:END:\n",
13 "* TODO Task\nSCHEDULED: <2026-10-05 Mon +1w>\n", 14 "* TODO Task\nSCHEDULED: <2026-10-05 Mon +1w>\n",
14 "* TODO Task\n- item one\n", 15 "* TODO Task\n- item one\n",
Tests/OrgPresentationTests/PresentationTests.swift +1
@@ -128,6 +128,7 @@ struct CyclingTests {
128 let state = Presentation.startupFoldState(levels) 128 let state = Presentation.startupFoldState(levels)
129 #expect(state?.folds.count == 1 && state?.bodyFolds.count == 2) 129 #expect(state?.folds.count == 1 && state?.bodyFolds.count == 2)
130 #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty) 130 #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty)
131 #expect(Presentation.startupOptions(OrgParser.parse("#+STARTUP: content\n* a\n:LOGBOOK:\n#+startup: hideblocks\n:END:\n")) == ["content", "hideblocks"])
131 } 132 }
132} 133}
133 134