krz/orgstar

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

Commit 8c221170c5

8c221170c5ed1f01bcb7cabccac77177aa399d9a

parent: eeaa3c1d16

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 01:23 UTC

Log TODO state changes

- org-log-done (time, note): CLOSED when an entry becomes done, removed
  when it becomes a TODO again; a closing note with `note`.
- Keyword flags `!` and `@` (TODO(t!) WAIT(w@/!)) log state changes,
  asking for the note in the echo area.
- org-log-into-drawer, #+STARTUP log options, LOGGING and
  LOG_INTO_DRAWER properties.
- Repeating entries log through the same path (org-log-repeat).
- New config keys org-log-done and org-log-into-drawer, read by the
  Emacs import.
- Board asks for notes too.

Oracle tests against Org 9.8.7.

Layout: unified · split

Sources/OrgApp/EmacsImport.swift +9
@@ -457,6 +457,15 @@ public struct EmacsImport: Sendable {
457 } else { 457 } else {
458 add(name, .bool(truthy)) 458 add(name, .bool(truthy))
459 } 459 }
460 case "org-log-done":
461 switch value.symbol {
462 case "nil": add(name, .string("nil"))
463 case "time", "t": add(name, .string("time"))
464 case "note": add(name, .string("note"))
465 default: note("unexpected value \(value.description)")
466 }
467 case "org-log-into-drawer":
468 if let s = value.string { add(name, .string(s)) } else { add(name, .string(truthy ? "LOGBOOK" : "")) }
460 case "display-line-numbers-type": 469 case "display-line-numbers-type":
461 add(name, .bool(truthy), truthy && value.symbol != "t" ? "display-line-numbers-type = true (Orgstar numbers lines absolutely)" : nil) 470 add(name, .bool(truthy), truthy && value.symbol != "t" ? "display-line-numbers-type = true (Orgstar numbers lines absolutely)" : nil)
462 case "org-agenda-span": 471 case "org-agenda-span":
Sources/OrgApp/UserConfig.swift +4
@@ -74,6 +74,10 @@ public enum UserConfig {
74 comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"), 74 comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"),
75 Setting(section: "", key: "org-todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"), 75 Setting(section: "", key: "org-todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"),
76 comment: "one sequence, in #+TODO syntax, for files without #+TODO; read at launch", legacy: "editor.todo-keywords"), 76 comment: "one sequence, in #+TODO syntax, for files without #+TODO; read at launch", legacy: "editor.todo-keywords"),
77 Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
78 fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""),
79 Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""),
80 comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""),
77 Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true), 81 Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true),
78 comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"), 82 comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"),
79 Setting(section: "", key: "org-agenda-span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10), 83 Setting(section: "", key: "org-agenda-span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10),
Sources/OrgCore/Commands/Command.swift +8 −1
@@ -13,6 +13,10 @@ public struct EditingOptions: Sendable, Equatable, Codable {
13 public var listAllowAlphabetical: Bool 13 public var listAllowAlphabetical: Bool
14 /// `org-log-repeat` `time`: completing a repeating entry records LAST_REPEAT and a state note. 14 /// `org-log-repeat` `time`: completing a repeating entry records LAST_REPEAT and a state note.
15 public var logRepeat: Bool 15 public var logRepeat: Bool
16 /// `org-log-done`: CLOSED when an entry becomes done, and a note with `.note`.
17 public var logDone: LogHow?
18 /// `org-log-into-drawer`: the drawer state notes go in, or nil for none.
19 public var logIntoDrawer: String?
16 /// `fill-column` for M-q. 20 /// `fill-column` for M-q.
17 public var fillColumn: Int 21 public var fillColumn: Int
18 /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers. 22 /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers.
@@ -25,8 +29,11 @@ public struct EditingOptions: Sendable, Equatable, Codable {
25 public init( 29 public init(
26 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, 30 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true,
27 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, 31 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70,
28 hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false 32 hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false,
33 logDone: LogHow? = nil, logIntoDrawer: String? = nil
29 ) { 34 ) {
35 self.logDone = logDone
36 self.logIntoDrawer = logIntoDrawer
30 self.tagsColumn = tagsColumn 37 self.tagsColumn = tagsColumn
31 self.insertHeadingRespectContent = insertHeadingRespectContent 38 self.insertHeadingRespectContent = insertHeadingRespectContent
32 self.metaReturnMaySplitLine = metaReturnMaySplitLine 39 self.metaReturnMaySplitLine = metaReturnMaySplitLine
Sources/OrgCore/Commands/Dwim.swift +1 −1
@@ -34,8 +34,8 @@ public struct OrgDwim: OrgCommand {
34 buffer.setTodoKeyword(sequence.active.first?.name) 34 buffer.setTodoKeyword(sequence.active.first?.name)
35 } else if let done = sequence.done.first?.name { 35 } else if let done = sequence.done.first?.name {
36 buffer.setTodoKeyword(done) 36 buffer.setTodoKeyword(done)
37 if let (marker, note) = try buffer.autoRepeat(doneWord: done, previous: current) { buffer.storeNote(marker, note) }
38 } 37 }
38 try buffer.afterTodoChange(previous: current, answers: context.answers)
39 } 39 }
40 } 40 }
41} 41}
Sources/OrgCore/Commands/EmacsBuffer.swift +7
@@ -21,6 +21,11 @@ final class EmacsBuffer {
21 let message: String 21 let message: String
22 } 22 }
23 23
24 /// The command needs an answer first.
25 struct Ask: Error {
26 let prompt: Prompt
27 }
28
24 private(set) var text: NSMutableString 29 private(set) var text: NSMutableString
25 var point: Int 30 var point: Int
26 private var markers: [Marker] = [] 31 private var markers: [Marker] = []
@@ -470,6 +475,8 @@ func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void)
470 try body(buffer) 475 try body(buffer)
471 } catch let error as EmacsBuffer.UserError { 476 } catch let error as EmacsBuffer.UserError {
472 return .failed(error.message) 477 return .failed(error.message)
478 } catch let ask as EmacsBuffer.Ask {
479 return .prompt(ask.prompt)
473 } catch { 480 } catch {
474 return .failed("\(error)") 481 return .failed("\(error)")
475 } 482 }
Sources/OrgCore/Commands/HeadingCommands.swift +1 −3
@@ -211,9 +211,7 @@ public struct TodoCycle: OrgCommand {
211 return followUp(context, cycle(in: context)) { buffer in 211 return followUp(context, cycle(in: context)) { buffer in
212 try buffer.saveExcursion { 212 try buffer.saveExcursion {
213 buffer.updateParentTodoStatistics() 213 buffer.updateParentTodoStatistics()
214 let settings = context.tree.settings 214 try buffer.afterTodoChange(previous: previous, answers: context.answers)
215 guard let new = buffer.todoKeyword, settings.isDone(new), !(previous.map(settings.isDone) ?? false) else { return }
216 if let (marker, note) = try buffer.autoRepeat(doneWord: new, previous: previous) { buffer.storeNote(marker, note) }
217 } 215 }
218 } 216 }
219 } 217 }
Sources/OrgCore/Commands/Properties.swift +1 −5
@@ -37,11 +37,7 @@ public struct SetTodo: OrgCommand {
37 try buffer.saveExcursion { 37 try buffer.saveExcursion {
38 let previous = buffer.todoKeyword 38 let previous = buffer.todoKeyword
39 buffer.setTodoKeyword(keyword) 39 buffer.setTodoKeyword(keyword)
40 guard previous != keyword else { return } 40 try buffer.afterTodoChange(previous: previous, answers: context.answers)
41 if let keyword, settings.isDone(keyword), !(previous.map(settings.isDone) ?? false),
42 let (marker, note) = try buffer.autoRepeat(doneWord: keyword, previous: previous) {
43 buffer.storeNote(marker, note)
44 }
45 } 41 }
46 } 42 }
47 } 43 }
Sources/OrgCore/Commands/Repeat.swift +39 −41
@@ -1,8 +1,7 @@
1import Foundation 1import Foundation
2 2
3// Repeating entries (`org-auto-repeat-maybe`, Org 9.8.7) and the state note org logs for them 3// Repeating entries (`org-auto-repeat-maybe`, Org 9.8.7) and where state notes go
4// (`org-add-log-setup`, `org-store-log-note`, `org-log-beginning`), with `org-log-repeat` 4// (`org-log-beginning`), with `org-todo-repeat-to-state` nil.
5// `time`, `org-log-into-drawer` nil, `org-todo-repeat-to-state` nil.
6 5
7extension EmacsBuffer { 6extension EmacsBuffer {
8 static let repeatPattern = "<[0-9]{4}-[0-9][0-9]-[0-9][0-9] [^>\\n]*?([.+]?\\+[0-9]+[hdwmy](/[0-9]+[hdwmy])?)" 7 static let repeatPattern = "<[0-9]{4}-[0-9][0-9]-[0-9][0-9] [^>\\n]*?([.+]?\\+[0-9]+[hdwmy](/[0-9]+[hdwmy])?)"
@@ -48,9 +47,9 @@ extension EmacsBuffer {
48 } 47 }
49 } 48 }
50 49
51 /// `org-auto-repeat-maybe` after `doneWord` replaced `previous`. Returns the note to log, if 50 /// `org-auto-repeat-maybe` after `doneWord` replaced `previous`. Returns the state note the
52 /// any, with the marker it belongs at. 51 /// repeat logs, if any.
53 func autoRepeat(doneWord: String, previous: String?) throws -> (Marker, String)? { 52 func autoRepeat(doneWord: String, previous: String?, log: LogSettings) throws -> LogSetup? {
54 guard let cookie = repeatCookie(), (Int(cookie.drop { !$0.isNumber }.prefix { $0.isNumber }) ?? 0) != 0 else { return nil } 53 guard let cookie = repeatCookie(), (Int(cookie.drop { !$0.isNumber }.prefix { $0.isNumber }) ?? 0) != 0 else { return nil }
55 let end = marker(entryEnd) 54 let end = marker(entryEnd)
56 defer { release(end) } 55 defer { release(end) }
@@ -63,8 +62,7 @@ extension EmacsBuffer {
63 try backToHeading() 62 try backToHeading()
64 try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed]) 63 try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed])
65 let stamp = formatTimestamp(calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now), withTime: true, inactive: true) 64 let stamp = formatTimestamp(calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now), withTime: true, inactive: true)
66 if options.logRepeat { entryPut("LAST_REPEAT", stamp) } 65 if log.repeatHow != nil { entryPut("LAST_REPEAT", stamp) }
67 let note = marker(point)
68 if let scheduled = planningValue(.scheduled), ("<" + scheduled + ">").range(of: Self.repeatPattern, options: .regularExpression) == nil { 66 if let scheduled = planningValue(.scheduled), ("<" + scheduled + ">").range(of: Self.repeatPattern, options: .regularExpression) == nil {
69 saveExcursion { removePlanning(.scheduled) } 67 saveExcursion { removePlanning(.scheduled) }
70 } 68 }
@@ -102,36 +100,7 @@ extension EmacsBuffer {
102 } 100 }
103 try saveExcursion { try changeTimestamp(n, what: unit, suppressDelay: true) } 101 try saveExcursion { try changeTimestamp(n, what: unit, suppressDelay: true) }
104 } 102 }
105 guard options.logRepeat else { 103 return log.repeatHow.map { LogSetup(purpose: .state, state: doneWord, previous: previous, how: $0, drawer: log.drawer) }
106 release(note)
107 return nil
108 }
109 func quoted(_ text: String?) -> String { text.map { "\"\($0)\"" } ?? "" }
110 let line = "State " + quoted(doneWord).padding(toLength: max(12, quoted(doneWord).count), withPad: " ", startingAt: 0)
111 + " from " + quoted(previous).padding(toLength: max(12, quoted(previous).count), withPad: " ", startingAt: 0)
112 + " " + stamp
113 return (note, line)
114 }
115
116 /// `org-store-log-note` for a one-line note at `marker`.
117 func storeNote(_ marker: Marker, _ text: String) {
118 saveExcursion {
119 point = marker.position
120 release(marker)
121 point = logBeginning()
122 if !bolp {
123 insert("\n")
124 } else if lookingAt("[ \\t]*\\S") != nil {
125 saveExcursion { insert("\n") }
126 }
127 if let item = inItem() {
128 let structure = saveExcursion { point = item; return listStruct() }
129 indentLineTo(structure.ind(structure.top))
130 } else {
131 indentLineTo(0)
132 }
133 insert("- " + text)
134 }
135 } 104 }
136 105
137 /// `org-end-of-meta-data` without FULL. 106 /// `org-end-of-meta-data` without FULL.
@@ -145,10 +114,11 @@ extension EmacsBuffer {
145 } 114 }
146 } 115 }
147 116
148 /// `org-log-beginning` without a log drawer, with `org-log-states-order-reversed` t: the 117 /// `org-log-beginning` with `org-log-states-order-reversed` t: the newest note comes first.
149 /// newest note comes first. 118 /// With `drawer`, notes go at the top of that drawer, which `create` adds when missing.
150 func logBeginning() -> Int { 119 func logBeginning(drawer: String? = nil, create: Bool = false) -> Int {
151 saveExcursion { 120 saveExcursion {
121 if let drawer { return drawerLogBeginning(drawer, create: create) }
152 endOfMetaData() 122 endOfMetaData()
153 let endpos = point 123 let endpos = point
154 skipForward([32, 9, 10]) 124 skipForward([32, 9, 10])
@@ -159,4 +129,32 @@ extension EmacsBuffer {
159 return end < length ? end + 1 : length 129 return end < length ? end + 1 : length
160 } 130 }
161 } 131 }
132
133 private func drawerLogBeginning(_ drawer: String, create: Bool) -> Int {
134 endOfMetaData()
135 let start = point
136 let end = atHeading ? point : saveExcursion { outlineNextHeading(); return point }
137 let name = NSRegularExpression.escapedPattern(for: drawer)
138 while let match = searchForward("^[ \\t]*:" + name + ":[ \\t]*$", bound: end, caseInsensitive: true) {
139 let closed = saveExcursion { searchForward("^[ \\t]*:END:[ \\t]*$", bound: end, caseInsensitive: true) != nil }
140 if closed {
141 point = match[0]!.upperBound
142 return bolp ? point : lineEnd(point) < length ? lineEnd(point) + 1 : length
143 }
144 }
145 point = start
146 guard create else { return bolp ? point : lineEnd(point) < length ? lineEnd(point) + 1 : length }
147 if !eobp, atHeading { point -= 1 }
148 let atBlankLine = lookingAt("^[ \\t]*$") != nil
149 let atNonBlankLineStart = bolp && !eolp
150 if !bolp { insert("\n") }
151 insert(":" + drawer + ":")
152 insert("\n:END:")
153 if atBlankLine || atNonBlankLineStart {
154 insert("\n")
155 point -= 1
156 }
157 point = lineStart(point) - 1
158 return lineStart(point) == point ? point : lineEnd(point) + 1
159 }
162} 160}
Sources/OrgCore/Commands/TodoLogging.swift added +190
@@ -0,0 +1,190 @@
1import Foundation
2
3// Logging TODO state changes as `org-todo` does (Org 9.8.7): CLOSED with `org-log-done`, state
4// notes for keywords with `!` and `@`, `#+STARTUP` log options, the LOGGING and
5// LOG_INTO_DRAWER properties, `org-log-into-drawer`, `org-log-states-order-reversed` t and the
6// default `org-log-note-headings`.
7
8/// What `org-log-done` and the `!`/`@` keyword flags ask for.
9public enum LogHow: String, Sendable, Equatable, Codable {
10 case time, note
11}
12
13/// The logging in effect for one entry.
14struct LogSettings {
15 var done: LogHow?
16 var repeatHow: LogHow?
17 var drawer: String?
18 /// `org-todo-log-states`: keyword → (on entering, on leaving).
19 var states: [String: (enter: LogHow?, leave: LogHow?)] = [:]
20
21 static func how(_ flag: String?) -> LogHow? {
22 switch flag {
23 case "!": .time
24 case "@": .note
25 default: nil
26 }
27 }
28
29 /// Applies one word of `#+STARTUP` or of a LOGGING property.
30 mutating func apply(startup word: String) {
31 switch word {
32 case "logdone": done = .time
33 case "lognotedone": done = .note
34 case "nologdone": done = nil
35 case "logrepeat": repeatHow = .time
36 case "lognoterepeat": repeatHow = .note
37 case "nologrepeat": repeatHow = nil
38 case "logdrawer": drawer = "LOGBOOK"
39 case "nologdrawer": drawer = nil
40 default: break
41 }
42 }
43
44 /// `org-extract-log-state-settings`: `WAIT(w@/!)`.
45 static func stateSetting(_ word: String) -> (String, LogHow?, LogHow?)? {
46 guard let match = word.firstMatch(of: /^(.*?)(?:\(([^!@\/])?([!@])?(?:\/([!@]))?\))?$/) else { return nil }
47 let enter = how(match.3.map(String.init))
48 let leave = how(match.4.map(String.init))
49 guard enter != nil || leave != nil else { return nil }
50 return (String(match.1), enter, leave)
51 }
52}
53
54/// A note `org-add-log-setup` asked for, stored when the command ends.
55struct LogSetup {
56 enum Purpose { case done, state }
57 var purpose: Purpose
58 var state: String?
59 var previous: String?
60 var how: LogHow
61 var drawer: String?
62}
63
64extension EmacsBuffer {
65 /// The logging settings for the entry at point.
66 func logSettings() -> LogSettings {
67 var log = LogSettings(done: options.logDone, repeatHow: options.logRepeat ? .time : nil, drawer: options.logIntoDrawer)
68 for sequence in settings.todoSequences {
69 for keyword in sequence.active + sequence.done {
70 let enter = LogSettings.how(keyword.logOnEnter)
71 let leave = LogSettings.how(keyword.logOnLeave)
72 if enter != nil || leave != nil, log.states[keyword.name] == nil { log.states[keyword.name] = (enter, leave) }
73 }
74 }
75 let startup = try! NSRegularExpression(pattern: "^[ \\t]*#\\+STARTUP:(.*)$", options: [.anchorsMatchLines, .caseInsensitive])
76 for match in startup.matches(in: string, range: NSRange(location: 0, length: length)) {
77 for word in substring(match.range(at: 1).location, NSMaxRange(match.range(at: 1))).split(whereSeparator: \.isWhitespace) {
78 log.apply(startup: String(word))
79 }
80 }
81 if let logging = inheritedProperty("LOGGING")?.value {
82 // `org-local-logging`.
83 log.done = nil
84 log.repeatHow = nil
85 log.states = [:]
86 for word in logging.split(whereSeparator: \.isWhitespace).map(String.init) {
87 if ["logdone", "lognotedone", "nologdone", "logrepeat", "lognoterepeat", "nologrepeat"].contains(word) {
88 log.apply(startup: word)
89 } else if let (keyword, enter, leave) = LogSettings.stateSetting(word), keywordsInOrder.contains(keyword) {
90 log.states[keyword] = (enter, leave)
91 }
92 }
93 }
94 if let drawer = inheritedProperty("LOG_INTO_DRAWER")?.value {
95 log.drawer = drawer == "nil" ? nil : drawer == "t" ? "LOGBOOK" : drawer
96 }
97 return log
98 }
99
100 /// `org-todo`'s logging after the keyword went from `previous` to `state`: CLOSED is added
101 /// or removed, and the note to take is returned.
102 func todoLogging(previous: String?, state: String?, log: LogSettings) throws -> LogSetup? {
103 guard log.done != nil || !log.states.isEmpty else { return nil }
104 let isDone = { (k: String?) in k.map(self.settings.isDone) ?? false }
105 let isNotDone = { (k: String?) in k.map { self.keywordsInOrder.contains($0) && !self.settings.isDone($0) } ?? false }
106 let nowDone = isDone(state) && !isDone(previous)
107 let dolog = state.flatMap { log.states[$0]?.enter } ?? previous.flatMap { log.states[$0]?.leave }
108 if state == nil || (isNotDone(state) && !isNotDone(previous)) {
109 try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed])
110 }
111 var setup: LogSetup?
112 if nowDone, let done = log.done {
113 let time = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now)
114 try addPlanningInfo(.closed, time: time, withTime: true, endTime: nil, remove: [])
115 if dolog == nil, done == .note { setup = LogSetup(purpose: .done, state: state, previous: previous, how: .note, drawer: log.drawer) }
116 }
117 if let state, let dolog { setup = LogSetup(purpose: .state, state: state, previous: previous, how: dolog, drawer: log.drawer) }
118 return setup
119 }
120
121 /// The question a note needs.
122 static func notePrompt(_ setup: LogSetup) -> Prompt {
123 let about = switch setup.purpose {
124 case .done: "closed todo item"
125 case .state: "state change from \"\(setup.previous ?? "")\" to \"\(setup.state ?? "")\""
126 }
127 return Prompt(key: "note", message: "Note for \(about):")
128 }
129
130 /// `org-store-log-note` for `setup` with the note's text, in the entry at point.
131 func storeLog(_ setup: LogSetup, text: String) {
132 func quoted(_ text: String?) -> String { text.map { "\"\($0)\"" } ?? "" }
133 let stamp = formatTimestamp(calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now), withTime: true, inactive: true)
134 var heading = switch setup.purpose {
135 case .done: "CLOSING NOTE " + stamp
136 case .state:
137 "State " + quoted(setup.state).padding(toLength: max(12, quoted(setup.state).count), withPad: " ", startingAt: 0)
138 + " from " + quoted(setup.previous).padding(toLength: max(12, quoted(setup.previous).count), withPad: " ", startingAt: 0)
139 + " " + stamp
140 }
141 var trimmed = text
142 while let range = trimmed.range(of: "^# .*\\n[ \\t\\n]*", options: .regularExpression) { trimmed.removeSubrange(range) }
143 trimmed = trimmed.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
144 let lines = trimmed.isEmpty ? [] : trimmed.components(separatedBy: "\n")
145 if !lines.isEmpty { heading += " \\\\" }
146 saveExcursion {
147 point = logBeginning(drawer: setup.drawer, create: true)
148 if !bolp {
149 insert("\n")
150 } else if lookingAt("[ \\t]*\\S") != nil {
151 saveExcursion { insert("\n") }
152 }
153 if let item = inItem() {
154 let structure = saveExcursion { point = item; return listStruct() }
155 indentLineTo(structure.ind(structure.top))
156 } else {
157 indentLineTo(0)
158 }
159 let bodyColumn = currentColumn + 2
160 insert("- " + heading)
161 for line in lines {
162 insert("\n")
163 if !line.isEmpty {
164 indentLineTo(bodyColumn)
165 insert(line)
166 }
167 }
168 }
169 }
170
171 /// What `org-todo` does after the keyword went from `previous` to the current one: CLOSED,
172 /// the repeat of a repeating entry, and the log note, asking for its text with `answers`.
173 func afterTodoChange(previous: String?, answers: [String: String]) throws {
174 let state = todoKeyword
175 guard state != previous else { return }
176 let log = logSettings()
177 var setup = try saveExcursion { try todoLogging(previous: previous, state: state, log: log) }
178 if let state, settings.isDone(state), !(previous.map(settings.isDone) ?? false),
179 let repeatSetup = try autoRepeat(doneWord: state, previous: previous, log: log) {
180 if setup == nil { setup = repeatSetup } else if repeatSetup.how == .note { setup?.how = .note }
181 }
182 guard let setup else { return }
183 if setup.how == .note {
184 guard let note = answers["note"] else { throw Ask(prompt: Self.notePrompt(setup)) }
185 storeLog(setup, text: note)
186 } else {
187 storeLog(setup, text: "")
188 }
189 }
190}
Sources/Orgstar/BoardView.swift +9 −2
@@ -190,9 +190,16 @@ struct BoardView: View {
190 190
191 // MARK: - Editing 191 // MARK: - Editing
192 192
193 private func perform(_ command: any OrgCommand, on entry: BoardEntry) { 193 private func perform(_ command: any OrgCommand, on entry: BoardEntry, answers: [String: String] = [:]) {
194 switch session.perform(command, path: entry.path, at: entry.headingOffset, expect: (entry.headingOffset, entry.headingLine)) { 194 switch session.perform(command, path: entry.path, at: entry.headingOffset, expect: (entry.headingOffset, entry.headingLine), answers: answers) {
195 case .failed(let text): message = text 195 case .failed(let text): message = text
196 case .prompt(let prompt):
197 // A state note: asked in the main window's echo area.
198 commands.ask(prompt) { answer in
199 guard let answer else { return }
200 perform(command, on: entry, answers: answers.merging([prompt.key: answer]) { $1 })
201 }
202 openWindow(id: "main")
196 default: message = nil 203 default: message = nil
197 } 204 }
198 tick += 1 205 tick += 1
Sources/Orgstar/ContentView.swift +3 −1
@@ -34,11 +34,13 @@ struct ContentView: View {
34 @AppStorage("fillColumn") private var fillColumn = 80 34 @AppStorage("fillColumn") private var fillColumn = 80
35 @AppStorage("hiddenMarkers") private var hiddenMarkers = true 35 @AppStorage("hiddenMarkers") private var hiddenMarkers = true
36 @AppStorage("prettyEntities") private var prettyEntities = true 36 @AppStorage("prettyEntities") private var prettyEntities = true
37 @AppStorage("logDone") private var logDone = "nil"
38 @AppStorage("logIntoDrawer") private var logIntoDrawer = ""
37 39
38 private var options: EditingOptions { 40 private var options: EditingOptions {
39 Preferences.options( 41 Preferences.options(
40 tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists, 42 tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists,
41 fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities 43 fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer
42 ) 44 )
43 } 45 }
44 @State private var selectedFile: String? 46 @State private var selectedFile: String?
Sources/Orgstar/Preferences.swift +3 −2
@@ -17,12 +17,13 @@ enum Preferences {
17 17
18 static func options( 18 static func options(
19 tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int, 19 tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int,
20 hiddenMarkers: Bool, prettyEntities: Bool 20 hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String
21 ) -> EditingOptions { 21 ) -> EditingOptions {
22 EditingOptions( 22 EditingOptions(
23 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, 23 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree,
24 metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn, 24 metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn,
25 hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true 25 hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true,
26 logDone: LogHow(rawValue: logDone), logIntoDrawer: logIntoDrawer.isEmpty ? nil : logIntoDrawer
26 ) 27 )
27 } 28 }
28} 29}
Tests/OrgAppTests/EmacsImportTests.swift +4 −1
@@ -18,6 +18,7 @@ struct EmacsImportTests {
18 org-agenda-files '("~/notes/" "~/work/plan.org") 18 org-agenda-files '("~/notes/" "~/work/plan.org")
19 org-tags-column -80)) 19 org-tags-column -80))
20 (setq org-tags-column 4) 20 (setq org-tags-column 4)
21 (setq org-log-done 'note org-log-into-drawer t)
21 (setq fill-column (+ 70 2)) 22 (setq fill-column (+ 70 2))
22 (setq org-capture-templates 23 (setq org-capture-templates
23 '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a") 24 '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a")
@@ -66,6 +67,8 @@ struct EmacsImportTests {
66 #expect(s["display-line-numbers-type"] == .bool(true)) 67 #expect(s["display-line-numbers-type"] == .bool(true))
67 #expect(s["org-hide-emphasis-markers"] == .bool(true)) 68 #expect(s["org-hide-emphasis-markers"] == .bool(true))
68 #expect(s["org-pretty-entities"] == .bool(true)) 69 #expect(s["org-pretty-entities"] == .bool(true))
70 #expect(s["org-log-done"] == .string("note"))
71 #expect(s["org-log-into-drawer"] == .string("LOGBOOK"))
69 // after! org runs after the top-level setq. 72 // after! org runs after the top-level setq.
70 #expect(s["org-tags-column"] == .integer(-80)) 73 #expect(s["org-tags-column"] == .integer(-80))
71 #expect(s["org-agenda-span"] == .integer(10)) 74 #expect(s["org-agenda-span"] == .integer(10))
@@ -133,7 +136,7 @@ struct EmacsImportTests {
133 let keymap = folder.appendingPathComponent("keymap.toml") 136 let keymap = folder.appendingPathComponent("keymap.toml")
134 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) 137 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
135 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a")) 138 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a"))
136 #expect(summary.hasPrefix("Imported 12 settings, 1 capture template, 3 key bindings."), "\(summary)") 139 #expect(summary.hasPrefix("Imported 14 settings, 1 capture template, 3 key bindings."), "\(summary)")
137 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") 140 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
138 #expect(defaults.integer(forKey: "themeFontSize") == 14) 141 #expect(defaults.integer(forKey: "themeFontSize") == 14)
139 } 142 }
Tests/OrgCoreTests/TodoLoggingTests.swift added +89
@@ -0,0 +1,89 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct TodoLoggingTests {
6 static let texts = [
7 "#+TODO: TODO(t) WAIT(w@/!) | DONE(d!) CANCELED(c@)\n* TODO Task\nBody\n",
8 "* TODO Task\nSCHEDULED: <2026-10-05 Mon>\n:PROPERTIES:\n:X: 1\n:END:\nBody\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",
11 "#+STARTUP: lognotedone logdrawer\n* TODO Task\n",
12 "* 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\n- item one\n",
15 "* TODO Task\n** Child\n",
16 "* TODO Task",
17 ]
18
19 static let options: [(LogHow?, String?)] = [(nil, nil), (.time, nil), (.time, "LOGBOOK"), (.note, nil), (.note, "LOGBOOK")]
20
21 static let note = "Called back\nsecond line"
22
23 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
24 func logsLikeOrgTodo() throws {
25 let changes: [(any OrgCommand, String)] = [
26 (TodoCycle(), "(org-todo)"),
27 (SetTodo(keyword: "DONE"), "(org-todo \"DONE\")"),
28 (SetTodo(keyword: "TODO"), "(org-todo \"TODO\")"),
29 (SetTodo(keyword: nil), "(org-todo 'none)"),
30 (SetTodo(keyword: "WAIT"), "(org-todo \"WAIT\")"),
31 (SetTodo(keyword: "CANCELED"), "(org-todo \"CANCELED\")"),
32 ]
33 var utc = Calendar(identifier: .gregorian)
34 utc.timeZone = TimeZone(identifier: "UTC")!
35 let now = Date(timeIntervalSince1970: 1_789_000_000)
36 var cases: [EmacsOracle.Case] = []
37 var ours: [String] = []
38 for text in Self.texts {
39 let heading = (text as NSString).range(of: "Task").location
40 for (done, drawer) in Self.options {
41 let options = EditingOptions(logDone: done, logIntoDrawer: drawer)
42 for (command, form) in changes {
43 let lisp = """
44 (let ((b (current-buffer)) (org-use-fast-todo-selection nil))
45 ;; As the user's settings, under the file's #+STARTUP.
46 (setq-default org-log-done \(done.map { "'" + $0.rawValue } ?? "nil") org-log-into-drawer \(drawer.map { "\"\($0)\"" } ?? "nil"))
47 (org-mode)
48 \(form)
49 (run-hooks 'post-command-hook)
50 (when (get-buffer "*Org Note*")
51 (with-current-buffer "*Org Note*" (goto-char (point-max)) (insert \(lispString(Self.note))) (org-store-log-note)))
52 (set-buffer b))
53 """
54 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(heading, in: text), form: frozenClock(1_789_000_000, lisp)))
55 var answers: [String: String] = [:]
56 var result = text
57 while true {
58 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [heading..<heading], now: now, calendar: utc, answers: answers, options: options)
59 switch command.run(in: context) {
60 case .commit(let r):
61 for edit in r.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { result = edit.apply(to: result) }
62 case .prompt(let prompt) where prompt.key == "note" && answers["note"] == nil:
63 answers["note"] = Self.note
64 continue
65 default: break
66 }
67 break
68 }
69 ours.append(result)
70 }
71 }
72 }
73 let emacs = try EmacsOracle.run(cases)
74 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
75 #expect(mine == theirs.text, "\(cases[i].form.split(separator: "\n")[1...2].joined()) on \(cases[i].text.debugDescription)\nours: \(mine.debugDescription)\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
76 }
77 }
78
79 @Test func asksForTheNote() {
80 let text = "#+TODO: TODO(t) WAIT(w@/!) | DONE(d!)\n* TODO Task\n"
81 let heading = (text as NSString).range(of: "Task").location
82 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [heading..<heading])
83 #expect(SetTodo(keyword: "WAIT").run(in: context) == .prompt(Prompt(key: "note", message: "Note for state change from \"TODO\" to \"WAIT\":")))
84 }
85}
86
87func lispString(_ s: String) -> String {
88 "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
89}