Sources/OrgCore/Commands/Timestamps.swift
324 lines · 14779 bytes
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}