krz/orgstar

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

Sources/OrgCore/Commands/ClockHistory.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgCore/Commands/ClockHistory.swift history · blame · raw

154 lines · 7638 bytes

  1import Foundation
  2
  3// Clock history and task selection (`org-clock-history`, `org-clock-history-push`,
  4// `org-clock-select-task`, Org 9.8.7). Org keeps markers, which follow edits; a task here is
  5// found again by its ID, else by its heading's position among the file's headings and its
  6// title, else by its title.
  7
  8/// A clocked entry, as `org-clock-history` and `org-clock-default-task` remember it.
  9public struct ClockTask: Codable, Equatable, Hashable, Sendable {
 10    public let path: String
 11    public let title: String
 12    /// The heading's index among the file's headings.
 13    public let ordinal: Int
 14    public let id: String?
 15
 16    public init(path: String, title: String, ordinal: Int, id: String?) {
 17        self.path = path
 18        self.title = title
 19        self.ordinal = ordinal
 20        self.id = id
 21    }
 22
 23    /// The entry containing `offset`; nil before the first heading.
 24    public static func at(_ offset: Int, in text: String, path: String) -> ClockTask? {
 25        let model = DocumentModel(tree: OrgParser.parse(text))
 26        guard let index = model.headings.lastIndex(where: { $0.headingRange.lowerBound <= offset }) else { return nil }
 27        let heading = model.headings[index]
 28        return ClockTask(path: path, title: heading.title, ordinal: index, id: heading.id)
 29    }
 30
 31    /// The heading's index in `model`, or nil when the entry is gone.
 32    public func index(in model: DocumentModel) -> Int? {
 33        if let id, let index = model.headings.firstIndex(where: { $0.id == id }) { return index }
 34        if ordinal < model.headings.count, model.headings[ordinal].title == title { return ordinal }
 35        return model.headings.firstIndex { $0.title == title }
 36    }
 37
 38    /// Where the heading starts in `text`, or nil when the entry is gone.
 39    public func locate(in text: String) -> Int? {
 40        let model = DocumentModel(tree: OrgParser.parse(text))
 41        return index(in: model).map { model.headings[$0].headingRange.lowerBound }
 42    }
 43
 44    /// The task as found in `text`, with its current position; nil when the entry is gone.
 45    public func refreshed(in text: String) -> ClockTask? {
 46        let model = DocumentModel(tree: OrgParser.parse(text))
 47        guard let index = index(in: model) else { return nil }
 48        return ClockTask(path: path, title: model.headings[index].title, ordinal: index, id: model.headings[index].id)
 49    }
 50}
 51
 52public enum ClockHistory {
 53    /// `org-clock-history-length`.
 54    public static let defaultLength = 5
 55
 56    /// `org-clock-history-push`: `task` goes first, other entries for the same heading and
 57    /// entries that are gone leave, and the oldest go past `length`. `text` reads a file.
 58    public static func push(_ task: ClockTask, onto history: [ClockTask], length: Int, text: (String) -> String?) -> [ClockTask] {
 59        let length = max(1, length)
 60        var texts: [String: String?] = [:]
 61        func read(_ path: String) -> String? {
 62            if let cached = texts[path] { return cached }
 63            let value = text(path)
 64            texts[path] = value
 65            return value
 66        }
 67        let target = read(task.path).flatMap { task.locate(in: $0) }
 68        var kept: [ClockTask] = []
 69        for entry in history {
 70            guard let contents = read(entry.path), let current = entry.refreshed(in: contents) else { continue }
 71            if entry.path == task.path, let target, current.locate(in: contents) == target { continue }
 72            kept.append(current)
 73        }
 74        if kept.count >= length { kept = Array(kept.prefix(length - 1)) }
 75        return [task] + kept
 76    }
 77
 78    /// One line of the selection: its key, the section it is under and `[k] category  task`.
 79    public struct Choice: Equatable, Sendable {
 80        public let key: Character
 81        public let section: String
 82        public let task: ClockTask
 83        /// `%-12s  %s` of the category and the heading without tags.
 84        public let line: String
 85    }
 86
 87    /// `org-clock-select-task`'s entries: the default task (`d`), the interrupted one (`i`),
 88    /// the clocking one (`c`), then recent tasks `1`–`9`, `A`… Nil when there is no recent
 89    /// clock. Entries that are gone are left out.
 90    public static func selection(
 91        defaultTask: ClockTask?, interrupted: ClockTask?, current: ClockTask?, history: [ClockTask], text: (String) -> String?
 92    ) -> [Choice]? {
 93        // Successive duplicates go.
 94        var recent: [ClockTask] = []
 95        for task in history where task != recent.last { recent.append(task) }
 96        guard !recent.isEmpty else { return nil }
 97        var choices: [Choice] = []
 98        func add(_ key: Character, _ section: String, _ task: ClockTask?) -> Bool {
 99            guard let task, let contents = text(task.path), let line = line(task, in: contents) else { return false }
100            choices.append(Choice(key: key, section: section, task: task, line: line))
101            return true
102        }
103        _ = add("d", "Default Task", defaultTask)
104        _ = add("i", "The task interrupted by starting the last one", interrupted)
105        _ = add("c", "Current Clocking Task", current)
106        var i = 0
107        for task in recent {
108            guard let contents = text(task.path), line(task, in: contents) != nil else { continue }
109            i += 1
110            let scalar = i < 10 ? 48 + i : 65 + i - 10
111            _ = add(Character(UnicodeScalar(UInt8(clamping: scalar))), "Recent Tasks", task)
112        }
113        return choices
114    }
115
116    /// The prompt's keys and lines, under their section titles.
117    public static func fastSelection(_ choices: [Choice]) -> FastSelection {
118        var items: [FastSelection.Item] = []
119        var section: String?
120        for choice in choices {
121            if choice.section != section {
122                section = choice.section
123                items.append(.label(choice.section))
124            }
125            items.append(.option(key: choice.key, name: choice.line))
126            items.append(.newline)
127        }
128        return FastSelection(items: items, multiple: false)
129    }
130
131    /// `org-clock-insert-selection-line`'s text for a task, nil when it is gone.
132    public static func line(_ task: ClockTask, in text: String) -> String? {
133        let model = DocumentModel(tree: OrgParser.parse(text))
134        guard let index = task.index(in: model) else { return nil }
135        let heading = model.headings[index]
136        let category = category(of: index, in: model, text: text, path: task.path)
137        let words = [heading.todo, heading.priority.map { "[#\($0)]" }, heading.title].compactMap { $0 }.filter { !$0.isEmpty }
138        let padded = category.count >= 12 ? category : category + String(repeating: " ", count: 12 - category.count)
139        return padded + "  " + words.joined(separator: " ")
140    }
141
142    /// `org-get-category`: the entry's or an ancestor's CATEGORY, `#+CATEGORY`, or the file
143    /// name without its extension.
144    static func category(of index: Int, in model: DocumentModel, text: String, path: String) -> String {
145        for layer in [index] + model.ancestors(of: index).reversed() {
146            if let value = model.headings[layer].properties.last(where: { $0.key.uppercased() == "CATEGORY" && !$0.additive })?.value {
147                return value
148            }
149        }
150        let keyword = text.components(separatedBy: "\n").last { $0.uppercased().hasPrefix("#+CATEGORY:") }
151            .map { String($0.dropFirst("#+CATEGORY:".count)).trimmingCharacters(in: .whitespaces) }
152        return keyword ?? model.property("CATEGORY", of: nil)?.value ?? ((path as NSString).lastPathComponent as NSString).deletingPathExtension
153    }
154}