Sources/OrgCore/Commands/Repeat.swift
162 lines · 7990 bytes
1import Foundation
2
3// Repeating entries (`org-auto-repeat-maybe`, Org 9.8.7) and where state notes go
4// (`org-log-beginning`), with `org-todo-repeat-to-state` nil.
5
6extension EmacsBuffer {
7 static let repeatPattern = "<[0-9]{4}-[0-9][0-9]-[0-9][0-9] [^>\\n]*?([.+]?\\+[0-9]+[hdwmy](/[0-9]+[hdwmy])?)"
8
9 /// `org-entry-end-position`: the next heading, or the end of the buffer.
10 var entryEnd: Int {
11 saveExcursion {
12 _ = try? backToHeading()
13 outlineNextHeading()
14 return point
15 }
16 }
17
18 /// `org-get-repeat`.
19 func repeatCookie() -> String? {
20 saveExcursion {
21 guard (try? backToHeading()) != nil else { return nil }
22 let end = entryEnd
23 return searchForward(Self.repeatPattern, bound: end, caseInsensitive: false).map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
24 }
25 }
26
27 /// The TODO keyword of the heading at point, if any.
28 var todoKeyword: String? {
29 saveExcursion {
30 guard (try? backToHeading()) != nil, let match = lookingAt(todoLinePattern), let keyword = match[2] else { return nil }
31 return substring(keyword.lowerBound, keyword.upperBound)
32 }
33 }
34
35 /// The keyword change `org-todo` makes, with its tag alignment and statistics update.
36 func setTodoKeyword(_ keyword: String?) {
37 saveExcursion {
38 guard (try? backToHeading()) != nil, let stars = lookingAt("\\*+ ") else { return }
39 point = stars[0]!.upperBound - 1
40 let names = keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|")
41 let match = lookingAt(" +(" + names + ")( +|[ \\t]*$)") ?? lookingAt("(?: *|[ \\t]*$)")!
42 replace(match[0]!, with: "")
43 point = match[0]!.lowerBound
44 insertBeforeMarkers(keyword.map { " " + $0 + " " } ?? " ")
45 alignTags(onLineAt: point)
46 updateParentTodoStatistics()
47 }
48 }
49
50 /// `org-auto-repeat-maybe` after `doneWord` replaced `previous`. Returns the state note the
51 /// repeat logs, if any.
52 func autoRepeat(doneWord: String, previous: String?, log: LogSettings) throws -> LogSetup? {
53 guard let cookie = repeatCookie(), (Int(cookie.drop { !$0.isNumber }.prefix { $0.isNumber }) ?? 0) != 0 else { return nil }
54 let end = marker(entryEnd)
55 defer { release(end) }
56 let sequence = settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == previous } }
57 let head = sequence.flatMap { ($0.active + $0.done).first?.name }
58 let propertyState = inheritedProperty("REPEAT_TO_STATE")?.value
59 let toState = propertyState.flatMap { keywordsInOrder.contains($0) ? $0 : nil }
60 ?? (sequence?.kind == .type ? previous : head)
61 setTodoKeyword(toState)
62 try backToHeading()
63 try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed])
64 let stamp = formatTimestamp(calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now), withTime: true, inactive: true)
65 if log.repeatHow != nil { entryPut("LAST_REPEAT", stamp) }
66 if let scheduled = planningValue(.scheduled), ("<" + scheduled + ">").range(of: Self.repeatPattern, options: .regularExpression) == nil {
67 saveExcursion { removePlanning(.scheduled) }
68 }
69 let units: [String: TimestampUnit] = ["h": .hour, "d": .day, "m": .month, "y": .year]
70 let today = calendar.startOfDay(for: now)
71 while let match = searchForward(Self.repeatPattern, bound: end.position, caseInsensitive: false) {
72 let stamp = substring(match[0]!.lowerBound, match[0]!.upperBound)
73 guard let parts = stamp.firstMatch(of: /([.+])?(\+[0-9]+)([hdwmy])/) else { continue }
74 var n = Int(parts.2.dropFirst())!
75 var what = String(parts.3)
76 if what == "w" { n *= 7; what = "d" }
77 guard let unit = units[what] else { continue }
78 if what == "h", stamp.range(of: "[0-9]{1,2}:[0-9]{2}", options: .regularExpression) == nil {
79 throw UserError(message: "Cannot repeat in \(n) hour(s) because no hour has been set")
80 }
81 if let kind = parts.1 {
82 let time = Self.parseTimeString(stamp).map(date)!
83 if kind == "." {
84 if what == "h" {
85 try changeTimestamp(Int((now.timeIntervalSince(time) / 60).rounded(.down)), what: .minute)
86 } else {
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 return log.repeatHow.map { LogSetup(purpose: .state, state: doneWord, previous: previous, how: $0, drawer: log.drawer) }
106 }
107
108 /// `org-end-of-meta-data` without FULL.
109 func endOfMetaData() {
110 _ = try? backToHeading()
111 forwardLine(1)
112 if lookingAt(Self.planningLinePattern) != nil { forwardLine(1) }
113 if let drawer = looking(Self.propertyDrawerPattern) {
114 point = drawer[0]!.upperBound
115 forwardLine(1)
116 }
117 }
118
119 /// `org-log-beginning` with `org-log-states-order-reversed` t: the newest note comes first.
120 /// With `drawer`, notes go at the top of that drawer, which `create` adds when missing.
121 func logBeginning(drawer: String? = nil, create: Bool = false) -> Int {
122 saveExcursion {
123 if let drawer { return drawerLogBeginning(drawer, create: create) }
124 endOfMetaData()
125 let endpos = point
126 skipForward([32, 9, 10])
127 forwardLine(0)
128 if point < endpos { point = endpos }
129 if bolp { return point }
130 let end = lineEnd(point)
131 return end < length ? end + 1 : length
132 }
133 }
134
135 private func drawerLogBeginning(_ drawer: String, create: Bool) -> Int {
136 endOfMetaData()
137 let start = point
138 let end = atHeading ? point : saveExcursion { outlineNextHeading(); return point }
139 let name = NSRegularExpression.escapedPattern(for: drawer)
140 while let match = searchForward("^[ \\t]*:" + name + ":[ \\t]*$", bound: end, caseInsensitive: true) {
141 let closed = saveExcursion { searchForward("^[ \\t]*:END:[ \\t]*$", bound: end, caseInsensitive: true) != nil }
142 if closed {
143 point = match[0]!.upperBound
144 return bolp ? point : lineEnd(point) < length ? lineEnd(point) + 1 : length
145 }
146 }
147 point = start
148 guard create else { return bolp ? point : lineEnd(point) < length ? lineEnd(point) + 1 : length }
149 if !eobp, atHeading { point -= 1 }
150 let atBlankLine = lookingAt("^[ \\t]*$") != nil
151 let atNonBlankLineStart = bolp && !eolp
152 if !bolp { insert("\n") }
153 insert(":" + drawer + ":")
154 insert("\n:END:")
155 if atBlankLine || atNonBlankLineStart {
156 insert("\n")
157 point -= 1
158 }
159 point = lineStart(point) - 1
160 return lineStart(point) == point ? point : lineEnd(point) + 1
161 }
162}