krz/orgstar

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

Sources/OrgCore/Commands/TimestampCommands.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgCore/Commands/TimestampCommands.swift history · blame · raw

235 lines · 12850 bytes

28 symbols in this file
  1import Foundation
  2
  3extension EmacsBuffer {
  4    /// Reads a date prompt answer the way `org-read-date` does: an empty answer takes the
  5    /// default date.
  6    func readDate(_ answer: String, default defaultDate: Date?) -> ReadDate.Answer {
  7        ReadDate.read(answer, default: defaultDate, now: now, calendar: calendar)
  8    }
  9
 10    /// The date an Org time string stands for, as a prompt default.
 11    func dateDefault(_ stamp: String?) -> Date? { stamp.flatMap(Self.parseTimeString).map(date) }
 12
 13    func date(_ components: DateComponents) -> Date { calendar.date(from: components)! }
 14
 15    /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one.
 16    func timestampCommand(_ answer: String, inactive: Bool) {
 17        let stamp = timestampForUpdate()
 18        let defaultDate = stamp.flatMap(Self.parseTimeString).map(date)
 19        let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } }
 20        let result = readDate(answer, default: defaultDate)
 21        let components = result.components(calendar)
 22        // Between a range's dashes, the end is replaced.
 23        var existing = atTimestamp()
 24        if stamp != nil, existing == nil {
 25            skipForward([45])
 26            existing = atTimestamp()
 27        }
 28        if let existing {
 29            replace(existing.match[0]!, with: "")
 30            insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
 31            if let repeater {
 32                point -= 1
 33                insert(" " + repeater)
 34            }
 35        } else {
 36            insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
 37        }
 38    }
 39
 40    /// The first timestamp after `kind` in the entry, as the date prompt's default.
 41    func planningDefault(_ kind: PlanningKind) -> String? {
 42        saveExcursion {
 43            guard (try? backToHeading()) != nil else { return nil }
 44            let end = saveExcursion { () -> Int in outlineNextHeading(); return point }
 45            return searchForward(kind.pattern, bound: end, caseInsensitive: false).map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
 46        }
 47    }
 48
 49    /// `org--deadline-or-schedule` without a prefix argument.
 50    func schedule(_ kind: PlanningKind, answer: String, answers: [String: String] = [:]) throws {
 51        let old = planningValue(kind)
 52        let repeatPattern = "([.+-]+[0-9]+[hdwmy](?:[/ ][-+]?[0-9]+[hdwmy])?)"
 53        let repeater = old.flatMap { $0.firstMatch(of: try! Regex(repeatPattern)).map { String($0.output[1].substring!) } }
 54        let defaultDate = planningDefault(kind).flatMap(Self.parseTimeString).map(date)
 55        let result = readDate(answer, default: defaultDate)
 56        try addPlanningInfo(kind, time: result.components(calendar), withTime: result.timeGiven, endTime: result.endTime, remove: [.closed])
 57        if let repeater, let inserted = lastInsertedTimestamp {
 58            try saveExcursion {
 59                try backToHeading()
 60                let bound = saveExcursion { () -> Int in forwardLine(2); return lineEnd(point) }
 61                if let match = searchForward(NSRegularExpression.escapedPattern(for: kind.keyword + " " + inserted), bound: bound, caseInsensitive: false) {
 62                    point = match[0]!.upperBound - 1
 63                    insert(" " + repeater)
 64                }
 65            }
 66        }
 67        if let old, let inserted = lastInsertedTimestamp {
 68            try logPlanningChange(kind, old: "<" + old + ">", new: inserted, answers: answers)
 69        }
 70    }
 71
 72    /// `org--deadline-or-schedule` with C-u: removes the planning timestamp.
 73    func removePlanning(_ kind: PlanningKind) {
 74        guard planningValue(kind) != nil else { return }
 75        saveExcursion {
 76            guard let begin = try? backToHeading() else { return }
 77            outlineNextHeading()
 78            let pattern = "\\b" + NSRegularExpression.escapedPattern(for: kind.keyword) + " +<[^>\\n]+>[ \\t]*"
 79            while let match = searchBackward(pattern, bound: begin, caseInsensitive: false) {
 80                replace(match[0]!, with: "")
 81                let before = substring(lineStart(point), point)
 82                if before.contains(where: { !$0.isWhitespace }), character(at: point - 1) == 32 {
 83                    deleteRegion(point - 1, point)
 84                } else if substring(lineStart(point), lineEnd(point)).allSatisfy({ $0 == " " || $0 == "\t" }) {
 85                    deleteRegion(lineStart(point), min(length, lineEnd(point) + 1))
 86                }
 87            }
 88        }
 89    }
 90}
 91
 92private func timestampCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
 93    commitBuffer(context, body)
 94}
 95
 96private func atTimestamp(_ context: EditContext) -> Bool {
 97    EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
 98}
 99
100/// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it.
101private func timeOfDayAtCaret(_ context: EditContext) -> String {
102    let buffer = EmacsBuffer(context.text, point: context.caret)
103    guard let stamp = buffer.timestampForUpdate() else { return "" }
104    return EmacsBuffer.compactTimeOfDay(stamp) ?? ""
105}
106
107/// C-c . and C-c !: `org-timestamp`.
108public struct InsertTimestamp: OrgCommand {
109    let inactive: Bool
110    public init(inactive: Bool = false) { self.inactive = inactive }
111    public var id: String { inactive ? "org.timestamp.inactive" : "org.timestamp.active" }
112    public var title: String { inactive ? "Insert Inactive Timestamp" : "Insert Timestamp" }
113    public func applies(in context: EditContext) -> Bool { true }
114    public func run(in context: EditContext) -> CommandStep {
115        guard let answer = context.answers["date"] else {
116            let buffer = EmacsBuffer(context.text, point: context.caret)
117            buffer.calendar = context.calendar
118            return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context),
119                                  date: .init(defaultDate: buffer.dateDefault(buffer.timestampForUpdate()), inactive: inactive)))
120        }
121        // Twice in a row on a timestamp: a range to a second one.
122        let repeated = context.lastCommand == "org.timestamp.active" || context.lastCommand == "org.timestamp.inactive"
123        if repeated, EmacsBuffer(context.text, point: context.caret).timestampForUpdate() != nil {
124            return timestampCommand(context) { buffer in
125                let result = buffer.readDate(answer, default: buffer.dateDefault(buffer.timestampForUpdate()))
126                buffer.insert("--")
127                buffer.insertTimestamp(result.components(buffer.calendar), withTime: result.timeGiven, inactive: inactive)
128            }
129        }
130        return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) }
131    }
132}
133
134/// C-c C-s and C-c C-d: `org-schedule` and `org-deadline`.
135public struct SetPlanning: OrgCommand {
136    let deadline: Bool
137    public init(deadline: Bool) { self.deadline = deadline }
138    public var id: String { deadline ? "org.deadline" : "org.schedule" }
139    public var title: String { deadline ? "Set Deadline" : "Schedule" }
140    public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
141    public func run(in context: EditContext) -> CommandStep {
142        guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") }
143        let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled
144        guard let answer = context.answers["date"] else {
145            let buffer = EmacsBuffer(context.text, point: context.caret)
146            buffer.calendar = context.calendar
147            let initial = buffer.planningDefault(kind).flatMap(EmacsBuffer.compactTimeOfDay) ?? ""
148            return .prompt(Prompt(key: "date", message: deadline ? "DEADLINE:" : "SCHEDULED:", initial: initial,
149                                  date: .init(defaultDate: buffer.dateDefault(buffer.planningDefault(kind)))))
150        }
151        return timestampCommand(context) { try $0.schedule(kind, answer: answer, answers: context.answers) }
152    }
153}
154
155/// C-u C-c C-s and C-u C-c C-d.
156public struct RemovePlanning: OrgCommand {
157    let deadline: Bool
158    public init(deadline: Bool) { self.deadline = deadline }
159    public var id: String { deadline ? "org.deadline.remove" : "org.schedule.remove" }
160    public var title: String { deadline ? "Remove Deadline" : "Remove Schedule" }
161    public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
162    public func run(in context: EditContext) -> CommandStep {
163        timestampCommand(context) { buffer in
164            let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled
165            let old = buffer.planningValue(kind)
166            if let old { try buffer.logPlanningChange(kind, old: "<" + old + ">", new: nil, answers: context.answers) }
167            buffer.removePlanning(kind)
168        }
169    }
170}
171
172/// S-up and S-down on a timestamp: `org-timestamp-up` and `org-timestamp-down`.
173public struct ShiftTimestamp: OrgCommand {
174    let up: Bool
175    public init(up: Bool) { self.up = up }
176    public var id: String { up ? "org.timestamp.up" : "org.timestamp.down" }
177    public var title: String { up ? "Increase Timestamp Part" : "Decrease Timestamp Part" }
178    public func applies(in context: EditContext) -> Bool { atTimestamp(context) }
179    public func run(in context: EditContext) -> CommandStep {
180        timestampCommand(context) { try $0.changeTimestamp(up ? 1 : -1, updown: true) }
181    }
182}
183
184/// S-right and S-left on a timestamp: `org-timestamp-up-day` and `org-timestamp-down-day`.
185public struct ShiftTimestampDay: OrgCommand {
186    let later: Bool
187    public init(later: Bool) { self.later = later }
188    public var id: String { later ? "org.timestamp.day-later" : "org.timestamp.day-earlier" }
189    public var title: String { later ? "Timestamp One Day Later" : "Timestamp One Day Earlier" }
190    public func applies(in context: EditContext) -> Bool { atTimestamp(context) }
191    public func run(in context: EditContext) -> CommandStep {
192        timestampCommand(context) { try $0.changeTimestamp(later ? 1 : -1, what: .day, updown: true) }
193    }
194}
195
196/// C-c C-y: `org-evaluate-time-range`. On a clock line, updates it; otherwise says how long the
197/// range at the caret, or the first on its line, lasts.
198public struct EvaluateTimeRange: OrgCommand {
199    public init() {}
200    public var id: String { "org.timestamp.evaluate-range" }
201    public var title: String { "Evaluate Time Range" }
202    public func applies(in context: EditContext) -> Bool { true }
203    public func run(in context: EditContext) -> CommandStep {
204        let buffer = EmacsBuffer(context.text, point: context.caret)
205        buffer.calendar = context.calendar
206        if buffer.lookingAtLine("^[ \\t]*CLOCK:") { return CtrlCCtrlC().run(in: context) }
207        let ns = context.text as NSString
208        let line = ns.lineRange(for: NSRange(location: min(context.caret, ns.length), length: 0))
209        let regex = try! NSRegularExpression(pattern: "[\\[<]([0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*?)[\\]>]--?-?[\\[<]([0-9]{4}-[0-9]{2}-[0-9]{2}[^\\]>\\n]*?)[\\]>]")
210        let matches = regex.matches(in: context.text, range: line)
211        guard let m = matches.first(where: { $0.range.location <= context.caret && context.caret <= NSMaxRange($0.range) }) ?? matches.first else {
212            return .failed("Not at a timestamp range, and none found in current line")
213        }
214        let ts1 = ns.substring(with: m.range(at: 1))
215        let ts2 = ns.substring(with: m.range(at: 2))
216        let haveTime = ts1.count > 15 || ts2.count > 15
217        let t1 = TagsMatcher.seconds(ts1, now: context.now, calendar: context.calendar)
218        let t2 = TagsMatcher.seconds(ts2, now: context.now, calendar: context.calendar)
219        var diff = abs(t2 - t1)
220        var d = 0, h = 0, minutes = 0
221        if haveTime {
222            d = Int(floor(diff / 86400)); diff = diff.truncatingRemainder(dividingBy: 86400)
223            h = Int(floor(diff / 3600)); diff = diff.truncatingRemainder(dividingBy: 3600)
224            minutes = Int(floor(diff / 60))
225        } else {
226            d = Int((diff / 86400).rounded())
227        }
228        var parts: [String] = []
229        if d > 0 { parts.append("\(d) day" + (d > 1 ? "s" : "")) }
230        if h > 0 { parts.append("\(h) hour" + (h > 1 ? "s" : "")) }
231        if minutes > 0 { parts.append("\(minutes) minute" + (minutes > 1 ? "s" : "")) }
232        let text = parts.isEmpty ? "" : parts.joined(separator: " ") + " "
233        return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.message(text)]))
234    }
235}