Commit 7f4768ef8f
Verified · cmc
Layout: unified · split
Sources/OrgApp/ConfigSync.swift +22 −14
| @@ -43,14 +43,11 @@ public final class ConfigSync { | |||
| 43 | } | 43 | } |
| 44 | if FileManager.default.fileExists(atPath: url.path) { | 44 | if FileManager.default.fileExists(atPath: url.path) { |
| 45 | reload() | 45 | reload() |
| 46 | // A file from before settings took Emacs's names is written again with them; one | 46 | // A file from before settings took Emacs's names gets them in place; one from |
| 47 | // from before a section existed gets the section. | 47 | // before a section existed gets the section. |
| 48 | if let text, UserConfig.usesLegacyNames(text) { | 48 | if let text { |
| 49 | let template = UserConfig.template(from: defaults) | 49 | let migrated = UserConfig.usesLegacyNames(text) ? UserConfig.migratingLegacyNames(text) : text |
| 50 | try? template.write(to: url, atomically: true, encoding: .utf8) | 50 | let completed = UserConfig.addingMissingSections(migrated, from: defaults) |
| 51 | self.text = template | ||
| 52 | } else if let text { | ||
| 53 | let completed = UserConfig.addingMissingSections(text, from: defaults) | ||
| 54 | if completed != text { | 51 | if completed != text { |
| 55 | try? completed.write(to: url, atomically: true, encoding: .utf8) | 52 | try? completed.write(to: url, atomically: true, encoding: .utf8) |
| 56 | self.text = completed | 53 | self.text = completed |
| @@ -67,15 +64,16 @@ public final class ConfigSync { | |||
| 67 | guard watch else { return } | 64 | guard watch else { return } |
| 68 | let directory = url.deletingLastPathComponent() | 65 | let directory = url.deletingLastPathComponent() |
| 69 | watcher = FSEventsWatcher(roots: [directory.resolvingSymlinksInPath()]) { [weak self] events in | 66 | watcher = FSEventsWatcher(roots: [directory.resolvingSymlinksInPath()]) { [weak self] events in |
| 70 | let names = Set(events.compactMap { event -> String? in | 67 | let changed = Set(events.compactMap { event -> URL? in |
| 71 | if case .changed(let url) = event { return url.lastPathComponent } | 68 | if case .changed(let url) = event { return url } |
| 72 | return nil | 69 | return nil |
| 73 | }) | 70 | }) |
| 74 | Task { @MainActor [weak self] in | 71 | Task { @MainActor [weak self] in |
| 75 | guard let self else { return } | 72 | guard let self else { return } |
| 76 | for name in names.sorted() { | 73 | for file in changed.sorted(by: { $0.path < $1.path }) { |
| 74 | let name = file.lastPathComponent | ||
| 77 | if name == self.url.lastPathComponent { self.reload() } | 75 | if name == self.url.lastPathComponent { self.reload() } |
| 78 | if name == self.defaults.string(forKey: "themeFile") { self.readTheme() } | 76 | if self.isThemeFile(file) { self.readTheme() } |
| 79 | self.serial += 1 | 77 | self.serial += 1 |
| 80 | self.changedFile = (name, self.serial) | 78 | self.changedFile = (name, self.serial) |
| 81 | } | 79 | } |
| @@ -94,13 +92,23 @@ public final class ConfigSync { | |||
| 94 | readTheme() | 92 | readTheme() |
| 95 | } | 93 | } |
| 96 | 94 | ||
| 95 | /// `theme-file`, relative to the configuration folder. | ||
| 96 | private var themeFile: URL? { | ||
| 97 | guard let name = defaults.string(forKey: "themeFile"), !name.isEmpty else { return nil } | ||
| 98 | return url.deletingLastPathComponent().appendingPathComponent(name) | ||
| 99 | } | ||
| 100 | |||
| 101 | /// Whether `file`, as the watcher reports it, is `theme-file`, which may be in a subfolder. | ||
| 102 | func isThemeFile(_ file: URL) -> Bool { | ||
| 103 | themeFile?.resolvingSymlinksInPath().path == file.resolvingSymlinksInPath().path | ||
| 104 | } | ||
| 105 | |||
| 97 | /// The theme from the file read last and its theme file. | 106 | /// The theme from the file read last and its theme file. |
| 98 | func readTheme() { | 107 | func readTheme() { |
| 99 | guard let text, let tables = try? TOML.parse(text) else { return } | 108 | guard let text, let tables = try? TOML.parse(text) else { return } |
| 100 | var spec = ThemeSpec.default | 109 | var spec = ThemeSpec.default |
| 101 | var themeProblems: [String] = [] | 110 | var themeProblems: [String] = [] |
| 102 | if let name = defaults.string(forKey: "themeFile"), !name.isEmpty { | 111 | if let name = defaults.string(forKey: "themeFile"), let file = themeFile { |
| 103 | let file = url.deletingLastPathComponent().appendingPathComponent(name) | ||
| 104 | if let themeText = try? String(contentsOf: file, encoding: .utf8) { | 112 | if let themeText = try? String(contentsOf: file, encoding: .utf8) { |
| 105 | do { | 113 | do { |
| 106 | (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name) | 114 | (spec, themeProblems) = ThemeSpec.reading(try TOML.parse(themeText), file: name) |
Sources/OrgApp/UserConfig.swift +48
| @@ -243,6 +243,54 @@ public enum UserConfig { | |||
| 243 | return values(in: text).keys.contains { names.contains($0) } | 243 | return values(in: text).keys.contains { names.contains($0) } |
| 244 | } | 244 | } |
| 245 | 245 | ||
| 246 | /// `text` with settings under their old names moved to their current names and places, | ||
| 247 | /// keeping comments and everything else. A section the move leaves empty is removed. | ||
| 248 | public static func migratingLegacyNames(_ text: String) -> String { | ||
| 249 | let values = values(in: text) | ||
| 250 | var blocks: [[String]] = [[]] | ||
| 251 | for line in text.components(separatedBy: "\n") { | ||
| 252 | if line.trimmingCharacters(in: .whitespaces).hasPrefix("[") { blocks.append([]) } | ||
| 253 | blocks[blocks.count - 1].append(line) | ||
| 254 | } | ||
| 255 | var moves: [(setting: Setting, value: TOML.Value, comment: String)] = [] | ||
| 256 | var kept: [String] = [] | ||
| 257 | for block in blocks { | ||
| 258 | let header = block.first.map { $0.trimmingCharacters(in: .whitespaces) }.flatMap { $0.hasPrefix("[") ? $0 : nil } | ||
| 259 | let section = header.map { String($0.dropFirst().prefix { $0 != "]" }).trimmingCharacters(in: .whitespaces) } ?? "" | ||
| 260 | var remaining: [String] = [] | ||
| 261 | for (index, line) in block.enumerated() { | ||
| 262 | guard header == nil || index > 0, let equals = line.firstIndex(of: "=") else { | ||
| 263 | remaining.append(line) | ||
| 264 | continue | ||
| 265 | } | ||
| 266 | let name = path(section, line[..<equals].trimmingCharacters(in: .whitespaces)) | ||
| 267 | guard let setting = settings.first(where: { !$0.legacy.isEmpty && $0.legacy == name }), let value = values[name] else { | ||
| 268 | remaining.append(line) | ||
| 269 | continue | ||
| 270 | } | ||
| 271 | // A setting also under its new name keeps that one. | ||
| 272 | if values[setting.path] == nil { | ||
| 273 | moves.append((setting, value, commentStart(line, after: equals).map { String(line[$0...]) } ?? "")) | ||
| 274 | } | ||
| 275 | } | ||
| 276 | let emptied = header != nil && remaining.count < block.count | ||
| 277 | && remaining.dropFirst().allSatisfy { $0.trimmingCharacters(in: .whitespaces).isEmpty } | ||
| 278 | if !emptied { kept += remaining } | ||
| 279 | } | ||
| 280 | var out = kept.joined(separator: "\n") | ||
| 281 | if text.hasSuffix("\n"), !out.isEmpty, !out.hasSuffix("\n") { out += "\n" } | ||
| 282 | for move in moves { | ||
| 283 | out = setting(out, section: move.setting.section, key: move.setting.key, to: move.value) | ||
| 284 | guard !move.comment.isEmpty else { continue } | ||
| 285 | var lines = out.components(separatedBy: "\n") | ||
| 286 | if let index = lines.firstIndex(of: "\(move.setting.key) = \(format(move.value))") { | ||
| 287 | lines[index] += " " + move.comment | ||
| 288 | out = lines.joined(separator: "\n") | ||
| 289 | } | ||
| 290 | } | ||
| 291 | return out | ||
| 292 | } | ||
| 293 | |||
| 246 | private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? { | 294 | private static func currentValue(_ setting: Setting, in defaults: UserDefaults) -> TOML.Value? { |
| 247 | defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults) | 295 | defaults.object(forKey: setting.defaultsKey) == nil ? nil : value(setting, in: defaults) |
| 248 | } | 296 | } |
Tests/OrgAppTests/UserConfigTests.swift +28
| @@ -77,6 +77,18 @@ struct UserConfigTests { | |||
| 77 | #expect(text.contains("\nfill-column = 64") && text.contains("keymap = \"doom\"") && !text.contains("[editor]")) | 77 | #expect(text.contains("\nfill-column = 64") && text.contains("keymap = \"doom\"") && !text.contains("[editor]")) |
| 78 | } | 78 | } |
| 79 | 79 | ||
| 80 | @Test func legacyNamesMoveInPlace() { | ||
| 81 | let text = "# mine\n[editor]\n# my editor\nfill-column = 64 # narrow\n\n[agenda]\nspan = 7\n\n[theme]\nfont-size = 15\n" | ||
| 82 | let migrated = UserConfig.migratingLegacyNames(text) | ||
| 83 | #expect(migrated == "# mine\nfill-column = 64 # narrow\norg-agenda-span = 7\n[editor]\n# my editor\n\n[theme]\nfont-size = 15\n") | ||
| 84 | #expect(!UserConfig.usesLegacyNames(migrated)) | ||
| 85 | let store = defaults() | ||
| 86 | #expect(UserConfig.apply(migrated, to: store).isEmpty) | ||
| 87 | #expect(store.integer(forKey: "fillColumn") == 64 && store.integer(forKey: "agendaSpan") == 7 && store.integer(forKey: "themeFontSize") == 15) | ||
| 88 | // The new name wins over the old one. | ||
| 89 | #expect(UserConfig.migratingLegacyNames("fill-column = 70\n[editor]\nfill-column = 64\n") == "fill-column = 70\n") | ||
| 90 | } | ||
| 91 | |||
| 80 | @Test func syncsBothWays() throws { | 92 | @Test func syncsBothWays() throws { |
| 81 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)") | 93 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-config-\(UUID().uuidString)") |
| 82 | defer { try? FileManager.default.removeItem(at: folder) } | 94 | defer { try? FileManager.default.removeItem(at: folder) } |
| @@ -122,4 +134,20 @@ struct ThemeConfigTests { | |||
| 122 | #expect(sync.theme.light["todo"]?.hex == "#000002") | 134 | #expect(sync.theme.light["todo"]?.hex == "#000002") |
| 123 | #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("default-theme.toml").path)) | 135 | #expect(FileManager.default.fileExists(atPath: folder.appendingPathComponent("default-theme.toml").path)) |
| 124 | } | 136 | } |
| 137 | |||
| 138 | @Test func themeFileInASubfolderIsRecognized() throws { | ||
| 139 | let name = "orgstar-theme-\(UUID().uuidString)" | ||
| 140 | let defaults = UserDefaults(suiteName: name)! | ||
| 141 | defer { defaults.removePersistentDomain(forName: name) } | ||
| 142 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-theme-\(UUID().uuidString)") | ||
| 143 | defer { try? FileManager.default.removeItem(at: folder) } | ||
| 144 | try FileManager.default.createDirectory(at: folder.appendingPathComponent("themes"), withIntermediateDirectories: true) | ||
| 145 | try "[theme]\ntheme-file = \"themes/x.toml\"\n".write(to: folder.appendingPathComponent("config.toml"), atomically: true, encoding: .utf8) | ||
| 146 | let sync = ConfigSync(url: folder.appendingPathComponent("config.toml"), defaults: defaults) | ||
| 147 | sync.start(watch: false) | ||
| 148 | // The watcher reports paths under the folder with symlinks resolved (/private/var/…). | ||
| 149 | let reported = folder.resolvingSymlinksInPath().appendingPathComponent("themes/x.toml") | ||
| 150 | #expect(sync.isThemeFile(reported)) | ||
| 151 | #expect(!sync.isThemeFile(folder.appendingPathComponent("x.toml"))) | ||
| 152 | } | ||
| 125 | } | 153 | } |