Sources/OrgApp/UserConfig.swift
451 lines · 29979 bytes
1import Foundation
2import OrgCore
3import OrgPresentation
4import OrgWorkspace
5
6/// The configuration folder (`$XDG_CONFIG_HOME/orgstar`, else `~/.config/orgstar`) and
7/// `config.toml` in it: every setting of the Settings window as text, to edit, keep in a
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.
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
14 public static var directory: URL {
15 if let directoryOverride { return directoryOverride }
16 let environment = ProcessInfo.processInfo.environment
17 if let override = environment["ORGSTAR_CONFIG_DIR"] { return URL(fileURLWithPath: override) }
18 // Development runs keep everything in their data folder.
19 if let data = environment["ORGSTAR_DATA_DIR"] { return URL(fileURLWithPath: data) }
20 if let xdg = environment["XDG_CONFIG_HOME"], !xdg.isEmpty {
21 return URL(fileURLWithPath: xdg).appendingPathComponent("orgstar")
22 }
23 return URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent(".config/orgstar")
24 }
25
26 /// The theme `config.toml` (`text`) sets: its `[theme]` tables' colors over those of
27 /// `theme-file` (a path relative to `folder`), over the default theme; with problems in
28 /// both. Nil when `text` isn't TOML.
29 public static func theme(
30 config text: String, themeFile name: String?, in folder: URL,
31 read: (URL) -> String? = { try? String(contentsOf: $0, encoding: .utf8) }
32 ) -> (theme: ThemeSpec, problems: [String])? {
33 guard let tables = try? TOML.parse(text) else { return nil }
34 var spec = ThemeSpec.default
35 var problems: [String] = []
36 if let name, !name.isEmpty {
37 if let themeText = read(folder.appendingPathComponent(name)) {
38 do {
39 (spec, problems) = ThemeSpec.reading(try TOML.parse(themeText), file: name)
40 } catch {
41 problems.append("\(name): \(error)")
42 }
43 } else {
44 problems.append("config.toml: theme-file \(name) can't be read")
45 }
46 }
47 let (merged, more) = ThemeSpec.reading(tables, over: spec)
48 return (merged, problems + more)
49 }
50
51 /// Where earlier versions kept the configuration files.
52 public static var legacyDirectory: URL {
53 FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Orgstar")
54 }
55
56 /// A file of the configuration folder; one that exists only where earlier versions kept
57 /// it (Application Support) is still read from there.
58 public static func file(_ name: String) -> URL {
59 let url = directory.appendingPathComponent(name)
60 let legacy = legacyDirectory.appendingPathComponent(name)
61 let manager = FileManager.default
62 if !manager.fileExists(atPath: url.path), manager.fileExists(atPath: legacy.path) { return legacy }
63 return url
64 }
65
66 public static var configFile: URL { directory.appendingPathComponent("config.toml") }
67
68 /// One setting: where it sits in the file, its user-defaults key, and how values convert.
69 /// Settings Emacs has a variable for sit at the top of the file under that variable's
70 /// name; the rest go in `[orgstar]`.
71 public struct Setting: Sendable {
72 public enum Kind: Sendable {
73 case bool, integer, string
74 /// A string in the file for each stored value.
75 case choice([(name: String, stored: String)])
76 case integerChoice([(name: String, stored: Int)])
77 /// `org-agenda-start-day`: `"-3d"` in the file, -3 stored.
78 case dayOffset
79 }
80
81 public let section: String
82 public let key: String
83 public let defaultsKey: String
84 public let kind: Kind
85 public let fallback: TOML.Value
86 public let comment: String
87 /// Where earlier versions of the file kept it, `section.key`.
88 public let legacy: String
89
90 public var path: String { section.isEmpty ? key : section + "." + key }
91 }
92
93 public static let settings: [Setting] = [
94 Setting(section: "", key: "fill-column", defaultsKey: "fillColumn", kind: .integer, fallback: .integer(80),
95 comment: "M-q fills to this column", legacy: "editor.fill-column"),
96 Setting(section: "", key: "org-tags-column", defaultsKey: "tagsColumn", kind: .integer, fallback: .integer(-77),
97 comment: "negative: tags end at that column; 0: one space before them", legacy: "editor.tags-column"),
98 Setting(section: "", key: "org-insert-heading-respect-content", defaultsKey: "headingAfterSubtree", kind: .bool, fallback: .bool(true),
99 comment: "M-RET adds the new heading after the subtree", legacy: "editor.insert-heading-respect-content"),
100 Setting(section: "", key: "org-M-RET-may-split-line", defaultsKey: "splitLine", kind: .bool, fallback: .bool(false),
101 comment: "M-RET splits the line at the caret", legacy: "editor.meta-return-may-split-line"),
102 Setting(section: "", key: "org-list-allow-alphabetical", defaultsKey: "alphabeticalLists", kind: .bool, fallback: .bool(true),
103 comment: "lists can use a. b. c.", legacy: "editor.list-allow-alphabetical"),
104 Setting(section: "", key: "org-hide-emphasis-markers", defaultsKey: "hiddenMarkers", kind: .bool, fallback: .bool(true),
105 comment: "hide *bold* and =code= markers when markup is hidden", legacy: "editor.hide-emphasis-markers"),
106 Setting(section: "", key: "org-pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true),
107 comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"),
108 Setting(section: "", key: "org-todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO(t) PROJ(p) LOOP(r) STRT(s) WAIT(w) HOLD(h) IDEA(i) | DONE(d) KILL(k)"),
109 comment: "for files without #+TODO, in #+TODO syntax, one sequence per line (\\n); keys in parentheses turn on fast selection; read at launch", legacy: "editor.todo-keywords"),
110 Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
111 fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""),
112 Setting(section: "", key: "org-log-reschedule", defaultsKey: "logReschedule", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
113 fallback: .string("nil"), comment: "nil, time or note: log changing or removing a SCHEDULED date", legacy: ""),
114 Setting(section: "", key: "org-log-redeadline", defaultsKey: "logRedeadline", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
115 fallback: .string("nil"), comment: "nil, time or note: log changing or removing a DEADLINE", legacy: ""),
116 Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""),
117 comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""),
118 Setting(section: "", key: "org-startup-indented", defaultsKey: "startupIndented", kind: .bool, fallback: .bool(true),
119 comment: "indent bodies under their headings (org-indent-mode); #+STARTUP: (no)indent overrides", legacy: ""),
120 Setting(section: "", key: "org-hide-leading-stars", defaultsKey: "hideLeadingStars", kind: .bool, fallback: .bool(false),
121 comment: "show only a heading's last star without indentation; #+STARTUP: hidestars/showstars overrides", legacy: ""),
122 Setting(section: "", key: "org-startup-align-all-tables", defaultsKey: "startupAlignAllTables", kind: .bool, fallback: .bool(false),
123 comment: "align every table when a file opens; #+STARTUP: (no)align overrides", legacy: ""),
124 Setting(section: "", key: "org-startup-truncated", defaultsKey: "startupTruncated", kind: .bool, fallback: .bool(false),
125 comment: "truncate long lines instead of wrapping them (Emacs's default is t; Doom wraps)", legacy: ""),
126 Setting(section: "", key: "spell-check", defaultsKey: "spellCheck", kind: .bool, fallback: .bool(false),
127 comment: "check spelling while typing, outside code, links, dates, tags and keywords", legacy: ""),
128 Setting(section: "", key: "electric-pair-mode", defaultsKey: "electricPair", kind: .bool, fallback: .bool(true),
129 comment: "type brackets, <> and quotes in pairs as electric-pair-mode does (off in plain Emacs; Doom pairs with smartparens)", legacy: ""),
130 Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false),
131 comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""),
132 Setting(section: "", key: "org-use-speed-commands", defaultsKey: "useSpeedCommands", kind: .bool, fallback: .bool(false),
133 comment: "single keys at the start of a heading line run commands (n, p, t, c, …)", legacy: ""),
134 Setting(section: "", key: "org-cycle-hide-drawer-startup", defaultsKey: "hideDrawerStartup", kind: .bool, fallback: .bool(true),
135 comment: "fold drawers when a file opens; #+STARTUP: nohidedrawers overrides", legacy: ""),
136 Setting(section: "", key: "org-cycle-hide-block-startup", defaultsKey: "hideBlockStartup", kind: .bool, fallback: .bool(false),
137 comment: "fold blocks when a file opens; #+STARTUP: hideblocks overrides", legacy: ""),
138 Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true),
139 comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"),
140 Setting(section: "", key: "org-agenda-span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10),
141 comment: "days the agenda shows", legacy: "agenda.span"),
142 Setting(section: "", key: "org-agenda-start-day", defaultsKey: "agendaStartOffset", kind: .dayOffset, fallback: .string("-3d"),
143 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
144 Setting(section: "", key: "org-agenda-show-all-dates", defaultsKey: "agendaShowAllDates", kind: .bool, fallback: .bool(false),
145 comment: "show days with no entries (Emacs's default is t; Orgstar hides them); today always shows", legacy: ""),
146 Setting(section: "", key: "org-agenda-skip-scheduled-if-done", defaultsKey: "agendaSkipScheduledIfDone", kind: .bool, fallback: .bool(false),
147 comment: "leave done entries out of the agenda even on the day they are scheduled", legacy: ""),
148 Setting(section: "", key: "org-agenda-skip-deadline-if-done", defaultsKey: "agendaSkipDeadlineIfDone", kind: .bool, fallback: .bool(false),
149 comment: "leave done entries out of the agenda even on the day they are due", legacy: ""),
150 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
151 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
152 Setting(section: "", key: "org-clock-idle-time", defaultsKey: "clockIdleTime", kind: .integer, fallback: .integer(0),
153 comment: "minutes idle with a clock running before asking what to do with the time; 0 never (nil)", legacy: ""),
154 Setting(section: "", key: "org-clock-history-length", defaultsKey: "clockHistoryLength", kind: .integer, fallback: .integer(5),
155 comment: "recently clocked entries to remember", legacy: ""),
156 Setting(section: "org-agenda-prefix-format", key: "agenda", defaultsKey: "agendaPrefixAgenda", kind: .string, fallback: .string(PrefixFormat.defaults["agenda"]!),
157 comment: "%c category, %t time, %s scheduled/deadline, %e effort, %l level, %b outline path; %-12 pads", legacy: ""),
158 Setting(section: "org-agenda-prefix-format", key: "todo", defaultsKey: "agendaPrefixTodo", kind: .string, fallback: .string(PrefixFormat.defaults["todo"]!),
159 comment: "the TODO list", legacy: ""),
160 Setting(section: "org-agenda-prefix-format", key: "tags", defaultsKey: "agendaPrefixTags", kind: .string, fallback: .string(PrefixFormat.defaults["tags"]!),
161 comment: "tag and property matches", legacy: ""),
162 Setting(section: "theme", key: "font", defaultsKey: "themeFont", kind: .string, fallback: .string(""),
163 comment: "a monospaced font family; \"\" for the system's", legacy: ""),
164 Setting(section: "theme", key: "font-size", defaultsKey: "themeFontSize", kind: .integer, fallback: .integer(13),
165 comment: "points", legacy: ""),
166 Setting(section: "theme", key: "line-spacing", defaultsKey: "themeLineSpacing", kind: .integer, fallback: .integer(2),
167 comment: "points between lines", legacy: ""),
168 Setting(section: "theme", key: "heading-size-step", defaultsKey: "themeHeadingStep", kind: .integer, fallback: .integer(1),
169 comment: "points a heading is larger than the level below; level 4 and deeper are body size", legacy: ""),
170 Setting(section: "theme", key: "theme-file", defaultsKey: "themeFile", kind: .string, fallback: .string(""),
171 comment: "a theme in its own file in this folder, under the colors set here; colors: see default-theme.toml", legacy: ""),
172 Setting(section: "orgstar", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]),
173 fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)", legacy: "general.save"),
174 Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]),
175 fallback: .string("emacs"), comment: "emacs, mac or doom; your own bindings go in keymap.toml", legacy: "general.keymap"),
176 Setting(section: "orgstar", key: "option-as-meta", defaultsKey: "optionAsMeta",
177 kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"),
178 comment: "left, right, both or none", legacy: "general.option-as-meta"),
179 Setting(section: "orgstar", key: "tab-bar", defaultsKey: "showTabBar", kind: .bool, fallback: .bool(false),
180 comment: "a tab for each open buffer above the editor (View ▸ Show Tab Bar)", legacy: ""),
181 Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
182 comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"),
183 Setting(section: "orgstar", key: "show-hidden-files", defaultsKey: "showHiddenFiles", kind: .bool, fallback: .bool(true),
184 comment: "list dotfiles and dot folders in your folders", legacy: ""),
185 Setting(section: "orgstar", key: "ignored-folders", defaultsKey: "ignoredFolders", kind: .string,
186 fallback: .string(ScanRules.defaultIgnoredFolders.sorted().joined(separator: " ")),
187 comment: "folder names never listed or searched, separated by spaces", legacy: ""),
188 Setting(section: "orgstar", key: "agenda-include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false),
189 comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"),
190 Setting(section: "orgstar", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true),
191 comment: "notify before timed entries", legacy: "agenda.reminders"),
192 Setting(section: "orgstar", key: "calendar-events", defaultsKey: "calendarEvents", kind: .bool, fallback: .bool(false),
193 comment: "show events from Calendar in the agenda, read-only", legacy: ""),
194 Setting(section: "orgstar", key: "calendar-event-calendars", defaultsKey: "calendarEventCalendars", kind: .string, fallback: .string(""),
195 comment: "the calendars whose events show, by title or identifier, separated by commas; \"\" for all", legacy: ""),
196 Setting(section: "orgstar", key: "global-capture-hotkey", defaultsKey: "globalCapture", kind: .bool, fallback: .bool(true),
197 comment: "⌃⌥Space opens Capture from any app; templates go in capture.toml", legacy: "capture.global-hotkey"),
198 ]
199
200 /// `"-3d"` as -3; nil for anything else.
201 static func dayOffset(_ s: String) -> Int? {
202 guard s.hasSuffix("d"), let n = Int(s.dropLast().replacingOccurrences(of: "+", with: "")) else { return nil }
203 return n
204 }
205
206 // MARK: - Values
207
208 /// The file value for what `defaults` holds, or the fallback.
209 public static func value(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value {
210 guard defaults.object(forKey: setting.defaultsKey) != nil else { return setting.fallback }
211 switch setting.kind {
212 case .bool: return .bool(defaults.bool(forKey: setting.defaultsKey))
213 case .integer: return .integer(defaults.integer(forKey: setting.defaultsKey))
214 case .string: return .string(defaults.string(forKey: setting.defaultsKey) ?? "")
215 case .choice(let choices):
216 let stored = defaults.string(forKey: setting.defaultsKey)
217 return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback
218 case .integerChoice(let choices):
219 let stored = defaults.integer(forKey: setting.defaultsKey)
220 return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback
221 case .dayOffset:
222 let days = defaults.integer(forKey: setting.defaultsKey)
223 return .string((days < 0 ? "" : "+") + "\(days)d")
224 }
225 }
226
227 /// Stores a file value; a value of the wrong kind is a problem.
228 static func store(_ value: TOML.Value, _ setting: Setting, in defaults: UserDefaults) -> String? {
229 let key = setting.defaultsKey
230 switch (setting.kind, value) {
231 case (.bool, .bool(let b)): defaults.set(b, forKey: key)
232 case (.integer, .integer(let i)): defaults.set(i, forKey: key)
233 case (.string, .string(let s)): defaults.set(s, forKey: key)
234 case (.choice(let choices), .string(let s)):
235 guard let choice = choices.first(where: { $0.name == s }) else {
236 return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))"
237 }
238 defaults.set(choice.stored, forKey: key)
239 case (.integerChoice(let choices), .string(let s)):
240 guard let choice = choices.first(where: { $0.name == s }) else {
241 return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))"
242 }
243 defaults.set(choice.stored, forKey: key)
244 case (.dayOffset, .string(let s)):
245 guard let days = dayOffset(s) else { return "\(setting.key) must look like \"-3d\" or \"+0d\"" }
246 defaults.set(days, forKey: key)
247 default:
248 return "\(setting.path) has the wrong kind of value"
249 }
250 return nil
251 }
252
253 /// Applies a file to `defaults`; settings it leaves out go back to their defaults.
254 /// Returns problems.
255 @discardableResult
256 public static func apply(_ text: String, to defaults: UserDefaults) -> [String] {
257 let tables: [TOML.Table]
258 do { tables = try TOML.parse(text) } catch { return ["config.toml: \(error)"] }
259 var problems: [String] = []
260 let present = Set(tables.flatMap { table in table.values.keys.map { path(table.name, $0) } })
261 for setting in settings where !present.contains(setting.path) && !present.contains(setting.legacy) {
262 if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) }
263 }
264 for table in tables {
265 // Colors are the theme's to read.
266 if ThemeSpec.isThemeTable(table.name), table.name != "theme" { continue }
267 for (key, value) in table.values.sorted(by: { $0.key < $1.key }) {
268 if table.name == "theme", !ThemeSpec.typeKeys.contains(key) { continue }
269 let name = path(table.name, key)
270 guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else {
271 problems.append("config.toml: unknown setting \(name)")
272 continue
273 }
274 if let current = currentValue(setting, in: defaults), current == value { continue }
275 if let problem = store(value, setting, in: defaults) { problems.append("config.toml: \(problem)") }
276 }
277 }
278 return problems
279 }
280
281 static func path(_ section: String, _ key: String) -> String { section.isEmpty ? key : section + "." + key }
282
283 /// Whether the file uses names from before settings took Emacs's names.
284 public static func usesLegacyNames(_ text: String) -> Bool {
285 let names = Set(settings.map(\.legacy))
286 return values(in: text).keys.contains { names.contains($0) }
287 }
288
289 /// `text` with settings under their old names moved to their current names and places,
290 /// keeping comments and everything else. A section the move leaves empty is removed.
291 public static func migratingLegacyNames(_ text: String) -> String {
292 let values = values(in: text)
293 var blocks: [[String]] = [[]]
294 for line in text.components(separatedBy: "\n") {
295 if line.trimmingCharacters(in: .whitespaces).hasPrefix("[") { blocks.append([]) }
296 blocks[blocks.count - 1].append(line)
297 }
298 var moves: [(setting: Setting, value: TOML.Value, comment: String)] = []
299 var kept: [String] = []
300 for block in blocks {
301 let header = block.first.map { $0.trimmingCharacters(in: .whitespaces) }.flatMap { $0.hasPrefix("[") ? $0 : nil }
302 let section = header.map { String($0.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces) } ?? ""
303 var remaining: [String] = []
304 for (index, line) in block.enumerated() {
305 guard header == nil || index > 0, let equals = line.firstIndex(of: "=") else {
306 remaining.append(line)
307 continue
308 }
309 let name = path(section, line[..<equals].trimmingCharacters(in: .whitespaces))
310 guard let setting = settings.first(where: { !$0.legacy.isEmpty && $0.legacy == name }), let value = values[name] else {
311 remaining.append(line)
312 continue
313 }
314 // A setting also under its new name keeps that one.
315 if values[setting.path] == nil {
316 moves.append((setting, value, commentStart(line, after: equals).map { String(line[$0...]) } ?? ""))
317 }
318 }
319 let emptied = header != nil && remaining.count < block.count
320 && remaining.dropFirst().allSatisfy { $0.trimmingCharacters(in: .whitespaces).isEmpty }
321 if !emptied { kept += remaining }
322 }
323 var out = kept.joined(separator: "\n")
324 if text.hasSuffix("\n"), !out.isEmpty, !out.hasSuffix("\n") { out += "\n" }
325 for move in moves {
326 out = setting(out, section: move.setting.section, key: move.setting.key, to: move.value)
327 guard !move.comment.isEmpty else { continue }
328 var lines = out.components(separatedBy: "\n")
329 if let index = lines.firstIndex(of: "\(move.setting.key) = \(format(move.value))") {
330 lines[index] += " " + move.comment
331 out = lines.joined(separator: "\n")
332 }
333 }
334 return out
335 }
336
337 private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? {
338 defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults)
339 }
340
341 /// The values a file sets, by `section.key`.
342 static func values(in text: String) -> [String: TOML.Value] {
343 guard let tables = try? TOML.parse(text) else { return [:] }
344 var result: [String: TOML.Value] = [:]
345 for table in tables { for (key, value) in table.values { result[path(table.name, key)] = value } }
346 return result
347 }
348
349 // MARK: - Text
350
351 static func format(_ value: TOML.Value) -> String {
352 switch value {
353 case .bool(let b): b ? "true" : "false"
354 case .integer(let i): String(i)
355 case .string(let s):
356 "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
357 .replacingOccurrences(of: "\n", with: "\\n").replacingOccurrences(of: "\t", with: "\\t") + "\""
358 }
359 }
360
361 /// A commented file with every setting at its current value.
362 public static func template(from defaults: UserDefaults) -> String {
363 var out = """
364 # Orgstar settings. The Settings window writes its changes here, and edits made here
365 # apply while Orgstar runs. Keys go in keymap.toml, capture templates in capture.toml
366 # and saved agenda views in views.toml, all in this folder.
367
368 # Emacs variables, by their Emacs names.
369
370 """
371 var section = ""
372 for setting in settings {
373 if setting.section != section {
374 section = setting.section
375 out += "\n[\(section)]\n"
376 }
377 out += "\(setting.key) = \(format(value(setting, in: defaults))) # \(setting.comment)\n"
378 }
379 return out
380 }
381
382 /// `text` with the sections it lacks entirely added at the end, every setting at its
383 /// value in `defaults`: how a file from an earlier version gets settings added since.
384 /// Single lines left out stay out; they mean the default.
385 public static func addingMissingSections(_ text: String, from defaults: UserDefaults) -> String {
386 let tables = (try? TOML.parse(text)) ?? []
387 let present = Set(tables.filter { !$0.values.isEmpty || $0.name != "" }.map(\.name))
388 var out = text
389 var section: String?
390 for setting in settings where !setting.section.isEmpty && !present.contains(setting.section) {
391 if setting.section != section {
392 section = setting.section
393 if !out.hasSuffix("\n") { out += "\n" }
394 out += "\n[\(setting.section)]\n"
395 }
396 out += "\(setting.key) = \(format(value(setting, in: defaults))) # \(setting.comment)\n"
397 }
398 return out
399 }
400
401 /// `text` with `section.key` set to `value`: the line's value is replaced (its comment
402 /// stays), or a line is added at the end of the section, or the section at the end.
403 public static func setting(_ text: String, section: String, key: String, to value: TOML.Value) -> String {
404 var lines = text.components(separatedBy: "\n")
405 var current = ""
406 var lastInSection: Int?
407 for (index, line) in lines.enumerated() {
408 let trimmed = line.trimmingCharacters(in: .whitespaces)
409 if trimmed.hasPrefix("[") && !trimmed.hasPrefix("[[") {
410 current = String(trimmed.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces)
411 if current == section { lastInSection = index }
412 continue
413 }
414 guard current == section else { continue }
415 if !trimmed.isEmpty { lastInSection = index }
416 guard let equals = line.firstIndex(of: "="),
417 line[..<equals].trimmingCharacters(in: .whitespaces) == key else { continue }
418 let comment = commentStart(line, after: equals).map { " " + line[$0...] } ?? ""
419 lines[index] = line[..<equals] + "= " + format(value) + comment
420 return lines.joined(separator: "\n")
421 }
422 if let lastInSection {
423 lines.insert("\(key) = \(format(value))", at: lastInSection + 1)
424 } else if section.isEmpty {
425 let firstHeader = lines.firstIndex { $0.trimmingCharacters(in: .whitespaces).hasPrefix("[") } ?? lines.count
426 lines.insert(contentsOf: ["\(key) = \(format(value))", ""], at: firstHeader)
427 } else {
428 if lines.last == "" { lines.removeLast() }
429 lines += ["", "[\(section)]", "\(key) = \(format(value))", ""]
430 }
431 return lines.joined(separator: "\n")
432 }
433
434 /// Where a `#` comment starts after the value, outside quotes.
435 private static func commentStart(_ line: String, after equals: String.Index) -> String.Index? {
436 var quote: Character?
437 var index = line.index(after: equals)
438 while index < line.endIndex {
439 let c = line[index]
440 if let q = quote {
441 if c == "\\" && q == "\"" { index = line.index(after: index) } else if c == q { quote = nil }
442 } else if c == "\"" || c == "'" {
443 quote = c
444 } else if c == "#" {
445 return index
446 }
447 if index < line.endIndex { index = line.index(after: index) }
448 }
449 return nil
450 }
451}