Commit 8c221170c5
Verified · cmc
Layout: unified · split
Sources/OrgApp/EmacsImport.swift +9
| @@ -457,6 +457,15 @@ public struct EmacsImport: Sendable { | ||
| 457 | 457 | } else { |
| 458 | 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 | 469 | case "display-line-numbers-type": |
| 461 | 470 | add(name, .bool(truthy), truthy && value.symbol != "t" ? "display-line-numbers-type = true (Orgstar numbers lines absolutely)" : nil) |
| 462 | 471 | case "org-agenda-span": |
Sources/OrgApp/UserConfig.swift +4
| @@ -74,6 +74,10 @@ public enum UserConfig { | ||
| 74 | 74 | comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"), |
| 75 | 75 | Setting(section: "", key: "org-todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"), |
| 76 | 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 | 81 | Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true), |
| 78 | 82 | comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"), |
| 79 | 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 | 13 | public var listAllowAlphabetical: Bool |
| 14 | 14 | /// `org-log-repeat` `time`: completing a repeating entry records LAST_REPEAT and a state note. |
| 15 | 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 | 20 | /// `fill-column` for M-q. |
| 17 | 21 | public var fillColumn: Int |
| 18 | 22 | /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers. |
| @@ -25,8 +29,11 @@ public struct EditingOptions: Sendable, Equatable, Codable { | ||
| 25 | 29 | public init( |
| 26 | 30 | tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, |
| 27 | 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 | 37 | self.tagsColumn = tagsColumn |
| 31 | 38 | self.insertHeadingRespectContent = insertHeadingRespectContent |
| 32 | 39 | self.metaReturnMaySplitLine = metaReturnMaySplitLine |
Sources/OrgCore/Commands/Dwim.swift +1 −1
| @@ -34,8 +34,8 @@ public struct OrgDwim: OrgCommand { | ||
| 34 | 34 | buffer.setTodoKeyword(sequence.active.first?.name) |
| 35 | 35 | } else if let done = sequence.done.first?.name { |
| 36 | 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 | 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 | 29 | private(set) var text: NSMutableString |
| 25 | 30 | var point: Int |
| 26 | 31 | private var markers: [Marker] = [] |
| @@ -470,6 +475,8 @@ func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) | ||
| 470 | 475 | try body(buffer) |
| 471 | 476 | } catch let error as EmacsBuffer.UserError { |
| 472 | 477 | return .failed(error.message) |
| 478 | } catch let ask as EmacsBuffer.Ask { | |
| 479 | return .prompt(ask.prompt) | |
| 473 | 480 | } catch { |
| 474 | 481 | return .failed("\(error)") |
| 475 | 482 | } |
Sources/OrgCore/Commands/HeadingCommands.swift +1 −3
| @@ -211,9 +211,7 @@ public struct TodoCycle: OrgCommand { | ||
| 211 | 211 | return followUp(context, cycle(in: context)) { buffer in |
| 212 | 212 | try buffer.saveExcursion { |
| 213 | 213 | buffer.updateParentTodoStatistics() |
| 214 | let settings = context.tree.settings | |
| 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) } | |
| 214 | try buffer.afterTodoChange(previous: previous, answers: context.answers) | |
| 217 | 215 | } |
| 218 | 216 | } |
| 219 | 217 | } |
Sources/OrgCore/Commands/Properties.swift +1 −5
| @@ -37,11 +37,7 @@ public struct SetTodo: OrgCommand { | ||
| 37 | 37 | try buffer.saveExcursion { |
| 38 | 38 | let previous = buffer.todoKeyword |
| 39 | 39 | buffer.setTodoKeyword(keyword) |
| 40 | guard previous != keyword else { return } | |
| 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 | } | |
| 40 | try buffer.afterTodoChange(previous: previous, answers: context.answers) | |
| 45 | 41 | } |
| 46 | 42 | } |
| 47 | 43 | } |
Sources/OrgCore/Commands/Repeat.swift +39 −41
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | // Repeating entries (`org-auto-repeat-maybe`, Org 9.8.7) and the state note org logs for them | |
| 4 | // (`org-add-log-setup`, `org-store-log-note`, `org-log-beginning`), with `org-log-repeat` | |
| 5 | // `time`, `org-log-into-drawer` nil, `org-todo-repeat-to-state` nil. | |
| 3 | // Repeating entries (`org-auto-repeat-maybe`, Org 9.8.7) and where state notes go | |
| 4 | // (`org-log-beginning`), with `org-todo-repeat-to-state` nil. | |
| 6 | 5 | |
| 7 | 6 | extension EmacsBuffer { |
| 8 | 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 | |
| 52 | /// any, with the marker it belongs at. | |
| 53 | func autoRepeat(doneWord: String, previous: String?) throws -> (Marker, String)? { | |
| 50 | /// `org-auto-repeat-maybe` after `doneWord` replaced `previous`. Returns the state note the | |
| 51 | /// repeat logs, if any. | |
| 52 | func autoRepeat(doneWord: String, previous: String?, log: LogSettings) throws -> LogSetup? { | |
| 54 | 53 | guard let cookie = repeatCookie(), (Int(cookie.drop { !$0.isNumber }.prefix { $0.isNumber }) ?? 0) != 0 else { return nil } |
| 55 | 54 | let end = marker(entryEnd) |
| 56 | 55 | defer { release(end) } |
| @@ -63,8 +62,7 @@ extension EmacsBuffer { | ||
| 63 | 62 | try backToHeading() |
| 64 | 63 | try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed]) |
| 65 | 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) } | |
| 67 | let note = marker(point) | |
| 65 | if log.repeatHow != nil { entryPut("LAST_REPEAT", stamp) } | |
| 68 | 66 | if let scheduled = planningValue(.scheduled), ("<" + scheduled + ">").range(of: Self.repeatPattern, options: .regularExpression) == nil { |
| 69 | 67 | saveExcursion { removePlanning(.scheduled) } |
| 70 | 68 | } |
| @@ -102,36 +100,7 @@ extension EmacsBuffer { | ||
| 102 | 100 | } |
| 103 | 101 | try saveExcursion { try changeTimestamp(n, what: unit, suppressDelay: true) } |
| 104 | 102 | } |
| 105 | guard options.logRepeat else { | |
| 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 | } | |
| 103 | return log.repeatHow.map { LogSetup(purpose: .state, state: doneWord, previous: previous, how: $0, drawer: log.drawer) } | |
| 135 | 104 | } |
| 136 | 105 | |
| 137 | 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 | |
| 149 | /// newest note comes first. | |
| 150 | func logBeginning() -> Int { | |
| 117 | /// `org-log-beginning` with `org-log-states-order-reversed` t: the newest note comes first. | |
| 118 | /// With `drawer`, notes go at the top of that drawer, which `create` adds when missing. | |
| 119 | func logBeginning(drawer: String? = nil, create: Bool = false) -> Int { | |
| 151 | 120 | saveExcursion { |
| 121 | if let drawer { return drawerLogBeginning(drawer, create: create) } | |
| 152 | 122 | endOfMetaData() |
| 153 | 123 | let endpos = point |
| 154 | 124 | skipForward([32, 9, 10]) |
| @@ -159,4 +129,32 @@ extension EmacsBuffer { | ||
| 159 | 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 @@ | ||
| 1 | import 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. | |
| 9 | public enum LogHow: String, Sendable, Equatable, Codable { | |
| 10 | case time, note | |
| 11 | } | |
| 12 | ||
| 13 | /// The logging in effect for one entry. | |
| 14 | struct 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. | |
| 55 | struct 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 | ||
| 64 | extension 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 | 191 | // MARK: - Editing |
| 192 | 192 | |
| 193 | private func perform(_ command: any OrgCommand, on entry: BoardEntry) { | |
| 194 | switch session.perform(command, path: entry.path, at: entry.headingOffset, expect: (entry.headingOffset, entry.headingLine)) { | |
| 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), answers: answers) { | |
| 195 | 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 | 203 | default: message = nil |
| 197 | 204 | } |
| 198 | 205 | tick += 1 |
Sources/Orgstar/ContentView.swift +3 −1
| @@ -34,11 +34,13 @@ struct ContentView: View { | ||
| 34 | 34 | @AppStorage("fillColumn") private var fillColumn = 80 |
| 35 | 35 | @AppStorage("hiddenMarkers") private var hiddenMarkers = true |
| 36 | 36 | @AppStorage("prettyEntities") private var prettyEntities = true |
| 37 | @AppStorage("logDone") private var logDone = "nil" | |
| 38 | @AppStorage("logIntoDrawer") private var logIntoDrawer = "" | |
| 37 | 39 | |
| 38 | 40 | private var options: EditingOptions { |
| 39 | 41 | Preferences.options( |
| 40 | 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 | 46 | @State private var selectedFile: String? |
Sources/Orgstar/Preferences.swift +3 −2
| @@ -17,12 +17,13 @@ enum Preferences { | ||
| 17 | 17 | |
| 18 | 18 | static func options( |
| 19 | 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 | 21 | ) -> EditingOptions { |
| 22 | 22 | EditingOptions( |
| 23 | 23 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, |
| 24 | 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 | 18 | org-agenda-files '("~/notes/" "~/work/plan.org") |
| 19 | 19 | org-tags-column -80)) |
| 20 | 20 | (setq org-tags-column 4) |
| 21 | (setq org-log-done 'note org-log-into-drawer t) | |
| 21 | 22 | (setq fill-column (+ 70 2)) |
| 22 | 23 | (setq org-capture-templates |
| 23 | 24 | '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a") |
| @@ -66,6 +67,8 @@ struct EmacsImportTests { | ||
| 66 | 67 | #expect(s["display-line-numbers-type"] == .bool(true)) |
| 67 | 68 | #expect(s["org-hide-emphasis-markers"] == .bool(true)) |
| 68 | 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 | 72 | // after! org runs after the top-level setq. |
| 70 | 73 | #expect(s["org-tags-column"] == .integer(-80)) |
| 71 | 74 | #expect(s["org-agenda-span"] == .integer(10)) |
| @@ -133,7 +136,7 @@ struct EmacsImportTests { | ||
| 133 | 136 | let keymap = folder.appendingPathComponent("keymap.toml") |
| 134 | 137 | #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) |
| 135 | 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 | 140 | #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") |
| 138 | 141 | #expect(defaults.integer(forKey: "themeFontSize") == 14) |
| 139 | 142 | } |
Tests/OrgCoreTests/TodoLoggingTests.swift added +89
| @@ -0,0 +1,89 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 | ||
| 87 | func lispString(_ s: String) -> String { | |
| 88 | "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" | |
| 89 | } | |