Commit 4a16622163
Verified · cmc
Layout: unified · split
Sources/OrgApp/EmacsImport.swift +1 −1
| @@ -469,7 +469,7 @@ public struct EmacsImport: Sendable { | |||
| 469 | if ["agenda", "todo", "tags"].contains(view) { add("org-agenda-prefix-format.\(view)", .string(s)) } | 469 | if ["agenda", "todo", "tags"].contains(view) { add("org-agenda-prefix-format.\(view)", .string(s)) } |
| 470 | } | 470 | } |
| 471 | } | 471 | } |
| 472 | case "org-log-done": | 472 | case "org-log-done", "org-log-reschedule", "org-log-redeadline": |
| 473 | switch value.symbol { | 473 | switch value.symbol { |
| 474 | case "nil": add(name, .string("nil")) | 474 | case "nil": add(name, .string("nil")) |
| 475 | case "time", "t": add(name, .string("time")) | 475 | case "time", "t": add(name, .string("time")) |
Sources/OrgApp/UserConfig.swift +4
| @@ -76,6 +76,10 @@ public enum UserConfig { | |||
| 76 | comment: "for files without #+TODO, in #+TODO syntax, one sequence per line (\\n); keys in parentheses turn on fast selection; read at launch", legacy: "editor.todo-keywords"), | 76 | comment: "for files without #+TODO, in #+TODO syntax, one sequence per line (\\n); keys in parentheses turn on fast selection; read at launch", legacy: "editor.todo-keywords"), |
| 77 | Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]), | 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: ""), | 78 | fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""), |
| 79 | Setting(section: "", key: "org-log-reschedule", defaultsKey: "logReschedule", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]), | ||
| 80 | fallback: .string("nil"), comment: "nil, time or note: log changing or removing a SCHEDULED date", legacy: ""), | ||
| 81 | Setting(section: "", key: "org-log-redeadline", defaultsKey: "logRedeadline", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]), | ||
| 82 | fallback: .string("nil"), comment: "nil, time or note: log changing or removing a DEADLINE", legacy: ""), | ||
| 79 | Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""), | 83 | 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: ""), | 84 | comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""), |
| 81 | Setting(section: "", key: "org-cycle-hide-drawer-startup", defaultsKey: "hideDrawerStartup", kind: .bool, fallback: .bool(true), | 85 | Setting(section: "", key: "org-cycle-hide-drawer-startup", defaultsKey: "hideDrawerStartup", kind: .bool, fallback: .bool(true), |
Sources/OrgCore/Commands/Command.swift +20 −4
| @@ -17,6 +17,9 @@ public struct EditingOptions: Sendable, Equatable, Codable { | |||
| 17 | public var logDone: LogHow? | 17 | public var logDone: LogHow? |
| 18 | /// `org-log-into-drawer`: the drawer state notes go in, or nil for none. | 18 | /// `org-log-into-drawer`: the drawer state notes go in, or nil for none. |
| 19 | public var logIntoDrawer: String? | 19 | public var logIntoDrawer: String? |
| 20 | /// `org-log-reschedule` and `org-log-redeadline`: a note when a date changes or goes. | ||
| 21 | public var logReschedule: LogHow? | ||
| 22 | public var logRedeadline: LogHow? | ||
| 20 | /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one. | 23 | /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one. |
| 21 | public var fastTodoSelection: Bool | 24 | public var fastTodoSelection: Bool |
| 22 | /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens. | 25 | /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens. |
| @@ -37,8 +40,10 @@ public struct EditingOptions: Sendable, Equatable, Codable { | |||
| 37 | listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, | 40 | listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, |
| 38 | hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false, | 41 | hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false, |
| 39 | logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true, | 42 | logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true, |
| 40 | hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false | 43 | hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false, logReschedule: LogHow? = nil, logRedeadline: LogHow? = nil |
| 41 | ) { | 44 | ) { |
| 45 | self.logReschedule = logReschedule | ||
| 46 | self.logRedeadline = logRedeadline | ||
| 42 | self.hideDrawerStartup = hideDrawerStartup | 47 | self.hideDrawerStartup = hideDrawerStartup |
| 43 | self.hideBlockStartup = hideBlockStartup | 48 | self.hideBlockStartup = hideBlockStartup |
| 44 | self.fastTodoSelection = fastTodoSelection | 49 | self.fastTodoSelection = fastTodoSelection |
| @@ -78,12 +83,15 @@ public struct EditContext: Sendable { | |||
| 78 | public let options: EditingOptions | 83 | public let options: EditingOptions |
| 79 | /// Tags used across the workspace, for completion; read when a command needs them. | 84 | /// Tags used across the workspace, for completion; read when a command needs them. |
| 80 | public let knownTags: @Sendable () -> [String] | 85 | public let knownTags: @Sendable () -> [String] |
| 86 | /// The command run just before, with nothing typed since (`last-command`). | ||
| 87 | public let lastCommand: String? | ||
| 81 | 88 | ||
| 82 | public init( | 89 | public init( |
| 83 | revision: Int, text: String, tree: OrgTree, selection: [Range<Int>], | 90 | revision: Int, text: String, tree: OrgTree, selection: [Range<Int>], |
| 84 | now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [], | 91 | now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [], |
| 85 | options: EditingOptions = .org, knownTags: @escaping @Sendable () -> [String] = { [] } | 92 | options: EditingOptions = .org, knownTags: @escaping @Sendable () -> [String] = { [] }, lastCommand: String? = nil |
| 86 | ) { | 93 | ) { |
| 94 | self.lastCommand = lastCommand | ||
| 87 | self.knownTags = knownTags | 95 | self.knownTags = knownTags |
| 88 | self.revision = revision | 96 | self.revision = revision |
| 89 | self.text = text | 97 | self.text = text |
| @@ -112,10 +120,17 @@ public struct Prompt: Sendable, Equatable { | |||
| 112 | public let completionSeparator: Character? | 120 | public let completionSeparator: Character? |
| 113 | /// Single keys answer instead of text. | 121 | /// Single keys answer instead of text. |
| 114 | public let fast: FastSelection? | 122 | public let fast: FastSelection? |
| 123 | /// A date prompt, read by `org-read-date` against this default (nil: today). | ||
| 124 | public let date: DateRead? | ||
| 125 | |||
| 126 | public struct DateRead: Sendable, Equatable { | ||
| 127 | public let defaultDate: Date? | ||
| 128 | public init(defaultDate: Date?) { self.defaultDate = defaultDate } | ||
| 129 | } | ||
| 115 | 130 | ||
| 116 | public init( | 131 | public init( |
| 117 | key: String, message: String, initial: String = "", choices: [String] = [], requireMatch: Bool = true, | 132 | key: String, message: String, initial: String = "", choices: [String] = [], requireMatch: Bool = true, |
| 118 | completionSeparator: Character? = nil, fast: FastSelection? = nil | 133 | completionSeparator: Character? = nil, fast: FastSelection? = nil, date: DateRead? = nil |
| 119 | ) { | 134 | ) { |
| 120 | self.key = key | 135 | self.key = key |
| 121 | self.message = message | 136 | self.message = message |
| @@ -124,6 +139,7 @@ public struct Prompt: Sendable, Equatable { | |||
| 124 | self.requireMatch = requireMatch | 139 | self.requireMatch = requireMatch |
| 125 | self.completionSeparator = completionSeparator | 140 | self.completionSeparator = completionSeparator |
| 126 | self.fast = fast | 141 | self.fast = fast |
| 142 | self.date = date | ||
| 127 | } | 143 | } |
| 128 | } | 144 | } |
| 129 | 145 | ||
| @@ -216,7 +232,7 @@ public enum Commands { | |||
| 216 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), | 232 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 217 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), | 233 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), |
| 218 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), | 234 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), |
| 219 | UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), | 235 | UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), |
| 220 | ] | 236 | ] |
| 221 | 237 | ||
| 222 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } | 238 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } |
Sources/OrgCore/Commands/TimestampCommands.swift +74 −12
| @@ -4,15 +4,12 @@ extension EmacsBuffer { | |||
| 4 | /// Reads a date prompt answer the way `org-read-date` does: an empty answer takes the | 4 | /// Reads a date prompt answer the way `org-read-date` does: an empty answer takes the |
| 5 | /// default date. | 5 | /// default date. |
| 6 | func readDate(_ answer: String, default defaultDate: Date?) -> ReadDate.Answer { | 6 | func readDate(_ answer: String, default defaultDate: Date?) -> ReadDate.Answer { |
| 7 | let def = defaultDate ?? now | 7 | ReadDate.read(answer, default: defaultDate, now: now, calendar: calendar) |
| 8 | var input = answer | ||
| 9 | if input.trimmingCharacters(in: .whitespaces).isEmpty { | ||
| 10 | let c = calendar.dateComponents([.year, .month, .day], from: def) | ||
| 11 | input = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) | ||
| 12 | } | ||
| 13 | return ReadDate.analyze(input, default: def, now: now, calendar: calendar) | ||
| 14 | } | 8 | } |
| 15 | 9 | ||
| 10 | /// The date an Org time string stands for, as a prompt default. | ||
| 11 | func dateDefault(_ stamp: String?) -> Date? { stamp.flatMap(Self.parseTimeString).map(date) } | ||
| 12 | |||
| 16 | func date(_ components: DateComponents) -> Date { calendar.date(from: components)! } | 13 | func date(_ components: DateComponents) -> Date { calendar.date(from: components)! } |
| 17 | 14 | ||
| 18 | /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one. | 15 | /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one. |
| @@ -45,7 +42,7 @@ extension EmacsBuffer { | |||
| 45 | } | 42 | } |
| 46 | 43 | ||
| 47 | /// `org--deadline-or-schedule` without a prefix argument. | 44 | /// `org--deadline-or-schedule` without a prefix argument. |
| 48 | func schedule(_ kind: PlanningKind, answer: String) throws { | 45 | func schedule(_ kind: PlanningKind, answer: String, answers: [String: String] = [:]) throws { |
| 49 | let old = planningValue(kind) | 46 | let old = planningValue(kind) |
| 50 | let repeatPattern = "([.+-]+[0-9]+[hdwmy](?:[/ ][-+]?[0-9]+[hdwmy])?)" | 47 | let repeatPattern = "([.+-]+[0-9]+[hdwmy](?:[/ ][-+]?[0-9]+[hdwmy])?)" |
| 51 | let repeater = old.flatMap { $0.firstMatch(of: try! Regex(repeatPattern)).map { String($0.output[1].substring!) } } | 48 | let repeater = old.flatMap { $0.firstMatch(of: try! Regex(repeatPattern)).map { String($0.output[1].substring!) } } |
| @@ -62,6 +59,9 @@ extension EmacsBuffer { | |||
| 62 | } | 59 | } |
| 63 | } | 60 | } |
| 64 | } | 61 | } |
| 62 | if let old, let inserted = lastInsertedTimestamp { | ||
| 63 | try logPlanningChange(kind, old: "<" + old + ">", new: inserted, answers: answers) | ||
| 64 | } | ||
| 65 | } | 65 | } |
| 66 | 66 | ||
| 67 | /// `org--deadline-or-schedule` with C-u: removes the planning timestamp. | 67 | /// `org--deadline-or-schedule` with C-u: removes the planning timestamp. |
| @@ -108,7 +108,21 @@ public struct InsertTimestamp: OrgCommand { | |||
| 108 | public func applies(in context: EditContext) -> Bool { true } | 108 | public func applies(in context: EditContext) -> Bool { true } |
| 109 | public func run(in context: EditContext) -> CommandStep { | 109 | public func run(in context: EditContext) -> CommandStep { |
| 110 | guard let answer = context.answers["date"] else { | 110 | guard let answer = context.answers["date"] else { |
| 111 | return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context))) | 111 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 112 | buffer.calendar = context.calendar | ||
| 113 | let stamp = buffer.atTimestamp().map { buffer.substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | ||
| 114 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" | ||
| 115 | return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context), | ||
| 116 | date: .init(defaultDate: repeated ? nil : buffer.dateDefault(stamp)))) | ||
| 117 | } | ||
| 118 | // Twice in a row on a timestamp: a range to a second one. | ||
| 119 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" | ||
| 120 | if repeated, EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil { | ||
| 121 | return timestampCommand(context) { buffer in | ||
| 122 | let result = buffer.readDate(answer, default: nil) | ||
| 123 | buffer.insert("--") | ||
| 124 | buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive) | ||
| 125 | } | ||
| 112 | } | 126 | } |
| 113 | return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) } | 127 | return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) } |
| 114 | } | 128 | } |
| @@ -126,10 +140,12 @@ public struct SetPlanning: OrgCommand { | |||
| 126 | let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled | 140 | let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled |
| 127 | guard let answer = context.answers["date"] else { | 141 | guard let answer = context.answers["date"] else { |
| 128 | let buffer = EmacsBuffer(context.text, point: context.caret) | 142 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 143 | buffer.calendar = context.calendar | ||
| 129 | let initial = buffer.planningDefault(kind).flatMap(EmacsBuffer.compactTimeOfDay) ?? "" | 144 | let initial = buffer.planningDefault(kind).flatMap(EmacsBuffer.compactTimeOfDay) ?? "" |
| 130 | return .prompt(Prompt(key: "date", message: deadline ? "DEADLINE:" : "SCHEDULED:", initial: initial)) | 145 | return .prompt(Prompt(key: "date", message: deadline ? "DEADLINE:" : "SCHEDULED:", initial: initial, |
| 146 | date: .init(defaultDate: buffer.dateDefault(buffer.planningDefault(kind))))) | ||
| 131 | } | 147 | } |
| 132 | return timestampCommand(context) { try $0.schedule(kind, answer: answer) } | 148 | return timestampCommand(context) { try $0.schedule(kind, answer: answer, answers: context.answers) } |
| 133 | } | 149 | } |
| 134 | } | 150 | } |
| 135 | 151 | ||
| @@ -141,7 +157,12 @@ public struct RemovePlanning: OrgCommand { | |||
| 141 | public var title: String { deadline ? "Remove Deadline" : "Remove Schedule" } | 157 | public var title: String { deadline ? "Remove Deadline" : "Remove Schedule" } |
| 142 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } | 158 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } |
| 143 | public func run(in context: EditContext) -> CommandStep { | 159 | public func run(in context: EditContext) -> CommandStep { |
| 144 | timestampCommand(context) { $0.removePlanning(deadline ? .deadline : .scheduled) } | 160 | timestampCommand(context) { buffer in |
| 161 | let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled | ||
| 162 | let old = buffer.planningValue(kind) | ||
| 163 | if let old { try buffer.logPlanningChange(kind, old: "<" + old + ">", new: nil, answers: context.answers) } | ||
| 164 | buffer.removePlanning(kind) | ||
| 165 | } | ||
| 145 | } | 166 | } |
| 146 | } | 167 | } |
| 147 | 168 | ||
| @@ -168,3 +189,44 @@ public struct ShiftTimestampDay: OrgCommand { | |||
| 168 | timestampCommand(context) { try $0.changeTimestamp(later ? 1 : -1, what: .day, updown: true) } | 189 | timestampCommand(context) { try $0.changeTimestamp(later ? 1 : -1, what: .day, updown: true) } |
| 169 | } | 190 | } |
| 170 | } | 191 | } |
| 192 | |||
| 193 | /// C-c C-y: `org-evaluate-time-range`. On a clock line, updates it; otherwise says how long the | ||
| 194 | /// range at the caret, or the first on its line, lasts. | ||
| 195 | public struct EvaluateTimeRange: OrgCommand { | ||
| 196 | public init() {} | ||
| 197 | public var id: String { "org.timestamp.evaluate-range" } | ||
| 198 | public var title: String { "Evaluate Time Range" } | ||
| 199 | public func applies(in context: EditContext) -> Bool { true } | ||
| 200 | public func run(in context: EditContext) -> CommandStep { | ||
| 201 | let buffer = EmacsBuffer(context.text, point: context.caret) | ||
| 202 | buffer.calendar = context.calendar | ||
| 203 | if buffer.lookingAtLine("^[ \\t]*CLOCK:") { return CtrlCCtrlC().run(in: context) } | ||
| 204 | let ns = context.text as NSString | ||
| 205 | let line = ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0)) | ||
| 206 | let regex = try! NSRegularExpression(pattern: "[\\[<]([0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*?)[\\]>]--?-?[\\[<]([0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*?)[\\]>]") | ||
| 207 | let matches = regex.matches(in: context.text, range: line) | ||
| 208 | guard let m = matches.first(where: { $0.range.location <= context.caret && context.caret <= NSMaxRange($0.range) }) ?? matches.first else { | ||
| 209 | return .failed("Not at a timestamp range, and none found in current line") | ||
| 210 | } | ||
| 211 | let ts1 = ns.substring(with: m.range(at: 1)) | ||
| 212 | let ts2 = ns.substring(with: m.range(at: 2)) | ||
| 213 | let haveTime = ts1.count > 15 || ts2.count > 15 | ||
| 214 | let t1 = TagsMatcher.seconds(ts1, now: context.now, calendar: context.calendar) | ||
| 215 | let t2 = TagsMatcher.seconds(ts2, now: context.now, calendar: context.calendar) | ||
| 216 | var diff = abs(t2 - t1) | ||
| 217 | var d = 0, h = 0, minutes = 0 | ||
| 218 | if haveTime { | ||
| 219 | d = Int(floor(diff / 86400)); diff = diff.truncatingRemainder(dividingBy: 86400) | ||
| 220 | h = Int(floor(diff / 3600)); diff = diff.truncatingRemainder(dividingBy: 3600) | ||
| 221 | minutes = Int(floor(diff / 60)) | ||
| 222 | } else { | ||
| 223 | d = Int((diff / 86400).rounded()) | ||
| 224 | } | ||
| 225 | var parts: [String] = [] | ||
| 226 | if d > 0 { parts.append("\(d) day" + (d > 1 ? "s" : "")) } | ||
| 227 | if h > 0 { parts.append("\(h) hour" + (h > 1 ? "s" : "")) } | ||
| 228 | if minutes > 0 { parts.append("\(minutes) minute" + (minutes > 1 ? "s" : "")) } | ||
| 229 | let text = parts.isEmpty ? "" : parts.joined(separator: " ") + " " | ||
| 230 | return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.message(text)])) | ||
| 231 | } | ||
| 232 | } | ||
Sources/OrgCore/Commands/TodoLogging.swift +50 −2
| @@ -14,6 +14,9 @@ public enum LogHow: String, Sendable, Equatable, Codable { | |||
| 14 | struct LogSettings { | 14 | struct LogSettings { |
| 15 | var done: LogHow? | 15 | var done: LogHow? |
| 16 | var repeatHow: LogHow? | 16 | var repeatHow: LogHow? |
| 17 | /// `org-log-reschedule` and `org-log-redeadline`. | ||
| 18 | var reschedule: LogHow? | ||
| 19 | var redeadline: LogHow? | ||
| 17 | var drawer: String? | 20 | var drawer: String? |
| 18 | /// `org-todo-log-states`: keyword → (on entering, on leaving). | 21 | /// `org-todo-log-states`: keyword → (on entering, on leaving). |
| 19 | var states: [String: (enter: LogHow?, leave: LogHow?)] = [:] | 22 | var states: [String: (enter: LogHow?, leave: LogHow?)] = [:] |
| @@ -35,6 +38,12 @@ struct LogSettings { | |||
| 35 | case "logrepeat": repeatHow = .time | 38 | case "logrepeat": repeatHow = .time |
| 36 | case "lognoterepeat": repeatHow = .note | 39 | case "lognoterepeat": repeatHow = .note |
| 37 | case "nologrepeat": repeatHow = nil | 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 | ||
| 38 | case "logdrawer": drawer = "LOGBOOK" | 47 | case "logdrawer": drawer = "LOGBOOK" |
| 39 | case "nologdrawer": drawer = nil | 48 | case "nologdrawer": drawer = nil |
| 40 | default: break | 49 | default: break |
| @@ -53,7 +62,7 @@ struct LogSettings { | |||
| 53 | 62 | ||
| 54 | /// A note `org-add-log-setup` asked for, stored when the command ends. | 63 | /// A note `org-add-log-setup` asked for, stored when the command ends. |
| 55 | struct LogSetup { | 64 | struct LogSetup { |
| 56 | enum Purpose { case done, state } | 65 | enum Purpose { case done, state, reschedule, delschedule, redeadline, deldeadline } |
| 57 | var purpose: Purpose | 66 | var purpose: Purpose |
| 58 | var state: String? | 67 | var state: String? |
| 59 | var previous: String? | 68 | var previous: String? |
| @@ -64,7 +73,8 @@ struct LogSetup { | |||
| 64 | extension EmacsBuffer { | 73 | extension EmacsBuffer { |
| 65 | /// The logging settings for the entry at point. | 74 | /// The logging settings for the entry at point. |
| 66 | func logSettings() -> LogSettings { | 75 | func logSettings() -> LogSettings { |
| 67 | var log = LogSettings(done: options.logDone, repeatHow: options.logRepeat ? .time : nil, drawer: options.logIntoDrawer) | 76 | var log = LogSettings(done: options.logDone, repeatHow: options.logRepeat ? .time : nil, |
| 77 | reschedule: options.logReschedule, redeadline: options.logRedeadline, drawer: options.logIntoDrawer) | ||
| 68 | for sequence in settings.todoSequences { | 78 | for sequence in settings.todoSequences { |
| 69 | for keyword in sequence.active + sequence.done { | 79 | for keyword in sequence.active + sequence.done { |
| 70 | let enter = LogSettings.how(keyword.logOnEnter) | 80 | let enter = LogSettings.how(keyword.logOnEnter) |
| @@ -123,6 +133,10 @@ extension EmacsBuffer { | |||
| 123 | let about = switch setup.purpose { | 133 | let about = switch setup.purpose { |
| 124 | case .done: "closed todo item" | 134 | case .done: "closed todo item" |
| 125 | case .state: "state change from \"\(setup.previous ?? "")\" to \"\(setup.state ?? "")\"" | 135 | case .state: "state change from \"\(setup.previous ?? "")\" to \"\(setup.state ?? "")\"" |
| 136 | case .reschedule: "rescheduling" | ||
| 137 | case .delschedule: "no longer scheduled" | ||
| 138 | case .redeadline: "changing deadline" | ||
| 139 | case .deldeadline: "removing deadline" | ||
| 126 | } | 140 | } |
| 127 | return Prompt(key: "note", message: "Note for \(about):") | 141 | return Prompt(key: "note", message: "Note for \(about):") |
| 128 | } | 142 | } |
| @@ -137,6 +151,10 @@ extension EmacsBuffer { | |||
| 137 | "State " + quoted(setup.state).padding(toLength: max(12, quoted(setup.state).count), withPad: " ", startingAt: 0) | 151 | "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) | 152 | + " from " + quoted(setup.previous).padding(toLength: max(12, quoted(setup.previous).count), withPad: " ", startingAt: 0) |
| 139 | + " " + stamp | 153 | + " " + stamp |
| 154 | case .reschedule: "Rescheduled from \(Self.quotedState(setup.previous)) on \(stamp)" | ||
| 155 | case .delschedule: "Not scheduled, was \(Self.quotedState(setup.previous)) on \(stamp)" | ||
| 156 | case .redeadline: "New deadline from \(Self.quotedState(setup.previous)) on \(stamp)" | ||
| 157 | case .deldeadline: "Removed deadline, was \(Self.quotedState(setup.previous)) on \(stamp)" | ||
| 140 | } | 158 | } |
| 141 | var trimmed = text | 159 | var trimmed = text |
| 142 | while let range = trimmed.range(of: "^# .*\\n[ \\t\\n]*", options: .regularExpression) { trimmed.removeSubrange(range) } | 160 | while let range = trimmed.range(of: "^# .*\\n[ \\t\\n]*", options: .regularExpression) { trimmed.removeSubrange(range) } |
| @@ -187,4 +205,34 @@ extension EmacsBuffer { | |||
| 187 | storeLog(setup, text: "") | 205 | storeLog(setup, text: "") |
| 188 | } | 206 | } |
| 189 | } | 207 | } |
| 208 | |||
| 209 | /// `%S` in `org-log-note-headings`: a timestamp as an inactive one, in quotes. | ||
| 210 | static func quotedState(_ s: String?) -> String { | ||
| 211 | guard let s else { return "" } | ||
| 212 | if s.range(of: "^[\\[<][0-9]{4}-[0-9]{2}-[0-9]{2}", options: .regularExpression) != nil { | ||
| 213 | return "\"[" + s.dropFirst().dropLast() + "]\"" | ||
| 214 | } | ||
| 215 | return "\"" + s + "\"" | ||
| 216 | } | ||
| 217 | |||
| 218 | /// The note `org--deadline-or-schedule` logs when a planning date changes or goes, with | ||
| 219 | /// `org-log-reschedule` and `org-log-redeadline`; asks for its text with `answers`. | ||
| 220 | func logPlanningChange(_ kind: PlanningKind, old: String, new: String?, answers: [String: String]) throws { | ||
| 221 | let log = logSettings() | ||
| 222 | guard let how = kind == .deadline ? log.redeadline : log.reschedule else { return } | ||
| 223 | if let new, new == old { return } | ||
| 224 | let purpose: LogSetup.Purpose = switch (kind == .deadline, new == nil) { | ||
| 225 | case (true, true): .deldeadline | ||
| 226 | case (true, false): .redeadline | ||
| 227 | case (false, true): .delschedule | ||
| 228 | case (false, false): .reschedule | ||
| 229 | } | ||
| 230 | let setup = LogSetup(purpose: purpose, state: new, previous: old, how: how, drawer: log.drawer) | ||
| 231 | if how == .note { | ||
| 232 | guard let note = answers["note"] else { throw Ask(prompt: Self.notePrompt(setup)) } | ||
| 233 | storeLog(setup, text: note) | ||
| 234 | } else { | ||
| 235 | storeLog(setup, text: "") | ||
| 236 | } | ||
| 237 | } | ||
| 190 | } | 238 | } |
Sources/OrgCore/Keymap/Presets.swift +3
| @@ -638,6 +638,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 638 | keys = "C-c C-c" | 638 | keys = "C-c C-c" |
| 639 | command = "org.ctrl-c-ctrl-c" | 639 | command = "org.ctrl-c-ctrl-c" |
| 640 | [[bind]] | 640 | [[bind]] |
| 641 | keys = "C-c C-y" | ||
| 642 | command = "org.timestamp.evaluate-range" | ||
| 643 | [[bind]] | ||
| 641 | keys = "C-c C-x C-r" | 644 | keys = "C-c C-x C-r" |
| 642 | command = "org.clock.report" | 645 | command = "org.clock.report" |
| 643 | [[bind]] | 646 | [[bind]] |
Sources/OrgCore/Time/ReadDate.swift +11
| @@ -165,6 +165,17 @@ public enum ReadDate { | |||
| 165 | return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match) | 165 | return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match) |
| 166 | } | 166 | } |
| 167 | 167 | ||
| 168 | /// `org-read-date`'s reading of a prompt answer: an empty one takes the default date. | ||
| 169 | public static func read(_ answer: String, default defaultDate: Date?, now: Date, calendar: Calendar) -> Answer { | ||
| 170 | let def = defaultDate ?? now | ||
| 171 | var input = answer | ||
| 172 | if input.trimmingCharacters(in: .whitespaces).isEmpty { | ||
| 173 | let c = calendar.dateComponents([.year, .month, .day], from: def) | ||
| 174 | input = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) | ||
| 175 | } | ||
| 176 | return analyze(input, default: def, now: now, calendar: calendar) | ||
| 177 | } | ||
| 178 | |||
| 168 | /// `org-read-date-analyze` with `org-read-date-prefer-future` t. | 179 | /// `org-read-date-analyze` with `org-read-date-prefer-future` t. |
| 169 | public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer { | 180 | public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer { |
| 170 | let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second, .weekday] | 181 | let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second, .weekday] |
Sources/OrgEditorAppKit/OrgEditor.swift +6 −1
| @@ -325,7 +325,8 @@ public final class OrgEditor: NSObject { | |||
| 325 | return EditContext( | 325 | return EditContext( |
| 326 | revision: document.revision, text: document.text, tree: document.tree, | 326 | revision: document.revision, text: document.text, tree: document.tree, |
| 327 | selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers, | 327 | selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers, |
| 328 | hidden: hidden.all, options: options, knownTags: knownTags | 328 | hidden: hidden.all, options: options, knownTags: knownTags, |
| 329 | lastCommand: lastCommand.flatMap { $0.revision == document.revision ? $0.id : nil } | ||
| 329 | ) | 330 | ) |
| 330 | } | 331 | } |
| 331 | 332 | ||
| @@ -375,8 +376,12 @@ public final class OrgEditor: NSObject { | |||
| 375 | } | 376 | } |
| 376 | } | 377 | } |
| 377 | 378 | ||
| 379 | /// The last command run and the revision it left, for `last-command`. | ||
| 380 | private var lastCommand: (id: String, revision: Int)? | ||
| 381 | |||
| 378 | /// Runs a command by id, as a key, a menu item or the palette would. | 382 | /// Runs a command by id, as a key, a menu item or the palette would. |
| 379 | public func run(_ id: String) { | 383 | public func run(_ id: String) { |
| 384 | defer { lastCommand = (id, document.revision) } | ||
| 380 | guard let command = EditorCommands.command(id) else { | 385 | guard let command = EditorCommands.command(id) else { |
| 381 | onMessage?("Unknown command \(id)") | 386 | onMessage?("Unknown command \(id)") |
| 382 | return | 387 | return |
Sources/Orgstar/ContentView.swift +3 −1
| @@ -36,6 +36,8 @@ struct ContentView: View { | |||
| 36 | @AppStorage("prettyEntities") private var prettyEntities = true | 36 | @AppStorage("prettyEntities") private var prettyEntities = true |
| 37 | @AppStorage("logDone") private var logDone = "nil" | 37 | @AppStorage("logDone") private var logDone = "nil" |
| 38 | @AppStorage("logIntoDrawer") private var logIntoDrawer = "" | 38 | @AppStorage("logIntoDrawer") private var logIntoDrawer = "" |
| 39 | @AppStorage("logReschedule") private var logReschedule = "nil" | ||
| 40 | @AppStorage("logRedeadline") private var logRedeadline = "nil" | ||
| 39 | @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true | 41 | @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true |
| 40 | @AppStorage("hideBlockStartup") private var hideBlockStartup = false | 42 | @AppStorage("hideBlockStartup") private var hideBlockStartup = false |
| 41 | 43 | ||
| @@ -43,7 +45,7 @@ struct ContentView: View { | |||
| 43 | Preferences.options( | 45 | Preferences.options( |
| 44 | tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists, | 46 | tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists, |
| 45 | fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer, | 47 | fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer, |
| 46 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup | 48 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline |
| 47 | ) | 49 | ) |
| 48 | } | 50 | } |
| 49 | /// A block being edited apart (`org-edit-special`). | 51 | /// A block being edited apart (`org-edit-special`). |
Sources/Orgstar/DatePickerView.swift added +112
| @@ -0,0 +1,112 @@ | |||
| 1 | import OrgCore | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | /// What `org-read-date` shows beside its prompt: the date the answer reads as, and a month | ||
| 5 | /// calendar to pick one by clicking. | ||
| 6 | struct DatePickerView: View { | ||
| 7 | let answer: String | ||
| 8 | let read: Prompt.DateRead | ||
| 9 | let pick: (String) -> Void | ||
| 10 | @State private var month: Date? | ||
| 11 | private let calendar = Calendar.current | ||
| 12 | |||
| 13 | var body: some View { | ||
| 14 | let result = Self.result(answer, read) | ||
| 15 | let selected = calendar.date(from: result.components(calendar))! | ||
| 16 | let shown = month ?? selected | ||
| 17 | VStack(alignment: .leading, spacing: 6) { | ||
| 18 | HStack { | ||
| 19 | Button { month = calendar.date(byAdding: .month, value: -1, to: shown) } label: { Image(systemName: "chevron.left") } | ||
| 20 | .accessibilityLabel("Previous month") | ||
| 21 | Text(shown.formatted(.dateTime.month(.wide).year())).frame(minWidth: 140) | ||
| 22 | Button { month = calendar.date(byAdding: .month, value: 1, to: shown) } label: { Image(systemName: "chevron.right") } | ||
| 23 | .accessibilityLabel("Next month") | ||
| 24 | Spacer() | ||
| 25 | Text("=> " + Self.stamp(result, calendar)).font(.system(.callout, design: .monospaced)) | ||
| 26 | } | ||
| 27 | .buttonStyle(.plain) | ||
| 28 | Grid(horizontalSpacing: 4, verticalSpacing: 2) { | ||
| 29 | GridRow { | ||
| 30 | ForEach(weekdaySymbols.indices, id: \.self) { Text(weekdaySymbols[$0]).foregroundStyle(.secondary) } | ||
| 31 | } | ||
| 32 | ForEach(weeks(of: shown), id: \.self) { week in | ||
| 33 | GridRow { | ||
| 34 | ForEach(week.indices, id: \.self) { i in | ||
| 35 | if let day = week[i] { | ||
| 36 | dayButton(day, selected: calendar.isDate(day, inSameDayAs: selected), result: result) | ||
| 37 | } else { | ||
| 38 | Color.clear.frame(width: 1, height: 1) | ||
| 39 | } | ||
| 40 | } | ||
| 41 | } | ||
| 42 | } | ||
| 43 | } | ||
| 44 | .font(.system(.callout, design: .monospaced)) | ||
| 45 | } | ||
| 46 | .padding(10) | ||
| 47 | .onChange(of: selected) { month = nil } | ||
| 48 | } | ||
| 49 | |||
| 50 | private func dayButton(_ day: Date, selected: Bool, result: ReadDate.Answer) -> some View { | ||
| 51 | Button { | ||
| 52 | pick(Self.answer(for: day, keeping: result, calendar)) | ||
| 53 | } label: { | ||
| 54 | Text("\(calendar.component(.day, from: day))") | ||
| 55 | .frame(width: 26, height: 20) | ||
| 56 | .background(selected ? Color.accentColor.opacity(0.35) : .clear, in: RoundedRectangle(cornerRadius: 4)) | ||
| 57 | .overlay { | ||
| 58 | if calendar.isDateInToday(day) { RoundedRectangle(cornerRadius: 4).stroke(Color.accentColor) } | ||
| 59 | } | ||
| 60 | } | ||
| 61 | .buttonStyle(.plain) | ||
| 62 | .accessibilityLabel(day.formatted(date: .complete, time: .omitted)) | ||
| 63 | } | ||
| 64 | |||
| 65 | private var weekdaySymbols: [String] { | ||
| 66 | let symbols = calendar.veryShortWeekdaySymbols | ||
| 67 | let first = calendar.firstWeekday - 1 | ||
| 68 | return Array(symbols[first...] + symbols[..<first]) | ||
| 69 | } | ||
| 70 | |||
| 71 | /// The month's days in weeks, nil before the first and after the last. | ||
| 72 | private func weeks(of date: Date) -> [[Date?]] { | ||
| 73 | let start = calendar.date(from: calendar.dateComponents([.year, .month], from: date))! | ||
| 74 | let count = calendar.range(of: .day, in: .month, for: start)!.count | ||
| 75 | let lead = (calendar.component(.weekday, from: start) - calendar.firstWeekday + 7) % 7 | ||
| 76 | var days: [Date?] = Array(repeating: nil, count: lead) | ||
| 77 | days += (0..<count).map { calendar.date(byAdding: .day, value: $0, to: start) } | ||
| 78 | days += Array(repeating: nil, count: (7 - days.count % 7) % 7) | ||
| 79 | return stride(from: 0, to: days.count, by: 7).map { Array(days[$0..<$0 + 7]) } | ||
| 80 | } | ||
| 81 | |||
| 82 | static func result(_ answer: String, _ read: Prompt.DateRead) -> ReadDate.Answer { | ||
| 83 | ReadDate.read(answer, default: read.defaultDate, now: Date(), calendar: .current) | ||
| 84 | } | ||
| 85 | |||
| 86 | /// The answer for `day`, with the time `result` had. | ||
| 87 | static func answer(for day: Date, keeping result: ReadDate.Answer, _ calendar: Calendar) -> String { | ||
| 88 | let c = calendar.dateComponents([.year, .month, .day], from: day) | ||
| 89 | var text = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) | ||
| 90 | if result.timeGiven { | ||
| 91 | text += String(format: " %02d:%02d", result.hour, result.minute) | ||
| 92 | if let end = result.endTime { text += "-" + end } | ||
| 93 | } | ||
| 94 | return text | ||
| 95 | } | ||
| 96 | |||
| 97 | /// The answer moved by `days`, as S-arrows move `org-read-date`'s calendar. | ||
| 98 | static func shifted(_ answer: String, _ read: Prompt.DateRead, by days: Int) -> String { | ||
| 99 | let calendar = Calendar.current | ||
| 100 | let result = result(answer, read) | ||
| 101 | let day = calendar.date(byAdding: .day, value: days, to: calendar.date(from: result.components(calendar))!)! | ||
| 102 | return Self.answer(for: day, keeping: result, calendar) | ||
| 103 | } | ||
| 104 | |||
| 105 | static func stamp(_ result: ReadDate.Answer, _ calendar: Calendar) -> String { | ||
| 106 | let formatter = DateFormatter() | ||
| 107 | formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| 108 | formatter.dateFormat = result.timeGiven ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE" | ||
| 109 | let end = result.timeGiven ? result.endTime.map { "-" + $0 } ?? "" : "" | ||
| 110 | return "<" + formatter.string(from: calendar.date(from: result.components(calendar))!) + end + ">" | ||
| 111 | } | ||
| 112 | } | ||
Sources/Orgstar/Keys.swift +10
| @@ -55,6 +55,10 @@ struct EchoArea: View { | |||
| 55 | } | 55 | } |
| 56 | .frame(maxHeight: 200) | 56 | .frame(maxHeight: 200) |
| 57 | } | 57 | } |
| 58 | if let read = request.prompt.date { | ||
| 59 | Divider() | ||
| 60 | DatePickerView(answer: answer, read: read) { commands.answer($0) } | ||
| 61 | } | ||
| 58 | Divider() | 62 | Divider() |
| 59 | HStack(spacing: 6) { | 63 | HStack(spacing: 6) { |
| 60 | Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) | 64 | Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) |
| @@ -77,6 +81,12 @@ struct EchoArea: View { | |||
| 77 | complete(first, request.prompt) | 81 | complete(first, request.prompt) |
| 78 | return .handled | 82 | return .handled |
| 79 | } | 83 | } |
| 84 | .onKeyPress(keys: [.leftArrow, .rightArrow, .upArrow, .downArrow]) { press in | ||
| 85 | guard let read = request.prompt.date, press.modifiers == .shift else { return .ignored } | ||
| 86 | let days = [KeyEquivalent.leftArrow: -1, .rightArrow: 1, .upArrow: -7, .downArrow: 7][press.key]! | ||
| 87 | answer = DatePickerView.shifted(answer, read, by: days) | ||
| 88 | return .handled | ||
| 89 | } | ||
| 80 | .onExitCommand { commands.answer(nil) } | 90 | .onExitCommand { commands.answer(nil) } |
| 81 | } | 91 | } |
| 82 | .padding(.horizontal, 10) | 92 | .padding(.horizontal, 10) |
Sources/Orgstar/Preferences.swift +3 −2
| @@ -18,14 +18,15 @@ enum Preferences { | |||
| 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, logDone: String, logIntoDrawer: String, | 20 | hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String, |
| 21 | hideDrawerStartup: Bool, hideBlockStartup: Bool | 21 | hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String |
| 22 | ) -> EditingOptions { | 22 | ) -> EditingOptions { |
| 23 | EditingOptions( | 23 | EditingOptions( |
| 24 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, | 24 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, |
| 25 | metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn, | 25 | metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn, |
| 26 | hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true, | 26 | hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true, |
| 27 | logDone: LogHow(rawValue: logDone), logIntoDrawer: logIntoDrawer.isEmpty ? nil : logIntoDrawer, | 27 | logDone: LogHow(rawValue: logDone), logIntoDrawer: logIntoDrawer.isEmpty ? nil : logIntoDrawer, |
| 28 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup | 28 | hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, |
| 29 | logReschedule: LogHow(rawValue: logReschedule), logRedeadline: LogHow(rawValue: logRedeadline) | ||
| 29 | ) | 30 | ) |
| 30 | } | 31 | } |
| 31 | } | 32 | } |
Tests/OrgAppTests/EmacsImportTests.swift +3 −2
| @@ -18,7 +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 org-log-done 'note org-log-into-drawer t org-log-reschedule 'time) |
| 22 | (setq org-agenda-prefix-format '((agenda . " %i %-12:c%?-12t% s%b") (todo . " %-8:c"))) | 22 | (setq org-agenda-prefix-format '((agenda . " %i %-12:c%?-12t% s%b") (todo . " %-8:c"))) |
| 23 | (setq fill-column (+ 70 2)) | 23 | (setq fill-column (+ 70 2)) |
| 24 | (setq org-capture-templates | 24 | (setq org-capture-templates |
| @@ -71,6 +71,7 @@ struct EmacsImportTests { | |||
| 71 | #expect(s["org-hide-emphasis-markers"] == .bool(true)) | 71 | #expect(s["org-hide-emphasis-markers"] == .bool(true)) |
| 72 | #expect(s["org-pretty-entities"] == .bool(true)) | 72 | #expect(s["org-pretty-entities"] == .bool(true)) |
| 73 | #expect(s["org-log-done"] == .string("note")) | 73 | #expect(s["org-log-done"] == .string("note")) |
| 74 | #expect(s["org-log-reschedule"] == .string("time")) | ||
| 74 | #expect(s["org-log-into-drawer"] == .string("LOGBOOK")) | 75 | #expect(s["org-log-into-drawer"] == .string("LOGBOOK")) |
| 75 | #expect(s["org-agenda-prefix-format.agenda"] == .string(" %i %-12:c%?-12t% s%b")) | 76 | #expect(s["org-agenda-prefix-format.agenda"] == .string(" %i %-12:c%?-12t% s%b")) |
| 76 | #expect(s["org-agenda-prefix-format.todo"] == .string(" %-8:c")) | 77 | #expect(s["org-agenda-prefix-format.todo"] == .string(" %-8:c")) |
| @@ -143,7 +144,7 @@ struct EmacsImportTests { | |||
| 143 | let keymap = folder.appendingPathComponent("keymap.toml") | 144 | let keymap = folder.appendingPathComponent("keymap.toml") |
| 144 | #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) | 145 | #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) |
| 145 | #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a")) | 146 | #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a")) |
| 146 | #expect(summary.hasPrefix("Imported 16 settings, 3 capture templates, 3 key bindings."), "\(summary)") | 147 | #expect(summary.hasPrefix("Imported 17 settings, 3 capture templates, 3 key bindings."), "\(summary)") |
| 147 | #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") | 148 | #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") |
| 148 | #expect(defaults.integer(forKey: "themeFontSize") == 14) | 149 | #expect(defaults.integer(forKey: "themeFontSize") == 14) |
| 149 | } | 150 | } |
Tests/OrgCoreTests/DateCommandTests.swift added +84
| @@ -0,0 +1,84 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct DateCommandTests { | ||
| 6 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | ||
| 7 | static let seconds = 1_789_000_000 | ||
| 8 | |||
| 9 | static func run(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String], options: EditingOptions = .org, lastCommand: String? = nil) -> (text: String, message: String?) { | ||
| 10 | var utc = Calendar(identifier: .gregorian) | ||
| 11 | utc.timeZone = TimeZone(identifier: "UTC")! | ||
| 12 | var answers = answers | ||
| 13 | while true { | ||
| 14 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Date(timeIntervalSince1970: Double(seconds)), | ||
| 15 | calendar: utc, answers: answers, options: options, lastCommand: lastCommand) | ||
| 16 | switch command.run(in: context) { | ||
| 17 | case .commit(let result): | ||
| 18 | var new = text | ||
| 19 | for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) } | ||
| 20 | let message = result.effects.compactMap { if case .message(let m) = $0 { m } else { nil } }.first | ||
| 21 | return (new, message) | ||
| 22 | case .prompt(let prompt) where prompt.key == "note" && answers["note"] == nil: | ||
| 23 | answers["note"] = "moved" | ||
| 24 | case .failed(let message): return (text, "failed: \(message)") | ||
| 25 | default: return (text, "other") | ||
| 26 | } | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | @Test(.enabled(if: oracle)) | ||
| 31 | func logsReschedulesLikeOrg() throws { | ||
| 32 | let texts = ["* Task\nSCHEDULED: <2026-10-05 Mon>\n", "* Task\nDEADLINE: <2026-10-05 Mon> SCHEDULED: <2026-10-01 Thu>\nbody\n", "* Task\n"] | ||
| 33 | let actions: [(any OrgCommand, String, [String: String])] = [ | ||
| 34 | (SetPlanning(deadline: false), "(org-schedule nil \"2026-10-10\")", ["date": "2026-10-10"]), | ||
| 35 | (SetPlanning(deadline: true), "(org-deadline nil \"2026-10-12\")", ["date": "2026-10-12"]), | ||
| 36 | (RemovePlanning(deadline: false), "(org-schedule '(4))", [:]), | ||
| 37 | (RemovePlanning(deadline: true), "(org-deadline '(4))", [:]), | ||
| 38 | ] | ||
| 39 | for (how, drawer) in [(LogHow.time, nil as String?), (.note, "LOGBOOK")] { | ||
| 40 | var cases: [EmacsOracle.Case] = [] | ||
| 41 | var ours: [String] = [] | ||
| 42 | for text in texts { | ||
| 43 | for (command, form, answers) in actions { | ||
| 44 | let lisp = """ | ||
| 45 | (let ((org-log-reschedule '\(how.rawValue)) (org-log-redeadline '\(how.rawValue)) (org-log-into-drawer \(drawer.map { "\"\($0)\"" } ?? "nil")) (b (current-buffer))) | ||
| 46 | \(form) | ||
| 47 | (run-hooks 'post-command-hook) | ||
| 48 | (when (get-buffer "*Org Note*") | ||
| 49 | (with-current-buffer "*Org Note*" (goto-char (point-max)) (insert "moved") (org-store-log-note))) | ||
| 50 | (set-buffer b)) | ||
| 51 | """ | ||
| 52 | cases.append(EmacsOracle.Case(text: text, point: 3, form: frozenClock(Self.seconds, lisp))) | ||
| 53 | let options = EditingOptions(logIntoDrawer: drawer, logReschedule: how, logRedeadline: how) | ||
| 54 | ours.append(Self.run(command, text, caret: 2, answers: answers, options: options).text) | ||
| 55 | } | ||
| 56 | } | ||
| 57 | for (i, (mine, theirs)) in zip(ours, try EmacsOracle.run(cases)).enumerated() { | ||
| 58 | #expect(mine == theirs.text, "\(how) \(cases[i].form.split(separator: "\n")[2]) on \(cases[i].text.debugDescription)\nours: \(mine.debugDescription)\nemacs: \(theirs.text.debugDescription) \(theirs.error)") | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | |||
| 63 | @Test(.enabled(if: oracle)) | ||
| 64 | func insertsRangesLikeOrgTimestamp() throws { | ||
| 65 | let text = "Meet <2026-10-05 Mon> then\n" | ||
| 66 | let caret = (text as NSString).range(of: " then").location | ||
| 67 | let emacs = try EmacsOracle.run([EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: frozenClock(Self.seconds, """ | ||
| 68 | (let ((last-command 'org-timestamp) (this-command 'org-timestamp) (ord (symbol-function 'org-read-date))) | ||
| 69 | (cl-letf (((symbol-function 'org-read-date) (lambda (&optional with-time to-time _from prompt &rest _) (funcall ord with-time to-time "+2d" prompt)))) | ||
| 70 | (org-timestamp nil))) | ||
| 71 | """))]) | ||
| 72 | let ours = Self.run(InsertTimestamp(), text, caret: caret, answers: ["date": "+2d"], lastCommand: "org.timestamp.active") | ||
| 73 | #expect(ours.text == emacs[0].text) | ||
| 74 | } | ||
| 75 | |||
| 76 | @Test(.enabled(if: oracle)) | ||
| 77 | func evaluatesRangesLikeOrg() throws { | ||
| 78 | let lines = ["<2026-10-05 Mon>--<2026-10-08 Thu>", "[2026-10-05 Mon 10:00]--[2026-10-07 Wed 12:30]", "x <2026-10-05 Mon 10:00>--<2026-10-05 Mon 10:45> y", "<2026-10-05 Mon>--<2026-10-05 Mon>"] | ||
| 79 | let forms = lines.map { "(progn (erase-buffer) (insert \(lispString($0))) (goto-char 1) (org-evaluate-time-range))" } | ||
| 80 | let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))") | ||
| 81 | let ours = lines.map { Self.run(EvaluateTimeRange(), $0, caret: 0, answers: [:]).message ?? "nil" } | ||
| 82 | #expect(ours == emacs) | ||
| 83 | } | ||
| 84 | } | ||