krz/orgstar

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

Commit f00fcfad5a

f00fcfad5a1c851204ac36fe0f968a34a11cadf6

parent: 3dd5b060e7

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 19:17 UTC

config.toml: Emacs variable names

Settings Emacs has a variable for sit at the top of config.toml under
that name (fill-column, org-tags-column, org-agenda-start-day as "-3d",
appt-message-warning-time, ...); the rest go in [orgstar]. A file with
the earlier names is read and written again with the new ones.

Layout: unified · split

Sources/OrgApp/ConfigSync.swift +7 −1
@@ -34,6 +34,12 @@ public final class ConfigSync {
34 try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) 34 try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
35 if FileManager.default.fileExists(atPath: url.path) { 35 if FileManager.default.fileExists(atPath: url.path) {
36 reload() 36 reload()
37 // A file from before settings took Emacs's names is written again with them.
38 if let text, UserConfig.usesLegacyNames(text) {
39 let template = UserConfig.template(from: defaults)
40 try? template.write(to: url, atomically: true, encoding: .utf8)
41 self.text = template
42 }
37 } else { 43 } else {
38 let template = UserConfig.template(from: defaults) 44 let template = UserConfig.template(from: defaults)
39 try? template.write(to: url, atomically: true, encoding: .utf8) 45 try? template.write(to: url, atomically: true, encoding: .utf8)
@@ -77,7 +83,7 @@ public final class ConfigSync {
77 let original = current 83 let original = current
78 for setting in UserConfig.settings where defaults.object(forKey: setting.defaultsKey) != nil { 84 for setting in UserConfig.settings where defaults.object(forKey: setting.defaultsKey) != nil {
79 let value = UserConfig.value(setting, in: defaults) 85 let value = UserConfig.value(setting, in: defaults)
80 if values[setting.section + "." + setting.key] != value { 86 if values[setting.path] != value {
81 current = UserConfig.setting(current, section: setting.section, key: setting.key, to: value) 87 current = UserConfig.setting(current, section: setting.section, key: setting.key, to: value)
82 } 88 }
83 } 89 }
Sources/OrgApp/UserConfig.swift +79 −42
@@ -31,12 +31,16 @@ public enum UserConfig {
31 public static var configFile: URL { directory.appendingPathComponent("config.toml") } 31 public static var configFile: URL { directory.appendingPathComponent("config.toml") }
32 32
33 /// One setting: where it sits in the file, its user-defaults key, and how values convert. 33 /// One setting: where it sits in the file, its user-defaults key, and how values convert.
34 /// Settings Emacs has a variable for sit at the top of the file under that variable's
35 /// name; the rest go in `[orgstar]`.
34 public struct Setting: Sendable { 36 public struct Setting: Sendable {
35 public enum Kind: Sendable { 37 public enum Kind: Sendable {
36 case bool, integer, string 38 case bool, integer, string
37 /// A string in the file for each stored value. 39 /// A string in the file for each stored value.
38 case choice([(name: String, stored: String)]) 40 case choice([(name: String, stored: String)])
39 case integerChoice([(name: String, stored: Int)]) 41 case integerChoice([(name: String, stored: Int)])
42 /// `org-agenda-start-day`: `"-3d"` in the file, -3 stored.
43 case dayOffset
40 } 44 }
41 45
42 public let section: String 46 public let section: String
@@ -45,47 +49,60 @@ public enum UserConfig {
45 public let kind: Kind 49 public let kind: Kind
46 public let fallback: TOML.Value 50 public let fallback: TOML.Value
47 public let comment: String 51 public let comment: String
52 /// Where earlier versions of the file kept it, `section.key`.
53 public let legacy: String
54
55 public var path: String { section.isEmpty ? key : section + "." + key }
48 } 56 }
49 57
50 public static let settings: [Setting] = [ 58 public static let settings: [Setting] = [
51 Setting(section: "general", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]), 59 Setting(section: "", key: "fill-column", defaultsKey: "fillColumn", kind: .integer, fallback: .integer(80),
52 fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)"), 60 comment: "M-q fills to this column", legacy: "editor.fill-column"),
53 Setting(section: "general", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]), 61 Setting(section: "", key: "org-tags-column", defaultsKey: "tagsColumn", kind: .integer, fallback: .integer(-77),
54 fallback: .string("emacs"), comment: "emacs, mac or doom; your own bindings go in keymap.toml"), 62 comment: "negative: tags end at that column; 0: one space before them", legacy: "editor.tags-column"),
55 Setting(section: "general", key: "option-as-meta", defaultsKey: "optionAsMeta", 63 Setting(section: "", key: "org-insert-heading-respect-content", defaultsKey: "headingAfterSubtree", kind: .bool, fallback: .bool(true),
56 kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"), comment: "left, right, both or none"), 64 comment: "M-RET adds the new heading after the subtree", legacy: "editor.insert-heading-respect-content"),
57 Setting(section: "editor", key: "tags-column", defaultsKey: "tagsColumn", kind: .integer, fallback: .integer(-77), 65 Setting(section: "", key: "org-M-RET-may-split-line", defaultsKey: "splitLine", kind: .bool, fallback: .bool(false),
58 comment: "org-tags-column: negative aligns tags to end there, 0 puts one space before them"), 66 comment: "M-RET splits the line at the caret", legacy: "editor.meta-return-may-split-line"),
59 Setting(section: "editor", key: "insert-heading-respect-content", defaultsKey: "headingAfterSubtree", kind: .bool, fallback: .bool(true), 67 Setting(section: "", key: "org-list-allow-alphabetical", defaultsKey: "alphabeticalLists", kind: .bool, fallback: .bool(true),
60 comment: "M-RET adds the new heading after the subtree"), 68 comment: "lists can use a. b. c.", legacy: "editor.list-allow-alphabetical"),
61 Setting(section: "editor", key: "meta-return-may-split-line", defaultsKey: "splitLine", kind: .bool, fallback: .bool(false), 69 Setting(section: "", key: "org-hide-emphasis-markers", defaultsKey: "hiddenMarkers", kind: .bool, fallback: .bool(true),
62 comment: "M-RET splits the line at the caret"), 70 comment: "hide *bold* and =code= markers when markup is hidden", legacy: "editor.hide-emphasis-markers"),
63 Setting(section: "editor", key: "list-allow-alphabetical", defaultsKey: "alphabeticalLists", kind: .bool, fallback: .bool(true), 71 Setting(section: "", key: "org-pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true),
64 comment: "lists can use a. b. c."), 72 comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"),
65 Setting(section: "editor", key: "fill-column", defaultsKey: "fillColumn", kind: .integer, fallback: .integer(80), comment: "M-q fills to this column"), 73 Setting(section: "", key: "org-todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"),
66 Setting(section: "editor", key: "hide-emphasis-markers", defaultsKey: "hiddenMarkers", kind: .bool, fallback: .bool(true), 74 comment: "one sequence, in #+TODO syntax, for files without #+TODO; read at launch", legacy: "editor.todo-keywords"),
67 comment: "org-hide-emphasis-markers: hide *bold* and =code= markers when markup is hidden"), 75 Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true),
68 Setting(section: "editor", key: "pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true), 76 comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"),
69 comment: "org-pretty-entities: \\alpha shows as α and x_{1} is lowered when markup is hidden"), 77 Setting(section: "", key: "org-agenda-span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10),
70 Setting(section: "editor", key: "todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"), 78 comment: "days the agenda shows", legacy: "agenda.span"),
71 comment: "for files without #+TODO, in #+TODO syntax; read at launch"), 79 Setting(section: "", key: "org-agenda-start-day", defaultsKey: "agendaStartOffset", kind: .dayOffset, fallback: .string("-3d"),
72 Setting(section: "editor", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false), 80 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
73 comment: "show link brackets and emphasis markers (View ▸ Show Markup)"), 81 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
74 Setting(section: "editor", key: "line-numbers", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true), 82 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
75 comment: "View ▸ Show Line Numbers"), 83 Setting(section: "orgstar", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]),
76 Setting(section: "agenda", key: "span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10), comment: "days shown"), 84 fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)", legacy: "general.save"),
77 Setting(section: "agenda", key: "start-offset", defaultsKey: "agendaStartOffset", kind: .integer, fallback: .integer(-3), 85 Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]),
78 comment: "first day, relative to today"), 86 fallback: .string("emacs"), comment: "emacs, mac or doom; your own bindings go in keymap.toml", legacy: "general.keymap"),
79 Setting(section: "agenda", key: "include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false), 87 Setting(section: "orgstar", key: "option-as-meta", defaultsKey: "optionAsMeta",
80 comment: "read org files in subfolders too"), 88 kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"),
81 Setting(section: "agenda", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true), 89 comment: "left, right, both or none", legacy: "general.option-as-meta"),
82 comment: "notify before timed entries"), 90 Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
83 Setting(section: "agenda", key: "reminder-lead", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12), 91 comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"),
84 comment: "minutes before; APPT_WARNTIME overrides"), 92 Setting(section: "orgstar", key: "agenda-include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false),
85 Setting(section: "capture", key: "global-hotkey", defaultsKey: "globalCapture", kind: .bool, fallback: .bool(true), 93 comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"),
86 comment: "⌃⌥Space opens Capture from any app; templates go in capture.toml"), 94 Setting(section: "orgstar", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true),
95 comment: "notify before timed entries", legacy: "agenda.reminders"),
96 Setting(section: "orgstar", key: "global-capture-hotkey", defaultsKey: "globalCapture", kind: .bool, fallback: .bool(true),
97 comment: "⌃⌥Space opens Capture from any app; templates go in capture.toml", legacy: "capture.global-hotkey"),
87 ] 98 ]
88 99
100 /// `"-3d"` as -3; nil for anything else.
101 static func dayOffset(_ s: String) -> Int? {
102 guard s.hasSuffix("d"), let n = Int(s.dropLast().replacingOccurrences(of: "+", with: "")) else { return nil }
103 return n
104 }
105
89 // MARK: - Values 106 // MARK: - Values
90 107
91 /// The file value for what `defaults` holds, or the fallback. 108 /// The file value for what `defaults` holds, or the fallback.
@@ -101,6 +118,9 @@ public enum UserConfig {
101 case .integerChoice(let choices): 118 case .integerChoice(let choices):
102 let stored = defaults.integer(forKey: setting.defaultsKey) 119 let stored = defaults.integer(forKey: setting.defaultsKey)
103 return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback 120 return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback
121 case .dayOffset:
122 let days = defaults.integer(forKey: setting.defaultsKey)
123 return .string((days < 0 ? "" : "+") + "\(days)d")
104 } 124 }
105 } 125 }
106 126
@@ -121,8 +141,11 @@ public enum UserConfig {
121 return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))" 141 return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))"
122 } 142 }
123 defaults.set(choice.stored, forKey: key) 143 defaults.set(choice.stored, forKey: key)
144 case (.dayOffset, .string(let s)):
145 guard let days = dayOffset(s) else { return "\(setting.key) must look like \"-3d\" or \"+0d\"" }
146 defaults.set(days, forKey: key)
124 default: 147 default:
125 return "\(setting.section).\(setting.key) has the wrong kind of value" 148 return "\(setting.path) has the wrong kind of value"
126 } 149 }
127 return nil 150 return nil
128 } 151 }
@@ -134,14 +157,15 @@ public enum UserConfig {
134 let tables: [TOML.Table] 157 let tables: [TOML.Table]
135 do { tables = try TOML.parse(text) } catch { return ["config.toml: \(error)"] } 158 do { tables = try TOML.parse(text) } catch { return ["config.toml: \(error)"] }
136 var problems: [String] = [] 159 var problems: [String] = []
137 let present = Set(tables.flatMap { table in table.values.keys.map { table.name + "." + $0 } }) 160 let present = Set(tables.flatMap { table in table.values.keys.map { path(table.name, $0) } })
138 for setting in settings where !present.contains(setting.section + "." + setting.key) { 161 for setting in settings where !present.contains(setting.path) && !present.contains(setting.legacy) {
139 if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) } 162 if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) }
140 } 163 }
141 for table in tables { 164 for table in tables {
142 for (key, value) in table.values.sorted(by: { $0.key < $1.key }) { 165 for (key, value) in table.values.sorted(by: { $0.key < $1.key }) {
143 guard let setting = settings.first(where: { $0.section == table.name && $0.key == key }) else { 166 let name = path(table.name, key)
144 problems.append("config.toml line \(table.line): unknown setting \(table.name.isEmpty ? "" : table.name + ".")\(key)") 167 guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else {
168 problems.append("config.toml: unknown setting \(name)")
145 continue 169 continue
146 } 170 }
147 if let current = currentValue(setting, in: defaults), current == value { continue } 171 if let current = currentValue(setting, in: defaults), current == value { continue }
@@ -151,6 +175,14 @@ public enum UserConfig {
151 return problems 175 return problems
152 } 176 }
153 177
178 static func path(_ section: String, _ key: String) -> String { section.isEmpty ? key : section + "." + key }
179
180 /// Whether the file uses names from before settings took Emacs's names.
181 public static func usesLegacyNames(_ text: String) -> Bool {
182 let names = Set(settings.map(\.legacy))
183 return values(in: text).keys.contains { names.contains($0) }
184 }
185
154 private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? { 186 private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? {
155 defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults) 187 defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults)
156 } 188 }
@@ -159,7 +191,7 @@ public enum UserConfig {
159 static func values(in text: String) -> [String: TOML.Value] { 191 static func values(in text: String) -> [String: TOML.Value] {
160 guard let tables = try? TOML.parse(text) else { return [:] } 192 guard let tables = try? TOML.parse(text) else { return [:] }
161 var result: [String: TOML.Value] = [:] 193 var result: [String: TOML.Value] = [:]
162 for table in tables { for (key, value) in table.values { result[table.name + "." + key] = value } } 194 for table in tables { for (key, value) in table.values { result[path(table.name, key)] = value } }
163 return result 195 return result
164 } 196 }
165 197
@@ -180,6 +212,8 @@ public enum UserConfig {
180 # apply while Orgstar runs. Keys go in keymap.toml, capture templates in capture.toml 212 # apply while Orgstar runs. Keys go in keymap.toml, capture templates in capture.toml
181 # and saved agenda views in views.toml, all in this folder. 213 # and saved agenda views in views.toml, all in this folder.
182 214
215 # Emacs variables, by their Emacs names.
216
183 """ 217 """
184 var section = "" 218 var section = ""
185 for setting in settings { 219 for setting in settings {
@@ -215,6 +249,9 @@ public enum UserConfig {
215 } 249 }
216 if let lastInSection { 250 if let lastInSection {
217 lines.insert("\(key) = \(format(value))", at: lastInSection + 1) 251 lines.insert("\(key) = \(format(value))", at: lastInSection + 1)
252 } else if section.isEmpty {
253 let firstHeader = lines.firstIndex { $0.trimmingCharacters(in: .whitespaces).hasPrefix("[") } ?? lines.count
254 lines.insert(contentsOf: ["\(key) = \(format(value))", ""], at: firstHeader)
218 } else { 255 } else {
219 if lines.last == "" { lines.removeLast() } 256 if lines.last == "" { lines.removeLast() }
220 lines += ["", "[\(section)]", "\(key) = \(format(value))", ""] 257 lines += ["", "[\(section)]", "\(key) = \(format(value))", ""]
Tests/OrgAppTests/UserConfigTests.swift +24 −5
@@ -20,6 +20,8 @@ struct UserConfigTests {
20 source.set(false, forKey: "showLineNumbers") 20 source.set(false, forKey: "showLineNumbers")
21 let text = UserConfig.template(from: source) 21 let text = UserConfig.template(from: source)
22 #expect(text.contains("keymap = \"doom\"")) 22 #expect(text.contains("keymap = \"doom\""))
23 #expect(text.contains("\nfill-column = 72"))
24 #expect(text.contains("org-agenda-start-day = \"-3d\""))
23 #expect(text.contains("option-as-meta = \"both\"")) 25 #expect(text.contains("option-as-meta = \"both\""))
24 let target = defaults() 26 let target = defaults()
25 #expect(UserConfig.apply(text, to: target).isEmpty) 27 #expect(UserConfig.apply(text, to: target).isEmpty)
@@ -32,10 +34,10 @@ struct UserConfigTests {
32 @Test func problemsAndRemovedLines() { 34 @Test func problemsAndRemovedLines() {
33 let target = defaults() 35 let target = defaults()
34 target.set(60, forKey: "fillColumn") 36 target.set(60, forKey: "fillColumn")
35 let problems = UserConfig.apply("[general]\nkeymap = \"vim\"\ncolour = 1\n[editor]\nfill-column = \"wide\"\n", to: target) 37 let problems = UserConfig.apply("fill-column = \"wide\"\norg-agenda-start-day = \"soon\"\n[orgstar]\nkeymap = \"vim\"\ncolour = 1\n", to: target)
36 #expect(problems.count == 3) 38 #expect(problems.count == 4)
37 #expect(target.integer(forKey: "fillColumn") == 60) 39 #expect(target.integer(forKey: "fillColumn") == 60)
38 #expect(UserConfig.apply("[editor]\n", to: target).isEmpty) 40 #expect(UserConfig.apply("[orgstar]\n", to: target).isEmpty)
39 #expect(target.object(forKey: "fillColumn") == nil) 41 #expect(target.object(forKey: "fillColumn") == nil)
40 } 42 }
41 43
@@ -49,6 +51,21 @@ struct UserConfigTests {
49 == "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n\n[capture]\nglobal-hotkey = false\n") 51 == "# mine\n[editor]\nfill-column = 80 # wide\nline-numbers = true\n\n[agenda]\nspan = 10\n\n[capture]\nglobal-hotkey = false\n")
50 #expect(UserConfig.setting("[general]\nkeymap = \"emacs#1\" # c\n", section: "general", key: "keymap", to: .string("doom")) 52 #expect(UserConfig.setting("[general]\nkeymap = \"emacs#1\" # c\n", section: "general", key: "keymap", to: .string("doom"))
51 == "[general]\nkeymap = \"doom\" # c\n") 53 == "[general]\nkeymap = \"doom\" # c\n")
54 #expect(UserConfig.setting("# top\n\n[orgstar]\nsave = \"explicit\"\n", section: "", key: "fill-column", to: .integer(70))
55 == "# top\nfill-column = 70\n\n[orgstar]\nsave = \"explicit\"\n")
56 }
57
58 @Test func legacyFilesAreRewritten() throws {
59 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)")
60 defer { try? FileManager.default.removeItem(at: folder) }
61 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
62 let url = folder.appendingPathComponent("config.toml")
63 try "[editor]\nfill-column = 64\n[general]\nkeymap = \"doom\"\n".write(to: url, atomically: true, encoding: .utf8)
64 let store = defaults()
65 ConfigSync(url: url, defaults: store).start(watch: false)
66 #expect(store.integer(forKey: "fillColumn") == 64)
67 let text = try String(contentsOf: url, encoding: .utf8)
68 #expect(text.contains("\nfill-column = 64") && text.contains("keymap = \"doom\"") && !text.contains("[editor]"))
52 } 69 }
53 70
54 @Test func syncsBothWays() throws { 71 @Test func syncsBothWays() throws {
@@ -58,16 +75,18 @@ struct UserConfigTests {
58 let store = defaults() 75 let store = defaults()
59 let sync = ConfigSync(url: url, defaults: store) 76 let sync = ConfigSync(url: url, defaults: store)
60 sync.start(watch: false) 77 sync.start(watch: false)
61 #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 80")) 78 #expect(try String(contentsOf: url, encoding: .utf8).contains("\nfill-column = 80"))
62 79
63 store.set(66, forKey: "fillColumn") 80 store.set(66, forKey: "fillColumn")
64 #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 66")) 81 #expect(try String(contentsOf: url, encoding: .utf8).contains("fill-column = 66"))
65 82
66 var text = try String(contentsOf: url, encoding: .utf8) 83 var text = try String(contentsOf: url, encoding: .utf8)
67 text = text.replacingOccurrences(of: "span = 10", with: "span = 7") 84 text = text.replacingOccurrences(of: "org-agenda-span = 10", with: "org-agenda-span = 7")
85 .replacingOccurrences(of: "org-agenda-start-day = \"-3d\"", with: "org-agenda-start-day = \"+0d\"")
68 try text.write(to: url, atomically: true, encoding: .utf8) 86 try text.write(to: url, atomically: true, encoding: .utf8)
69 sync.reload() 87 sync.reload()
70 #expect(store.integer(forKey: "agendaSpan") == 7) 88 #expect(store.integer(forKey: "agendaSpan") == 7)
89 #expect(store.integer(forKey: "agendaStartOffset") == 0)
71 #expect(sync.problems.isEmpty) 90 #expect(sync.problems.isEmpty)
72 } 91 }
73} 92}