Commit 14aff41909
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/TimestampCommands.swift +12 −9
| @@ -14,12 +14,17 @@ extension EmacsBuffer { | ||
| 14 | 14 | |
| 15 | 15 | /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one. |
| 16 | 16 | func timestampCommand(_ answer: String, inactive: Bool) { |
| 17 | let existing = atTimestamp() | |
| 18 | let stamp = existing.map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | |
| 17 | let stamp = timestampForUpdate() | |
| 19 | 18 | let defaultDate = stamp.flatMap(Self.parseTimeString).map(date) |
| 20 | 19 | let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } } |
| 21 | 20 | let result = readDate(answer, default: defaultDate) |
| 22 | 21 | let components = result.components(calendar) |
| 22 | // Between a range's dashes, the end is replaced. | |
| 23 | var existing = atTimestamp() | |
| 24 | if stamp != nil, existing == nil { | |
| 25 | skipForward([45]) | |
| 26 | existing = atTimestamp() | |
| 27 | } | |
| 23 | 28 | if let existing { |
| 24 | 29 | replace(existing.match[0]!, with: "") |
| 25 | 30 | insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime) |
| @@ -95,8 +100,8 @@ private func atTimestamp(_ context: EditContext) -> Bool { | ||
| 95 | 100 | /// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it. |
| 96 | 101 | private func timeOfDayAtCaret(_ context: EditContext) -> String { |
| 97 | 102 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 98 | guard let match = buffer.atTimestamp()?.match[0] else { return "" } | |
| 99 | return EmacsBuffer.compactTimeOfDay(buffer.substring(match.lowerBound, match.upperBound)) ?? "" | |
| 103 | guard let stamp = buffer.timestampForUpdate() else { return "" } | |
| 104 | return EmacsBuffer.compactTimeOfDay(stamp) ?? "" | |
| 100 | 105 | } |
| 101 | 106 | |
| 102 | 107 | /// C-c . and C-c !: `org-timestamp`. |
| @@ -110,16 +115,14 @@ public struct InsertTimestamp: OrgCommand { | ||
| 110 | 115 | guard let answer = context.answers["date"] else { |
| 111 | 116 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 112 | 117 | 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 | 118 | return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context), |
| 116 | date: .init(defaultDate: repeated ? nil : buffer.dateDefault(stamp), inactive: inactive))) | |
| 119 | date: .init(defaultDate: buffer.dateDefault(buffer.timestampForUpdate()), inactive: inactive))) | |
| 117 | 120 | } |
| 118 | 121 | // Twice in a row on a timestamp: a range to a second one. |
| 119 | 122 | let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive" |
| 120 | if repeated, EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil { | |
| 123 | if repeated, EmacsBuffer(context.text, point: context.caret).timestampForUpdate() != nil { | |
| 121 | 124 | return timestampCommand(context) { buffer in |
| 122 | let result = buffer.readDate(answer, default: nil) | |
| 125 | let result = buffer.readDate(answer, default: buffer.dateDefault(buffer.timestampForUpdate())) | |
| 123 | 126 | buffer.insert("--") |
| 124 | 127 | buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive) |
| 125 | 128 | } |
Sources/OrgCore/Commands/Timestamps.swift +25
| @@ -66,6 +66,31 @@ extension EmacsBuffer { | ||
| 66 | 66 | return (part, match) |
| 67 | 67 | } |
| 68 | 68 | |
| 69 | /// `org-at-date-range-p` with INACTIVE-OK: the range around point, groups 1 and 2 the insides | |
| 70 | /// of its two timestamps. | |
| 71 | func atDateRange() -> [Range<Int>?]? { | |
| 72 | let pattern = "[\\[<](" + AgendaSource.tsInternal + ")[\\]>]--?-?[\\[<](" + AgendaSource.tsInternal + ")[\\]>]" | |
| 73 | return saveExcursion { | |
| 74 | let pos = point | |
| 75 | for _ in 0..<2 { | |
| 76 | while let c = character(at: point - 1), ![91, 60, 13, 10].contains(c) { point -= 1 } | |
| 77 | skipBackward([60, 91]) | |
| 78 | if let match = lookingAt(pattern), match[0]!.upperBound >= pos { return match } | |
| 79 | } | |
| 80 | return nil | |
| 81 | } | |
| 82 | } | |
| 83 | ||
| 84 | /// The timestamp `org-timestamp` takes its defaults from: in a date range the start before | |
| 85 | /// the dashes and the end from the second-to-last dash on, otherwise the one at point. | |
| 86 | func timestampForUpdate() -> String? { | |
| 87 | if let range = atDateRange() { | |
| 88 | let stamp = point < range[2]!.lowerBound - 2 ? range[1]! : range[2]! | |
| 89 | return substring(stamp.lowerBound, stamp.upperBound) | |
| 90 | } | |
| 91 | return atTimestamp().map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | |
| 92 | } | |
| 93 | ||
| 69 | 94 | /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent. |
| 70 | 95 | static func parseTimeString(_ text: String) -> DateComponents? { |
| 71 | 96 | guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil } |
Tests/OrgCoreTests/TimestampCommandTests.swift +46 −2
| @@ -6,10 +6,10 @@ struct TimestampCommandTests { | ||
| 6 | 6 | static let now = ReadDateTests.now |
| 7 | 7 | static let calendar = ReadDateTests.utc |
| 8 | 8 | |
| 9 | func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil) -> (text: String, caret: Int, failure: String?) { | |
| 9 | func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil, lastCommand: String? = nil) -> (text: String, caret: Int, failure: String?) { | |
| 10 | 10 | let context = EditContext( |
| 11 | 11 | revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], |
| 12 | now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:] | |
| 12 | now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:], lastCommand: lastCommand | |
| 13 | 13 | ) |
| 14 | 14 | switch command.run(in: context) { |
| 15 | 15 | case .commit(let result): |
| @@ -100,6 +100,50 @@ struct TimestampCommandTests { | ||
| 100 | 100 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") |
| 101 | 101 | } |
| 102 | 102 | |
| 103 | /// `org-timestamp` in and around date ranges: the start before the dashes, the end from the | |
| 104 | /// second dash on, and twice in a row a new range wherever there is a stamp to default from. | |
| 105 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 106 | func timestampsInRangesMatchEmacs() throws { | |
| 107 | try #require(EmacsOracle.isAvailable) | |
| 108 | let texts = [ | |
| 109 | "* a\n:LOGBOOK:\nCLOCK: [2026-09-01 Tue 10:00]--[2026-09-01 Tue 11:30] => 1:30\n:END:\n", | |
| 110 | "x <2026-09-01 Tue>--<2026-09-03 Thu> y\n", | |
| 111 | "[2026-09-01 Tue]-[2026-09-02 Wed]\n", | |
| 112 | "<2026-09-01 Tue 10:00>---<2026-09-01 Tue 12:00 +1w>\n", | |
| 113 | ] | |
| 114 | var cases: [EmacsOracle.Case] = [] | |
| 115 | var ours: [(label: String, text: String, caret: Int, failed: Bool)] = [] | |
| 116 | for text in texts { | |
| 117 | for caret in EmacsOracle.positions(text) { | |
| 118 | for (command, form) in [(InsertTimestamp(), "(org-timestamp nil)"), (InsertTimestamp(inactive: true), "(org-timestamp nil t)")] { | |
| 119 | for answer in ["", "+1", "10:00", "++2d"] { | |
| 120 | for repeated in [false, true] { | |
| 121 | let commands = repeated ? "(last-command 'org-timestamp) (this-command 'org-timestamp)" : "" | |
| 122 | let wrapped = frozenClock(1_789_000_000, """ | |
| 123 | (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "\(answer)"))) | |
| 124 | (let ((org-read-date-popup-calendar nil) \(commands)) \(form))) | |
| 125 | """) | |
| 126 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: wrapped)) | |
| 127 | let result = run(command, text, caret: caret, answer: answer, lastCommand: repeated ? "org.timestamp.active" : nil) | |
| 128 | ours.append(("\(command.id) \(answer.debugDescription)\(repeated ? " repeated" : "") at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil)) | |
| 129 | } | |
| 130 | } | |
| 131 | } | |
| 132 | } | |
| 133 | } | |
| 134 | var mismatches = 0 | |
| 135 | for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) { | |
| 136 | let failed = !emacs.error.isEmpty | |
| 137 | let caret = EmacsOracle.offset(emacs.point, in: emacs.text) | |
| 138 | guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue } | |
| 139 | mismatches += 1 | |
| 140 | if mismatches <= 12 { | |
| 141 | Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")") | |
| 142 | } | |
| 143 | } | |
| 144 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") | |
| 145 | } | |
| 146 | ||
| 103 | 147 | /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`). |
| 104 | 148 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 105 | 149 | func clockShiftsMatchEmacs() throws { |