Commit 4a16622163
Verified · cmc
Layout: unified · split
Sources/OrgApp/EmacsImport.swift +1 −1
| @@ -469,7 +469,7 @@ public struct EmacsImport: Sendable { | ||
| 469 | 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 | 473 | switch value.symbol { |
| 474 | 474 | case "nil": add(name, .string("nil")) |
| 475 | 475 | case "time", "t": add(name, .string("time")) |
Sources/OrgApp/UserConfig.swift +4
| @@ -76,6 +76,10 @@ public enum UserConfig { | ||
| 76 | 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 | 77 | Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]), |
| 78 | 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 | 83 | Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""), |
| 80 | 84 | comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""), |
| 81 | 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 | 17 | public var logDone: LogHow? |
| 18 | 18 | /// `org-log-into-drawer`: the drawer state notes go in, or nil for none. |
| 19 | 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 | 23 | /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one. |
| 21 | 24 | public var fastTodoSelection: Bool |
| 22 | 25 | /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens. |
| @@ -37,8 +40,10 @@ public struct EditingOptions: Sendable, Equatable, Codable { | ||
| 37 | 40 | listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, |
| 38 | 41 | hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false, |
| 39 | 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 | 47 | self.hideDrawerStartup = hideDrawerStartup |
| 43 | 48 | self.hideBlockStartup = hideBlockStartup |
| 44 | 49 | self.fastTodoSelection = fastTodoSelection |
| @@ -78,12 +83,15 @@ public struct EditContext: Sendable { | ||
| 78 | 83 | public let options: EditingOptions |
| 79 | 84 | /// Tags used across the workspace, for completion; read when a command needs them. |
| 80 | 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 | 89 | public init( |
| 83 | 90 | revision: Int, text: String, tree: OrgTree, selection: [Range<Int>], |
| 84 | 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 | 95 | self.knownTags = knownTags |
| 88 | 96 | self.revision = revision |
| 89 | 97 | self.text = text |
| @@ -112,10 +120,17 @@ public struct Prompt: Sendable, Equatable { | ||
| 112 | 120 | public let completionSeparator: Character? |
| 113 | 121 | /// Single keys answer instead of text. |
| 114 | 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 | 131 | public init( |
| 117 | 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 | 135 | self.key = key |
| 121 | 136 | self.message = message |
| @@ -124,6 +139,7 @@ public struct Prompt: Sendable, Equatable { | ||
| 124 | 139 | self.requireMatch = requireMatch |
| 125 | 140 | self.completionSeparator = completionSeparator |
| 126 | 141 | self.fast = fast |
| 142 | self.date = date | |
| 127 | 143 | } |
| 128 | 144 | } |
| 129 | 145 | |
| @@ -216,7 +232,7 @@ public enum Commands { | ||
| 216 | 232 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 217 | 233 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), |
| 218 | 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 | 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 | 4 | /// Reads a date prompt answer the way `org-read-date` does: an empty answer takes the |
| 5 | 5 | /// default date. |
| 6 | 6 | func readDate(_ answer: String, default defaultDate: Date?) -> ReadDate.Answer { |
| 7 | let def = defaultDate ?? now | |
| 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) | |
| 7 | ReadDate.read(answer, default: defaultDate, 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 | 13 | func date(_ components: DateComponents) -> Date { calendar.date(from: components)! } |
| 17 | 14 | |
| 18 | 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 | 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 | 46 | let old = planningValue(kind) |
| 50 | 47 | let repeatPattern = "([.+-]+[0-9]+[hdwmy](?:[/ ][-+]?[0-9]+[hdwmy])?)" |
| 51 | 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 | 67 | /// `org--deadline-or-schedule` with C-u: removes the planning timestamp. |
| @@ -108,7 +108,21 @@ public struct InsertTimestamp: OrgCommand { | ||
| 108 | 108 | public func applies(in context: EditContext) -> Bool { true } |
| 109 | 109 | public func run(in context: EditContext) -> CommandStep { |
| 110 | 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 | 127 | return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) } |
| 114 | 128 | } |
| @@ -126,10 +140,12 @@ public struct SetPlanning: OrgCommand { | ||
| 126 | 140 | let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled |
| 127 | 141 | guard let answer = context.answers["date"] else { |
| 128 | 142 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 143 | buffer.calendar = context.calendar | |
| 129 | 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 | 157 | public var title: String { deadline ? "Remove Deadline" : "Remove Schedule" } |
| 142 | 158 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } |
| 143 | 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 | 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 | 14 | struct LogSettings { |
| 15 | 15 | var done: LogHow? |
| 16 | 16 | var repeatHow: LogHow? |
| 17 | /// `org-log-reschedule` and `org-log-redeadline`. | |
| 18 | var reschedule: LogHow? | |
| 19 | var redeadline: LogHow? | |
| 17 | 20 | var drawer: String? |
| 18 | 21 | /// `org-todo-log-states`: keyword → (on entering, on leaving). |
| 19 | 22 | var states: [String: (enter: LogHow?, leave: LogHow?)] = [:] |
| @@ -35,6 +38,12 @@ struct LogSettings { | ||
| 35 | 38 | case "logrepeat": repeatHow = .time |
| 36 | 39 | case "lognoterepeat": repeatHow = .note |
| 37 | 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 | 47 | case "logdrawer": drawer = "LOGBOOK" |
| 39 | 48 | case "nologdrawer": drawer = nil |
| 40 | 49 | default: break |
| @@ -53,7 +62,7 @@ struct LogSettings { | ||
| 53 | 62 | |
| 54 | 63 | /// A note `org-add-log-setup` asked for, stored when the command ends. |
| 55 | 64 | struct LogSetup { |
| 56 | enum Purpose { case done, state } | |
| 65 | enum Purpose { case done, state, reschedule, delschedule, redeadline, deldeadline } | |
| 57 | 66 | var purpose: Purpose |
| 58 | 67 | var state: String? |
| 59 | 68 | var previous: String? |
| @@ -64,7 +73,8 @@ struct LogSetup { | ||
| 64 | 73 | extension EmacsBuffer { |
| 65 | 74 | /// The logging settings for the entry at point. |
| 66 | 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 | 78 | for sequence in settings.todoSequences { |
| 69 | 79 | for keyword in sequence.active + sequence.done { |
| 70 | 80 | let enter = LogSettings.how(keyword.logOnEnter) |
| @@ -123,6 +133,10 @@ extension EmacsBuffer { | ||
| 123 | 133 | let about = switch setup.purpose { |
| 124 | 134 | case .done: "closed todo item" |
| 125 | 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 | 141 | return Prompt(key: "note", message: "Note for \(about):") |
| 128 | 142 | } |
| @@ -137,6 +151,10 @@ extension EmacsBuffer { | ||
| 137 | 151 | "State " + quoted(setup.state).padding(toLength: max(12, quoted(setup.state).count), withPad: " ", startingAt: 0) |
| 138 | 152 | + " from " + quoted(setup.previous).padding(toLength: max(12, quoted(setup.previous).count), withPad: " ", startingAt: 0) |
| 139 | 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 | 159 | var trimmed = text |
| 142 | 160 | while let range = trimmed.range(of: "^# .*\\n[ \\t\\n]*", options: .regularExpression) { trimmed.removeSubrange(range) } |
| @@ -187,4 +205,34 @@ extension EmacsBuffer { | ||
| 187 | 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 | 638 | keys = "C-c C-c" |
| 639 | 639 | command = "org.ctrl-c-ctrl-c" |
| 640 | 640 | [[bind]] |
| 641 | keys = "C-c C-y" | |
| 642 | command = "org.timestamp.evaluate-range" | |
| 643 | [[bind]] | |
| 641 | 644 | keys = "C-c C-x C-r" |
| 642 | 645 | command = "org.clock.report" |
| 643 | 646 | [[bind]] |
Sources/OrgCore/Time/ReadDate.swift +11
| @@ -165,6 +165,17 @@ public enum ReadDate { | ||
| 165 | 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 | 179 | /// `org-read-date-analyze` with `org-read-date-prefer-future` t. |
| 169 | 180 | public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer { |
| 170 | 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 | 325 | return EditContext( |
| 326 | 326 | revision: document.revision, text: document.text, tree: document.tree, |
| 327 | 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 | 382 | /// Runs a command by id, as a key, a menu item or the palette would. |
| 379 | 383 | public func run(_ id: String) { |
| 384 | defer { lastCommand = (id, document.revision) } | |
| 380 | 385 | guard let command = EditorCommands.command(id) else { |
| 381 | 386 | onMessage?("Unknown command \(id)") |
| 382 | 387 | return |
Sources/Orgstar/ContentView.swift +3 −1
| @@ -36,6 +36,8 @@ struct ContentView: View { | ||
| 36 | 36 | @AppStorage("prettyEntities") private var prettyEntities = true |
| 37 | 37 | @AppStorage("logDone") private var logDone = "nil" |
| 38 | 38 | @AppStorage("logIntoDrawer") private var logIntoDrawer = "" |
| 39 | @AppStorage("logReschedule") private var logReschedule = "nil" | |
| 40 | @AppStorage("logRedeadline") private var logRedeadline = "nil" | |
| 39 | 41 | @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true |
| 40 | 42 | @AppStorage("hideBlockStartup") private var hideBlockStartup = false |
| 41 | 43 | |
| @@ -43,7 +45,7 @@ struct ContentView: View { | ||
| 43 | 45 | Preferences.options( |
| 44 | 46 | tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists, |
| 45 | 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 | 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 | 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 | 62 | Divider() |
| 59 | 63 | HStack(spacing: 6) { |
| 60 | 64 | Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary) |
| @@ -77,6 +81,12 @@ struct EchoArea: View { | ||
| 77 | 81 | complete(first, request.prompt) |
| 78 | 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 | 90 | .onExitCommand { commands.answer(nil) } |
| 81 | 91 | } |
| 82 | 92 | .padding(.horizontal, 10) |
Sources/Orgstar/Preferences.swift +3 −2
| @@ -18,14 +18,15 @@ enum Preferences { | ||
| 18 | 18 | static func options( |
| 19 | 19 | tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int, |
| 20 | 20 | hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String, |
| 21 | hideDrawerStartup: Bool, hideBlockStartup: Bool | |
| 21 | hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String | |
| 22 | 22 | ) -> EditingOptions { |
| 23 | 23 | EditingOptions( |
| 24 | 24 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, |
| 25 | 25 | metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn, |
| 26 | 26 | hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true, |
| 27 | 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 | 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 | (setq org-log-done 'note org-log-into-drawer t org-log-reschedule 'time) | |
| 22 | 22 | (setq org-agenda-prefix-format '((agenda . " %i %-12:c%?-12t% s%b") (todo . " %-8:c"))) |
| 23 | 23 | (setq fill-column (+ 70 2)) |
| 24 | 24 | (setq org-capture-templates |
| @@ -71,6 +71,7 @@ struct EmacsImportTests { | ||
| 71 | 71 | #expect(s["org-hide-emphasis-markers"] == .bool(true)) |
| 72 | 72 | #expect(s["org-pretty-entities"] == .bool(true)) |
| 73 | 73 | #expect(s["org-log-done"] == .string("note")) |
| 74 | #expect(s["org-log-reschedule"] == .string("time")) | |
| 74 | 75 | #expect(s["org-log-into-drawer"] == .string("LOGBOOK")) |
| 75 | 76 | #expect(s["org-agenda-prefix-format.agenda"] == .string(" %i %-12:c%?-12t% s%b")) |
| 76 | 77 | #expect(s["org-agenda-prefix-format.todo"] == .string(" %-8:c")) |
| @@ -143,7 +144,7 @@ struct EmacsImportTests { | ||
| 143 | 144 | let keymap = folder.appendingPathComponent("keymap.toml") |
| 144 | 145 | #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) |
| 145 | 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 | 148 | #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") |
| 148 | 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 | } | |