krz/orgstar

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

Sources/OrgCore/Commands/TimestampCommands.swift

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

170 lines · 8921 bytes

  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        let def = defaultDate ?? now
  8        var input = answer
  9        if input.trimmingCharacters(in: .whitespaces).isEmpty {
 10            let c = calendar.dateComponents([.year, .month, .day], from: def)
 11            input = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
 12        }
 13        return ReadDate.analyze(input, default: def, now: now, calendar: calendar)
 14    }
 15
 16    func date(_ components: DateComponents) -> Date { calendar.date(from: components)! }
 17
 18    /// `org-timestamp`: replaces the timestamp at point, keeping its repeater, or inserts one.
 19    func timestampCommand(_ answer: String, inactive: Bool) {
 20        let existing = atTimestamp()
 21        let stamp = existing.map { substring($0.match[0]!.lowerBound, $0.match[0]!.upperBound) }
 22        let defaultDate = stamp.flatMap(Self.parseTimeString).map(date)
 23        let repeater = stamp.flatMap { $0.firstMatch(of: try! Regex("([.+-]+[0-9]+[hdwmy] ?)+")).map { String($0.output[0].substring!) } }
 24        let result = readDate(answer, default: defaultDate)
 25        let components = result.components(calendar)
 26        if let existing {
 27            replace(existing.match[0]!, with: "")
 28            insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
 29            if let repeater {
 30                point -= 1
 31                insert(" " + repeater)
 32            }
 33        } else {
 34            insertTimestamp(components, withTime: result.timeGiven, inactive: inactive, endTime: result.endTime)
 35        }
 36    }
 37
 38    /// The first timestamp after `kind` in the entry, as the date prompt's default.
 39    func planningDefault(_ kind: PlanningKind) -> String? {
 40        saveExcursion {
 41            guard (try? backToHeading()) != nil else { return nil }
 42            let end = saveExcursion { () -> Int in outlineNextHeading(); return point }
 43            return searchForward(kind.pattern, bound: end, caseInsensitive: false).map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
 44        }
 45    }
 46
 47    /// `org--deadline-or-schedule` without a prefix argument.
 48    func schedule(_ kind: PlanningKind, answer: String) throws {
 49        let old = planningValue(kind)
 50        let repeatPattern = "([.+-]+[0-9]+[hdwmy](?:[/ ][-+]?[0-9]+[hdwmy])?)"
 51        let repeater = old.flatMap { $0.firstMatch(of: try! Regex(repeatPattern)).map { String($0.output[1].substring!) } }
 52        let defaultDate = planningDefault(kind).flatMap(Self.parseTimeString).map(date)
 53        let result = readDate(answer, default: defaultDate)
 54        try addPlanningInfo(kind, time: result.components(calendar), withTime: result.timeGiven, endTime: result.endTime, remove: [.closed])
 55        if let repeater, let inserted = lastInsertedTimestamp {
 56            try saveExcursion {
 57                try backToHeading()
 58                let bound = saveExcursion { () -> Int in forwardLine(2); return lineEnd(point) }
 59                if let match = searchForward(NSRegularExpression.escapedPattern(for: kind.keyword + " " + inserted), bound: bound, caseInsensitive: false) {
 60                    point = match[0]!.upperBound - 1
 61                    insert(" " + repeater)
 62                }
 63            }
 64        }
 65    }
 66
 67    /// `org--deadline-or-schedule` with C-u: removes the planning timestamp.
 68    func removePlanning(_ kind: PlanningKind) {
 69        guard planningValue(kind) != nil else { return }
 70        saveExcursion {
 71            guard let begin = try? backToHeading() else { return }
 72            outlineNextHeading()
 73            let pattern = "\\b" + NSRegularExpression.escapedPattern(for: kind.keyword) + " +<[^>\\n]+>[ \\t]*"
 74            while let match = searchBackward(pattern, bound: begin, caseInsensitive: false) {
 75                replace(match[0]!, with: "")
 76                let before = substring(lineStart(point), point)
 77                if before.contains(where: { !$0.isWhitespace }), character(at: point - 1) == 32 {
 78                    deleteRegion(point - 1, point)
 79                } else if substring(lineStart(point), lineEnd(point)).allSatisfy({ $0 == " " || $0 == "\t" }) {
 80                    deleteRegion(lineStart(point), min(length, lineEnd(point) + 1))
 81                }
 82            }
 83        }
 84    }
 85}
 86
 87private func timestampCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
 88    commitBuffer(context, body)
 89}
 90
 91private func atTimestamp(_ context: EditContext) -> Bool {
 92    EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
 93}
 94
 95/// The prompt's starting text: the time of the timestamp at the caret, as org pre-fills it.
 96private func timeOfDayAtCaret(_ context: EditContext) -> String {
 97    let buffer = EmacsBuffer(context.text, point: context.caret)
 98    guard let match = buffer.atTimestamp()?.match[0] else { return "" }
 99    return EmacsBuffer.compactTimeOfDay(buffer.substring(match.lowerBound, match.upperBound)) ?? ""
100}
101
102/// C-c . and C-c !: `org-timestamp`.
103public struct InsertTimestamp: OrgCommand {
104    let inactive: Bool
105    public init(inactive: Bool = false) { self.inactive = inactive }
106    public var id: String { inactive ? "org.timestamp.inactive" : "org.timestamp.active" }
107    public var title: String { inactive ? "Insert Inactive Timestamp" : "Insert Timestamp" }
108    public func applies(in context: EditContext) -> Bool { true }
109    public func run(in context: EditContext) -> CommandStep {
110        guard let answer = context.answers["date"] else {
111            return .prompt(Prompt(key: "date", message: "Date+time:", initial: timeOfDayAtCaret(context)))
112        }
113        return timestampCommand(context) { $0.timestampCommand(answer, inactive: inactive) }
114    }
115}
116
117/// C-c C-s and C-c C-d: `org-schedule` and `org-deadline`.
118public struct SetPlanning: OrgCommand {
119    let deadline: Bool
120    public init(deadline: Bool) { self.deadline = deadline }
121    public var id: String { deadline ? "org.deadline" : "org.schedule" }
122    public var title: String { deadline ? "Set Deadline" : "Schedule" }
123    public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
124    public func run(in context: EditContext) -> CommandStep {
125        guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") }
126        let kind: EmacsBuffer.PlanningKind = deadline ? .deadline : .scheduled
127        guard let answer = context.answers["date"] else {
128            let buffer = EmacsBuffer(context.text, point: context.caret)
129            let initial = buffer.planningDefault(kind).flatMap(EmacsBuffer.compactTimeOfDay) ?? ""
130            return .prompt(Prompt(key: "date", message: deadline ? "DEADLINE:" : "SCHEDULED:", initial: initial))
131        }
132        return timestampCommand(context) { try $0.schedule(kind, answer: answer) }
133    }
134}
135
136/// C-u C-c C-s and C-u C-c C-d.
137public struct RemovePlanning: OrgCommand {
138    let deadline: Bool
139    public init(deadline: Bool) { self.deadline = deadline }
140    public var id: String { deadline ? "org.deadline.remove" : "org.schedule.remove" }
141    public var title: String { deadline ? "Remove Deadline" : "Remove Schedule" }
142    public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
143    public func run(in context: EditContext) -> CommandStep {
144        timestampCommand(context) { $0.removePlanning(deadline ? .deadline : .scheduled) }
145    }
146}
147
148/// S-up and S-down on a timestamp: `org-timestamp-up` and `org-timestamp-down`.
149public struct ShiftTimestamp: OrgCommand {
150    let up: Bool
151    public init(up: Bool) { self.up = up }
152    public var id: String { up ? "org.timestamp.up" : "org.timestamp.down" }
153    public var title: String { up ? "Increase Timestamp Part" : "Decrease Timestamp Part" }
154    public func applies(in context: EditContext) -> Bool { atTimestamp(context) }
155    public func run(in context: EditContext) -> CommandStep {
156        timestampCommand(context) { try $0.changeTimestamp(up ? 1 : -1, updown: true) }
157    }
158}
159
160/// S-right and S-left on a timestamp: `org-timestamp-up-day` and `org-timestamp-down-day`.
161public struct ShiftTimestampDay: OrgCommand {
162    let later: Bool
163    public init(later: Bool) { self.later = later }
164    public var id: String { later ? "org.timestamp.day-later" : "org.timestamp.day-earlier" }
165    public var title: String { later ? "Timestamp One Day Later" : "Timestamp One Day Earlier" }
166    public func applies(in context: EditContext) -> Bool { atTimestamp(context) }
167    public func run(in context: EditContext) -> CommandStep {
168        timestampCommand(context) { try $0.changeTimestamp(later ? 1 : -1, what: .day, updown: true) }
169    }
170}