Sources/OrgCore/Commands/ClockHistory.swift
154 lines · 7638 bytes
14 symbols in this file
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}