Timestamps, planning and repeats !17
13 files changed, +1286 −5
Layout: unified · split
Sources/OrgCore/Commands/Command.swift +10 −1
| @@ -11,12 +11,18 @@ public struct EditingOptions: Sendable, Equatable, Codable { | |||
| 11 | public var metaReturnMaySplitLine: Bool | 11 | public var metaReturnMaySplitLine: Bool |
| 12 | /// `org-list-allow-alphabetical`: `a.` and `A)` are list bullets. | 12 | /// `org-list-allow-alphabetical`: `a.` and `A)` are list bullets. |
| 13 | public var listAllowAlphabetical: Bool | 13 | public var listAllowAlphabetical: Bool |
| 14 | /// `org-log-repeat` `time`: completing a repeating entry records LAST_REPEAT and a state note. | ||
| 15 | public var logRepeat: Bool | ||
| 14 | 16 | ||
| 15 | public init(tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, listAllowAlphabetical: Bool = false) { | 17 | public init( |
| 18 | tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, | ||
| 19 | listAllowAlphabetical: Bool = false, logRepeat: Bool = true | ||
| 20 | ) { | ||
| 16 | self.tagsColumn = tagsColumn | 21 | self.tagsColumn = tagsColumn |
| 17 | self.insertHeadingRespectContent = insertHeadingRespectContent | 22 | self.insertHeadingRespectContent = insertHeadingRespectContent |
| 18 | self.metaReturnMaySplitLine = metaReturnMaySplitLine | 23 | self.metaReturnMaySplitLine = metaReturnMaySplitLine |
| 19 | self.listAllowAlphabetical = listAllowAlphabetical | 24 | self.listAllowAlphabetical = listAllowAlphabetical |
| 25 | self.logRepeat = logRepeat | ||
| 20 | } | 26 | } |
| 21 | 27 | ||
| 22 | /// Org's own defaults. | 28 | /// Org's own defaults. |
| @@ -141,6 +147,9 @@ public enum Commands { | |||
| 141 | SetTags(), | 147 | SetTags(), |
| 142 | InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(), | 148 | InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(), |
| 143 | MoveItemUp(), MoveItemDown(), ToggleCheckbox(), | 149 | MoveItemUp(), MoveItemDown(), ToggleCheckbox(), |
| 150 | InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true), | ||
| 151 | RemovePlanning(deadline: false), RemovePlanning(deadline: true), | ||
| 152 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), | ||
| 144 | ] | 153 | ] |
| 145 | 154 | ||
| 146 | public static func command(_ id: String) -> (any OrgCommand)? { | 155 | public static func command(_ id: String) -> (any OrgCommand)? { |
Sources/OrgCore/Commands/EmacsBuffer.swift +7
| @@ -27,6 +27,11 @@ final class EmacsBuffer { | |||
| 27 | let settings: OrgSettings | 27 | let settings: OrgSettings |
| 28 | /// Groups of the last successful match, as Emacs's match data. | 28 | /// Groups of the last successful match, as Emacs's match data. |
| 29 | var lastMatch: [Range<Int>?] = [] | 29 | var lastMatch: [Range<Int>?] = [] |
| 30 | /// The clock and calendar commands see. | ||
| 31 | var now = Date() | ||
| 32 | var calendar = Calendar.current | ||
| 33 | /// `org-last-inserted-timestamp`. | ||
| 34 | var lastInsertedTimestamp: String? | ||
| 30 | 35 | ||
| 31 | let options: EditingOptions | 36 | let options: EditingOptions |
| 32 | 37 | ||
| @@ -434,6 +439,8 @@ final class EmacsBuffer { | |||
| 434 | /// and the new caret. A user error becomes `.failed`. | 439 | /// and the new caret. A user error becomes `.failed`. |
| 435 | func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { | 440 | func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { |
| 436 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options) | 441 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options) |
| 442 | buffer.now = context.now | ||
| 443 | buffer.calendar = context.calendar | ||
| 437 | do { | 444 | do { |
| 438 | try body(buffer) | 445 | try body(buffer) |
| 439 | } catch let error as EmacsBuffer.UserError { | 446 | } catch let error as EmacsBuffer.UserError { |
Sources/OrgCore/Commands/HeadingCommands.swift +11 −2
| @@ -220,9 +220,18 @@ public struct TodoCycle: OrgCommand { | |||
| 220 | /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are | 220 | /// As `org-todo`: the blanks after the stars, the keyword and the blanks after it are |
| 221 | /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags | 221 | /// replaced by " NEXT " (or " " for no keyword) with `insert-before-markers`, then tags |
| 222 | /// are aligned. | 222 | /// are aligned. |
| 223 | /// `org-todo`, then the parent's TODO statistics. | 223 | /// `org-todo`, then the parent's TODO statistics, and the repeat of a repeating entry |
| 224 | /// that became done. | ||
| 224 | public func run(in context: EditContext) -> CommandStep { | 225 | public func run(in context: EditContext) -> CommandStep { |
| 225 | followUp(context, cycle(in: context)) { $0.updateParentTodoStatistics() } | 226 | let previous = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword |
| 227 | return followUp(context, cycle(in: context)) { buffer in | ||
| 228 | try buffer.saveExcursion { | ||
| 229 | buffer.updateParentTodoStatistics() | ||
| 230 | let settings = context.tree.settings | ||
| 231 | guard let new = buffer.todoKeyword, settings.isDone(new), !(previous.map(settings.isDone) ?? false) else { return } | ||
| 232 | if let (marker, note) = try buffer.autoRepeat(doneWord: new, previous: previous) { buffer.storeNote(marker, note) } | ||
| 233 | } | ||
| 234 | } | ||
| 226 | } | 235 | } |
| 227 | 236 | ||
| 228 | private func cycle(in context: EditContext) -> CommandStep { | 237 | private func cycle(in context: EditContext) -> CommandStep { |
Sources/OrgCore/Commands/Repeat.swift added +162
| @@ -0,0 +1,162 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // Repeating entries (`org-auto-repeat-maybe`, Org 9.8.7) and the state note org logs for them | ||
| 4 | // (`org-add-log-setup`, `org-store-log-note`, `org-log-beginning`), with `org-log-repeat` | ||
| 5 | // `time`, `org-log-into-drawer` nil, `org-todo-repeat-to-state` nil. | ||
| 6 | |||
| 7 | extension EmacsBuffer { | ||
| 8 | static let repeatPattern = "<[0-9]{4}-[0-9][0-9]-[0-9][0-9] [^>\\n]*?([.+]?\\+[0-9]+[hdwmy](/[0-9]+[hdwmy])?)" | ||
| 9 | |||
| 10 | /// `org-entry-end-position`: the next heading, or the end of the buffer. | ||
| 11 | var entryEnd: Int { | ||
| 12 | saveExcursion { | ||
| 13 | _ = try? backToHeading() | ||
| 14 | outlineNextHeading() | ||
| 15 | return point | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | /// `org-get-repeat`. | ||
| 20 | func repeatCookie() -> String? { | ||
| 21 | saveExcursion { | ||
| 22 | guard (try? backToHeading()) != nil else { return nil } | ||
| 23 | let end = entryEnd | ||
| 24 | return searchForward(Self.repeatPattern, bound: end, caseInsensitive: false).map { substring($0[1]!.lowerBound, $0[1]!.upperBound) } | ||
| 25 | } | ||
| 26 | } | ||
| 27 | |||
| 28 | /// The TODO keyword of the heading at point, if any. | ||
| 29 | var todoKeyword: String? { | ||
| 30 | saveExcursion { | ||
| 31 | guard (try? backToHeading()) != nil, let match = lookingAt(todoLinePattern), let keyword = match[2] else { return nil } | ||
| 32 | return substring(keyword.lowerBound, keyword.upperBound) | ||
| 33 | } | ||
| 34 | } | ||
| 35 | |||
| 36 | /// The keyword change `org-todo` makes, with its tag alignment and statistics update. | ||
| 37 | func setTodoKeyword(_ keyword: String?) { | ||
| 38 | saveExcursion { | ||
| 39 | guard (try? backToHeading()) != nil, let stars = lookingAt("\\*+ ") else { return } | ||
| 40 | point = stars[0]!.upperBound - 1 | ||
| 41 | let names = keywordsInOrder.map { NSRegularExpression.escapedPattern(for: $0) }.joined(separator: "|") | ||
| 42 | let match = lookingAt(" +(" + names + ")( +|[ \\t]*$)") ?? lookingAt("(?: *|[ \\t]*$)")! | ||
| 43 | replace(match[0]!, with: "") | ||
| 44 | point = match[0]!.lowerBound | ||
| 45 | insertBeforeMarkers(keyword.map { " " + $0 + " " } ?? " ") | ||
| 46 | alignTags(onLineAt: point) | ||
| 47 | updateParentTodoStatistics() | ||
| 48 | } | ||
| 49 | } | ||
| 50 | |||
| 51 | /// `org-auto-repeat-maybe` after `doneWord` replaced `previous`. Returns the note to log, if | ||
| 52 | /// any, with the marker it belongs at. | ||
| 53 | func autoRepeat(doneWord: String, previous: String?) throws -> (Marker, String)? { | ||
| 54 | guard let cookie = repeatCookie(), (Int(cookie.drop { !$0.isNumber }.prefix { $0.isNumber }) ?? 0) != 0 else { return nil } | ||
| 55 | let end = marker(entryEnd) | ||
| 56 | defer { release(end) } | ||
| 57 | let sequence = settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == previous } } | ||
| 58 | let head = sequence.flatMap { ($0.active + $0.done).first?.name } | ||
| 59 | let propertyState = inheritedProperty("REPEAT_TO_STATE")?.value | ||
| 60 | let toState = propertyState.flatMap { keywordsInOrder.contains($0) ? $0 : nil } | ||
| 61 | ?? (sequence?.kind == .type ? previous : head) | ||
| 62 | setTodoKeyword(toState) | ||
| 63 | try backToHeading() | ||
| 64 | try addPlanningInfo(nil, time: nil, withTime: false, endTime: nil, remove: [.closed]) | ||
| 65 | let stamp = formatTimestamp(calendar.dateComponents([.year, .month, .day, .hour, .minute], from: now), withTime: true, inactive: true) | ||
| 66 | if options.logRepeat { entryPut("LAST_REPEAT", stamp) } | ||
| 67 | let note = marker(point) | ||
| 68 | if let scheduled = planningValue(.scheduled), ("<" + scheduled + ">").range(of: Self.repeatPattern, options: .regularExpression) == nil { | ||
| 69 | saveExcursion { removePlanning(.scheduled) } | ||
| 70 | } | ||
| 71 | let units: [String: TimestampUnit] = ["h": .hour, "d": .day, "m": .month, "y": .year] | ||
| 72 | let today = calendar.startOfDay(for: now) | ||
| 73 | while let match = searchForward(Self.repeatPattern, bound: end.position, caseInsensitive: false) { | ||
| 74 | let stamp = substring(match[0]!.lowerBound, match[0]!.upperBound) | ||
| 75 | guard let parts = stamp.firstMatch(of: /([.+])?(\+[0-9]+)([hdwmy])/) else { continue } | ||
| 76 | var n = Int(parts.2.dropFirst())! | ||
| 77 | var what = String(parts.3) | ||
| 78 | if what == "w" { n *= 7; what = "d" } | ||
| 79 | guard let unit = units[what] else { continue } | ||
| 80 | if what == "h", stamp.range(of: "[0-9]{1,2}:[0-9]{2}", options: .regularExpression) == nil { | ||
| 81 | throw UserError(message: "Cannot repeat in \(n) hour(s) because no hour has been set") | ||
| 82 | } | ||
| 83 | if let kind = parts.1 { | ||
| 84 | let time = Self.parseTimeString(stamp).map(date)! | ||
| 85 | if kind == "." { | ||
| 86 | if what != "h" { | ||
| 87 | let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: time), to: today).day! | ||
| 88 | try changeTimestamp(days, what: .day) | ||
| 89 | } | ||
| 90 | } else { | ||
| 91 | var shifts = 0 | ||
| 92 | var current = time | ||
| 93 | while shifts == 0 || (what == "h" ? current <= now : calendar.startOfDay(for: current) <= today) { | ||
| 94 | shifts += 1 | ||
| 95 | if shifts > 100_000 { break } | ||
| 96 | try changeTimestamp(n, what: unit) | ||
| 97 | let found = inRegexp(Self.timestampPattern)! | ||
| 98 | current = Self.parseTimeString(substring(found[1]!.lowerBound, found[1]!.upperBound)).map(date)! | ||
| 99 | } | ||
| 100 | try changeTimestamp(-n, what: unit) | ||
| 101 | } | ||
| 102 | } | ||
| 103 | try saveExcursion { try changeTimestamp(n, what: unit, suppressDelay: true) } | ||
| 104 | } | ||
| 105 | guard options.logRepeat else { | ||
| 106 | release(note) | ||
| 107 | return nil | ||
| 108 | } | ||
| 109 | func quoted(_ text: String?) -> String { text.map { "\"\($0)\"" } ?? "" } | ||
| 110 | let line = "State " + quoted(doneWord).padding(toLength: max(12, quoted(doneWord).count), withPad: " ", startingAt: 0) | ||
| 111 | + " from " + quoted(previous).padding(toLength: max(12, quoted(previous).count), withPad: " ", startingAt: 0) | ||
| 112 | + " " + stamp | ||
| 113 | return (note, line) | ||
| 114 | } | ||
| 115 | |||
| 116 | /// `org-store-log-note` for a one-line note at `marker`. | ||
| 117 | func storeNote(_ marker: Marker, _ text: String) { | ||
| 118 | saveExcursion { | ||
| 119 | point = marker.position | ||
| 120 | release(marker) | ||
| 121 | point = logBeginning() | ||
| 122 | if !bolp { | ||
| 123 | insert("\n") | ||
| 124 | } else if lookingAt("[ \\t]*\\S") != nil { | ||
| 125 | saveExcursion { insert("\n") } | ||
| 126 | } | ||
| 127 | if let item = inItem() { | ||
| 128 | let structure = saveExcursion { point = item; return listStruct() } | ||
| 129 | indentLineTo(structure.ind(structure.top)) | ||
| 130 | } else { | ||
| 131 | indentLineTo(0) | ||
| 132 | } | ||
| 133 | insert("- " + text) | ||
| 134 | } | ||
| 135 | } | ||
| 136 | |||
| 137 | /// `org-end-of-meta-data` without FULL. | ||
| 138 | func endOfMetaData() { | ||
| 139 | _ = try? backToHeading() | ||
| 140 | forwardLine(1) | ||
| 141 | if lookingAt(Self.planningLinePattern) != nil { forwardLine(1) } | ||
| 142 | if let drawer = looking(Self.propertyDrawerPattern) { | ||
| 143 | point = drawer[0]!.upperBound | ||
| 144 | forwardLine(1) | ||
| 145 | } | ||
| 146 | } | ||
| 147 | |||
| 148 | /// `org-log-beginning` without a log drawer, with `org-log-states-order-reversed` t: the | ||
| 149 | /// newest note comes first. | ||
| 150 | func logBeginning() -> Int { | ||
| 151 | saveExcursion { | ||
| 152 | endOfMetaData() | ||
| 153 | let endpos = point | ||
| 154 | skipForward([32, 9, 10]) | ||
| 155 | forwardLine(0) | ||
| 156 | if point < endpos { point = endpos } | ||
| 157 | if bolp { return point } | ||
| 158 | let end = lineEnd(point) | ||
| 159 | return end < length ? end + 1 : length | ||
| 160 | } | ||
| 161 | } | ||
| 162 | } | ||
Sources/OrgCore/Commands/TimestampCommands.swift added +170
| @@ -0,0 +1,170 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | extension 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 | |||
| 87 | private func timestampCommand(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { | ||
| 88 | commitBuffer(context, body) | ||
| 89 | } | ||
| 90 | |||
| 91 | private 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. | ||
| 96 | private 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`. | ||
| 103 | public 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`. | ||
| 118 | public 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. | ||
| 137 | public 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`. | ||
| 149 | public 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`. | ||
| 161 | public 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 | } | ||
Sources/OrgCore/Commands/Timestamps.swift added +324
| @@ -0,0 +1,324 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // Timestamps and planning, ported from Org 9.8.7: `org-at-timestamp-p`, `org-timestamp`, | ||
| 4 | // `org-timestamp-change`, `org-modify-ts-extra`, `org-add-planning-info`, | ||
| 5 | // `org--deadline-or-schedule`. `org-time-stamp-rounding-minutes` (0 5), `org-adapt-indentation` | ||
| 6 | // nil, `org-log-reschedule` and `org-log-redeadline` nil. | ||
| 7 | |||
| 8 | extension EmacsBuffer { | ||
| 9 | static let timestampCore = "(([0-9]{4})-([0-9]{2})-([0-9]{2})(?: *([^\\]+0-9>\\r\\n -]+))?( ([0-9]{1,2}):([0-9]{2}))?)" | ||
| 10 | /// `org-ts-regexp3`: groups 2 year, 3 month, 4 day, 5 day name, 7 hour, 8 minute. | ||
| 11 | static let timestampPattern = "[\\[<]" + timestampCore + "[^\\]>\\n]{0,16}[\\]>]" | ||
| 12 | /// `org-ts-regexp0`, for time strings. | ||
| 13 | static let timeStringPattern = "(([0-9]{4})-([0-9]{2})-([0-9]{2})( +[^\\]+0-9>\\r\\n -]+)?( +([0-9]{1,2}):([0-9]{2}))?)" | ||
| 14 | |||
| 15 | /// `org-in-regexp` on the current line: the match that contains point. | ||
| 16 | func inRegexp(_ pattern: String) -> [Range<Int>?]? { | ||
| 17 | let regex = Self.regex(pattern, caseInsensitive: false) | ||
| 18 | let lineRange = NSRange(lineStart(point)..<lineEnd(point)) | ||
| 19 | for match in regex.matches(in: string, options: Self.searchOptions, range: lineRange) { | ||
| 20 | let range = match.range.location..<NSMaxRange(match.range) | ||
| 21 | guard range.lowerBound <= point else { break } | ||
| 22 | if range.upperBound >= point { | ||
| 23 | let groups = (0..<match.numberOfRanges).map { i -> Range<Int>? in | ||
| 24 | let r = match.range(at: i) | ||
| 25 | return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r) | ||
| 26 | } | ||
| 27 | lastMatch = groups | ||
| 28 | return groups | ||
| 29 | } | ||
| 30 | } | ||
| 31 | return nil | ||
| 32 | } | ||
| 33 | |||
| 34 | enum TimestampPart: Equatable { | ||
| 35 | case bracket, after, year, month, hour, minute, day | ||
| 36 | /// Characters past the minutes or day name, in the repeater or end time. | ||
| 37 | case extra(Int) | ||
| 38 | } | ||
| 39 | |||
| 40 | /// `org-at-timestamp-p` with `lax`: where in a timestamp point is, and the match. | ||
| 41 | func atTimestamp() -> (part: TimestampPart, match: [Range<Int>?])? { | ||
| 42 | guard let match = inRegexp(Self.timestampPattern) else { return nil } | ||
| 43 | let pos = point | ||
| 44 | func inGroup(_ i: Int) -> Bool { match[i].map { $0.lowerBound <= pos && pos <= $0.upperBound } ?? false } | ||
| 45 | let whole = match[0]! | ||
| 46 | let part: TimestampPart | ||
| 47 | if pos == whole.lowerBound || pos == whole.upperBound - 1 { | ||
| 48 | part = .bracket | ||
| 49 | } else if pos == whole.upperBound { | ||
| 50 | part = .after | ||
| 51 | } else if inGroup(2) { | ||
| 52 | part = .year | ||
| 53 | } else if inGroup(3) { | ||
| 54 | part = .month | ||
| 55 | } else if inGroup(7) { | ||
| 56 | part = .hour | ||
| 57 | } else if inGroup(8) { | ||
| 58 | part = .minute | ||
| 59 | } else if inGroup(4) || inGroup(5) { | ||
| 60 | part = .day | ||
| 61 | } else if let last = match[8]?.upperBound ?? match[5]?.upperBound, pos > last, pos < whole.upperBound { | ||
| 62 | part = .extra(pos - last) | ||
| 63 | } else { | ||
| 64 | part = .day | ||
| 65 | } | ||
| 66 | return (part, match) | ||
| 67 | } | ||
| 68 | |||
| 69 | /// `org-parse-time-string`: date and time of an Org time string, time 0:00 if absent. | ||
| 70 | static func parseTimeString(_ text: String) -> DateComponents? { | ||
| 71 | guard let match = text.firstMatch(of: try! Regex(timeStringPattern)) else { return nil } | ||
| 72 | func group(_ i: Int) -> Int? { match.output[i].substring.flatMap { Int($0) } } | ||
| 73 | return DateComponents(year: group(2), month: group(3), day: group(4), hour: group(7) ?? 0, minute: group(8) ?? 0) | ||
| 74 | } | ||
| 75 | |||
| 76 | /// `format-time-string` with Org's timestamp formats, plus `extra` before the closing bracket. | ||
| 77 | func formatTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, extra: String? = nil) -> String { | ||
| 78 | let date = calendar.date(from: components)! | ||
| 79 | let formatter = DateFormatter() | ||
| 80 | formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| 81 | formatter.calendar = calendar | ||
| 82 | formatter.timeZone = calendar.timeZone | ||
| 83 | formatter.dateFormat = withTime ? "yyyy-MM-dd EEE HH:mm" : "yyyy-MM-dd EEE" | ||
| 84 | return (inactive ? "[" : "<") + formatter.string(from: date) + (extra ?? "") + (inactive ? "]" : ">") | ||
| 85 | } | ||
| 86 | |||
| 87 | /// `org-insert-timestamp`: inserted before markers, as org does. | ||
| 88 | @discardableResult | ||
| 89 | func insertTimestamp(_ components: DateComponents, withTime: Bool, inactive: Bool, endTime: String? = nil, extra: String? = nil) -> String { | ||
| 90 | var suffix = extra | ||
| 91 | if let endTime, let match = endTime.firstMatch(of: /([0-9]+):([0-9]+)/) { | ||
| 92 | suffix = String(format: "-%02d:%02d", Int(match.1)!, Int(match.2)!) | ||
| 93 | } | ||
| 94 | let stamp = formatTimestamp(components, withTime: withTime, inactive: inactive, extra: suffix) | ||
| 95 | insertBeforeMarkers(stamp) | ||
| 96 | lastInsertedTimestamp = stamp | ||
| 97 | return stamp | ||
| 98 | } | ||
| 99 | |||
| 100 | /// `org-modify-ts-extra`. | ||
| 101 | static func modifyTimestampExtra(_ extra: String, _ position: Int, _ increments: Int, _ step: Int) -> String { | ||
| 102 | let regex = try! NSRegularExpression(pattern: "(-([012][0-9]):([0-5][0-9]))?( +\\+([0-9]+)([dmwy]))?( +-([0-9]+)([dmwy]))?") | ||
| 103 | let ns = extra as NSString | ||
| 104 | guard let match = regex.firstMatch(in: extra, range: NSRange(location: 0, length: ns.length)) else { return extra } | ||
| 105 | func range(_ i: Int) -> Range<Int>? { | ||
| 106 | let r = match.range(at: i) | ||
| 107 | return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r) | ||
| 108 | } | ||
| 109 | func inGroup(_ i: Int) -> Bool { range(i).map { $0.lowerBound <= position && position <= $0.upperBound } ?? false } | ||
| 110 | func text(_ i: Int) -> String { ns.substring(with: match.range(at: i)) } | ||
| 111 | let order: [(String, Int)] = [("d", 0), ("w", 1), ("m", 2), ("y", 3), ("d", -1), ("y", 4)] | ||
| 112 | func shifted(_ unit: String) -> String? { | ||
| 113 | let index = order.first { $0.0 == unit }!.1 + increments | ||
| 114 | return order.first { $0.1 == index }?.0 | ||
| 115 | } | ||
| 116 | var group: Int? | ||
| 117 | var new = "" | ||
| 118 | if inGroup(2) || inGroup(3) { | ||
| 119 | var minute = Int(text(3))!, hour = Int(text(2))! | ||
| 120 | if inGroup(2) { | ||
| 121 | hour += increments | ||
| 122 | } else { | ||
| 123 | let n = step * increments | ||
| 124 | let rem = minute % step | ||
| 125 | if rem != 0 { minute += n > 0 ? -rem : step - rem } | ||
| 126 | minute += n | ||
| 127 | } | ||
| 128 | if minute < 0 { minute += 60; hour -= 1 } | ||
| 129 | if minute > 59 { minute -= 60; hour += 1 } | ||
| 130 | hour = ((hour % 24) + 24) % 24 | ||
| 131 | group = 1 | ||
| 132 | new = String(format: "-%02d:%02d", hour, minute) | ||
| 133 | } else if inGroup(6) { | ||
| 134 | group = 6 | ||
| 135 | new = shifted(text(6)) ?? "" | ||
| 136 | } else if inGroup(5) { | ||
| 137 | group = 5 | ||
| 138 | new = String(max(1, increments + Int(text(5))!)) | ||
| 139 | } else if inGroup(9) { | ||
| 140 | group = 9 | ||
| 141 | new = shifted(text(9)) ?? "" | ||
| 142 | } else if inGroup(8) { | ||
| 143 | group = 8 | ||
| 144 | new = String(max(0, increments + Int(text(8))!)) | ||
| 145 | } | ||
| 146 | guard let group, let target = range(group) else { return extra } | ||
| 147 | return ns.replacingCharacters(in: NSRange(target), with: new) | ||
| 148 | } | ||
| 149 | |||
| 150 | enum TimestampUnit { case minute, hour, day, month, year } | ||
| 151 | |||
| 152 | /// `org-timestamp-change`. `what` nil changes the part at point. | ||
| 153 | func changeTimestamp(_ n: Int, what: TimestampUnit? = nil, updown: Bool = false, suppressDelay: Bool = false) throws { | ||
| 154 | let origin = point | ||
| 155 | guard let (category, match) = atTimestamp() else { throw UserError(message: "Not at a timestamp") } | ||
| 156 | if what == nil, category == .bracket { | ||
| 157 | toggleTimestampType(match[0]!) | ||
| 158 | return | ||
| 159 | } | ||
| 160 | let target: TimestampPart = what.map { unit in | ||
| 161 | switch unit { | ||
| 162 | case .minute: .minute | ||
| 163 | case .hour: .hour | ||
| 164 | case .day: .day | ||
| 165 | case .month: .month | ||
| 166 | case .year: .year | ||
| 167 | } | ||
| 168 | } ?? category | ||
| 169 | let whole = match[0]! | ||
| 170 | let inactive = character(at: whole.lowerBound) == 91 | ||
| 171 | let stamp = substring(whole.lowerBound, whole.upperBound) | ||
| 172 | replace(whole, with: "") | ||
| 173 | var extra: String? | ||
| 174 | if let found = stamp.firstMatch(of: try! Regex("((-[012][0-9]:[0-5][0-9])?( +[.+]?-?[-+][0-9]+[hdwmy](/[0-9]+[hdwmy])?)*)[\\]>]")) { | ||
| 175 | extra = found.output[1].substring.map(String.init) | ||
| 176 | if suppressDelay, let value = extra { extra = value.replacingOccurrences(of: " --[0-9]+[hdwmy]", with: "", options: .regularExpression) } | ||
| 177 | } | ||
| 178 | let withTime = stamp.range(of: "^.{10}.*?[0-9]+:[0-9][0-9]", options: .regularExpression) != nil | ||
| 179 | var time = Self.parseTimeString(stamp)! | ||
| 180 | var step = 5 | ||
| 181 | var increment = n | ||
| 182 | if updown, target == .minute { | ||
| 183 | increment = step * n.signum() | ||
| 184 | let rem = time.minute! % step | ||
| 185 | if rem != 0 { time.minute! += n > 0 ? -rem : step - rem } | ||
| 186 | } else { | ||
| 187 | step = 1 | ||
| 188 | } | ||
| 189 | switch target { | ||
| 190 | case .minute: time.minute! += increment | ||
| 191 | case .hour: time.hour! += increment | ||
| 192 | case .day: time.day! += increment | ||
| 193 | case .month: time.month! += increment | ||
| 194 | case .year: time.year! += increment | ||
| 195 | default: break | ||
| 196 | } | ||
| 197 | let normalized = calendar.dateComponents([.year, .month, .day, .hour, .minute], from: calendar.date(from: time)!) | ||
| 198 | if target == .hour || target == .minute, let value = extra, value.range(of: "-([012][0-9]):([0-5][0-9])", options: .regularExpression) != nil { | ||
| 199 | extra = Self.modifyTimestampExtra(value, target == .hour ? 2 : 5, n, step) | ||
| 200 | } | ||
| 201 | if case .extra(let position) = target, let value = extra { | ||
| 202 | extra = Self.modifyTimestampExtra(value, position, n, step) | ||
| 203 | } | ||
| 204 | let insertion = point | ||
| 205 | insertTimestamp(normalized, withTime: withTime, inactive: inactive, extra: extra) | ||
| 206 | point = insertion | ||
| 207 | let after = looking(Self.timestampPattern, caseInsensitive: false)! | ||
| 208 | switch category { | ||
| 209 | case .day: | ||
| 210 | let end = after[5].map { $0.upperBound - 1 } ?? after[7]?.lowerBound ?? after[1]!.upperBound - 1 | ||
| 211 | point = min(end, origin) | ||
| 212 | case .hour: point = min(after[7]!.upperBound, origin) | ||
| 213 | case .minute: point = min(after[8]!.upperBound - 1, origin) | ||
| 214 | case .extra: point = min(after[0]!.upperBound - 1, origin) | ||
| 215 | case .after: point = after[0]!.upperBound | ||
| 216 | default: point = origin | ||
| 217 | } | ||
| 218 | } | ||
| 219 | |||
| 220 | /// `org-toggle-timestamp-type`. | ||
| 221 | func toggleTimestampType(_ range: Range<Int>) { | ||
| 222 | saveExcursion { | ||
| 223 | for offset in range { | ||
| 224 | let swapped: String? = switch character(at: offset) { | ||
| 225 | case 91: "<" | ||
| 226 | case 93: ">" | ||
| 227 | case 60: "[" | ||
| 228 | case 62: "]" | ||
| 229 | default: nil | ||
| 230 | } | ||
| 231 | if let swapped { replace(offset..<(offset + 1), with: swapped) } | ||
| 232 | } | ||
| 233 | } | ||
| 234 | } | ||
| 235 | |||
| 236 | /// `org-get-compact-tod`: the time of day in a timestamp, as the date prompt shows it. | ||
| 237 | static func compactTimeOfDay(_ stamp: String) -> String? { | ||
| 238 | guard let match = stamp.firstMatch(of: /(([012]?[0-9]):([0-5][0-9]))(-(([012]?[0-9]):([0-5][0-9])))?/) else { return nil } | ||
| 239 | let t1 = String(match.1) | ||
| 240 | guard let t2h = match.6, let t2m = match.7 else { return t1 } | ||
| 241 | var dh = Int(t2h)! - Int(match.2)! | ||
| 242 | var dm = Int(t2m)! - Int(match.3)! | ||
| 243 | if dm < 0 { dm += 60; dh -= 1 } | ||
| 244 | return t1 + "+" + String(dh) + (dm != 0 ? String(format: ":%02d", dm) : "") | ||
| 245 | } | ||
| 246 | |||
| 247 | // MARK: - Planning | ||
| 248 | |||
| 249 | static let planningLinePattern = "^[ \\t]*(CLOSED|DEADLINE|SCHEDULED):" | ||
| 250 | static let keywordTimeNotClock = "\\b(CLOSED|DEADLINE|SCHEDULED):[ \\t]*[\\[<]([^\\]>]+)[\\]>]" | ||
| 251 | |||
| 252 | enum PlanningKind { | ||
| 253 | case closed, deadline, scheduled | ||
| 254 | |||
| 255 | var keyword: String { | ||
| 256 | switch self { | ||
| 257 | case .closed: "CLOSED:" | ||
| 258 | case .deadline: "DEADLINE:" | ||
| 259 | case .scheduled: "SCHEDULED:" | ||
| 260 | } | ||
| 261 | } | ||
| 262 | |||
| 263 | var pattern: String { | ||
| 264 | switch self { | ||
| 265 | case .closed: "\\bCLOSED: *\\[([^\\]]+)\\]" | ||
| 266 | case .deadline: "\\bDEADLINE: *<([^>]+)>" | ||
| 267 | case .scheduled: "\\bSCHEDULED: *<([^>]+)>" | ||
| 268 | } | ||
| 269 | } | ||
| 270 | } | ||
| 271 | |||
| 272 | /// The time string of the entry's planning `kind`, as `org-entry-get`. | ||
| 273 | func planningValue(_ kind: PlanningKind) -> String? { | ||
| 274 | saveExcursion { | ||
| 275 | guard (try? backToHeading()) != nil else { return nil } | ||
| 276 | forwardLine(1) | ||
| 277 | guard lookingAt(Self.planningLinePattern) != nil, | ||
| 278 | let match = searchForward(kind.pattern, bound: lineEnd(point), caseInsensitive: false) else { return nil } | ||
| 279 | return substring(match[1]!.lowerBound, match[1]!.upperBound) | ||
| 280 | } | ||
| 281 | } | ||
| 282 | |||
| 283 | /// `org-add-planning-info` with a resolved time. | ||
| 284 | func addPlanningInfo(_ what: PlanningKind?, time: DateComponents?, withTime: Bool, endTime: String?, remove: [PlanningKind]) throws { | ||
| 285 | try saveExcursion { | ||
| 286 | try backToHeading() | ||
| 287 | let planning = saveExcursion { () -> Bool in | ||
| 288 | forwardLine(1) | ||
| 289 | return lookingAt(Self.planningLinePattern) != nil | ||
| 290 | } | ||
| 291 | if planning { | ||
| 292 | forwardLine(1) | ||
| 293 | skipForward(Self.blanks) | ||
| 294 | for type in (what.map { [$0] } ?? []) + remove { | ||
| 295 | saveExcursion { | ||
| 296 | guard let match = searchForward(type.pattern, bound: lineEnd(point), caseInsensitive: false) else { return } | ||
| 297 | let start = match[0]!.lowerBound | ||
| 298 | let end = searchForward(Self.keywordTimeNotClock, bound: lineEnd(point), caseInsensitive: false)?[0]!.lowerBound ?? lineEnd(point) | ||
| 299 | deleteRegion(start, end) | ||
| 300 | } | ||
| 301 | } | ||
| 302 | if lookingAt("[ \\t]*$") != nil, what == nil { | ||
| 303 | deleteRegion(lineStart(point) - 1, lineEnd(point)) | ||
| 304 | } else { | ||
| 305 | let p = point | ||
| 306 | saveExcursion { | ||
| 307 | endOfLine() | ||
| 308 | let end = point | ||
| 309 | while point > p, let c = character(at: point - 1), c == 32 || c == 9 { point -= 1 } | ||
| 310 | if point < end { deleteRegion(point, end) } | ||
| 311 | } | ||
| 312 | } | ||
| 313 | } else if what != nil { | ||
| 314 | endOfLine() | ||
| 315 | insert("\n") | ||
| 316 | } | ||
| 317 | if let what, let time { | ||
| 318 | insert(what.keyword + " ") | ||
| 319 | insertTimestamp(time, withTime: withTime || what == .closed, inactive: what == .closed, endTime: endTime) | ||
| 320 | if !eolp { insert(" ") } | ||
| 321 | } | ||
| 322 | } | ||
| 323 | } | ||
| 324 | } | ||
Sources/OrgCore/Keymap/Keymap.swift +2 −1
| @@ -103,7 +103,7 @@ public struct Keymap: Sendable, Equatable { | |||
| 103 | 103 | ||
| 104 | /// Context names a binding's `when` can use. | 104 | /// Context names a binding's `when` can use. |
| 105 | public enum KeyContext { | 105 | public enum KeyContext { |
| 106 | public static let names: Set<String> = ["heading", "table", "item", "region"] | 106 | public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp"] |
| 107 | 107 | ||
| 108 | public static func holds(_ name: String, in context: EditContext) -> Bool { | 108 | public static func holds(_ name: String, in context: EditContext) -> Bool { |
| 109 | switch name { | 109 | switch name { |
| @@ -111,6 +111,7 @@ public enum KeyContext { | |||
| 111 | case "table": return enclosing(.table, context) != nil | 111 | case "table": return enclosing(.table, context) != nil |
| 112 | case "item": return enclosing(.item, context) != nil | 112 | case "item": return enclosing(.item, context) != nil |
| 113 | case "region": return context.selection.contains { !$0.isEmpty } | 113 | case "region": return context.selection.contains { !$0.isEmpty } |
| 114 | case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil | ||
| 114 | default: return false | 115 | default: return false |
| 115 | } | 116 | } |
| 116 | } | 117 | } |
Sources/OrgCore/Keymap/Presets.swift +56
| @@ -254,6 +254,34 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 254 | [[bind]] | 254 | [[bind]] |
| 255 | keys = "C-c $" | 255 | keys = "C-c $" |
| 256 | command = "app.archive" | 256 | command = "app.archive" |
| 257 | [[bind]] | ||
| 258 | keys = "C-c ." | ||
| 259 | command = "org.timestamp.active" | ||
| 260 | [[bind]] | ||
| 261 | keys = "C-c !" | ||
| 262 | command = "org.timestamp.inactive" | ||
| 263 | [[bind]] | ||
| 264 | keys = "C-c C-s" | ||
| 265 | command = "org.schedule" | ||
| 266 | [[bind]] | ||
| 267 | keys = "C-c C-d" | ||
| 268 | command = "org.deadline" | ||
| 269 | [[bind]] | ||
| 270 | keys = "S-<up>" | ||
| 271 | command = "org.timestamp.up" | ||
| 272 | when = "timestamp" | ||
| 273 | [[bind]] | ||
| 274 | keys = "S-<down>" | ||
| 275 | command = "org.timestamp.down" | ||
| 276 | when = "timestamp" | ||
| 277 | [[bind]] | ||
| 278 | keys = "S-<right>" | ||
| 279 | command = "org.timestamp.day-later" | ||
| 280 | when = "timestamp" | ||
| 281 | [[bind]] | ||
| 282 | keys = "S-<left>" | ||
| 283 | command = "org.timestamp.day-earlier" | ||
| 284 | when = "timestamp" | ||
| 257 | 285 | ||
| 258 | """ | 286 | """ |
| 259 | 287 | ||
| @@ -346,6 +374,34 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 346 | [[bind]] | 374 | [[bind]] |
| 347 | keys = "C-s-a" | 375 | keys = "C-s-a" |
| 348 | command = "app.archive" | 376 | command = "app.archive" |
| 377 | [[bind]] | ||
| 378 | keys = "C-s-." | ||
| 379 | command = "org.timestamp.active" | ||
| 380 | [[bind]] | ||
| 381 | keys = "C-M-s-." | ||
| 382 | command = "org.timestamp.inactive" | ||
| 383 | [[bind]] | ||
| 384 | keys = "C-s-s" | ||
| 385 | command = "org.schedule" | ||
| 386 | [[bind]] | ||
| 387 | keys = "C-s-d" | ||
| 388 | command = "org.deadline" | ||
| 389 | [[bind]] | ||
| 390 | keys = "C-s-<up>" | ||
| 391 | command = "org.timestamp.up" | ||
| 392 | when = "timestamp" | ||
| 393 | [[bind]] | ||
| 394 | keys = "C-s-<down>" | ||
| 395 | command = "org.timestamp.down" | ||
| 396 | when = "timestamp" | ||
| 397 | [[bind]] | ||
| 398 | keys = "C-s-<right>" | ||
| 399 | command = "org.timestamp.day-later" | ||
| 400 | when = "timestamp" | ||
| 401 | [[bind]] | ||
| 402 | keys = "C-s-<left>" | ||
| 403 | command = "org.timestamp.day-earlier" | ||
| 404 | when = "timestamp" | ||
| 349 | 405 | ||
| 350 | """ | 406 | """ |
| 351 | } | 407 | } |
Sources/OrgCore/Time/ReadDate.swift added +297
| @@ -0,0 +1,297 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Org's date prompt (`org-read-date-analyze`, Org 9.8.7) and the `parse-time-string` it uses, | ||
| 4 | /// with `org-read-date-prefer-future` t. `now` and `defaultDate` stand in for the clock. | ||
| 5 | public enum ReadDate { | ||
| 6 | public struct Answer: Sendable, Equatable { | ||
| 7 | public var year: Int, month: Int, day: Int, hour: Int, minute: Int | ||
| 8 | /// The answer included a time (`org-time-was-given`). | ||
| 9 | public var timeGiven: Bool | ||
| 10 | /// An end time such as `11:00`, from `10:00-11:00` (`org-end-time-was-given`). | ||
| 11 | public var endTime: String? | ||
| 12 | |||
| 13 | /// Normalized like `encode-time` (day 32 becomes the next month). | ||
| 14 | public func components(_ calendar: Calendar) -> DateComponents { | ||
| 15 | var components = DateComponents(year: year, month: month, day: 1, hour: 0, minute: 0) | ||
| 16 | let base = calendar.date(from: components)! | ||
| 17 | let date = calendar.date(byAdding: DateComponents(month: 0, day: day - 1, hour: hour, minute: minute), to: base)! | ||
| 18 | components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .weekday], from: date) | ||
| 19 | return components | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | static let weekdays: [(String, Int)] = [ | ||
| 24 | ("sun", 0), ("mon", 1), ("tue", 2), ("wed", 3), ("thu", 4), ("fri", 5), ("sat", 6), | ||
| 25 | ("sunday", 0), ("monday", 1), ("tuesday", 2), ("wednesday", 3), ("thursday", 4), ("friday", 5), ("saturday", 6), | ||
| 26 | ] | ||
| 27 | static let months: [(String, Int)] = [ | ||
| 28 | ("jan", 1), ("feb", 2), ("mar", 3), ("apr", 4), ("may", 5), ("jun", 6), ("jul", 7), ("aug", 8), ("sep", 9), | ||
| 29 | ("oct", 10), ("nov", 11), ("dec", 12), ("january", 1), ("february", 2), ("march", 3), ("april", 4), ("june", 6), | ||
| 30 | ("july", 7), ("august", 8), ("september", 9), ("october", 10), ("november", 11), ("december", 12), | ||
| 31 | ] | ||
| 32 | |||
| 33 | enum Token: Equatable { | ||
| 34 | case number(Int) | ||
| 35 | case word(String) | ||
| 36 | } | ||
| 37 | |||
| 38 | /// `parse-time-tokenize` on the downcased string. | ||
| 39 | static func tokenize(_ string: String) -> [Token] { | ||
| 40 | let chars = Array(string.lowercased().unicodeScalars) | ||
| 41 | func kind(_ c: Unicode.Scalar) -> Character? { | ||
| 42 | switch c { | ||
| 43 | case "a"..."z": "a" | ||
| 44 | case "0"..."9": "0" | ||
| 45 | case "+", "-", ":": Character(c) | ||
| 46 | default: nil | ||
| 47 | } | ||
| 48 | } | ||
| 49 | var tokens: [Token] = [] | ||
| 50 | var index = 0 | ||
| 51 | while index < chars.count { | ||
| 52 | while index < chars.count, kind(chars[index]) == nil { index += 1 } | ||
| 53 | guard index < chars.count else { break } | ||
| 54 | let start = index | ||
| 55 | var digits = kind(chars[index]) == "0" | ||
| 56 | index += 1 | ||
| 57 | while index < chars.count, let k = kind(chars[index]) { | ||
| 58 | digits = digits && k == "0" | ||
| 59 | index += 1 | ||
| 60 | } | ||
| 61 | let text = String(String.UnicodeScalarView(chars[start..<index])) | ||
| 62 | tokens.append(digits ? .number(Int(text) ?? 0) : .word(text)) | ||
| 63 | } | ||
| 64 | return tokens | ||
| 65 | } | ||
| 66 | |||
| 67 | /// `parse-time-string`'s fallback: (sec, min, hour, day, month, year, weekday). | ||
| 68 | static func parseTime(_ string: String) -> [Int?] { | ||
| 69 | var time: [Int?] = Array(repeating: nil, count: 9) | ||
| 70 | func digits(_ text: String, _ range: Range<Int>) -> Int? { | ||
| 71 | let chars = Array(text) | ||
| 72 | guard range.upperBound <= chars.count else { return nil } | ||
| 73 | return Int(String(chars[range])) | ||
| 74 | } | ||
| 75 | for token in tokenize(string) { | ||
| 76 | if time[6] == nil, case .word(let w) = token, let day = weekdays.first(where: { $0.0 == w }) { | ||
| 77 | time[6] = day.1 | ||
| 78 | } else if time[3] == nil, case .number(let n) = token, (1...31).contains(n) { | ||
| 79 | time[3] = n | ||
| 80 | } else if time[4] == nil, case .word(let w) = token, let month = months.first(where: { $0.0 == w }) { | ||
| 81 | time[4] = month.1 | ||
| 82 | } else if time[5] == nil, case .number(let n) = token, n >= 100 { | ||
| 83 | time[5] = n | ||
| 84 | } else if time[2] == nil, case .word(let w) = token, w.count == 8, Array(w)[2] == ":", Array(w)[5] == ":" { | ||
| 85 | (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), digits(w, 6..<8)) | ||
| 86 | } else if time[5] == nil, case .word(let w) = token, w.count == 10, Array(w)[4] == "-", Array(w)[7] == "-" { | ||
| 87 | (time[5], time[4], time[3]) = (digits(w, 0..<4), digits(w, 5..<7), digits(w, 8..<10)) | ||
| 88 | } else if time[2] == nil, case .word(let w) = token, w.count == 5, Array(w)[2] == ":" { | ||
| 89 | (time[2], time[1], time[0]) = (digits(w, 0..<2), digits(w, 3..<5), 0) | ||
| 90 | } else if time[2] == nil, case .word(let w) = token, w.count == 4, Array(w)[1] == ":" { | ||
| 91 | (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), 0) | ||
| 92 | } else if time[2] == nil, case .word(let w) = token, w.count == 7, Array(w)[1] == ":" { | ||
| 93 | (time[2], time[1], time[0]) = (digits(w, 0..<1), digits(w, 2..<4), digits(w, 5..<7)) | ||
| 94 | } else if time[5] == nil, case .number(let n) = token, (50...110).contains(n) { | ||
| 95 | time[5] = 1900 + n | ||
| 96 | } else if time[5] == nil, case .number(let n) = token, (0...49).contains(n) { | ||
| 97 | time[5] = 2000 + n | ||
| 98 | } | ||
| 99 | } | ||
| 100 | return time | ||
| 101 | } | ||
| 102 | |||
| 103 | /// `org-small-year-to-year`. | ||
| 104 | static func smallYear(_ year: Int, currentYear: Int) -> Int { | ||
| 105 | guard year < 100 else { return year } | ||
| 106 | let century = currentYear / 100 | ||
| 107 | let offset = year - currentYear % 100 | ||
| 108 | if offset > 30 { return (century - 1) * 100 + year } | ||
| 109 | if offset > -70 { return century * 100 + year } | ||
| 110 | return (century + 1) * 100 + year | ||
| 111 | } | ||
| 112 | |||
| 113 | /// Replaces the first match of `pattern` in `string`, with `$n` group references. | ||
| 114 | private static func replaceFirst(_ string: inout String, _ match: NSTextCheckingResult, _ template: String) { | ||
| 115 | let ns = string as NSString | ||
| 116 | var result = "" | ||
| 117 | var chars = Array(template) | ||
| 118 | var i = 0 | ||
| 119 | while i < chars.count { | ||
| 120 | if chars[i] == "\\", i + 1 < chars.count, let n = chars[i + 1].wholeNumberValue { | ||
| 121 | let range = match.range(at: n) | ||
| 122 | if range.location != NSNotFound { result += ns.substring(with: range) } | ||
| 123 | i += 2 | ||
| 124 | } else { | ||
| 125 | result.append(chars[i]) | ||
| 126 | i += 1 | ||
| 127 | } | ||
| 128 | } | ||
| 129 | chars = [] | ||
| 130 | string = ns.replacingCharacters(in: match.range, with: result) | ||
| 131 | } | ||
| 132 | |||
| 133 | private static func firstMatch(_ pattern: String, _ string: String, caseInsensitive: Bool = false) -> NSTextCheckingResult? { | ||
| 134 | let regex = try! NSRegularExpression(pattern: pattern, options: caseInsensitive ? [.caseInsensitive] : []) | ||
| 135 | return regex.firstMatch(in: string, range: NSRange(location: 0, length: (string as NSString).length)) | ||
| 136 | } | ||
| 137 | |||
| 138 | private static func group(_ match: NSTextCheckingResult, _ i: Int, _ string: String) -> String? { | ||
| 139 | let range = match.range(at: i) | ||
| 140 | return range.location == NSNotFound ? nil : (string as NSString).substring(with: range) | ||
| 141 | } | ||
| 142 | |||
| 143 | /// `org-read-date-get-relative`: (shift, unit, relative to the default). | ||
| 144 | static func relative(_ string: String, today: DateComponents, default def: DateComponents) -> (Int, String, Bool, NSTextCheckingResult)? { | ||
| 145 | let names = weekdays.map(\.0).joined(separator: "|") | ||
| 146 | guard let match = firstMatch("^[ \\t]*([-+]{0,2})([0-9]+)?([hdwmy]|(" + names + "))?([ \\t]|$)", string, caseInsensitive: true) else { return nil } | ||
| 147 | let sign = group(match, 1, string) ?? "" | ||
| 148 | guard !sign.isEmpty || match.range(at: 4).location != NSNotFound else { return nil } | ||
| 149 | let direction: Character = sign.last ?? "+" | ||
| 150 | let relativeToDefault = sign.count == 2 | ||
| 151 | let n = group(match, 2, string).flatMap { Int($0) } ?? 1 | ||
| 152 | let what = group(match, 3, string) ?? "d" | ||
| 153 | let date = relativeToDefault ? def : today | ||
| 154 | if let target = weekdays.first(where: { $0.0 == what.lowercased() })?.1 { | ||
| 155 | let weekday = (date.weekday ?? 1) - 1 | ||
| 156 | var delta = (7 + target - weekday) % 7 | ||
| 157 | if delta == 0 { delta = 7 } | ||
| 158 | if direction == "-" { | ||
| 159 | delta -= 7 | ||
| 160 | if delta == 0 { delta = -7 } | ||
| 161 | } | ||
| 162 | if n > 1 { delta += (n - 1) * (direction == "-" ? -7 : 7) } | ||
| 163 | return (delta, "d", relativeToDefault, match) | ||
| 164 | } | ||
| 165 | return (n * (direction == "-" ? -1 : 1), what, relativeToDefault, match) | ||
| 166 | } | ||
| 167 | |||
| 168 | /// `org-read-date-analyze` with `org-read-date-prefer-future` t. | ||
| 169 | public static func analyze(_ input: String, default defaultDate: Date, now: Date, calendar: Calendar) -> Answer { | ||
| 170 | let units: Set<Calendar.Component> = [.year, .month, .day, .hour, .minute, .second, .weekday] | ||
| 171 | let def = calendar.dateComponents(units, from: defaultDate) | ||
| 172 | let today = calendar.dateComponents(units, from: now) | ||
| 173 | let currentYear = today.year! | ||
| 174 | var ans = input | ||
| 175 | if firstMatch("^[ \\t]*\\.[ \\t]*$", ans) != nil { ans = "+0" } | ||
| 176 | var delta: (Int, String, Bool)? | ||
| 177 | if let relative = relative(ans, today: today, default: def) { | ||
| 178 | delta = (relative.0, relative.1, relative.2) | ||
| 179 | ans = (ans as NSString).replacingCharacters(in: relative.3.range, with: "") | ||
| 180 | } | ||
| 181 | var isoYear: Int?, isoWeekday: Int?, isoWeek: Int? | ||
| 182 | if let match = firstMatch("\\b(?:([0-9]+)-)?[wW]([0-9]{1,2})(?:-([0-6]))?([ \\t]|$)", ans) { | ||
| 183 | isoYear = group(match, 1, ans).flatMap { Int($0) }.map { smallYear($0, currentYear: currentYear) } | ||
| 184 | isoWeekday = group(match, 3, ans).flatMap { Int($0) } | ||
| 185 | isoWeek = group(match, 2, ans).flatMap { Int($0) } | ||
| 186 | ans = (ans as NSString).replacingCharacters(in: match.range, with: "") | ||
| 187 | } | ||
| 188 | var killYear = false | ||
| 189 | if let match = firstMatch("^ *(([0-9]+)-)?([0-1]?[0-9])-([0-3]?[0-9])([^-0-9]|$)", ans) { | ||
| 190 | var year = group(match, 2, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }() | ||
| 191 | year = smallYear(year, currentYear: currentYear) | ||
| 192 | let month = Int(group(match, 3, ans)!)!, day = Int(group(match, 4, ans)!)! | ||
| 193 | replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5") | ||
| 194 | } | ||
| 195 | if let match = firstMatch("^ *(3[01]|0?[1-9]|[12][0-9])\\. ?(0?[1-9]|1[012])\\.( ?[1-9][0-9]{3})?", ans) { | ||
| 196 | let year = group(match, 3, ans).flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } ?? { killYear = true; return currentYear }() | ||
| 197 | let day = Int(group(match, 1, ans)!)!, month = Int(group(match, 2, ans)!)! | ||
| 198 | replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day)) | ||
| 199 | } | ||
| 200 | if let match = firstMatch("^ *(0?[1-9]|1[012])/(0?[1-9]|[12][0-9]|3[01])(/([0-9]+))?([^/0-9]|$)", ans) { | ||
| 201 | var year = group(match, 4, ans).flatMap { Int($0) } ?? { killYear = true; return currentYear }() | ||
| 202 | year = smallYear(year, currentYear: currentYear) | ||
| 203 | let month = Int(group(match, 1, ans)!)!, day = Int(group(match, 2, ans)!)! | ||
| 204 | replaceFirst(&ans, match, String(format: "%04d-%02d-%02d", year, month, day) + "\\5") | ||
| 205 | } | ||
| 206 | let plainTime = "(^|[^+])[012]?[0-9]:[0-9][0-9]([ \\t\\n]|$)" | ||
| 207 | for _ in 0..<2 where firstMatch(plainTime, ans) == nil { | ||
| 208 | guard let match = firstMatch("([012]?[0-9])(:([0-5][0-9]))?(am|AM|pm|PM)\\b", ans) else { break } | ||
| 209 | var hour = Int(group(match, 1, ans)!)! | ||
| 210 | let minute = group(match, 3, ans).flatMap { Int($0) } ?? 0 | ||
| 211 | let pm = group(match, 4, ans)!.lowercased() == "pm" | ||
| 212 | if hour == 12, !pm { hour = 0 } else if pm, hour < 12 { hour += 12 } | ||
| 213 | ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute)) | ||
| 214 | } | ||
| 215 | for _ in 0..<2 where firstMatch(plainTime, ans) == nil { | ||
| 216 | guard let match = firstMatch("(?:([012]?[0-9])?h([0-5][0-9]))|(?:([012]?[0-9])h([0-5][0-9])?)\\b", ans) else { break } | ||
| 217 | let hour = (group(match, 1, ans) ?? group(match, 3, ans)).flatMap { Int($0) } ?? 0 | ||
| 218 | let minute = (group(match, 2, ans) ?? group(match, 4, ans)).flatMap { Int($0) } ?? 0 | ||
| 219 | ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d", hour, minute)) | ||
| 220 | } | ||
| 221 | if let match = firstMatch("([012]?[0-9]):([0-6][0-9])\\+([012]?[0-9])(:([0-5][0-9]))?", ans) { | ||
| 222 | let hour = Int(group(match, 1, ans)!)!, minute = Int(group(match, 2, ans)!)! | ||
| 223 | var h2 = hour + Int(group(match, 3, ans)!)! | ||
| 224 | var m2 = minute + (group(match, 5, ans).flatMap { Int($0) } ?? 0) | ||
| 225 | if m2 >= 60 { h2 += 1; m2 -= 60 } | ||
| 226 | ans = (ans as NSString).replacingCharacters(in: match.range, with: String(format: "%02d:%02d-%02d:%02d", hour, minute, h2, m2)) | ||
| 227 | } | ||
| 228 | var timeGiven = false | ||
| 229 | var endTime: String? | ||
| 230 | let timeOfDay = "(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b)(--?(\\b[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))\\b))?" | ||
| 231 | if let match = firstMatch(timeOfDay, ans), let end = group(match, 8, ans) { | ||
| 232 | endTime = end | ||
| 233 | let range = match.range(at: 7) | ||
| 234 | ans = (ans as NSString).replacingCharacters(in: range, with: "") | ||
| 235 | } | ||
| 236 | let tl = parseTime(ans) | ||
| 237 | var futurep = false | ||
| 238 | var day = tl[3] ?? def.day! | ||
| 239 | let month: Int | ||
| 240 | if let m = tl[4] { | ||
| 241 | month = m | ||
| 242 | } else if tl[3] != nil { | ||
| 243 | futurep = true | ||
| 244 | month = day < today.day! ? today.month! + 1 : today.month! | ||
| 245 | } else { | ||
| 246 | month = def.month! | ||
| 247 | } | ||
| 248 | var year: Int | ||
| 249 | if !killYear, let y = tl[5] { | ||
| 250 | year = y | ||
| 251 | } else if futurep { | ||
| 252 | year = (month > today.month! || day >= today.day!) ? today.year! : today.year! + 1 | ||
| 253 | } else if tl[4] != nil { | ||
| 254 | futurep = true | ||
| 255 | if month > today.month! { year = today.year! } else if month < today.month! { year = today.year! + 1 } else if day < today.day! { year = today.year! + 1 } else { year = today.year! } | ||
| 256 | } else { | ||
| 257 | year = def.year! | ||
| 258 | } | ||
| 259 | var month2 = month | ||
| 260 | var hour = tl[2] ?? def.hour! | ||
| 261 | let minute = tl[1] ?? def.minute! | ||
| 262 | var wday = tl[6] | ||
| 263 | if let isoWeek { | ||
| 264 | let isoYearValue = isoYear ?? year | ||
| 265 | let weekday = isoWeekday ?? wday ?? 1 | ||
| 266 | wday = nil | ||
| 267 | var iso = Calendar(identifier: .iso8601) | ||
| 268 | iso.timeZone = calendar.timeZone | ||
| 269 | let isoComponents = DateComponents(weekday: weekday == 0 ? 1 : weekday + 1, weekOfYear: isoWeek, yearForWeekOfYear: isoYearValue) | ||
| 270 | if let date = iso.date(from: isoComponents) { | ||
| 271 | let c = iso.dateComponents([.year, .month, .day], from: date) | ||
| 272 | (year, month2, day) = (c.year!, c.month!, c.day!) | ||
| 273 | } | ||
| 274 | } else if let (n, unit, relativeToDefault) = delta { | ||
| 275 | if !relativeToDefault { (day, month2, year) = (today.day!, today.month!, today.year!) } | ||
| 276 | switch unit.lowercased() { | ||
| 277 | case "h", "": | ||
| 278 | timeGiven = true | ||
| 279 | hour += n | ||
| 280 | case "d": day += n | ||
| 281 | case "w": day += 7 * n | ||
| 282 | case "m": month2 += n | ||
| 283 | case "y": year += n | ||
| 284 | default: break | ||
| 285 | } | ||
| 286 | } else if let target = wday, tl[3] == nil { | ||
| 287 | let base = calendar.date(from: DateComponents(year: year, month: month2, day: 1))! | ||
| 288 | let date = calendar.date(byAdding: .day, value: day - 1, to: base)! | ||
| 289 | let current = calendar.component(.weekday, from: date) - 1 | ||
| 290 | if target != current { day += ((target - current + 7) % 7 + 7) % 7 } | ||
| 291 | } | ||
| 292 | if tl[2] != nil { timeGiven = true } | ||
| 293 | if year < 100 { year += 2000 } | ||
| 294 | _ = futurep | ||
| 295 | return Answer(year: year, month: month2, day: day, hour: hour, minute: minute, timeGiven: timeGiven, endTime: endTime) | ||
| 296 | } | ||
| 297 | } | ||
Tests/OrgCoreTests/EmacsOracle.swift −1
| @@ -29,7 +29,6 @@ enum EmacsOracle { | |||
| 29 | (require 'cl-lib) | 29 | (require 'cl-lib) |
| 30 | (setq org-todo-keywords '((sequence "TODO" "DONE")) | 30 | (setq org-todo-keywords '((sequence "TODO" "DONE")) |
| 31 | org-log-done nil | 31 | org-log-done nil |
| 32 | org-log-repeat nil | ||
| 33 | org-adapt-indentation nil | 32 | org-adapt-indentation nil |
| 34 | org-tags-column -77 | 33 | org-tags-column -77 |
| 35 | org-priority-highest ?A | 34 | org-priority-highest ?A |
Tests/OrgCoreTests/ReadDateTests.swift added +73
| @@ -0,0 +1,73 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// Emacs Lisp that runs `body` with the clock stopped at `seconds` (UTC in the oracle). | ||
| 6 | func frozenClock(_ seconds: Int, _ body: String) -> String { | ||
| 7 | """ | ||
| 8 | (cl-letf* ((frozen (seconds-to-time \(seconds))) | ||
| 9 | (odt (symbol-function 'decode-time)) | ||
| 10 | (oft (symbol-function 'format-time-string)) | ||
| 11 | (otl (symbol-function 'time-less-p)) | ||
| 12 | ((symbol-function 'current-time) (lambda () frozen)) | ||
| 13 | ((symbol-function 'org-today) (lambda () (time-to-days frozen))) | ||
| 14 | ((symbol-function 'time-less-p) (lambda (a b) (funcall otl (or a frozen) (or b frozen)))) | ||
| 15 | ((symbol-function 'decode-time) (lambda (&optional time zone form) (funcall odt (or time frozen) zone form))) | ||
| 16 | ((symbol-function 'format-time-string) (lambda (f &optional time zone) (funcall oft f (or time frozen) zone)))) | ||
| 17 | \(body)) | ||
| 18 | """ | ||
| 19 | } | ||
| 20 | |||
| 21 | struct ReadDateTests { | ||
| 22 | /// Thu 2026-09-10 00:26 UTC. | ||
| 23 | static let now = Date(timeIntervalSince1970: 1_789_000_000) | ||
| 24 | static var utc: Calendar { | ||
| 25 | var calendar = Calendar(identifier: .gregorian) | ||
| 26 | calendar.timeZone = TimeZone(identifier: "UTC")! | ||
| 27 | return calendar | ||
| 28 | } | ||
| 29 | |||
| 30 | static func format(_ answer: ReadDate.Answer) -> String { | ||
| 31 | let c = answer.components(utc) | ||
| 32 | let date = String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!) | ||
| 33 | return (answer.timeGiven ? date + String(format: " %02d:%02d", c.hour!, c.minute!) : date) + "|" + (answer.endTime ?? "nil") | ||
| 34 | } | ||
| 35 | |||
| 36 | static let inputs = [ | ||
| 37 | "", ".", "+0", "+1", "+2d", "-3d", "+1w", "+2m", "-1y", "++3", "fri", "+fri", "-fri", "+2fri", "mon", | ||
| 38 | "2026-10-07", "2026-10-7", "10-7", "7.10.", "7.10.2027", "10/7", "10/7/27", "12/31", | ||
| 39 | "3", "15", "sep 15", "15 sep", "jan", "dec 25", "2027", | ||
| 40 | "10:00", "9:30", "10am", "3pm", "12am", "12:30pm", "10h", "10h30", "h45", | ||
| 41 | "10:00-11:30", "10:00+1:30", "fri 10:00", "2026-10-07 14:15", "+2d 9:00", "+3h", | ||
| 42 | "w42", "2027-w01", "w3-5", "tomorrow", "nonsense", | ||
| 43 | ] | ||
| 44 | |||
| 45 | @Test func examples() { | ||
| 46 | let answer = ReadDate.analyze("fri 10:00", default: Self.now, now: Self.now, calendar: Self.utc) | ||
| 47 | #expect(Self.format(answer) == "2026-09-11 10:00|nil") | ||
| 48 | #expect(Self.format(ReadDate.analyze("10:00-11:30", default: Self.now, now: Self.now, calendar: Self.utc)) == "2026-09-10 10:00|11:30") | ||
| 49 | } | ||
| 50 | |||
| 51 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 52 | func readDateMatchesEmacs() throws { | ||
| 53 | try #require(EmacsOracle.isAvailable) | ||
| 54 | let cases = Self.inputs.map { input in | ||
| 55 | EmacsOracle.Case(text: "", point: 1, form: frozenClock(1_789_000_000, """ | ||
| 56 | (eval '(let ((org-time-was-given nil) (org-end-time-was-given nil)) | ||
| 57 | (let ((d (org-read-date nil nil "\(input)"))) | ||
| 58 | (insert d "|" (or org-end-time-was-given "nil")))) | ||
| 59 | nil) | ||
| 60 | """)) | ||
| 61 | } | ||
| 62 | var mismatches = 0 | ||
| 63 | for (input, emacs) in zip(Self.inputs, try EmacsOracle.run(cases)) { | ||
| 64 | let ours = Self.format(ReadDate.analyze(input, default: Self.now, now: Self.now, calendar: Self.utc)) | ||
| 65 | let theirs = emacs.error.isEmpty ? emacs.text : "error: \(emacs.error)" | ||
| 66 | if ours != theirs { | ||
| 67 | mismatches += 1 | ||
| 68 | Issue.record("\(input.debugDescription): ours \(ours), emacs \(theirs)") | ||
| 69 | } | ||
| 70 | } | ||
| 71 | #expect(mismatches == 0) | ||
| 72 | } | ||
| 73 | } | ||
Tests/OrgCoreTests/TimestampCommandTests.swift added +137
| @@ -0,0 +1,137 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct TimestampCommandTests { | ||
| 6 | static let now = ReadDateTests.now | ||
| 7 | static let calendar = ReadDateTests.utc | ||
| 8 | |||
| 9 | func run(_ command: any OrgCommand, _ text: String, caret: Int, answer: String? = nil) -> (text: String, caret: Int, failure: String?) { | ||
| 10 | let context = EditContext( | ||
| 11 | revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], | ||
| 12 | now: Self.now, calendar: Self.calendar, answers: answer.map { ["date": $0] } ?? [:] | ||
| 13 | ) | ||
| 14 | switch command.run(in: context) { | ||
| 15 | case .commit(let result): | ||
| 16 | let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) } | ||
| 17 | return (new, result.selection?.first?.lowerBound ?? caret, nil) | ||
| 18 | case .failed(let message): return (text, caret, message) | ||
| 19 | case .prompt(let prompt): return (text, caret, "prompt \(prompt.key)") | ||
| 20 | } | ||
| 21 | } | ||
| 22 | |||
| 23 | @Test func examples() { | ||
| 24 | #expect(run(InsertTimestamp(), "x\n", caret: 1, answer: "fri").text == "x<2026-09-11 Fri>\n") | ||
| 25 | #expect(run(SetPlanning(deadline: false), "* a\n", caret: 0, answer: "+1").text == "* a\nSCHEDULED: <2026-09-11 Fri>\n") | ||
| 26 | #expect(run(ShiftTimestampDay(later: true), "<2026-09-10 Thu>\n", caret: 5).text == "<2026-09-11 Fri>\n") | ||
| 27 | #expect(run(ShiftTimestamp(up: true), "<2026-09-10 Thu 10:07>\n", caret: 20).text == "<2026-09-10 Thu 10:10>\n") | ||
| 28 | } | ||
| 29 | |||
| 30 | static let texts = [ | ||
| 31 | "* a\n", | ||
| 32 | "* a\nbody <2026-09-01 Tue> text\n", | ||
| 33 | "* a\nSCHEDULED: <2026-09-01 Tue +1w>\n", | ||
| 34 | "* a\nDEADLINE: <2026-09-20 Sun> SCHEDULED: <2026-09-01 Tue 10:00-11:30>\n", | ||
| 35 | "* a\nCLOSED: [2026-09-02 Wed 09:00] SCHEDULED: <2026-09-01 Tue>\n", | ||
| 36 | "* a\n[2026-12-31 Thu 23:58] <2026-01-31 Sat .+2m -3d>\n", | ||
| 37 | "text <2026-02-28 Sat 07:03>\n", | ||
| 38 | ] | ||
| 39 | static let answers = ["", "+1", "fri 9:00", "2027-01-15", "10:00-11:00", "++2d"] | ||
| 40 | |||
| 41 | static let commands: [(any OrgCommand, String)] = [ | ||
| 42 | (InsertTimestamp(), "(org-timestamp nil)"), | ||
| 43 | (InsertTimestamp(inactive: true), "(org-timestamp nil t)"), | ||
| 44 | (SetPlanning(deadline: false), "(org-schedule nil)"), | ||
| 45 | (SetPlanning(deadline: true), "(org-deadline nil)"), | ||
| 46 | (RemovePlanning(deadline: false), "(org-schedule '(4))"), | ||
| 47 | (ShiftTimestamp(up: true), "(org-timestamp-up)"), | ||
| 48 | (ShiftTimestamp(up: false), "(org-timestamp-down)"), | ||
| 49 | (ShiftTimestampDay(later: true), "(org-timestamp-up-day)"), | ||
| 50 | (ShiftTimestampDay(later: false), "(org-timestamp-down-day)"), | ||
| 51 | ] | ||
| 52 | |||
| 53 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 54 | func timestampCommandsMatchEmacs() throws { | ||
| 55 | try #require(EmacsOracle.isAvailable) | ||
| 56 | var cases: [EmacsOracle.Case] = [] | ||
| 57 | var ours: [(label: String, text: String, caret: Int, failed: Bool)] = [] | ||
| 58 | for text in Self.texts { | ||
| 59 | let tree = OrgParser.parse(text) | ||
| 60 | for caret in EmacsOracle.positions(text) { | ||
| 61 | for (command, form) in Self.commands { | ||
| 62 | let prompts = command is InsertTimestamp || command is SetPlanning | ||
| 63 | if command is RemovePlanning || command is SetPlanning, entryHeading(at: caret, in: tree) == nil { continue } | ||
| 64 | if command is ShiftTimestamp || command is ShiftTimestampDay, EmacsBuffer(text, point: caret).atTimestamp() == nil { continue } | ||
| 65 | for answer in prompts ? Self.answers : [""] { | ||
| 66 | let escaped = answer.replacingOccurrences(of: "\"", with: "\\\"") | ||
| 67 | let wrapped = frozenClock(1_789_000_000, """ | ||
| 68 | (cl-letf (((symbol-function 'read-string) (lambda (&rest _) "\(escaped)"))) | ||
| 69 | (let ((org-read-date-popup-calendar nil)) \(form))) | ||
| 70 | """) | ||
| 71 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: wrapped)) | ||
| 72 | let result = run(command, text, caret: caret, answer: prompts ? answer : nil) | ||
| 73 | ours.append(("\(command.id) \(answer.debugDescription) at \(caret) in \(text.debugDescription)", result.text, result.caret, result.failure != nil)) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | var mismatches = 0 | ||
| 79 | for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) { | ||
| 80 | let failed = !emacs.error.isEmpty | ||
| 81 | let caret = EmacsOracle.offset(emacs.point, in: emacs.text) | ||
| 82 | guard mine.failed != failed || (!failed && (mine.text != emacs.text || mine.caret != caret)) else { continue } | ||
| 83 | mismatches += 1 | ||
| 84 | if mismatches <= 12 { | ||
| 85 | Issue.record("\(mine.label)\n ours: \(mine.failed ? "failed" : "\(mine.text.debugDescription) @\(mine.caret)")\n emacs: \(failed ? emacs.error : "\(emacs.text.debugDescription) @\(caret)")") | ||
| 86 | } | ||
| 87 | } | ||
| 88 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") | ||
| 89 | } | ||
| 90 | |||
| 91 | static let repeating = [ | ||
| 92 | "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n", | ||
| 93 | "* TODO a\nDEADLINE: <2026-09-01 Tue .+1d>\n", | ||
| 94 | "* TODO a\nSCHEDULED: <2026-08-01 Sat ++1m>\nbody\n", | ||
| 95 | "* TODO a :t:\n<2026-09-08 Tue 10:00 +2d>\n", | ||
| 96 | "* TODO a\nDEADLINE: <2026-09-05 Sat +1w> SCHEDULED: <2026-09-01 Tue>\n", | ||
| 97 | "* TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n:PROPERTIES:\n:LAST_REPEAT: [2026-09-01 Tue 08:00]\n:END:\n- State \"DONE\" from \"TODO\" [2026-09-01 Tue 08:00]\nnotes\n", | ||
| 98 | "#+TODO: TODO STRT | DONE\n* STRT a\nSCHEDULED: <2026-09-01 Tue +1d>\n", | ||
| 99 | "* P [0/1]\n** TODO a\nSCHEDULED: <2026-09-01 Tue +1w>\n* Q\n", | ||
| 100 | "* TODO no repeat\nSCHEDULED: <2026-09-01 Tue>\n", | ||
| 101 | "* TODO hourly\nSCHEDULED: <2026-09-10 Thu 00:00 +1h>\n", | ||
| 102 | ] | ||
| 103 | |||
| 104 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 105 | func repeatsMatchEmacs() throws { | ||
| 106 | try #require(EmacsOracle.isAvailable) | ||
| 107 | var cases: [EmacsOracle.Case] = [] | ||
| 108 | var ours: [(label: String, text: String, caret: Int)] = [] | ||
| 109 | for text in Self.repeating { | ||
| 110 | let ns = text as NSString | ||
| 111 | let heading = ns.range(of: "\n\\*|^\\*", options: .regularExpression) | ||
| 112 | let start = heading.location == 0 ? 0 : heading.location + 1 | ||
| 113 | let lineEnd = ns.range(of: "\n", range: NSRange(location: start, length: ns.length - start)).location | ||
| 114 | for caret in [start, start + 2, lineEnd, min(lineEnd + 3, ns.length)] { | ||
| 115 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(caret, in: text), form: frozenClock(1_789_000_000, "(progn (org-todo) (when org-log-setup (org-add-log-note)))"))) | ||
| 116 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], now: Self.now, calendar: Self.calendar) | ||
| 117 | guard case .commit(let result) = TodoCycle().run(in: context) else { | ||
| 118 | ours.append(("failed at \(caret)", "", -1)) | ||
| 119 | continue | ||
| 120 | } | ||
| 121 | let new = result.edits.sorted { $0.range.lowerBound > $1.range.lowerBound }.reduce(text) { $1.apply(to: $0) } | ||
| 122 | ours.append(("at \(caret) in \(text.debugDescription)", new, result.selection?.first?.lowerBound ?? caret)) | ||
| 123 | } | ||
| 124 | } | ||
| 125 | var mismatches = 0 | ||
| 126 | for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) { | ||
| 127 | let caret = EmacsOracle.offset(emacs.point, in: emacs.text) | ||
| 128 | guard !emacs.error.isEmpty || mine.text != emacs.text || mine.caret != caret else { continue } | ||
| 129 | mismatches += 1 | ||
| 130 | if mismatches <= 10 { | ||
| 131 | let (a, b) = excerpts(mine.text, emacs.text) | ||
| 132 | Issue.record("\(mine.label)\n ours: \(a.debugDescription) @\(mine.caret)\n emacs: \(emacs.error.isEmpty ? "\(b.debugDescription) @\(caret)" : emacs.error)") | ||
| 133 | } | ||
| 134 | } | ||
| 135 | #expect(mismatches == 0, "\(mismatches) of \(ours.count) differ") | ||
| 136 | } | ||
| 137 | } | ||
docs/plans/2026-10-05-timestamps.md added +37
| @@ -0,0 +1,37 @@ | |||
| 1 | # Timestamps Implementation Plan | ||
| 2 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
| 4 | |||
| 5 | **Goal:** Org's date prompt, timestamp insertion (C-c ., C-c !), scheduling and deadlines (C-c C-s, C-c C-d, and removal), timestamp shifting (S-arrows on a timestamp), and repeating entries completed with DONE, all byte- and caret-exact against Emacs with a frozen clock. | ||
| 6 | |||
| 7 | **Architecture:** `ReadDate` ports `org-read-date-analyze` with `parse-time-string`'s tokenizer and rules, `org-read-date-get-relative` and `org-small-year-to-year`. `Timestamps.swift` ports `org-at-timestamp-p` (point categories), `org-insert-timestamp`, `org-timestamp-change` with `org-modify-ts-extra`, `org-toggle-timestamp-type`, `org-add-planning-info`. `TimestampCommands.swift` holds `org-timestamp`, `org--deadline-or-schedule` and the shift commands. `Repeat.swift` ports `org-auto-repeat-maybe` and the state note (`org-store-log-note`, `org-log-beginning`); TODO cycling runs it when an entry becomes done. Commands read `now` and `calendar` from `EditContext`. A `timestamp` key context lets S-arrows shift timestamps and fall back to priority and TODO on headings. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle. | ||
| 10 | |||
| 11 | **Spec:** `docs/design.md`, "Commands and keymaps" (revisions, time and prompts). | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - Org defaults: `org-read-date-prefer-future` t, `org-time-stamp-rounding-minutes` (0 5), `org-log-repeat` `time`, `org-log-into-drawer` nil, `org-log-states-order-reversed` t, `org-todo-repeat-to-state` nil, `org-log-reschedule`/`org-log-redeadline` nil. | ||
| 16 | - The oracle freezes `current-time`, `decode-time`, `format-time-string`, `org-today` and `time-less-p`, runs with `TZ=UTC`, stubs `read-string` with the answer, and stores the deferred log note as the post-command hook would. | ||
| 17 | |||
| 18 | ## Defaults chosen (user may change) | ||
| 19 | |||
| 20 | - Mac preset: ⌃⌘. timestamp, ⌃⌥⌘. inactive, ⌃⌘S schedule, ⌃⌘D deadline, ⌃⌘ arrows shift a timestamp under the caret. | ||
| 21 | - Not yet: date ranges in the prompt and S-arrows on ranges, the calendar picker, CLOCK line updates, deadline warning edits (C-u C-u). | ||
| 22 | |||
| 23 | --- | ||
| 24 | |||
| 25 | ### Task 1: Date prompt | ||
| 26 | |||
| 27 | - [ ] Port; oracle over 52 inputs (relative, weekdays, ISO weeks, US and European dates, am/pm, `HHhMM`, ranges, durations). | ||
| 28 | - [ ] Commit "Port the date prompt". | ||
| 29 | |||
| 30 | ### Task 2: Timestamps, planning, repeats | ||
| 31 | |||
| 32 | - [ ] Port; oracle over 7 texts × every caret × 9 commands × 6 answers, and 10 repeating entries. | ||
| 33 | - [ ] Commit "Port timestamp and planning commands". | ||
| 34 | |||
| 35 | ### Task 3: Keys | ||
| 36 | |||
| 37 | - [ ] Bind both presets; `timestamp` context. | ||