Commit 9879b1b41a
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaModel.swift +2 −2
| @@ -111,10 +111,10 @@ public final class AgendaModel { | ||
| 111 | 111 | if let cached = read.cached { |
| 112 | 112 | sources.append((read.path, read.mtime, read.size, cached, true)) |
| 113 | 113 | } else if let text = read.text { |
| 114 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), false)) | |
| 114 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults.resolvingSetupFiles(in: text, path: read.path, read: OrgSettings.readFile)), false)) | |
| 115 | 115 | } else if let data = FileManager.default.contents(atPath: read.path) { |
| 116 | 116 | let text = SourceText(bytes: [UInt8](data)).text |
| 117 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true)) | |
| 117 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults.resolvingSetupFiles(in: text, path: read.path, read: OrgSettings.readFile)), true)) | |
| 118 | 118 | } |
| 119 | 119 | } |
| 120 | 120 | return sources |
Sources/OrgApp/DocumentSession.swift +13 −5
| @@ -77,6 +77,14 @@ public final class DocumentSession { | ||
| 77 | 77 | /// TODO keywords and priorities for files without their own settings. |
| 78 | 78 | public var defaults: OrgSettings = .default |
| 79 | 79 | |
| 80 | /// A buffer for `bytes` read from `path`, with the keywords of the setup files it names. | |
| 81 | func loaded(_ bytes: [UInt8], path: String) -> DocumentState { | |
| 82 | let settings = defaults.resolvingSetupFiles(in: SourceText(bytes: bytes).text, path: path) { file in | |
| 83 | (try? self.fileSystem.read(URL(fileURLWithPath: file))).flatMap { $0 }.flatMap { String(bytes: $0, encoding: .utf8) } | |
| 84 | } | |
| 85 | return DocumentState(bytes: bytes, defaults: settings) | |
| 86 | } | |
| 87 | ||
| 80 | 88 | /// One open file. |
| 81 | 89 | @MainActor |
| 82 | 90 | final class Entry { |
| @@ -154,7 +162,7 @@ public final class DocumentSession { | ||
| 154 | 162 | lastError = "\((path as NSString).lastPathComponent) no longer exists." |
| 155 | 163 | return |
| 156 | 164 | } |
| 157 | let entry = Entry(path: path, buffer: StateBuffer(DocumentState(bytes: bytes, defaults: defaults))) | |
| 165 | let entry = Entry(path: path, buffer: StateBuffer(loaded(bytes, path: path))) | |
| 158 | 166 | let index = current.flatMap { c in entries.firstIndex { $0 === c } }.map { $0 + 1 } ?? entries.count |
| 159 | 167 | entries.insert(entry, at: index) |
| 160 | 168 | activate(entry) |
| @@ -467,7 +475,7 @@ public final class DocumentSession { | ||
| 467 | 475 | state = open.buffer.document |
| 468 | 476 | } else { |
| 469 | 477 | guard let bytes = try? fileSystem.read(URL(fileURLWithPath: from.path)) else { return nil } |
| 470 | state = DocumentState(bytes: bytes, defaults: defaults) | |
| 478 | state = loaded(bytes, path: from.path) | |
| 471 | 479 | } |
| 472 | 480 | guard state.isEditable else { return nil } |
| 473 | 481 | let context = EditContext(revision: state.revision, text: state.text, tree: state.tree, selection: [from.offset..<from.offset], now: now, options: options) |
| @@ -510,7 +518,7 @@ public final class DocumentSession { | ||
| 510 | 518 | } |
| 511 | 519 | do { |
| 512 | 520 | guard let bytes = try fileSystem.read(url) else { return "\(targetName) no longer exists." } |
| 513 | var state = DocumentState(bytes: bytes, defaults: defaults) | |
| 521 | var state = loaded(bytes, path: url.path) | |
| 514 | 522 | guard state.isEditable else { return "\(targetName) isn't UTF-8, so it can't be changed." } |
| 515 | 523 | guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." } |
| 516 | 524 | guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." } |
| @@ -554,7 +562,7 @@ public final class DocumentSession { | ||
| 554 | 562 | do { |
| 555 | 563 | let url = URL(fileURLWithPath: destination.path) |
| 556 | 564 | let existing = destination.isSameFile ? nil : try fileSystem.read(url) |
| 557 | var state = DocumentState(bytes: existing ?? [], defaults: defaults) | |
| 565 | var state = loaded(existing ?? [], path: url.path) | |
| 558 | 566 | if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." } |
| 559 | 567 | let edits: Archive.Edits |
| 560 | 568 | switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) { |
| @@ -631,7 +639,7 @@ public final class DocumentSession { | ||
| 631 | 639 | let name = url.lastPathComponent |
| 632 | 640 | do { |
| 633 | 641 | guard let bytes = try fileSystem.read(url) else { return .failed("\(name) no longer exists.") } |
| 634 | var state = DocumentState(bytes: bytes, defaults: defaults) | |
| 642 | var state = loaded(bytes, path: path) | |
| 635 | 643 | guard state.isEditable else { return .failed("\(name) isn't UTF-8, so it can't be changed.") } |
| 636 | 644 | guard matches(state.text) else { return stale } |
| 637 | 645 | let step = try state.run(command, selection: [offset..<offset], now: now, answers: answers, options: options) |
Sources/OrgCore/Agenda/AgendaSource.swift +1 −1
| @@ -106,7 +106,7 @@ public struct AgendaSource: Sendable { | ||
| 106 | 106 | collect(tree.root) |
| 107 | 107 | func sectionStamps(_ index: Int) -> [String] { stampsByHeading[index] ?? [] } |
| 108 | 108 | |
| 109 | var keywordCategory: String? | |
| 109 | var keywordCategory = SetupFile.keywords(tree.settings.setupKeywords).last { $0.key == "CATEGORY" }?.value.trimmingCharacters(in: .whitespaces) | |
| 110 | 110 | var srcBodies: [Range<Int>] = [] |
| 111 | 111 | var planningLines: [Range<Int>] = [] |
| 112 | 112 | var timestampNodes: [Int] = [] |
Sources/OrgCore/Commands/Archive.swift +3 −1
| @@ -9,7 +9,9 @@ extension EmacsBuffer { | ||
| 9 | 9 | func keyword(_ name: String) -> String? { |
| 10 | 10 | saveExcursion { |
| 11 | 11 | point = 0 |
| 12 | guard let match = searchForward("^[ \\t]*#\\+" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]*(.*?)[ \\t]*$") else { return nil } | |
| 12 | guard let match = searchForward("^[ \\t]*#\\+" + NSRegularExpression.escapedPattern(for: name) + ":[ \\t]*(.*?)[ \\t]*$") else { | |
| 13 | return SetupFile.keywords(settings.setupKeywords).last { $0.key == name.uppercased() }?.value.trimmingCharacters(in: .whitespaces) | |
| 14 | } | |
| 13 | 15 | return substring(match[1]!.lowerBound, match[1]!.upperBound) |
| 14 | 16 | } |
| 15 | 17 | } |
Sources/OrgCore/Commands/ColumnView.swift +1 −1
| @@ -239,7 +239,7 @@ public enum ColumnView { | ||
| 239 | 239 | } |
| 240 | 240 | if top == nil { top = here } |
| 241 | 241 | } |
| 242 | if format == nil, let keyword = text.firstMatch(of: #/(?mi)^[ \t]*#\+COLUMNS:[ \t]+(.+?)[ \t]*$/#) { format = String(keyword.1) } | |
| 242 | if format == nil, let keyword = (tree.settings.setupKeywords + text).firstMatch(of: #/(?mi)^[ \t]*#\+COLUMNS:[ \t]+(.+?)[ \t]*$/#) { format = String(keyword.1) } | |
| 243 | 243 | let specs = compile(format ?? defaultFormat) |
| 244 | 244 | // The entries in scope, without archived or commented subtrees. |
| 245 | 245 | var scope: [Int] = [] |
Sources/OrgCore/Commands/InlineImages.swift +1 −1
| @@ -34,7 +34,7 @@ public enum InlineImages { | ||
| 34 | 34 | /// `#+STARTUP: inlineimages` or `noinlineimages`, if the file says. |
| 35 | 35 | public static func startup(_ tree: OrgTree) -> Bool? { |
| 36 | 36 | var shown: Bool? |
| 37 | for m in tree.root.text.matches(of: #/(?mi)^[ \t]*#\+STARTUP:(.*)$/#) { | |
| 37 | for m in (tree.settings.setupKeywords + tree.root.text).matches(of: #/(?mi)^[ \t]*#\+STARTUP:(.*)$/#) { | |
| 38 | 38 | for word in m.1.split(whereSeparator: \.isWhitespace) { |
| 39 | 39 | if word.lowercased() == "inlineimages" { shown = true } |
| 40 | 40 | if word.lowercased() == "noinlineimages" { shown = false } |
Sources/OrgCore/Commands/TagCommands.swift +1 −1
| @@ -37,7 +37,7 @@ public struct SetTags: OrgCommand { | ||
| 37 | 37 | } |
| 38 | 38 | let currentText = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)) } ?? "" |
| 39 | 39 | let current = currentText.split(separator: ":").map(String.init) |
| 40 | let table = FastSelection.tagTable(context.text) ?? Self.bufferTags(context).map { .option(key: "\0", name: $0) } | |
| 40 | let table = FastSelection.tagTable(context.tree.settings.setupKeywords + context.text) ?? Self.bufferTags(context).map { .option(key: "\0", name: $0) } | |
| 41 | 41 | let tags: [String] |
| 42 | 42 | if let fast = FastSelection.tags(table: table, current: current, inherited: inheritedTags(context)) { |
| 43 | 43 | guard let answer = context.answers["tags"] else { |
Sources/OrgCore/Commands/TodoLogging.swift +3
| @@ -82,6 +82,9 @@ extension EmacsBuffer { | ||
| 82 | 82 | if enter != nil || leave != nil, log.states[keyword.name] == nil { log.states[keyword.name] = (enter, leave) } |
| 83 | 83 | } |
| 84 | 84 | } |
| 85 | for (key, value) in SetupFile.keywords(settings.setupKeywords) where key == "STARTUP" { | |
| 86 | for word in value.split(whereSeparator: \.isWhitespace) { log.apply(startup: String(word)) } | |
| 87 | } | |
| 85 | 88 | let startup = try! NSRegularExpression(pattern: "^[ \\t]*#\\+STARTUP:(.*)$", options: [.anchorsMatchLines, .caseInsensitive]) |
| 86 | 89 | for match in startup.matches(in: string, range: NSRange(location: 0, length: length)) { |
| 87 | 90 | for word in substring(match.range(at: 1).location, NSMaxRange(match.range(at: 1))).split(whereSeparator: \.isWhitespace) { |
Sources/OrgCore/Completion/OrgCompletion.swift +2 −2
| @@ -113,7 +113,7 @@ public enum OrgCompletion { | ||
| 113 | 113 | var p = caret |
| 114 | 114 | while p > start, let ch = UnicodeScalar(ns.character(at: p - 1)), CharacterSet(charactersIn: "-_").union(.alphanumerics).contains(ch), ch.isASCII { p -= 1 } |
| 115 | 115 | if p - 2 >= start, ns.character(at: p - 1) == 91, ns.character(at: p - 2) == 91 { |
| 116 | let abbreviations = Links.abbreviations(in: context.text).keys.map { $0 + ":" } | |
| 116 | let abbreviations = Links.abbreviations(in: context.tree.settings.setupKeywords + context.text).keys.map { $0 + ":" } | |
| 117 | 117 | return make(.link, from: p, abbreviations + linkTypes.map { $0 + ":" }) |
| 118 | 118 | } |
| 119 | 119 | } |
| @@ -136,7 +136,7 @@ public enum OrgCompletion { | ||
| 136 | 136 | let from = colon + ((typed as NSString).range(of: ":", options: .backwards).toOptional.map { NSMaxRange($0) } ?? 0) |
| 137 | 137 | let heading = DocumentModel(tree: context.tree).headings.last { $0.headingRange.lowerBound <= caret } |
| 138 | 138 | let own = Set(heading?.localTags ?? []) |
| 139 | let alist = FastSelection.tagTable(context.text)?.compactMap { item -> String? in | |
| 139 | let alist = FastSelection.tagTable(context.tree.settings.setupKeywords + context.text)?.compactMap { item -> String? in | |
| 140 | 140 | if case .option(_, let name) = item { return name } |
| 141 | 141 | return nil |
| 142 | 142 | } ?? [] |
Sources/OrgCore/Compute/TableFormulas.swift +1 −1
| @@ -369,7 +369,7 @@ extension EmacsBuffer { | ||
| 369 | 369 | func substituteConstants(_ formula: String) -> String { |
| 370 | 370 | guard formula.range(of: "\\$[a-zA-Z]", options: .regularExpression) != nil else { return formula } |
| 371 | 371 | var constants: [String: String] = [:] |
| 372 | for m in string.matches(of: #/(?mi)^[ \t]*#\+CONSTANTS:(.*)$/#) { | |
| 372 | for m in (settings.setupKeywords + string).matches(of: #/(?mi)^[ \t]*#\+CONSTANTS:(.*)$/#) { | |
| 373 | 373 | for pair in m.1.split(whereSeparator: \.isWhitespace) { |
| 374 | 374 | let parts = pair.split(separator: "=", maxSplits: 1) |
| 375 | 375 | if parts.count == 2, constants[String(parts[0])] == nil { constants[String(parts[0])] = String(parts[1]) } |
Sources/OrgCore/Export/ExportOptions.swift +3 −2
| @@ -179,11 +179,12 @@ public enum ExportPreprocess { | ||
| 179 | 179 | /// `{{{name(arg, …)}}}` replaced: `#+MACRO` definitions with `$1`… and the built-in title, |
| 180 | 180 | /// author, date, email, input-file, keyword, property, n and time macros. Others become |
| 181 | 181 | /// empty, as Lisp ones do here. |
| 182 | public static func macros(_ text: String, path: String?, now: Date = Date()) -> String { | |
| 182 | /// `setup` is the setup files' keyword lines, whose definitions count too. | |
| 183 | public static func macros(_ text: String, path: String?, now: Date = Date(), setup: String = "") -> String { | |
| 183 | 184 | guard text.contains("{{{") else { return text } |
| 184 | 185 | var definitions: [String: String] = [:] |
| 185 | 186 | var keywords: [String: String] = [:] |
| 186 | for m in text.matches(of: #/(?mi)^[ \t]*#\+([A-Z_]+):[ \t]*(.*?)[ \t]*$/#) { | |
| 187 | for m in (setup + text).matches(of: #/(?mi)^[ \t]*#\+([A-Z_]+):[ \t]*(.*?)[ \t]*$/#) { | |
| 187 | 188 | let key = m.1.uppercased() |
| 188 | 189 | let value = String(m.2) |
| 189 | 190 | if key == "MACRO", let d = value.firstMatch(of: /^(\S+)[ \t]*(.*)$/) { |
Sources/OrgCore/Export/HTMLExport.swift +15 −14
| @@ -19,18 +19,19 @@ public enum HTMLExport { | ||
| 19 | 19 | public static func metadata(_ text: String, settings: OrgSettings = .default) -> ExportMetadata { |
| 20 | 20 | let tree = OrgParser.parse(text, defaults: settings) |
| 21 | 21 | var meta = ExportMetadata() |
| 22 | for node in tree.root.children where node.kind == .zerothSection { | |
| 23 | for child in node.children where child.kind == .keyword { | |
| 24 | let line = child.text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 25 | guard let colon = line.firstIndex(of: ":") else { continue } | |
| 26 | let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased() | |
| 27 | let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) | |
| 28 | switch key { | |
| 29 | case "TITLE": meta.title = value | |
| 30 | case "AUTHOR": meta.author = value | |
| 31 | case "DATE": meta.date = value | |
| 32 | default: break | |
| 33 | } | |
| 22 | // Setup files' keywords come first, as Org reads them where `#+SETUPFILE` is. | |
| 23 | let setup = settings.setupKeywords.split(separator: "\n").map(String.init) | |
| 24 | let own = tree.root.children.filter { $0.kind == .zerothSection }.flatMap { $0.children.filter { $0.kind == .keyword } }.map(\.text) | |
| 25 | for text in setup + own { | |
| 26 | let line = text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 27 | guard let colon = line.firstIndex(of: ":") else { continue } | |
| 28 | let key = line.dropFirst(2).prefix { $0 != ":" }.uppercased() | |
| 29 | let value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) | |
| 30 | switch key { | |
| 31 | case "TITLE": meta.title = value | |
| 32 | case "AUTHOR": meta.author = value | |
| 33 | case "DATE": meta.date = value | |
| 34 | default: break | |
| 34 | 35 | } |
| 35 | 36 | } |
| 36 | 37 | return meta |
| @@ -41,8 +42,8 @@ public enum HTMLExport { | ||
| 41 | 42 | /// `{{{input-file}}}`. |
| 42 | 43 | public static func page(_ text: String, fallbackTitle: String, settings: OrgSettings = .default, path: String? = nil, now: Date = Date()) -> String { |
| 43 | 44 | let directory = path.map { URL(fileURLWithPath: $0).deletingLastPathComponent() } |
| 44 | let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now) | |
| 45 | let options = ExportOptions.parse(text) | |
| 45 | let text = ExportPreprocess.macros(ExportPreprocess.includes(text, directory: directory), path: path, now: now, setup: settings.setupKeywords) | |
| 46 | let options = ExportOptions.parse(settings.setupKeywords + text) | |
| 46 | 47 | let meta = metadata(text, settings: settings) |
| 47 | 48 | let title = meta.title ?? fallbackTitle |
| 48 | 49 | let renderer = HTMLRenderer(settings: settings, options: options) |
Sources/OrgCore/Links/LinkStore.swift +1 −1
| @@ -247,7 +247,7 @@ public struct InsertLink: OrgCommand { | ||
| 247 | 247 | guard let answer = context.answers["link"] else { return .prompt(Prompt(key: "link", message: "Link:", initial: initial)) } |
| 248 | 248 | link = answer |
| 249 | 249 | } else { |
| 250 | let prefixes = Array(Links.abbreviations(in: context.text).keys).sorted() + Links.types | |
| 250 | let prefixes = Array(Links.abbreviations(in: context.tree.settings.setupKeywords + context.text).keys).sorted() + Links.types | |
| 251 | 251 | guard let answer = context.answers["link"] else { |
| 252 | 252 | let message = stored.first.map { "Insert link (default \($0.link)):" } ?? "Insert link:" |
| 253 | 253 | let choices = stored.map(\.link) + stored.compactMap(\.description) + prefixes.map { $0 + ":" } |
Sources/OrgCore/Links/Links.swift +1 −1
| @@ -133,7 +133,7 @@ public enum Links { | ||
| 133 | 133 | guard let node = found else { return nil } |
| 134 | 134 | let whole = node.text |
| 135 | 135 | if whole.hasPrefix("[["), let path = node.tokens.first(where: { $0.kind == .linkPath }) { |
| 136 | return (parse(bracket: path.text, abbreviations: abbreviations(in: text)), node.range) | |
| 136 | return (parse(bracket: path.text, abbreviations: abbreviations(in: tree.settings.setupKeywords + text)), node.range) | |
| 137 | 137 | } |
| 138 | 138 | let plain = whole.hasPrefix("<") && whole.hasSuffix(">") ? String(whole.dropFirst().dropLast()) : whole |
| 139 | 139 | return (parse(plain: plain), node.range) |
Sources/OrgCore/Parser/Settings.swift +27 −16
| @@ -47,6 +47,9 @@ public struct OrgSettings: Sendable, Equatable { | ||
| 47 | 47 | public var radioTargets: [String] = [] |
| 48 | 48 | /// `org-list-allow-alphabetical`: `a.` and `A)` are bullets. |
| 49 | 49 | public var listAllowAlphabetical = false |
| 50 | /// Keyword lines from the file's `#+SETUPFILE`s, as `#+KEY: value` lines: they count as | |
| 51 | /// the file's own, before them. | |
| 52 | public var setupKeywords = "" | |
| 50 | 53 | |
| 51 | 54 | public init(todoSequences: [TodoSequence], priorities: Priorities, radioTargets: [String] = []) { |
| 52 | 55 | self.todoSequences = todoSequences |
| @@ -80,35 +83,42 @@ public struct OrgSettings: Sendable, Equatable { | ||
| 80 | 83 | |
| 81 | 84 | enum SettingsScanner { |
| 82 | 85 | /// Reads `#+TODO`, `#+SEQ_TODO`, `#+TYP_TODO` and `#+PRIORITIES` outside blocks. Any TODO |
| 83 | /// line replaces the default sequences, as in org. | |
| 86 | /// line replaces the default sequences; as in org, `TYP_TODO` sequences come first, then | |
| 87 | /// `TODO`, then `SEQ_TODO`. | |
| 84 | 88 | static func scan(lines: [RawLine], info: [ClassifiedLine], blockEnds: [Int: Int], defaults: OrgSettings) -> OrgSettings { |
| 85 | var sequences: [TodoSequence] = [] | |
| 89 | var types: [TodoSequence] = [], todos: [TodoSequence] = [], seqs: [TodoSequence] = [] | |
| 86 | 90 | var priorities = defaults.priorities |
| 91 | func apply(_ key: String, _ value: Substring) { | |
| 92 | switch key { | |
| 93 | case "TODO": | |
| 94 | if let s = todoSequence(value, kind: .sequence) { todos.append(s) } | |
| 95 | case "SEQ_TODO": | |
| 96 | if let s = todoSequence(value, kind: .sequence) { seqs.append(s) } | |
| 97 | case "TYP_TODO": | |
| 98 | if let s = todoSequence(value, kind: .type) { types.append(s) } | |
| 99 | case "PRIORITIES": | |
| 100 | let words = value.split(whereSeparator: \.isWhitespace) | |
| 101 | if words.count == 3 { | |
| 102 | priorities = Priorities(highest: String(words[0]), lowest: String(words[1]), default: String(words[2])) | |
| 103 | } | |
| 104 | default: | |
| 105 | break | |
| 106 | } | |
| 107 | } | |
| 108 | for (key, value) in SetupFile.keywords(defaults.setupKeywords) { apply(key, value) } | |
| 87 | 109 | var k = 0 |
| 88 | 110 | while k < lines.count { |
| 89 | 111 | switch info[k].cls { |
| 90 | 112 | case .blockBegin, .dynamicBegin: |
| 91 | 113 | if let end = blockEnds[k] { k = end } |
| 92 | 114 | case .keyword(let key): |
| 93 | let value = keywordValue(lines[k].content) | |
| 94 | switch key { | |
| 95 | case "TODO", "SEQ_TODO": | |
| 96 | if let s = todoSequence(value, kind: .sequence) { sequences.append(s) } | |
| 97 | case "TYP_TODO": | |
| 98 | if let s = todoSequence(value, kind: .type) { sequences.append(s) } | |
| 99 | case "PRIORITIES": | |
| 100 | let words = value.split(whereSeparator: \.isWhitespace) | |
| 101 | if words.count == 3 { | |
| 102 | priorities = Priorities(highest: String(words[0]), lowest: String(words[1]), default: String(words[2])) | |
| 103 | } | |
| 104 | default: | |
| 105 | break | |
| 106 | } | |
| 115 | apply(key, keywordValue(lines[k].content)) | |
| 107 | 116 | default: |
| 108 | 117 | break |
| 109 | 118 | } |
| 110 | 119 | k += 1 |
| 111 | 120 | } |
| 121 | let sequences = types + todos + seqs | |
| 112 | 122 | var radio: [String] = [] |
| 113 | 123 | for line in lines where line.content.contains("<<<") { |
| 114 | 124 | for m in String(line.content).matches(of: /<<<([^<>\n\r \t](?:[^<>\n\r]*[^<>\n\r \t])?)>>>/) where !radio.contains(String(m.1)) { |
| @@ -118,6 +128,7 @@ enum SettingsScanner { | ||
| 118 | 128 | var settings = OrgSettings(todoSequences: sequences.isEmpty ? defaults.todoSequences : sequences, priorities: priorities, |
| 119 | 129 | radioTargets: radio.sorted { $0.count > $1.count }) |
| 120 | 130 | settings.listAllowAlphabetical = defaults.listAllowAlphabetical |
| 131 | settings.setupKeywords = defaults.setupKeywords | |
| 121 | 132 | return settings |
| 122 | 133 | } |
| 123 | 134 | |
Sources/OrgCore/Parser/SetupFile.swift added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// `#+SETUPFILE` (`org--collect-keywords-1`): each setup file's keywords count as if they were | |
| 4 | /// in the file that names it, setup files named there included. Paths are relative to the | |
| 5 | /// naming file's folder; URLs aren't fetched. | |
| 6 | public enum SetupFile { | |
| 7 | /// The keyword lines the setup files `text` names bring, in order, as `#+KEY: value` lines. | |
| 8 | /// `read` gives a file's text, or nil when it can't be read. | |
| 9 | public static func keywordLines(in text: String, directory: String, read: (String) -> String?) -> String { | |
| 10 | lines(in: text, directory: directory, read: read, own: false, seen: []) | |
| 11 | } | |
| 12 | ||
| 13 | private static func lines(in text: String, directory: String, read: (String) -> String?, own: Bool, seen: Set<String>) -> String { | |
| 14 | guard own || text.range(of: "#+setupfile:", options: .caseInsensitive) != nil else { return "" } | |
| 15 | var out = "" | |
| 16 | for node in OrgParser.parse(text).root.descendants() where node.kind == .keyword { | |
| 17 | let line = node.text.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 18 | guard let colon = line.firstIndex(of: ":") else { continue } | |
| 19 | let key = line[line.index(line.startIndex, offsetBy: 2)..<colon].uppercased() | |
| 20 | guard key == "SETUPFILE" else { | |
| 21 | if own { out += line + "\n" } | |
| 22 | continue | |
| 23 | } | |
| 24 | var value = line[line.index(after: colon)...].trimmingCharacters(in: .whitespaces) | |
| 25 | if value.count >= 2, value.hasPrefix("\""), value.hasSuffix("\"") { value = String(value.dropFirst().dropLast()) } | |
| 26 | guard !value.isEmpty, value.range(of: "^[a-zA-Z][a-zA-Z0-9+.-]*://", options: .regularExpression) == nil else { continue } | |
| 27 | let expanded = (value as NSString).expandingTildeInPath | |
| 28 | let path = ((expanded.hasPrefix("/") ? expanded : (directory as NSString).appendingPathComponent(expanded)) as NSString).standardizingPath | |
| 29 | guard !seen.contains(path), let contents = read(path) else { continue } | |
| 30 | out += lines(in: contents, directory: (path as NSString).deletingLastPathComponent, read: read, own: true, seen: seen.union([path])) | |
| 31 | } | |
| 32 | return out | |
| 33 | } | |
| 34 | ||
| 35 | /// `(key, value)` for each `#+KEY: value` line. | |
| 36 | static func keywords(_ lines: String) -> [(key: String, value: Substring)] { | |
| 37 | lines.split(separator: "\n").compactMap { line in | |
| 38 | guard line.hasPrefix("#+"), let colon = line.firstIndex(of: ":") else { return nil } | |
| 39 | return (line[line.index(line.startIndex, offsetBy: 2)..<colon].uppercased(), line[line.index(after: colon)...]) | |
| 40 | } | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | extension OrgSettings { | |
| 45 | /// These settings with the keywords of the setup files `text` names, for a file at `path`. | |
| 46 | public func resolvingSetupFiles(in text: String, path: String, read: (String) -> String?) -> OrgSettings { | |
| 47 | var copy = self | |
| 48 | copy.setupKeywords = SetupFile.keywordLines(in: text, directory: (path as NSString).deletingLastPathComponent, read: read) | |
| 49 | return copy | |
| 50 | } | |
| 51 | ||
| 52 | /// A file's text readable as UTF-8, for `resolvingSetupFiles`. | |
| 53 | public static func readFile(_ path: String) -> String? { | |
| 54 | FileManager.default.contents(atPath: path).flatMap { String(data: $0, encoding: .utf8) } | |
| 55 | } | |
| 56 | } | |
Sources/OrgCore/Semantic/DocumentModel.swift +5 −2
| @@ -22,6 +22,7 @@ public struct DocumentModel: Sendable { | ||
| 22 | 22 | public init(tree: OrgTree, settings: SemanticSettings = .default) { |
| 23 | 23 | self.settings = settings |
| 24 | 24 | self.orgSettings = tree.settings |
| 25 | for (key, value) in SetupFile.keywords(tree.settings.setupKeywords) { fileKeyword(key, value.trimmingCharacters(in: .whitespaces)) } | |
| 25 | 26 | var pending: [PendingBlock] = [] |
| 26 | 27 | walk(tree.root, heading: nil, pending: &pending) |
| 27 | 28 | // Header arguments depend on file and heading properties, so blocks resolve last. |
| @@ -205,8 +206,10 @@ public struct DocumentModel: Sendable { | ||
| 205 | 206 | private mutating func fileKeyword(_ node: SyntaxNode) { |
| 206 | 207 | let text = node.text.trimmingCharacters(in: .whitespacesAndNewlines) |
| 207 | 208 | guard let colon = text.firstIndex(of: ":") else { return } |
| 208 | let key = text[text.index(text.startIndex, offsetBy: 2)..<colon].uppercased() | |
| 209 | let value = text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces) | |
| 209 | fileKeyword(text[text.index(text.startIndex, offsetBy: 2)..<colon].uppercased(), text[text.index(after: colon)...].trimmingCharacters(in: .whitespaces)) | |
| 210 | } | |
| 211 | ||
| 212 | private mutating func fileKeyword(_ key: String, _ value: String) { | |
| 210 | 213 | switch key { |
| 211 | 214 | case "FILETAGS": |
| 212 | 215 | fileTags += value.split(whereSeparator: { $0 == ":" || $0.isWhitespace }).map(String.init) |
Sources/OrgIndex/FileRecord.swift +3 −1
| @@ -122,7 +122,9 @@ public struct FileRecord: Sendable, Equatable { | ||
| 122 | 122 | self.hash = Self.hash(bytes) |
| 123 | 123 | self.settingsVersion = settings.version |
| 124 | 124 | let source = SourceText(bytes: bytes) |
| 125 | headings = kind == .conflict ? [] : Self.headings(source.text, settings: settings) | |
| 125 | var resolved = settings | |
| 126 | resolved.org = settings.org.resolvingSetupFiles(in: source.text, path: path, read: OrgSettings.readFile) | |
| 127 | headings = kind == .conflict ? [] : Self.headings(source.text, settings: resolved) | |
| 126 | 128 | } |
| 127 | 129 | |
| 128 | 130 | /// A file the index lists without reading: `hash` stands for its size and mtime. |
Sources/OrgPresentation/Folding.swift +2 −1
| @@ -220,7 +220,8 @@ extension Presentation { | ||
| 220 | 220 | /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside |
| 221 | 221 | /// blocks and paragraphs is never read as a setting. |
| 222 | 222 | public static func startupOptions(_ tree: OrgTree) -> [String] { |
| 223 | var options: [String] = [] | |
| 223 | var options = tree.settings.setupKeywords.split(separator: "\n").filter { $0.uppercased().hasPrefix("#+STARTUP:") } | |
| 224 | .flatMap { $0.dropFirst("#+STARTUP:".count).split(whereSeparator: \.isWhitespace).map(String.init) } | |
| 224 | 225 | func visit(_ node: GreenNode) { |
| 225 | 226 | for case .node(let child) in node.children { |
| 226 | 227 | switch child.kind { |
Sources/Orgstar/ContentView.swift +1 −1
| @@ -382,7 +382,7 @@ struct ContentView: View { | ||
| 382 | 382 | } |
| 383 | 383 | let url = URL(fileURLWithPath: path) |
| 384 | 384 | let base = url.deletingPathExtension() |
| 385 | let defaults = Preferences.orgSettings | |
| 385 | let defaults = Preferences.orgSettings.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile) | |
| 386 | 386 | let open = open || format == "html-open" |
| 387 | 387 | switch format { |
| 388 | 388 | case "html", "html-open", "markdown": |
Sources/OrgstarMobile/ExportMenu.swift +1
| @@ -32,6 +32,7 @@ struct OrgExport: Transferable { | ||
| 32 | 32 | |
| 33 | 33 | private func write() throws -> SentTransferredFile { |
| 34 | 34 | let base = ((path as NSString).lastPathComponent as NSString).deletingPathExtension |
| 35 | let settings = settings.resolvingSetupFiles(in: text, path: path, read: OrgSettings.readFile) | |
| 35 | 36 | let content = switch format { |
| 36 | 37 | case .html: HTMLExport.page(text, fallbackTitle: base, settings: settings, path: path) |
| 37 | 38 | case .markdown: MarkdownExport.document(text, settings: settings) |
Sources/OrgstarQuickLook/PreviewProvider.swift +1 −1
| @@ -12,7 +12,7 @@ final class PreviewProvider: QLPreviewProvider, QLPreviewingController { | ||
| 12 | 12 | func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply { |
| 13 | 13 | let text = try String(contentsOf: request.fileURL, encoding: .utf8) |
| 14 | 14 | let title = request.fileURL.deletingPathExtension().lastPathComponent |
| 15 | let html = HTMLExport.page(text, fallbackTitle: title, path: request.fileURL.path) | |
| 15 | let html = HTMLExport.page(text, fallbackTitle: title, settings: OrgSettings.default.resolvingSetupFiles(in: text, path: request.fileURL.path, read: OrgSettings.readFile), path: request.fileURL.path) | |
| 16 | 16 | return QLPreviewReply(dataOfContentType: .html, contentSize: CGSize(width: 800, height: 800)) { _ in Data(html.utf8) } |
| 17 | 17 | } |
| 18 | 18 | } |
Tests/OrgAppTests/AppTests.swift +9
| @@ -190,6 +190,15 @@ struct DocumentSessionTests { | ||
| 190 | 190 | #expect(session.revealRequest?.offset == 12) |
| 191 | 191 | } |
| 192 | 192 | |
| 193 | @Test func openReadsSetupFiles() throws { | |
| 194 | let notes = try Folder() | |
| 195 | try notes.write("setup.org", "#+TODO: NEXT | DONE\n") | |
| 196 | let file = try notes.write("a.org", "#+SETUPFILE: setup.org\n* NEXT One\n") | |
| 197 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | |
| 198 | session.open(file.path) | |
| 199 | #expect(session.document?.tree.settings.todoKeywordNames.contains("NEXT") == true) | |
| 200 | } | |
| 201 | ||
| 193 | 202 | @Test func missingFileReportsAnError() { |
| 194 | 203 | let session = DocumentSession() |
| 195 | 204 | session.open("/nonexistent/a.org") |
Tests/OrgCoreTests/SettingsTests.swift +1 −1
| @@ -33,7 +33,7 @@ struct SettingsTests { | ||
| 33 | 33 | @Test func severalLinesMakeSeveralSequences() { |
| 34 | 34 | let settings = scan("#+TODO: A | B\n#+TYP_TODO: X | Y\n") |
| 35 | 35 | #expect(settings.todoSequences.count == 2) |
| 36 | #expect(settings.todoSequences[1].kind == .type) | |
| 36 | #expect(settings.todoSequences.map(\.kind) == [.type, .sequence]) | |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | 39 | @Test func keywordsInsideBlocksAreIgnored() { |
Tests/OrgCoreTests/SetupFileTests.swift added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | /// `#+SETUPFILE` keywords, nested setup files included, as Org reads them. | |
| 6 | struct SetupFileTests { | |
| 7 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | |
| 8 | ||
| 9 | static let common = """ | |
| 10 | #+TODO: NEXT WAIT | DONE CANCELLED | |
| 11 | #+PRIORITIES: A E C | |
| 12 | #+TAGS: work home | |
| 13 | #+FILETAGS: :shared: | |
| 14 | #+PROPERTY: owner alice | |
| 15 | #+PROPERTY: header-args :results silent | |
| 16 | #+TITLE: Setup Title | |
| 17 | #+OPTIONS: toc:nil num:nil | |
| 18 | #+MACRO: who Alice | |
| 19 | #+SETUPFILE: ../deep.org | |
| 20 | #+SETUPFILE: ../loop.org | |
| 21 | """ + "\n" | |
| 22 | static let deep = "#+TYP_TODO: BUG | FIXED\n#+SETUPFILE: setup/common.org\n" | |
| 23 | static let main = """ | |
| 24 | #+SETUPFILE: setup/common.org | |
| 25 | ||
| 26 | * NEXT [#E] Task :local: | |
| 27 | {{{who}}} | |
| 28 | * BUG Other | |
| 29 | """ + "\n" | |
| 30 | ||
| 31 | @Test func collectsNestedKeywordLines() { | |
| 32 | let files = ["/w/setup/common.org": Self.common, "/w/deep.org": Self.deep] | |
| 33 | let lines = SetupFile.keywordLines(in: Self.main, directory: "/w") { files[$0] } | |
| 34 | #expect(lines.hasPrefix("#+TODO: NEXT WAIT | DONE CANCELLED\n")) | |
| 35 | #expect(lines.contains("#+TYP_TODO: BUG | FIXED\n#+TITLE") == false) | |
| 36 | #expect(lines.contains("#+TYP_TODO: BUG | FIXED\n")) | |
| 37 | #expect(lines.components(separatedBy: "#+TODO:").count == 2) | |
| 38 | #expect(SetupFile.keywordLines(in: "* x\n", directory: "/w") { files[$0] } == "") | |
| 39 | #expect(SetupFile.keywordLines(in: "#+SETUPFILE: https://example.com/a.org\n", directory: "/w") { _ in "#+TODO: X\n" } == "") | |
| 40 | } | |
| 41 | ||
| 42 | @Test(.enabled(if: oracle)) | |
| 43 | func matchesOrg() throws { | |
| 44 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-setup-\(UUID().uuidString)") | |
| 45 | try FileManager.default.createDirectory(at: folder.appendingPathComponent("setup"), withIntermediateDirectories: true) | |
| 46 | defer { try? FileManager.default.removeItem(at: folder) } | |
| 47 | try Self.common.write(to: folder.appendingPathComponent("setup/common.org"), atomically: true, encoding: .utf8) | |
| 48 | try Self.deep.write(to: folder.appendingPathComponent("deep.org"), atomically: true, encoding: .utf8) | |
| 49 | let path = folder.appendingPathComponent("main.org").path | |
| 50 | try Self.main.write(toFile: path, atomically: true, encoding: .utf8) | |
| 51 | ||
| 52 | let emacs = try EmacsOracle.evaluate("", """ | |
| 53 | (with-current-buffer (find-file-noselect \(lispString(path))) | |
| 54 | (require 'ox) | |
| 55 | (goto-char (point-min)) | |
| 56 | (re-search-forward "^\\\\* NEXT") | |
| 57 | (let ((env (org-export-get-environment))) | |
| 58 | (list (mapconcat #'identity org-todo-keywords-1 " ") | |
| 59 | (format "%c %c %c" org-priority-highest org-priority-lowest org-priority-default) | |
| 60 | (mapconcat #'identity (org-get-tags) " ") | |
| 61 | (or (cdr (assoc "owner" org-keyword-properties)) "") | |
| 62 | (mapconcat #'identity (delq nil (mapcar (lambda (x) (and (stringp (car x)) (car x))) org-current-tag-alist)) " ") | |
| 63 | (org-element-interpret-data (plist-get env :title)) | |
| 64 | (format "%s" (plist-get env :with-toc)) | |
| 65 | (progn (org-macro-initialize-templates) (or (cdr (assoc "who" org-macro-templates)) ""))))) | |
| 66 | """) | |
| 67 | ||
| 68 | let settings = OrgSettings.default.resolvingSetupFiles(in: Self.main, path: path, read: OrgSettings.readFile) | |
| 69 | let tree = OrgParser.parse(Self.main, defaults: settings) | |
| 70 | let model = DocumentModel(tree: tree) | |
| 71 | let task = try #require(model.headings.firstIndex { $0.title.contains("Task") }) | |
| 72 | let priorities = tree.settings.priorities | |
| 73 | let tagNames = (FastSelection.tagTable(settings.setupKeywords + Self.main) ?? []).compactMap { item -> String? in | |
| 74 | if case .option(_, let name) = item { name } else { nil } | |
| 75 | } | |
| 76 | let options = ExportOptions.parse(settings.setupKeywords + Self.main) | |
| 77 | let page = HTMLExport.page(Self.main, fallbackTitle: "main", settings: settings, path: path) | |
| 78 | let ours = [ | |
| 79 | tree.settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) }.joined(separator: " "), | |
| 80 | "\(priorities.highest) \(priorities.lowest) \(priorities.default)", | |
| 81 | model.tags(of: task).map(\.value).joined(separator: " "), | |
| 82 | model.property("owner", of: nil)?.value ?? "", | |
| 83 | tagNames.joined(separator: " "), | |
| 84 | HTMLExport.metadata(Self.main, settings: settings).title ?? "", | |
| 85 | options.toc == nil ? "nil" : "\(options.toc!)", | |
| 86 | page.contains("Alice") ? "Alice" : "", | |
| 87 | ] | |
| 88 | #expect(ours == emacs) | |
| 89 | #expect(page.contains("<h1 class=\"title\">Setup Title</h1>") && !page.contains("id=\"table-of-contents\"")) | |
| 90 | } | |
| 91 | } | |