krz/orgstar

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

Sources/OrgCore/Commands/Clock.swift

16086b4cf2caff5328774b2cd5ae3ffe1ab65ca4
orgstar/Sources/OrgCore/Commands/Clock.swift history · blame · raw

208 lines · 9562 bytes

  1import Foundation
  2
  3// Clocking (org-clock.el, Org 9.8.7) with `org-clock-into-drawer` t (a LOGBOOK drawer),
  4// `org-log-states-order-reversed` t (newest first), no rounding, no resume, and zero-time
  5// clocks kept.
  6
  7extension EmacsBuffer {
  8    static let clockDrawer = "LOGBOOK"
  9    static let openClockPattern = "^[ \\t]*CLOCK: \\[([0-9]{4}-[0-9]{2}-[0-9]{2} *[^\\]\\s]+ +[012][0-9]:[0-5][0-9])\\][ \\t]*$"
 10
 11    /// `org-clock-find-position` without `find-unclosed`.
 12    func clockFindPosition() throws {
 13        let beg = try backToHeading()
 14        let end = saveExcursion { () -> Int in
 15            _ = outlineNextHeading()
 16            return point
 17        }
 18        let drawerPattern = "^[ \\t]*:" + Self.clockDrawer + ":[ \\t]*$"
 19        point = beg
 20        while let match = searchForward(drawerPattern, bound: end, caseInsensitive: false) {
 21            if element(at: lineStart(match[0]!.lowerBound))?.kind == .drawer {
 22                point = match[0]!.upperBound
 23                forwardLine(1)
 24                return
 25            }
 26        }
 27        point = beg
 28        var positions: [Int] = []
 29        saveExcursion {
 30            while let match = searchForward("^[ \\t]*CLOCK:", bound: end, caseInsensitive: false) {
 31                if element(at: lineStart(match[0]!.lowerBound))?.kind == .clock { positions.insert(lineStart(match[0]!.lowerBound), at: 0) }
 32            }
 33        }
 34        if positions.isEmpty {
 35            endOfMetaData()
 36            if !bolp { insertBeforeMarkers("\n") }
 37            insertBeforeMarkers(":" + Self.clockDrawer + ":\n:END:\n")
 38            forwardLine(-1)
 39            return
 40        }
 41        // Clocks outside a drawer move into a new one.
 42        endOfMetaData()
 43        let start = point
 44        var moved: [String] = []
 45        for position in positions {
 46            let lineStartOffset = position
 47            let next = min(length, lineEnd(lineStartOffset) + 1)
 48            var from = lineStartOffset
 49            while from > 0, let c = character(at: from - 1), c == 32 || c == 9 || c == 10 || c == 13 { from -= 1 }
 50            from = min(length, lineEnd(from) + 1)
 51            if from > lineStartOffset { from = lineStartOffset }
 52            let text = substring(from, next).trimmingCharacters(in: .whitespacesAndNewlines)
 53            deleteRegion(from, next)
 54            moved.append(text)
 55        }
 56        point = min(start, length)
 57        insert(moved.joined(separator: "\n") + "\n:END:\n")
 58        let endMarker = marker(point)
 59        defer { release(endMarker) }
 60        point = start
 61        saveExcursion { insertBeforeMarkers(":" + Self.clockDrawer + ":\n") }
 62        forwardLine(1)
 63    }
 64
 65    /// `org-clock-in` on the heading at point. Returns the clock's start stamp.
 66    @discardableResult
 67    func clockIn() throws -> String {
 68        let start = point
 69        let pointMarker = marker(start)
 70        defer {
 71            point = pointMarker.position
 72            release(pointMarker)
 73        }
 74        if eobp, !atHeadingLine { point = lineStart(max(0, lineStart(point) - 1)) }
 75        try clockFindPosition()
 76        insertBeforeMarkers("\n")
 77        point -= 1
 78        if point > 0, saveExcursion({ () -> Int? in
 79            point = lineStart(point) - 1
 80            return point >= 0 ? inItem() : nil
 81        }) != nil {
 82            beginningOfLine()
 83            let current = substring(point, lineEnd(point)).prefix { $0 == " " || $0 == "\t" }.count
 84            indentLineTo(max(0, current - 2))
 85        }
 86        insert("CLOCK: ")
 87        let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now)
 88        let stamp = insertTimestamp(components, withTime: true, inactive: true)
 89        // `org-indent-line` puts lines in a drawer at column 0 without adapted indentation.
 90        let lineText = substring(lineStart(point), lineEnd(point))
 91        let indent = lineText.prefix { $0 == " " || $0 == "\t" }.count
 92        if indent > 0 { deleteRegion(lineStart(point), lineStart(point) + indent) }
 93        return String(stamp.dropFirst().dropLast())
 94    }
 95
 96    var atHeadingLine: Bool { lookingAtLine("^\\*+ ") }
 97
 98    /// The running clock's line: `CLOCK: [start]` with nothing after it.
 99    func runningClockLine(_ start: String) -> Int? {
100        let pattern = "^[ \\t]*CLOCK: \\[" + NSRegularExpression.escapedPattern(for: start) + "\\][ \\t]*$"
101        return saveExcursion {
102            point = 0
103            return searchForward(pattern, caseInsensitive: false).map { lineStart($0[0]!.lowerBound) }
104        }
105    }
106
107    /// `org-clock-out` for the clock that started at `start`.
108    func clockOut(_ start: String) throws {
109        guard let line = runningClockLine(start) else { throw UserError(message: "Clock start time is gone") }
110        point = line
111        guard let match = looking("[ \\t]*CLOCK: \\[([^\\]]+)\\]", caseInsensitive: false) else { throw UserError(message: "Clock start time is gone") }
112        point = match[0]!.upperBound
113        deleteRegion(point, lineEnd(point))
114        insert("--")
115        let components = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now)
116        let end = insertTimestamp(components, withTime: true, inactive: true)
117        guard let startTime = Self.parseTimeString(start), let endTime = Self.parseTimeString(String(end.dropFirst().dropLast())),
118              let a = calendar.date(from: startTime), let b = calendar.date(from: endTime) else { throw UserError(message: "Clock start time is gone") }
119        let seconds = Int(b.timeIntervalSince(a))
120        let hours = Int((Double(seconds) / 3600).rounded(.down))
121        let minutes = Int((Double(seconds - hours * 3600) / 60).rounded(.down))
122        insert(" => " + String(format: "%2d:%02d", hours, minutes))
123        removeEmptyClockDrawer()
124    }
125
126    /// `org-clock-cancel`: the running clock's line goes, and the drawer if it empties.
127    func clockCancel(_ start: String) throws {
128        guard let line = runningClockLine(start) else { throw UserError(message: "No active clock") }
129        point = lineEnd(line)
130        let from = max(0, line - 1)
131        deleteRegion(from, lineEnd(line))
132        point = from
133        removeEmptyDrawer(at: point)
134    }
135
136    /// `org-clock-remove-empty-clock-drawer` for the subtree at point.
137    func removeEmptyClockDrawer() {
138        saveExcursion {
139            guard let heading = try? backToHeading() else { return }
140            let end = saveExcursion { () -> Int in
141                point = heading
142                return endOfSubtree()
143            }
144            point = heading
145            let pattern = "(?i)^[ \\t]*:" + Self.clockDrawer + ":[ \\t]*$"
146            while let match = searchForward(pattern, bound: end, caseInsensitive: true) {
147                removeEmptyDrawer(at: match[0]!.upperBound)
148            }
149        }
150    }
151
152    /// `org-remove-empty-drawer-at`.
153    func removeEmptyDrawer(at position: Int) {
154        let lineStartOffset = lineStart(min(position, length))
155        let line = substring(lineStartOffset, lineEnd(lineStartOffset))
156        guard line.trimmingCharacters(in: .whitespaces).range(of: "^:[\\w-]+:$", options: .regularExpression) != nil,
157              line.trimmingCharacters(in: .whitespaces).uppercased() != ":END:" else { return }
158        let next = min(length, lineEnd(lineStartOffset) + 1)
159        let following = substring(next, lineEnd(next))
160        guard following.trimmingCharacters(in: .whitespaces).uppercased() == ":END:" else { return }
161        deleteRegion(lineStartOffset, min(length, lineEnd(next) + 1))
162    }
163}
164
165/// C-c C-x C-i: `org-clock-in` at the heading at the caret.
166public struct ClockIn: OrgCommand {
167    public init() {}
168    public var id: String { "org.clock.in" }
169    public var title: String { "Clock In" }
170    public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
171    public func run(in context: EditContext) -> CommandStep {
172        guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") }
173        return commitBuffer(context) { try $0.clockIn() }
174    }
175
176    /// The start of the clock `ClockIn` left running in the entry at `offset`.
177    public static func runningStart(in text: String, near offset: Int) -> String? {
178        let buffer = EmacsBuffer(text, point: offset)
179        guard let heading = try? buffer.backToHeading() else { return nil }
180        let end = buffer.saveExcursion { () -> Int in
181            _ = buffer.outlineNextHeading()
182            return buffer.point
183        }
184        buffer.point = heading
185        guard let match = buffer.searchForward(EmacsBuffer.openClockPattern, bound: end, caseInsensitive: false) else { return nil }
186        return buffer.substring(match[1]!.lowerBound, match[1]!.upperBound)
187    }
188}
189
190/// C-c C-x C-o and C-c C-x C-q for the clock that started at `start`.
191public struct ClockOut: OrgCommand {
192    public let start: String
193    public let cancel: Bool
194    public init(start: String, cancel: Bool = false) {
195        self.start = start
196        self.cancel = cancel
197    }
198    public var id: String { cancel ? "org.clock.cancel" : "org.clock.out" }
199    public var title: String { cancel ? "Cancel Clock" : "Clock Out" }
200    public func applies(in context: EditContext) -> Bool { true }
201    public func run(in context: EditContext) -> CommandStep {
202        commitBuffer(context) { buffer in
203            try buffer.saveExcursion {
204                if cancel { try buffer.clockCancel(start) } else { try buffer.clockOut(start) }
205            }
206        }
207    }
208}