krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Agenda/Habit.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgCore/Agenda/Habit.swift history · blame · raw

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}