Commit 359666677e
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +35 −20
| @@ -54,6 +54,37 @@ public final class AgendaModel { | |||
| 54 | startOffset = viewStart ?? startOffset | 54 | startOffset = viewStart ?? startOffset |
| 55 | } | 55 | } |
| 56 | let first = start ?? today + startOffset | 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 | let defaults = workspace.settings.org | 88 | let defaults = workspace.settings.org |
| 58 | var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = [] | 89 | var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = [] |
| 59 | for path in files { | 90 | for path in files { |
| @@ -67,9 +98,7 @@ public final class AgendaModel { | |||
| 67 | let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil } | 98 | let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil } |
| 68 | reads.append((path, cached, mtime, size, nil)) | 99 | reads.append((path, cached, mtime, size, nil)) |
| 69 | } | 100 | } |
| 70 | let options = self.options | 101 | let loaded = await Task.detached { |
| 71 | let mode = self.mode | ||
| 72 | let result = await Task.detached { | ||
| 73 | var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = [] | 102 | var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = [] |
| 74 | for read in reads { | 103 | for read in reads { |
| 75 | if let cached = read.cached { | 104 | if let cached = read.cached { |
| @@ -81,26 +110,12 @@ public final class AgendaModel { | |||
| 81 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true)) | 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) | 113 | return sources |
| 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) | ||
| 96 | }.value | 114 | }.value |
| 97 | guard id == generation else { return } | 115 | for source in loaded where source.fromDisk { |
| 98 | for source in result.0 where source.fromDisk { | ||
| 99 | cache[source.path] = (source.mtime, source.size, source.source) | 116 | cache[source.path] = (source.mtime, source.size, source.source) |
| 100 | } | 117 | } |
| 101 | days = result.1 | 118 | return loaded.map(\.source) |
| 102 | list = result.2 | ||
| 103 | isComputing = false | ||
| 104 | } | 119 | } |
| 105 | 120 | ||
| 106 | public func showToday() { start = nil } | 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 | public let todo: String? | 35 | public let todo: String? |
| 36 | public let isDone: Bool | 36 | public let isDone: Bool |
| 37 | public let urgency: Int | 37 | public let urgency: Int |
| 38 | /// `APPT_WARNTIME`: minutes of warning before the entry's time. | ||
| 39 | public let warntime: Int? | ||
| 38 | /// The line as org prints it, with one space before the tags. | 40 | /// The line as org prints it, with one space before the tags. |
| 39 | public let line: String | 41 | public let line: String |
| 40 | } | 42 | } |
| @@ -277,6 +279,7 @@ public enum Agenda { | |||
| 277 | kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category, | 279 | kind: kind, path: source.path, headingOffset: heading.start, headingLine: heading.line, markerOffset: marker, category: category, |
| 278 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), | 280 | timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name), |
| 279 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), | 281 | todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority), |
| 282 | warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, | ||
| 280 | line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString) | 283 | line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString) |
| 281 | ) | 284 | ) |
| 282 | } | 285 | } |
| @@ -391,7 +394,7 @@ public enum Agenda { | |||
| 391 | let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing) | 394 | let timed = timeFields(&txt, tagString: "", dotime: .string(dotime), trailing: options.gridTrailing) |
| 392 | return AgendaItem( | 395 | return AgendaItem( |
| 393 | kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay, | 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 | line: " " + pad("", 12) + pad(timed.time, 12) + txt | 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 | let workspace: WorkspaceModel | 10 | let workspace: WorkspaceModel |
| 11 | let session: DocumentSession | 11 | let session: DocumentSession |
| 12 | let commands: AppCommands | 12 | let commands: AppCommands |
| 13 | let scheduler: ReminderScheduler? | ||
| 13 | @State private var agenda: AgendaModel | 14 | @State private var agenda: AgendaModel |
| 14 | @State private var views = AgendaViewLoader.builtIn | 15 | @State private var views = AgendaViewLoader.builtIn |
| 15 | @State private var viewName = AgendaViewLoader.builtIn[0].name | 16 | @State private var viewName = AgendaViewLoader.builtIn[0].name |
| @@ -28,10 +29,11 @@ struct AgendaView: View { | |||
| 28 | static let match = "Match…" | 29 | static let match = "Match…" |
| 29 | static let todoMatch = "TODO Match…" | 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 | self.workspace = workspace | 33 | self.workspace = workspace |
| 33 | self.session = session | 34 | self.session = session |
| 34 | self.commands = commands | 35 | self.commands = commands |
| 36 | self.scheduler = scheduler | ||
| 35 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) | 37 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) |
| 36 | } | 38 | } |
| 37 | 39 | ||
| @@ -98,7 +100,7 @@ struct AgendaView: View { | |||
| 98 | } | 100 | } |
| 99 | .onKeyPress(phases: .down) { press in handle(press) } | 101 | .onKeyPress(phases: .down) { press in handle(press) } |
| 100 | .safeAreaInset(edge: .bottom, spacing: 0) { | 102 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 101 | if let message { | 103 | if let message = message ?? reminderStatus { |
| 102 | Text(message) | 104 | Text(message) |
| 103 | .font(.callout) | 105 | .font(.callout) |
| 104 | .frame(maxWidth: .infinity, alignment: .leading) | 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 | private var listTitle: String { | 193 | private var listTitle: String { |
| 182 | switch agenda.mode { | 194 | switch agenda.mode { |
| 183 | case .todo(let keywords): "TODO items: \(keywords ?? "all")" | 195 | case .todo(let keywords): "TODO items: \(keywords ?? "all")" |
Sources/Orgstar/ContentView.swift +46
| @@ -8,6 +8,12 @@ struct ContentView: View { | |||
| 8 | let workspace: WorkspaceModel | 8 | let workspace: WorkspaceModel |
| 9 | let session: DocumentSession | 9 | let session: DocumentSession |
| 10 | @Bindable var commands: AppCommands | 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 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | 17 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 12 | @AppStorage("keymap") private var preset = KeymapPreset.emacs | 18 | @AppStorage("keymap") private var preset = KeymapPreset.emacs |
| 13 | @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue | 19 | @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue |
| @@ -137,6 +143,18 @@ struct ContentView: View { | |||
| 137 | } message: { | 143 | } message: { |
| 138 | Text(workspace.lastError ?? session.lastError ?? "") | 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 | .task { | 158 | .task { |
| 141 | DevelopmentHooks.scheduleSnapshot() | 159 | DevelopmentHooks.scheduleSnapshot() |
| 142 | if let path = DevelopmentHooks.fileToOpen { selectedFile = path } | 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 | private func loadKeymap() { | 196 | private func loadKeymap() { |
| 151 | let loaded = KeymapLoader.load(preset) | 197 | let loaded = KeymapLoader.load(preset) |
| 152 | keymap = loaded.keymap | 198 | keymap = loaded.keymap |
Sources/Orgstar/OrgstarApp.swift +36 −4
| @@ -3,6 +3,7 @@ import OrgApp | |||
| 3 | import OrgCore | 3 | import OrgCore |
| 4 | import OrgEditorAppKit | 4 | import OrgEditorAppKit |
| 5 | import SwiftUI | 5 | import SwiftUI |
| 6 | import UserNotifications | ||
| 6 | 7 | ||
| 7 | /// Window-level requests the menu bar makes. | 8 | /// Window-level requests the menu bar makes. |
| 8 | @MainActor | 9 | @MainActor |
| @@ -62,8 +63,30 @@ struct PromptRequest: Identifiable { | |||
| 62 | } | 63 | } |
| 63 | 64 | ||
| 64 | @MainActor | 65 | @MainActor |
| 65 | final class AppDelegate: NSObject, NSApplicationDelegate { | 66 | final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { |
| 66 | var session: DocumentSession? | 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 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } | 91 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } |
| 69 | 92 | ||
| @@ -102,6 +125,7 @@ struct OrgstarApp: App { | |||
| 102 | try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings)) | 125 | try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings)) |
| 103 | } | 126 | } |
| 104 | @State private var commands = AppCommands() | 127 | @State private var commands = AppCommands() |
| 128 | @State private var scheduler = SystemReminderCenter.isAvailable ? ReminderScheduler(center: SystemReminderCenter()) : nil | ||
| 105 | @State private var session: DocumentSession = { | 129 | @State private var session: DocumentSession = { |
| 106 | let session = DocumentSession() | 130 | let session = DocumentSession() |
| 107 | session.defaults = Preferences.orgSettings | 131 | session.defaults = Preferences.orgSettings |
| @@ -112,8 +136,11 @@ struct OrgstarApp: App { | |||
| 112 | Window("Orgstar", id: "main") { | 136 | Window("Orgstar", id: "main") { |
| 113 | switch workspace { | 137 | switch workspace { |
| 114 | case .success(let model): | 138 | case .success(let model): |
| 115 | ContentView(workspace: model, session: session, commands: commands) | 139 | ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler) |
| 116 | .onAppear { delegate.session = session } | 140 | .onAppear { |
| 141 | delegate.session = session | ||
| 142 | delegate.commands = commands | ||
| 143 | } | ||
| 117 | case .failure(let error): | 144 | case .failure(let error): |
| 118 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) | 145 | ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription)) |
| 119 | } | 146 | } |
| @@ -121,7 +148,7 @@ struct OrgstarApp: App { | |||
| 121 | .defaultSize(width: 1280, height: 860) | 148 | .defaultSize(width: 1280, height: 860) |
| 122 | Window("Agenda", id: "agenda") { | 149 | Window("Agenda", id: "agenda") { |
| 123 | if case .success(let model) = workspace { | 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 | .defaultSize(width: 760, height: 720) | 154 | .defaultSize(width: 760, height: 720) |
| @@ -188,6 +215,8 @@ struct SettingsView: View { | |||
| 188 | @AppStorage("agendaSpan") private var agendaSpan = 10 | 215 | @AppStorage("agendaSpan") private var agendaSpan = 10 |
| 189 | @AppStorage("agendaStartOffset") private var agendaStartOffset = -3 | 216 | @AppStorage("agendaStartOffset") private var agendaStartOffset = -3 |
| 190 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false | 217 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 218 | @AppStorage("reminders") private var reminders = true | ||
| 219 | @AppStorage("reminderLead") private var reminderLead = 12 | ||
| 191 | 220 | ||
| 192 | var body: some View { | 221 | var body: some View { |
| 193 | Form { | 222 | Form { |
| @@ -231,6 +260,9 @@ struct SettingsView: View { | |||
| 231 | Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31) | 260 | 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) | 261 | 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) | 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 | .padding(20) | 267 | .padding(20) |
| 236 | .frame(width: 480) | 268 | .frame(width: 480) |
Tests/OrgAppTests/AppTests.swift +47
| @@ -373,3 +373,50 @@ struct AgendaCommandTests { | |||
| 373 | #expect(try String(contentsOf: b, encoding: .utf8) == "* DONE Three\n") | 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". | ||