Capture into date trees, IDs and the clocked entry !71
10 files changed, +585 −68
Layout: unified · split
Sources/OrgApp/CaptureModel.swift +50 −20
| @@ -15,6 +15,11 @@ import OrgDocument | |||
| 15 | /// template = "* TODO %?\n%i\n%a" | 15 | /// template = "* TODO %?\n%i\n%a" |
| 16 | /// prepend = false | 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 | @MainActor | 23 | @MainActor |
| 19 | public enum CaptureTemplates { | 24 | public enum CaptureTemplates { |
| 20 | public static var userFile: URL { UserConfig.file("capture.toml") } | 25 | public static var userFile: URL { UserConfig.file("capture.toml") } |
| @@ -40,7 +45,9 @@ public enum CaptureTemplates { | |||
| 40 | problems.append("capture.toml line \(table.line): expected [[template]]") | 45 | problems.append("capture.toml line \(table.line): expected [[template]]") |
| 41 | continue | 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 | problems.append("capture.toml line \(table.line): a template needs key, file and template") | 51 | problems.append("capture.toml line \(table.line): a template needs key, file and template") |
| 45 | continue | 52 | continue |
| 46 | } | 53 | } |
| @@ -48,18 +55,33 @@ public enum CaptureTemplates { | |||
| 48 | problems.append("capture.toml line \(table.line): unknown type") | 55 | problems.append("capture.toml line \(table.line): unknown type") |
| 49 | continue | 56 | continue |
| 50 | } | 57 | } |
| 58 | let olp = values["olp"]?.string.map { $0.split(separator: "/").map(String.init) } | ||
| 51 | let target: CaptureTemplate.Target | 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 | target = .headline(file: file, headline: headline) | 71 | target = .headline(file: file, headline: headline) |
| 54 | } else if let olp = table.values["olp"]?.string { | 72 | } else if let olp { |
| 55 | target = .outline(file: file, path: olp.split(separator: "/").map(String.init)) | 73 | target = .outline(file: file, path: olp) |
| 56 | } else { | 74 | } else { |
| 57 | target = .file(file) | 75 | target = .file(file) |
| 58 | } | 76 | } |
| 59 | templates.append(CaptureTemplate( | 77 | func flag(_ name: String) -> Bool { values[name]?.bool ?? false } |
| 60 | key: key, name: table.values["name"]?.string ?? key, kind: kind, target: target, template: body, | 78 | func lines(_ name: String) -> Int? { values[name]?.integer } |
| 61 | prepend: table.values["prepend"]?.bool ?? false | 79 | let options = CaptureTemplate.Options( |
| 62 | )) | 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 | return (templates.isEmpty ? defaults : templates, problems) | 86 | return (templates.isEmpty ? defaults : templates, problems) |
| 65 | } | 87 | } |
| @@ -67,21 +89,23 @@ public enum CaptureTemplates { | |||
| 67 | 89 | ||
| 68 | extension DocumentSession { | 90 | extension DocumentSession { |
| 69 | /// Places captured `text` with `template`: through the buffer when the target is open, | 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. | 92 | /// otherwise in the file, created if missing, through the save path. `id` and `clock` |
| 71 | public func capture(_ text: String, template: CaptureTemplate, root: String?) -> String? { | 93 | /// targets must be resolved first (`file` names the ID's file). Returns the file and where |
| 72 | let path = Self.resolve(template.target.file, root: root) | 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 | if !isOpen(path), !FileManager.default.fileExists(atPath: path) { | 97 | if !isOpen(path), !FileManager.default.fileExists(atPath: path) { |
| 74 | do { | 98 | do { |
| 75 | try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true) | 99 | try FileManager.default.createDirectory(at: URL(fileURLWithPath: path).deletingLastPathComponent(), withIntermediateDirectories: true) |
| 76 | try Data().write(to: URL(fileURLWithPath: path)) | 100 | try Data().write(to: URL(fileURLWithPath: path)) |
| 77 | } catch { | 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) { | 105 | switch perform(CapturePlacement(text: text, template: template), path: path, at: 0, expect: nil, now: now) { |
| 82 | case .commit: return nil | 106 | case .commit(let result): return .success((path, result.selection?.first?.lowerBound ?? 0)) |
| 83 | case .failed(let message): return message | 107 | case .failed(let message): return .failure(CaptureError(message: message)) |
| 84 | default: return "Can't capture there." | 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 | struct CapturePlacement: OrgCommand { | 135 | struct CapturePlacement: OrgCommand { |
| 106 | let text: String | 136 | let text: String |
| 107 | let template: CaptureTemplate | 137 | let template: CaptureTemplate |
| @@ -109,11 +139,11 @@ struct CapturePlacement: OrgCommand { | |||
| 109 | var title: String { "Capture" } | 139 | var title: String { "Capture" } |
| 110 | func applies(in context: EditContext) -> Bool { true } | 140 | func applies(in context: EditContext) -> Bool { true } |
| 111 | func run(in context: EditContext) -> CommandStep { | 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 | case .failure(.message(let message)): | 143 | case .failure(.message(let message)): |
| 114 | return .failed(message) | 144 | return .failed(message) |
| 115 | case .success(let new): | 145 | case .success(let placed): |
| 116 | return .commit(EditResult(baseRevision: context.revision, edits: TextEdit.between(context.text, new))) | 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 | /// `org-clock-in` at the heading under the caret, clocking out a running clock first. | 35 | /// `org-clock-in` at the heading under the caret, clocking out a running clock first. |
| 36 | public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String { | 36 | public func clockIn(_ session: DocumentSession, now: Date = Date()) -> String { |
| 37 | guard let path = session.path, session.document != nil else { return "No file is open." } | 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 | if running != nil { | 46 | if running != nil { |
| 40 | let message = clockOut(session, now: now) | 47 | let message = clockOut(session, now: now) |
| 41 | if running != nil { return message } | 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 | case .commit: | 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 | return "The clock line wasn't found after clocking in." | 53 | return "The clock line wasn't found after clocking in." |
| 47 | } | 54 | } |
| 48 | running = Running(path: path, start: start, heading: entry.title, startedAt: now) | 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 | /// Where the running clock's line is, for `org-clock-goto`. | 97 | /// Where the running clock's line is, for `org-clock-goto`. |
| 80 | public func location(_ session: DocumentSession) -> (path: String, offset: Int)? { | 98 | public func location(_ session: DocumentSession) -> (path: String, offset: Int)? { |
| 81 | guard let clock = running else { return nil } | 99 | guard let clock = running else { return nil } |
Sources/OrgApp/EmacsImport.swift +33 −3
| @@ -526,13 +526,27 @@ public struct EmacsImport: Sendable { | |||
| 526 | func quoted(_ s: String) -> String { UserConfig.format(.string(s)) } | 526 | func quoted(_ s: String) -> String { UserConfig.format(.string(s)) } |
| 527 | var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n" | 527 | var toml = "[[template]]\nkey = \(quoted(key))\nname = \(quoted(name))\ntype = \(quoted(type))\n" |
| 528 | let strings = target.dropFirst().compactMap(\.string) | 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 | let place: String? = switch kind { | 535 | let place: String? = switch kind { |
| 530 | case "file" where strings.count == 1 && target.count == 2: | 536 | case "file" where strings.count == 1 && target.count == 2: |
| 531 | "file = \(quoted(strings[0]))\n" | 537 | "file = \(quoted(strings[0]))\n" |
| 532 | case "file+headline" where strings.count == 2 && target.count == 3: | 538 | case "file+headline" where strings.count == 2 && target.count == 3: |
| 533 | "file = \(quoted(strings[0]))\nheadline = \(quoted(strings[1]))\n" | 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 | "file = \(quoted(strings[0]))\nolp = \(quoted(strings.dropFirst().joined(separator: "/")))\n" | 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 | default: | 550 | default: |
| 537 | nil | 551 | nil |
| 538 | } | 552 | } |
| @@ -540,9 +554,25 @@ public struct EmacsImport: Sendable { | |||
| 540 | unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "target \(kind) isn't supported", source: source)) | 554 | unsupported.append(Note(id: id(), name: "capture \"\(key)\"", reason: "target \(kind) isn't supported", source: source)) |
| 541 | return | 555 | return |
| 542 | } | 556 | } |
| 557 | if kind == "file+weektree" { treeType = "week" } | ||
| 543 | toml += place | 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" } | 559 | if let treeType { |
| 545 | if prepend { toml += "prepend = true\n" } | 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 | toml += "template = \(quoted(template))\n" | 576 | toml += "template = \(quoted(template))\n" |
| 547 | items.append(Item(id: id(), name: "capture \"\(key)\" \(name)", summary: "capture template \"\(key)\"", source: source, change: .capture(key: key, toml: toml))) | 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 | public struct CaptureTemplate: Sendable, Equatable, Identifiable { | 6 | public struct CaptureTemplate: Sendable, Equatable, Identifiable { |
| 7 | public enum Kind: String, Sendable, Equatable { case entry, item, checkitem, plain } | 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 | public enum Target: Sendable, Equatable { | 12 | public enum Target: Sendable, Equatable { |
| 10 | case file(String) | 13 | case file(String) |
| 11 | case headline(file: String, headline: String) | 14 | case headline(file: String, headline: String) |
| 12 | case outline(file: String, path: [String]) | 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 | public var file: String { | 26 | public var file: String { |
| 15 | switch self { | 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 | public let key: String | 69 | public let key: String |
| 22 | public let name: String | 70 | public let name: String |
| 23 | public let kind: Kind | 71 | public let kind: Kind |
| 24 | public let target: Target | 72 | public let target: Target |
| 25 | public let template: String | 73 | public let template: String |
| 26 | public let prepend: Bool | 74 | public let options: Options |
| 75 | public var prepend: Bool { options.prepend } | ||
| 27 | public var id: String { key } | 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 | self.key = key | 79 | self.key = key |
| 31 | self.name = name | 80 | self.name = name |
| 32 | self.kind = kind | 81 | self.kind = kind |
| 33 | self.target = target | 82 | self.target = target |
| 34 | self.template = template | 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 | // MARK: - Placing | 285 | // MARK: - Placing |
| 230 | 286 | ||
| 231 | /// The target file's text after capturing `text`, as `org-capture-finalize` leaves it. | 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 | let buffer = EmacsBuffer(fileText, point: 0, settings: settings) | 301 | let buffer = EmacsBuffer(fileText, point: 0, settings: settings) |
| 302 | let start: Int | ||
| 234 | do { | 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 | switch template.kind { | 306 | switch template.kind { |
| 237 | case .entry: try placeEntry(text, prepend: template.prepend, heading: entryHeading, buffer) | 307 | case .entry: start = try placeEntry(text, options: options, heading: entryHeading, buffer) |
| 238 | case .plain: placePlain(text, prepend: template.prepend, heading: entryHeading, buffer) | 308 | case .plain: start = placePlain(text, options: options, heading: entryHeading, buffer) |
| 239 | case .item, .checkitem: try placeItem(text, checkbox: template.kind == .checkitem, prepend: template.prepend, heading: entryHeading, buffer) | 309 | case .item, .checkitem: start = try placeItem(text, checkbox: template.kind == .checkitem, options: options, heading: entryHeading, buffer) |
| 240 | } | 310 | } |
| 241 | } catch let error as Failure { | 311 | } catch let error as Failure { |
| 242 | return .failure(error) | 312 | return .failure(error) |
| @@ -248,14 +318,32 @@ public enum Capture { | |||
| 248 | // Saving the target adds a final newline, as `require-final-newline` does in Org. | 318 | // Saving the target adds a final newline, as `require-final-newline` does in Org. |
| 249 | var result = buffer.text as String | 319 | var result = buffer.text as String |
| 250 | if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" } | 320 | if !result.isEmpty, !result.hasSuffix("\n") { result += "\n" } |
| 251 | return .success(result) | 321 | return .success((result, start)) |
| 252 | } | 322 | } |
| 253 | 323 | ||
| 254 | /// `org-capture-set-target-location`: point at the target heading, or nil for a file. | 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 | switch target { | 326 | switch target { |
| 257 | case .file: | 327 | case .file: |
| 258 | return nil | 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 | case .headline(_, let headline): | 347 | case .headline(_, let headline): |
| 260 | buffer.point = 0 | 348 | buffer.point = 0 |
| 261 | let pattern = "^\\*+[ \\t]+(?:[A-Z]+[ \\t]+)?(?:\\[#.\\][ \\t]*)?" + NSRegularExpression.escapedPattern(for: headline) + "(?:[ \\t]+:[\\w@#%:]+:)?[ \\t]*$" | 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 | /// `org-capture-place-entry`. | 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 | var template = entry | 487 | var template = entry |
| 399 | if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template } | 488 | if template.range(of: "(?m)^\\*+ ", options: .regularExpression) == nil { template = "* " + template } |
| 400 | // `org-capture-verify-tree`: the first heading has the lowest level. | 489 | // `org-capture-verify-tree`: the first heading has the lowest level. |
| @@ -424,7 +513,8 @@ public enum Capture { | |||
| 424 | if !buffer.bolp { buffer.insert("\n") } | 513 | if !buffer.bolp { buffer.insert("\n") } |
| 425 | let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer) | 514 | let blank = blankBeforeHeading(buffer) && !previousLineEmpty(buffer) |
| 426 | deleteEmptyLinesBefore(buffer) | 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 | // `org-paste-subtree` at `level`. | 518 | // `org-paste-subtree` at `level`. |
| 429 | let oldLevel = levels.first ?? 1 | 519 | let oldLevel = levels.first ?? 1 |
| 430 | let shift = level - oldLevel | 520 | let shift = level - oldLevel |
| @@ -438,11 +528,19 @@ public enum Capture { | |||
| 438 | } | 528 | } |
| 439 | if !shifted.hasSuffix("\n") { shifted += "\n" } | 529 | if !shifted.hasSuffix("\n") { shifted += "\n" } |
| 440 | buffer.insert(shifted) | 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 | deleteEmptyLinesAfter(buffer) | 537 | deleteEmptyLinesAfter(buffer) |
| 538 | buffer.saveExcursion { buffer.insert(String(repeating: "\n", count: n)) } | ||
| 442 | } | 539 | } |
| 443 | 540 | ||
| 444 | /// `org-capture-place-plain-text`. | 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 | if let heading { | 544 | if let heading { |
| 447 | buffer.point = heading | 545 | buffer.point = heading |
| 448 | if prepend { | 546 | if prepend { |
| @@ -458,8 +556,11 @@ public enum Capture { | |||
| 458 | } | 556 | } |
| 459 | if !buffer.bolp { buffer.insert("\n") } | 557 | if !buffer.bolp { buffer.insert("\n") } |
| 460 | deleteEmptyLinesBefore(buffer) | 558 | deleteEmptyLinesBefore(buffer) |
| 559 | buffer.insert(String(repeating: "\n", count: options.before ?? 0)) | ||
| 560 | let start = buffer.point | ||
| 461 | buffer.insert(text.hasSuffix("\n") || text.isEmpty ? text : text + "\n") | 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 | /// `org-end-of-meta-data t`: past planning, drawers and the property drawer. | 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 | 586 | /// `org-capture-place-item`: after the last item of the first list in the entry (the file |
| 486 | /// end of the entry's text. | 587 | /// for a file target), or at the end of the entry's text. |
| 487 | static func placeItem(_ text: String, checkbox: Bool, prepend: Bool, heading: Int?, _ buffer: EmacsBuffer) throws { | 588 | static func placeItem(_ text: String, checkbox: Bool, options: CaptureTemplate.Options, heading: Int?, _ buffer: EmacsBuffer) throws -> Int { |
| 589 | let prepend = options.prepend | ||
| 488 | var item = text.trimmingCharacters(in: .newlines) | 590 | var item = text.trimmingCharacters(in: .newlines) |
| 489 | if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil { | 591 | if item.range(of: "^[ \\t]*([-+*]|[0-9]+[.)])[ \\t]", options: .regularExpression) == nil { |
| 490 | let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil | 592 | let boxed = item.range(of: "^\\[[ X-]\\]", options: .regularExpression) != nil |
| @@ -501,16 +603,15 @@ public enum Capture { | |||
| 501 | end = buffer.point | 603 | end = buffer.point |
| 502 | } else { | 604 | } else { |
| 503 | start = 0 | 605 | start = 0 |
| 504 | buffer.point = 0 | 606 | end = buffer.length |
| 505 | if !atHeading(buffer) { nextHeading(buffer) } | ||
| 506 | end = buffer.point | ||
| 507 | } | 607 | } |
| 508 | // The first list's items in the range. | 608 | // The first list's items in the range. |
| 509 | buffer.point = start | 609 | buffer.point = start |
| 510 | var items: [(start: Int, end: Int, indent: String)] = [] | 610 | var items: [(start: Int, end: Int, indent: String)] = [] |
| 511 | while buffer.point < end { | 611 | while buffer.point < end { |
| 512 | let line = buffer.substring(buffer.point, buffer.lineEnd(buffer.point)) | 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 | if let first = items.first, String(m.1) != first.indent { | 615 | if let first = items.first, String(m.1) != first.indent { |
| 515 | // A nested item belongs to the current one. | 616 | // A nested item belongs to the current one. |
| 516 | } else { | 617 | } else { |
| @@ -538,13 +639,24 @@ public enum Capture { | |||
| 538 | } | 639 | } |
| 539 | } | 640 | } |
| 540 | if !buffer.bolp { buffer.insert("\n") } | 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 | buffer.insert(indented + "\n") | 648 | buffer.insert(indented + "\n") |
| 649 | if prepend { emptyLinesAfter(buffer, min(1, options.after)) } | ||
| 650 | return at | ||
| 542 | } else { | 651 | } else { |
| 543 | buffer.point = prepend ? start : end | 652 | buffer.point = prepend ? start : end |
| 544 | if !buffer.bolp { buffer.insert("\n") } | 653 | if !buffer.bolp { buffer.insert("\n") } |
| 545 | deleteEmptyLinesBefore(buffer) | 654 | deleteEmptyLinesBefore(buffer) |
| 655 | buffer.insert(String(repeating: "\n", count: options.before ?? 0)) | ||
| 656 | let at = buffer.point | ||
| 546 | buffer.insert(item + "\n") | 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 | let workspace: WorkspaceModel | 10 | let workspace: WorkspaceModel |
| 11 | let session: DocumentSession | 11 | let session: DocumentSession |
| 12 | let commands: AppCommands | 12 | let commands: AppCommands |
| 13 | let clock: ClockModel | ||
| 13 | @State private var templates = CaptureTemplates.defaults | 14 | @State private var templates = CaptureTemplates.defaults |
| 14 | @State private var problem: String? | 15 | @State private var problem: String? |
| 15 | @State private var template: CaptureTemplate? | 16 | @State private var template: CaptureTemplate? |
| @@ -82,9 +83,12 @@ struct CaptureView: View { | |||
| 82 | private func target(_ template: CaptureTemplate) -> String { | 83 | private func target(_ template: CaptureTemplate) -> String { |
| 83 | let file = (template.target.file as NSString).lastPathComponent | 84 | let file = (template.target.file as NSString).lastPathComponent |
| 84 | switch template.target { | 85 | switch template.target { |
| 85 | case .file: return file | 86 | case .file, .heading: return file |
| 86 | case .headline(_, let headline): return "\(file) / \(headline)" | 87 | case .headline(_, let headline): return "\(file) / \(headline)" |
| 87 | case .outline(_, let path): return ([file] + path).joined(separator: " / ") | 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 | self.template = nil | 123 | self.template = nil |
| 120 | case .success(let filled): | 124 | case .success(let filled): |
| 121 | text = filled.text | 125 | text = filled.text |
| 126 | if template.options.immediateFinish { | ||
| 127 | finish() | ||
| 128 | return | ||
| 129 | } | ||
| 122 | let index = String.Index(utf16Offset: filled.cursor, in: text) | 130 | let index = String.Index(utf16Offset: filled.cursor, in: text) |
| 123 | selection = TextSelection(insertionPoint: index) | 131 | selection = TextSelection(insertionPoint: index) |
| 124 | editing = true | 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 | private func finish() { | 153 | private func finish() { |
| 130 | guard let template else { return } | 154 | guard let template else { return } |
| 131 | if let message = session.capture(text, template: template, root: workspace.rootPaths.first) { | 155 | let opened = context.now |
| 132 | problem = message | 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 | return | 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 | close() | 181 | close() |
| 137 | } | 182 | } |
| 138 | 183 | ||
Sources/Orgstar/OrgstarApp.swift +1 −1
| @@ -270,7 +270,7 @@ struct OrgstarApp: App { | |||
| 270 | .defaultSize(width: 860, height: 560) | 270 | .defaultSize(width: 860, height: 560) |
| 271 | Window("Capture", id: "capture") { | 271 | Window("Capture", id: "capture") { |
| 272 | if case .success(let model) = workspace { | 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 | .windowResizability(.contentSize) | 276 | .windowResizability(.contentSize) |
Tests/OrgAppTests/AppTests.swift +39 −3
| @@ -626,6 +626,35 @@ struct CaptureAppTests { | |||
| 626 | #expect(loaded.templates[0].target == .outline(file: "work.org", path: ["Projects", "Work"])) | 626 | #expect(loaded.templates[0].target == .outline(file: "work.org", path: ["Projects", "Work"])) |
| 627 | #expect(loaded.templates[0].template == "* TODO %?\n%a") | 627 | #expect(loaded.templates[0].template == "* TODO %?\n%a") |
| 628 | #expect(loaded.templates[1].kind == .plain && loaded.templates[1].prepend) | 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 | #expect(CaptureTemplates.load(userFile: folder.url.appendingPathComponent("none.toml")).templates == CaptureTemplates.defaults) | 658 | #expect(CaptureTemplates.load(userFile: folder.url.appendingPathComponent("none.toml")).templates == CaptureTemplates.defaults) |
| 630 | } | 659 | } |
| 631 | 660 | ||
| @@ -634,15 +663,22 @@ struct CaptureAppTests { | |||
| 634 | let todo = try notes.write("todo.org", "* Inbox\n** TODO Old\n* Later\n") | 663 | let todo = try notes.write("todo.org", "* Inbox\n** TODO Old\n* Later\n") |
| 635 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | 664 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) |
| 636 | let template = CaptureTemplates.defaults[0] | 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 | #expect(try String(contentsOf: todo, encoding: .utf8) == "* Inbox\n** TODO Old\n** TODO New\n* Later\n") | 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 | #expect(try String(contentsOf: notes.url.appendingPathComponent("notes.org"), encoding: .utf8) == "* Inbox\n** Note\n") | 671 | #expect(try String(contentsOf: notes.url.appendingPathComponent("notes.org"), encoding: .utf8) == "* Inbox\n** Note\n") |
| 642 | 672 | ||
| 643 | session.open(todo.path) | 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 | #expect(session.document?.text == "* Inbox\n** TODO Old\n** TODO New\n** TODO Third\n* Later\n") | 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 | (setq fill-column (+ 70 2)) | 22 | (setq fill-column (+ 70 2)) |
| 23 | (setq org-capture-templates | 23 | (setq org-capture-templates |
| 24 | '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a") | 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 | ("w" "Work") | 28 | ("w" "Work") |
| 27 | ("wn" "Work note" plain (file "~/work/notes.org") "%?" :prepend t))) | 29 | ("wn" "Work note" plain (file "~/work/notes.org") "%?" :prepend t))) |
| 28 | (map! :leader :desc "Report" "z t" #'tr/report | 30 | (map! :leader :desc "Report" "z t" #'tr/report |
| @@ -81,7 +83,7 @@ struct EmacsImportTests { | |||
| 81 | #expect(folders == ["~/notes/", "~/work"]) | 83 | #expect(folders == ["~/notes/", "~/work"]) |
| 82 | #expect(result.unreadable.map(\.name) == ["fill-column"]) | 84 | #expect(result.unreadable.map(\.name) == ["fill-column"]) |
| 83 | #expect(Set(result.unsupported.map(\.name)) == ["org-support-shift-select", "doom-theme", "org-roam-directory", | 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 | #expect(result.skipped == 1) | 87 | #expect(result.skipped == 1) |
| 86 | #expect(result.doomDefaultsLeftOut == 1) | 88 | #expect(result.doomDefaultsLeftOut == 1) |
| 87 | } | 89 | } |
| @@ -89,10 +91,12 @@ struct EmacsImportTests { | |||
| 89 | @Test func readsCaptureTemplatesAndBindings() throws { | 91 | @Test func readsCaptureTemplatesAndBindings() throws { |
| 90 | let result = try scan() | 92 | let result = try scan() |
| 91 | let captures = result.items.compactMap { if case .capture(let key, let toml) = $0.change { (key, toml) } else { nil } } | 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 | #expect(captures[0].1 == "[[template]]\nkey = \"t\"\nname = \"Todo\"\ntype = \"entry\"\nfile = \"~/notes/todo.org\"\nheadline = \"Inbox\"\ntemplate = \"* TODO %?\\n%a\"\n") | 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\"")) | 99 | #expect(captures[3].1.contains("prepend = true\n") && captures[3].1.contains("type = \"plain\"")) |
| 95 | #expect(result.unsupported.contains { $0.name == "capture \"j\"" }) | ||
| 96 | 100 | ||
| 97 | let bindings = result.items.compactMap { if case .binding(let keys, let command, let mode) = $0.change { "\(keys) \(command) \(mode ?? "-")" } else { nil } } | 101 | let bindings = result.items.compactMap { if case .binding(let keys, let command, let mode) = $0.change { "\(keys) \(command) \(mode ?? "-")" } else { nil } } |
| 98 | #expect(bindings == ["SPC m x org.checkbox.toggle normal", "g s app.save normal", "C-c a app.agenda normal"]) | 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 | #expect(defaults.integer(forKey: "agendaStartOffset") == -3) | 135 | #expect(defaults.integer(forKey: "agendaStartOffset") == -3) |
| 132 | #expect(defaults.string(forKey: "todoKeywords") == "TODO(t) WAIT(w) | DONE(d)\nBUG | FIXED") | 136 | #expect(defaults.string(forKey: "todoKeywords") == "TODO(t) WAIT(w) | DONE(d)\nBUG | FIXED") |
| 133 | let capture = try String(contentsOf: folder.appendingPathComponent("capture.toml"), encoding: .utf8) | 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 | #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty) | 139 | #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty) |
| 136 | let keymap = folder.appendingPathComponent("keymap.toml") | 140 | let keymap = folder.appendingPathComponent("keymap.toml") |
| 137 | #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) | 141 | #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) |
| 138 | #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a")) | 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 | #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") | 144 | #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") |
| 141 | #expect(defaults.integer(forKey: "themeFontSize") == 14) | 145 | #expect(defaults.integer(forKey: "themeFontSize") == 14) |
| 142 | } | 146 | } |
Tests/OrgCoreTests/CaptureTests.swift +91 −4
| @@ -23,14 +23,33 @@ struct CaptureTests { | |||
| 23 | let path = folder.appendingPathComponent("target\(i).org").path | 23 | let path = folder.appendingPathComponent("target\(i).org").path |
| 24 | try c.file.write(toFile: path, atomically: true, encoding: .utf8) | 24 | try c.file.write(toFile: path, atomically: true, encoding: .utf8) |
| 25 | let target: String | 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 | switch c.template.target { | 31 | switch c.template.target { |
| 27 | case .file: target = "(file \(lisp(path)))" | 32 | case .file: target = "(file \(lisp(path)))" |
| 28 | case .headline(_, let h): target = "(file+headline \(lisp(path)) \(lisp(h)))" | 33 | case .headline(_, let h): target = "(file+headline \(lisp(path)) \(lisp(h)))" |
| 29 | case .outline(_, let p): target = "(file+olp \(lisp(path)) \(p.map(lisp).joined(separator: " ")))" | 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 | forms.append(""" | 41 | forms.append(""" |
| 32 | (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(c.template.prepend ? " :prepend t" : ""))))) | 42 | (let ((org-capture-templates '(("x" "case" \(c.template.kind.rawValue) \(target) \(lisp(c.template.template))\(properties)))) |
| 33 | (org-capture nil "x") | 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 | (insert \(lisp(c.typed))) | 53 | (insert \(lisp(c.typed))) |
| 35 | (org-capture-finalize) | 54 | (org-capture-finalize) |
| 36 | (with-current-buffer (find-buffer-visiting \(lisp(path))) | 55 | (with-current-buffer (find-buffer-visiting \(lisp(path))) |
| @@ -43,24 +62,28 @@ struct CaptureTests { | |||
| 43 | ;;; -*- lexical-binding: t -*- | 62 | ;;; -*- lexical-binding: t -*- |
| 44 | (require 'org) | 63 | (require 'org) |
| 45 | (require 'org-capture) | 64 | (require 'org-capture) |
| 65 | (require 'org-id) | ||
| 46 | (require 'json) | 66 | (require 'json) |
| 47 | (setq org-adapt-indentation nil) | 67 | (setq org-adapt-indentation nil) |
| 48 | (defvar results nil) | 68 | (defvar results nil) |
| 49 | \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")")) | 69 | \(frozenClock(seconds, "(progn " + forms.joined(separator: "\n") + ")")) |
| 50 | (with-temp-file \(lisp(output.path)) (insert (json-encode (vconcat (nreverse results))))) | 70 | (with-temp-file \(lisp(output.path)) (insert (json-encode (vconcat (nreverse results))))) |
| 51 | """.write(to: script, atomically: true, encoding: .utf8) | 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 | return try JSONDecoder().decode([String].self, from: Data(contentsOf: output)) | 74 | return try JSONDecoder().decode([String].self, from: Data(contentsOf: output)) |
| 54 | } | 75 | } |
| 55 | 76 | ||
| 56 | static func ours(_ c: Case, now: Date) -> String { | 77 | static func ours(_ c: Case, now: Date) -> String { |
| 78 | var utcCalendar = Calendar(identifier: .gregorian) | ||
| 79 | utcCalendar.timeZone = TimeZone(identifier: "UTC")! | ||
| 57 | var utc = Calendar(identifier: .gregorian) | 80 | var utc = Calendar(identifier: .gregorian) |
| 58 | utc.timeZone = TimeZone(identifier: "UTC")! | 81 | utc.timeZone = TimeZone(identifier: "UTC")! |
| 59 | guard case .success(let filled) = Capture.fill(c.template.template, context: CaptureContext(now: now, calendar: utc)) else { return "fill failed" } | 82 | guard case .success(let filled) = Capture.fill(c.template.template, context: CaptureContext(now: now, calendar: utc)) else { return "fill failed" } |
| 60 | var text = filled.text | 83 | var text = filled.text |
| 61 | let index = String.Index(utf16Offset: filled.cursor, in: text) | 84 | let index = String.Index(utf16Offset: filled.cursor, in: text) |
| 62 | text.insert(contentsOf: c.typed, at: index) | 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 | case .success(let result): return result | 87 | case .success(let result): return result |
| 65 | case .failure(.message(let message)): return "failed: \(message)" | 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 | @Test func fillsPrompts() { | 202 | @Test func fillsPrompts() { |
| 116 | let template = "* %^{Title|untitled} for %^{Who}\n%\\1 again" | 203 | let template = "* %^{Title|untitled} for %^{Who}\n%\\1 again" |
| 117 | #expect(Capture.questions(template) == [ | 204 | #expect(Capture.questions(template) == [ |