krz/orgstar

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

Sources/OrgApp/UserConfig.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/OrgApp/UserConfig.swift history · blame · raw

462 lines · 31409 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-enforce-todo-dependencies", defaultsKey: "enforceTodoDependencies", kind: .bool, fallback: .bool(false),
119                comment: "an entry can't be done before its TODO children, or under an ORDERED parent before its earlier siblings", legacy: ""),
120        Setting(section: "", key: "org-enforce-todo-checkbox-dependencies", defaultsKey: "enforceTodoCheckboxDependencies", kind: .bool, fallback: .bool(false),
121                comment: "an entry can't be done while it has unchecked boxes", legacy: ""),
122        Setting(section: "", key: "org-startup-indented", defaultsKey: "startupIndented", kind: .bool, fallback: .bool(true),
123                comment: "indent bodies under their headings (org-indent-mode); #+STARTUP: (no)indent overrides", legacy: ""),
124        Setting(section: "", key: "org-hide-leading-stars", defaultsKey: "hideLeadingStars", kind: .bool, fallback: .bool(false),
125                comment: "show only a heading's last star without indentation; #+STARTUP: hidestars/showstars overrides", legacy: ""),
126        Setting(section: "", key: "org-startup-align-all-tables", defaultsKey: "startupAlignAllTables", kind: .bool, fallback: .bool(false),
127                comment: "align every table when a file opens; #+STARTUP: (no)align overrides", legacy: ""),
128        Setting(section: "", key: "org-startup-truncated", defaultsKey: "startupTruncated", kind: .bool, fallback: .bool(false),
129                comment: "truncate long lines instead of wrapping them (Emacs's default is t; Doom wraps)", legacy: ""),
130        Setting(section: "", key: "spell-check", defaultsKey: "spellCheck", kind: .bool, fallback: .bool(false),
131                comment: "check spelling while typing, outside code, links, dates, tags and keywords", legacy: ""),
132        Setting(section: "", key: "electric-pair-mode", defaultsKey: "electricPair", kind: .bool, fallback: .bool(true),
133                comment: "type brackets, <> and quotes in pairs as electric-pair-mode does (off in plain Emacs; Doom pairs with smartparens)", legacy: ""),
134        Setting(section: "", key: "org-startup-with-inline-images", defaultsKey: "startupWithInlineImages", kind: .bool, fallback: .bool(false),
135                comment: "show image links as images when a file opens; #+STARTUP: (no)inlineimages overrides", legacy: ""),
136        Setting(section: "", key: "org-use-speed-commands", defaultsKey: "useSpeedCommands", kind: .bool, fallback: .bool(false),
137                comment: "single keys at the start of a heading line run commands (n, p, t, c, …)", legacy: ""),
138        Setting(section: "", key: "org-cycle-hide-drawer-startup", defaultsKey: "hideDrawerStartup", kind: .bool, fallback: .bool(true),
139                comment: "fold drawers when a file opens; #+STARTUP: nohidedrawers overrides", legacy: ""),
140        Setting(section: "", key: "org-cycle-hide-block-startup", defaultsKey: "hideBlockStartup", kind: .bool, fallback: .bool(false),
141                comment: "fold blocks when a file opens; #+STARTUP: hideblocks overrides", legacy: ""),
142        Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true),
143                comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"),
144        Setting(section: "", key: "org-agenda-span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10),
145                comment: "days the agenda shows", legacy: "agenda.span"),
146        Setting(section: "", key: "org-agenda-start-day", defaultsKey: "agendaStartOffset", kind: .dayOffset, fallback: .string("-3d"),
147                comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
148        Setting(section: "", key: "org-agenda-show-all-dates", defaultsKey: "agendaShowAllDates", kind: .bool, fallback: .bool(false),
149                comment: "show days with no entries (Emacs's default is t; Orgstar hides them); today always shows", legacy: ""),
150        Setting(section: "", key: "org-agenda-skip-scheduled-if-done", defaultsKey: "agendaSkipScheduledIfDone", kind: .bool, fallback: .bool(false),
151                comment: "leave done entries out of the agenda even on the day they are scheduled", legacy: ""),
152        Setting(section: "", key: "org-agenda-skip-deadline-if-done", defaultsKey: "agendaSkipDeadlineIfDone", kind: .bool, fallback: .bool(false),
153                comment: "leave done entries out of the agenda even on the day they are due", legacy: ""),
154        Setting(section: "", key: "org-agenda-search-view-always-boolean", defaultsKey: "agendaSearchAlwaysBoolean", kind: .bool, fallback: .bool(false),
155                comment: "search strings are always +word -word {regexp} snippets, never a phrase", legacy: ""),
156        Setting(section: "", key: "org-agenda-search-view-force-full-words", defaultsKey: "agendaSearchForceFullWords", kind: .bool, fallback: .bool(false),
157                comment: "search snippets match whole words only", legacy: ""),
158        Setting(section: "", key: "org-agenda-dim-blocked-tasks", defaultsKey: "agendaDimBlockedTasks",
159                kind: .choice([("t", "t"), ("nil", "nil"), ("invisible", "invisible")]), fallback: .string("t"),
160                comment: "t dims entries the enforce settings block, invisible hides them (unless only checkboxes block them), nil shows them as usual", legacy: ""),
161        Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
162                comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
163        Setting(section: "", key: "org-clock-idle-time", defaultsKey: "clockIdleTime", kind: .integer, fallback: .integer(0),
164                comment: "minutes idle with a clock running before asking what to do with the time; 0 never (nil)", legacy: ""),
165        Setting(section: "", key: "org-clock-history-length", defaultsKey: "clockHistoryLength", kind: .integer, fallback: .integer(5),
166                comment: "recently clocked entries to remember", legacy: ""),
167        Setting(section: "org-agenda-prefix-format", key: "agenda", defaultsKey: "agendaPrefixAgenda", kind: .string, fallback: .string(PrefixFormat.defaults["agenda"]!),
168                comment: "%c category, %t time, %s scheduled/deadline, %e effort, %l level, %b outline path; %-12 pads", legacy: ""),
169        Setting(section: "org-agenda-prefix-format", key: "todo", defaultsKey: "agendaPrefixTodo", kind: .string, fallback: .string(PrefixFormat.defaults["todo"]!),
170                comment: "the TODO list", legacy: ""),
171        Setting(section: "org-agenda-prefix-format", key: "tags", defaultsKey: "agendaPrefixTags", kind: .string, fallback: .string(PrefixFormat.defaults["tags"]!),
172                comment: "tag and property matches", legacy: ""),
173        Setting(section: "theme", key: "font", defaultsKey: "themeFont", kind: .string, fallback: .string(""),
174                comment: "a monospaced font family; \"\" for the system's", legacy: ""),
175        Setting(section: "theme", key: "font-size", defaultsKey: "themeFontSize", kind: .integer, fallback: .integer(13),
176                comment: "points", legacy: ""),
177        Setting(section: "theme", key: "line-spacing", defaultsKey: "themeLineSpacing", kind: .integer, fallback: .integer(2),
178                comment: "points between lines", legacy: ""),
179        Setting(section: "theme", key: "heading-size-step", defaultsKey: "themeHeadingStep", kind: .integer, fallback: .integer(1),
180                comment: "points a heading is larger than the level below; level 4 and deeper are body size", legacy: ""),
181        Setting(section: "theme", key: "theme-file", defaultsKey: "themeFile", kind: .string, fallback: .string(""),
182                comment: "a theme in its own file in this folder, under the colors set here; colors: see default-theme.toml", legacy: ""),
183        Setting(section: "orgstar", key: "save", defaultsKey: "saveMode", kind: .choice([("automatic", "automatic"), ("explicit", "explicit")]),
184                fallback: .string("automatic"), comment: "automatic (1 s after typing stops) or explicit (only with ⌘S)", legacy: "general.save"),
185        Setting(section: "orgstar", key: "keymap", defaultsKey: "keymap", kind: .choice([("emacs", "emacs"), ("mac", "mac"), ("doom", "doom")]),
186                fallback: .string("emacs"), comment: "emacs, mac or doom; your own bindings go in keymap.toml", legacy: "general.keymap"),
187        Setting(section: "orgstar", key: "option-as-meta", defaultsKey: "optionAsMeta",
188                kind: .integerChoice([("left", 1), ("right", 2), ("both", 3), ("none", 0)]), fallback: .string("left"),
189                comment: "left, right, both or none", legacy: "general.option-as-meta"),
190        Setting(section: "orgstar", key: "tab-bar", defaultsKey: "showTabBar", kind: .bool, fallback: .bool(false),
191                comment: "a tab for each open buffer above the editor (View ▸ Show Tab Bar)", legacy: ""),
192        Setting(section: "orgstar", key: "show-markup", defaultsKey: "showMarkup", kind: .bool, fallback: .bool(false),
193                comment: "show link brackets and emphasis markers (View ▸ Show Markup)", legacy: "editor.show-markup"),
194        Setting(section: "orgstar", key: "show-hidden-files", defaultsKey: "showHiddenFiles", kind: .bool, fallback: .bool(true),
195                comment: "list dotfiles and dot folders in your folders", legacy: ""),
196        Setting(section: "orgstar", key: "ignored-folders", defaultsKey: "ignoredFolders", kind: .string,
197                fallback: .string(ScanRules.defaultIgnoredFolders.sorted().joined(separator: " ")),
198                comment: "folder names never listed or searched, separated by spaces", legacy: ""),
199        Setting(section: "orgstar", key: "agenda-include-subfolders", defaultsKey: "agendaSubfolders", kind: .bool, fallback: .bool(false),
200                comment: "the agenda reads org files in subfolders too", legacy: "agenda.include-subfolders"),
201        Setting(section: "orgstar", key: "reminders", defaultsKey: "reminders", kind: .bool, fallback: .bool(true),
202                comment: "notify before timed entries", legacy: "agenda.reminders"),
203        Setting(section: "orgstar", key: "calendar-events", defaultsKey: "calendarEvents", kind: .bool, fallback: .bool(false),
204                comment: "show events from Calendar in the agenda, read-only", legacy: ""),
205        Setting(section: "orgstar", key: "calendar-event-calendars", defaultsKey: "calendarEventCalendars", kind: .string, fallback: .string(""),
206                comment: "the calendars whose events show, by title or identifier, separated by commas; \"\" for all", legacy: ""),
207        Setting(section: "orgstar", key: "global-capture-hotkey", defaultsKey: "globalCapture", kind: .bool, fallback: .bool(true),
208                comment: "⌃⌥Space opens Capture from any app; templates go in capture.toml", legacy: "capture.global-hotkey"),
209    ]
210
211    /// `"-3d"` as -3; nil for anything else.
212    static func dayOffset(_ s: String) -> Int? {
213        guard s.hasSuffix("d"), let n = Int(s.dropLast().replacingOccurrences(of: "+", with: "")) else { return nil }
214        return n
215    }
216
217    // MARK: - Values
218
219    /// The file value for what `defaults` holds, or the fallback.
220    public static func value(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value {
221        guard defaults.object(forKey: setting.defaultsKey) != nil else { return setting.fallback }
222        switch setting.kind {
223        case .bool: return .bool(defaults.bool(forKey: setting.defaultsKey))
224        case .integer: return .integer(defaults.integer(forKey: setting.defaultsKey))
225        case .string: return .string(defaults.string(forKey: setting.defaultsKey) ?? "")
226        case .choice(let choices):
227            let stored = defaults.string(forKey: setting.defaultsKey)
228            return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback
229        case .integerChoice(let choices):
230            let stored = defaults.integer(forKey: setting.defaultsKey)
231            return choices.first { $0.stored == stored }.map { .string($0.name) } ?? setting.fallback
232        case .dayOffset:
233            let days = defaults.integer(forKey: setting.defaultsKey)
234            return .string((days < 0 ? "" : "+") + "\(days)d")
235        }
236    }
237
238    /// Stores a file value; a value of the wrong kind is a problem.
239    static func store(_ value: TOML.Value, _ setting: Setting, in defaults: UserDefaults) -> String? {
240        let key = setting.defaultsKey
241        switch (setting.kind, value) {
242        case (.bool, .bool(let b)): defaults.set(b, forKey: key)
243        case (.integer, .integer(let i)): defaults.set(i, forKey: key)
244        case (.string, .string(let s)): defaults.set(s, forKey: key)
245        case (.choice(let choices), .string(let s)):
246            guard let choice = choices.first(where: { $0.name == s }) else {
247                return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))"
248            }
249            defaults.set(choice.stored, forKey: key)
250        case (.integerChoice(let choices), .string(let s)):
251            guard let choice = choices.first(where: { $0.name == s }) else {
252                return "\(setting.key) must be one of \(choices.map(\.name).joined(separator: ", "))"
253            }
254            defaults.set(choice.stored, forKey: key)
255        case (.dayOffset, .string(let s)):
256            guard let days = dayOffset(s) else { return "\(setting.key) must look like \"-3d\" or \"+0d\"" }
257            defaults.set(days, forKey: key)
258        default:
259            return "\(setting.path) has the wrong kind of value"
260        }
261        return nil
262    }
263
264    /// Applies a file to `defaults`; settings it leaves out go back to their defaults.
265    /// Returns problems.
266    @discardableResult
267    public static func apply(_ text: String, to defaults: UserDefaults) -> [String] {
268        let tables: [TOML.Table]
269        do { tables = try TOML.parse(text) } catch { return ["config.toml: \(error)"] }
270        var problems: [String] = []
271        let present = Set(tables.flatMap { table in table.values.keys.map { path(table.name, $0) } })
272        for setting in settings where !present.contains(setting.path) && !present.contains(setting.legacy) {
273            if defaults.object(forKey: setting.defaultsKey) != nil { defaults.removeObject(forKey: setting.defaultsKey) }
274        }
275        for table in tables {
276            // Colors are the theme's to read.
277            if ThemeSpec.isThemeTable(table.name), table.name != "theme" { continue }
278            for (key, value) in table.values.sorted(by: { $0.key < $1.key }) {
279                if table.name == "theme", !ThemeSpec.typeKeys.contains(key) { continue }
280                let name = path(table.name, key)
281                guard let setting = settings.first(where: { $0.path == name || $0.legacy == name }) else {
282                    problems.append("config.toml: unknown setting \(name)")
283                    continue
284                }
285                if let current = currentValue(setting, in: defaults), current == value { continue }
286                if let problem = store(value, setting, in: defaults) { problems.append("config.toml: \(problem)") }
287            }
288        }
289        return problems
290    }
291
292    static func path(_ section: String, _ key: String) -> String { section.isEmpty ? key : section + "." + key }
293
294    /// Whether the file uses names from before settings took Emacs's names.
295    public static func usesLegacyNames(_ text: String) -> Bool {
296        let names = Set(settings.map(\.legacy))
297        return values(in: text).keys.contains { names.contains($0) }
298    }
299
300    /// `text` with settings under their old names moved to their current names and places,
301    /// keeping comments and everything else. A section the move leaves empty is removed.
302    public static func migratingLegacyNames(_ text: String) -> String {
303        let values = values(in: text)
304        var blocks: [[String]] = [[]]
305        for line in text.components(separatedBy: "\n") {
306            if line.trimmingCharacters(in: .whitespaces).hasPrefix("[") { blocks.append([]) }
307            blocks[blocks.count - 1].append(line)
308        }
309        var moves: [(setting: Setting, value: TOML.Value, comment: String)] = []
310        var kept: [String] = []
311        for block in blocks {
312            let header = block.first.map { $0.trimmingCharacters(in: .whitespaces) }.flatMap { $0.hasPrefix("[") ? $0 : nil }
313            let section = header.map { String($0.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces) } ?? ""
314            var remaining: [String] = []
315            for (index, line) in block.enumerated() {
316                guard header == nil || index > 0, let equals = line.firstIndex(of: "=") else {
317                    remaining.append(line)
318                    continue
319                }
320                let name = path(section, line[..<equals].trimmingCharacters(in: .whitespaces))
321                guard let setting = settings.first(where: { !$0.legacy.isEmpty && $0.legacy == name }), let value = values[name] else {
322                    remaining.append(line)
323                    continue
324                }
325                // A setting also under its new name keeps that one.
326                if values[setting.path] == nil {
327                    moves.append((setting, value, commentStart(line, after: equals).map { String(line[$0...]) } ?? ""))
328                }
329            }
330            let emptied = header != nil && remaining.count < block.count
331                && remaining.dropFirst().allSatisfy { $0.trimmingCharacters(in: .whitespaces).isEmpty }
332            if !emptied { kept += remaining }
333        }
334        var out = kept.joined(separator: "\n")
335        if text.hasSuffix("\n"), !out.isEmpty, !out.hasSuffix("\n") { out += "\n" }
336        for move in moves {
337            out = setting(out, section: move.setting.section, key: move.setting.key, to: move.value)
338            guard !move.comment.isEmpty else { continue }
339            var lines = out.components(separatedBy: "\n")
340            if let index = lines.firstIndex(of: "\(move.setting.key) = \(format(move.value))") {
341                lines[index] += "  " + move.comment
342                out = lines.joined(separator: "\n")
343            }
344        }
345        return out
346    }
347
348    private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? {
349        defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults)
350    }
351
352    /// The values a file sets, by `section.key`.
353    static func values(in text: String) -> [String: TOML.Value] {
354        guard let tables = try? TOML.parse(text) else { return [:] }
355        var result: [String: TOML.Value] = [:]
356        for table in tables { for (key, value) in table.values { result[path(table.name, key)] = value } }
357        return result
358    }
359
360    // MARK: - Text
361
362    static func format(_ value: TOML.Value) -> String {
363        switch value {
364        case .bool(let b): b ? "true" : "false"
365        case .integer(let i): String(i)
366        case .string(let s):
367            "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"")
368                .replacingOccurrences(of: "\n", with: "\\n").replacingOccurrences(of: "\t", with: "\\t") + "\""
369        }
370    }
371
372    /// A commented file with every setting at its current value.
373    public static func template(from defaults: UserDefaults) -> String {
374        var out = """
375            # Orgstar settings. The Settings window writes its changes here, and edits made here
376            # apply while Orgstar runs. Keys go in keymap.toml, capture templates in capture.toml
377            # and saved agenda views in views.toml, all in this folder.
378
379            # Emacs variables, by their Emacs names.
380
381            """
382        var section = ""
383        for setting in settings {
384            if setting.section != section {
385                section = setting.section
386                out += "\n[\(section)]\n"
387            }
388            out += "\(setting.key) = \(format(value(setting, in: defaults)))  # \(setting.comment)\n"
389        }
390        return out
391    }
392
393    /// `text` with the sections it lacks entirely added at the end, every setting at its
394    /// value in `defaults`: how a file from an earlier version gets settings added since.
395    /// Single lines left out stay out; they mean the default.
396    public static func addingMissingSections(_ text: String, from defaults: UserDefaults) -> String {
397        let tables = (try? TOML.parse(text)) ?? []
398        let present = Set(tables.filter { !$0.values.isEmpty || $0.name != "" }.map(\.name))
399        var out = text
400        var section: String?
401        for setting in settings where !setting.section.isEmpty && !present.contains(setting.section) {
402            if setting.section != section {
403                section = setting.section
404                if !out.hasSuffix("\n") { out += "\n" }
405                out += "\n[\(setting.section)]\n"
406            }
407            out += "\(setting.key) = \(format(value(setting, in: defaults)))  # \(setting.comment)\n"
408        }
409        return out
410    }
411
412    /// `text` with `section.key` set to `value`: the line's value is replaced (its comment
413    /// stays), or a line is added at the end of the section, or the section at the end.
414    public static func setting(_ text: String, section: String, key: String, to value: TOML.Value) -> String {
415        var lines = text.components(separatedBy: "\n")
416        var current = ""
417        var lastInSection: Int?
418        for (index, line) in lines.enumerated() {
419            let trimmed = line.trimmingCharacters(in: .whitespaces)
420            if trimmed.hasPrefix("[") && !trimmed.hasPrefix("[[") {
421                current = String(trimmed.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces)
422                if current == section { lastInSection = index }
423                continue
424            }
425            guard current == section else { continue }
426            if !trimmed.isEmpty { lastInSection = index }
427            guard let equals = line.firstIndex(of: "="),
428                  line[..<equals].trimmingCharacters(in: .whitespaces) == key else { continue }
429            let comment = commentStart(line, after: equals).map { "  " + line[$0...] } ?? ""
430            lines[index] = line[..<equals] + "= " + format(value) + comment
431            return lines.joined(separator: "\n")
432        }
433        if let lastInSection {
434            lines.insert("\(key) = \(format(value))", at: lastInSection + 1)
435        } else if section.isEmpty {
436            let firstHeader = lines.firstIndex { $0.trimmingCharacters(in: .whitespaces).hasPrefix("[") } ?? lines.count
437            lines.insert(contentsOf: ["\(key) = \(format(value))", ""], at: firstHeader)
438        } else {
439            if lines.last == "" { lines.removeLast() }
440            lines += ["", "[\(section)]", "\(key) = \(format(value))", ""]
441        }
442        return lines.joined(separator: "\n")
443    }
444
445    /// Where a `#` comment starts after the value, outside quotes.
446    private static func commentStart(_ line: String, after equals: String.Index) -> String.Index? {
447        var quote: Character?
448        var index = line.index(after: equals)
449        while index < line.endIndex {
450            let c = line[index]
451            if let q = quote {
452                if c == "\\" && q == "\"" { index = line.index(after: index) } else if c == q { quote = nil }
453            } else if c == "\"" || c == "'" {
454                quote = c
455            } else if c == "#" {
456                return index
457            }
458            if index < line.endIndex { index = line.index(after: index) }
459        }
460        return nil
461    }
462}