Sources/OrgCore/Commands/Repeat.swift
162 lines · 7569 bytes
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}