Sources/OrgCore/Commands/Timestamps.swift
355 lines · 16237 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-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
94 /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent.
95 static func parseTimeString(_ text: String) -> DateComponents? {
96 guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil }
97 func group(_ i: Int) -> Int? { match.output[i].substring.flatMap { Int($0) } }
98 return DateComponents(year: group(2), month: group(3), day: group(4), hour: group(7) ?? 0, minute: group(8) ?? 0)
99 }
100
101 /// `format-time-string` with Org's timestamp formats, plus `extra` before the closing bracket.
102 func formatTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, extra: String? = nil) -> String {
103 let date = calendar.date(from: components)!
104 let formatter = DateFormatter()
105 formatter.locale = Locale(identifier: "en_US_POSIX")
106 formatter.calendar = calendar
107 formatter.timeZone = calendar.timeZone
108 formatter.dateFormat = withTime ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE"
109 return (inactive ? "[" : "<") + formatter.string(from: date) + (extra ?? "") + (inactive ? "]" : ">")
110 }
111
112 /// `org-insert-timestamp`: inserted before markers, as org does.
113 @discardableResult
114 func insertTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, endTime: String? = nil, extra: String? = nil) -> String {
115 var suffix = extra
116 if let endTime, let match = endTime.firstMatch(of: /([0-9]+):([0-9]+)/) {
117 suffix = String(format: "-%02d:%02d", Int(match.1)!, Int(match.2)!)
118 }
119 let stamp = formatTimestamp(components, withTime: withTime, inactive: inactive, extra: suffix)
120 insertBeforeMarkers(stamp)
121 lastInsertedTimestamp = stamp
122 return stamp
123 }
124
125 /// `org-modify-ts-extra`.
126 static func modifyTimestampExtra(_ extra: String, _ position: Int, _ increments: Int, _ step: Int) -> String {
127 let regex = try! NSRegularExpression(pattern: "(-([012][0-9]):([0-5][0-9]))?( +\\+([0-9]+)([dmwy]))?( +-([0-9]+)([dmwy]))?")
128 let ns = extra as NSString
129 guard let match = regex.firstMatch(in: extra, range: NSRange(location: 0, length: ns.length)) else { return extra }
130 func range(_ i: Int) -> Range<Int>? {
131 let r = match.range(at: i)
132 return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
133 }
134 func inGroup(_ i: Int) -> Bool { range(i).map { $0.lowerBound <= position && position <= $0.upperBound } ?? false }
135 func text(_ i: Int) -> String { ns.substring(with: match.range(at: i)) }
136 let order: [(String, Int)] = [("d", 0), ("w", 1), ("m", 2), ("y", 3), ("d", -1), ("y", 4)]
137 func shifted(_ unit: String) -> String? {
138 let index = order.first { $0.0 == unit }!.1 + increments
139 return order.first { $0.1 == index }?.0
140 }
141 var group: Int?
142 var new = ""
143 if inGroup(2) || inGroup(3) {
144 var minute = Int(text(3))!, hour = Int(text(2))!
145 if inGroup(2) {
146 hour += increments
147 } else {
148 let n = step * increments
149 let rem = minute % step
150 if rem != 0 { minute += n > 0 ? -rem : step - rem }
151 minute += n
152 }
153 if minute < 0 { minute += 60; hour -= 1 }
154 if minute > 59 { minute -= 60; hour += 1 }
155 hour = ((hour % 24) + 24) % 24
156 group = 1
157 new = String(format: "-%02d:%02d", hour, minute)
158 } else if inGroup(6) {
159 group = 6
160 new = shifted(text(6)) ?? ""
161 } else if inGroup(5) {
162 group = 5
163 new = String(max(1, increments + Int(text(5))!))
164 } else if inGroup(9) {
165 group = 9
166 new = shifted(text(9)) ?? ""
167 } else if inGroup(8) {
168 group = 8
169 new = String(max(0, increments + Int(text(8))!))
170 }
171 guard let group, let target = range(group) else { return extra }
172 return ns.replacingCharacters(in: NSRange(target), with: new)
173 }
174
175 enum TimestampUnit { case minute, hour, day, month, year }
176
177 /// `org-timestamp-change`. `what` nil changes the part at point. On a CLOCK line the
178 /// duration is then updated, unless `updateClock` is false.
179 func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false, updateClock: Bool = true) throws {
180 let origin = point
181 guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") }
182 if what == nil, category == .bracket {
183 toggleTimestampType(match[0]!)
184 return
185 }
186 let target: TimestampPart = what.map { unit in
187 switch unit {
188 case .minute: .minute
189 case .hour: .hour
190 case .day: .day
191 case .month: .month
192 case .year: .year
193 }
194 } ?? category
195 let whole = match[0]!
196 let inactive = character(at: whole.lowerBound) == 91
197 let stamp = substring(whole.lowerBound, whole.upperBound)
198 replace(whole, with: "")
199 var extra: String?
200 if let found = stamp.firstMatch(of: try! Regex("((-[012][0-9]:[0-5][0-9])?( +[.+]?-?[-+][0-9]+[hdwmy](/[0-9]+[hdwmy])?)*)[\\]>]")) {
201 extra = found.output[1].substring.map(String.init)
202 if suppressDelay, let value = extra { extra = value.replacingOccurrences(of: " --[0-9]+[hdwmy]", with: "", options: .regularExpression) }
203 }
204 let withTime = stamp.range(of: "^.{10}.*?[0-9]+:[0-9][0-9]", options: .regularExpression) != nil
205 var time = Self.parseTimeString(stamp)!
206 var step = 5
207 var increment = n
208 if updown, target == .minute {
209 increment = step * n.signum()
210 let rem = time.minute! % step
211 if rem != 0 { time.minute! += n > 0 ? -rem : step - rem }
212 } else {
213 step = 1
214 }
215 switch target {
216 case .minute: time.minute! += increment
217 case .hour: time.hour! += increment
218 case .day: time.day! += increment
219 case .month: time.month! += increment
220 case .year: time.year! += increment
221 default: break
222 }
223 let normalized = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: calendar.date(from: time)!)
224 if target == .hour || target == .minute, let value = extra, value.range(of: "-([012][0-9]):([0-5][0-9])", options: .regularExpression) != nil {
225 extra = Self.modifyTimestampExtra(value, target == .hour ? 2 : 5, n, step)
226 }
227 if case .extra(let position) = target, let value = extra {
228 extra = Self.modifyTimestampExtra(value, position, n, step)
229 }
230 let insertion = point
231 insertTimestamp(normalized, withTime: withTime, inactive: inactive, extra: extra)
232 point = insertion
233 let after = looking(Self.timestampPattern, caseInsensitive: false)!
234 switch category {
235 case .day:
236 let end = after[5].map { $0.upperBound - 1 } ?? after[7]?.lowerBound ?? after[1]!.upperBound - 1
237 point = min(end, origin)
238 case .hour: point = min(after[7]!.upperBound, origin)
239 case .minute: point = min(after[8]!.upperBound - 1, origin)
240 case .extra: point = min(after[0]!.upperBound - 1, origin)
241 case .after: point = after[0]!.upperBound
242 default: point = origin
243 }
244 if updateClock {
245 let before = point
246 clockUpdateTime()
247 if point >= before { point = before }
248 }
249 }
250
251 /// `org-toggle-timestamp-type`.
252 func toggleTimestampType(_ range: Range<Int>) {
253 saveExcursion {
254 for offset in range {
255 let swapped: String? = switch character(at: offset) {
256 case 91: "<"
257 case 93: ">"
258 case 60: "["
259 case 62: "]"
260 default: nil
261 }
262 if let swapped { replace(offset..<(offset + 1), with: swapped) }
263 }
264 }
265 }
266
267 /// `org-get-compact-tod`: the time of day in a timestamp, as the date prompt shows it.
268 static func compactTimeOfDay(_ stamp: String) -> String? {
269 guard let match = stamp.firstMatch(of: /(([012]?[0-9]):([0-5][0-9]))(-(([012]?[0-9]):([0-5][0-9])))?/) else { return nil }
270 let t1 = String(match.1)
271 guard let t2h = match.6, let t2m = match.7 else { return t1 }
272 var dh = Int(t2h)! - Int(match.2)!
273 var dm = Int(t2m)! - Int(match.3)!
274 if dm < 0 { dm += 60; dh -= 1 }
275 return t1 + "+" + String(dh) + (dm != 0 ? String(format: ":%02d", dm) : "")
276 }
277
278 // MARK: - Planning
279
280 static let planningLinePattern = "^[ \\t]*(CLOSED|DEADLINE|SCHEDULED):"
281 static let keywordTimeNotClock = "\\b(CLOSED|DEADLINE|SCHEDULED):[ \\t]*[\\[<]([^\\]>]+)[\\]>]"
282
283 enum PlanningKind {
284 case closed, deadline, scheduled
285
286 var keyword: String {
287 switch self {
288 case .closed: "CLOSED:"
289 case .deadline: "DEADLINE:"
290 case .scheduled: "SCHEDULED:"
291 }
292 }
293
294 var pattern: String {
295 switch self {
296 case .closed: "\\bCLOSED: *\\[([^\\]]+)\\]"
297 case .deadline: "\\bDEADLINE: *<([^>]+)>"
298 case .scheduled: "\\bSCHEDULED: *<([^>]+)>"
299 }
300 }
301 }
302
303 /// The time string of the entry's planning `kind`, as `org-entry-get`.
304 func planningValue(_ kind: PlanningKind) -> String? {
305 saveExcursion {
306 guard (try? backToHeading()) != nil else { return nil }
307 forwardLine(1)
308 guard lookingAt(Self.planningLinePattern) != nil,
309 let match = searchForward(kind.pattern, bound: lineEnd(point), caseInsensitive: false) else { return nil }
310 return substring(match[1]!.lowerBound, match[1]!.upperBound)
311 }
312 }
313
314 /// `org-add-planning-info` with a resolved time.
315 func addPlanningInfo(_ what: PlanningKind?, time: DateComponents?, withTime: Bool, endTime: String?, remove: [PlanningKind]) throws {
316 try saveExcursion {
317 try backToHeading()
318 let planning = saveExcursion { () -> Bool in
319 forwardLine(1)
320 return lookingAt(Self.planningLinePattern) != nil
321 }
322 if planning {
323 forwardLine(1)
324 skipForward(Self.blanks)
325 for type in (what.map { [$0] } ?? []) + remove {
326 saveExcursion {
327 guard let match = searchForward(type.pattern, bound: lineEnd(point), caseInsensitive: false) else { return }
328 let start = match[0]!.lowerBound
329 let end = searchForward(Self.keywordTimeNotClock, bound: lineEnd(point), caseInsensitive: false)?[0]!.lowerBound ?? lineEnd(point)
330 deleteRegion(start, end)
331 }
332 }
333 if lookingAt("[ \\t]*$") != nil, what == nil {
334 deleteRegion(lineStart(point) - 1, lineEnd(point))
335 } else {
336 let p = point
337 saveExcursion {
338 endOfLine()
339 let end = point
340 while point > p, let c = character(at: point - 1), c == 32 || c == 9 { point -= 1 }
341 if point < end { deleteRegion(point, end) }
342 }
343 }
344 } else if what != nil {
345 endOfLine()
346 insert("\n")
347 }
348 if let what, let time {
349 insert(what.keyword + " ")
350 insertTimestamp(time, withTime: withTime || what == .closed, inactive: what == .closed, endTime: endTime)
351 if !eolp { insert(" ") }
352 }
353 }
354 }
355}