Sources/OrgCore/Commands/TodoLogging.swift
233 lines · 11097 bytes
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 /// `org-log-reschedule` and `org-log-redeadline`.
18 var reschedule: LogHow?
19 var redeadline: LogHow?
20 var drawer: String?
21 /// `org-todo-log-states`: keyword → (on entering, on leaving).
22 var states: [String: (enter: LogHow?, leave: LogHow?)] = [:]
23
24 static func how(_ flag: String?) -> LogHow? {
25 switch flag {
26 case "!": .time
27 case "@": .note
28 default: nil
29 }
30 }
31
32 /// Applies one word of `#+STARTUP` or of a LOGGING property.
33 mutating func apply(startup word: String) {
34 switch word {
35 case "logdone": done = .time
36 case "lognotedone": done = .note
37 case "nologdone": done = nil
38 case "logrepeat": repeatHow = .time
39 case "lognoterepeat": repeatHow = .note
40 case "nologrepeat": repeatHow = nil
41 case "logreschedule": reschedule = .time
42 case "lognotereschedule": reschedule = .note
43 case "nologreschedule": reschedule = nil
44 case "logredeadline": redeadline = .time
45 case "lognoteredeadline": redeadline = .note
46 case "nologredeadline": redeadline = nil
47 case "logdrawer": drawer = "LOGBOOK"
48 case "nologdrawer": drawer = nil
49 default: break
50 }
51 }
52
53 /// `org-extract-log-state-settings`: `WAIT(w@/!)`.
54 static func stateSetting(_ word: String) -> (String, LogHow?, LogHow?)? {
55 guard let match = word.firstMatch(of: /^(.*?)(?:\(([^!@\/])?([!@])?(?:\/([!@]))?\))?$/) else { return nil }
56 let enter = how(match.3.map(String.init))
57 let leave = how(match.4.map(String.init))
58 guard enter != nil || leave != nil else { return nil }
59 return (String(match.1), enter, leave)
60 }
61}
62
63/// A note `org-add-log-setup` asked for, stored when the command ends.
64struct LogSetup {
65 enum Purpose { case done, state, reschedule, delschedule, redeadline, deldeadline }
66 var purpose: Purpose
67 var state: String?
68 var previous: String?
69 var how: LogHow
70 var drawer: String?
71}
72
73extension EmacsBuffer {
74 /// The logging settings for the entry at point.
75 func logSettings() -> LogSettings {
76 var log = LogSettings(done: options.logDone, repeatHow: options.logRepeat ? .time : nil,
77 reschedule: options.logReschedule, redeadline: options.logRedeadline, drawer: options.logIntoDrawer)
78 for sequence in settings.todoSequences {
79 for keyword in sequence.active + sequence.done {
80 let enter = LogSettings.how(keyword.logOnEnter)
81 let leave = LogSettings.how(keyword.logOnLeave)
82 if enter != nil || leave != nil, log.states[keyword.name] == nil { log.states[keyword.name] = (enter, leave) }
83 }
84 }
85 for word in settings.startup { log.apply(startup: word) }
86 if let logging = inheritedProperty("LOGGING")?.value {
87 // `org-local-logging`.
88 log.done = nil
89 log.repeatHow = nil
90 log.states = [:]
91 for word in logging.split(whereSeparator: \.isWhitespace).map(String.init) {
92 if ["logdone", "lognotedone", "nologdone", "logrepeat", "lognoterepeat", "nologrepeat"].contains(word) {
93 log.apply(startup: word)
94 } else if let (keyword, enter, leave) = LogSettings.stateSetting(word), keywordsInOrder.contains(keyword) {
95 log.states[keyword] = (enter, leave)
96 }
97 }
98 }
99 if let drawer = inheritedProperty("LOG_INTO_DRAWER")?.value {
100 log.drawer = drawer == "nil" ? nil : drawer == "t" ? "LOGBOOK" : drawer
101 }
102 return log
103 }
104
105 /// `org-todo`'s logging after the keyword went from `previous` to `state`: CLOSED is added
106 /// or removed, and the note to take is returned.
107 func todoLogging(previous: String?, state: String?, log: LogSettings) throws -> LogSetup? {
108 guard log.done != nil || !log.states.isEmpty else { return nil }
109 let isDone = { (k: String?) in k.map(self.settings.isDone) ?? false }
110 let isNotDone = { (k: String?) in k.map { self.keywordsInOrder.contains($0) && !self.settings.isDone($0) } ?? false }
111 let nowDone = isDone(state) && !isDone(previous)
112 let dolog = state.flatMap { log.states[$0]?.enter } ?? previous.flatMap { log.states[$0]?.leave }
113 if state == nil || (isNotDone(state) && !isNotDone(previous)) {
114 try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed])
115 }
116 var setup: LogSetup?
117 if nowDone, let done = log.done {
118 let time = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now)
119 try addPlanningInfo(.closed, time: time, withTime: true, endTime: nil, remove: [])
120 if dolog == nil, done == .note { setup = LogSetup(purpose: .done, state: state, previous: previous, how: .note, drawer: log.drawer) }
121 }
122 if let state, let dolog { setup = LogSetup(purpose: .state, state: state, previous: previous, how: dolog, drawer: log.drawer) }
123 return setup
124 }
125
126 /// The question a note needs.
127 static func notePrompt(_ setup: LogSetup) -> Prompt {
128 let about = switch setup.purpose {
129 case .done: "closed todo item"
130 case .state: "state change from \"\(setup.previous ?? "")\" to \"\(setup.state ?? "")\""
131 case .reschedule: "rescheduling"
132 case .delschedule: "no longer scheduled"
133 case .redeadline: "changing deadline"
134 case .deldeadline: "removing deadline"
135 }
136 return Prompt(key: "note", message: "Note for \(about):")
137 }
138
139 /// `org-store-log-note` for `setup` with the note's text, in the entry at point.
140 func storeLog(_ setup: LogSetup, text: String) {
141 func quoted(_ text: String?) -> String { text.map { "\"\($0)\"" } ?? "" }
142 let stamp = formatTimestamp(calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now), withTime: true, inactive: true)
143 var heading = switch setup.purpose {
144 case .done: "CLOSING NOTE " + stamp
145 case .state:
146 "State " + quoted(setup.state).padding(toLength: max(12, quoted(setup.state).count), withPad: " ", startingAt: 0)
147 + " from " + quoted(setup.previous).padding(toLength: max(12, quoted(setup.previous).count), withPad: " ", startingAt: 0)
148 + " " + stamp
149 case .reschedule: "Rescheduled from \(Self.quotedState(setup.previous)) on \(stamp)"
150 case .delschedule: "Not scheduled, was \(Self.quotedState(setup.previous)) on \(stamp)"
151 case .redeadline: "New deadline from \(Self.quotedState(setup.previous)) on \(stamp)"
152 case .deldeadline: "Removed deadline, was \(Self.quotedState(setup.previous)) on \(stamp)"
153 }
154 var trimmed = text
155 while let range = trimmed.range(of: "^# .*\\n[ \\t\\n]*", options: .regularExpression) { trimmed.removeSubrange(range) }
156 trimmed = trimmed.replacingOccurrences(of: "\\s+$", with: "", options: .regularExpression)
157 let lines = trimmed.isEmpty ? [] : trimmed.components(separatedBy: "\n")
158 if !lines.isEmpty { heading += " \\\\" }
159 saveExcursion {
160 point = logBeginning(drawer: setup.drawer, create: true)
161 if !bolp {
162 insert("\n")
163 } else if lookingAt("[ \\t]*\\S") != nil {
164 saveExcursion { insert("\n") }
165 }
166 if let item = inItem() {
167 let structure = saveExcursion { point = item; return listStruct() }
168 indentLineTo(structure.ind(structure.top))
169 } else {
170 indentLineTo(0)
171 }
172 let bodyColumn = currentColumn + 2
173 insert("- " + heading)
174 for line in lines {
175 insert("\n")
176 if !line.isEmpty {
177 indentLineTo(bodyColumn)
178 insert(line)
179 }
180 }
181 }
182 }
183
184 /// What `org-todo` does after the keyword went from `previous` to the current one: CLOSED,
185 /// the repeat of a repeating entry, and the log note, asking for its text with `answers`.
186 func afterTodoChange(previous: String?, answers: [String: String]) throws {
187 let state = todoKeyword
188 guard state != previous else { return }
189 let log = logSettings()
190 var setup = try saveExcursion { try todoLogging(previous: previous, state: state, log: log) }
191 if let state, settings.isDone(state), !(previous.map(settings.isDone) ?? false),
192 let repeatSetup = try autoRepeat(doneWord: state, previous: previous, log: log) {
193 if setup == nil { setup = repeatSetup } else if repeatSetup.how == .note { setup?.how = .note }
194 }
195 guard let setup else { return }
196 if setup.how == .note {
197 guard let note = answers["note"] else { throw Ask(prompt: Self.notePrompt(setup)) }
198 storeLog(setup, text: note)
199 } else {
200 storeLog(setup, text: "")
201 }
202 }
203
204 /// `%S` in `org-log-note-headings`: a timestamp as an inactive one, in quotes.
205 static func quotedState(_ s: String?) -> String {
206 guard let s else { return "" }
207 if s.range(of: "^[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}", options: .regularExpression) != nil {
208 return "\"[" + s.dropFirst().dropLast() + "]\""
209 }
210 return "\"" + s + "\""
211 }
212
213 /// The note `org--deadline-or-schedule` logs when a planning date changes or goes, with
214 /// `org-log-reschedule` and `org-log-redeadline`; asks for its text with `answers`.
215 func logPlanningChange(_ kind: PlanningKind, old: String, new: String?, answers: [String: String]) throws {
216 let log = logSettings()
217 guard let how = kind == .deadline ? log.redeadline : log.reschedule else { return }
218 if let new, new == old { return }
219 let purpose: LogSetup.Purpose = switch (kind == .deadline, new == nil) {
220 case (true, true): .deldeadline
221 case (true, false): .redeadline
222 case (false, true): .delschedule
223 case (false, false): .reschedule
224 }
225 let setup = LogSetup(purpose: purpose, state: new, previous: old, how: how, drawer: log.drawer)
226 if how == .note {
227 guard let note = answers["note"] else { throw Ask(prompt: Self.notePrompt(setup)) }
228 storeLog(setup, text: note)
229 } else {
230 storeLog(setup, text: "")
231 }
232 }
233}