Commit 62fdf490e1
Verified · cmc
Layout: unified · split
Sources/OrgApp/CaptureModel.swift +50 −20
| @@ -15,6 +15,11 @@ import OrgDocument | ||
| 15 | 15 | /// template = "* TODO %?\n%i\n%a" |
| 16 | 16 | /// prepend = false |
| 17 | 17 | /// ``` |
| 18 | /// | |
| 19 | /// Other targets: `datetree = true` (under `olp` if given; `tree-type` "day", "week" or | |
| 20 | /// "month"), `id = "…"` without `file`, `clock = true` without `file`. Other properties, as | |
| 21 | /// in `org-capture-templates`: `immediate-finish`, `empty-lines`, `empty-lines-before`, | |
| 22 | /// `empty-lines-after`, `jump-to-captured`, `clock-in`, `clock-keep`, `clock-resume`. | |
| 18 | 23 | @MainActor |
| 19 | 24 | public enum CaptureTemplates { |
| 20 | 25 | public static var userFile: URL { UserConfig.file("capture.toml") } |
| @@ -40,7 +45,9 @@ public enum CaptureTemplates { | ||
| 40 | 45 | problems.append("capture.toml line \(table.line): expected [[template]]") |
| 41 | 46 | continue |
| 42 | 47 | } |
| 43 | guard let key = table.values["key"]?.string, let file = table.values["file"]?.string, let body = table.values["template"]?.string else { | |
| 48 | let values = table.values | |
| 49 | let fileless = values["id"]?.string != nil || values["clock"]?.bool == true | |
| 50 | guard let key = values["key"]?.string, let body = values["template"]?.string, let file = values["file"]?.string ?? (fileless ? "" : nil) else { | |
| 44 | 51 | problems.append("capture.toml line \(table.line): a template needs key, file and template") |
| 45 | 52 | continue |
| 46 | 53 | } |
| @@ -48,18 +55,33 @@ public enum CaptureTemplates { | ||
| 48 | 55 | problems.append("capture.toml line \(table.line): unknown type") |
| 49 | 56 | continue |
| 50 | 57 | } |
| 58 | let olp = values["olp"]?.string.map { $0.split(separator: "/").map(String.init) } | |
| 51 | 59 | let target: CaptureTemplate.Target |
| 52 | if let headline = table.values["headline"]?.string { | |
| 60 | if let id = values["id"]?.string { | |
| 61 | target = .id(id) | |
| 62 | } else if values["clock"]?.bool == true { | |
| 63 | target = .clock | |
| 64 | } else if values["datetree"]?.bool == true { | |
| 65 | guard let tree = CaptureTemplate.TreeType(rawValue: values["tree-type"]?.string ?? "day") else { | |
| 66 | problems.append("capture.toml line \(table.line): tree-type is day, week or month") | |
| 67 | continue | |
| 68 | } | |
| 69 | target = .datetree(file: file, path: olp ?? [], tree: tree) | |
| 70 | } else if let headline = values["headline"]?.string { | |
| 53 | 71 | target = .headline(file: file, headline: headline) |
| 54 | } else if let olp = table.values["olp"]?.string { | |
| 55 | target = .outline(file: file, path: olp.split(separator: "/").map(String.init)) | |
| 72 | } else if let olp { | |
| 73 | target = .outline(file: file, path: olp) | |
| 56 | 74 | } else { |
| 57 | 75 | target = .file(file) |
| 58 | 76 | } |
| 59 | templates.append(CaptureTemplate( | |
| 60 | key: key, name: table.values["name"]?.string ?? key, kind: kind, target: target, template: body, | |
| 61 | prepend: table.values["prepend"]?.bool ?? false | |
| 62 | )) | |
| 77 | func flag(_ name: String) -> Bool { values[name]?.bool ?? false } | |
| 78 | func lines(_ name: String) -> Int? { values[name]?.integer } | |
| 79 | let options = CaptureTemplate.Options( | |
| 80 | prepend: flag("prepend"), immediateFinish: flag("immediate-finish"), emptyLines: lines("empty-lines"), | |
| 81 | emptyLinesBefore: lines("empty-lines-before"), emptyLinesAfter: lines("empty-lines-after"), | |
| 82 | jumpToCaptured: flag("jump-to-captured"), clockIn: flag("clock-in"), clockKeep: flag("clock-keep"), clockResume: flag("clock-resume") | |
| 83 | ) | |
| 84 | templates.append(CaptureTemplate(key: key, name: values["name"]?.string ?? key, kind: kind, target: target, template: body, options: options)) | |
| 63 | 85 | } |
| 64 | 86 | return (templates.isEmpty ? defaults : templates, problems) |
| 65 | 87 | } |
| @@ -67,21 +89,23 @@ public enum CaptureTemplates { | ||
| 67 | 89 | |
| 68 | 90 | extension DocumentSession { |
| 69 | 91 | /// Places captured `text` with `template`: through the buffer when the target is open, |
| 70 | /// otherwise in the file, created if missing, through the save path. | |
| 71 | public func capture(_ text: String, template: CaptureTemplate, root: String?) -> String? { | |
| 72 | let path = Self.resolve(template.target.file, root: root) | |
| 92 | /// otherwise in the file, created if missing, through the save path. `id` and `clock` | |
| 93 | /// targets must be resolved first (`file` names the ID's file). Returns the file and where | |
| 94 | /// the entry starts. | |
| 95 | public func capture(_ text: String, template: CaptureTemplate, root: String?, file: String? = nil, now: Date = Date()) -> Result<(path: String, offset: Int), CaptureError> { | |
| 96 | let path = file ?? Self.resolve(template.target.file, root: root) | |
| 73 | 97 | if !isOpen(path), !FileManager.default.fileExists(atPath: path) { |
| 74 | 98 | do { |
| 75 | 99 | try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true) |
| 76 | 100 | try Data().write(to: URL(fileURLWithPath: path)) |
| 77 | 101 | } catch { |
| 78 | return "Can't create \(path): \(error.localizedDescription)" | |
| 102 | return .failure(CaptureError(message: "Can't create \(path): \(error.localizedDescription)")) | |
| 79 | 103 | } |
| 80 | 104 | } |
| 81 | switch perform(CapturePlacement(text: text, template: template), path: path, at: 0, expect: nil) { | |
| 82 | case .commit: return nil | |
| 83 | case .failed(let message): return message | |
| 84 | default: return "Can't capture there." | |
| 105 | switch perform(CapturePlacement(text: text, template: template), path: path, at: 0, expect: nil, now: now) { | |
| 106 | case .commit(let result): return .success((path, result.selection?.first?.lowerBound ?? 0)) | |
| 107 | case .failed(let message): return .failure(CaptureError(message: message)) | |
| 108 | default: return .failure(CaptureError(message: "Can't capture there.")) | |
| 85 | 109 | } |
| 86 | 110 | } |
| 87 | 111 | |
| @@ -101,7 +125,13 @@ extension DocumentSession { | ||
| 101 | 125 | } |
| 102 | 126 | } |
| 103 | 127 | |
| 104 | /// The edit `Capture.place` makes, as a command so the save path applies it. | |
| 128 | public struct CaptureError: Error, Equatable { | |
| 129 | public let message: String | |
| 130 | public init(message: String) { self.message = message } | |
| 131 | } | |
| 132 | ||
| 133 | /// The edit `Capture.place` makes, as a command so the save path applies it. The selection | |
| 134 | /// it leaves is the captured entry's start. | |
| 105 | 135 | struct CapturePlacement: OrgCommand { |
| 106 | 136 | let text: String |
| 107 | 137 | let template: CaptureTemplate |
| @@ -109,11 +139,11 @@ struct CapturePlacement: OrgCommand { | ||
| 109 | 139 | var title: String { "Capture" } |
| 110 | 140 | func applies(in context: EditContext) -> Bool { true } |
| 111 | 141 | func run(in context: EditContext) -> CommandStep { |
| 112 | switch Capture.place(text, template: template, in: context.text, settings: context.tree.settings) { | |
| 142 | switch Capture.placement(text, template: template, in: context.text, settings: context.tree.settings, now: context.now, calendar: context.calendar) { | |
| 113 | 143 | case .failure(.message(let message)): |
| 114 | 144 | return .failed(message) |
| 115 | case .success(let new): | |
| 116 | return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, new))) | |
| 145 | case .success(let placed): | |
| 146 | return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, placed.text), selection: [placed.start..<placed.start])) | |
| 117 | 147 | } |
| 118 | 148 | } |
| 119 | 149 | } |
Sources/OrgApp/ClockModel.swift +21 −3
| @@ -35,14 +35,21 @@ public final class ClockModel { | ||
| 35 | 35 | /// `org-clock-in` at the heading under the caret, clocking out a running clock first. |
| 36 | 36 | public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String { |
| 37 | 37 | guard let path = session.path, session.document != nil else { return "No file is open." } |
| 38 | guard let entry = session.outline.last(where: { $0.start <= session.caret }) else { return "Before first headline" } | |
| 38 | return clockIn(session, path: path, at: session.caret, now: now) | |
| 39 | } | |
| 40 | ||
| 41 | /// `org-clock-in` at the heading containing `offset` in `path`. | |
| 42 | public func clockIn(_ session: DocumentSession, path: String, at offset: Int, now: Date = Date()) -> String { | |
| 43 | guard let text = Self.text(path, session) else { return "Can't read \(path)" } | |
| 44 | let tree = OrgParser.parse(text) | |
| 45 | guard let entry = DocumentModel(tree: tree).headings.last(where: { $0.headingRange.lowerBound <= offset }) else { return "Before first headline" } | |
| 39 | 46 | if running != nil { |
| 40 | 47 | let message = clockOut(session, now: now) |
| 41 | 48 | if running != nil { return message } |
| 42 | 49 | } |
| 43 | switch session.perform(ClockIn(), path: path, at: session.caret, expect: nil, now: now) { | |
| 50 | switch session.perform(ClockIn(), path: path, at: offset, expect: nil, now: now) { | |
| 44 | 51 | case .commit: |
| 45 | guard let updated = session.document?.text, let start = ClockIn.runningStart(in: updated, near: entry.start) else { | |
| 52 | guard let updated = Self.text(path, session), let start = ClockIn.runningStart(in: updated, near: entry.headingRange.lowerBound) else { | |
| 46 | 53 | return "The clock line wasn't found after clocking in." |
| 47 | 54 | } |
| 48 | 55 | running = Running(path: path, start: start, heading: entry.title, startedAt: now) |
| @@ -76,6 +83,17 @@ public final class ClockModel { | ||
| 76 | 83 | } |
| 77 | 84 | } |
| 78 | 85 | |
| 86 | static func text(_ path: String, _ session: DocumentSession) -> String? { | |
| 87 | session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) | |
| 88 | } | |
| 89 | ||
| 90 | /// The clocked entry's heading, for the `clock` capture target. | |
| 91 | public func entry(_ session: DocumentSession) -> (path: String, offset: Int)? { | |
| 92 | guard let line = location(session), let text = Self.text(line.path, session) else { return nil } | |
| 93 | let heading = DocumentModel(tree: OrgParser.parse(text)).headings.last { $0.headingRange.lowerBound <= line.offset } | |
| 94 | return heading.map { (line.path, $0.headingRange.lowerBound) } | |
| 95 | } | |
| 96 | ||
| 79 | 97 | /// Where the running clock's line is, for `org-clock-goto`. |
| 80 | 98 | public func location(_ session: DocumentSession) -> (path: String, offset: Int)? { |
| 81 | 99 | guard let clock = running else { return nil } |
Sources/OrgApp/EmacsImport.swift +33 −3
| @@ -526,13 +526,27 @@ public struct EmacsImport: Sendable { | ||
| 526 | 526 | func quoted(_ s: String) -> String { UserConfig.format(.string(s)) } |
| 527 | 527 | var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n" |
| 528 | 528 | let strings = target.dropFirst().compactMap(\.string) |
| 529 | // Property list after the template: `:prepend t :empty-lines 1 …`. | |
| 530 | var properties: [String: Sexp] = [:] | |
| 531 | let rest = Array(parts.dropFirst(5)) | |
| 532 | for i in stride(from: 0, to: rest.count - 1, by: 2) { if let name = rest[i].symbol { properties[name] = rest[i + 1] } } | |
| 533 | let allStrings = strings.count == target.count - 1 | |
| 534 | var treeType = properties[":tree-type"]?.symbol | |
| 529 | 535 | let place: String? = switch kind { |
| 530 | 536 | case "file" where strings.count == 1 && target.count == 2: |
| 531 | 537 | "file = \(quoted(strings[0]))\n" |
| 532 | 538 | case "file+headline" where strings.count == 2 && target.count == 3: |
| 533 | 539 | "file = \(quoted(strings[0]))\nheadline = \(quoted(strings[1]))\n" |
| 534 | case "file+olp" where strings.count >= 2 && strings.count == target.count - 1: | |
| 540 | case "file+olp" where strings.count >= 2 && allStrings: | |
| 535 | 541 | "file = \(quoted(strings[0]))\nolp = \(quoted(strings.dropFirst().joined(separator: "/")))\n" |
| 542 | case "file+olp+datetree" where strings.count >= 1 && allStrings: | |
| 543 | "file = \(quoted(strings[0]))\ndatetree = true\n" + (strings.count > 1 ? "olp = \(quoted(strings.dropFirst().joined(separator: "/")))\n" : "") | |
| 544 | case let legacy where (legacy == "file+datetree" || legacy == "file+weektree") && strings.count == 1 && target.count == 2: | |
| 545 | "file = \(quoted(strings[0]))\ndatetree = true\n" | |
| 546 | case "id" where strings.count == 1 && target.count == 2: | |
| 547 | "id = \(quoted(strings[0]))\n" | |
| 548 | case "clock" where target.count == 1: | |
| 549 | "clock = true\n" | |
| 536 | 550 | default: |
| 537 | 551 | nil |
| 538 | 552 | } |
| @@ -540,9 +554,25 @@ public struct EmacsImport: Sendable { | ||
| 540 | 554 | unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "target \(kind) isn't supported", source: source)) |
| 541 | 555 | return |
| 542 | 556 | } |
| 557 | if kind == "file+weektree" { treeType = "week" } | |
| 543 | 558 | toml += place |
| 544 | let prepend = parts.dropFirst(5).enumerated().contains { $0.element.symbol == ":prepend" && parts.dropFirst(5).dropFirst($0.offset + 1).first?.symbol != "nil" } | |
| 545 | if prepend { toml += "prepend = true\n" } | |
| 559 | if let treeType { | |
| 560 | guard ["day", "week", "month"].contains(treeType) else { | |
| 561 | unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: ":tree-type \(treeType) isn't supported", source: source)) | |
| 562 | return | |
| 563 | } | |
| 564 | if treeType != "day" { toml += "tree-type = \(quoted(treeType))\n" } | |
| 565 | } | |
| 566 | for flag in ["prepend", "immediate-finish", "jump-to-captured", "clock-in", "clock-keep", "clock-resume"] { | |
| 567 | if let value = properties[":" + flag], value.symbol != "nil" { toml += "\(flag) = true\n" } | |
| 568 | } | |
| 569 | for count in ["empty-lines", "empty-lines-before", "empty-lines-after"] { | |
| 570 | if let n = properties[":" + count]?.integer { toml += "\(count) = \(n)\n" } | |
| 571 | } | |
| 572 | let ignored = properties.keys.filter { ![":prepend", ":immediate-finish", ":jump-to-captured", ":clock-in", ":clock-keep", ":clock-resume", ":empty-lines", ":empty-lines-before", ":empty-lines-after", ":tree-type"].contains($0) } | |
| 573 | if !ignored.isEmpty { | |
| 574 | unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "\(ignored.sorted().joined(separator: ", ")) left out", source: source)) | |
| 575 | } | |
| 546 | 576 | toml += "template = \(quoted(template))\n" |
| 547 | 577 | items.append(Item(id: id(), name: "capture \"\(key)\" \(name)", summary: "capture template \"\(key)\"", source: source, change: .capture(key: key, toml: toml))) |
| 548 | 578 | } |
Sources/OrgCore/Capture/Capture.swift +135 −23
| @@ -6,33 +6,89 @@ import Foundation | ||
| 6 | 6 | public struct CaptureTemplate: Sendable, Equatable, Identifiable { |
| 7 | 7 | public enum Kind: String, Sendable, Equatable { case entry, item, checkitem, plain } |
| 8 | 8 | |
| 9 | /// `:tree-type`: what a date tree groups entries by. | |
| 10 | public enum TreeType: String, Sendable, Equatable { case day, week, month } | |
| 11 | ||
| 9 | 12 | public enum Target: Sendable, Equatable { |
| 10 | 13 | case file(String) |
| 11 | 14 | case headline(file: String, headline: String) |
| 12 | 15 | case outline(file: String, path: [String]) |
| 16 | /// `file+olp+datetree`: today's entry in a date tree, under `path` or at the top. | |
| 17 | case datetree(file: String, path: [String], tree: TreeType) | |
| 18 | /// `(id "…")`: the heading with that ID; the app finds its file. | |
| 19 | case id(String) | |
| 20 | /// `(clock)`: the clocked-in entry; the app turns it into `heading`. | |
| 21 | case clock | |
| 22 | /// The heading starting at `offset` in `file`. | |
| 23 | case heading(file: String, offset: Int) | |
| 13 | 24 | |
| 25 | /// The target file, or "" for `id` and `clock` until the app resolves them. | |
| 14 | 26 | public var file: String { |
| 15 | 27 | switch self { |
| 16 | case .file(let f), .headline(let f, _), .outline(let f, _): f | |
| 28 | case .file(let f), .headline(let f, _), .outline(let f, _), .datetree(let f, _, _), .heading(let f, _): f | |
| 29 | case .id, .clock: "" | |
| 17 | 30 | } |
| 18 | 31 | } |
| 19 | 32 | } |
| 20 | 33 | |
| 34 | /// Template properties beyond the target (`org-capture-templates`). | |
| 35 | public struct Options: Sendable, Equatable { | |
| 36 | public var prepend = false | |
| 37 | /// `:immediate-finish`: file without showing the text. | |
| 38 | public var immediateFinish = false | |
| 39 | /// `:empty-lines`, `:empty-lines-before`, `:empty-lines-after`. | |
| 40 | public var emptyLines: Int? | |
| 41 | public var emptyLinesBefore: Int? | |
| 42 | public var emptyLinesAfter: Int? | |
| 43 | /// `:jump-to-captured`: show the entry after filing it. | |
| 44 | public var jumpToCaptured = false | |
| 45 | /// `:clock-in`, `:clock-keep`, `:clock-resume`. | |
| 46 | public var clockIn = false | |
| 47 | public var clockKeep = false | |
| 48 | public var clockResume = false | |
| 49 | ||
| 50 | public init( | |
| 51 | prepend: Bool = false, immediateFinish: Bool = false, emptyLines: Int? = nil, emptyLinesBefore: Int? = nil, | |
| 52 | emptyLinesAfter: Int? = nil, jumpToCaptured: Bool = false, clockIn: Bool = false, clockKeep: Bool = false, clockResume: Bool = false | |
| 53 | ) { | |
| 54 | self.prepend = prepend | |
| 55 | self.immediateFinish = immediateFinish | |
| 56 | self.emptyLines = emptyLines | |
| 57 | self.emptyLinesBefore = emptyLinesBefore | |
| 58 | self.emptyLinesAfter = emptyLinesAfter | |
| 59 | self.jumpToCaptured = jumpToCaptured | |
| 60 | self.clockIn = clockIn | |
| 61 | self.clockKeep = clockKeep | |
| 62 | self.clockResume = clockResume | |
| 63 | } | |
| 64 | ||
| 65 | var before: Int? { emptyLinesBefore ?? emptyLines } | |
| 66 | var after: Int { emptyLinesAfter ?? emptyLines ?? 0 } | |
| 67 | } | |
| 68 | ||
| 21 | 69 | public let key: String |
| 22 | 70 | public let name: String |
| 23 | 71 | public let kind: Kind |
| 24 | 72 | public let target: Target |
| 25 | 73 | public let template: String |
| 26 | public let prepend: Bool | |
| 74 | public let options: Options | |
| 75 | public var prepend: Bool { options.prepend } | |
| 27 | 76 | public var id: String { key } |
| 28 | 77 | |
| 29 | public init(key: String, name: String, kind: Kind = .entry, target: Target, template: String, prepend: Bool = false) { | |
| 78 | public init(key: String, name: String, kind: Kind = .entry, target: Target, template: String, prepend: Bool = false, options: Options = Options()) { | |
| 30 | 79 | self.key = key |
| 31 | 80 | self.name = name |
| 32 | 81 | self.kind = kind |
| 33 | 82 | self.target = target |
| 34 | 83 | self.template = template |
| 35 | self.prepend = prepend | |
| 84 | var options = options | |
| 85 | if prepend { options.prepend = true } | |
| 86 | self.options = options | |
| 87 | } | |
| 88 | ||
| 89 | /// The same template aimed at `target`. | |
| 90 | public func retargeted(_ target: Target) -> CaptureTemplate { | |
| 91 | CaptureTemplate(key: key, name: name, kind: kind, target: target, template: template, options: options) | |
| 36 | 92 | } |
| 37 | 93 | } |
| 38 | 94 | |
| @@ -229,14 +285,28 @@ public enum Capture { | ||
| 229 | 285 | // MARK: - Placing |
| 230 | 286 | |
| 231 | 287 | /// The target file's text after capturing `text`, as `org-capture-finalize` leaves it. |
| 232 | public static func place(_ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default) -> Result<String, Failure> { | |
| 288 | /// `now` and `calendar` pick a date tree's day. | |
| 289 | public static func place( | |
| 290 | _ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default, | |
| 291 | now: Date = Date(), calendar: Calendar = .current | |
| 292 | ) -> Result<String, Failure> { | |
| 293 | placement(text, template: template, in: fileText, settings: settings, now: now, calendar: calendar).map(\.text) | |
| 294 | } | |
| 295 | ||
| 296 | /// The new text, and where the captured entry starts in it. | |
| 297 | public static func placement( | |
| 298 | _ text: String, template: CaptureTemplate, in fileText: String, settings: OrgSettings = .default, | |
| 299 | now: Date = Date(), calendar: Calendar = .current | |
| 300 | ) -> Result<(text: String, start: Int), Failure> { | |
| 233 | 301 | let buffer = EmacsBuffer(fileText, point: 0, settings: settings) |
| 302 | let start: Int | |
| 234 | 303 | do { |
| 235 | let entryHeading = try locate(template.target, buffer) | |
| 304 | let entryHeading = try locate(template.target, buffer, date: calendar.dateComponents([.year, .month, .day], from: now)) | |
| 305 | let options = template.options | |
| 236 | 306 | switch template.kind { |
| 237 | case .entry: try placeEntry(text, prepend: template.prepend, heading: entryHeading, buffer) | |
| 238 | case .plain: placePlain(text, prepend: template.prepend, heading: entryHeading, buffer) | |
| 239 | case .item, .checkitem: try placeItem(text, checkbox: template.kind == .checkitem, prepend: template.prepend, heading: entryHeading, buffer) | |
| 307 | case .entry: start = try placeEntry(text, options: options, heading: entryHeading, buffer) | |
| 308 | case .plain: start = placePlain(text, options: options, heading: entryHeading, buffer) | |
| 309 | case .item, .checkitem: start = try placeItem(text, checkbox: template.kind == .checkitem, options: options, heading: entryHeading, buffer) | |
| 240 | 310 | } |
| 241 | 311 | } catch let error as Failure { |
| 242 | 312 | return .failure(error) |
| @@ -248,14 +318,32 @@ public enum Capture { | ||
| 248 | 318 | // Saving the target adds a final newline, as `require-final-newline` does in Org. |
| 249 | 319 | var result = buffer.text as String |
| 250 | 320 | if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" } |
| 251 | return .success(result) | |
| 321 | return .success((result, start)) | |
| 252 | 322 | } |
| 253 | 323 | |
| 254 | 324 | /// `org-capture-set-target-location`: point at the target heading, or nil for a file. |
| 255 | static func locate(_ target: CaptureTemplate.Target, _ buffer: EmacsBuffer) throws -> Int? { | |
| 325 | static func locate(_ target: CaptureTemplate.Target, _ buffer: EmacsBuffer, date: DateComponents) throws -> Int? { | |
| 256 | 326 | switch target { |
| 257 | 327 | case .file: |
| 258 | 328 | return nil |
| 329 | case .id(let id): | |
| 330 | buffer.point = 0 | |
| 331 | guard let match = buffer.searchForward("^[ \\t]*:ID:[ \\t]+" + NSRegularExpression.escapedPattern(for: id) + "[ \\t]*$", caseInsensitive: true) else { | |
| 332 | throw Failure.message("Cannot find target ID \"\(id)\"") | |
| 333 | } | |
| 334 | buffer.point = match[0]!.lowerBound | |
| 335 | buffer.backToHeadingOrPointMin() | |
| 336 | return buffer.beforeFirstHeading ? nil : buffer.point | |
| 337 | case .clock: | |
| 338 | throw Failure.message("No running clock") | |
| 339 | case .heading(_, let offset): | |
| 340 | guard offset <= buffer.length else { throw Failure.message("The clocked entry moved; try again") } | |
| 341 | buffer.point = buffer.lineStart(offset) | |
| 342 | guard atHeading(buffer) else { throw Failure.message("The clocked entry moved; try again") } | |
| 343 | return buffer.point | |
| 344 | case .datetree(_, let path, let tree): | |
| 345 | let parent = path.isEmpty ? nil : try locate(.outline(file: "", path: path), buffer, date: date) | |
| 346 | return Datetree.findCreate(buffer, under: parent, tree: tree, date: date) | |
| 259 | 347 | case .headline(_, let headline): |
| 260 | 348 | buffer.point = 0 |
| 261 | 349 | let pattern = "^\\*+[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: headline) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$" |
| @@ -394,7 +482,8 @@ public enum Capture { | ||
| 394 | 482 | } |
| 395 | 483 | |
| 396 | 484 | /// `org-capture-place-entry`. |
| 397 | static func placeEntry(_ entry: String, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) throws { | |
| 485 | static func placeEntry(_ entry: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int { | |
| 486 | let prepend = options.prepend | |
| 398 | 487 | var template = entry |
| 399 | 488 | if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template } |
| 400 | 489 | // `org-capture-verify-tree`: the first heading has the lowest level. |
| @@ -424,7 +513,8 @@ public enum Capture { | ||
| 424 | 513 | if !buffer.bolp { buffer.insert("\n") } |
| 425 | 514 | let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer) |
| 426 | 515 | deleteEmptyLinesBefore(buffer) |
| 427 | if blank { buffer.insert("\n") } | |
| 516 | buffer.insert(String(repeating: "\n", count: options.before ?? (blank ? 1 : 0))) | |
| 517 | let start = buffer.point | |
| 428 | 518 | // `org-paste-subtree` at `level`. |
| 429 | 519 | let oldLevel = levels.first ?? 1 |
| 430 | 520 | let shift = level - oldLevel |
| @@ -438,11 +528,19 @@ public enum Capture { | ||
| 438 | 528 | } |
| 439 | 529 | if !shifted.hasSuffix("\n") { shifted += "\n" } |
| 440 | 530 | buffer.insert(shifted) |
| 531 | emptyLinesAfter(buffer, options.after) | |
| 532 | return start | |
| 533 | } | |
| 534 | ||
| 535 | /// `org-capture-empty-lines-after`: exactly `n` blank lines after point, point kept. | |
| 536 | static func emptyLinesAfter(_ buffer: EmacsBuffer, _ n: Int) { | |
| 441 | 537 | deleteEmptyLinesAfter(buffer) |
| 538 | buffer.saveExcursion { buffer.insert(String(repeating: "\n", count: n)) } | |
| 442 | 539 | } |
| 443 | 540 | |
| 444 | 541 | /// `org-capture-place-plain-text`. |
| 445 | static func placePlain(_ text: String, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) { | |
| 542 | static func placePlain(_ text: String, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) -> Int { | |
| 543 | let prepend = options.prepend | |
| 446 | 544 | if let heading { |
| 447 | 545 | buffer.point = heading |
| 448 | 546 | if prepend { |
| @@ -458,8 +556,11 @@ public enum Capture { | ||
| 458 | 556 | } |
| 459 | 557 | if !buffer.bolp { buffer.insert("\n") } |
| 460 | 558 | deleteEmptyLinesBefore(buffer) |
| 559 | buffer.insert(String(repeating: "\n", count: options.before ?? 0)) | |
| 560 | let start = buffer.point | |
| 461 | 561 | buffer.insert(text.hasSuffix("\n") || text.isEmpty ? text : text + "\n") |
| 462 | deleteEmptyLinesAfter(buffer) | |
| 562 | emptyLinesAfter(buffer, options.after) | |
| 563 | return start | |
| 463 | 564 | } |
| 464 | 565 | |
| 465 | 566 | /// `org-end-of-meta-data t`: past planning, drawers and the property drawer. |
| @@ -482,9 +583,10 @@ public enum Capture { | ||
| 482 | 583 | } |
| 483 | 584 | } |
| 484 | 585 | |
| 485 | /// `org-capture-place-item`: after the last item of the first list in the entry, or at the | |
| 486 | /// end of the entry's text. | |
| 487 | static func placeItem(_ text: String, checkbox: Bool, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) throws { | |
| 586 | /// `org-capture-place-item`: after the last item of the first list in the entry (the file | |
| 587 | /// for a file target), or at the end of the entry's text. | |
| 588 | static func placeItem(_ text: String, checkbox: Bool, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int { | |
| 589 | let prepend = options.prepend | |
| 488 | 590 | var item = text.trimmingCharacters(in: .newlines) |
| 489 | 591 | if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil { |
| 490 | 592 | let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil |
| @@ -501,16 +603,15 @@ public enum Capture { | ||
| 501 | 603 | end = buffer.point |
| 502 | 604 | } else { |
| 503 | 605 | start = 0 |
| 504 | buffer.point = 0 | |
| 505 | if !atHeading(buffer) { nextHeading(buffer) } | |
| 506 | end = buffer.point | |
| 606 | end = buffer.length | |
| 507 | 607 | } |
| 508 | 608 | // The first list's items in the range. |
| 509 | 609 | buffer.point = start |
| 510 | 610 | var items: [(start: Int, end: Int, indent: String)] = [] |
| 511 | 611 | while buffer.point < end { |
| 512 | 612 | let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point)) |
| 513 | if let m = line.firstMatch(of: /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]/) { | |
| 613 | // A `*` bullet needs indentation; at the margin it starts a heading. | |
| 614 | if let m = line.firstMatch(of: /^([ \t]*)([-+*]|[0-9]+[.)])[ \t]/), m.2 != "*" || !m.1.isEmpty { | |
| 514 | 615 | if let first = items.first, String(m.1) != first.indent { |
| 515 | 616 | // A nested item belongs to the current one. |
| 516 | 617 | } else { |
| @@ -538,13 +639,24 @@ public enum Capture { | ||
| 538 | 639 | } |
| 539 | 640 | } |
| 540 | 641 | if !buffer.bolp { buffer.insert("\n") } |
| 642 | // In an existing list, at most one blank line between items. | |
| 643 | if !prepend { | |
| 644 | deleteEmptyLinesBefore(buffer) | |
| 645 | buffer.insert(String(repeating: "\n", count: min(1, options.before ?? 0))) | |
| 646 | } | |
| 647 | let at = buffer.point | |
| 541 | 648 | buffer.insert(indented + "\n") |
| 649 | if prepend { emptyLinesAfter(buffer, min(1, options.after)) } | |
| 650 | return at | |
| 542 | 651 | } else { |
| 543 | 652 | buffer.point = prepend ? start : end |
| 544 | 653 | if !buffer.bolp { buffer.insert("\n") } |
| 545 | 654 | deleteEmptyLinesBefore(buffer) |
| 655 | buffer.insert(String(repeating: "\n", count: options.before ?? 0)) | |
| 656 | let at = buffer.point | |
| 546 | 657 | buffer.insert(item + "\n") |
| 547 | deleteEmptyLinesAfter(buffer) | |
| 658 | emptyLinesAfter(buffer, options.after) | |
| 659 | return at | |
| 548 | 660 | } |
| 549 | 661 | } |
| 550 | 662 | } |
Sources/OrgCore/Capture/Datetree.swift added +155
| @@ -0,0 +1,155 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | // Date trees (org-datetree.el, Org 9.8.7): `org-datetree-find-create-entry` with the groupings | |
| 4 | // capture uses, and `org-datetree-add-timestamp` nil. Each level is found or created inside | |
| 5 | // the one above it, as org does while narrowed to it. | |
| 6 | enum Datetree { | |
| 7 | static let weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] | |
| 8 | static let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] | |
| 9 | ||
| 10 | /// Each level's heading title, and the pattern whose first group orders siblings. | |
| 11 | static func levels(_ tree: CaptureTemplate.TreeType, date: DateComponents) -> [(title: String, pattern: String)] { | |
| 12 | var gregorian = Calendar(identifier: .gregorian) | |
| 13 | gregorian.timeZone = TimeZone(identifier: "UTC")! | |
| 14 | let day = gregorian.date(from: DateComponents(year: date.year, month: date.month, day: date.day))! | |
| 15 | let weekday = weekdays[gregorian.component(.weekday, from: day) - 1] | |
| 16 | var iso = Calendar(identifier: .iso8601) | |
| 17 | iso.timeZone = gregorian.timeZone | |
| 18 | let isoYear = iso.component(.yearForWeekOfYear, from: day) | |
| 19 | let week = iso.component(.weekOfYear, from: day) | |
| 20 | let year = (title: String(date.year!), pattern: "([12][0-9]{3})") | |
| 21 | let dayLevel = (title: String(format: "%04d-%02d-%02d ", date.year!, date.month!, date.day!) + weekday, | |
| 22 | pattern: "([12][0-9]{3}-[01][0-9]-[0123][0-9]) \\w+") | |
| 23 | switch tree { | |
| 24 | case .day, .month: | |
| 25 | let month = (title: String(format: "%04d-%02d ", date.year!, date.month!) + months[date.month! - 1], | |
| 26 | pattern: "([12][0-9]{3}-[01][0-9]) \\w+") | |
| 27 | return tree == .day ? [year, month, dayLevel] : [year, month] | |
| 28 | case .week: | |
| 29 | return [(String(isoYear), year.pattern), (String(format: "%04d-W%02d", isoYear, week), "([12][0-9]{3}-W[0-5][0-9])"), dayLevel] | |
| 30 | } | |
| 31 | } | |
| 32 | ||
| 33 | /// Point at the date's entry, created where missing, under the heading at `parent` or at | |
| 34 | /// the top of the file. Returns the entry's start. | |
| 35 | static func findCreate(_ buffer: EmacsBuffer, under parent: Int?, tree: CaptureTemplate.TreeType, date: DateComponents) -> Int { | |
| 36 | let low = buffer.marker(parent ?? 0) | |
| 37 | let high = buffer.marker(parent.map { narrowEnd(buffer, $0, limit: buffer.length) } ?? buffer.length, advances: true) | |
| 38 | defer { | |
| 39 | buffer.release(low) | |
| 40 | buffer.release(high) | |
| 41 | } | |
| 42 | var level = parent.map { Capture.headingLevel(buffer, at: $0) + 1 } ?? 1 | |
| 43 | for (title, pattern) in levels(tree, date: date) { | |
| 44 | subheading(buffer, title: title, pattern: pattern, level: level, low: low, high: high) | |
| 45 | level += 1 | |
| 46 | } | |
| 47 | return low.position | |
| 48 | } | |
| 49 | ||
| 50 | /// `org-datetree--find-create-subheading`: leaves the region narrowed to the subheading. | |
| 51 | static func subheading(_ buffer: EmacsBuffer, title: String, pattern: String, level: Int, low: EmacsBuffer.Marker, high: EmacsBuffer.Marker) { | |
| 52 | func compare(_ sibling: String) -> Int? { | |
| 53 | guard let a = firstGroup(pattern, sibling), let b = firstGroup(pattern, title) else { return nil } | |
| 54 | return a < b ? -1 : a > b ? 1 : 0 | |
| 55 | } | |
| 56 | var sibling: (start: Int, order: Int)? | |
| 57 | buffer.point = low.position | |
| 58 | while let match = buffer.searchForward("^(\\*+) ", bound: high.position, caseInsensitive: false) { | |
| 59 | let start = match[0]!.lowerBound | |
| 60 | buffer.point = buffer.lineEnd(start) | |
| 61 | guard match[1]!.count == level else { continue } | |
| 62 | if let order = compare(rawTitle(buffer, start)), order >= 0 { | |
| 63 | sibling = (start, order) | |
| 64 | break | |
| 65 | } | |
| 66 | } | |
| 67 | if let sibling { | |
| 68 | buffer.point = sibling.start | |
| 69 | } else { | |
| 70 | buffer.point = high.position | |
| 71 | if !buffer.bolp { buffer.insert("\n") } | |
| 72 | } | |
| 73 | if let sibling, sibling.order == 0 { | |
| 74 | narrow(buffer, to: sibling.start, low: low, high: high) | |
| 75 | return | |
| 76 | } | |
| 77 | let end = buffer.point | |
| 78 | while buffer.point > low.position, let c = buffer.character(at: buffer.point - 1), c == 32 || c == 9 || c == 10 { buffer.point -= 1 } | |
| 79 | buffer.deleteRegion(buffer.point, end) | |
| 80 | if blankBeforeHeading(buffer, low: low.position, high: high.position) { buffer.insert("\n") } | |
| 81 | buffer.insert("\n" + String(repeating: "*", count: level) + " " + title + "\n") | |
| 82 | buffer.forwardLine(-1) | |
| 83 | narrow(buffer, to: buffer.point, low: low, high: high) | |
| 84 | } | |
| 85 | ||
| 86 | static func firstGroup(_ pattern: String, _ s: String) -> String? { | |
| 87 | let regex = try! NSRegularExpression(pattern: pattern) | |
| 88 | let ns = s as NSString | |
| 89 | guard let m = regex.firstMatch(in: s, range: NSRange(location: 0, length: ns.length)) else { return nil } | |
| 90 | return ns.substring(with: m.range(at: 1)) | |
| 91 | } | |
| 92 | ||
| 93 | /// A heading's `:raw-value`: its title without keyword, priority or tags. | |
| 94 | static func rawTitle(_ buffer: EmacsBuffer, _ start: Int) -> String { | |
| 95 | buffer.saveExcursion { | |
| 96 | buffer.point = start | |
| 97 | guard let match = buffer.lookingAt(buffer.complexHeadingPattern), let title = match[4] else { return "" } | |
| 98 | return buffer.substring(title.lowerBound, title.upperBound) | |
| 99 | } | |
| 100 | } | |
| 101 | ||
| 102 | /// `org-narrow-to-subtree` inside the current region. | |
| 103 | static func narrow(_ buffer: EmacsBuffer, to heading: Int, low: EmacsBuffer.Marker, high: EmacsBuffer.Marker) { | |
| 104 | low.position = heading | |
| 105 | high.position = narrowEnd(buffer, heading, limit: high.position) | |
| 106 | buffer.point = heading | |
| 107 | } | |
| 108 | ||
| 109 | /// The subtree's end, before the newline that precedes the next heading, within `limit`. | |
| 110 | static func narrowEnd(_ buffer: EmacsBuffer, _ heading: Int, limit: Int) -> Int { | |
| 111 | let end = buffer.saveExcursion { () -> Int in | |
| 112 | Capture.endOfSubtree(buffer, heading: heading) | |
| 113 | return buffer.point | |
| 114 | } | |
| 115 | return min(limit, end == buffer.length ? end : end - 1) | |
| 116 | } | |
| 117 | ||
| 118 | /// `org--blank-before-heading-p` with `org-blank-before-new-entry` `(heading . auto)`, | |
| 119 | /// in the region `low..<high`. | |
| 120 | static func blankBeforeHeading(_ buffer: EmacsBuffer, low: Int, high: Int) -> Bool { | |
| 121 | buffer.saveExcursion { | |
| 122 | func headingStart(before limit: Int) -> Int? { | |
| 123 | buffer.saveExcursion { | |
| 124 | buffer.point = buffer.lineEnd(limit) | |
| 125 | return buffer.searchBackward("^\\*+ ", bound: low, caseInsensitive: false)?[0]?.lowerBound | |
| 126 | } | |
| 127 | } | |
| 128 | /// `outline-next-heading` bounded by `high`. | |
| 129 | func nextHeading() -> Bool { | |
| 130 | if let match = buffer.searchForward("\\n\\*+ ", bound: high, caseInsensitive: false) { | |
| 131 | buffer.point = match[0]!.lowerBound + 1 | |
| 132 | return true | |
| 133 | } | |
| 134 | buffer.point = high | |
| 135 | return false | |
| 136 | } | |
| 137 | func previousLineEmpty() -> Bool { | |
| 138 | guard buffer.point > low else { return false } | |
| 139 | let start = buffer.lineStart(buffer.point) | |
| 140 | guard start > low else { return false } | |
| 141 | let previous = max(low, buffer.lineStart(start - 1)) | |
| 142 | return buffer.substring(previous, start - 1).allSatisfy { $0 == " " || $0 == "\t" } | |
| 143 | } | |
| 144 | if headingStart(before: buffer.point) == nil { | |
| 145 | guard nextHeading() else { return false } | |
| 146 | } | |
| 147 | guard let heading = headingStart(before: buffer.point) else { return false } | |
| 148 | buffer.point = heading | |
| 149 | if buffer.point != low { return previousLineEmpty() } | |
| 150 | if nextHeading() { return previousLineEmpty() } | |
| 151 | while buffer.point > low, let c = buffer.character(at: buffer.point - 1), c == 32 || c == 9 { buffer.point -= 1 } | |
| 152 | return buffer.bolp ? previousLineEmpty() : false | |
| 153 | } | |
| 154 | } | |
| 155 | } | |
Sources/Orgstar/CaptureView.swift +49 −4
| @@ -10,6 +10,7 @@ struct CaptureView: View { | ||
| 10 | 10 | let workspace: WorkspaceModel |
| 11 | 11 | let session: DocumentSession |
| 12 | 12 | let commands: AppCommands |
| 13 | let clock: ClockModel | |
| 13 | 14 | @State private var templates = CaptureTemplates.defaults |
| 14 | 15 | @State private var problem: String? |
| 15 | 16 | @State private var template: CaptureTemplate? |
| @@ -82,9 +83,12 @@ struct CaptureView: View { | ||
| 82 | 83 | private func target(_ template: CaptureTemplate) -> String { |
| 83 | 84 | let file = (template.target.file as NSString).lastPathComponent |
| 84 | 85 | switch template.target { |
| 85 | case .file: return file | |
| 86 | case .file, .heading: return file | |
| 86 | 87 | case .headline(_, let headline): return "\(file) / \(headline)" |
| 87 | 88 | case .outline(_, let path): return ([file] + path).joined(separator: " / ") |
| 89 | case .datetree(_, let path, _): return ([file] + path + ["date tree"]).joined(separator: " / ") | |
| 90 | case .id(let id): return "ID \(id)" | |
| 91 | case .clock: return "clocked entry" | |
| 88 | 92 | } |
| 89 | 93 | } |
| 90 | 94 | |
| @@ -119,6 +123,10 @@ struct CaptureView: View { | ||
| 119 | 123 | self.template = nil |
| 120 | 124 | case .success(let filled): |
| 121 | 125 | text = filled.text |
| 126 | if template.options.immediateFinish { | |
| 127 | finish() | |
| 128 | return | |
| 129 | } | |
| 122 | 130 | let index = String.Index(utf16Offset: filled.cursor, in: text) |
| 123 | 131 | selection = TextSelection(insertionPoint: index) |
| 124 | 132 | editing = true |
| @@ -126,13 +134,50 @@ struct CaptureView: View { | ||
| 126 | 134 | } |
| 127 | 135 | } |
| 128 | 136 | |
| 137 | /// `id` and `clock` targets as a file and a heading. | |
| 138 | private func resolved(_ template: CaptureTemplate) -> Result<(template: CaptureTemplate, file: String?), CaptureError> { | |
| 139 | switch template.target { | |
| 140 | case .id(let id): | |
| 141 | guard let location = try? workspace.index.headings(withID: id).first else { | |
| 142 | return .failure(CaptureError(message: "Cannot find target ID \"\(id)\"")) | |
| 143 | } | |
| 144 | return .success((template, location.path)) | |
| 145 | case .clock: | |
| 146 | guard let entry = clock.entry(session) else { return .failure(CaptureError(message: "No running clock")) } | |
| 147 | return .success((template.retargeted(.heading(file: entry.path, offset: entry.offset)), entry.path)) | |
| 148 | default: | |
| 149 | return .success((template, nil)) | |
| 150 | } | |
| 151 | } | |
| 152 | ||
| 129 | 153 | private func finish() { |
| 130 | 154 | guard let template else { return } |
| 131 | if let message = session.capture(text, template: template, root: workspace.rootPaths.first) { | |
| 132 | problem = message | |
| 155 | let opened = context.now | |
| 156 | let now = Date() | |
| 157 | let placed = resolved(template).flatMap { resolved in | |
| 158 | session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now) | |
| 159 | } | |
| 160 | switch placed { | |
| 161 | case .failure(let error): | |
| 162 | problem = error.message | |
| 163 | if template.options.immediateFinish { editing = true } | |
| 133 | 164 | return |
| 165 | case .success(let entry): | |
| 166 | commands.show("Captured to \(target(template))") | |
| 167 | let options = template.options | |
| 168 | if options.clockIn { | |
| 169 | // Clocked from when capture began; the clock it interrupted resumes after. | |
| 170 | let previous = clock.entry(session) | |
| 171 | commands.show(clock.clockIn(session, path: entry.path, at: entry.offset, now: opened)) | |
| 172 | if !options.clockKeep { | |
| 173 | commands.show(clock.clockOut(session, now: now)) | |
| 174 | if options.clockResume, let previous { | |
| 175 | commands.show(clock.clockIn(session, path: previous.path, at: previous.offset, now: now)) | |
| 176 | } | |
| 177 | } | |
| 178 | } | |
| 179 | if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) } | |
| 134 | 180 | } |
| 135 | commands.show("Captured to \(target(template))") | |
| 136 | 181 | close() |
| 137 | 182 | } |
| 138 | 183 | |
Sources/Orgstar/OrgstarApp.swift +1 −1
| @@ -270,7 +270,7 @@ struct OrgstarApp: App { | ||
| 270 | 270 | .defaultSize(width: 860, height: 560) |
| 271 | 271 | Window("Capture", id: "capture") { |
| 272 | 272 | if case .success(let model) = workspace { |
| 273 | CaptureView(workspace: model, session: session, commands: commands) | |
| 273 | CaptureView(workspace: model, session: session, commands: commands, clock: clock) | |
| 274 | 274 | } |
| 275 | 275 | } |
| 276 | 276 | .windowResizability(.contentSize) |
Tests/OrgAppTests/AppTests.swift +39 −3
| @@ -626,6 +626,35 @@ struct CaptureAppTests { | ||
| 626 | 626 | #expect(loaded.templates[0].target == .outline(file: "work.org", path: ["Projects", "Work"])) |
| 627 | 627 | #expect(loaded.templates[0].template == "* TODO %?\n%a") |
| 628 | 628 | #expect(loaded.templates[1].kind == .plain && loaded.templates[1].prepend) |
| 629 | try """ | |
| 630 | [[template]] | |
| 631 | key = "d" | |
| 632 | file = "journal.org" | |
| 633 | datetree = true | |
| 634 | olp = "Journal" | |
| 635 | tree-type = "month" | |
| 636 | template = "* %?" | |
| 637 | empty-lines-after = 1 | |
| 638 | jump-to-captured = true | |
| 639 | [[template]] | |
| 640 | key = "i" | |
| 641 | id = "abc" | |
| 642 | template = "* %?" | |
| 643 | clock-in = true | |
| 644 | clock-resume = true | |
| 645 | [[template]] | |
| 646 | key = "k" | |
| 647 | clock = true | |
| 648 | type = "item" | |
| 649 | template = "%?" | |
| 650 | immediate-finish = true | |
| 651 | """.write(to: file, atomically: true, encoding: .utf8) | |
| 652 | let targets = CaptureTemplates.load(userFile: file) | |
| 653 | #expect(targets.problems.isEmpty) | |
| 654 | #expect(targets.templates.map(\.target) == [.datetree(file: "journal.org", path: ["Journal"], tree: .month), .id("abc"), .clock]) | |
| 655 | #expect(targets.templates[0].options == .init(emptyLinesAfter: 1, jumpToCaptured: true)) | |
| 656 | #expect(targets.templates[1].options == .init(clockIn: true, clockResume: true)) | |
| 657 | #expect(targets.templates[2].options.immediateFinish) | |
| 629 | 658 | #expect(CaptureTemplates.load(userFile: folder.url.appendingPathComponent("none.toml")).templates == CaptureTemplates.defaults) |
| 630 | 659 | } |
| 631 | 660 | |
| @@ -634,15 +663,22 @@ struct CaptureAppTests { | ||
| 634 | 663 | let todo = try notes.write("todo.org", "* Inbox\n** TODO Old\n* Later\n") |
| 635 | 664 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) |
| 636 | 665 | let template = CaptureTemplates.defaults[0] |
| 637 | #expect(session.capture("* TODO New\n", template: template, root: notes.url.path) == nil) | |
| 666 | let placed = try session.capture("* TODO New\n", template: template, root: notes.url.path).get() | |
| 638 | 667 | #expect(try String(contentsOf: todo, encoding: .utf8) == "* Inbox\n** TODO Old\n** TODO New\n* Later\n") |
| 668 | #expect(placed.path == todo.path && placed.offset == ("* Inbox\n** TODO Old\n" as NSString).length) | |
| 639 | 669 | |
| 640 | #expect(session.capture("* Note\n", template: CaptureTemplates.defaults[1], root: notes.url.path) == nil) | |
| 670 | _ = try session.capture("* Note\n", template: CaptureTemplates.defaults[1], root: notes.url.path).get() | |
| 641 | 671 | #expect(try String(contentsOf: notes.url.appendingPathComponent("notes.org"), encoding: .utf8) == "* Inbox\n** Note\n") |
| 642 | 672 | |
| 643 | 673 | session.open(todo.path) |
| 644 | #expect(session.capture("* TODO Third\n", template: template, root: notes.url.path) == nil) | |
| 674 | _ = try session.capture("* TODO Third\n", template: template, root: notes.url.path).get() | |
| 645 | 675 | #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n") |
| 676 | ||
| 677 | // An ID's file is resolved by the caller. | |
| 678 | let id = CaptureTemplate(key: "i", name: "", target: .id("x1"), template: "") | |
| 679 | let ided = try notes.write("ided.org", "* Target\n:PROPERTIES:\n:ID: x1\n:END:\n") | |
| 680 | _ = try session.capture("* Child\n", template: id, root: nil, file: ided.path).get() | |
| 681 | #expect(try String(contentsOf: ided, encoding: .utf8) == "* Target\n:PROPERTIES:\n:ID: x1\n:END:\n** Child\n") | |
| 646 | 682 | } |
| 647 | 683 | } |
| 648 | 684 | |
Tests/OrgAppTests/EmacsImportTests.swift +11 −7
| @@ -22,7 +22,9 @@ struct EmacsImportTests { | ||
| 22 | 22 | (setq fill-column (+ 70 2)) |
| 23 | 23 | (setq org-capture-templates |
| 24 | 24 | '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a") |
| 25 | ("j" "Journal" entry (file+olp+datetree "~/notes/journal.org") "* %?") | |
| 25 | ("j" "Journal" entry (file+olp+datetree "~/notes/journal.org") "* %?" :tree-type week :empty-lines 1 :time-prompt t) | |
| 26 | ("c" "Clocked" item (clock) "%?" :immediate-finish t) | |
| 27 | ("x" "Function" entry (function ignore) "* %?") | |
| 26 | 28 | ("w" "Work") |
| 27 | 29 | ("wn" "Work note" plain (file "~/work/notes.org") "%?" :prepend t))) |
| 28 | 30 | (map! :leader :desc "Report" "z t" #'tr/report |
| @@ -81,7 +83,7 @@ struct EmacsImportTests { | ||
| 81 | 83 | #expect(folders == ["~/notes/", "~/work"]) |
| 82 | 84 | #expect(result.unreadable.map(\.name) == ["fill-column"]) |
| 83 | 85 | #expect(Set(result.unsupported.map(\.name)) == ["org-support-shift-select", "doom-theme", "org-roam-directory", |
| 84 | "capture \"j\"", "SPC z t → tr/report", "C-c l"]) | |
| 86 | "capture \"j\"", "capture \"x\"", "SPC z t → tr/report", "C-c l"]) | |
| 85 | 87 | #expect(result.skipped == 1) |
| 86 | 88 | #expect(result.doomDefaultsLeftOut == 1) |
| 87 | 89 | } |
| @@ -89,10 +91,12 @@ struct EmacsImportTests { | ||
| 89 | 91 | @Test func readsCaptureTemplatesAndBindings() throws { |
| 90 | 92 | let result = try scan() |
| 91 | 93 | let captures = result.items.compactMap { if case .capture(let key, let toml) = $0.change { (key, toml) } else { nil } } |
| 92 | #expect(captures.map(\.0) == ["t", "wn"]) | |
| 94 | #expect(captures.map(\.0) == ["t", "j", "c", "wn"]) | |
| 95 | #expect(captures[1].1 == "[[template]]\nkey = \"j\"\nname = \"Journal\"\ntype = \"entry\"\nfile = \"~/notes/journal.org\"\ndatetree = true\ntree-type = \"week\"\nempty-lines = 1\ntemplate = \"* %?\"\n") | |
| 96 | #expect(captures[2].1.contains("clock = true\n") && captures[2].1.contains("immediate-finish = true\n")) | |
| 97 | #expect(result.unsupported.contains { $0.name == "capture \"j\"" && $0.reason == ":time-prompt left out" }) | |
| 93 | 98 | #expect(captures[0].1 == "[[template]]\nkey = \"t\"\nname = \"Todo\"\ntype = \"entry\"\nfile = \"~/notes/todo.org\"\nheadline = \"Inbox\"\ntemplate = \"* TODO %?\\n%a\"\n") |
| 94 | #expect(captures[1].1.contains("prepend = true\n") && captures[1].1.contains("type = \"plain\"")) | |
| 95 | #expect(result.unsupported.contains { $0.name == "capture \"j\"" }) | |
| 99 | #expect(captures[3].1.contains("prepend = true\n") && captures[3].1.contains("type = \"plain\"")) | |
| 96 | 100 | |
| 97 | 101 | let bindings = result.items.compactMap { if case .binding(let keys, let command, let mode) = $0.change { "\(keys) \(command) \(mode ?? "-")" } else { nil } } |
| 98 | 102 | #expect(bindings == ["SPC m x org.checkbox.toggle normal", "g s app.save normal", "C-c a app.agenda normal"]) |
| @@ -131,12 +135,12 @@ struct EmacsImportTests { | ||
| 131 | 135 | #expect(defaults.integer(forKey: "agendaStartOffset") == -3) |
| 132 | 136 | #expect(defaults.string(forKey: "todoKeywords") == "TODO(t) WAIT(w) | DONE(d)\nBUG | FIXED") |
| 133 | 137 | let capture = try String(contentsOf: folder.appendingPathComponent("capture.toml"), encoding: .utf8) |
| 134 | #expect(capture.components(separatedBy: "[[template]]").count == 3) | |
| 138 | #expect(capture.components(separatedBy: "[[template]]").count == 5) | |
| 135 | 139 | #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty) |
| 136 | 140 | let keymap = folder.appendingPathComponent("keymap.toml") |
| 137 | 141 | #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) |
| 138 | 142 | #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a")) |
| 139 | #expect(summary.hasPrefix("Imported 14 settings, 1 capture template, 3 key bindings."), "\(summary)") | |
| 143 | #expect(summary.hasPrefix("Imported 14 settings, 3 capture templates, 3 key bindings."), "\(summary)") | |
| 140 | 144 | #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") |
| 141 | 145 | #expect(defaults.integer(forKey: "themeFontSize") == 14) |
| 142 | 146 | } |
Tests/OrgCoreTests/CaptureTests.swift +91 −4
| @@ -23,14 +23,33 @@ struct CaptureTests { | ||
| 23 | 23 | let path = folder.appendingPathComponent("target\(i).org").path |
| 24 | 24 | try c.file.write(toFile: path, atomically: true, encoding: .utf8) |
| 25 | 25 | let target: String |
| 26 | var properties = c.template.prepend ? " :prepend t" : "" | |
| 27 | let o = c.template.options | |
| 28 | if let n = o.emptyLines { properties += " :empty-lines \(n)" } | |
| 29 | if let n = o.emptyLinesBefore { properties += " :empty-lines-before \(n)" } | |
| 30 | if let n = o.emptyLinesAfter { properties += " :empty-lines-after \(n)" } | |
| 26 | 31 | switch c.template.target { |
| 27 | 32 | case .file: target = "(file \(lisp(path)))" |
| 28 | 33 | case .headline(_, let h): target = "(file+headline \(lisp(path)) \(lisp(h)))" |
| 29 | 34 | case .outline(_, let p): target = "(file+olp \(lisp(path)) \(p.map(lisp).joined(separator: " ")))" |
| 35 | case .datetree(_, let p, let tree): | |
| 36 | target = "(file+olp+datetree \(lisp(path)) \(p.map(lisp).joined(separator: " ")))" | |
| 37 | properties += " :tree-type \(tree.rawValue)" | |
| 38 | case .id(let id): target = "(id \(lisp(id)))" | |
| 39 | case .clock, .heading: target = "(clock)" | |
| 30 | 40 | } |
| 31 | 41 | forms.append(""" |
| 32 | (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(c.template.prepend ? " :prepend t" : ""))))) | |
| 33 | (org-capture nil "x") | |
| 42 | (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(properties)))) | |
| 43 | (path \(lisp(path)))) | |
| 44 | (cl-letf (((symbol-function 'org-id-find) | |
| 45 | (lambda (id &rest _) | |
| 46 | (with-current-buffer (find-file-noselect path) | |
| 47 | (save-excursion | |
| 48 | (goto-char (point-min)) | |
| 49 | (when (re-search-forward (concat "^[ \t]*:ID:[ \t]+" (regexp-quote id) "[ \t]*$") nil t) | |
| 50 | (org-back-to-heading t) | |
| 51 | (cons path (point)))))))) | |
| 52 | (org-capture nil "x")) | |
| 34 | 53 | (insert \(lisp(c.typed))) |
| 35 | 54 | (org-capture-finalize) |
| 36 | 55 | (with-current-buffer (find-buffer-visiting \(lisp(path))) |
| @@ -43,24 +62,28 @@ struct CaptureTests { | ||
| 43 | 62 | ;;; -*- lexical-binding: t -*- |
| 44 | 63 | (require 'org) |
| 45 | 64 | (require 'org-capture) |
| 65 | (require 'org-id) | |
| 46 | 66 | (require 'json) |
| 47 | 67 | (setq org-adapt-indentation nil) |
| 48 | 68 | (defvar results nil) |
| 49 | 69 | \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")")) |
| 50 | 70 | (with-temp-file \(lisp(output.path)) (insert (json-encode (vconcat (nreverse results))))) |
| 51 | 71 | """.write(to: script, atomically: true, encoding: .utf8) |
| 52 | _ = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"]) | |
| 72 | let log = try EmacsOracle.run(["-Q", "--batch", "-l", script.path], environment: ["TZ": "UTC"]) | |
| 73 | guard FileManager.default.fileExists(atPath: output.path) else { throw EmacsOracle.EvaluateError(log: log) } | |
| 53 | 74 | return try JSONDecoder().decode([String].self, from: Data(contentsOf: output)) |
| 54 | 75 | } |
| 55 | 76 | |
| 56 | 77 | static func ours(_ c: Case, now: Date) -> String { |
| 78 | var utcCalendar = Calendar(identifier: .gregorian) | |
| 79 | utcCalendar.timeZone = TimeZone(identifier: "UTC")! | |
| 57 | 80 | var utc = Calendar(identifier: .gregorian) |
| 58 | 81 | utc.timeZone = TimeZone(identifier: "UTC")! |
| 59 | 82 | guard case .success(let filled) = Capture.fill(c.template.template, context: CaptureContext(now: now, calendar: utc)) else { return "fill failed" } |
| 60 | 83 | var text = filled.text |
| 61 | 84 | let index = String.Index(utf16Offset: filled.cursor, in: text) |
| 62 | 85 | text.insert(contentsOf: c.typed, at: index) |
| 63 | switch Capture.place(text, template: c.template, in: c.file) { | |
| 86 | switch Capture.place(text, template: c.template, in: c.file, now: now, calendar: utcCalendar) { | |
| 64 | 87 | case .success(let result): return result |
| 65 | 88 | case .failure(.message(let message)): return "failed: \(message)" |
| 66 | 89 | } |
| @@ -112,6 +135,70 @@ struct CaptureTests { | ||
| 112 | 135 | } |
| 113 | 136 | } |
| 114 | 137 | |
| 138 | static let datetreeFiles = [ | |
| 139 | "", | |
| 140 | "#+TITLE: Journal\n", | |
| 141 | "* 2026\n** 2026-09 September\n*** 2026-09-01 Tuesday\nOld\n*** 2026-09-30 Wednesday\n** 2026-11 November\n* 2027\n", | |
| 142 | "* 2025\n\n** 2025-01 January\n\n*** 2025-01-01 Wednesday\n\nNew year\n\n* Other\n", | |
| 143 | "Notes before.\n\n* Journal\n** 2026\n*** 2026-09 September\n* Tasks\n** TODO Thing\n", | |
| 144 | "* Journal\n:PROPERTIES:\n:ID: journal-id\n:END:\n* Tasks\n", | |
| 145 | "* 2026\n** 2026-W36\n*** 2026-09-01 Tuesday\n** 2026-W38\n", | |
| 146 | ] | |
| 147 | ||
| 148 | static let datetreeTemplates: [CaptureTemplate] = [ | |
| 149 | CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .day), template: "* %?"), | |
| 150 | CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .week), template: "* %U %?"), | |
| 151 | CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .month), template: "* %?"), | |
| 152 | CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: ["Journal"], tree: .day), template: "* %?"), | |
| 153 | CaptureTemplate(key: "x", name: "", kind: .plain, target: .datetree(file: "", path: [], tree: .day), template: "%U %?"), | |
| 154 | CaptureTemplate(key: "x", name: "", kind: .item, target: .datetree(file: "", path: [], tree: .day), template: "%?"), | |
| 155 | CaptureTemplate(key: "x", name: "", target: .datetree(file: "", path: [], tree: .day), template: "* %?", options: .init(prepend: true)), | |
| 156 | ] | |
| 157 | ||
| 158 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil), arguments: [1_789_000_000, 1_798_804_800]) | |
| 159 | func datetreesMatchEmacs(seconds: Int) throws { | |
| 160 | var cases: [Case] = [] | |
| 161 | for template in Self.datetreeTemplates { | |
| 162 | for file in Self.datetreeFiles { | |
| 163 | if case .datetree(_, let path, _) = template.target, !path.isEmpty, !file.contains("* Journal") { continue } | |
| 164 | cases.append(Case(file: file, template: template, typed: "Entry")) | |
| 165 | } | |
| 166 | } | |
| 167 | let emacs = try Self.emacs(cases, seconds: seconds) | |
| 168 | let now = Date(timeIntervalSince1970: Double(seconds)) | |
| 169 | for (c, expected) in zip(cases, emacs) { | |
| 170 | let mine = Self.ours(c, now: now) | |
| 171 | #expect(mine == expected, "\(c.template.target) \(c.template.kind) into \(c.file.debugDescription)\nours:\n\(mine.debugDescription)\nemacs:\n\(expected.debugDescription)") | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 175 | static let optionTemplates: [CaptureTemplate] = [ | |
| 176 | CaptureTemplate(key: "x", name: "", target: .id("journal-id"), template: "* %?"), | |
| 177 | CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* %?", options: .init(emptyLines: 1)), | |
| 178 | CaptureTemplate(key: "x", name: "", target: .headline(file: "", headline: "Inbox"), template: "* %?", options: .init(emptyLinesBefore: 2, emptyLinesAfter: 1)), | |
| 179 | CaptureTemplate(key: "x", name: "", target: .file(""), template: "* %?", options: .init(prepend: true, emptyLinesAfter: 1)), | |
| 180 | CaptureTemplate(key: "x", name: "", kind: .plain, target: .headline(file: "", headline: "Inbox"), template: "%?", options: .init(emptyLines: 1)), | |
| 181 | CaptureTemplate(key: "x", name: "", kind: .item, target: .file(""), template: "%?", options: .init(emptyLines: 1)), | |
| 182 | ] | |
| 183 | ||
| 184 | @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | |
| 185 | func targetsAndEmptyLinesMatchEmacs() throws { | |
| 186 | var cases: [Case] = [] | |
| 187 | let files = ["* Journal\n:PROPERTIES:\n:ID: journal-id\n:END:\nText\n** Old\n* Inbox\n", "* Inbox\n\n** Old\n\n* Later\n", "Preamble\n* Inbox\nBody\n"] | |
| 188 | for template in Self.optionTemplates { | |
| 189 | for file in files { | |
| 190 | if case .id = template.target, !file.contains("journal-id") { continue } | |
| 191 | cases.append(Case(file: file, template: template, typed: "Entry")) | |
| 192 | } | |
| 193 | } | |
| 194 | let seconds = 1_789_000_000 | |
| 195 | let emacs = try Self.emacs(cases, seconds: seconds) | |
| 196 | for (c, expected) in zip(cases, emacs) { | |
| 197 | let mine = Self.ours(c, now: Date(timeIntervalSince1970: Double(seconds))) | |
| 198 | #expect(mine == expected, "\(c.template.target) \(c.template.kind) \(c.template.options) into \(c.file.debugDescription)\nours:\n\(mine.debugDescription)\nemacs:\n\(expected.debugDescription)") | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 115 | 202 | @Test func fillsPrompts() { |
| 116 | 203 | let template = "* %^{Title|untitled} for %^{Who}\n%\\1 again" |
| 117 | 204 | #expect(Capture.questions(template) == [ |