krz/orgstar

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

Commit 618504036c

618504036c53cf3c7621b45e97b4d5473e247ccc

parent: fcb63f5b7b

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 22:49 UTC

iOS: settings from a synced config folder, and reminders

The iOS app takes config.toml and capture.toml from a folder you choose (a
synced copy of the Mac's ~/.config/orgstar), kept as a bookmark and applied at
launch, when its files change and when the app comes back. TODO keywords,
logging, tags column, agenda span and start day, subfolders and reminders
follow it; without one, the Mac's defaults apply instead of plain TODO/DONE.
Both apps read these settings through OrgPreferences.

Reminders: the next week's timed entries, at most 64, set on launch, return,
file changes and every hour; the agenda says how far ahead they go.

Layout: unified · split

Sources/OrgApp/OrgPreferences.swift added +59
@@ -0,0 +1,59 @@
1import Foundation
2import OrgCore
3
4/// The Org settings `config.toml` holds, read from user defaults with the file's fallbacks, for
5/// both apps.
6public enum OrgPreferences {
7 public static let defaultTodoKeywords = "TODO(t) PROJ(p) LOOP(r) STRT(s) WAIT(w) HOLD(h) IDEA(i) | DONE(d) KILL(k)"
8
9 /// TODO keywords for files without `#+TODO`, and alphabetical lists.
10 public static func orgSettings(_ defaults: UserDefaults = .standard) -> OrgSettings {
11 let line = defaults.string(forKey: "todoKeywords") ?? defaultTodoKeywords
12 var settings = OrgSettings.default.withTodoKeywords(line) ?? .default
13 settings.listAllowAlphabetical = bool("alphabeticalLists", defaults)
14 return settings
15 }
16
17 /// What commands run from the agenda and capture follow: logging and tags.
18 public static func options(_ defaults: UserDefaults = .standard) -> EditingOptions {
19 EditingOptions(
20 tagsColumn: integer("tagsColumn", defaults), insertHeadingRespectContent: bool("headingAfterSubtree", defaults),
21 listAllowAlphabetical: bool("alphabeticalLists", defaults), fillColumn: integer("fillColumn", defaults),
22 logDone: LogHow(rawValue: string("logDone", defaults)), logIntoDrawer: string("logIntoDrawer", defaults).nonEmpty,
23 logReschedule: LogHow(rawValue: string("logReschedule", defaults)), logRedeadline: LogHow(rawValue: string("logRedeadline", defaults))
24 )
25 }
26
27 public static func agendaSpan(_ defaults: UserDefaults = .standard) -> Int { integer("agendaSpan", defaults) }
28 public static func agendaStartOffset(_ defaults: UserDefaults = .standard) -> Int { integer("agendaStartOffset", defaults) }
29 public static func agendaSubfolders(_ defaults: UserDefaults = .standard) -> Bool { bool("agendaSubfolders", defaults) }
30 public static func reminders(_ defaults: UserDefaults = .standard) -> Bool { bool("reminders", defaults) }
31 /// `appt-message-warning-time`, in minutes.
32 public static func reminderLead(_ defaults: UserDefaults = .standard) -> Int { integer("reminderLead", defaults) }
33
34 private static func fallback(_ key: String) -> TOML.Value? {
35 UserConfig.settings.first { $0.defaultsKey == key }?.fallback
36 }
37
38 private static func bool(_ key: String, _ defaults: UserDefaults) -> Bool {
39 defaults.object(forKey: key) as? Bool ?? fallback(key)?.bool ?? false
40 }
41
42 private static func string(_ key: String, _ defaults: UserDefaults) -> String {
43 defaults.string(forKey: key) ?? fallback(key)?.string ?? ""
44 }
45
46 /// Integers, and day offsets stored as integers with `"-3d"` as the fallback.
47 private static func integer(_ key: String, _ defaults: UserDefaults) -> Int {
48 if let value = defaults.object(forKey: key) as? Int { return value }
49 switch fallback(key) {
50 case .integer(let value)?: return value
51 case .string(let text)?: return Int(text.trimmingCharacters(in: CharacterSet(charactersIn: "+d"))) ?? 0
52 default: return 0
53 }
54 }
55}
56
57private extension String {
58 var nonEmpty: String? { isEmpty ? nil : self }
59}
Sources/OrgApp/UserConfig.swift +4
@@ -8,7 +8,11 @@ import OrgWorkspace
8/// dotfiles repository and share. The file is the source of truth: Settings writes its changes 8/// dotfiles repository and share. The file is the source of truth: Settings writes its changes
9/// into it, keeping comments and order, and edits to it apply while the app runs. 9/// into it, keeping comments and order, and edits to it apply while the app runs.
10public enum UserConfig { 10public enum UserConfig {
11 /// A folder chosen in the app (on iOS, a synced copy of the Mac's), used over the default.
12 nonisolated(unsafe) public static var directoryOverride: URL?
13
11 public static var directory: URL { 14 public static var directory: URL {
15 if let directoryOverride { return directoryOverride }
12 let environment = ProcessInfo.processInfo.environment 16 let environment = ProcessInfo.processInfo.environment
13 if let override = environment["ORGSTAR_CONFIG_DIR"] { return URL(fileURLWithPath: override) } 17 if let override = environment["ORGSTAR_CONFIG_DIR"] { return URL(fileURLWithPath: override) }
14 // Development runs keep everything in their data folder. 18 // Development runs keep everything in their data folder.
Sources/OrgApp/WorkspaceModel.swift +7 −1
@@ -21,7 +21,13 @@ public final class WorkspaceModel {
21 public private(set) var changedFile: (path: String, version: Int)? 21 public private(set) var changedFile: (path: String, version: Int)?
22 22
23 public let index: IndexStore 23 public let index: IndexStore
24 public let settings: IndexSettings 24 /// A change (other TODO keywords) reindexes the files it affects.
25 public var settings: IndexSettings {
26 didSet {
27 guard settings.version != oldValue.version else { return }
28 Task { await refresh() }
29 }
30 }
25 private let rootsFile: URL 31 private let rootsFile: URL
26 /// Resolved root folders, keyed by their resolved path, which is how the index stores them. 32 /// Resolved root folders, keyed by their resolved path, which is how the index stores them.
27 private var resolvedRoots: [String: URL] = [:] 33 private var resolvedRoots: [String: URL] = [:]
Sources/Orgstar/Preferences.swift +2 −5
@@ -4,17 +4,14 @@ import OrgCore
4 4
5/// Org preferences, stored in user defaults by the Settings window. 5/// Org preferences, stored in user defaults by the Settings window.
6enum Preferences { 6enum Preferences {
7 static let defaultTodoKeywords = "TODO(t) PROJ(p) LOOP(r) STRT(s) WAIT(w) HOLD(h) IDEA(i) | DONE(d) KILL(k)" 7 static let defaultTodoKeywords = OrgPreferences.defaultTodoKeywords
8 8
9 /// TODO keywords for files without `#+TODO`, read at launch. 9 /// TODO keywords for files without `#+TODO`, read at launch.
10 @MainActor 10 @MainActor
11 static var orgSettings: OrgSettings { 11 static var orgSettings: OrgSettings {
12 // config.toml applies before anything reads settings. 12 // config.toml applies before anything reads settings.
13 ConfigSync.shared.start() 13 ConfigSync.shared.start()
14 let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords 14 return OrgPreferences.orgSettings()
15 var settings = OrgSettings.default.withTodoKeywords(line) ?? .default
16 settings.listAllowAlphabetical = UserDefaults.standard.object(forKey: "alphabeticalLists") as? Bool ?? true
17 return settings
18 } 15 }
19 16
20 static func options( 17 static func options(
Sources/OrgstarMobile/AgendaScreen.swift +19 −1
@@ -8,6 +8,7 @@ struct AgendaScreen: View {
8 let workspace: WorkspaceModel 8 let workspace: WorkspaceModel
9 let session: DocumentSession 9 let session: DocumentSession
10 @Bindable var agenda: AgendaModel 10 @Bindable var agenda: AgendaModel
11 let scheduler: ReminderScheduler
11 @State private var message: String? 12 @State private var message: String?
12 @State private var tick = 0 13 @State private var tick = 0
13 14
@@ -28,6 +29,9 @@ struct AgendaScreen: View {
28 var tick: Int 29 var tick: Int
29 var view: String 30 var view: String
30 var start: Int? 31 var start: Int?
32 var span: Int
33 var startOffset: Int
34 var subfolders: Bool
31 } 35 }
32 36
33 var body: some SwiftUI.View { 37 var body: some SwiftUI.View {
@@ -45,6 +49,9 @@ struct AgendaScreen: View {
45 } else { 49 } else {
46 ForEach(agenda.list.indices, id: \.self) { i in row(agenda.list[i]) } 50 ForEach(agenda.list.indices, id: \.self) { i in row(agenda.list[i]) }
47 } 51 }
52 if let reminderStatus {
53 Section { } footer: { Text(reminderStatus) }
54 }
48 } 55 }
49 .listStyle(.insetGrouped) 56 .listStyle(.insetGrouped)
50 .navigationTitle(agenda.isComputing && agenda.days.isEmpty ? "Agenda…" : "Agenda") 57 .navigationTitle(agenda.isComputing && agenda.days.isEmpty ? "Agenda…" : "Agenda")
@@ -81,11 +88,22 @@ struct AgendaScreen: View {
81 await agenda.refresh(open: session.openTexts) 88 await agenda.refresh(open: session.openTexts)
82 } 89 }
83 .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick, 90 .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick,
84 view: view.wrappedValue.rawValue, start: agenda.start)) { 91 view: view.wrappedValue.rawValue, start: agenda.start, span: agenda.span, startOffset: agenda.startOffset,
92 subfolders: agenda.includeSubfolders)) {
85 await agenda.refresh(open: session.openTexts) 93 await agenda.refresh(open: session.openTexts)
86 } 94 }
87 } 95 }
88 96
97 /// How far ahead notifications are set, as the design asks the agenda to show: iOS keeps
98 /// at most 64, and sets no more while the app isn't opened.
99 private var reminderStatus: String? {
100 guard OrgPreferences.reminders() else { return nil }
101 if !scheduler.isAuthorized { return "Notifications are off for Orgstar in Settings." }
102 guard let through = scheduler.scheduledThrough else { return nil }
103 return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
104 + ". Open Orgstar to set later ones."
105 }
106
89 private func row(_ item: AgendaItem) -> some SwiftUI.View { 107 private func row(_ item: AgendaItem) -> some SwiftUI.View {
90 VStack(alignment: .leading, spacing: 2) { 108 VStack(alignment: .leading, spacing: 2) {
91 HStack(alignment: .firstTextBaseline, spacing: 6) { 109 HStack(alignment: .firstTextBaseline, spacing: 6) {
Sources/OrgstarMobile/MobileModel.swift added +131
@@ -0,0 +1,131 @@
1#if os(iOS)
2import Foundation
3import Observation
4import OrgApp
5import OrgCore
6import OrgIndex
7import OrgWorkspace
8
9/// The app's models, and the settings from the configuration folder: a folder you choose
10/// (a synced copy of the Mac's `~/.config/orgstar`) with `config.toml` and `capture.toml`.
11@MainActor
12@Observable
13final class MobileModel {
14 let workspace: WorkspaceModel?
15 let failure: String?
16 let session = DocumentSession()
17 let agenda: AgendaModel?
18 let scheduler = ReminderScheduler(center: SystemReminderCenter())
19 private(set) var configFolder: URL?
20 private(set) var configProblems: [String] = []
21 /// Bumped when settings apply, so views that read them update.
22 private(set) var settingsVersion = 0
23 @ObservationIgnored private var presenter: FolderPresenter?
24
25 private static let bookmarkKey = "configFolderBookmark"
26
27 init() {
28 // Development: `ORGSTAR_CONFIG_DIR` stands in for a chosen folder.
29 let folder = Self.resolveBookmark() ?? ProcessInfo.processInfo.environment["ORGSTAR_CONFIG_DIR"].map { URL(fileURLWithPath: $0) }
30 UserConfig.directoryOverride = folder
31 let problems = Self.readConfig(folder)
32 let settings = OrgPreferences.orgSettings()
33 do {
34 let workspace = try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: settings))
35 self.workspace = workspace
36 agenda = AgendaModel(workspace: workspace)
37 failure = nil
38 } catch {
39 workspace = nil
40 agenda = nil
41 failure = error.localizedDescription
42 }
43 configFolder = folder
44 configProblems = problems
45 applySettings()
46 watchConfig()
47 }
48
49 /// Chooses the configuration folder, or with nil stops using one; its settings apply now.
50 func setConfigFolder(_ url: URL?) {
51 if let url {
52 _ = url.startAccessingSecurityScopedResource()
53 if let bookmark = try? url.bookmarkData() { UserDefaults.standard.set(bookmark, forKey: Self.bookmarkKey) }
54 } else {
55 UserDefaults.standard.removeObject(forKey: Self.bookmarkKey)
56 // What the folder set no longer applies.
57 for setting in UserConfig.settings { UserDefaults.standard.removeObject(forKey: setting.defaultsKey) }
58 }
59 configFolder = url
60 reloadConfig()
61 watchConfig()
62 }
63
64 func reloadConfig() {
65 UserConfig.directoryOverride = configFolder
66 configProblems = Self.readConfig(configFolder)
67 applySettings()
68 }
69
70 private func applySettings() {
71 let settings = OrgPreferences.orgSettings()
72 session.defaults = settings
73 session.options = OrgPreferences.options()
74 workspace?.settings = .with(todoKeywords: settings)
75 if let agenda {
76 agenda.span = OrgPreferences.agendaSpan()
77 agenda.startOffset = OrgPreferences.agendaStartOffset()
78 agenda.includeSubfolders = OrgPreferences.agendaSubfolders()
79 }
80 settingsVersion += 1
81 }
82
83 /// Edits to the folder's files, synced from the Mac, apply as they arrive.
84 private func watchConfig() {
85 presenter?.stop()
86 presenter = nil
87 guard let configFolder else { return }
88 let presenter = FolderPresenter(root: configFolder) { [weak self] _ in
89 Task { @MainActor [weak self] in self?.reloadConfig() }
90 }
91 presenter.start()
92 self.presenter = presenter
93 }
94
95 private static func resolveBookmark() -> URL? {
96 guard let data = UserDefaults.standard.data(forKey: bookmarkKey) else { return nil }
97 var stale = false
98 guard let url = try? URL(resolvingBookmarkData: data, bookmarkDataIsStale: &stale) else { return nil }
99 _ = url.startAccessingSecurityScopedResource()
100 if stale, let fresh = try? url.bookmarkData() { UserDefaults.standard.set(fresh, forKey: bookmarkKey) }
101 return url
102 }
103
104 /// Applies the folder's `config.toml` to the settings; returns its problems.
105 private static func readConfig(_ folder: URL?) -> [String] {
106 guard let folder else { return [] }
107 let file = folder.appendingPathComponent("config.toml")
108 var text: String?
109 var error: NSError?
110 NSFileCoordinator(filePresenter: nil).coordinate(readingItemAt: file, options: [], error: &error) { url in
111 text = try? String(contentsOf: url, encoding: .utf8)
112 }
113 guard let text else { return ["No config.toml in \(folder.lastPathComponent); the defaults apply."] }
114 return UserConfig.apply(text, to: .standard)
115 }
116
117 /// Notifications for the next week's timed entries, at most 64, as iOS allows.
118 func updateReminders() async {
119 guard let workspace else { return }
120 guard OrgPreferences.reminders() else {
121 await scheduler.clear()
122 return
123 }
124 guard !workspace.files.isEmpty else { return }
125 let agenda = AgendaModel(workspace: workspace)
126 agenda.includeSubfolders = OrgPreferences.agendaSubfolders()
127 let reminders = await agenda.reminders(open: session.openTexts, lead: OrgPreferences.reminderLead())
128 await scheduler.reconcile(reminders)
129 }
130}
131#endif
Sources/OrgstarMobile/OrgstarMobileApp.swift +56 −14
@@ -2,25 +2,37 @@
2import OrgApp 2import OrgApp
3import OrgCore 3import OrgCore
4import SwiftUI 4import SwiftUI
5import UserNotifications
5 6
6/// Orgstar on iPhone and iPad: the agenda, capture, and the folders they read. 7/// Orgstar on iPhone and iPad: the agenda, capture, and the folders they read.
7@main 8@main
8struct OrgstarMobileApp: App { 9struct OrgstarMobileApp: App {
9 @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) } 10 @UIApplicationDelegateAdaptor private var delegate: AppDelegate
10 @State private var session = DocumentSession() 11 @State private var model = MobileModel()
11 12
12 var body: some Scene { 13 var body: some Scene {
13 WindowGroup { 14 WindowGroup {
14 switch workspace { 15 if let workspace = model.workspace, let agenda = model.agenda {
15 case .success(let model): 16 RootView(model: model, workspace: workspace, agenda: agenda)
16 RootView(workspace: model, session: session) 17 } else {
17 case .failure(let error): 18 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? ""))
18 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
19 } 19 }
20 } 20 }
21 } 21 }
22} 22}
23 23
24/// Reminders show while the app is open too.
25final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
26 func application(_ application: UIApplication, didFinishLaunchingWithOptions options: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
27 UNUserNotificationCenter.current().delegate = self
28 return true
29 }
30
31 nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
32 [.banner, .sound, .list]
33 }
34}
35
24/// An org-protocol capture, or a plain one. 36/// An org-protocol capture, or a plain one.
25struct CaptureRequest: Identifiable { 37struct CaptureRequest: Identifiable {
26 let id = UUID() 38 let id = UUID()
@@ -29,22 +41,29 @@ struct CaptureRequest: Identifiable {
29} 41}
30 42
31struct RootView: View { 43struct RootView: View {
44 let model: MobileModel
32 let workspace: WorkspaceModel 45 let workspace: WorkspaceModel
33 let session: DocumentSession 46 let agenda: AgendaModel
34 @State private var agenda: AgendaModel
35 @State private var capture: CaptureRequest? 47 @State private var capture: CaptureRequest?
48 @State private var hour = 0
49 @Environment(\.scenePhase) private var scenePhase
50
51 private var session: DocumentSession { model.session }
36 52
37 init(workspace: WorkspaceModel, session: DocumentSession) { 53 private struct ReminderKey: Equatable {
38 self.workspace = workspace 54 var files: Int
39 self.session = session 55 var changed: Int?
40 _agenda = State(initialValue: AgendaModel(workspace: workspace)) 56 var edits: Int
57 var settings: Int
58 var active: Bool
59 var hour: Int
41 } 60 }
42 61
43 var body: some View { 62 var body: some View {
44 TabView { 63 TabView {
45 Tab("Agenda", systemImage: "calendar") { 64 Tab("Agenda", systemImage: "calendar") {
46 NavigationStack { 65 NavigationStack {
47 AgendaScreen(workspace: workspace, session: session, agenda: agenda) 66 AgendaScreen(workspace: workspace, session: session, agenda: agenda, scheduler: model.scheduler)
48 .toolbar { captureButton } 67 .toolbar { captureButton }
49 } 68 }
50 } 69 }
@@ -54,6 +73,9 @@ struct RootView: View {
54 .toolbar { captureButton } 73 .toolbar { captureButton }
55 } 74 }
56 } 75 }
76 Tab("Settings", systemImage: "gearshape") {
77 NavigationStack { SettingsScreen(model: model) }
78 }
57 } 79 }
58 .sheet(item: $capture) { request in 80 .sheet(item: $capture) { request in
59 CaptureScreen(workspace: workspace, session: session, request: request) 81 CaptureScreen(workspace: workspace, session: session, request: request)
@@ -64,6 +86,26 @@ struct RootView: View {
64 workspace.startWatching() 86 workspace.startWatching()
65 await workspace.refresh() 87 await workspace.refresh()
66 } 88 }
89 .task {
90 while !Task.isCancelled {
91 try? await Task.sleep(for: .seconds(3600))
92 hour += 1
93 }
94 }
95 // Reminders follow the files, the settings, coming back to the app and the hour.
96 .task(id: ReminderKey(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
97 settings: model.settingsVersion, active: scenePhase == .active, hour: hour)) {
98 guard scenePhase == .active else { return }
99 try? await Task.sleep(for: .seconds(1))
100 guard !Task.isCancelled else { return }
101 await model.updateReminders()
102 }
103 .onChange(of: scenePhase) { _, phase in
104 // Files and settings synced while away; not every sync tool tells file presenters.
105 guard phase == .active else { return }
106 model.reloadConfig()
107 Task { await workspace.refresh() }
108 }
67 .onOpenURL { url in 109 .onOpenURL { url in
68 if case .capture(let template, let properties)? = OrgProtocol.parse(url.absoluteString) { 110 if case .capture(let template, let properties)? = OrgProtocol.parse(url.absoluteString) {
69 capture = CaptureRequest(template: template, properties: properties) 111 capture = CaptureRequest(template: template, properties: properties)
Sources/OrgstarMobile/SettingsScreen.swift added +45
@@ -0,0 +1,45 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// Where settings come from: a folder with the Mac's `config.toml` and `capture.toml`, kept in
7/// sync by iCloud Drive or another app.
8struct SettingsScreen: View {
9 let model: MobileModel
10 @State private var choosing = false
11
12 var body: some View {
13 Form {
14 Section {
15 LabeledContent("Folder", value: model.configFolder?.lastPathComponent ?? "None")
16 Button(model.configFolder == nil ? "Choose Folder…" : "Choose Another Folder…") { choosing = true }
17 if model.configFolder != nil {
18 Button("Stop Using This Folder", role: .destructive) { model.setConfigFolder(nil) }
19 }
20 ForEach(model.configProblems, id: \.self) { Text($0).foregroundStyle(.red) }
21 } header: {
22 Text("Configuration")
23 } footer: {
24 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.")
25 }
26 Section("In use") {
27 let _ = model.settingsVersion
28 LabeledContent("TODO keywords", value: todoKeywords)
29 LabeledContent("Agenda", value: "\(OrgPreferences.agendaSpan()) days from \(OrgPreferences.agendaStartOffset())d")
30 LabeledContent("Reminders", value: OrgPreferences.reminders() ? "\(OrgPreferences.reminderLead()) min before" : "Off")
31 LabeledContent("Capture templates", value: CaptureTemplates.load().templates.map(\.key).joined(separator: " "))
32 }
33 }
34 .navigationTitle("Settings")
35 .fileImporter(isPresented: $choosing, allowedContentTypes: [.folder]) { result in
36 if case .success(let url) = result { model.setConfigFolder(url) }
37 }
38 }
39
40 private var todoKeywords: String {
41 OrgPreferences.orgSettings().todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }
42 .joined(separator: "; ")
43 }
44}
45#endif
Tests/OrgAppTests/OrgPreferencesTests.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgApp
5
6struct OrgPreferencesTests {
7 @Test func readsWhatConfigTomlSets() throws {
8 let defaults = try #require(UserDefaults(suiteName: "orgstar-preferences-\(UUID().uuidString)"))
9 // Nothing set: the file's fallbacks, which match the Mac's.
10 #expect(OrgPreferences.orgSettings(defaults).todoSequences.first?.active.map(\.name) == ["TODO", "PROJ", "LOOP", "STRT", "WAIT", "HOLD", "IDEA"])
11 #expect(OrgPreferences.options(defaults).logDone == nil && OrgPreferences.options(defaults).tagsColumn == -77)
12 #expect(OrgPreferences.agendaSpan(defaults) == 10 && OrgPreferences.agendaStartOffset(defaults) == -3)
13 #expect(OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 12)
14
15 let problems = UserConfig.apply("""
16 org-todo-keywords = "TODO NEXT | DONE"
17 org-log-done = "time"
18 org-log-into-drawer = "LOGBOOK"
19 org-agenda-span = 7
20 org-agenda-start-day = "-1d"
21 appt-message-warning-time = 30
22 [orgstar]
23 reminders = false
24 """, to: defaults)
25 #expect(problems.isEmpty, "\(problems)")
26 #expect(OrgPreferences.orgSettings(defaults).todoSequences.first?.active.map(\.name) == ["TODO", "NEXT"])
27 let options = OrgPreferences.options(defaults)
28 #expect(options.logDone == .time && options.logIntoDrawer == "LOGBOOK")
29 #expect(OrgPreferences.agendaSpan(defaults) == 7 && OrgPreferences.agendaStartOffset(defaults) == -1)
30 #expect(!OrgPreferences.reminders(defaults) && OrgPreferences.reminderLead(defaults) == 30)
31 }
32}