krz/orgstar

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

Sources/OrgApp/UserConfig.swift

ab6ccec56eca03d0dadf2c9332aab10c4490eb62
orgstar/Sources/OrgApp/UserConfig.swift history · blame · raw

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}