krz/orgstar

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

Sources/OrgCore/Commands/TodoLogging.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgCore/Commands/TodoLogging.swift history · blame · raw

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}