Agenda !21
16 files changed, +1462 −4
Layout: unified · split
Sources/OrgApp/AgendaModel.swift added +93
| @@ -0,0 +1,93 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | import OrgCore | ||
| 4 | import OrgIndex | ||
| 5 | |||
| 6 | /// The day/week agenda over the workspace's agenda files. Sources are read from disk and | ||
| 7 | /// cached by modification date and size; the open file's come from its buffer, so the agenda | ||
| 8 | /// shows unsaved edits. | ||
| 9 | @MainActor | ||
| 10 | @Observable | ||
| 11 | public final class AgendaModel { | ||
| 12 | public private(set) var days: [AgendaDay] = [] | ||
| 13 | /// The first day shown, as `Days` numbers it; nil follows today. | ||
| 14 | public var start: Int? | ||
| 15 | public var span = 10 | ||
| 16 | /// Where the view starts relative to today, as Doom's `org-agenda-start-day "-3d"`. | ||
| 17 | public var startOffset = -3 | ||
| 18 | /// Files below the top level of each folder are agenda files too. | ||
| 19 | public var includeSubfolders = false | ||
| 20 | public var options = AgendaOptions() | ||
| 21 | public private(set) var isComputing = false | ||
| 22 | |||
| 23 | private let workspace: WorkspaceModel | ||
| 24 | @ObservationIgnored private var cache: [String: (mtime: Date, size: Int, source: AgendaSource)] = [:] | ||
| 25 | @ObservationIgnored private var generation = 0 | ||
| 26 | |||
| 27 | public init(workspace: WorkspaceModel) { | ||
| 28 | self.workspace = workspace | ||
| 29 | } | ||
| 30 | |||
| 31 | /// Org files directly in each folder (or below it, with `includeSubfolders`), by path, as | ||
| 32 | /// Emacs lists a directory in `org-agenda-files`. | ||
| 33 | public var files: [String] { | ||
| 34 | workspace.files | ||
| 35 | .filter { $0.kind == .org && (includeSubfolders || !$0.relativePath.contains("/")) } | ||
| 36 | .map(\.path) | ||
| 37 | .sorted() | ||
| 38 | } | ||
| 39 | |||
| 40 | /// Recomputes the days. `open` is the open file's path and text. | ||
| 41 | public func refresh(open: (path: String, text: String)?, now: Date = Date(), calendar: Calendar = .current) async { | ||
| 42 | generation += 1 | ||
| 43 | let id = generation | ||
| 44 | isComputing = true | ||
| 45 | let today = Days.absolute(now, calendar: calendar) | ||
| 46 | let clock = calendar.dateComponents([.hour, .minute], from: now) | ||
| 47 | let first = start ?? today + startOffset | ||
| 48 | let defaults = workspace.settings.org | ||
| 49 | var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = [] | ||
| 50 | for path in files { | ||
| 51 | if let open, open.path == path { | ||
| 52 | reads.append((path, nil, .distantPast, -1, open.text)) | ||
| 53 | continue | ||
| 54 | } | ||
| 55 | let attributes = try? FileManager.default.attributesOfItem(atPath: path) | ||
| 56 | let mtime = attributes?[.modificationDate] as? Date ?? .distantPast | ||
| 57 | let size = (attributes?[.size] as? NSNumber)?.intValue ?? -1 | ||
| 58 | let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil } | ||
| 59 | reads.append((path, cached, mtime, size, nil)) | ||
| 60 | } | ||
| 61 | let span = self.span | ||
| 62 | let options = self.options | ||
| 63 | let result = await Task.detached { | ||
| 64 | var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = [] | ||
| 65 | for read in reads { | ||
| 66 | if let cached = read.cached { | ||
| 67 | sources.append((read.path, read.mtime, read.size, cached, true)) | ||
| 68 | } else if let text = read.text { | ||
| 69 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), false)) | ||
| 70 | } else if let data = FileManager.default.contents(atPath: read.path) { | ||
| 71 | let text = SourceText(bytes: [UInt8](data)).text | ||
| 72 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true)) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | let days = Agenda.list(sources.map(\.source), start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options) | ||
| 76 | return (sources, days) | ||
| 77 | }.value | ||
| 78 | guard id == generation else { return } | ||
| 79 | for source in result.0 where source.fromDisk { | ||
| 80 | cache[source.path] = (source.mtime, source.size, source.source) | ||
| 81 | } | ||
| 82 | days = result.1 | ||
| 83 | isComputing = false | ||
| 84 | } | ||
| 85 | |||
| 86 | public func showToday() { start = nil } | ||
| 87 | |||
| 88 | /// Moves the view by a whole span, forward or back. | ||
| 89 | public func shift(forward: Bool, now: Date = Date(), calendar: Calendar = .current) { | ||
| 90 | let current = start ?? Days.absolute(now, calendar: calendar) + startOffset | ||
| 91 | start = current + (forward ? span : -span) | ||
| 92 | } | ||
| 93 | } | ||
Sources/OrgApp/DocumentSession.swift +8 −1
| @@ -53,6 +53,9 @@ public final class DocumentSession { | |||
| 53 | /// keystroke; views follow `path`, `isDirty` and `outline`. | 53 | /// keystroke; views follow `path`, `isDirty` and `outline`. |
| 54 | @ObservationIgnored public private(set) var document: DocumentState? | 54 | @ObservationIgnored public private(set) var document: DocumentState? |
| 55 | public private(set) var outline: [OutlineEntry] = [] | 55 | public private(set) var outline: [OutlineEntry] = [] |
| 56 | /// Bumped when the buffer settles after edits, and when a file opens: views that read | ||
| 57 | /// `document` follow this. | ||
| 58 | public private(set) var editVersion = 0 | ||
| 56 | public private(set) var isDirty = false | 59 | public private(set) var isDirty = false |
| 57 | /// The file isn't UTF-8, so it can't be edited or saved. | 60 | /// The file isn't UTF-8, so it can't be edited or saved. |
| 58 | public private(set) var isReadOnly = false | 61 | public private(set) var isReadOnly = false |
| @@ -117,6 +120,7 @@ public final class DocumentSession { | |||
| 117 | try? await Task.sleep(for: .milliseconds(300)) | 120 | try? await Task.sleep(for: .milliseconds(300)) |
| 118 | guard !Task.isCancelled, let self, let document = self.document else { return } | 121 | guard !Task.isCancelled, let self, let document = self.document else { return } |
| 119 | self.outline = Presentation.outline(document.tree) | 122 | self.outline = Presentation.outline(document.tree) |
| 123 | self.editVersion += 1 | ||
| 120 | } | 124 | } |
| 121 | scheduleAutosave() | 125 | scheduleAutosave() |
| 122 | } | 126 | } |
| @@ -127,7 +131,10 @@ public final class DocumentSession { | |||
| 127 | document = state | 131 | document = state |
| 128 | if isDirty != state.isDirty { isDirty = state.isDirty } | 132 | if isDirty != state.isDirty { isDirty = state.isDirty } |
| 129 | if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } | 133 | if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } |
| 130 | if updateOutline { outline = Presentation.outline(state.tree) } | 134 | if updateOutline { |
| 135 | outline = Presentation.outline(state.tree) | ||
| 136 | editVersion += 1 | ||
| 137 | } | ||
| 131 | } | 138 | } |
| 132 | 139 | ||
| 133 | // MARK: - Saving | 140 | // MARK: - Saving |
Sources/OrgCore/Agenda/Agenda.swift added +421
| @@ -0,0 +1,421 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | public struct AgendaItem: Sendable, Equatable { | ||
| 4 | public enum Kind: String, Sendable { | ||
| 5 | case deadline | ||
| 6 | case upcomingDeadline = "upcoming-deadline" | ||
| 7 | case scheduled | ||
| 8 | case pastScheduled = "past-scheduled" | ||
| 9 | case timestamp | ||
| 10 | case block | ||
| 11 | case timeGrid | ||
| 12 | case currentTime | ||
| 13 | } | ||
| 14 | |||
| 15 | public let kind: Kind | ||
| 16 | public let path: String? | ||
| 17 | /// Start of the entry's heading line. | ||
| 18 | public let headingOffset: Int? | ||
| 19 | /// Where org's marker points: the timestamp, or the end of a range. | ||
| 20 | public let markerOffset: Int? | ||
| 21 | public let category: String | ||
| 22 | /// `HHMM`, when the item has a time of day. | ||
| 23 | public let timeOfDay: Int? | ||
| 24 | /// `14:00-15:00`, ` 9:00......`, or empty. | ||
| 25 | public let time: String | ||
| 26 | /// The leader: `Deadline: `, `Sched. 2x: `, `(1/3): `. | ||
| 27 | public let extra: String | ||
| 28 | /// The heading text as shown: TODO keyword, priority, title, without tags. | ||
| 29 | public let text: String | ||
| 30 | public let tags: [String] | ||
| 31 | public let todo: String? | ||
| 32 | public let isDone: Bool | ||
| 33 | public let urgency: Int | ||
| 34 | /// The line as org prints it, with one space before the tags. | ||
| 35 | public let line: String | ||
| 36 | } | ||
| 37 | |||
| 38 | public struct AgendaDay: Sendable, Equatable { | ||
| 39 | /// Emacs's absolute day number (`Days`). | ||
| 40 | public let day: Int | ||
| 41 | public let isToday: Bool | ||
| 42 | /// `Monday 5 October 2026 W41`. | ||
| 43 | public let header: String | ||
| 44 | public let items: [AgendaItem] | ||
| 45 | } | ||
| 46 | |||
| 47 | public struct AgendaOptions: Sendable, Equatable { | ||
| 48 | public var deadlineWarningDays = 14 | ||
| 49 | public var gridTimes = [800, 1000, 1200, 1400, 1600, 1800, 2000] | ||
| 50 | public var gridTrailing = " ┄┄┄┄┄ " | ||
| 51 | public var gridLine = "┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄" | ||
| 52 | public var currentTimeLine = "← now ───────────────────────────────────────────────" | ||
| 53 | |||
| 54 | public init() {} | ||
| 55 | |||
| 56 | /// What Emacs shows without a graphical display. | ||
| 57 | public static let terminal: AgendaOptions = { | ||
| 58 | var options = AgendaOptions() | ||
| 59 | options.gridTrailing = "......" | ||
| 60 | options.gridLine = "----------------" | ||
| 61 | options.currentTimeLine = "now - - - - - - - - - - - - - - - - - - - - - - - - -" | ||
| 62 | return options | ||
| 63 | }() | ||
| 64 | } | ||
| 65 | |||
| 66 | /// `org-agenda-list`. | ||
| 67 | public enum Agenda { | ||
| 68 | public static func list( | ||
| 69 | _ sources: [AgendaSource], start: Int, days: Int, today: Int, now: (hour: Int, minute: Int), | ||
| 70 | options: AgendaOptions = AgendaOptions() | ||
| 71 | ) -> [AgendaDay] { | ||
| 72 | (start..<(start + days)).map { day in | ||
| 73 | var items: [AgendaItem] = [] | ||
| 74 | for source in sources { items += entries(source, day: day, today: today, options: options) } | ||
| 75 | let isToday = day == today | ||
| 76 | if (isToday || days == 1), items.contains(where: { $0.timeOfDay != nil }) { | ||
| 77 | items = grid(day: day, isToday: isToday, now: now, options: options) + items | ||
| 78 | } | ||
| 79 | return AgendaDay(day: day, isToday: isToday, header: header(day), items: sort(items)) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | |||
| 83 | /// `org-agenda-get-day-entries` with the default entry types. | ||
| 84 | static func entries(_ source: AgendaSource, day: Int, today: Int, options: AgendaOptions) -> [AgendaItem] { | ||
| 85 | let deadlines = self.deadlines(source, day: day, today: today, options: options) | ||
| 86 | return deadlines + scheduled(source, day: day, today: today, options: options) + blocks(source, day: day, options: options) | ||
| 87 | + timestamps(source, day: day, today: today, options: options) | ||
| 88 | } | ||
| 89 | |||
| 90 | // MARK: - Collectors | ||
| 91 | |||
| 92 | static func deadlines(_ source: AgendaSource, day current: Int, today: Int, options: AgendaOptions) -> [AgendaItem] { | ||
| 93 | let isToday = current == today | ||
| 94 | var items: [AgendaItem] = [] | ||
| 95 | for heading in source.headings { | ||
| 96 | guard let (s, offset) = heading.deadline, let deadline = Days.absolute(of: s) else { continue } | ||
| 97 | let repeat_ = current <= today ? deadline : (Days.closest(s, to: current, prefer: .future) ?? deadline) | ||
| 98 | let diff = deadline - current | ||
| 99 | let warningDays = Days.warningDays(s, delay: false, defaultDays: options.deadlineWarningDays) | ||
| 100 | if current != deadline, current != repeat_ { | ||
| 101 | if !isToday { continue } | ||
| 102 | if deadline > current { | ||
| 103 | if diff > warningDays { continue } | ||
| 104 | } else if 10000 < -diff { | ||
| 105 | continue | ||
| 106 | } | ||
| 107 | } | ||
| 108 | if heading.isDone, deadline != current { continue } | ||
| 109 | let time: Dotime? = current != deadline && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline) | ||
| 110 | let extra: String | ||
| 111 | if isToday, deadline < today { | ||
| 112 | extra = String(format: "%2d d. ago: ", -diff) | ||
| 113 | } else if isToday, deadline > today { | ||
| 114 | extra = String(format: "In %3d d.: ", diff) | ||
| 115 | } else { | ||
| 116 | extra = "Deadline: " | ||
| 117 | } | ||
| 118 | let upcoming = isToday && deadline > today | ||
| 119 | items.append(format( | ||
| 120 | source, heading, kind: upcoming ? .upcomingDeadline : .deadline, marker: offset, extra: extra, | ||
| 121 | dotime: time, removing: nil, trailing: options.gridTrailing, urgency: { (isToday ? -diff : 0) + $0 } | ||
| 122 | )) | ||
| 123 | } | ||
| 124 | return items | ||
| 125 | } | ||
| 126 | |||
| 127 | static func scheduled(_ source: AgendaSource, day current: Int, today: Int, options: AgendaOptions) -> [AgendaItem] { | ||
| 128 | let isToday = current == today | ||
| 129 | var items: [AgendaItem] = [] | ||
| 130 | for heading in source.headings { | ||
| 131 | guard let (s, offset) = heading.scheduled, let schedule = Days.absolute(of: s) else { continue } | ||
| 132 | let repeat_ = current <= today ? schedule : (Days.closest(s, to: current, prefer: .future) ?? schedule) | ||
| 133 | let diff = current - schedule | ||
| 134 | let past = schedule < today | ||
| 135 | let delay = Days.warningDays(s, delay: true, defaultDays: 0) | ||
| 136 | if (delay > 0 && diff < delay) || diff > 10000 || schedule > current | ||
| 137 | || (current != schedule && current != today && current != repeat_) { | ||
| 138 | continue | ||
| 139 | } | ||
| 140 | if heading.isDone, schedule != current { continue } | ||
| 141 | let time: Dotime? = current != schedule && current != repeat_ ? nil : (timeIn(s).map { .string($0) } ?? .headline) | ||
| 142 | let extra = isToday && past ? String(format: "Sched.%2dx: ", diff) : "Scheduled: " | ||
| 143 | items.append(format( | ||
| 144 | source, heading, kind: past ? .pastScheduled : .scheduled, marker: offset, extra: extra, | ||
| 145 | dotime: time, removing: nil, trailing: options.gridTrailing, urgency: { 99 + diff + $0 } | ||
| 146 | )) | ||
| 147 | } | ||
| 148 | return items | ||
| 149 | } | ||
| 150 | |||
| 151 | static func blocks(_ source: AgendaSource, day current: Int, options: AgendaOptions) -> [AgendaItem] { | ||
| 152 | var items: [AgendaItem] = [] | ||
| 153 | for block in source.blocks { | ||
| 154 | guard let startDay = Days.absolute(of: block.start), let endDay = Days.absolute(of: block.end), | ||
| 155 | current >= startDay, endDay >= current else { continue } | ||
| 156 | let heading = source.headings[block.heading] | ||
| 157 | let extra = startDay == endDay ? "" : "(\(current - startDay + 1)/\(endDay - startDay + 1)): " | ||
| 158 | let dotime: Dotime? | ||
| 159 | if startDay == current, endDay == current { | ||
| 160 | dotime = .string("<\(block.start)>--<\(block.end)>") | ||
| 161 | } else if startDay == current { | ||
| 162 | dotime = .string("<\(block.start)>") | ||
| 163 | } else if endDay == current { | ||
| 164 | dotime = .string("<\(block.end)>") | ||
| 165 | } else { | ||
| 166 | dotime = nil | ||
| 167 | } | ||
| 168 | items.append(format(source, heading, kind: .block, marker: block.marker, extra: extra, dotime: dotime, removing: nil, trailing: options.gridTrailing, urgency: { $0 })) | ||
| 169 | } | ||
| 170 | return items | ||
| 171 | } | ||
| 172 | |||
| 173 | static let repeating = try! NSRegularExpression(pattern: "^<[0-9]+-[0-9]+-[0-9]+[^>\\n]+?\\+[0-9]+[hdwmy]>") | ||
| 174 | static let stampBoth = try! NSRegularExpression(pattern: "^[\\[<](\(AgendaSource.tsInternal))[\\]>]") | ||
| 175 | static let stampActive = try! NSRegularExpression(pattern: "<(\(AgendaSource.tsInternal))>") | ||
| 176 | static let stampAny = try! NSRegularExpression(pattern: "[\\[<](\(AgendaSource.tsInternal))[\\]>]") | ||
| 177 | |||
| 178 | static func timestamps(_ source: AgendaSource, day current: Int, today: Int, options: AgendaOptions) -> [AgendaItem] { | ||
| 179 | let date = Days.date(current) | ||
| 180 | let literal = String(format: "<%04d-%02d-%02d", date.year, date.month, date.day) | ||
| 181 | var items: [AgendaItem] = [] | ||
| 182 | for stamp in source.stamps { | ||
| 183 | let rest = stamp.rest as NSString | ||
| 184 | let all = NSRange(location: 0, length: rest.length) | ||
| 185 | let timestamp: String | ||
| 186 | if stamp.rest.hasPrefix(literal) { | ||
| 187 | guard let m = stampBoth.firstMatch(in: stamp.rest, range: all) else { continue } | ||
| 188 | timestamp = rest.substring(with: m.range) | ||
| 189 | } else if let m = repeating.firstMatch(in: stamp.rest, range: all) { | ||
| 190 | let repeat_ = rest.substring(with: m.range) | ||
| 191 | guard let past = Days.closest(repeat_, to: current > today ? today : current, prefer: .past) else { continue } | ||
| 192 | let future = current <= today ? past : (Days.closest(repeat_, to: current, prefer: .future) ?? past) | ||
| 193 | if current != past, current != future { continue } | ||
| 194 | timestamp = repeat_ | ||
| 195 | } else { | ||
| 196 | continue | ||
| 197 | } | ||
| 198 | let heading = source.headings[stamp.heading] | ||
| 199 | items.append(format( | ||
| 200 | source, heading, kind: .timestamp, marker: stamp.offset, extra: "", | ||
| 201 | dotime: .string(timestamp), removing: stampActive, trailing: options.gridTrailing, urgency: { $0 } | ||
| 202 | )) | ||
| 203 | } | ||
| 204 | return items | ||
| 205 | } | ||
| 206 | |||
| 207 | /// `" HH:MM"` in a planning timestamp: the time and everything after it. | ||
| 208 | static func timeIn(_ s: String) -> String? { | ||
| 209 | guard let r = s.range(of: " [012]?[0-9]:[0-9][0-9]", options: .regularExpression) else { return nil } | ||
| 210 | return String(s[s.index(after: r.lowerBound)...]) + " " | ||
| 211 | } | ||
| 212 | |||
| 213 | // MARK: - Formatting | ||
| 214 | |||
| 215 | enum Dotime { | ||
| 216 | case string(String) | ||
| 217 | /// Look for a time in the heading only. | ||
| 218 | case headline | ||
| 219 | } | ||
| 220 | |||
| 221 | static let tagGroup = try! NSRegularExpression(pattern: "[ \\t]+(:([\\p{L}\\p{N}_@#%:]+):)[ \\t]*$") | ||
| 222 | static let stampTime = try! NSRegularExpression( | ||
| 223 | pattern: "<([0-9]{4}-[0-9]{2}-[0-9]{2} +[\\p{L}\\p{N}_]+ +)([012][0-9]:[0-5][0-9])(-([012][0-9]:[0-5][0-9]))?[^\\n\\r>]*?>(--?<\\1([012][0-9]:[0-5][0-9])>)?" | ||
| 224 | ) | ||
| 225 | static let plainTimePart = "((?<![\\p{L}\\p{N}_])[012]?[0-9]((:([0-5][0-9]([AaPp][Mm])?))|([AaPp][Mm]))(?![\\p{L}\\p{N}_]))" | ||
| 226 | static let plainTime = try! NSRegularExpression(pattern: "\(plainTimePart)(--?\(plainTimePart))?") | ||
| 227 | static let timeOfDay = try! NSRegularExpression( | ||
| 228 | pattern: "(?<![\\p{L}\\p{N}_])([012]?[0-9])(?::([0-5][0-9])(am|pm)?|(am|pm))(?![\\p{L}\\p{N}_])", | ||
| 229 | options: .caseInsensitive | ||
| 230 | ) | ||
| 231 | |||
| 232 | /// `org-agenda-format-item` with the default agenda prefix ` %i %-12:c%?-12t% s`, then | ||
| 233 | /// `org-agenda-highlight-todo`. | ||
| 234 | static func format( | ||
| 235 | _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int, | ||
| 236 | extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, urgency: (Int) -> Int | ||
| 237 | ) -> AgendaItem { | ||
| 238 | var txt = heading.head.trimmingCharacters(in: .whitespaces) | ||
| 239 | // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list. | ||
| 240 | let tags = heading.tags | ||
| 241 | if let m = tagGroup.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) { | ||
| 242 | txt = (txt as NSString).substring(to: m.range.location) | ||
| 243 | } | ||
| 244 | var tagString = "" | ||
| 245 | if !tags.isEmpty { | ||
| 246 | var haveInherited = tags[0].inherited | ||
| 247 | var parts: [String] = [] | ||
| 248 | for tag in tags { | ||
| 249 | if haveInherited, !tag.inherited { | ||
| 250 | haveInherited = false | ||
| 251 | parts.append(":" + tag.name) | ||
| 252 | } else { | ||
| 253 | parts.append(tag.name) | ||
| 254 | } | ||
| 255 | } | ||
| 256 | tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":") | ||
| 257 | } | ||
| 258 | |||
| 259 | let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing) | ||
| 260 | if let removing { | ||
| 261 | while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) { | ||
| 262 | txt = (txt as NSString).replacingCharacters(in: m.range, with: "") | ||
| 263 | } | ||
| 264 | } | ||
| 265 | let category = heading.category | ||
| 266 | let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12) + (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ") | ||
| 267 | txt = highlightTodo(txt, keywords: source.keywords) | ||
| 268 | let priority = Self.priority(prefix + txt, source.priorities) | ||
| 269 | return AgendaItem( | ||
| 270 | kind: kind, path: source.path, headingOffset: heading.start, markerOffset: marker, category: category, | ||
| 271 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), | ||
| 272 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), | ||
| 273 | line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString) | ||
| 274 | ) | ||
| 275 | } | ||
| 276 | |||
| 277 | /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the | ||
| 278 | /// text, and formats the `%t` field. | ||
| 279 | static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String) -> (timeOfDay: Int?, time: String) { | ||
| 280 | guard let dotime else { return (nil, "") } | ||
| 281 | let full = tagString.isEmpty ? txt : txt + " " + tagString | ||
| 282 | let withoutStamps = stampAny.stringByReplacingMatches(in: full, range: NSRange(location: 0, length: (full as NSString).length), withTemplate: "") | ||
| 283 | let containing: String | ||
| 284 | switch dotime { | ||
| 285 | case .string(let s): containing = s + withoutStamps | ||
| 286 | case .headline: containing = withoutStamps | ||
| 287 | } | ||
| 288 | guard let timeOfDay = time(of: containing) else { return (nil, "") } | ||
| 289 | let ns = containing as NSString | ||
| 290 | let all = NSRange(location: 0, length: ns.length) | ||
| 291 | var start: String? | ||
| 292 | var end: String? | ||
| 293 | if let m = stampTime.firstMatch(in: containing, range: all) { | ||
| 294 | start = ns.substring(with: m.range(at: 2)) | ||
| 295 | let endGroup = m.range(at: 3).location != NSNotFound ? 4 : 6 | ||
| 296 | if m.range(at: endGroup).location != NSNotFound { end = ns.substring(with: m.range(at: endGroup)) } | ||
| 297 | removeTime(ns.substring(with: m.range), from: &txt) | ||
| 298 | } else if let m = plainTime.firstMatch(in: containing, range: all) { | ||
| 299 | start = ns.substring(with: m.range(at: 1)) | ||
| 300 | if m.range(at: 8).location != NSNotFound { end = ns.substring(with: m.range(at: 8)) } | ||
| 301 | removeTime(ns.substring(with: m.range), from: &txt) | ||
| 302 | } | ||
| 303 | let startText = start.flatMap(formattedTime).map { String(repeating: " ", count: max(0, 5 - $0.count)) + $0 } | ||
| 304 | let endText = end.flatMap(formattedTime) | ||
| 305 | let time: String | ||
| 306 | if let startText, let endText { | ||
| 307 | time = startText + "-" + endText | ||
| 308 | } else if let startText { | ||
| 309 | time = startText + trailing | ||
| 310 | } else { | ||
| 311 | time = "" | ||
| 312 | } | ||
| 313 | return (timeOfDay, time) | ||
| 314 | } | ||
| 315 | |||
| 316 | /// `org-agenda-remove-times-when-in-prefix`. | ||
| 317 | static func removeTime(_ timeString: String, from txt: inout String) { | ||
| 318 | let ns = txt as NSString | ||
| 319 | let regex = try! NSRegularExpression(pattern: NSRegularExpression.escapedPattern(for: timeString) + " *") | ||
| 320 | guard let m = regex.firstMatch(in: txt, range: NSRange(location: 0, length: ns.length)) else { return } | ||
| 321 | if NSMaxRange(m.range) < ns.length, ns.character(at: NSMaxRange(m.range)) == 93 { return } | ||
| 322 | txt = ns.replacingCharacters(in: m.range, with: "") | ||
| 323 | } | ||
| 324 | |||
| 325 | /// `org-get-time-of-day` as a number. | ||
| 326 | static func time(of s: String) -> Int? { | ||
| 327 | let ns = s as NSString | ||
| 328 | guard let m = timeOfDay.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return nil } | ||
| 329 | var hours = Int(ns.substring(with: m.range(at: 1)))! | ||
| 330 | let minutes = m.range(at: 2).location != NSNotFound ? Int(ns.substring(with: m.range(at: 2)))! : 0 | ||
| 331 | let ampmRange = m.range(at: 3).location != NSNotFound ? m.range(at: 3) : m.range(at: 4) | ||
| 332 | if ampmRange.location != NSNotFound { | ||
| 333 | let am = ns.substring(with: ampmRange).lowercased() == "am" | ||
| 334 | hours = hours == 12 ? (am ? 0 : 12) : hours + (am ? 0 : 12) | ||
| 335 | } | ||
| 336 | return hours * 100 + minutes | ||
| 337 | } | ||
| 338 | |||
| 339 | /// `org-get-time-of-day` with `overtime`: `H:MM`, or `+H:MM` past midnight. | ||
| 340 | static func formattedTime(_ s: String) -> String? { | ||
| 341 | guard let value = time(of: s) else { return nil } | ||
| 342 | let hours = value / 100 | ||
| 343 | let minutes = value % 100 | ||
| 344 | if hours > 24 || (hours == 24 && minutes > 0) { return String(format: "+%d:%02d", hours - 24, minutes) } | ||
| 345 | return String(format: "%d:%02d", hours, minutes) | ||
| 346 | } | ||
| 347 | |||
| 348 | /// `format "%-Ns"`: pads to a display width. | ||
| 349 | static func pad(_ s: String, _ width: Int) -> String { | ||
| 350 | s + String(repeating: " ", count: max(0, width - displayWidth(s))) | ||
| 351 | } | ||
| 352 | |||
| 353 | /// `org-agenda-highlight-todo` with `org-agenda-todo-keyword-format` `%-1s`: the spaces | ||
| 354 | /// after a leading TODO keyword become one. | ||
| 355 | static func highlightTodo(_ txt: String, keywords: [String]) -> String { | ||
| 356 | let dots = txt.prefix { $0 == "." } | ||
| 357 | let rest = txt.dropFirst(dots.count) | ||
| 358 | for keyword in keywords.sorted(by: { $0.count > $1.count }) where rest.hasPrefix(keyword) { | ||
| 359 | let after = rest.dropFirst(keyword.count) | ||
| 360 | guard after.first == " " else { continue } | ||
| 361 | return dots + keyword + " " + after.drop { $0 == " " } | ||
| 362 | } | ||
| 363 | return txt | ||
| 364 | } | ||
| 365 | |||
| 366 | /// `org-get-priority`. | ||
| 367 | static func priority(_ s: String, _ priorities: Priorities) -> Int { | ||
| 368 | func value(_ p: String) -> Int { | ||
| 369 | p.allSatisfy(\.isNumber) ? Int(p)! : Int(p.unicodeScalars.first!.value) | ||
| 370 | } | ||
| 371 | let lowest = value(priorities.lowest) | ||
| 372 | if let r = s.range(of: "\\[#([A-Z]|[0-9]|[1-5][0-9]|6[0-4])\\]", options: .regularExpression) { | ||
| 373 | return 1000 * (lowest - value(String(s[r].dropFirst(2).dropLast()))) | ||
| 374 | } | ||
| 375 | return 1000 * (lowest - value(priorities.default)) | ||
| 376 | } | ||
| 377 | |||
| 378 | // MARK: - Grid, sorting, headers | ||
| 379 | |||
| 380 | /// `org-agenda-add-time-grid-maybe`. | ||
| 381 | static func grid(day: Int, isToday: Bool, now: (hour: Int, minute: Int), options: AgendaOptions) -> [AgendaItem] { | ||
| 382 | func item(_ kind: AgendaItem.Kind, _ text: String, _ dotime: String) -> AgendaItem { | ||
| 383 | var txt = text | ||
| 384 | let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing) | ||
| 385 | return AgendaItem( | ||
| 386 | kind: kind, path: nil, headingOffset: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, | ||
| 387 | time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, | ||
| 388 | line: " " + pad("", 12) + pad(timed.time, 12) + txt | ||
| 389 | ) | ||
| 390 | } | ||
| 391 | var new = options.gridTimes.reversed().map { t in | ||
| 392 | item(.timeGrid, options.gridLine, String(format: "%02d:%02d", t / 100, t % 100)) | ||
| 393 | } | ||
| 394 | if isToday { | ||
| 395 | new.insert(item(.currentTime, options.currentTimeLine, String(format: "%02d:%02d ", now.hour, now.minute)), at: 0) | ||
| 396 | } | ||
| 397 | return new | ||
| 398 | } | ||
| 399 | |||
| 400 | /// `org-entries-lessp` with `(habit-down time-up urgency-down category-keep)`, stably. | ||
| 401 | static func sort(_ items: [AgendaItem]) -> [AgendaItem] { | ||
| 402 | items.enumerated().sorted { a, b in | ||
| 403 | let ta = a.element.timeOfDay ?? 9901 | ||
| 404 | let tb = b.element.timeOfDay ?? 9901 | ||
| 405 | if ta != tb { return ta < tb } | ||
| 406 | if a.element.urgency != b.element.urgency { return a.element.urgency > b.element.urgency } | ||
| 407 | return a.offset < b.offset | ||
| 408 | }.map(\.element) | ||
| 409 | } | ||
| 410 | |||
| 411 | static let dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] | ||
| 412 | static let monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] | ||
| 413 | |||
| 414 | /// `org-agenda-format-date-aligned`. | ||
| 415 | public static func header(_ day: Int) -> String { | ||
| 416 | let date = Days.date(day) | ||
| 417 | let weekday = Days.weekday(day) | ||
| 418 | let week = weekday == 1 ? String(format: " W%02d", Days.isoWeek(day)) : "" | ||
| 419 | return pad(dayNames[weekday], 10) + String(format: " %2d ", date.day) + monthNames[date.month - 1] + String(format: " %4d", date.year) + week | ||
| 420 | } | ||
| 421 | } | ||
Sources/OrgCore/Agenda/AgendaSource.swift added +204
| @@ -0,0 +1,204 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// What the agenda needs from one file, read once from its text: headings with their planning, | ||
| 4 | /// and the active timestamps and date ranges org's agenda would consider. Positions are UTF-16. | ||
| 5 | public struct AgendaSource: Sendable { | ||
| 6 | public let path: String | ||
| 7 | |||
| 8 | struct Heading: Sendable { | ||
| 9 | /// Start of the heading line. | ||
| 10 | let start: Int | ||
| 11 | /// The line after the stars. | ||
| 12 | let head: String | ||
| 13 | let todo: String? | ||
| 14 | let isDone: Bool | ||
| 15 | /// Inherited tags first; the flag marks inherited ones. | ||
| 16 | let tags: [(name: String, inherited: Bool)] | ||
| 17 | let category: String | ||
| 18 | /// The planning timestamp without brackets, and the position of its `<`. | ||
| 19 | let scheduled: (raw: String, offset: Int)? | ||
| 20 | let deadline: (raw: String, offset: Int)? | ||
| 21 | } | ||
| 22 | |||
| 23 | struct Stamp: Sendable { | ||
| 24 | let offset: Int | ||
| 25 | let heading: Int | ||
| 26 | /// From the `<` to the end of the line. | ||
| 27 | let rest: String | ||
| 28 | } | ||
| 29 | |||
| 30 | struct Block: Sendable { | ||
| 31 | let start: String | ||
| 32 | let end: String | ||
| 33 | /// End of the range; org's marker sits there. | ||
| 34 | let marker: Int | ||
| 35 | let heading: Int | ||
| 36 | } | ||
| 37 | |||
| 38 | let headings: [Heading] | ||
| 39 | let stamps: [Stamp] | ||
| 40 | let blocks: [Block] | ||
| 41 | /// The file's TODO keywords, active and done. | ||
| 42 | let keywords: [String] | ||
| 43 | let priorities: Priorities | ||
| 44 | |||
| 45 | public init(path: String, text: String, defaults: OrgSettings = .default) { | ||
| 46 | self.path = path | ||
| 47 | let tree = OrgParser.parse(text, defaults: defaults) | ||
| 48 | let model = DocumentModel(tree: tree) | ||
| 49 | let ns = text as NSString | ||
| 50 | let settings = tree.settings | ||
| 51 | keywords = settings.todoSequences.flatMap { $0.active.map(\.name) + $0.done.map(\.name) } | ||
| 52 | priorities = settings.priorities | ||
| 53 | |||
| 54 | var keywordCategory: String? | ||
| 55 | var srcBodies: [Range<Int>] = [] | ||
| 56 | var planningLines: [Range<Int>] = [] | ||
| 57 | var timestampNodes: [Int] = [] | ||
| 58 | var propertyLines: [Range<Int>] = [] | ||
| 59 | func walk(_ node: SyntaxNode) { | ||
| 60 | for child in node.children { | ||
| 61 | switch child.kind { | ||
| 62 | case .keyword: | ||
| 63 | let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| 64 | if line.uppercased().hasPrefix("#+CATEGORY:") { | ||
| 65 | keywordCategory = line.dropFirst("#+CATEGORY:".count).trimmingCharacters(in: .whitespaces) | ||
| 66 | } | ||
| 67 | case .block: | ||
| 68 | if DocumentModel.isSrcBlock(child) { | ||
| 69 | let lines = child.range | ||
| 70 | let firstEnd = ns.range(of: "\n", range: NSRange(location: lines.lowerBound, length: lines.count)).location | ||
| 71 | let lastStart = ns.lineRange(for: NSRange(location: max(lines.lowerBound, lines.upperBound - 1), length: 0)).location | ||
| 72 | if firstEnd != NSNotFound, firstEnd + 1 <= lastStart { srcBodies.append((firstEnd + 1)..<lastStart) } | ||
| 73 | } | ||
| 74 | case .planning: | ||
| 75 | planningLines.append(child.range) | ||
| 76 | case .clock: | ||
| 77 | break | ||
| 78 | case .nodeProperty: | ||
| 79 | propertyLines.append(child.range) | ||
| 80 | case .timestamp: | ||
| 81 | if child.text.hasPrefix("<") { timestampNodes.append(child.offset) } | ||
| 82 | default: | ||
| 83 | walk(child) | ||
| 84 | } | ||
| 85 | } | ||
| 86 | } | ||
| 87 | walk(tree.root) | ||
| 88 | |||
| 89 | let fileName = ((path as NSString).lastPathComponent as NSString).deletingPathExtension | ||
| 90 | let fileCategory = keywordCategory ?? model.property("CATEGORY", of: nil)?.value ?? fileName | ||
| 91 | let fileTags = model.fileTags | ||
| 92 | let fileArchived = fileTags.contains("ARCHIVE") | ||
| 93 | |||
| 94 | // Archived and commented trees are skipped whole, as `org-agenda-skip` does. | ||
| 95 | var skipped: [Bool] = [] | ||
| 96 | var headings: [Heading] = [] | ||
| 97 | for (index, info) in model.headings.enumerated() { | ||
| 98 | let own = info.localTags.contains("ARCHIVE") || Self.isCommented(info.title) | ||
| 99 | skipped.append(fileArchived || own || (info.parent.map { skipped[$0] } ?? false)) | ||
| 100 | let line = ns.substring(with: NSRange(location: info.headingRange.lowerBound, length: info.headingRange.count)) | ||
| 101 | let head = line.replacingOccurrences(of: "^\\*+[ \\t]+", with: "", options: .regularExpression) | ||
| 102 | .trimmingCharacters(in: .newlines) | ||
| 103 | |||
| 104 | var itags: [String] = fileTags | ||
| 105 | for ancestor in model.ancestors(of: index) { itags += model.headings[ancestor].localTags } | ||
| 106 | var tags: [(String, Bool)] = itags.map { ($0, true) } + info.localTags.map { ($0, false) } | ||
| 107 | // `delete-dups` over the reversed list: the most local copy of a tag stays. | ||
| 108 | var seen: Set<String> = [] | ||
| 109 | tags = tags.reversed().filter { seen.insert($0.0).inserted }.reversed() | ||
| 110 | |||
| 111 | var category = fileCategory | ||
| 112 | for layer in ([index] + model.ancestors(of: index).reversed()) { | ||
| 113 | if let value = model.headings[layer].properties.last(where: { $0.key.uppercased() == "CATEGORY" && !$0.additive })?.value { | ||
| 114 | category = value | ||
| 115 | break | ||
| 116 | } | ||
| 117 | } | ||
| 118 | |||
| 119 | var scheduled: (String, Int)? | ||
| 120 | var deadline: (String, Int)? | ||
| 121 | let afterHeading = info.headingRange.upperBound | ||
| 122 | if let planning = planningLines.first(where: { $0.lowerBound == afterHeading }) { | ||
| 123 | let planningText = ns.substring(with: NSRange(location: planning.lowerBound, length: planning.count)) | ||
| 124 | let regex = try! NSRegularExpression(pattern: "\\b(SCHEDULED|DEADLINE): *<([^>]+)>") | ||
| 125 | let pns = planningText as NSString | ||
| 126 | for m in regex.matches(in: planningText, range: NSRange(location: 0, length: pns.length)) { | ||
| 127 | let value = (pns.substring(with: m.range(at: 2)), planning.lowerBound + m.range(at: 2).location - 1) | ||
| 128 | if pns.substring(with: m.range(at: 1)) == "SCHEDULED" { scheduled = scheduled ?? value } else { deadline = deadline ?? value } | ||
| 129 | } | ||
| 130 | } | ||
| 131 | headings.append(Heading( | ||
| 132 | start: info.headingRange.lowerBound, head: head, todo: info.todo, isDone: info.isDone, | ||
| 133 | tags: tags, category: category, scheduled: scheduled, deadline: deadline | ||
| 134 | )) | ||
| 135 | } | ||
| 136 | self.headings = headings | ||
| 137 | |||
| 138 | let starts = model.headings.map(\.headingRange.lowerBound) | ||
| 139 | func heading(at offset: Int) -> Int? { | ||
| 140 | var low = 0 | ||
| 141 | var high = starts.count | ||
| 142 | while low < high { | ||
| 143 | let mid = (low + high) / 2 | ||
| 144 | if starts[mid] <= offset { low = mid + 1 } else { high = mid } | ||
| 145 | } | ||
| 146 | return low == 0 ? nil : low - 1 | ||
| 147 | } | ||
| 148 | func restOfLine(_ offset: Int) -> String { | ||
| 149 | let line = ns.lineRange(for: NSRange(location: offset, length: 0)) | ||
| 150 | return ns.substring(with: NSRange(location: offset, length: NSMaxRange(line) - offset)).trimmingCharacters(in: .newlines) | ||
| 151 | } | ||
| 152 | |||
| 153 | // Timestamps: those the parser sees as timestamp objects, and dates in property lines. | ||
| 154 | var stampOffsets = timestampNodes | ||
| 155 | let datePattern = try! NSRegularExpression(pattern: "<[0-9]{4}-[0-9]{2}-[0-9]{2}") | ||
| 156 | for line in propertyLines { | ||
| 157 | for m in datePattern.matches(in: text, range: NSRange(location: line.lowerBound, length: line.count)) { | ||
| 158 | stampOffsets.append(m.range.location) | ||
| 159 | } | ||
| 160 | } | ||
| 161 | var stamps: [Stamp] = [] | ||
| 162 | for offset in stampOffsets.sorted() { | ||
| 163 | guard let owner = heading(at: offset), !skipped[owner] else { continue } | ||
| 164 | let rest = restOfLine(offset) | ||
| 165 | guard rest.range(of: "^<[0-9]{4}-[0-9]{2}-[0-9]{2}", options: .regularExpression) != nil else { continue } | ||
| 166 | if Self.inDateRange(offset, ns) { continue } | ||
| 167 | stamps.append(Stamp(offset: offset, heading: owner, rest: rest)) | ||
| 168 | } | ||
| 169 | self.stamps = stamps | ||
| 170 | |||
| 171 | // Ranges: org scans the raw text, skipping comment lines, skipped trees and src blocks. | ||
| 172 | var blocks: [Block] = [] | ||
| 173 | let range = try! NSRegularExpression(pattern: "<(\(Self.tsInternal))>--?-?<(\(Self.tsInternal))>") | ||
| 174 | for m in range.matches(in: text, range: NSRange(location: 0, length: ns.length)) { | ||
| 175 | let point = NSMaxRange(m.range) | ||
| 176 | let line = ns.substring(with: ns.lineRange(for: NSRange(location: point, length: 0))) | ||
| 177 | if line.range(of: "^\\s*#( |$|\\n)", options: .regularExpression) != nil { continue } | ||
| 178 | guard let owner = heading(at: max(0, point - 1)), !skipped[owner] else { continue } | ||
| 179 | if srcBodies.contains(where: { $0.contains(point) }) { continue } | ||
| 180 | blocks.append(Block(start: ns.substring(with: m.range(at: 1)), end: ns.substring(with: m.range(at: 2)), marker: point, heading: owner)) | ||
| 181 | } | ||
| 182 | self.blocks = blocks | ||
| 183 | } | ||
| 184 | |||
| 185 | /// `org-ts--internal-regexp`. | ||
| 186 | static let tsInternal = "[0-9]{4}-[0-9]{2}-[0-9]{2}(?: .*?)?" | ||
| 187 | |||
| 188 | /// `org-element` reads a title starting with `COMMENT` as a commented heading. | ||
| 189 | static func isCommented(_ title: String) -> Bool { | ||
| 190 | title == "COMMENT" || title.hasPrefix("COMMENT ") | ||
| 191 | } | ||
| 192 | |||
| 193 | /// `org-at-date-range-p` with point just after the date that starts at `offset`. | ||
| 194 | static func inDateRange(_ offset: Int, _ ns: NSString) -> Bool { | ||
| 195 | let point = offset + 11 | ||
| 196 | let line = ns.lineRange(for: NSRange(location: offset, length: 0)) | ||
| 197 | let regex = try! NSRegularExpression(pattern: "[\\[<](\(tsInternal))[\\]>]--?-?[\\[<](\(tsInternal))[\\]>]") | ||
| 198 | for m in regex.matches(in: ns as String, range: line) { | ||
| 199 | if m.range.location > point { break } | ||
| 200 | if NSMaxRange(m.range) >= point { return true } | ||
| 201 | } | ||
| 202 | return false | ||
| 203 | } | ||
| 204 | } | ||
Sources/OrgCore/Agenda/Days.swift added +144
| @@ -0,0 +1,144 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Calendar days as Emacs numbers them (`calendar-absolute-from-gregorian`): 1 is | ||
| 4 | /// 1 January of year 1, proleptic Gregorian. | ||
| 5 | public enum Days { | ||
| 6 | /// Out-of-range days and months roll over, as with `encode-time`. | ||
| 7 | public static func absolute(year: Int, month: Int, day: Int) -> Int { | ||
| 8 | let monthIndex = month - 1 | ||
| 9 | let y = year + Int((Double(monthIndex) / 12).rounded(.down)) | ||
| 10 | let m = monthIndex - 12 * Int((Double(monthIndex) / 12).rounded(.down)) + 1 | ||
| 11 | // Days from civil (Howard Hinnant), with 1970-01-01 = 0. | ||
| 12 | let yy = m <= 2 ? y - 1 : y | ||
| 13 | let era = (yy >= 0 ? yy : yy - 399) / 400 | ||
| 14 | let yoe = yy - era * 400 | ||
| 15 | let doy = (153 * (m > 2 ? m - 3 : m + 9) + 2) / 5 | ||
| 16 | let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy | ||
| 17 | return era * 146097 + doe - 719468 + 719163 + day - 1 | ||
| 18 | } | ||
| 19 | |||
| 20 | public static func date(_ absolute: Int) -> (year: Int, month: Int, day: Int) { | ||
| 21 | let z = absolute - 719163 + 719468 | ||
| 22 | let era = (z >= 0 ? z : z - 146096) / 146097 | ||
| 23 | let doe = z - era * 146097 | ||
| 24 | let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365 | ||
| 25 | let doy = doe - (365 * yoe + yoe / 4 - yoe / 100) | ||
| 26 | let mp = (5 * doy + 2) / 153 | ||
| 27 | let day = doy - (153 * mp + 2) / 5 + 1 | ||
| 28 | let month = mp < 10 ? mp + 3 : mp - 9 | ||
| 29 | return (yoe + era * 400 + (month <= 2 ? 1 : 0), month, day) | ||
| 30 | } | ||
| 31 | |||
| 32 | /// 0 is Sunday, as `calendar-day-of-week`. | ||
| 33 | public static func weekday(_ absolute: Int) -> Int { | ||
| 34 | ((absolute % 7) + 7) % 7 | ||
| 35 | } | ||
| 36 | |||
| 37 | /// `org-days-to-iso-week`. | ||
| 38 | public static func isoWeek(_ absolute: Int) -> Int { | ||
| 39 | let monday = absolute - (weekday(absolute) + 6) % 7 | ||
| 40 | let thursday = monday + 3 | ||
| 41 | let year = date(thursday).year | ||
| 42 | return (thursday - Days.absolute(year: year, month: 1, day: 1)) / 7 + 1 | ||
| 43 | } | ||
| 44 | |||
| 45 | /// The day of a `Date` in `calendar`'s time zone. | ||
| 46 | public static func absolute(_ date: Date, calendar: Calendar) -> Int { | ||
| 47 | let c = calendar.dateComponents([.year, .month, .day], from: date) | ||
| 48 | return absolute(year: c.year!, month: c.month!, day: c.day!) | ||
| 49 | } | ||
| 50 | |||
| 51 | static let dateRegex = try! NSRegularExpression(pattern: "([0-9]{4})-([0-9]{2})-([0-9]{2})") | ||
| 52 | static let timeRegex = try! NSRegularExpression(pattern: "([0-9]{1,2}):([0-9]{2})") | ||
| 53 | |||
| 54 | /// `time-to-days` of `org-time-string-to-time`: the first date in the string. | ||
| 55 | static func absolute(of string: String) -> Int? { | ||
| 56 | let ns = string as NSString | ||
| 57 | guard let m = dateRegex.firstMatch(in: string, range: NSRange(location: 0, length: ns.length)) else { return nil } | ||
| 58 | func int(_ i: Int) -> Int { Int(ns.substring(with: m.range(at: i)))! } | ||
| 59 | return absolute(year: int(1), month: int(2), day: int(3)) | ||
| 60 | } | ||
| 61 | |||
| 62 | /// The hour of `org-parse-time-string`, 0 when there is none. | ||
| 63 | static func hour(of string: String) -> Int { | ||
| 64 | let ns = string as NSString | ||
| 65 | guard let date = dateRegex.firstMatch(in: string, range: NSRange(location: 0, length: ns.length)) else { return 0 } | ||
| 66 | let rest = NSRange(location: NSMaxRange(date.range), length: ns.length - NSMaxRange(date.range)) | ||
| 67 | // org-ts-regexp0: a day name may sit between the date and the time. | ||
| 68 | let pattern = try! NSRegularExpression(pattern: "^( +[^\\]+0-9>\\r\\n -]+)?( +([0-9]{1,2}):([0-9]{2}))") | ||
| 69 | guard let m = pattern.firstMatch(in: string, range: rest) else { return 0 } | ||
| 70 | return Int(ns.substring(with: m.range(at: 3)))! | ||
| 71 | } | ||
| 72 | |||
| 73 | public enum Prefer { case past, future, nearest } | ||
| 74 | |||
| 75 | static let repeatRegex = try! NSRegularExpression(pattern: "\\+([0-9]+)([hdwmy])") | ||
| 76 | |||
| 77 | /// `org-closest-date`: the occurrence of a repeating timestamp `start` closest to `current`. | ||
| 78 | /// Timestamps without a repeater give their own day. | ||
| 79 | static func closest(_ start: String, to current: Int, prefer: Prefer) -> Int? { | ||
| 80 | guard let sday = absolute(of: start) else { return nil } | ||
| 81 | let ns = start as NSString | ||
| 82 | guard let m = repeatRegex.firstMatch(in: start, range: NSRange(location: 0, length: ns.length)) else { return sday } | ||
| 83 | let value = Int(ns.substring(with: m.range(at: 1)))! | ||
| 84 | let unit = ns.substring(with: m.range(at: 2)) | ||
| 85 | if value == 0 { return sday } | ||
| 86 | let cday = current | ||
| 87 | if cday <= sday { return sday } | ||
| 88 | let base = date(sday) | ||
| 89 | let target = date(cday) | ||
| 90 | var n1: Int | ||
| 91 | var n2: Int | ||
| 92 | switch unit { | ||
| 93 | case "h": | ||
| 94 | let missing = mod(24 * (cday - sday) - hour(of: start), value) | ||
| 95 | n1 = missing == 0 ? cday : cday - (1 + missing / 24) | ||
| 96 | n2 = cday + (value - missing) / 24 | ||
| 97 | case "d", "w": | ||
| 98 | let step = unit == "w" ? 7 * value : value | ||
| 99 | n1 = sday + step * ((cday - sday) / step) | ||
| 100 | n2 = n1 + step | ||
| 101 | case "m": | ||
| 102 | func add(_ d: (year: Int, month: Int, day: Int), _ n: Int) -> (year: Int, month: Int, day: Int) { | ||
| 103 | (d.year, d.month + n, d.day) | ||
| 104 | } | ||
| 105 | let months = ((12 * (target.year - base.year) + (target.month - base.month) + (target.day > base.day ? 0 : -1)) / value) * value | ||
| 106 | let before = add(base, months) | ||
| 107 | n1 = absolute(year: before.year, month: before.month, day: before.day) | ||
| 108 | let after = add(before, value) | ||
| 109 | n2 = absolute(year: after.year, month: after.month, day: after.day) | ||
| 110 | default: | ||
| 111 | let later = target.month > base.month || (target.month == base.month && target.day > base.day) | ||
| 112 | let years = ((target.year - base.year - (later ? 0 : 1)) / value) * value | ||
| 113 | n1 = absolute(year: base.year + years, month: base.month, day: base.day) | ||
| 114 | n2 = absolute(year: base.year + years + value, month: base.month, day: base.day) | ||
| 115 | } | ||
| 116 | switch prefer { | ||
| 117 | case .past: return cday == n2 ? n2 : n1 | ||
| 118 | case .future: return cday == n1 ? n1 : n2 | ||
| 119 | case .nearest: return abs(cday - n1) > abs(cday - n2) ? n2 : n1 | ||
| 120 | } | ||
| 121 | } | ||
| 122 | |||
| 123 | private static func mod(_ a: Int, _ b: Int) -> Int { ((a % b) + b) % b } | ||
| 124 | |||
| 125 | static let wdaysRegex = try! NSRegularExpression(pattern: "-([0-9]+)([hdwmy])(\\z|>| )") | ||
| 126 | |||
| 127 | /// `org-get-wdays`: the warning days of a deadline, or with `delay` the delay days of a | ||
| 128 | /// scheduled timestamp. | ||
| 129 | static func warningDays(_ stamp: String, delay: Bool, defaultDays: Int) -> Int { | ||
| 130 | let tv = defaultDays | ||
| 131 | if (delay && tv < 0) || (!delay && tv <= 0) { return -tv } | ||
| 132 | let ns = stamp as NSString | ||
| 133 | guard let m = wdaysRegex.firstMatch(in: stamp, range: NSRange(location: 0, length: ns.length)) else { return tv } | ||
| 134 | let n = Double(ns.substring(with: m.range(at: 1)))! | ||
| 135 | let factor: Double = switch ns.substring(with: m.range(at: 2)) { | ||
| 136 | case "d": 1 | ||
| 137 | case "w": 7 | ||
| 138 | case "m": 30.4 | ||
| 139 | case "y": 365.25 | ||
| 140 | default: 0.041667 | ||
| 141 | } | ||
| 142 | return Int((n * factor).rounded(.down)) | ||
| 143 | } | ||
| 144 | } | ||
Sources/OrgCore/Keymap/Presets.swift +3
| @@ -150,6 +150,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 150 | [[bind]] | 150 | [[bind]] |
| 151 | keys = "M-x" | 151 | keys = "M-x" |
| 152 | command = "app.palette" | 152 | command = "app.palette" |
| 153 | [[bind]] | ||
| 154 | keys = "C-c a" | ||
| 155 | command = "app.agenda" | ||
| 153 | 156 | ||
| 154 | # Org | 157 | # Org |
| 155 | [[bind]] | 158 | [[bind]] |
Sources/OrgCore/Parser/Inline.swift +2 −2
| @@ -177,10 +177,10 @@ struct InlineScanner { | |||
| 177 | case "[": | 177 | case "[": |
| 178 | if !inLink, let link = bracketLink(i, limit) { return link } | 178 | if !inLink, let link = bracketLink(i, limit) { return link } |
| 179 | if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) } | 179 | if let end = footnoteReference(i, limit) { return .object(.footnoteReference, i..<end) } |
| 180 | if let end = scanTimestamp(chars, at: i, limit: limit)?.end { return .object(.timestamp, i..<end) } | 180 | if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) } |
| 181 | if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) } | 181 | if let end = statisticsCookie(i, limit) { return .object(.statisticsCookie, i..<end) } |
| 182 | case "<": | 182 | case "<": |
| 183 | if let end = scanTimestamp(chars, at: i, limit: limit)?.end { return .object(.timestamp, i..<end) } | 183 | if !inLink, let end = timestampEnd(chars, at: i, limit: limit) { return .object(.timestamp, i..<end) } |
| 184 | if let end = target(i, limit) { return .object(.target, i..<end) } | 184 | if let end = target(i, limit) { return .object(.target, i..<end) } |
| 185 | if !inLink, let end = angleLink(i, limit) { return .object(.link, i..<end) } | 185 | if !inLink, let end = angleLink(i, limit) { return .object(.link, i..<end) } |
| 186 | case "{": | 186 | case "{": |
Sources/OrgCore/Timestamp.swift +31
| @@ -61,6 +61,37 @@ func scanTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (sta | |||
| 61 | return first | 61 | return first |
| 62 | } | 62 | } |
| 63 | 63 | ||
| 64 | /// The end of the timestamp object at `start`. Org reads anything matching | ||
| 65 | /// `org-ts-regexp-both` as one, `<2026-10-07 Wed 9am>` included, so a timestamp `parse` rejects | ||
| 66 | /// is still an object. | ||
| 67 | func timestampEnd(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> Int? { | ||
| 68 | if let strict = scanTimestamp(chars, at: start, limit: limit) { return strict.end } | ||
| 69 | guard let first = lenientTimestampEnd(chars, at: start, limit: limit) else { return nil } | ||
| 70 | if first + 2 < limit, chars[first] == "-", chars[first + 1] == "-", | ||
| 71 | let second = lenientTimestampEnd(chars, at: first + 2, limit: limit) { | ||
| 72 | return second | ||
| 73 | } | ||
| 74 | return first | ||
| 75 | } | ||
| 76 | |||
| 77 | /// `[[<]YYYY-MM-DD( .*?)?[]>]`. | ||
| 78 | private func lenientTimestampEnd(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> Int? { | ||
| 79 | guard start + 11 < limit, chars[start] == "<" || chars[start] == "[" else { return nil } | ||
| 80 | for (k, c) in "dddd-dd-dd".unicodeScalars.enumerated() { | ||
| 81 | let actual = chars[start + 1 + k] | ||
| 82 | guard c == "d" ? isASCIIDigit(actual) : actual == c else { return nil } | ||
| 83 | } | ||
| 84 | var j = start + 11 | ||
| 85 | if chars[j] == "]" || chars[j] == ">" { return j + 1 } | ||
| 86 | guard chars[j] == " " else { return nil } | ||
| 87 | j += 1 | ||
| 88 | while j < limit, chars[j] != "\n" { | ||
| 89 | if chars[j] == "]" || chars[j] == ">" { return j + 1 } | ||
| 90 | j += 1 | ||
| 91 | } | ||
| 92 | return nil | ||
| 93 | } | ||
| 94 | |||
| 64 | /// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive. | 95 | /// `<YYYY-MM-DD DAY HH:MM-HH:MM REPEATER WARNING>`, or the same in `[...]` for inactive. |
| 65 | private func scanSingleTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? { | 96 | private func scanSingleTimestamp(_ chars: [Unicode.Scalar], at start: Int, limit: Int) -> (stamp: Timestamp, end: Int)? { |
| 66 | guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil } | 97 | guard start < limit, chars[start] == "<" || chars[start] == "[" else { return nil } |
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -42,6 +42,7 @@ public enum EditorCommands { | |||
| 42 | EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app), | 42 | EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app), |
| 43 | EditorCommand(id: "app.refile", title: "Refile…", action: .app), | 43 | EditorCommand(id: "app.refile", title: "Refile…", action: .app), |
| 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), | 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), |
| 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), | ||
| 45 | 46 | ||
| 46 | movement("forward-char", "Forward Character", "moveForward:"), | 47 | movement("forward-char", "Forward Character", "moveForward:"), |
| 47 | movement("backward-char", "Backward Character", "moveBackward:"), | 48 | movement("backward-char", "Backward Character", "moveBackward:"), |
Sources/Orgstar/AgendaView.swift added +190
| @@ -0,0 +1,190 @@ | |||
| 1 | import OrgApp | ||
| 2 | import OrgCore | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// The agenda window: `org-agenda-list` over the agenda files. Return or a double click jumps | ||
| 6 | /// to the entry in the main window; f/b move a span, `.` returns to today, g refreshes. | ||
| 7 | struct AgendaView: View { | ||
| 8 | let workspace: WorkspaceModel | ||
| 9 | let session: DocumentSession | ||
| 10 | let commands: AppCommands | ||
| 11 | @State private var agenda: AgendaModel | ||
| 12 | @State private var selection: String? | ||
| 13 | @State private var tick = 0 | ||
| 14 | @AppStorage("agendaSpan") private var span = 10 | ||
| 15 | @AppStorage("agendaStartOffset") private var startOffset = -3 | ||
| 16 | @AppStorage("agendaSubfolders") private var includeSubfolders = false | ||
| 17 | @Environment(\.openWindow) private var openWindow | ||
| 18 | |||
| 19 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) { | ||
| 20 | self.workspace = workspace | ||
| 21 | self.session = session | ||
| 22 | self.commands = commands | ||
| 23 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) | ||
| 24 | } | ||
| 25 | |||
| 26 | private struct RefreshKey: Hashable { | ||
| 27 | var start: Int? | ||
| 28 | var span: Int | ||
| 29 | var startOffset: Int | ||
| 30 | var includeSubfolders: Bool | ||
| 31 | var files: Int | ||
| 32 | var changed: Int? | ||
| 33 | var edits: Int | ||
| 34 | var path: String? | ||
| 35 | var tick: Int | ||
| 36 | } | ||
| 37 | |||
| 38 | private var refreshKey: RefreshKey { | ||
| 39 | RefreshKey( | ||
| 40 | start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, | ||
| 41 | files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, | ||
| 42 | path: session.path, tick: tick | ||
| 43 | ) | ||
| 44 | } | ||
| 45 | |||
| 46 | var body: some View { | ||
| 47 | List(selection: $selection) { | ||
| 48 | ForEach(agenda.days, id: \.day) { day in | ||
| 49 | Section { | ||
| 50 | ForEach(Array(day.items.enumerated()), id: \.offset) { index, item in | ||
| 51 | AgendaRow(item: item) | ||
| 52 | .tag(Self.id(day, index)) | ||
| 53 | .selectionDisabled(item.path == nil) | ||
| 54 | } | ||
| 55 | } header: { | ||
| 56 | Text(day.header) | ||
| 57 | .font(.headline) | ||
| 58 | .foregroundStyle(day.isToday ? Color.accentColor : .primary) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | .contextMenu(forSelectionType: String.self) { _ in | ||
| 63 | } primaryAction: { ids in | ||
| 64 | if let id = ids.first { jump(id) } | ||
| 65 | } | ||
| 66 | .onKeyPress(.return) { | ||
| 67 | guard let selection else { return .ignored } | ||
| 68 | jump(selection) | ||
| 69 | return .handled | ||
| 70 | } | ||
| 71 | .onKeyPress(characters: CharacterSet(charactersIn: "fb.gr")) { press in | ||
| 72 | switch press.characters { | ||
| 73 | case "f": agenda.shift(forward: true) | ||
| 74 | case "b": agenda.shift(forward: false) | ||
| 75 | case ".": agenda.showToday() | ||
| 76 | default: tick += 1 | ||
| 77 | } | ||
| 78 | return .handled | ||
| 79 | } | ||
| 80 | .toolbar { | ||
| 81 | ToolbarItemGroup { | ||
| 82 | if agenda.isComputing { ProgressView().controlSize(.small) } | ||
| 83 | Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) } | ||
| 84 | .help("Previous \(span) days (b)") | ||
| 85 | Button("Today") { agenda.showToday() } | ||
| 86 | .help("Back to today (.)") | ||
| 87 | Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) } | ||
| 88 | .help("Next \(span) days (f)") | ||
| 89 | } | ||
| 90 | } | ||
| 91 | .overlay { | ||
| 92 | if agenda.files.isEmpty { | ||
| 93 | ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add.")) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | .navigationTitle("Agenda") | ||
| 97 | .task(id: refreshKey) { | ||
| 98 | agenda.span = span | ||
| 99 | agenda.startOffset = startOffset | ||
| 100 | agenda.includeSubfolders = includeSubfolders | ||
| 101 | let open = session.path.flatMap { path in session.document.map { (path, $0.text) } } | ||
| 102 | await agenda.refresh(open: open) | ||
| 103 | } | ||
| 104 | .task { | ||
| 105 | // The time grid's "now" line and the day itself move on. | ||
| 106 | while !Task.isCancelled { | ||
| 107 | try? await Task.sleep(for: .seconds(60)) | ||
| 108 | tick += 1 | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | private static func id(_ day: AgendaDay, _ index: Int) -> String { "\(day.day):\(index)" } | ||
| 114 | |||
| 115 | private func jump(_ id: String) { | ||
| 116 | let parts = id.split(separator: ":").compactMap { Int($0) } | ||
| 117 | guard parts.count == 2, let day = agenda.days.first(where: { $0.day == parts[0] }), parts[1] < day.items.count else { return } | ||
| 118 | let item = day.items[parts[1]] | ||
| 119 | guard let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return } | ||
| 120 | commands.jump(path: path, offset: offset) | ||
| 121 | openWindow(id: "main") | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | struct AgendaRow: View { | ||
| 126 | let item: AgendaItem | ||
| 127 | |||
| 128 | var body: some View { | ||
| 129 | switch item.kind { | ||
| 130 | case .timeGrid: | ||
| 131 | HStack(spacing: 8) { | ||
| 132 | Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) | ||
| 133 | .monospacedDigit() | ||
| 134 | .frame(width: 140, alignment: .trailing) | ||
| 135 | VStack { Divider() } | ||
| 136 | } | ||
| 137 | .font(.caption) | ||
| 138 | .foregroundStyle(.tertiary) | ||
| 139 | case .currentTime: | ||
| 140 | HStack(spacing: 8) { | ||
| 141 | Text("now " + item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) | ||
| 142 | .monospacedDigit() | ||
| 143 | .frame(width: 140, alignment: .trailing) | ||
| 144 | Rectangle().fill(Color.accentColor).frame(height: 1) | ||
| 145 | } | ||
| 146 | .font(.caption) | ||
| 147 | .foregroundStyle(Color.accentColor) | ||
| 148 | default: | ||
| 149 | HStack(spacing: 8) { | ||
| 150 | Text(item.category) | ||
| 151 | .lineLimit(1) | ||
| 152 | .foregroundStyle(.secondary) | ||
| 153 | .frame(width: 80, alignment: .leading) | ||
| 154 | Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) | ||
| 155 | .monospacedDigit() | ||
| 156 | .frame(width: 80, alignment: .leading) | ||
| 157 | if !item.extra.isEmpty { | ||
| 158 | Text(item.extra.trimmingCharacters(in: .whitespaces)) | ||
| 159 | .monospacedDigit() | ||
| 160 | .foregroundStyle(leaderColor) | ||
| 161 | } | ||
| 162 | title | ||
| 163 | .lineLimit(1) | ||
| 164 | Spacer(minLength: 8) | ||
| 165 | if !item.tags.isEmpty { | ||
| 166 | Text(":" + item.tags.joined(separator: ":") + ":") | ||
| 167 | .font(.caption) | ||
| 168 | .foregroundStyle(.secondary) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | private var leaderColor: Color { | ||
| 175 | switch item.kind { | ||
| 176 | case .deadline: .red | ||
| 177 | case .upcomingDeadline: .orange | ||
| 178 | case .pastScheduled: .brown | ||
| 179 | default: .secondary | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | /// The TODO keyword colored as in the editor. | ||
| 184 | private var title: Text { | ||
| 185 | guard let todo = item.todo, item.text.hasPrefix(todo + " ") else { return Text(item.text.trimmingCharacters(in: .whitespaces)) } | ||
| 186 | let text = item.text.trimmingCharacters(in: .whitespaces) | ||
| 187 | let rest = String(text.dropFirst(todo.count)) | ||
| 188 | return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary) | ||
| 189 | } | ||
| 190 | } | ||
Sources/Orgstar/ContentView.swift +14
| @@ -29,6 +29,7 @@ struct ContentView: View { | |||
| 29 | @State private var selectedFile: String? | 29 | @State private var selectedFile: String? |
| 30 | @State private var query = "" | 30 | @State private var query = "" |
| 31 | @FocusState private var searchFocused: Bool | 31 | @FocusState private var searchFocused: Bool |
| 32 | @Environment(\.openWindow) private var openWindow | ||
| 32 | 33 | ||
| 33 | var body: some View { | 34 | var body: some View { |
| 34 | NavigationSplitView { | 35 | NavigationSplitView { |
| @@ -101,6 +102,15 @@ struct ContentView: View { | |||
| 101 | .onChange(of: workspace.changedFile?.version) { | 102 | .onChange(of: workspace.changedFile?.version) { |
| 102 | if let path = workspace.changedFile?.path { session.fileChanged(path) } | 103 | if let path = workspace.changedFile?.path { session.fileChanged(path) } |
| 103 | } | 104 | } |
| 105 | .onChange(of: commands.jumpRequest?.id) { | ||
| 106 | guard let request = commands.jumpRequest else { return } | ||
| 107 | if request.path != session.path { | ||
| 108 | guard UnsavedChanges.resolve(session) else { return } | ||
| 109 | session.open(request.path) | ||
| 110 | selectedFile = request.path | ||
| 111 | } | ||
| 112 | session.reveal(request.offset) | ||
| 113 | } | ||
| 104 | .onChange(of: commands.focusSearch) { _, focus in | 114 | .onChange(of: commands.focusSearch) { _, focus in |
| 105 | if focus { | 115 | if focus { |
| 106 | searchFocused = true | 116 | searchFocused = true |
| @@ -130,6 +140,8 @@ struct ContentView: View { | |||
| 130 | .task { | 140 | .task { |
| 131 | DevelopmentHooks.scheduleSnapshot() | 141 | DevelopmentHooks.scheduleSnapshot() |
| 132 | if let path = DevelopmentHooks.fileToOpen { selectedFile = path } | 142 | if let path = DevelopmentHooks.fileToOpen { selectedFile = path } |
| 143 | if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) } | ||
| 144 | if let id = DevelopmentHooks.windowToOpen { openWindow(id: id) } | ||
| 133 | await workspace.refresh() | 145 | await workspace.refresh() |
| 134 | workspace.startWatching() | 146 | workspace.startWatching() |
| 135 | } | 147 | } |
| @@ -163,6 +175,8 @@ struct ContentView: View { | |||
| 163 | } | 175 | } |
| 164 | case "app.archive": | 176 | case "app.archive": |
| 165 | if let message = session.archive() { commands.show(message) } | 177 | if let message = session.archive() { commands.show(message) } |
| 178 | case "app.agenda": | ||
| 179 | openWindow(id: "agenda") | ||
| 166 | default: | 180 | default: |
| 167 | if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } | 181 | if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } |
| 168 | } | 182 | } |
Sources/Orgstar/DevelopmentHooks.swift +7 −1
| @@ -3,11 +3,15 @@ import AppKit | |||
| 3 | /// Switches for checking the app from scripts, read from the environment: | 3 | /// Switches for checking the app from scripts, read from the environment: |
| 4 | /// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`). | 4 | /// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`). |
| 5 | /// - `ORGSTAR_OPEN`: a file to select at launch. | 5 | /// - `ORGSTAR_OPEN`: a file to select at launch. |
| 6 | /// - `ORGSTAR_ROOT`: a folder to add at launch. | ||
| 7 | /// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`); the snapshot renders it. | ||
| 6 | /// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main | 8 | /// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main |
| 7 | /// window to this PNG path and quit. Rendering from inside the app needs no screen-recording | 9 | /// window to this PNG path and quit. Rendering from inside the app needs no screen-recording |
| 8 | /// permission. | 10 | /// permission. |
| 9 | enum DevelopmentHooks { | 11 | enum DevelopmentHooks { |
| 10 | static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] } | 12 | static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] } |
| 13 | static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] } | ||
| 14 | static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] } | ||
| 11 | 15 | ||
| 12 | @MainActor | 16 | @MainActor |
| 13 | static func scheduleSnapshot() { | 17 | static func scheduleSnapshot() { |
| @@ -15,7 +19,9 @@ enum DevelopmentHooks { | |||
| 15 | guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } | 19 | guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } |
| 16 | let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 | 20 | let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 |
| 17 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { | 21 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { |
| 18 | if let view = NSApp.windows.first(where: \.isVisible)?.contentView?.superview, | 22 | let visible = NSApp.windows.filter(\.isVisible) |
| 23 | let window = windowToOpen.flatMap { id in visible.first { $0.identifier?.rawValue.hasPrefix(id) == true } } ?? visible.first | ||
| 24 | if let view = window?.contentView?.superview, | ||
| 19 | let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { | 25 | let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { |
| 20 | view.cacheDisplay(in: view.bounds, to: bitmap) | 26 | view.cacheDisplay(in: view.bounds, to: bitmap) |
| 21 | try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) | 27 | try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) |
Sources/Orgstar/OrgstarApp.swift +34
| @@ -13,6 +13,9 @@ final class AppCommands { | |||
| 13 | var focusSearch = false | 13 | var focusSearch = false |
| 14 | var showingConflict = false | 14 | var showingConflict = false |
| 15 | var showingPalette = false | 15 | var showingPalette = false |
| 16 | /// A location another window asked the main window to show. | ||
| 17 | private(set) var jumpRequest: (path: String, offset: Int, id: Int)? | ||
| 18 | private var jumpID = 0 | ||
| 16 | /// The editor showing the open file, for menus and the palette. | 19 | /// The editor showing the open file, for menus and the palette. |
| 17 | var editor: OrgEditor? | 20 | var editor: OrgEditor? |
| 18 | /// The pending key prefix, and what can follow it. | 21 | /// The pending key prefix, and what can follow it. |
| @@ -36,6 +39,11 @@ final class AppCommands { | |||
| 36 | request.reply(text) | 39 | request.reply(text) |
| 37 | } | 40 | } |
| 38 | 41 | ||
| 42 | func jump(path: String, offset: Int) { | ||
| 43 | jumpID += 1 | ||
| 44 | jumpRequest = (path, offset, jumpID) | ||
| 45 | } | ||
| 46 | |||
| 39 | func show(_ text: String) { | 47 | func show(_ text: String) { |
| 40 | messageID += 1 | 48 | messageID += 1 |
| 41 | let id = messageID | 49 | let id = messageID |
| @@ -111,6 +119,12 @@ struct OrgstarApp: App { | |||
| 111 | } | 119 | } |
| 112 | } | 120 | } |
| 113 | .defaultSize(width: 1280, height: 860) | 121 | .defaultSize(width: 1280, height: 860) |
| 122 | Window("Agenda", id: "agenda") { | ||
| 123 | if case .success(let model) = workspace { | ||
| 124 | AgendaView(workspace: model, session: session, commands: commands) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | .defaultSize(width: 760, height: 720) | ||
| 114 | .commands { | 128 | .commands { |
| 115 | CommandGroup(after: .newItem) { | 129 | CommandGroup(after: .newItem) { |
| 116 | Button("Add Folder…") { commands.showingFolderPicker = true } | 130 | Button("Add Folder…") { commands.showingFolderPicker = true } |
| @@ -131,6 +145,9 @@ struct OrgstarApp: App { | |||
| 131 | Button("Search Notes") { commands.focusSearch = true } | 145 | Button("Search Notes") { commands.focusSearch = true } |
| 132 | .keyboardShortcut("f", modifiers: [.command, .shift]) | 146 | .keyboardShortcut("f", modifiers: [.command, .shift]) |
| 133 | } | 147 | } |
| 148 | CommandGroup(before: .windowList) { | ||
| 149 | AgendaMenuItem() | ||
| 150 | } | ||
| 134 | } | 151 | } |
| 135 | .commands { | 152 | .commands { |
| 136 | CommandMenu("Org") { | 153 | CommandMenu("Org") { |
| @@ -146,6 +163,16 @@ struct OrgstarApp: App { | |||
| 146 | } | 163 | } |
| 147 | } | 164 | } |
| 148 | 165 | ||
| 166 | /// Window ▸ Agenda. | ||
| 167 | struct AgendaMenuItem: View { | ||
| 168 | @Environment(\.openWindow) private var openWindow | ||
| 169 | |||
| 170 | var body: some View { | ||
| 171 | Button("Agenda") { openWindow(id: "agenda") } | ||
| 172 | .keyboardShortcut("a", modifiers: [.command, .shift]) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 149 | struct SettingsView: View { | 176 | struct SettingsView: View { |
| 150 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | 177 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 151 | @AppStorage("keymap") private var preset = KeymapPreset.emacs | 178 | @AppStorage("keymap") private var preset = KeymapPreset.emacs |
| @@ -158,6 +185,9 @@ struct SettingsView: View { | |||
| 158 | @AppStorage("hiddenMarkers") private var hiddenMarkers = true | 185 | @AppStorage("hiddenMarkers") private var hiddenMarkers = true |
| 159 | @AppStorage("prettyEntities") private var prettyEntities = true | 186 | @AppStorage("prettyEntities") private var prettyEntities = true |
| 160 | @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords | 187 | @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords |
| 188 | @AppStorage("agendaSpan") private var agendaSpan = 10 | ||
| 189 | @AppStorage("agendaStartOffset") private var agendaStartOffset = -3 | ||
| 190 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false | ||
| 161 | 191 | ||
| 162 | var body: some View { | 192 | var body: some View { |
| 163 | Form { | 193 | Form { |
| @@ -197,6 +227,10 @@ struct SettingsView: View { | |||
| 197 | Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.") | 227 | Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.") |
| 198 | .font(.caption) | 228 | .font(.caption) |
| 199 | .foregroundStyle(.secondary) | 229 | .foregroundStyle(.secondary) |
| 230 | Divider() | ||
| 231 | Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31) | ||
| 232 | Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0) | ||
| 233 | Toggle("Agenda includes files in subfolders", isOn: $agendaSubfolders) | ||
| 200 | } | 234 | } |
| 201 | .padding(20) | 235 | .padding(20) |
| 202 | .frame(width: 480) | 236 | .frame(width: 480) |
Tests/OrgAppTests/AppTests.swift +33
| @@ -273,3 +273,36 @@ enum StateBufferCaret { | |||
| 273 | session.attach(buffer) | 273 | session.attach(buffer) |
| 274 | } | 274 | } |
| 275 | } | 275 | } |
| 276 | |||
| 277 | @MainActor | ||
| 278 | struct AgendaModelTests { | ||
| 279 | @Test func topLevelFilesAndTheOpenBuffer() async throws { | ||
| 280 | let notes = try Folder() | ||
| 281 | try notes.write("a.org", "* TODO Pay rent\nSCHEDULED: <2026-10-05 Mon>\n") | ||
| 282 | try notes.write("sub/b.org", "* Hidden <2026-10-05 Mon>\n") | ||
| 283 | try notes.write("old.org_archive", "* Archived <2026-10-05 Mon>\n") | ||
| 284 | let model = try WorkspaceModel(directory: nil) | ||
| 285 | try await model.addRoot(notes.url) | ||
| 286 | let agenda = AgendaModel(workspace: model) | ||
| 287 | var utc = Calendar(identifier: .gregorian) | ||
| 288 | utc.timeZone = TimeZone(identifier: "UTC")! | ||
| 289 | let now = try #require(utc.date(from: DateComponents(year: 2026, month: 10, day: 5, hour: 9))) | ||
| 290 | |||
| 291 | await agenda.refresh(open: nil, now: now, calendar: utc) | ||
| 292 | #expect(agenda.days.count == 10) | ||
| 293 | #expect(agenda.days[3].isToday) | ||
| 294 | #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent"]) | ||
| 295 | |||
| 296 | agenda.includeSubfolders = true | ||
| 297 | await agenda.refresh(open: nil, now: now, calendar: utc) | ||
| 298 | #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent", "Hidden "]) | ||
| 299 | |||
| 300 | let path = try #require(agenda.files.first { $0.hasSuffix("/a.org") }) | ||
| 301 | await agenda.refresh(open: (path, "* DONE Pay rent\nSCHEDULED: <2026-10-05 Mon>\n"), now: now, calendar: utc) | ||
| 302 | #expect(agenda.days[3].items.first?.text == "DONE Pay rent") | ||
| 303 | |||
| 304 | agenda.shift(forward: true, now: now, calendar: utc) | ||
| 305 | await agenda.refresh(open: nil, now: now, calendar: utc) | ||
| 306 | #expect(agenda.days.first?.day == Days.absolute(year: 2026, month: 10, day: 12)) | ||
| 307 | } | ||
| 308 | } | ||
Tests/OrgCoreTests/AgendaTests.swift added +241
| @@ -0,0 +1,241 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | /// Runs `org-agenda-list` over files in `emacs -Q --batch` with the clock frozen. | ||
| 6 | enum AgendaOracle { | ||
| 7 | struct Line: Codable { | ||
| 8 | var line: String | ||
| 9 | var type: String? | ||
| 10 | var category: String? | ||
| 11 | var tod: Int? | ||
| 12 | var extra: String? | ||
| 13 | var urgency: Int? | ||
| 14 | var file: String? | ||
| 15 | var pos: Int? | ||
| 16 | } | ||
| 17 | |||
| 18 | struct Day: Codable { | ||
| 19 | var header: String | ||
| 20 | var items: [Line] | ||
| 21 | } | ||
| 22 | |||
| 23 | static let keywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL" | ||
| 24 | |||
| 25 | static func script(files: [String], start: String, span: Int, seconds: Int) -> String { | ||
| 26 | let fileList = files.map { "\"\($0)\"" }.joined(separator: " ") | ||
| 27 | let words = keywords.split(separator: " ").map { "\"\($0)\"" }.joined(separator: " ") | ||
| 28 | return #""" | ||
| 29 | ;;; -*- lexical-binding: t -*- | ||
| 30 | (require 'org) | ||
| 31 | (require 'org-agenda) | ||
| 32 | (require 'json) | ||
| 33 | (setq org-todo-keywords '((sequence \#(words))) | ||
| 34 | org-agenda-files '(\#(fileList))) | ||
| 35 | (add-to-list 'auto-mode-alist '("\\.org_archive\\'" . org-mode)) | ||
| 36 | (defun orgstar-dump () | ||
| 37 | (let (days header items) | ||
| 38 | (goto-char (point-min)) | ||
| 39 | (while (not (eobp)) | ||
| 40 | (let* ((p (line-beginning-position)) | ||
| 41 | (line (buffer-substring-no-properties p (line-end-position))) | ||
| 42 | (hd (get-text-property p 'org-hd-marker))) | ||
| 43 | (cond | ||
| 44 | ((get-text-property p 'org-agenda-date-header) | ||
| 45 | (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) | ||
| 46 | (setq header line items nil)) | ||
| 47 | (header | ||
| 48 | (push `((line . ,line) | ||
| 49 | (type . ,(get-text-property p 'type)) | ||
| 50 | (category . ,(get-text-property p 'org-category)) | ||
| 51 | (tod . ,(get-text-property p 'time-of-day)) | ||
| 52 | (extra . ,(get-text-property p 'extra)) | ||
| 53 | (urgency . ,(get-text-property p 'urgency)) | ||
| 54 | (file . ,(and hd (buffer-file-name (marker-buffer hd)))) | ||
| 55 | (pos . ,(and hd (marker-position hd)))) | ||
| 56 | items)))) | ||
| 57 | (forward-line 1)) | ||
| 58 | (when header (push `((header . ,header) (items . ,(vconcat (nreverse items)))) days)) | ||
| 59 | (vconcat (nreverse days)))) | ||
| 60 | \#(frozenClock(seconds, """ | ||
| 61 | (progn | ||
| 62 | (org-agenda-list nil "\(start)" \(span)) | ||
| 63 | (let ((result (with-current-buffer org-agenda-buffer-name (orgstar-dump)))) | ||
| 64 | (let ((coding-system-for-write 'utf-8-unix)) | ||
| 65 | (with-temp-file (getenv "ORACLE_OUTPUT") | ||
| 66 | (insert (json-encode result)))))) | ||
| 67 | """)) | ||
| 68 | """# | ||
| 69 | } | ||
| 70 | |||
| 71 | static func run(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int, minute: Int) throws -> [Day] { | ||
| 72 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-agenda-\(UUID().uuidString)") | ||
| 73 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | ||
| 74 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 75 | var paths: [String] = [] | ||
| 76 | for file in files { | ||
| 77 | let url = folder.appendingPathComponent(file.name) | ||
| 78 | try file.text.write(to: url, atomically: true, encoding: .utf8) | ||
| 79 | paths.append(url.resolvingSymlinksInPath().path) | ||
| 80 | } | ||
| 81 | let date = Days.date(start) | ||
| 82 | let seconds = (today - 719163) * 86400 + hour * 3600 + minute * 60 | ||
| 83 | let scriptURL = folder.appendingPathComponent("agenda.el") | ||
| 84 | let output = folder.appendingPathComponent("output.json") | ||
| 85 | try script(files: paths, start: String(format: "%04d-%02d-%02d", date.year, date.month, date.day), span: span, seconds: seconds) | ||
| 86 | .write(to: scriptURL, atomically: true, encoding: .utf8) | ||
| 87 | let log = try EmacsOracle.run(["-Q", "--batch", "-l", scriptURL.path], environment: ["ORACLE_OUTPUT": output.path, "TZ": "UTC"]) | ||
| 88 | guard let data = try? Data(contentsOf: output) else { throw OracleError(log: log) } | ||
| 89 | let days = try JSONDecoder().decode([Day].self, from: data) | ||
| 90 | // Positions as UTF-16 offsets, files by name. | ||
| 91 | return days.map { day in | ||
| 92 | Day(header: day.header, items: day.items.map { line in | ||
| 93 | var line = line | ||
| 94 | if let file = line.file, let pos = line.pos, let index = paths.firstIndex(of: URL(fileURLWithPath: file).resolvingSymlinksInPath().path) { | ||
| 95 | line.file = files[index].name | ||
| 96 | line.pos = EmacsOracle.offset(pos, in: files[index].text) | ||
| 97 | } | ||
| 98 | return line | ||
| 99 | }) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | struct OracleError: Error { let log: String } | ||
| 104 | |||
| 105 | /// Tags realigned to one space, as their column depends on the window. | ||
| 106 | static func normalize(_ line: String) -> String { | ||
| 107 | line.replacingOccurrences(of: "[ \\t]+(:[^ \\t]+:)[ \\t]*$", with: " $1", options: .regularExpression) | ||
| 108 | } | ||
| 109 | |||
| 110 | static func compare(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30) throws { | ||
| 111 | let emacs = try run(files: files, start: start, span: span, today: today, hour: hour, minute: minute) | ||
| 112 | let defaults = OrgSettings.default.withTodoKeywords(keywords)! | ||
| 113 | let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) } | ||
| 114 | let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: .terminal) | ||
| 115 | #expect(ours.count == emacs.count) | ||
| 116 | for (o, e) in zip(ours, emacs) { | ||
| 117 | #expect(o.header == e.header) | ||
| 118 | let theirs = e.items.map { item in | ||
| 119 | "\(normalize(item.line)) | \(item.type ?? "-") \(item.category ?? "") \(item.tod.map(String.init) ?? "-") [\(item.extra ?? "")] \(item.urgency ?? 0) \(item.file ?? "-")@\(item.pos.map(String.init) ?? "-")" | ||
| 120 | } | ||
| 121 | let mine = o.items.map { item in | ||
| 122 | let type: String = switch item.kind { | ||
| 123 | case .timeGrid, .currentTime: "-" | ||
| 124 | default: item.kind.rawValue | ||
| 125 | } | ||
| 126 | return "\(normalize(item.line)) | \(type) \(item.category) \(item.timeOfDay.map(String.init) ?? "-") [\(item.extra)] \(item.urgency) \(item.path ?? "-")@\(item.headingOffset.map(String.init) ?? "-")" | ||
| 127 | } | ||
| 128 | #expect(mine == theirs, "\(e.header) today \(Days.date(today))") | ||
| 129 | } | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | struct AgendaTests { | ||
| 134 | @Test func days() { | ||
| 135 | #expect(Days.absolute(year: 1970, month: 1, day: 1) == 719163) | ||
| 136 | #expect(Days.absolute(year: 2026, month: 10, day: 5) - Days.absolute(year: 2026, month: 9, day: 30) == 5) | ||
| 137 | #expect(Days.weekday(Days.absolute(year: 2026, month: 10, day: 5)) == 1) | ||
| 138 | #expect(Days.isoWeek(Days.absolute(year: 2026, month: 10, day: 5)) == 41) | ||
| 139 | #expect(Days.isoWeek(Days.absolute(year: 2027, month: 1, day: 1)) == 53) | ||
| 140 | let d = Days.date(Days.absolute(year: 2024, month: 2, day: 29)) | ||
| 141 | #expect(d.year == 2024 && d.month == 2 && d.day == 29) | ||
| 142 | #expect(Days.date(Days.absolute(year: 2026, month: 2, day: 31)) == (2026, 3, 3)) | ||
| 143 | #expect(Days.date(Days.absolute(year: 2026, month: 13, day: 1)) == (2027, 1, 1)) | ||
| 144 | } | ||
| 145 | |||
| 146 | static let synthetic = """ | ||
| 147 | #+TODO: TODO NEXT | DONE | ||
| 148 | #+FILETAGS: :ft: | ||
| 149 | Before the first heading <2026-10-05 Mon>. | ||
| 150 | * TODO [#A] Pay rent :home: | ||
| 151 | SCHEDULED: <2026-10-01 Thu> | ||
| 152 | * Meeting 10:00 with Bob | ||
| 153 | <2026-10-05 Mon 14:00-15:00> | ||
| 154 | * DONE Old | ||
| 155 | DEADLINE: <2026-10-03 Sat> | ||
| 156 | * NEXT Report :work: | ||
| 157 | DEADLINE: <2026-10-12 Mon -3d> | ||
| 158 | * NEXT Soon | ||
| 159 | DEADLINE: <2026-10-09 Fri> | ||
| 160 | * TODO Overdue | ||
| 161 | DEADLINE: <2026-09-20 Sun 08:15> | ||
| 162 | * Trip | ||
| 163 | <2026-10-04 Sun>--<2026-10-07 Wed> | ||
| 164 | * Same day range | ||
| 165 | <2026-10-06 Tue 10:00>--<2026-10-06 Tue 12:30> | ||
| 166 | * Gym | ||
| 167 | <2026-09-01 Tue 07:00 +1w> | ||
| 168 | ** Child | ||
| 169 | :PROPERTIES: | ||
| 170 | :CATEGORY: kid | ||
| 171 | :END: | ||
| 172 | SCHEDULED: <2026-10-06 Tue> | ||
| 173 | *** Grandchild :x: | ||
| 174 | <2026-10-07 Wed 9am> | ||
| 175 | * TODO Water plants | ||
| 176 | SCHEDULED: <2026-09-28 Mon .+2d> | ||
| 177 | * TODO Monthly | ||
| 178 | SCHEDULED: <2026-08-31 Mon +1m> | ||
| 179 | * TODO Yearly | ||
| 180 | DEADLINE: <2025-10-08 Wed +1y> | ||
| 181 | * TODO Delayed | ||
| 182 | SCHEDULED: <2026-10-02 Fri -2d> | ||
| 183 | * Archived :ARCHIVE: | ||
| 184 | <2026-10-05 Mon> | ||
| 185 | * COMMENT Commented | ||
| 186 | <2026-10-05 Mon> | ||
| 187 | * Blocks | ||
| 188 | #+begin_src sh | ||
| 189 | echo <2026-10-05 Mon> | ||
| 190 | #+end_src | ||
| 191 | # <2026-10-05 Mon> | ||
| 192 | =<2026-10-06 Tue>= and [[https://example.com][<2026-10-06 Tue>]] | ||
| 193 | * [#C] Low priority <2026-10-06 Tue> | ||
| 194 | * TODO Late meeting 23:30 | ||
| 195 | SCHEDULED: <2026-10-05 Mon> | ||
| 196 | * Props | ||
| 197 | :PROPERTIES: | ||
| 198 | :WHEN: <2026-10-06 Tue 16:00> | ||
| 199 | :END: | ||
| 200 | * TODO Spaces :a:b: | ||
| 201 | <2026-10-06 Tue 11:00> | ||
| 202 | * Ünïcode tïtle :tag: | ||
| 203 | <2026-10-05 Mon 08:00> | ||
| 204 | * Range with times <2026-10-05 Mon 10:00-11:00> | ||
| 205 | * Hourly | ||
| 206 | <2026-10-04 Sun 22:00 +12h> | ||
| 207 | """ | ||
| 208 | |||
| 209 | static let second = """ | ||
| 210 | #+CATEGORY: other | ||
| 211 | * WAIT Second file | ||
| 212 | DEADLINE: <2026-10-05 Mon> | ||
| 213 | * Two stamps | ||
| 214 | <2026-10-05 Mon 13:00> and <2026-10-06 Tue> | ||
| 215 | """ | ||
| 216 | |||
| 217 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 218 | func matchesEmacs() throws { | ||
| 219 | let files = [("a.org", Self.synthetic), ("b.org", Self.second)] | ||
| 220 | for today in [(2026, 10, 5), (2026, 10, 1), (2026, 10, 8), (2026, 9, 29)] { | ||
| 221 | let day = Days.absolute(year: today.0, month: today.1, day: today.2) | ||
| 222 | try AgendaOracle.compare(files: files, start: day - 3, span: 10, today: day) | ||
| 223 | } | ||
| 224 | let day = Days.absolute(year: 2026, month: 10, day: 6) | ||
| 225 | try AgendaOracle.compare(files: files, start: day, span: 1, today: day) | ||
| 226 | try AgendaOracle.compare(files: files, start: day + 1, span: 1, today: day) | ||
| 227 | } | ||
| 228 | |||
| 229 | /// `ORGSTAR_ORACLE_CORPUS=<folder>`: the top-level files of the folder, ten days around | ||
| 230 | /// two dates. | ||
| 231 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) | ||
| 232 | func matchesEmacsOnACorpus() throws { | ||
| 233 | let root = URL(fileURLWithPath: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"]!) | ||
| 234 | let files = try FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) | ||
| 235 | .filter { $0.pathExtension == "org" }.sorted { $0.path < $1.path } | ||
| 236 | .compactMap { url in (try? String(contentsOf: url, encoding: .utf8)).map { (url.lastPathComponent, $0) } } | ||
| 237 | for today in [Days.absolute(Date(), calendar: .current), Days.absolute(year: 2025, month: 3, day: 14)] { | ||
| 238 | try AgendaOracle.compare(files: files, start: today - 3, span: 10, today: today) | ||
| 239 | } | ||
| 240 | } | ||
| 241 | } | ||
docs/plans/2026-10-05-agenda.md added +36
| @@ -0,0 +1,36 @@ | |||
| 1 | # Agenda 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:** The day/week agenda (`org-agenda-list`): which entries appear on which day, with org's leaders, times, categories, tags, sort order and time grid, checked line by line against Emacs; an agenda window in the app that jumps to entries. | ||
| 6 | |||
| 7 | **Architecture:** `OrgCore/Agenda` ports the collectors of org-agenda.el: `org-agenda-get-deadlines`, `-get-scheduled`, `-get-timestamps`, `-get-blocks`, `org-agenda-format-item` with the default prefix ` %i %-12:c%?-12t% s`, `org-agenda-highlight-todo`, `org-entries-lessp` with `(habit-down time-up urgency-down category-keep)`, `org-agenda-add-time-grid-maybe`, and the day headers of `org-agenda-format-date-aligned`. Repeaters go through `org-closest-date`. An `AgendaSource` is built per file from its text and tree (headings, planning, timestamps with positions, categories, skip ranges); the app caches sources by file hash and replaces them with the open session's text, which is the overlay the design asks of the index. The index rows don't carry positions or raw timestamp text, so the agenda reads sources rather than SQL. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle. | ||
| 10 | |||
| 11 | **Spec:** `docs/design.md`, "Phase 3", "Workspace, storage and index". | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - Oracle: `org-agenda-list` over temp files with the clock frozen; each line compared as text (tags realigned to one space) plus `type`, `org-category`, `time-of-day`, `extra`, `urgency` and the heading marker. | ||
| 16 | - Org defaults otherwise: 14 warning days, deadlines and scheduled items shown when done, future repeats shown, overdue items only on today, no habits (phase 6), no diary or diary sexps. | ||
| 17 | |||
| 18 | ## Defaults chosen (user may change) | ||
| 19 | |||
| 20 | - Doom's view: 10 days starting 3 days before today. | ||
| 21 | - Agenda files: the top-level `.org` files of each workspace root, as Doom's `org-agenda-files` is the org directory. | ||
| 22 | |||
| 23 | --- | ||
| 24 | |||
| 25 | ### Task 1: Engine | ||
| 26 | |||
| 27 | - [x] Absolute days, ISO weeks; `org-closest-date`; `org-get-wdays`. | ||
| 28 | - [x] `AgendaSource` from text; collectors; formatting; sorting; time grid; day headers. | ||
| 29 | - [x] Oracle over synthetic files (repeaters of every unit, warnings, delays, ranges, times in headings, priorities, categories, inherited tags, archived and commented trees, src blocks, done items) for several "today"s, and over the three corpora for a 10-day span. | ||
| 30 | - [x] Commit "Agenda engine". | ||
| 31 | |||
| 32 | ### Task 2: App | ||
| 33 | |||
| 34 | - [x] Agenda window (⌘⇧A, palette): day headers, items with category, time, leader, TODO, title, tags; previous/next span, today; click or RET jumps to the entry; refreshes on saves and open-document edits. | ||
| 35 | - [x] Settings: span, start offset, agenda folders. | ||
| 36 | - [x] Commit "Agenda view". | ||