Commit 14aff41909
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/TimestampCommands.swift +12 −9
| @@ -14,12 +14,17 @@ extension EmacsBuffer { | |||
| 14 | 14 | ||
| 15 | /// `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. |
| 16 | func timestampCommand(_ answer: String, inactive: Bool) { | 16 | func timestampCommand(_ answer: String, inactive: Bool) { |
| 17 | let existing = atTimestamp() | 17 | let stamp = timestampForUpdate() |
| 18 | let stamp = existing.map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) } | ||
| 19 | let defaultDate = stamp.flatMap(Self.parseTimeString).map(date) | 18 | let defaultDate = stamp.flatMap(Self.parseTimeString).map(date) |
| 20 | let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } } | 19 | let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } } |
| 21 | let result = readDate(answer, default: defaultDate) | 20 | let result = readDate(answer, default: defaultDate) |
| 22 | let components = result.components(calendar) | 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 | if let existing { | 28 | if let existing { |
| 24 | replace(existing.match[0]!, with: "") | 29 | replace(existing.match[0]!, with: "") |
| 25 | insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime) | 30 | insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime) |
| @@ -95,8 +100,8 @@ private func atTimestamp(_ context: EditContext) -> Bool { | |||
| 95 | /// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it. | 100 | /// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it. |
| 96 | private func timeOfDayAtCaret(_ context: EditContext) -> String { | 101 | private func timeOfDayAtCaret(_ context: EditContext) -> String { |
| 97 | let buffer = EmacsBuffer(context.text, point: context.caret) | 102 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 98 | guard let match = buffer.atTimestamp()?.match[0] else { return "" } | 103 | guard let stamp = buffer.timestampForUpdate() else { return "" } |
| 99 | return EmacsBuffer.compactTimeOfDay(buffer.substring(match.lowerBound, match.upperBound)) ?? "" | 104 | return EmacsBuffer.compactTimeOfDay(stamp) ?? "" |
| 100 | } | 105 | } |
| 101 | 106 | ||
| 102 | /// C-c . and C-c !: `org-timestamp`. | 107 | /// C-c . and C-c !: `org-timestamp`. |
| @@ -110,16 +115,14 @@ public struct InsertTimestamp: OrgCommand { | |||
| 110 | guard let answer = context.answers["date"] else { | 115 | guard let answer = context.answers["date"] else { |
| 111 | let buffer = EmacsBuffer(context.text, point: context.caret) | 116 | let buffer = EmacsBuffer(context.text, point: context.caret) |
| 112 | buffer.calendar = context.calendar | 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 | return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context), | 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 | // Twice in a row on a timestamp: a range to a second one. | 121 | // 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" | 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 | return timestampCommand(context) { buffer in | 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 | buffer.insert("--") | 126 | buffer.insert("--") |
| 124 | buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive) | 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 | return (part, match) | 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 | /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent. | 94 | /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent. |
| 70 | static func parseTimeString(_ text: String) -> DateComponents? { | 95 | static func parseTimeString(_ text: String) -> DateComponents? { |
| 71 | guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil } | 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 | static let now = ReadDateTests.now | 6 | static let now = ReadDateTests.now |
| 7 | static let calendar = ReadDateTests.utc | 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 | let context = EditContext( | 10 | let context = EditContext( |
| 11 | revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], | 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 | switch command.run(in: context) { | 14 | switch command.run(in: context) { |
| 15 | case .commit(let result): | 15 | case .commit(let result): |
| @@ -100,6 +100,50 @@ struct TimestampCommandTests { | |||
| 100 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") | 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 | /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`). | 147 | /// Shifting a timestamp on a CLOCK line rewrites the duration (`org-clock-update-time-maybe`). |
| 104 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | 148 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) |
| 105 | func clockShiftsMatchEmacs() throws { | 149 | func clockShiftsMatchEmacs() throws { |