krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 5ac7dd8915

5ac7dd8915d13640badfe09136d90e6b673166a3

parent: cacf40a484

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 05:08 UTC

Port timestamp and planning commands

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +10 −1
@@ -11,12 +11,18 @@ public struct EditingOptions: Sendable, Equatable, Codable {
1111 public var metaReturnMaySplitLine: Bool
1212 /// `org-list-allow-alphabetical`: `a.` and `A)` are list bullets.
1313 public var listAllowAlphabetical: Bool
14 /// `org-log-repeat` `time`: completing a repeating entry records LAST_REPEAT and a state note.
15 public var logRepeat: Bool
1416
15 public init(tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, listAllowAlphabetical: Bool = false) {
17 public init(
18 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true,
19 listAllowAlphabetical: Bool = false, logRepeat: Bool = true
20 ) {
1621 self.tagsColumn = tagsColumn
1722 self.insertHeadingRespectContent = insertHeadingRespectContent
1823 self.metaReturnMaySplitLine = metaReturnMaySplitLine
1924 self.listAllowAlphabetical = listAllowAlphabetical
25 self.logRepeat = logRepeat
2026 }
2127
2228 /// Org's own defaults.
@@ -141,6 +147,9 @@ public enum Commands {
141147 SetTags(),
142148 InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(),
143149 MoveItemUp(), MoveItemDown(), ToggleCheckbox(),
150 InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true),
151 RemovePlanning(deadline: false), RemovePlanning(deadline: true),
152 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
144153 ]
145154
146155 public static func command(_ id: String) -> (any OrgCommand)? {
Sources/OrgCore/Commands/EmacsBuffer.swift +7
@@ -27,6 +27,11 @@ final class EmacsBuffer {
2727 let settings: OrgSettings
2828 /// Groups of the last successful match, as Emacs's match data.
2929 var lastMatch: [Range<Int>?] = []
30 /// The clock and calendar commands see.
31 var now = Date()
32 var calendar = Calendar.current
33 /// `org-last-inserted-timestamp`.
34 var lastInsertedTimestamp: String?
3035
3136 let options: EditingOptions
3237
@@ -434,6 +439,8 @@ final class EmacsBuffer {
434439/// and the new caret. A user error becomes `.failed`.
435440func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
436441 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
442 buffer.now = context.now
443 buffer.calendar = context.calendar
437444 do {
438445 try body(buffer)
439446 } catch let error as EmacsBuffer.UserError {
Sources/OrgCore/Commands/HeadingCommands.swift +11 −2
@@ -220,9 +220,18 @@ public struct TodoCycle: OrgCommand {
220220 /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are
221221 /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags
222222 /// are aligned.
223 /// `org-todo`, then the parent's TODO statistics.
223 /// `org-todo`, then the parent's TODO statistics, and the repeat of a repeating entry
224 /// that became done.
224225 public func run(in context: EditContext) -> CommandStep {
225 followUp(context, cycle(in: context)) { $0.updateParentTodoStatistics() }
226 let previous = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword
227 return followUp(context, cycle(in: context)) { buffer in
228 try buffer.saveExcursion {
229 buffer.updateParentTodoStatistics()
230 let settings = context.tree.settings
231 guard let new = buffer.todoKeyword, settings.isDone(new), !(previous.map(settings.isDone) ?? false) else { return }
232 if let (marker, note) = try buffer.autoRepeat(doneWord: new, previous: previous) { buffer.storeNote(marker, note) }
233 }
234 }
226235 }
227236
228237 private func cycle(in context: EditContext) -> CommandStep {
Sources/OrgCore/Commands/Repeat.swift added +162
@@ -0,0 +1,162 @@
1import Foundation
2
3// Repeating entries (`org-auto-repeat-maybe`, Org 9.8.7) and the state note org logs for them
4// (`org-add-log-setup`, `org-store-log-note`, `org-log-beginning`), with `org-log-repeat`
5// `time`, `org-log-into-drawer` nil, `org-todo-repeat-to-state` nil.
6
7extension EmacsBuffer {
8 static let repeatPattern = "<[0-9]{4}-[0-9][0-9]-[0-9][0-9] [^>\\n]*?([.+]?\\+[0-9]+[hdwmy](/[0-9]+[hdwmy])?)"
9
10 /// `org-entry-end-position`: the next heading, or the end of the buffer.
11 var entryEnd: Int {
12 saveExcursion {
13 _ = try? backToHeading()
14 outlineNextHeading()
15 return point
16 }
17 }
18
19 /// `org-get-repeat`.
20 func repeatCookie() -> String? {
21 saveExcursion {
22 guard (try? backToHeading()) != nil else { return nil }
23 let end = entryEnd
24 return searchForward(Self.repeatPattern, bound: end, caseInsensitive: false).map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
25 }
26 }
27
28 /// The TODO keyword of the heading at point, if any.
29 var todoKeyword: String? {
30 saveExcursion {
31 guard (try? backToHeading()) != nil, let match = lookingAt(todoLinePattern), let keyword = match[2] else { return nil }
32 return substring(keyword.lowerBound, keyword.upperBound)
33 }
34 }
35
36 /// The keyword change `org-todo` makes, with its tag alignment and statistics update.
37 func setTodoKeyword(_ keyword: String?) {
38 saveExcursion {
39 guard (try? backToHeading()) != nil, let stars = lookingAt("\\*+ ") else { return }
40 point = stars[0]!.upperBound - 1
41 let names = keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|")
42 let match = lookingAt(" +(" + names + ")( +|[ \\t]*$)") ?? lookingAt("(?: *|[ \\t]*$)")!
43 replace(match[0]!, with: "")
44 point = match[0]!.lowerBound
45 insertBeforeMarkers(keyword.map { " " + $0 + " " } ?? " ")
46 alignTags(onLineAt: point)
47 updateParentTodoStatistics()
48 }
49 }
50
51 /// `org-auto-repeat-maybe` after `doneWord` replaced `previous`. Returns the note to log, if
52 /// any, with the marker it belongs at.
53 func autoRepeat(doneWord: String, previous: String?) throws -> (Marker, String)? {
54 guard let cookie = repeatCookie(), (Int(cookie.drop { !$0.isNumber }.prefix { $0.isNumber }) ?? 0) != 0 else { return nil }
55 let end = marker(entryEnd)
56 defer { release(end) }
57 let sequence = settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == previous } }
58 let head = sequence.flatMap { ($0.active + $0.done).first?.name }
59 let propertyState = inheritedProperty("REPEAT_TO_STATE")?.value
60 let toState = propertyState.flatMap { keywordsInOrder.contains($0) ? $0 : nil }
61 ?? (sequence?.kind == .type ? previous : head)
62 setTodoKeyword(toState)
63 try backToHeading()
64 try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed])
65 let stamp = formatTimestamp(calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now), withTime: true, inactive: true)
66 if options.logRepeat { entryPut("LAST_REPEAT", stamp) }
67 let note = marker(point)
68 if let scheduled = planningValue(.scheduled), ("<" + scheduled + ">").range(of: Self.repeatPattern, options: .regularExpression) == nil {
69 saveExcursion { removePlanning(.scheduled) }
70 }
71 let units: [String: TimestampUnit] = ["h": .hour, "d": .day, "m": .month, "y": .year]
72 let today = calendar.startOfDay(for: now)
73 while let match = searchForward(Self.repeatPattern, bound: end.position, caseInsensitive: false) {
74 let stamp = substring(match[0]!.lowerBound, match[0]!.upperBound)
75 guard let parts = stamp.firstMatch(of: /([.+])?(\+[0-9]+)([hdwmy])/) else { continue }
76 var n = Int(parts.2.dropFirst())!
77 var what = String(parts.3)
78 if what == "w" { n *= 7; what = "d" }
79 guard let unit = units[what] else { continue }
80 if what == "h", stamp.range(of: "[0-9]{1,2}:[0-9]{2}", options: .regularExpression) == nil {
81 throw UserError(message: "Cannot repeat in \(n) hour(s) because no hour has been set")
82 }
83 if let kind = parts.1 {
84 let time = Self.parseTimeString(stamp).map(date)!
85 if kind == "." {
86 if what != "h" {
87 let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: time), to: today).day!
88 try changeTimestamp(days, what: .day)
89 }
90 } else {
91 var shifts = 0
92 var current = time
93 while shifts == 0 || (what == "h" ? current <= now : calendar.startOfDay(for: current) <= today) {
94 shifts += 1
95 if shifts > 100_000 { break }
96 try changeTimestamp(n, what: unit)
97 let found = inRegexp(Self.timestampPattern)!
98 current = Self.parseTimeString(substring(found[1]!.lowerBound, found[1]!.upperBound)).map(date)!
99 }
100 try changeTimestamp(-n, what: unit)
101 }
102 }
103 try saveExcursion { try changeTimestamp(n, what: unit, suppressDelay: true) }
104 }
105 guard options.logRepeat else {
106 release(note)
107 return nil
108 }
109 func quoted(_ text: String?) -> String { text.map { "\"\($0)\"" } ?? "" }
110 let line = "State " + quoted(doneWord).padding(toLength: max(12, quoted(doneWord).count), withPad: " ", startingAt: 0)
111 + " from " + quoted(previous).padding(toLength: max(12, quoted(previous).count), withPad: " ", startingAt: 0)
112 + " " + stamp
113 return (note, line)
114 }
115
116 /// `org-store-log-note` for a one-line note at `marker`.
117 func storeNote(_ marker: Marker, _ text: String) {
118 saveExcursion {
119 point = marker.position
120 release(marker)
121 point = logBeginning()
122 if !bolp {
123 insert("\n")
124 } else if lookingAt("[ \\t]*\\S") != nil {
125 saveExcursion { insert("\n") }
126 }
127 if let item = inItem() {
128 let structure = saveExcursion { point = item; return listStruct() }
129 indentLineTo(structure.ind(structure.top))
130 } else {
131 indentLineTo(0)
132 }
133 insert("- " + text)
134 }
135 }
136
137 /// `org-end-of-meta-data` without FULL.
138 func endOfMetaData() {
139 _ = try? backToHeading()
140 forwardLine(1)
141 if lookingAt(Self.planningLinePattern) != nil { forwardLine(1) }
142 if let drawer = looking(Self.propertyDrawerPattern) {
143 point = drawer[0]!.upperBound
144 forwardLine(1)
145 }
146 }
147
148 /// `org-log-beginning` without a log drawer, with `org-log-states-order-reversed` t: the
149 /// newest note comes first.
150 func logBeginning() -> Int {
151 saveExcursion {
152 endOfMetaData()
153 let endpos = point
154 skipForward([32, 9, 10])
155 forwardLine(0)
156 if point < endpos { point = endpos }
157 if bolp { return point }
158 let end = lineEnd(point)
159 return end < length ? end + 1 : length
160 }
161 }
162}
Sources/OrgCore/Commands/TimestampCommands.swift added +170
@@ -0,0 +1,170 @@
1import Foundation
2
3extension EmacsBuffer {
4 /// Reads a date prompt answer the way `org-read-date` does: an empty answer takes the
5 /// default date.
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)
14 }
15
16 func date(_ components: DateComponents) -> Date { calendar.date(from: components)! }
17
18 /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one.
19 func timestampCommand(_ answer: String, inactive: Bool) {
20 let existing = atTimestamp()
21 let stamp = existing.map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) }
22 let defaultDate = stamp.flatMap(Self.parseTimeString).map(date)
23 let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } }
24 let result = readDate(answer, default: defaultDate)
25 let components = result.components(calendar)
26 if let existing {
27 replace(existing.match[0]!, with: "")
28 insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
29 if let repeater {
30 point -= 1
31 insert(" " + repeater)
32 }
33 } else {
34 insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
35 }
36 }
37
38 /// The first timestamp after `kind` in the entry, as the date prompt's default.
39 func planningDefault(_ kind: PlanningKind) -> String? {
40 saveExcursion {
41 guard (try? backToHeading()) != nil else { return nil }
42 let end = saveExcursion { () -> Int in outlineNextHeading(); return point }
43 return searchForward(kind.pattern, bound: end, caseInsensitive: false).map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
44 }
45 }
46
47 /// `org--deadline-or-schedule` without a prefix argument.
48 func schedule(_ kind: PlanningKind, answer: String) throws {
49 let old = planningValue(kind)
50 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!) } }
52 let defaultDate = planningDefault(kind).flatMap(Self.parseTimeString).map(date)
53 let result = readDate(answer, default: defaultDate)
54 try addPlanningInfo(kind, time: result.components(calendar), withTime: result.timeGiven, endTime: result.endTime, remove: [.closed])
55 if let repeater, let inserted = lastInsertedTimestamp {
56 try saveExcursion {
57 try backToHeading()
58 let bound = saveExcursion { () -> Int in forwardLine(2); return lineEnd(point) }
59 if let match = searchForward(NSRegularExpression.escapedPattern(for: kind.keyword + " " + inserted), bound: bound, caseInsensitive: false) {
60 point = match[0]!.upperBound - 1
61 insert(" " + repeater)
62 }
63 }
64 }
65 }
66
67 /// `org--deadline-or-schedule` with C-u: removes the planning timestamp.
68 func removePlanning(_ kind: PlanningKind) {
69 guard planningValue(kind) != nil else { return }
70 saveExcursion {
71 guard let begin = try? backToHeading() else { return }
72 outlineNextHeading()
73 let pattern = "\\b" + NSRegularExpression.escapedPattern(for: kind.keyword) + " +<[^>\\n]+>[ \\t]*"
74 while let match = searchBackward(pattern, bound: begin, caseInsensitive: false) {
75 replace(match[0]!, with: "")
76 let before = substring(lineStart(point), point)
77 if before.contains(where: { !$0.isWhitespace }), character(at: point - 1) == 32 {
78 deleteRegion(point - 1, point)
79 } else if substring(lineStart(point), lineEnd(point)).allSatisfy({ $0 == " " || $0 == "\t" }) {
80 deleteRegion(lineStart(point), min(length, lineEnd(point) + 1))
81 }
82 }
83 }
84 }
85}
86
87private func timestampCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
88 commitBuffer(context, body)
89}
90
91private func atTimestamp(_ context: EditContext) -> Bool {
92 EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
93}
94
95/// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it.
96private func timeOfDayAtCaret(_ context: EditContext) -> String {
97 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)) ?? ""
100}
101
102/// C-c . and C-c !: `org-timestamp`.
103public struct InsertTimestamp: OrgCommand {
104 let inactive: Bool
105 public init(inactive: Bool = false) { self.inactive = inactive }
106 public var id: String { inactive ? "org.timestamp.inactive" : "org.timestamp.active" }
107 public var title: String { inactive ? "Insert Inactive Timestamp" : "Insert Timestamp" }
108 public func applies(in context: EditContext) -> Bool { true }
109 public func run(in context: EditContext) -> CommandStep {
110 guard let answer = context.answers["date"] else {
111 return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context)))
112 }
113 return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) }
114 }
115}
116
117/// C-c C-s and C-c C-d: `org-schedule` and `org-deadline`.
118public struct SetPlanning: OrgCommand {
119 let deadline: Bool
120 public init(deadline: Bool) { self.deadline = deadline }
121 public var id: String { deadline ? "org.deadline" : "org.schedule" }
122 public var title: String { deadline ? "Set Deadline" : "Schedule" }
123 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
124 public func run(in context: EditContext) -> CommandStep {
125 guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") }
126 let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled
127 guard let answer = context.answers["date"] else {
128 let buffer = EmacsBuffer(context.text, point: context.caret)
129 let initial = buffer.planningDefault(kind).flatMap(EmacsBuffer.compactTimeOfDay) ?? ""
130 return .prompt(Prompt(key: "date", message: deadline ? "DEADLINE:" : "SCHEDULED:", initial: initial))
131 }
132 return timestampCommand(context) { try $0.schedule(kind, answer: answer) }
133 }
134}
135
136/// C-u C-c C-s and C-u C-c C-d.
137public struct RemovePlanning: OrgCommand {
138 let deadline: Bool
139 public init(deadline: Bool) { self.deadline = deadline }
140 public var id: String { deadline ? "org.deadline.remove" : "org.schedule.remove" }
141 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 }
143 public func run(in context: EditContext) -> CommandStep {
144 timestampCommand(context) { $0.removePlanning(deadline ? .deadline : .scheduled) }
145 }
146}
147
148/// S-up and S-down on a timestamp: `org-timestamp-up` and `org-timestamp-down`.
149public struct ShiftTimestamp: OrgCommand {
150 let up: Bool
151 public init(up: Bool) { self.up = up }
152 public var id: String { up ? "org.timestamp.up" : "org.timestamp.down" }
153 public var title: String { up ? "Increase Timestamp Part" : "Decrease Timestamp Part" }
154 public func applies(in context: EditContext) -> Bool { atTimestamp(context) }
155 public func run(in context: EditContext) -> CommandStep {
156 timestampCommand(context) { try $0.changeTimestamp(up ? 1 : -1, updown: true) }
157 }
158}
159
160/// S-right and S-left on a timestamp: `org-timestamp-up-day` and `org-timestamp-down-day`.
161public struct ShiftTimestampDay: OrgCommand {
162 let later: Bool
163 public init(later: Bool) { self.later = later }
164 public var id: String { later ? "org.timestamp.day-later" : "org.timestamp.day-earlier" }
165 public var title: String { later ? "Timestamp One Day Later" : "Timestamp One Day Earlier" }
166 public func applies(in context: EditContext) -> Bool { atTimestamp(context) }
167 public func run(in context: EditContext) -> CommandStep {
168 timestampCommand(context) { try $0.changeTimestamp(later ? 1 : -1, what: .day, updown: true) }
169 }
170}
Sources/OrgCore/Commands/Timestamps.swift added +324
@@ -0,0 +1,324 @@
1import Foundation
2
3// Timestamps and planning, ported from Org 9.8.7: `org-at-timestamp-p`, `org-timestamp`,
4// `org-timestamp-change`, `org-modify-ts-extra`, `org-add-planning-info`,
5// `org--deadline-or-schedule`. `org-time-stamp-rounding-minutes` (0 5), `org-adapt-indentation`
6// nil, `org-log-reschedule` and `org-log-redeadline` nil.
7
8extension EmacsBuffer {
9 static let timestampCore = "(([0-9]{4})-([0-9]{2})-([0-9]{2})(?: *([^\\]+0-9>\\r\\n -]+))?( ([0-9]{1,2}):([0-9]{2}))?)"
10 /// `org-ts-regexp3`: groups 2 year, 3 month, 4 day, 5 day name, 7 hour, 8 minute.
11 static let timestampPattern = "[\\[<]" + timestampCore + "[^\\]>\\n]{0,16}[\\]>]"
12 /// `org-ts-regexp0`, for time strings.
13 static let timeStringPattern = "(([0-9]{4})-([0-9]{2})-([0-9]{2})( +[^\\]+0-9>\\r\\n -]+)?( +([0-9]{1,2}):([0-9]{2}))?)"
14
15 /// `org-in-regexp` on the current line: the match that contains point.
16 func inRegexp(_ pattern: String) -> [Range<Int>?]? {
17 let regex = Self.regex(pattern, caseInsensitive: false)
18 let lineRange = NSRange(lineStart(point)..<lineEnd(point))
19 for match in regex.matches(in: string, options: Self.searchOptions, range: lineRange) {
20 let range = match.range.location..<NSMaxRange(match.range)
21 guard range.lowerBound <= point else { break }
22 if range.upperBound >= point {
23 let groups = (0..<match.numberOfRanges).map { i -> Range<Int>? in
24 let r = match.range(at: i)
25 return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
26 }
27 lastMatch = groups
28 return groups
29 }
30 }
31 return nil
32 }
33
34 enum TimestampPart: Equatable {
35 case bracket, after, year, month, hour, minute, day
36 /// Characters past the minutes or day name, in the repeater or end time.
37 case extra(Int)
38 }
39
40 /// `org-at-timestamp-p` with `lax`: where in a timestamp point is, and the match.
41 func atTimestamp() -> (part: TimestampPart, match: [Range<Int>?])? {
42 guard let match = inRegexp(Self.timestampPattern) else { return nil }
43 let pos = point
44 func inGroup(_ i: Int) -> Bool { match[i].map { $0.lowerBound <= pos && pos <= $0.upperBound } ?? false }
45 let whole = match[0]!
46 let part: TimestampPart
47 if pos == whole.lowerBound || pos == whole.upperBound - 1 {
48 part = .bracket
49 } else if pos == whole.upperBound {
50 part = .after
51 } else if inGroup(2) {
52 part = .year
53 } else if inGroup(3) {
54 part = .month
55 } else if inGroup(7) {
56 part = .hour
57 } else if inGroup(8) {
58 part = .minute
59 } else if inGroup(4) || inGroup(5) {
60 part = .day
61 } else if let last = match[8]?.upperBound ?? match[5]?.upperBound, pos > last, pos < whole.upperBound {
62 part = .extra(pos - last)
63 } else {
64 part = .day
65 }
66 return (part, match)
67 }
68
69 /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent.
70 static func parseTimeString(_ text: String) -> DateComponents? {
71 guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil }
72 func group(_ i: Int) -> Int? { match.output[i].substring.flatMap { Int($0) } }
73 return DateComponents(year: group(2), month: group(3), day: group(4), hour: group(7) ?? 0, minute: group(8) ?? 0)
74 }
75
76 /// `format-time-string` with Org's timestamp formats, plus `extra` before the closing bracket.
77 func formatTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, extra: String? = nil) -> String {
78 let date = calendar.date(from: components)!
79 let formatter = DateFormatter()
80 formatter.locale = Locale(identifier: "en_US_POSIX")
81 formatter.calendar = calendar
82 formatter.timeZone = calendar.timeZone
83 formatter.dateFormat = withTime ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
84 return (inactive ? "[" : "<") + formatter.string(from: date) + (extra ?? "") + (inactive ? "]" : ">")
85 }
86
87 /// `org-insert-timestamp`: inserted before markers, as org does.
88 @discardableResult
89 func insertTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, endTime: String? = nil, extra: String? = nil) -> String {
90 var suffix = extra
91 if let endTime, let match = endTime.firstMatch(of: /([0-9]+):([0-9]+)/) {
92 suffix = String(format: "-%02d:%02d", Int(match.1)!, Int(match.2)!)
93 }
94 let stamp = formatTimestamp(components, withTime: withTime, inactive: inactive, extra: suffix)
95 insertBeforeMarkers(stamp)
96 lastInsertedTimestamp = stamp
97 return stamp
98 }
99
100 /// `org-modify-ts-extra`.
101 static func modifyTimestampExtra(_ extra: String, _ position: Int, _ increments: Int, _ step: Int) -> String {
102 let regex = try! NSRegularExpression(pattern: "(-([012][0-9]):([0-5][0-9]))?( +\\+([0-9]+)([dmwy]))?( +-([0-9]+)([dmwy]))?")
103 let ns = extra as NSString
104 guard let match = regex.firstMatch(in: extra, range: NSRange(location: 0, length: ns.length)) else { return extra }
105 func range(_ i: Int) -> Range<Int>? {
106 let r = match.range(at: i)
107 return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
108 }
109 func inGroup(_ i: Int) -> Bool { range(i).map { $0.lowerBound <= position && position <= $0.upperBound } ?? false }
110 func text(_ i: Int) -> String { ns.substring(with: match.range(at: i)) }
111 let order: [(String, Int)] = [("d", 0), ("w", 1), ("m", 2), ("y", 3), ("d", -1), ("y", 4)]
112 func shifted(_ unit: String) -> String? {
113 let index = order.first { $0.0 == unit }!.1 + increments
114 return order.first { $0.1 == index }?.0
115 }
116 var group: Int?
117 var new = ""
118 if inGroup(2) || inGroup(3) {
119 var minute = Int(text(3))!, hour = Int(text(2))!
120 if inGroup(2) {
121 hour += increments
122 } else {
123 let n = step * increments
124 let rem = minute % step
125 if rem != 0 { minute += n > 0 ? -rem : step - rem }
126 minute += n
127 }
128 if minute < 0 { minute += 60; hour -= 1 }
129 if minute > 59 { minute -= 60; hour += 1 }
130 hour = ((hour % 24) + 24) % 24
131 group = 1
132 new = String(format: "-%02d:%02d", hour, minute)
133 } else if inGroup(6) {
134 group = 6
135 new = shifted(text(6)) ?? ""
136 } else if inGroup(5) {
137 group = 5
138 new = String(max(1, increments + Int(text(5))!))
139 } else if inGroup(9) {
140 group = 9
141 new = shifted(text(9)) ?? ""
142 } else if inGroup(8) {
143 group = 8
144 new = String(max(0, increments + Int(text(8))!))
145 }
146 guard let group, let target = range(group) else { return extra }
147 return ns.replacingCharacters(in: NSRange(target), with: new)
148 }
149
150 enum TimestampUnit { case minute, hour, day, month, year }
151
152 /// `org-timestamp-change`. `what` nil changes the part at point.
153 func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false) throws {
154 let origin = point
155 guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") }
156 if what == nil, category == .bracket {
157 toggleTimestampType(match[0]!)
158 return
159 }
160 let target: TimestampPart = what.map { unit in
161 switch unit {
162 case .minute: .minute
163 case .hour: .hour
164 case .day: .day
165 case .month: .month
166 case .year: .year
167 }
168 } ?? category
169 let whole = match[0]!
170 let inactive = character(at: whole.lowerBound) == 91
171 let stamp = substring(whole.lowerBound, whole.upperBound)
172 replace(whole, with: "")
173 var extra: String?
174 if let found = stamp.firstMatch(of: try! Regex("((-[012][0-9]:[0-5][0-9])?( +[.+]?-?[-+][0-9]+[hdwmy](/[0-9]+[hdwmy])?)*)[\\]>]")) {
175 extra = found.output[1].substring.map(String.init)
176 if suppressDelay, let value = extra { extra = value.replacingOccurrences(of: " --[0-9]+[hdwmy]", with: "", options: .regularExpression) }
177 }
178 let withTime = stamp.range(of: "^.{10}.*?[0-9]+:[0-9][0-9]", options: .regularExpression) != nil
179 var time = Self.parseTimeString(stamp)!
180 var step = 5
181 var increment = n
182 if updown, target == .minute {
183 increment = step * n.signum()
184 let rem = time.minute! % step
185 if rem != 0 { time.minute! += n > 0 ? -rem : step - rem }
186 } else {
187 step = 1
188 }
189 switch target {
190 case .minute: time.minute! += increment
191 case .hour: time.hour! += increment
192 case .day: time.day! += increment
193 case .month: time.month! += increment
194 case .year: time.year! += increment
195 default: break
196 }
197 let normalized = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: calendar.date(from: time)!)
198 if target == .hour || target == .minute, let value = extra, value.range(of: "-([012][0-9]):([0-5][0-9])", options: .regularExpression) != nil {
199 extra = Self.modifyTimestampExtra(value, target == .hour ? 2 : 5, n, step)
200 }
201 if case .extra(let position) = target, let value = extra {
202 extra = Self.modifyTimestampExtra(value, position, n, step)
203 }
204 let insertion = point
205 insertTimestamp(normalized, withTime: withTime, inactive: inactive, extra: extra)
206 point = insertion
207 let after = looking(Self.timestampPattern, caseInsensitive: false)!
208 switch category {
209 case .day:
210 let end = after[5].map { $0.upperBound - 1 } ?? after[7]?.lowerBound ?? after[1]!.upperBound - 1
211 point = min(end, origin)
212 case .hour: point = min(after[7]!.upperBound, origin)
213 case .minute: point = min(after[8]!.upperBound - 1, origin)
214 case .extra: point = min(after[0]!.upperBound - 1, origin)
215 case .after: point = after[0]!.upperBound
216 default: point = origin
217 }
218 }
219
220 /// `org-toggle-timestamp-type`.
221 func toggleTimestampType(_ range: Range<Int>) {
222 saveExcursion {
223 for offset in range {
224 let swapped: String? = switch character(at: offset) {
225 case 91: "<"
226 case 93: ">"
227 case 60: "["
228 case 62: "]"
229 default: nil
230 }
231 if let swapped { replace(offset..<(offset + 1), with: swapped) }
232 }
233 }
234 }
235
236 /// `org-get-compact-tod`: the time of day in a timestamp, as the date prompt shows it.
237 static func compactTimeOfDay(_ stamp: String) -> String? {
238 guard let match = stamp.firstMatch(of: /(([012]?[0-9]):([0-5][0-9]))(-(([012]?[0-9]):([0-5][0-9])))?/) else { return nil }
239 let t1 = String(match.1)
240 guard let t2h = match.6, let t2m = match.7 else { return t1 }
241 var dh = Int(t2h)! - Int(match.2)!
242 var dm = Int(t2m)! - Int(match.3)!
243 if dm < 0 { dm += 60; dh -= 1 }
244 return t1 + "+" + String(dh) + (dm != 0 ? String(format: ":%02d", dm) : "")
245 }
246
247 // MARK: - Planning
248
249 static let planningLinePattern = "^[ \\t]*(CLOSED|DEADLINE|SCHEDULED):"
250 static let keywordTimeNotClock = "\\b(CLOSED|DEADLINE|SCHEDULED):[ \\t]*[\\[<]([^\\]>]+)[\\]>]"
251
252 enum PlanningKind {
253 case closed, deadline, scheduled
254
255 var keyword: String {
256 switch self {
257 case .closed: "CLOSED:"
258 case .deadline: "DEADLINE:"
259 case .scheduled: "SCHEDULED:"
260 }
261 }
262
263 var pattern: String {
264 switch self {
265 case .closed: "\\bCLOSED: *\\[([^\\]]+)\\]"
266 case .deadline: "\\bDEADLINE: *<([^>]+)>"
267 case .scheduled: "\\bSCHEDULED: *<([^>]+)>"
268 }
269 }
270 }
271
272 /// The time string of the entry's planning `kind`, as `org-entry-get`.
273 func planningValue(_ kind: PlanningKind) -> String? {
274 saveExcursion {
275 guard (try? backToHeading()) != nil else { return nil }
276 forwardLine(1)
277 guard lookingAt(Self.planningLinePattern) != nil,
278 let match = searchForward(kind.pattern, bound: lineEnd(point), caseInsensitive: false) else { return nil }
279 return substring(match[1]!.lowerBound, match[1]!.upperBound)
280 }
281 }
282
283 /// `org-add-planning-info` with a resolved time.
284 func addPlanningInfo(_ what: PlanningKind?, time: DateComponents?, withTime: Bool, endTime: String?, remove: [PlanningKind]) throws {
285 try saveExcursion {
286 try backToHeading()
287 let planning = saveExcursion { () -> Bool in
288 forwardLine(1)
289 return lookingAt(Self.planningLinePattern) != nil
290 }
291 if planning {
292 forwardLine(1)
293 skipForward(Self.blanks)
294 for type in (what.map { [$0] } ?? []) + remove {
295 saveExcursion {
296 guard let match = searchForward(type.pattern, bound: lineEnd(point), caseInsensitive: false) else { return }
297 let start = match[0]!.lowerBound
298 let end = searchForward(Self.keywordTimeNotClock, bound: lineEnd(point), caseInsensitive: false)?[0]!.lowerBound ?? lineEnd(point)
299 deleteRegion(start, end)
300 }
301 }
302 if lookingAt("[ \\t]*$") != nil, what == nil {
303 deleteRegion(lineStart(point) - 1, lineEnd(point))
304 } else {
305 let p = point
306 saveExcursion {
307 endOfLine()
308 let end = point
309 while point > p, let c = character(at: point - 1), c == 32 || c == 9 { point -= 1 }
310 if point < end { deleteRegion(point, end) }
311 }
312 }
313 } else if what != nil {
314 endOfLine()
315 insert("\n")
316 }
317 if let what, let time {
318 insert(what.keyword + " ")
319 insertTimestamp(time, withTime: withTime || what == .closed, inactive: what == .closed, endTime: endTime)
320 if !eolp { insert(" ") }
321 }
322 }
323 }
324}
Sources/OrgCore/Keymap/Keymap.swift +2 −1
@@ -103,7 +103,7 @@ public struct Keymap: Sendable, Equatable {
103103
104104/// Context names a binding's `when` can use.
105105public enum KeyContext {
106 public static let names: Set<String> = ["heading", "table", "item", "region"]
106 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp"]
107107
108108 public static func holds(_ name: String, in context: EditContext) -> Bool {
109109 switch name {
@@ -111,6 +111,7 @@ public enum KeyContext {
111111 case "table": return enclosing(.table, context) != nil
112112 case "item": return enclosing(.item, context) != nil
113113 case "region": return context.selection.contains { !$0.isEmpty }
114 case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
114115 default: return false
115116 }
116117 }
Sources/OrgCore/Keymap/Presets.swift +56
@@ -254,6 +254,34 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
254254 [[bind]]
255255 keys = "C-c $"
256256 command = "app.archive"
257 [[bind]]
258 keys = "C-c ."
259 command = "org.timestamp.active"
260 [[bind]]
261 keys = "C-c !"
262 command = "org.timestamp.inactive"
263 [[bind]]
264 keys = "C-c C-s"
265 command = "org.schedule"
266 [[bind]]
267 keys = "C-c C-d"
268 command = "org.deadline"
269 [[bind]]
270 keys = "S-<up>"
271 command = "org.timestamp.up"
272 when = "timestamp"
273 [[bind]]
274 keys = "S-<down>"
275 command = "org.timestamp.down"
276 when = "timestamp"
277 [[bind]]
278 keys = "S-<right>"
279 command = "org.timestamp.day-later"
280 when = "timestamp"
281 [[bind]]
282 keys = "S-<left>"
283 command = "org.timestamp.day-earlier"
284 when = "timestamp"
257285
258286 """
259287
@@ -346,6 +374,34 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
346374 [[bind]]
347375 keys = "C-s-a"
348376 command = "app.archive"
377 [[bind]]
378 keys = "C-s-."
379 command = "org.timestamp.active"
380 [[bind]]
381 keys = "C-M-s-."
382 command = "org.timestamp.inactive"
383 [[bind]]
384 keys = "C-s-s"
385 command = "org.schedule"
386 [[bind]]
387 keys = "C-s-d"
388 command = "org.deadline"
389 [[bind]]
390 keys = "C-s-<up>"
391 command = "org.timestamp.up"
392 when = "timestamp"
393 [[bind]]
394 keys = "C-s-<down>"
395 command = "org.timestamp.down"
396 when = "timestamp"
397 [[bind]]
398 keys = "C-s-<right>"
399 command = "org.timestamp.day-later"
400 when = "timestamp"
401 [[bind]]
402 keys = "C-s-<left>"
403 command = "org.timestamp.day-earlier"
404 when = "timestamp"
349405
350406 """
351407}
Tests/OrgCoreTests/EmacsOracle.swift −1
@@ -29,7 +29,6 @@ enum EmacsOracle {
2929 (require 'cl-lib)
3030 (setq org-todo-keywords '((sequence "TODO" "DONE"))
3131 org-log-done nil
32 org-log-repeat nil
3332 org-adapt-indentation nil
3433 org-tags-column -77
3534 org-priority-highest ?A
Tests/OrgCoreTests/TimestampCommandTests.swift added +137
@@ -0,0 +1,137 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct TimestampCommandTests {
6 static let now = ReadDateTests.now
7 static let calendar = ReadDateTests.utc
8
9 func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil) -> (text: String, caret: Int, failure: String?) {
10 let context = EditContext(
11 revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret],
12 now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:]
13 )
14 switch command.run(in: context) {
15 case .commit(let result):
16 let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
17 return (new, result.selection?.first?.lowerBound ?? caret, nil)
18 case .failed(let message): return (text, caret, message)
19 case .prompt(let prompt): return (text, caret, "prompt \(prompt.key)")
20 }
21 }
22
23 @Test func examples() {
24 #expect(run(InsertTimestamp(), "x\n", caret: 1, answer: "fri").text == "x<2026-09-11 Fri>\n")
25 #expect(run(SetPlanning(deadline: false), "* a\n", caret: 0, answer: "+1").text == "* a\nSCHEDULED: <2026-09-11 Fri>\n")
26 #expect(run(ShiftTimestampDay(later: true), "<2026-09-10 Thu>\n", caret: 5).text == "<2026-09-11 Fri>\n")
27 #expect(run(ShiftTimestamp(up: true), "<2026-09-10 Thu 10:07>\n", caret: 20).text == "<2026-09-10 Thu 10:10>\n")
28 }
29
30 static let texts = [
31 "* a\n",
32 "* a\nbody <2026-09-01 Tue> text\n",
33 "* a\nSCHEDULED: <2026-09-01 Tue +1w>\n",
34 "* a\nDEADLINE: <2026-09-20 Sun> SCHEDULED: <2026-09-01 Tue 10:00-11:30>\n",
35 "* a\nCLOSED: [2026-09-02 Wed 09:00] SCHEDULED: <2026-09-01 Tue>\n",
36 "* a\n[2026-12-31 Thu 23:58] <2026-01-31 Sat .+2m -3d>\n",
37 "text <2026-02-28 Sat 07:03>\n",
38 ]
39 static let answers = ["", "+1", "fri 9:00", "2027-01-15", "10:00-11:00", "++2d"]
40
41 static let commands: [(any OrgCommand, String)] = [
42 (InsertTimestamp(), "(org-timestamp nil)"),
43 (InsertTimestamp(inactive: true), "(org-timestamp nil t)"),
44 (SetPlanning(deadline: false), "(org-schedule nil)"),
45 (SetPlanning(deadline: true), "(org-deadline nil)"),
46 (RemovePlanning(deadline: false), "(org-schedule '(4))"),
47 (ShiftTimestamp(up: true), "(org-timestamp-up)"),
48 (ShiftTimestamp(up: false), "(org-timestamp-down)"),
49 (ShiftTimestampDay(later: true), "(org-timestamp-up-day)"),
50 (ShiftTimestampDay(later: false), "(org-timestamp-down-day)"),
51 ]
52
53 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
54 func timestampCommandsMatchEmacs() throws {
55 try #require(EmacsOracle.isAvailable)
56 var cases: [EmacsOracle.Case] = []
57 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
58 for text in Self.texts {
59 let tree = OrgParser.parse(text)
60 for caret in EmacsOracle.positions(text) {
61 for (command, form) in Self.commands {
62 let prompts = command is InsertTimestamp || command is SetPlanning
63 if command is RemovePlanning || command is SetPlanning, entryHeading(at: caret, in: tree) == nil { continue }
64 if command is ShiftTimestamp || command is ShiftTimestampDay, EmacsBuffer(text, point: caret).atTimestamp() == nil { continue }
65 for answer in prompts ? Self.answers : [""] {
66 let escaped = answer.replacingOccurrences(of: "\"", with: "\\\"")
67 let wrapped = frozenClock(1_789_000_000, """
68 (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "\(escaped)")))
69 (let ((org-read-date-popup-calendar nil)) \(form)))
70 """)
71 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: wrapped))
72 let result = run(command, text, caret: caret, answer: prompts ? answer : nil)
73 ours.append(("\(command.id) \(answer.debugDescription) at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
74 }
75 }
76 }
77 }
78 var mismatches = 0
79 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
80 let failed = !emacs.error.isEmpty
81 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
82 guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue }
83 mismatches += 1
84 if mismatches <= 12 {
85 Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")")
86 }
87 }
88 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
89 }
90
91 static let repeating = [
92 "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n",
93 "* TODO a\nDEADLINE: <2026-09-01 Tue .+1d>\n",
94 "* TODO a\nSCHEDULED: <2026-08-01 Sat ++1m>\nbody\n",
95 "* TODO a :t:\n<2026-09-08 Tue 10:00 +2d>\n",
96 "* TODO a\nDEADLINE: <2026-09-05 Sat +1w> SCHEDULED: <2026-09-01 Tue>\n",
97 "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n:PROPERTIES:\n:LAST_REPEAT: [2026-09-01 Tue 08:00]\n:END:\n- State \"DONE\" from \"TODO\" [2026-09-01 Tue 08:00]\nnotes\n",
98 "#+TODO: TODO STRT | DONE\n* STRT a\nSCHEDULED: <2026-09-01 Tue +1d>\n",
99 "* P [0/1]\n** TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n* Q\n",
100 "* TODO no repeat\nSCHEDULED: <2026-09-01 Tue>\n",
101 "* TODO hourly\nSCHEDULED: <2026-09-10 Thu 00:00 +1h>\n",
102 ]
103
104 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
105 func repeatsMatchEmacs() throws {
106 try #require(EmacsOracle.isAvailable)
107 var cases: [EmacsOracle.Case] = []
108 var ours: [(label: String, text: String, caret: Int)] = []
109 for text in Self.repeating {
110 let ns = text as NSString
111 let heading = ns.range(of: "\n\\*|^\\*", options: .regularExpression)
112 let start = heading.location == 0 ? 0 : heading.location + 1
113 let lineEnd = ns.range(of: "\n", range: NSRange(location: start, length: ns.length - start)).location
114 for caret in [start, start + 2, lineEnd, min(lineEnd + 3, ns.length)] {
115 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: frozenClock(1_789_000_000, "(progn (org-todo) (when org-log-setup (org-add-log-note)))")))
116 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Self.now, calendar: Self.calendar)
117 guard case .commit(let result) = TodoCycle().run(in: context) else {
118 ours.append(("failed at \(caret)", "", -1))
119 continue
120 }
121 let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) }
122 ours.append(("at \(caret) in \(text.debugDescription)", new, result.selection?.first?.lowerBound ?? caret))
123 }
124 }
125 var mismatches = 0
126 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
127 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
128 guard !emacs.error.isEmpty || mine.text != emacs.text || mine.caret != caret else { continue }
129 mismatches += 1
130 if mismatches <= 10 {
131 let (a, b) = excerpts(mine.text, emacs.text)
132 Issue.record("\(mine.label)\n ours: \(a.debugDescription) @\(mine.caret)\n emacs: \(emacs.error.isEmpty ? "\(b.debugDescription) @\(caret)" : emacs.error)")
133 }
134 }
135 #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ")
136 }
137}