krz/orgstar

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

Commit 86bed886a4

86bed886a477e04cc2293078691394c1dee6256b

parent: 5445e35a58

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 05:16 UTC

iOS: capture onto an agenda day, calendar events setting

A capture request can carry a day: it is the default time of the template's
date escapes, as org-agenda-capture sets org-overriding-default-time, and an
entry the template gives no active timestamp gets SCHEDULED on that day.

Settings toggles calendar-events and chooses calendar-event-calendars,
writing config.toml in the configuration folder (or this device's settings
without one). MobileModel keeps one CalendarEvents for the agenda.

Layout: unified · split

Sources/OrgstarMobile/CaptureScreen.swift +43 −6
@@ -42,7 +42,7 @@ struct CaptureScreen: View {
42 } 42 }
43 } else { 43 } else {
44 ForEach(questions.indices, id: \.self) { index in 44 ForEach(questions.indices, id: \.self) { index in
45 QuestionField(question: questions[index], answer: $answers[index]) 45 QuestionField(question: questions[index], answer: $answers[index], defaultDate: request.day.map(Self.date))
46 } 46 }
47 if let problem { Text(problem).foregroundStyle(.red) } 47 if let problem { Text(problem).foregroundStyle(.red) }
48 } 48 }
@@ -95,11 +95,12 @@ struct CaptureScreen: View {
95 /// The template expanded with the answers; the caret where `%?` was. 95 /// The template expanded with the answers; the caret where `%?` was.
96 private func fill() { 96 private func fill() {
97 guard let template else { return } 97 guard let template else { return }
98 context.now = Date() 98 context.now = request.day.map(Self.date) ?? Date()
99 switch Capture.fill(template.template, context: context, answers: answers) { 99 switch Capture.fill(template.template, context: context, answers: answers) {
100 case .success(let filled): 100 case .success(let filled):
101 text = filled.text 101 let (filledText, cursor) = request.day.map { Self.scheduling(filled.text, cursor: filled.cursor, on: $0) } ?? (filled.text, filled.cursor)
102 selection = TextSelection(insertionPoint: String.Index(utf16Offset: filled.cursor, in: filled.text)) 102 text = filledText
103 selection = TextSelection(insertionPoint: String.Index(utf16Offset: cursor, in: filledText))
103 problem = nil 104 problem = nil
104 editing = true 105 editing = true
105 focused = true 106 focused = true
@@ -109,6 +110,31 @@ struct CaptureScreen: View {
109 } 110 }
110 } 111 }
111 112
113 /// Now's time of day on `day` (a `Days` number): the default time of an agenda day's
114 /// capture, as `org-agenda-capture` sets `org-overriding-default-time`.
115 static func date(_ day: Int) -> Date {
116 let calendar = Calendar.current
117 let d = Days.date(day)
118 var parts = calendar.dateComponents([.hour, .minute, .second], from: Date())
119 (parts.year, parts.month, parts.day) = (d.year, d.month, d.day)
120 return calendar.date(from: parts) ?? Date()
121 }
122
123 /// An entry captured from an agenda day is scheduled on it: a SCHEDULED line goes under
124 /// the heading unless the template already gave the entry an active timestamp. The caret
125 /// keeps its place in the text.
126 static func scheduling(_ text: String, cursor: Int, on day: Int) -> (String, Int) {
127 guard text.hasPrefix("*"), text.firstMatch(of: /<[0-9]{4}-[0-9]{2}-[0-9]{2}/) == nil else { return (text, cursor) }
128 let d = Days.date(day)
129 let weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][Days.weekday(day)]
130 let line = String(format: "SCHEDULED: <%04d-%02d-%02d ", d.year, d.month, d.day) + weekday + ">"
131 let utf16 = Array(text.utf16)
132 let end = utf16.firstIndex(of: 10) ?? utf16.count
133 let inserted = "\n" + line
134 let result = String(decoding: utf16[..<end], as: UTF16.self) + inserted + String(decoding: utf16[end...], as: UTF16.self)
135 return (result, cursor > end ? cursor + inserted.utf16.count : cursor)
136 }
137
112 private func file() { 138 private func file() {
113 guard let template else { return } 139 guard let template else { return }
114 let now = Date() 140 let now = Date()
@@ -135,7 +161,16 @@ struct CaptureScreen: View {
135struct QuestionField: View { 161struct QuestionField: View {
136 let question: Capture.Question 162 let question: Capture.Question
137 @Binding var answer: String 163 @Binding var answer: String
138 @State private var date = Date() 164 /// What an empty date answer means: now, or the agenda day capture started from.
165 let defaultDate: Date?
166 @State private var date: Date
167
168 init(question: Capture.Question, answer: Binding<String>, defaultDate: Date? = nil) {
169 self.question = question
170 _answer = answer
171 self.defaultDate = defaultDate
172 _date = State(initialValue: defaultDate ?? Date())
173 }
139 174
140 var body: some View { 175 var body: some View {
141 Section { 176 Section {
@@ -165,7 +200,9 @@ struct QuestionField: View {
165 switch question.kind { 200 switch question.kind {
166 case .text, .property, .clipboard: question.defaultValue 201 case .text, .property, .clipboard: question.defaultValue
167 case .tags: "tag:tag" 202 case .tags: "tag:tag"
168 case .date(let withTime, _): withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri" 203 case .date(let withTime, _):
204 defaultDate.map { Self.stamp($0, withTime: withTime) + "; or +2d, fri" }
205 ?? (withTime ? "now; or +2d, fri 14:00" : "today; or +2d, fri")
169 } 206 }
170 } 207 }
171 208
Sources/OrgstarMobile/MobileModel.swift +27
@@ -32,6 +32,10 @@ final class MobileModel {
32 var openRequest: ReaderRoute? 32 var openRequest: ReaderRoute?
33 /// Agenda days to show, from a followed timestamp: the first (a `Days` number) and how many. 33 /// Agenda days to show, from a followed timestamp: the first (a `Days` number) and how many.
34 var agendaRequest: AgendaDays? 34 var agendaRequest: AgendaDays?
35 /// A capture to show, from the agenda.
36 var captureRequest: CaptureRequest?
37 /// Calendar's events for the agenda, with `calendar-events`.
38 let calendarEvents = CalendarEvents()
35 39
36 struct AgendaDays: Equatable { 40 struct AgendaDays: Equatable {
37 let start: Int 41 let start: Int
@@ -115,10 +119,33 @@ final class MobileModel {
115 agenda.startOffset = OrgPreferences.agendaStartOffset() 119 agenda.startOffset = OrgPreferences.agendaStartOffset()
116 agenda.includeSubfolders = OrgPreferences.agendaSubfolders() 120 agenda.includeSubfolders = OrgPreferences.agendaSubfolders()
117 agenda.options = OrgPreferences.agendaOptions(agenda.options) 121 agenda.options = OrgPreferences.agendaOptions(agenda.options)
122 agenda.calendarEvents = OrgPreferences.calendarEvents() ? calendarEvents : nil
123 agenda.calendarNames = OrgPreferences.calendarEventCalendars()
118 } 124 }
119 settingsVersion += 1 125 settingsVersion += 1
120 } 126 }
121 127
128 /// Changes a setting from the app: in the folder's config.toml when there is a folder, as
129 /// the Mac's Settings write it, else in this device's settings.
130 func set(_ defaultsKey: String, to value: TOML.Value) {
131 guard let setting = UserConfig.settings.first(where: { $0.defaultsKey == defaultsKey }) else { return }
132 if let configFolder {
133 let file = configFolder.appendingPathComponent("config.toml")
134 var error: NSError?
135 NSFileCoordinator(filePresenter: nil).coordinate(writingItemAt: file, options: [], error: &error) { url in
136 let text = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
137 try? UserConfig.setting(text, section: setting.section, key: setting.key, to: value).write(to: url, atomically: true, encoding: .utf8)
138 }
139 } else {
140 switch value {
141 case .bool(let b): UserDefaults.standard.set(b, forKey: defaultsKey)
142 case .string(let s): UserDefaults.standard.set(s, forKey: defaultsKey)
143 case .integer(let i): UserDefaults.standard.set(i, forKey: defaultsKey)
144 }
145 }
146 reloadConfig()
147 }
148
122 /// The capture templates, for the share extension to offer. 149 /// The capture templates, for the share extension to offer.
123 private func shareTemplates() { 150 private func shareTemplates() {
124 try? inbox?.setTemplates(CaptureTemplates.load().templates.map { CaptureInbox.Template(key: $0.key, name: $0.name) }) 151 try? inbox?.setTemplates(CaptureTemplates.load().templates.map { CaptureInbox.Template(key: $0.key, name: $0.name) })
Sources/OrgstarMobile/OrgstarMobileApp.swift +7
@@ -64,6 +64,8 @@ struct CaptureRequest: Identifiable {
64 let id = UUID() 64 let id = UUID()
65 var template: String? 65 var template: String?
66 var properties: [String: String] = [:] 66 var properties: [String: String] = [:]
67 /// An agenda day (a `Days` number) the entry is scheduled on.
68 var day: Int?
67} 69}
68 70
69extension CaptureRequest { 71extension CaptureRequest {
@@ -167,6 +169,11 @@ struct RootView: View {
167 // An open file takes in what changed on disk. 169 // An open file takes in what changed on disk.
168 if let path = workspace.changedFile?.path { session.fileChanged(path) } 170 if let path = workspace.changedFile?.path { session.fileChanged(path) }
169 } 171 }
172 .onChange(of: model.captureRequest?.id) {
173 guard let request = model.captureRequest else { return }
174 model.captureRequest = nil
175 capture = request
176 }
170 .sheet(item: $capture, onDismiss: showWaiting) { request in 177 .sheet(item: $capture, onDismiss: showWaiting) { request in
171 CaptureScreen(workspace: workspace, session: session, request: request) 178 CaptureScreen(workspace: workspace, session: session, request: request)
172 } 179 }
Sources/OrgstarMobile/SettingsScreen.swift +68
@@ -1,6 +1,7 @@
1#if os(iOS) 1#if os(iOS)
2import OrgApp 2import OrgApp
3import OrgCore 3import OrgCore
4import OrgPresentation
4import SwiftUI 5import SwiftUI
5import UIKit 6import UIKit
6 7
@@ -9,6 +10,13 @@ import UIKit
9struct SettingsScreen: View { 10struct SettingsScreen: View {
10 let model: MobileModel 11 let model: MobileModel
11 @State private var choosing = false 12 @State private var choosing = false
13 @State private var calendars: [CalendarChoice] = []
14
15 private struct CalendarChoice {
16 let id: String
17 let title: String
18 let color: RGBA?
19 }
12 20
13 var body: some View { 21 var body: some View {
14 Form { 22 Form {
@@ -24,6 +32,7 @@ struct SettingsScreen: View {
24 } footer: { 32 } footer: {
25 Text("A folder with config.toml and capture.toml, such as a synced copy of ~/.config/orgstar from your Mac. Changes to them apply as they sync.") 33 Text("A folder with config.toml and capture.toml, such as a synced copy of ~/.config/orgstar from your Mac. Changes to them apply as they sync.")
26 } 34 }
35 calendarSection
27 Section("In use") { 36 Section("In use") {
28 let _ = model.settingsVersion 37 let _ = model.settingsVersion
29 LabeledContent("TODO keywords", value: todoKeywords) 38 LabeledContent("TODO keywords", value: todoKeywords)
@@ -40,6 +49,65 @@ struct SettingsScreen: View {
40 } 49 }
41 } 50 }
42 51
52 /// `calendar-events` and `calendar-event-calendars`, written to config.toml.
53 private var calendarSection: some View {
54 let _ = model.settingsVersion
55 let on = OrgPreferences.calendarEvents()
56 let chosen = OrgPreferences.calendarEventCalendars()
57 return Section {
58 Toggle("Show Calendar Events", isOn: Binding {
59 on
60 } set: { value in
61 model.set("calendarEvents", to: .bool(value))
62 if value { Task { await requestCalendars() } }
63 })
64 if on {
65 if !CalendarEvents.isAuthorized {
66 Button("Allow Access to Calendars…") { Task { await requestCalendars() } }
67 }
68 ForEach(calendars, id: \.id) { calendar in
69 let shown = CalendarEvents.chooses(id: calendar.id, title: calendar.title, chosen: chosen)
70 Button {
71 toggle(calendar.title, shown: shown, chosen: chosen)
72 } label: {
73 HStack {
74 Circle().fill(calendar.color?.swiftUI ?? .secondary).frame(width: 10, height: 10)
75 Text(calendar.title).foregroundStyle(.primary)
76 Spacer()
77 if shown { Image(systemName: "checkmark").foregroundStyle(Color.accentColor) }
78 }
79 }
80 .tint(.primary)
81 }
82 }
83 } header: {
84 Text("Calendar")
85 } footer: {
86 Text(on ? "Events from the checked calendars show in the agenda, read-only." : "Show events from Calendar in the agenda, read-only.")
87 }
88 .task(id: on) { loadCalendars() }
89 }
90
91 private func requestCalendars() async {
92 _ = await model.calendarEvents.requestAccess()
93 loadCalendars()
94 model.reloadConfig()
95 }
96
97 private func loadCalendars() {
98 calendars = model.calendarEvents.calendars()
99 .sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }
100 .map { CalendarChoice(id: $0.id, title: $0.title, color: $0.color) }
101 }
102
103 /// Shows or hides a calendar's events; with every calendar checked the setting is empty.
104 private func toggle(_ title: String, shown: Bool, chosen: [String]) {
105 var titles = chosen.isEmpty ? calendars.map(\.title) : chosen
106 if shown { titles.removeAll { $0 == title } } else { titles.append(title) }
107 let value = Set(titles) == Set(calendars.map(\.title)) ? "" : titles.joined(separator: ", ")
108 model.set("calendarEventCalendars", to: .string(value))
109 }
110
43 /// The span and start day, worded as the Mac's Settings word the start. 111 /// The span and start day, worded as the Mac's Settings word the start.
44 private var agenda: String { 112 private var agenda: String {
45 func days(_ count: Int) -> String { count == 1 ? "1 day" : "\(count) days" } 113 func days(_ count: Int) -> String { count == 1 ? "1 day" : "\(count) days" }