Sources/OrgCore/Agenda/Habit.swift
186 lines · 8670 bytes
1import Foundation
2
3/// An entry with `STYLE: habit` (org-habit.el, Org 9.8.7), as `org-habit-parse-todo` reads
4/// it, with `org-log-states-order-reversed` t.
5public struct Habit: Sendable, Equatable {
6 /// Days are absolute day numbers (`Days`).
7 public let scheduled: Int
8 public let scheduledRepeat: Int
9 let deadlineDay: Int?
10 let deadlineRepeatDays: Int?
11 /// Days the entry was done, at most `preceding + following` of them, oldest first.
12 public let doneDates: [Int]
13 /// `+`, `++` or `.+`.
14 public let repeatType: String
15
16 public static let precedingDays = 21
17 public static let followingDays = 7
18
19 /// `org-habit-deadline`.
20 public var deadline: Int {
21 deadlineDay ?? (deadlineRepeatDays != nil ? scheduled + scheduledRepeat - 1 : scheduled)
22 }
23
24 /// `org-habit-deadline-repeat`.
25 public var deadlineRepeat: Int { deadlineRepeatDays ?? scheduledRepeat }
26
27 static let repeatRegex = try! NSRegularExpression(pattern: "[+.]?\\+[0-9]+[hdwmy](/[0-9]+[hdwmy])?")
28 static let durationRegex = try! NSRegularExpression(pattern: "([0-9]+)([dwmy])")
29
30 /// `org-habit-duration-to-days`.
31 static func days(_ s: String) -> Int? {
32 let ns = s as NSString
33 guard let m = durationRegex.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return nil }
34 let n = Double(ns.substring(with: m.range(at: 1)))!
35 let factor: Double = switch ns.substring(with: m.range(at: 2)) {
36 case "d": 1
37 case "w": 7
38 case "m": 30.4
39 default: 365.25
40 }
41 return Int((n * factor).rounded(.down))
42 }
43
44 /// `org-habit-parse-todo` for the entry whose text is `entry` (heading line to the next
45 /// heading) and whose SCHEDULED timestamp is `scheduled` (without brackets). Nil where
46 /// org signals an error.
47 public static func parse(entry: String, scheduled: String, doneKeywords: [String]) -> Habit? {
48 guard let day = Days.absolute(of: scheduled) else { return nil }
49 let sns = scheduled as NSString
50 guard let rm = repeatRegex.firstMatch(in: scheduled, range: NSRange(location: 0, length: sns.length)) else { return nil }
51 let repeat_ = sns.substring(with: rm.range)
52 guard let srDays = days(repeat_), srDays > 0 else { return nil }
53 let type = String(repeat_.prefix { $0 == "." || $0 == "+" })
54 var deadline: Int?
55 var drDays: Int?
56 if let slash = repeat_.range(of: "/[0-9]+[dwmy]", options: .regularExpression) {
57 guard let dr = days(String(repeat_[slash].dropFirst())), dr > srDays else { return nil }
58 drDays = dr
59 deadline = day + dr - srDays
60 }
61 let done = doneKeywords.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "|")
62 let inactive = "\\[([0-9]{4}-[0-9]{2}-[0-9]{2} ?[^\\]\\r\\n>]*?)\\]"
63 // `org-log-note-headings` `done` is "CLOSING NOTE %t".
64 let pattern = "^[ \\t]*-[ \\t]+(?:State \"(?:\(done))\".*\(inactive)|CLOSING NOTE \(inactive))"
65 let regex = try! NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines, .caseInsensitive])
66 let ens = entry as NSString
67 var closed: [Int] = []
68 for m in regex.matches(in: entry, range: NSRange(location: 0, length: ens.length)) {
69 guard closed.count < precedingDays + followingDays else { break }
70 let group = m.range(at: 1).location != NSNotFound ? m.range(at: 1) : m.range(at: 2)
71 if let d = Days.absolute(of: ens.substring(with: group)) { closed.insert(d, at: 0) }
72 }
73 return Habit(scheduled: day, scheduledRepeat: srDays, deadlineDay: deadline, deadlineRepeatDays: drDays, doneDates: closed, repeatType: type)
74 }
75
76 /// `org-habit-get-urgency`.
77 public func urgency(today: Int) -> Int {
78 var pri = 1000 + (today - scheduled) * 10
79 if scheduled != deadline, today == deadline { pri += 50 }
80 let slip = today - (deadline - 1)
81 pri += slip > 0 ? slip * 100 : slip * 10
82 return pri
83 }
84
85 public enum Face: String, Sendable {
86 case clear, clearFuture, ready, readyFuture, alert, alertFuture, overdue, overdueFuture
87 }
88
89 public struct Cell: Sendable, Equatable {
90 public let day: Int
91 /// `*` done, `!` today, or a space.
92 public let glyph: Character
93 public let face: Face
94 }
95
96 /// `org-habit-get-faces`.
97 func faces(now: Int, scheduledDays: Int?, done: Bool) -> (Face, Face) {
98 let scheduled = scheduledDays ?? self.scheduled
99 let deadline = scheduledDays.map { $0 + deadlineRepeat - scheduledRepeat } ?? self.deadline
100 if now < scheduled { return (.clear, .clearFuture) }
101 if now < deadline { return (.ready, .readyFuture) }
102 if now == deadline { return done ? (.ready, .readyFuture) : (.alert, .alertFuture) }
103 return (.overdue, .overdueFuture)
104 }
105
106 /// `org-habit-build-graph` from `today - preceding` to `today + following` (exclusive),
107 /// as `org-habit-insert-consistency-graphs` calls it.
108 public func graph(today: Int, preceding: Int = precedingDays, following: Int = followingDays) -> [Cell] {
109 let allDone = doneDates.sorted()
110 var doneIndex = 0
111 var lastDone: Int?
112 let first = today - preceding
113 while doneIndex < allDone.count, allDone[doneIndex] < first {
114 lastDone = allDone[doneIndex]
115 doneIndex += 1
116 }
117 var cells: [Cell] = []
118 for day in first..<(today + following) {
119 let past = day < today
120 let isToday = day == today
121 let isDone = doneIndex < allDone.count && allDone[doneIndex] == day
122 let faces: (Face, Face)
123 if past, lastDone == nil, !(scheduled < today) {
124 faces = allDone.first == day ? (.ready, .readyFuture) : (.clear, .clearFuture)
125 } else {
126 var scheduledDays: Int?
127 if past, let last = lastDone {
128 let remaining = allDone.count - doneIndex
129 if remaining == 0 {
130 scheduledDays = scheduled
131 } else if repeatType == ".+" {
132 scheduledDays = last + scheduledRepeat
133 } else if repeatType == "+" {
134 scheduledDays = scheduled - remaining * scheduledRepeat
135 } else {
136 let firstDone = allDone[0]
137 let shift = ((scheduled - firstDone) % scheduledRepeat + scheduledRepeat) % scheduledRepeat
138 var s = (shift == 0 ? scheduledRepeat : shift) + firstDone
139 if firstDone != last {
140 for done in allDone.dropFirst() {
141 s += (1 + max(done - s, 0) / scheduledRepeat) * scheduledRepeat
142 if done == last { break }
143 }
144 }
145 scheduledDays = s
146 }
147 }
148 faces = self.faces(now: day, scheduledDays: scheduledDays, done: isDone)
149 }
150 var glyph: Character = " "
151 var marked = false
152 if isDone {
153 glyph = "*"
154 marked = true
155 while doneIndex < allDone.count, allDone[doneIndex] == day {
156 lastDone = day
157 doneIndex += 1
158 }
159 } else if isToday {
160 glyph = "!"
161 }
162 var face = past || isToday ? faces.0 : faces.1
163 if past, face != .overdue, !marked { face = faces.1 }
164 cells.append(Cell(day: day, glyph: glyph, face: face))
165 }
166 return cells
167 }
168
169 /// The agenda line with the graph at `org-habit-graph-column` (40), as
170 /// `org-habit-insert-consistency-graphs` puts it.
171 public static func insertGraph(_ cells: [Cell], into line: String, column: Int = 40) -> String {
172 var chars = Array(line)
173 var width = 0
174 var index = 0
175 while index < chars.count, width < column {
176 width += displayWidth(of: chars[index])
177 index += 1
178 }
179 if width < column { chars += Array(repeating: " ", count: column - width); index = chars.count }
180 let remove = Swift.min(precedingDays + followingDays + 1, chars.count - index)
181 chars.removeSubrange(index..<(index + remove))
182 // build-graph's vector has one more cell than it fills.
183 chars.insert(contentsOf: cells.map(\.glyph) + [" "], at: index)
184 return String(chars)
185 }
186}