Commit 359666677e
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +35 −20
| @@ -54,6 +54,37 @@ public final class AgendaModel { | ||
| 54 | 54 | startOffset = viewStart ?? startOffset |
| 55 | 55 | } |
| 56 | 56 | let first = start ?? today + startOffset |
| 57 | let options = self.options | |
| 58 | let mode = self.mode | |
| 59 | let all = await sources(open: open) | |
| 60 | let result = await Task.detached { | |
| 61 | var days: [AgendaDay] = [] | |
| 62 | var list: [AgendaItem] = [] | |
| 63 | switch mode { | |
| 64 | case .agenda: | |
| 65 | days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options) | |
| 66 | case .todo(let keywords): | |
| 67 | list = Agenda.todoList(all, keywords: keywords) | |
| 68 | case .match(let match, let todoOnly): | |
| 69 | list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar) | |
| 70 | } | |
| 71 | return (days, list) | |
| 72 | }.value | |
| 73 | guard id == generation else { return } | |
| 74 | days = result.0 | |
| 75 | list = result.1 | |
| 76 | isComputing = false | |
| 77 | } | |
| 78 | ||
| 79 | /// Reminders for the timed entries of the next `days` days. | |
| 80 | public func reminders(open: (path: String, text: String)?, now: Date = Date(), calendar: Calendar = .current, days: Int = 7, lead: Int = 12) async -> [Reminder] { | |
| 81 | let all = await sources(open: open) | |
| 82 | return await Task.detached { Reminders.upcoming(all, now: now, calendar: calendar, days: days, lead: lead) }.value | |
| 83 | } | |
| 84 | ||
| 85 | /// The agenda files' sources: the open file's from its text, the others from the cache | |
| 86 | /// while their modification date and size are unchanged. | |
| 87 | private func sources(open: (path: String, text: String)?) async -> [AgendaSource] { | |
| 57 | 88 | let defaults = workspace.settings.org |
| 58 | 89 | var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = [] |
| 59 | 90 | for path in files { |
| @@ -67,9 +98,7 @@ public final class AgendaModel { | ||
| 67 | 98 | let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil } |
| 68 | 99 | reads.append((path, cached, mtime, size, nil)) |
| 69 | 100 | } |
| 70 | let options = self.options | |
| 71 | let mode = self.mode | |
| 72 | let result = await Task.detached { | |
| 101 | let loaded = await Task.detached { | |
| 73 | 102 | var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = [] |
| 74 | 103 | for read in reads { |
| 75 | 104 | if let cached = read.cached { |
| @@ -81,26 +110,12 @@ public final class AgendaModel { | ||
| 81 | 110 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true)) |
| 82 | 111 | } |
| 83 | 112 | } |
| 84 | let all = sources.map(\.source) | |
| 85 | var days: [AgendaDay] = [] | |
| 86 | var list: [AgendaItem] = [] | |
| 87 | switch mode { | |
| 88 | case .agenda: | |
| 89 | days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options) | |
| 90 | case .todo(let keywords): | |
| 91 | list = Agenda.todoList(all, keywords: keywords) | |
| 92 | case .match(let match, let todoOnly): | |
| 93 | list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar) | |
| 94 | } | |
| 95 | return (sources, days, list) | |
| 113 | return sources | |
| 96 | 114 | }.value |
| 97 | guard id == generation else { return } | |
| 98 | for source in result.0 where source.fromDisk { | |
| 115 | for source in loaded where source.fromDisk { | |
| 99 | 116 | cache[source.path] = (source.mtime, source.size, source.source) |
| 100 | 117 | } |
| 101 | days = result.1 | |
| 102 | list = result.2 | |
| 103 | isComputing = false | |
| 118 | return loaded.map(\.source) | |
| 104 | 119 | } |
| 105 | 120 | |
| 106 | 121 | public func showToday() { start = nil } |
Sources/OrgApp/ReminderScheduler.swift added +95
| @@ -0,0 +1,95 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | import OrgCore | |
| 4 | #if canImport(UserNotifications) | |
| 5 | import UserNotifications | |
| 6 | #endif | |
| 7 | ||
| 8 | /// Where reminders are delivered: the system's notification center, or a fake in tests. | |
| 9 | public protocol ReminderCenter: Sendable { | |
| 10 | /// Whether notifications may be shown, asking the user the first time. | |
| 11 | func authorize() async -> Bool | |
| 12 | /// IDs of pending requests. | |
| 13 | func pendingIDs() async -> [String] | |
| 14 | func add(_ reminder: Reminder) async throws | |
| 15 | func remove(_ ids: [String]) | |
| 16 | } | |
| 17 | ||
| 18 | /// Keeps the pending notifications equal to the reminders the agenda gives. | |
| 19 | @MainActor | |
| 20 | @Observable | |
| 21 | public final class ReminderScheduler { | |
| 22 | /// The last event a reminder is set for, so the agenda can say how far ahead they go. | |
| 23 | public private(set) var scheduledThrough: Date? | |
| 24 | /// False when the user turned notifications off for the app. | |
| 25 | public private(set) var isAuthorized = true | |
| 26 | ||
| 27 | private let center: ReminderCenter | |
| 28 | ||
| 29 | public init(center: ReminderCenter) { | |
| 30 | self.center = center | |
| 31 | } | |
| 32 | ||
| 33 | /// Adds reminders that are new, removes ours that no longer apply, leaves the rest. | |
| 34 | public func reconcile(_ reminders: [Reminder]) async { | |
| 35 | isAuthorized = await center.authorize() | |
| 36 | guard isAuthorized else { | |
| 37 | scheduledThrough = nil | |
| 38 | return | |
| 39 | } | |
| 40 | let wanted = Set(reminders.map(\.id)) | |
| 41 | let pending = Set(await center.pendingIDs().filter { $0.hasPrefix("orgstar.") }) | |
| 42 | let stale = pending.subtracting(wanted) | |
| 43 | if !stale.isEmpty { center.remove(Array(stale)) } | |
| 44 | for reminder in reminders where !pending.contains(reminder.id) { | |
| 45 | try? await center.add(reminder) | |
| 46 | } | |
| 47 | scheduledThrough = reminders.map(\.eventDate).max() | |
| 48 | } | |
| 49 | ||
| 50 | /// Removes every reminder, when the user turns them off. | |
| 51 | public func clear() async { | |
| 52 | let ours = await center.pendingIDs().filter { $0.hasPrefix("orgstar.") } | |
| 53 | if !ours.isEmpty { center.remove(ours) } | |
| 54 | scheduledThrough = nil | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | #if canImport(UserNotifications) | |
| 59 | /// `UNUserNotificationCenter`. Only usable from an app bundle. | |
| 60 | public struct SystemReminderCenter: ReminderCenter { | |
| 61 | public init() {} | |
| 62 | ||
| 63 | /// The system center needs a bundle identifier; a bare debug binary has none. | |
| 64 | public static var isAvailable: Bool { Bundle.main.bundleIdentifier != nil } | |
| 65 | ||
| 66 | public func authorize() async -> Bool { | |
| 67 | let center = UNUserNotificationCenter.current() | |
| 68 | let settings = await center.notificationSettings() | |
| 69 | switch settings.authorizationStatus { | |
| 70 | case .authorized, .provisional: return true | |
| 71 | case .notDetermined: return (try? await center.requestAuthorization(options: [.alert, .sound])) ?? false | |
| 72 | default: return false | |
| 73 | } | |
| 74 | } | |
| 75 | ||
| 76 | public func pendingIDs() async -> [String] { | |
| 77 | await UNUserNotificationCenter.current().pendingNotificationRequests().map(\.identifier) | |
| 78 | } | |
| 79 | ||
| 80 | public func add(_ reminder: Reminder) async throws { | |
| 81 | let content = UNMutableNotificationContent() | |
| 82 | content.title = reminder.title | |
| 83 | content.body = reminder.body | |
| 84 | content.sound = .default | |
| 85 | content.userInfo = ["path": reminder.path, "offset": reminder.offset] | |
| 86 | let components = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: reminder.fireDate) | |
| 87 | let trigger = UNCalendarNotificationTrigger(dateMatching: components, repeats: false) | |
| 88 | try await UNUserNotificationCenter.current().add(UNNotificationRequest(identifier: reminder.id, content: content, trigger: trigger)) | |
| 89 | } | |
| 90 | ||
| 91 | public func remove(_ ids: [String]) { | |
| 92 | UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) | |
| 93 | } | |
| 94 | } | |
| 95 | #endif | |
Sources/OrgCore/Agenda/Agenda.swift +4 −1
| @@ -35,6 +35,8 @@ public struct AgendaItem: Sendable, Equatable { | ||
| 35 | 35 | public let todo: String? |
| 36 | 36 | public let isDone: Bool |
| 37 | 37 | public let urgency: Int |
| 38 | /// `APPT_WARNTIME`: minutes of warning before the entry's time. | |
| 39 | public let warntime: Int? | |
| 38 | 40 | /// The line as org prints it, with one space before the tags. |
| 39 | 41 | public let line: String |
| 40 | 42 | } |
| @@ -277,6 +279,7 @@ public enum Agenda { | ||
| 277 | 279 | kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category, |
| 278 | 280 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), |
| 279 | 281 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), |
| 282 | warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, | |
| 280 | 283 | line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString) |
| 281 | 284 | ) |
| 282 | 285 | } |
| @@ -391,7 +394,7 @@ public enum Agenda { | ||
| 391 | 394 | let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing) |
| 392 | 395 | return AgendaItem( |
| 393 | 396 | kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, |
| 394 | time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, | |
| 397 | time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, warntime: nil, | |
| 395 | 398 | line: " " + pad("", 12) + pad(timed.time, 12) + txt |
| 396 | 399 | ) |
| 397 | 400 | } |
Sources/OrgCore/Agenda/Reminders.swift added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// A notification for an agenda entry with a time of day. | |
| 4 | public struct Reminder: Sendable, Equatable { | |
| 5 | /// Stable while the entry, its day and time stay the same. | |
| 6 | public let id: String | |
| 7 | public let fireDate: Date | |
| 8 | public let eventDate: Date | |
| 9 | public let title: String | |
| 10 | public let body: String | |
| 11 | public let path: String | |
| 12 | public let offset: Int | |
| 13 | ||
| 14 | public init(id: String, fireDate: Date, eventDate: Date, title: String, body: String, path: String, offset: Int) { | |
| 15 | self.id = id | |
| 16 | self.fireDate = fireDate | |
| 17 | self.eventDate = eventDate | |
| 18 | self.title = title | |
| 19 | self.body = body | |
| 20 | self.path = path | |
| 21 | self.offset = offset | |
| 22 | } | |
| 23 | } | |
| 24 | ||
| 25 | /// What `org-agenda-to-appt` hands appt: timed entries, each with a warning before it. | |
| 26 | public enum Reminders { | |
| 27 | /// Reminders for the next `days` days from `now`, earliest first, at most `limit`. An | |
| 28 | /// entry whose warning time has passed but which hasn't started fires now. | |
| 29 | public static func upcoming( | |
| 30 | _ sources: [AgendaSource], now: Date, calendar: Calendar, days: Int = 7, lead: Int = 12, limit: Int = 64 | |
| 31 | ) -> [Reminder] { | |
| 32 | let today = Days.absolute(now, calendar: calendar) | |
| 33 | let clock = calendar.dateComponents([.hour, .minute], from: now) | |
| 34 | let list = Agenda.list(sources, start: today, days: days, today: today, now: (clock.hour ?? 0, clock.minute ?? 0)) | |
| 35 | var reminders: [Reminder] = [] | |
| 36 | for day in list { | |
| 37 | let date = Days.date(day.day) | |
| 38 | guard let midnight = calendar.date(from: DateComponents(year: date.year, month: date.month, day: date.day)) else { continue } | |
| 39 | for item in day.items { | |
| 40 | guard let time = item.timeOfDay, let path = item.path, !item.isDone, | |
| 41 | [.deadline, .scheduled, .timestamp, .block].contains(item.kind), | |
| 42 | let event = calendar.date(byAdding: .minute, value: (time / 100) * 60 + time % 100, to: midnight), | |
| 43 | event > now else { continue } | |
| 44 | let fire = max(event.addingTimeInterval(-Double((item.warntime ?? lead) * 60)), now.addingTimeInterval(1)) | |
| 45 | let clockText = String(format: "%d:%02d", time / 100, time % 100) | |
| 46 | let title = item.text.trimmingCharacters(in: .whitespaces) | |
| 47 | let leader = item.extra.trimmingCharacters(in: .whitespaces) | |
| 48 | reminders.append(Reminder( | |
| 49 | id: "orgstar.\(path)|\(item.headingLine ?? title)|\(day.day)|\(time)|\(item.kind.rawValue)", | |
| 50 | fireDate: fire, eventDate: event, title: title, | |
| 51 | body: ([clockText] + (leader.isEmpty ? [] : [leader]) + [item.category]).joined(separator: " · "), | |
| 52 | path: path, offset: item.markerOffset ?? item.headingOffset ?? 0 | |
| 53 | )) | |
| 54 | } | |
| 55 | } | |
| 56 | return Array(reminders.sorted { $0.fireDate < $1.fireDate }.prefix(limit)) | |
| 57 | } | |
| 58 | } | |
Sources/Orgstar/AgendaView.swift +14 −2
| @@ -10,6 +10,7 @@ struct AgendaView: View { | ||
| 10 | 10 | let workspace: WorkspaceModel |
| 11 | 11 | let session: DocumentSession |
| 12 | 12 | let commands: AppCommands |
| 13 | let scheduler: ReminderScheduler? | |
| 13 | 14 | @State private var agenda: AgendaModel |
| 14 | 15 | @State private var views = AgendaViewLoader.builtIn |
| 15 | 16 | @State private var viewName = AgendaViewLoader.builtIn[0].name |
| @@ -28,10 +29,11 @@ struct AgendaView: View { | ||
| 28 | 29 | static let match = "Match…" |
| 29 | 30 | static let todoMatch = "TODO Match…" |
| 30 | 31 | |
| 31 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) { | |
| 32 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?) { | |
| 32 | 33 | self.workspace = workspace |
| 33 | 34 | self.session = session |
| 34 | 35 | self.commands = commands |
| 36 | self.scheduler = scheduler | |
| 35 | 37 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) |
| 36 | 38 | } |
| 37 | 39 | |
| @@ -98,7 +100,7 @@ struct AgendaView: View { | ||
| 98 | 100 | } |
| 99 | 101 | .onKeyPress(phases: .down) { press in handle(press) } |
| 100 | 102 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 101 | if let message { | |
| 103 | if let message = message ?? reminderStatus { | |
| 102 | 104 | Text(message) |
| 103 | 105 | .font(.callout) |
| 104 | 106 | .frame(maxWidth: .infinity, alignment: .leading) |
| @@ -178,6 +180,16 @@ struct AgendaView: View { | ||
| 178 | 180 | } |
| 179 | 181 | } |
| 180 | 182 | |
| 183 | @AppStorage("reminders") private var remindersEnabled = true | |
| 184 | ||
| 185 | /// How far ahead notifications are set, as the design asks the agenda to show. | |
| 186 | private var reminderStatus: String? { | |
| 187 | guard let scheduler, remindersEnabled else { return nil } | |
| 188 | if !scheduler.isAuthorized { return "Notifications are off for Orgstar in System Settings." } | |
| 189 | guard let through = scheduler.scheduledThrough else { return nil } | |
| 190 | return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + "." | |
| 191 | } | |
| 192 | ||
| 181 | 193 | private var listTitle: String { |
| 182 | 194 | switch agenda.mode { |
| 183 | 195 | case .todo(let keywords): "TODO items: \(keywords ?? "all")" |
Sources/Orgstar/ContentView.swift +46
| @@ -8,6 +8,12 @@ struct ContentView: View { | ||
| 8 | 8 | let workspace: WorkspaceModel |
| 9 | 9 | let session: DocumentSession |
| 10 | 10 | @Bindable var commands: AppCommands |
| 11 | let scheduler: ReminderScheduler? | |
| 12 | @AppStorage("reminders") private var remindersEnabled = true | |
| 13 | @AppStorage("reminderLead") private var reminderLead = 12 | |
| 14 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false | |
| 15 | @State private var reminderAgenda: AgendaModel? | |
| 16 | @State private var hour = 0 | |
| 11 | 17 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 12 | 18 | @AppStorage("keymap") private var preset = KeymapPreset.emacs |
| 13 | 19 | @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue |
| @@ -137,6 +143,18 @@ struct ContentView: View { | ||
| 137 | 143 | } message: { |
| 138 | 144 | Text(workspace.lastError ?? session.lastError ?? "") |
| 139 | 145 | } |
| 146 | .task(id: ReminderKey( | |
| 147 | enabled: remindersEnabled, lead: reminderLead, subfolders: agendaSubfolders, files: workspace.files.count, | |
| 148 | changed: workspace.changedFile?.version, edits: session.editVersion, hour: hour | |
| 149 | )) { | |
| 150 | await updateReminders() | |
| 151 | } | |
| 152 | .task { | |
| 153 | while !Task.isCancelled { | |
| 154 | try? await Task.sleep(for: .seconds(3600)) | |
| 155 | hour += 1 | |
| 156 | } | |
| 157 | } | |
| 140 | 158 | .task { |
| 141 | 159 | DevelopmentHooks.scheduleSnapshot() |
| 142 | 160 | if let path = DevelopmentHooks.fileToOpen { selectedFile = path } |
| @@ -147,6 +165,34 @@ struct ContentView: View { | ||
| 147 | 165 | } |
| 148 | 166 | } |
| 149 | 167 | |
| 168 | private struct ReminderKey: Hashable { | |
| 169 | var enabled: Bool | |
| 170 | var lead: Int | |
| 171 | var subfolders: Bool | |
| 172 | var files: Int | |
| 173 | var changed: Int? | |
| 174 | var edits: Int | |
| 175 | var hour: Int | |
| 176 | } | |
| 177 | ||
| 178 | /// Sets the notifications for the next week's timed entries, once edits pause. | |
| 179 | private func updateReminders() async { | |
| 180 | guard let scheduler else { return } | |
| 181 | guard remindersEnabled else { | |
| 182 | await scheduler.clear() | |
| 183 | return | |
| 184 | } | |
| 185 | try? await Task.sleep(for: .seconds(2)) | |
| 186 | guard !Task.isCancelled, !workspace.files.isEmpty else { return } | |
| 187 | let agenda = reminderAgenda ?? AgendaModel(workspace: workspace) | |
| 188 | reminderAgenda = agenda | |
| 189 | agenda.includeSubfolders = agendaSubfolders | |
| 190 | let open = session.path.flatMap { path in session.document.map { (path, $0.text) } } | |
| 191 | let reminders = await agenda.reminders(open: open, lead: reminderLead) | |
| 192 | guard !Task.isCancelled else { return } | |
| 193 | await scheduler.reconcile(reminders) | |
| 194 | } | |
| 195 | ||
| 150 | 196 | private func loadKeymap() { |
| 151 | 197 | let loaded = KeymapLoader.load(preset) |
| 152 | 198 | keymap = loaded.keymap |
Sources/Orgstar/OrgstarApp.swift +36 −4
| @@ -3,6 +3,7 @@ import OrgApp | ||
| 3 | 3 | import OrgCore |
| 4 | 4 | import OrgEditorAppKit |
| 5 | 5 | import SwiftUI |
| 6 | import UserNotifications | |
| 6 | 7 | |
| 7 | 8 | /// Window-level requests the menu bar makes. |
| 8 | 9 | @MainActor |
| @@ -62,8 +63,30 @@ struct PromptRequest: Identifiable { | ||
| 62 | 63 | } |
| 63 | 64 | |
| 64 | 65 | @MainActor |
| 65 | final class AppDelegate: NSObject, NSApplicationDelegate { | |
| 66 | final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { | |
| 66 | 67 | var session: DocumentSession? |
| 68 | var commands: AppCommands? | |
| 69 | ||
| 70 | func applicationDidFinishLaunching(_ notification: Notification) { | |
| 71 | if SystemReminderCenter.isAvailable { UNUserNotificationCenter.current().delegate = self } | |
| 72 | } | |
| 73 | ||
| 74 | /// Reminders show while Orgstar is in front too. | |
| 75 | nonisolated func userNotificationCenter( | |
| 76 | _ center: UNUserNotificationCenter, willPresent notification: UNNotification | |
| 77 | ) async -> UNNotificationPresentationOptions { | |
| 78 | [.banner, .sound, .list] | |
| 79 | } | |
| 80 | ||
| 81 | /// Clicking a reminder shows its entry. | |
| 82 | nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async { | |
| 83 | let info = response.notification.request.content.userInfo | |
| 84 | guard let path = info["path"] as? String, let offset = info["offset"] as? Int else { return } | |
| 85 | await MainActor.run { | |
| 86 | NSApp.activate() | |
| 87 | commands?.jump(path: path, offset: offset) | |
| 88 | } | |
| 89 | } | |
| 67 | 90 | |
| 68 | 91 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } |
| 69 | 92 | |
| @@ -102,6 +125,7 @@ struct OrgstarApp: App { | ||
| 102 | 125 | try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings)) |
| 103 | 126 | } |
| 104 | 127 | @State private var commands = AppCommands() |
| 128 | @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil | |
| 105 | 129 | @State private var session: DocumentSession = { |
| 106 | 130 | let session = DocumentSession() |
| 107 | 131 | session.defaults = Preferences.orgSettings |
| @@ -112,8 +136,11 @@ struct OrgstarApp: App { | ||
| 112 | 136 | Window("Orgstar", id: "main") { |
| 113 | 137 | switch workspace { |
| 114 | 138 | case .success(let model): |
| 115 | ContentView(workspace: model, session: session, commands: commands) | |
| 116 | .onAppear { delegate.session = session } | |
| 139 | ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler) | |
| 140 | .onAppear { | |
| 141 | delegate.session = session | |
| 142 | delegate.commands = commands | |
| 143 | } | |
| 117 | 144 | case .failure(let error): |
| 118 | 145 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) |
| 119 | 146 | } |
| @@ -121,7 +148,7 @@ struct OrgstarApp: App { | ||
| 121 | 148 | .defaultSize(width: 1280, height: 860) |
| 122 | 149 | Window("Agenda", id: "agenda") { |
| 123 | 150 | if case .success(let model) = workspace { |
| 124 | AgendaView(workspace: model, session: session, commands: commands) | |
| 151 | AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler) | |
| 125 | 152 | } |
| 126 | 153 | } |
| 127 | 154 | .defaultSize(width: 760, height: 720) |
| @@ -188,6 +215,8 @@ struct SettingsView: View { | ||
| 188 | 215 | @AppStorage("agendaSpan") private var agendaSpan = 10 |
| 189 | 216 | @AppStorage("agendaStartOffset") private var agendaStartOffset = -3 |
| 190 | 217 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 218 | @AppStorage("reminders") private var reminders = true | |
| 219 | @AppStorage("reminderLead") private var reminderLead = 12 | |
| 191 | 220 | |
| 192 | 221 | var body: some View { |
| 193 | 222 | Form { |
| @@ -231,6 +260,9 @@ struct SettingsView: View { | ||
| 231 | 260 | Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31) |
| 232 | 261 | Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0) |
| 233 | 262 | Toggle("Agenda includes files in subfolders", isOn: $agendaSubfolders) |
| 263 | Toggle("Notify before timed agenda entries", isOn: $reminders) | |
| 264 | Stepper("\(reminderLead) minutes before (APPT_WARNTIME overrides)", value: $reminderLead, in: 0...120) | |
| 265 | .disabled(!reminders) | |
| 234 | 266 | } |
| 235 | 267 | .padding(20) |
| 236 | 268 | .frame(width: 480) |
Tests/OrgAppTests/AppTests.swift +47
| @@ -373,3 +373,50 @@ struct AgendaCommandTests { | ||
| 373 | 373 | #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n") |
| 374 | 374 | } |
| 375 | 375 | } |
| 376 | ||
| 377 | final class FakeReminderCenter: ReminderCenter, @unchecked Sendable { | |
| 378 | private let lock = NSLock() | |
| 379 | private var pending: [String] = ["someone.else"] | |
| 380 | var authorized = true | |
| 381 | private(set) var added: [String] = [] | |
| 382 | ||
| 383 | func authorize() async -> Bool { authorized } | |
| 384 | func pendingIDs() async -> [String] { lock.withLock { pending } } | |
| 385 | func add(_ reminder: Reminder) async throws { | |
| 386 | lock.withLock { | |
| 387 | pending.append(reminder.id) | |
| 388 | added.append(reminder.id) | |
| 389 | } | |
| 390 | } | |
| 391 | func remove(_ ids: [String]) { lock.withLock { pending.removeAll { ids.contains($0) } } } | |
| 392 | var ids: [String] { lock.withLock { pending } } | |
| 393 | } | |
| 394 | ||
| 395 | @MainActor | |
| 396 | struct ReminderSchedulerTests { | |
| 397 | func reminder(_ id: String, _ minutes: Double) -> Reminder { | |
| 398 | let date = Date(timeIntervalSince1970: 1_000_000 + minutes * 60) | |
| 399 | return Reminder(id: "orgstar.\(id)", fireDate: date, eventDate: date.addingTimeInterval(720), title: id, body: "", path: "/a.org", offset: 0) | |
| 400 | } | |
| 401 | ||
| 402 | @Test func reconciles() async { | |
| 403 | let center = FakeReminderCenter() | |
| 404 | let scheduler = ReminderScheduler(center: center) | |
| 405 | await scheduler.reconcile([reminder("a", 1), reminder("b", 2)]) | |
| 406 | #expect(center.ids == ["someone.else", "orgstar.a", "orgstar.b"]) | |
| 407 | #expect(scheduler.scheduledThrough == reminder("b", 2).eventDate) | |
| 408 | ||
| 409 | await scheduler.reconcile([reminder("b", 2), reminder("c", 3)]) | |
| 410 | #expect(center.ids == ["someone.else", "orgstar.b", "orgstar.c"]) | |
| 411 | #expect(center.added == ["orgstar.a", "orgstar.b", "orgstar.c"]) | |
| 412 | ||
| 413 | await scheduler.clear() | |
| 414 | #expect(center.ids == ["someone.else"]) | |
| 415 | #expect(scheduler.scheduledThrough == nil) | |
| 416 | ||
| 417 | center.authorized = false | |
| 418 | await scheduler.reconcile([reminder("d", 4)]) | |
| 419 | #expect(!scheduler.isAuthorized) | |
| 420 | #expect(center.ids == ["someone.else"]) | |
| 421 | } | |
| 422 | } | |
Tests/OrgCoreTests/AgendaTests.swift +52
| @@ -324,3 +324,55 @@ struct AgendaTests { | ||
| 324 | 324 | } |
| 325 | 325 | } |
| 326 | 326 | } |
| 327 | ||
| 328 | struct ReminderTests { | |
| 329 | static var utc: Calendar { | |
| 330 | var calendar = Calendar(identifier: .gregorian) | |
| 331 | calendar.timeZone = TimeZone(identifier: "UTC")! | |
| 332 | return calendar | |
| 333 | } | |
| 334 | ||
| 335 | static func date(_ day: Int, _ hour: Int, _ minute: Int) -> Date { | |
| 336 | utc.date(from: DateComponents(year: 2026, month: 10, day: day, hour: hour, minute: minute))! | |
| 337 | } | |
| 338 | ||
| 339 | @Test func timedEntriesWithTheirWarnings() { | |
| 340 | let text = """ | |
| 341 | * TODO Call | |
| 342 | SCHEDULED: <2026-10-05 Mon 14:00> | |
| 343 | * Meeting | |
| 344 | :PROPERTIES: | |
| 345 | :APPT_WARNTIME: 30 | |
| 346 | :END: | |
| 347 | <2026-10-06 Tue 10:00-11:00> | |
| 348 | * DONE Finished | |
| 349 | SCHEDULED: <2026-10-05 Mon 15:00> | |
| 350 | * Untimed | |
| 351 | SCHEDULED: <2026-10-05 Mon> | |
| 352 | * Earlier | |
| 353 | <2026-10-05 Mon 08:00> | |
| 354 | * Soon | |
| 355 | <2026-10-05 Mon 09:35> | |
| 356 | * TODO Report due | |
| 357 | DEADLINE: <2026-10-07 Wed 17:00> | |
| 358 | """ | |
| 359 | let source = AgendaSource(path: "/n/a.org", text: text) | |
| 360 | let now = Self.date(5, 9, 30) | |
| 361 | let reminders = Reminders.upcoming([source], now: now, calendar: Self.utc) | |
| 362 | #expect(reminders.map(\.title) == ["Soon", "TODO Call", "Meeting", "TODO Report due"]) | |
| 363 | #expect(reminders[0].fireDate == now.addingTimeInterval(1)) | |
| 364 | #expect(reminders[1].fireDate == Self.date(5, 13, 48)) | |
| 365 | #expect(reminders[1].body == "14:00 · Scheduled: · a") | |
| 366 | #expect(reminders[2].fireDate == Self.date(6, 9, 30)) | |
| 367 | #expect(reminders[3].eventDate == Self.date(7, 17, 0)) | |
| 368 | #expect(Reminders.upcoming([source], now: now, calendar: Self.utc, limit: 2).count == 2) | |
| 369 | #expect(Reminders.upcoming([source], now: now, calendar: Self.utc, days: 1).map(\.title) == ["Soon", "TODO Call"]) | |
| 370 | } | |
| 371 | ||
| 372 | @Test func repeatsFillTheWeek() { | |
| 373 | let source = AgendaSource(path: "/n/a.org", text: "* Standup\n<2026-10-01 Thu 09:00 +1d>\n") | |
| 374 | let reminders = Reminders.upcoming([source], now: Self.date(5, 9, 30), calendar: Self.utc) | |
| 375 | #expect(reminders.count == 6) | |
| 376 | #expect(Set(reminders.map(\.id)).count == 6) | |
| 377 | } | |
| 378 | } | |
docs/plans/2026-10-05-notifications.md added +29
| @@ -0,0 +1,29 @@ | ||
| 1 | # Notifications 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:** Local notifications before timed agenda entries, as `org-agenda-to-appt` and appt give in Emacs, kept in step with the files and shown in the agenda. | |
| 6 | ||
| 7 | **Architecture:** `OrgCore/Agenda/Reminders.swift` turns the next days of the agenda into reminders: entries with a time of day (scheduled, deadline, timestamp, range start), not done, fired `APPT_WARNTIME` or the lead time before. `ReminderScheduler` in OrgApp reconciles them with the pending requests of a `NotificationCenter` (the real one wraps `UNUserNotificationCenter`; tests use a fake): removes what no longer applies, adds what's new, keeps at most 64 (the iOS cap). The app reconciles at launch, on file changes and on edits, and hourly. Clicking a notification jumps to the entry. The agenda window says how far ahead reminders are set. | |
| 8 | ||
| 9 | **Tech Stack:** Swift 6.2 tools, Swift Testing, UserNotifications. | |
| 10 | ||
| 11 | **Spec:** `docs/design.md`, "Phase 3", risks table ("Notifications"). | |
| 12 | ||
| 13 | ## Global Constraints | |
| 14 | ||
| 15 | - Notifications need the app bundle (`scripts/build-app.sh`); an unbundled debug binary skips them. | |
| 16 | - Request IDs start with `orgstar.`; other requests are left alone. | |
| 17 | ||
| 18 | ## Defaults chosen (user may change) | |
| 19 | ||
| 20 | - On, 12 minutes before (appt's `appt-message-warning-time`), 7 days ahead. | |
| 21 | ||
| 22 | --- | |
| 23 | ||
| 24 | ### Task 1: Reminders | |
| 25 | ||
| 26 | - [x] `Reminders.upcoming`; `AgendaItem.warntime`; tests for leads, `APPT_WARNTIME`, done entries, ranges, the cap, entries already past. | |
| 27 | - [x] `ReminderScheduler` with a fake center: adds, removes, keeps, reports the horizon. | |
| 28 | - [x] App: settings, reconcile triggers, click to jump, agenda footer. | |
| 29 | - [x] Commit "Notifications". | |