Commit b7b7f8ef81
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/Command.swift +1 −1
| @@ -232,7 +232,7 @@ public enum Commands { | |||
| 232 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), | 232 | ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), |
| 233 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), | 233 | OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), |
| 234 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), | 234 | CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), |
| 235 | UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), | 235 | UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), |
| 236 | ] | 236 | ] |
| 237 | 237 | ||
| 238 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } | 238 | public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } |
Sources/OrgCore/Commands/SubtreeCommands.swift +7 −4
| @@ -240,7 +240,7 @@ public struct SortEntries: OrgCommand { | |||
| 240 | 240 | ||
| 241 | public func run(in context: EditContext) -> CommandStep { | 241 | public func run(in context: EditContext) -> CommandStep { |
| 242 | // `org-sort` sorts table lines and list items there; those aren't here yet. | 242 | // `org-sort` sorts table lines and list items there; those aren't here yet. |
| 243 | if KeyContext.holds("table", in: context) { return .failed("Sorting table lines isn't supported yet") } | 243 | if KeyContext.holds("table", in: context) { return SortTableLines().run(in: context) } |
| 244 | if EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).inItem() != nil { | 244 | if EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).inItem() != nil { |
| 245 | return .failed("Sorting list items isn't supported yet") | 245 | return .failed("Sorting list items isn't supported yet") |
| 246 | } | 246 | } |
| @@ -442,8 +442,11 @@ public struct SortEntries: OrgCommand { | |||
| 442 | static func visibleTitle(_ title: String) -> String { | 442 | static func visibleTitle(_ title: String) -> String { |
| 443 | var t = title | 443 | var t = title |
| 444 | if t == "COMMENT" { t = "" } else if t.hasPrefix("COMMENT ") { t = String(t.dropFirst(8)) } | 444 | if t == "COMMENT" { t = "" } else if t.hasPrefix("COMMENT ") { t = String(t.dropFirst(8)) } |
| 445 | t = Links.displayFormat(t) | 445 | return removeInvisible(t) |
| 446 | t = t.replacingOccurrences(of: "(^|[\\s(])[*/_+=~]([^\\s*/_+=~](?:.*?[^\\s])?)[*/_+=~](?=$|[\\s.,;:!?)])", with: "$1$2", options: .regularExpression) | 446 | } |
| 447 | return t | 447 | |
| 448 | /// `org-sort-remove-invisible`: links as their descriptions, emphasis markers gone. | ||
| 449 | static func removeInvisible(_ s: String) -> String { | ||
| 450 | Links.displayFormat(s).replacingOccurrences(of: "(^|[\\s(])[*/_+=~]([^\\s*/_+=~](?:.*?[^\\s])?)[*/_+=~](?=$|[\\s.,;:!?)])", with: "$1$2", options: .regularExpression) | ||
| 448 | } | 451 | } |
| 449 | } | 452 | } |
Sources/OrgCore/Commands/Table.swift +7
| @@ -625,9 +625,16 @@ public struct CreateTable: OrgCommand { | |||
| 625 | public var title: String { "Create Table" } | 625 | public var title: String { "Create Table" } |
| 626 | public func applies(in context: EditContext) -> Bool { !KeyContext.holds("table", in: context) } | 626 | public func applies(in context: EditContext) -> Bool { !KeyContext.holds("table", in: context) } |
| 627 | public func run(in context: EditContext) -> CommandStep { | 627 | public func run(in context: EditContext) -> CommandStep { |
| 628 | if let converted = convert(context) { return converted } | ||
| 628 | guard let size = context.answers["size"] else { | 629 | guard let size = context.answers["size"] else { |
| 629 | return .prompt(Prompt(key: "size", message: "Table size Columns x Rows [e.g. 5x2]:", initial: "")) | 630 | return .prompt(Prompt(key: "size", message: "Table size Columns x Rows [e.g. 5x2]:", initial: "")) |
| 630 | } | 631 | } |
| 631 | return tableCommand(context) { try $0.tableCreate(size.isEmpty ? "5x2" : size) } | 632 | return tableCommand(context) { try $0.tableCreate(size.isEmpty ? "5x2" : size) } |
| 632 | } | 633 | } |
| 634 | |||
| 635 | /// `org-table-create-or-convert-from-region`. | ||
| 636 | private func convert(_ context: EditContext) -> CommandStep? { | ||
| 637 | guard let region = context.selection.first, !region.isEmpty else { return nil } | ||
| 638 | return commitBuffer(context) { $0.tableConvertRegion(region.lowerBound, region.upperBound) } | ||
| 639 | } | ||
| 633 | } | 640 | } |
Sources/OrgCore/Commands/TableTransforms.swift added +260
| @@ -0,0 +1,260 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // Whole-table changes (org-table.el, Org 9.8.7): `org-table-sort-lines`, | ||
| 4 | // `org-table-transpose-table-at-point`, `org-table-convert-region`, `org-table-import`'s | ||
| 5 | // insertion and `org-table-export`'s TSV and CSV. | ||
| 6 | |||
| 7 | extension EmacsBuffer { | ||
| 8 | /// `org-table-sort-lines` on the lines between the hlines around point. `kind` is one of | ||
| 9 | /// `aAnNtT`. | ||
| 10 | func tableSortLines(_ kind: Character, column requested: Int? = nil) throws { | ||
| 11 | try saveExcursion { | ||
| 12 | skipBackward([32, 9]) | ||
| 13 | if bolp { _ = searchForward("\\|", bound: lineEnd(point), caseInsensitive: false) } | ||
| 14 | try checkInsideDataField() | ||
| 15 | } | ||
| 16 | let current = tableCurrentColumn() | ||
| 17 | let column = current > 0 ? current : requested ?? 1 | ||
| 18 | guard "aAnNtT".contains(kind) else { throw UserError(message: "Invalid sorting type `\(kind)'") } | ||
| 19 | let start = tableBegin() | ||
| 20 | let end = tableEnd() | ||
| 21 | let hline = "^[ \\t]*\\|-" | ||
| 22 | let low = saveExcursion { () -> Int in | ||
| 23 | guard searchBackward(hline, bound: start, caseInsensitive: false) != nil else { return start } | ||
| 24 | forwardLine(1) | ||
| 25 | return point | ||
| 26 | } | ||
| 27 | let high = saveExcursion { searchForward(hline, bound: end, caseInsensitive: false)?[0]?.lowerBound ?? end } | ||
| 28 | let lineOffset = countLines(low, lineStart(point)) | ||
| 29 | let column0 = currentColumn | ||
| 30 | // Records: each data line, without its newline. | ||
| 31 | var records: [Range<Int>] = [] | ||
| 32 | point = low | ||
| 33 | while point < high { | ||
| 34 | if lookingAt("[ \\t]*\\|[^-]") != nil { records.append(point..<lineEnd(point)) } | ||
| 35 | if forwardLine(1) != 0 { break } | ||
| 36 | } | ||
| 37 | let keys = records.map { record -> SortEntries.SortKey in | ||
| 38 | let field = saveExcursion { () -> String in | ||
| 39 | point = record.lowerBound | ||
| 40 | let line = substring(record.lowerBound, record.upperBound).trimmingCharacters(in: .whitespaces) | ||
| 41 | let fields = Self.splitFields(line, separator: " *\\| *") | ||
| 42 | return column <= fields.count ? fields[column - 1] : "" | ||
| 43 | }.trimmingCharacters(in: CharacterSet(charactersIn: " \t\n\r")) | ||
| 44 | switch kind.lowercased() { | ||
| 45 | case "n": return .number(Self.stringToNumber(field)) | ||
| 46 | case "a": return .text(SortEntries.removeInvisible(field).lowercased()) | ||
| 47 | default: return .number(Self.timeKey(field, now: now, calendar: calendar)) | ||
| 48 | } | ||
| 49 | } | ||
| 50 | let reverse = kind.isUppercase | ||
| 51 | var order = Array(records.indices) | ||
| 52 | if reverse { order.reverse() } | ||
| 53 | order = order.enumerated().sorted { a, b in | ||
| 54 | let less = keys[a.element] < keys[b.element] | ||
| 55 | let greater = keys[b.element] < keys[a.element] | ||
| 56 | return less != greater ? less : a.offset < b.offset | ||
| 57 | }.map(\.element) | ||
| 58 | if reverse { order.reverse() } | ||
| 59 | let texts = records.map { substring($0.lowerBound, $0.upperBound) } | ||
| 60 | for (record, source) in zip(records, order).reversed() { | ||
| 61 | replace(record, with: texts[source]) | ||
| 62 | } | ||
| 63 | point = low | ||
| 64 | forwardLine(lineOffset) | ||
| 65 | moveToColumn(column0) | ||
| 66 | } | ||
| 67 | |||
| 68 | /// The `t` sort key: a timestamp's time, else a duration's minutes, else 0. | ||
| 69 | static func timeKey(_ field: String, now: Date, calendar: Calendar) -> Double { | ||
| 70 | if let stamp = field.firstMatch(of: /[\[<][0-9]{4}-[0-9]{2}-[0-9]{2}[^\]>\n]*[\]>]/) { | ||
| 71 | return TagsMatcher.seconds(String(stamp.0), now: now, calendar: calendar) | ||
| 72 | } | ||
| 73 | if let minutes = durationMinutes(field.trimmingCharacters(in: .whitespaces)) { return minutes } | ||
| 74 | if let clock = field.firstMatch(of: /\b([0-9]+):([0-9]{2})\b/) { return Double(clock.1)! * 60 + Double(clock.2)! } | ||
| 75 | return 0 | ||
| 76 | } | ||
| 77 | |||
| 78 | /// `org-duration-to-minutes` for strings `org-duration-p` accepts. | ||
| 79 | static func durationMinutes(_ s: String) -> Double? { | ||
| 80 | if let m = s.wholeMatch(of: /([0-9]+):([0-9]{2})(?::([0-9]{2}))?/) { | ||
| 81 | return Double(m.1)! * 60 + Double(m.2)! + (m.3.map { Double($0)! / 60 } ?? 0) | ||
| 82 | } | ||
| 83 | let units: [String: Double] = ["min": 1, "h": 60, "d": 1440, "w": 10080, "m": 43200, "y": 525960] | ||
| 84 | let parts = s.split(separator: " ") | ||
| 85 | guard !parts.isEmpty else { return nil } | ||
| 86 | var total = 0.0 | ||
| 87 | for part in parts { | ||
| 88 | guard let m = part.wholeMatch(of: /([0-9]+(?:\.[0-9]+)?)(min|h|d|w|m|y)/) else { return nil } | ||
| 89 | total += Double(m.1)! * units[String(m.2)]! | ||
| 90 | } | ||
| 91 | return total | ||
| 92 | } | ||
| 93 | |||
| 94 | /// `org-table-current-line`: the index of the data line at point, from 1. | ||
| 95 | func tableCurrentLine() -> Int { | ||
| 96 | let position = point | ||
| 97 | let end = tableEnd() | ||
| 98 | return saveExcursion { | ||
| 99 | point = tableBegin() | ||
| 100 | var count = 0 | ||
| 101 | while let match = searchForward("^[ \\t]*\\|[^-]", bound: end, caseInsensitive: false) { | ||
| 102 | count += 1 | ||
| 103 | point = match[0]!.upperBound | ||
| 104 | if lineEnd(point) >= position { break } | ||
| 105 | } | ||
| 106 | return count | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 110 | /// `org-table-goto-line`. | ||
| 111 | func tableGotoLine(_ n: Int) { | ||
| 112 | let end = tableEnd() | ||
| 113 | point = tableBegin() | ||
| 114 | var count = 0 | ||
| 115 | while count < n, let match = searchForward("^[ \\t]*\\|[^-]", bound: end, caseInsensitive: false) { | ||
| 116 | count += 1 | ||
| 117 | point = count == n ? match[0]!.lowerBound : match[0]!.upperBound | ||
| 118 | } | ||
| 119 | } | ||
| 120 | |||
| 121 | /// `org-table-transpose-table-at-point`. | ||
| 122 | func tableTranspose() { | ||
| 123 | let table = tableToLisp().compactMap { $0 } | ||
| 124 | let dline = tableCurrentLine() | ||
| 125 | let column = tableCurrentColumn() | ||
| 126 | let width = table.first?.count ?? 0 | ||
| 127 | let contents = (0..<width).map { c in table.map { c < $0.count ? $0[c] : "" } } | ||
| 128 | point = tableBegin() | ||
| 129 | _ = searchForward("\\|", caseInsensitive: false) | ||
| 130 | point -= 1 | ||
| 131 | deleteRegion(point, tableEnd()) | ||
| 132 | insert(contents.map { "| " + $0.joined(separator: " | ") + " |\n" }.joined()) | ||
| 133 | tableGotoLine(column) | ||
| 134 | tableGotoColumn(dline) | ||
| 135 | tableAlign() | ||
| 136 | } | ||
| 137 | |||
| 138 | /// `org-table-convert-region` with the separator guessed: tabs when every line has one, | ||
| 139 | /// commas (CSV) when every line has one, else runs of spaces. | ||
| 140 | func tableConvertRegion(_ start: Int, _ end: Int) { | ||
| 141 | point = min(start, end) | ||
| 142 | point = lineStart(point) | ||
| 143 | let begin = marker(point) | ||
| 144 | point = max(start, end) | ||
| 145 | if bolp, point > 0 { point -= 1 } else { endOfLine() } | ||
| 146 | let stop = marker(point) | ||
| 147 | defer { | ||
| 148 | release(begin) | ||
| 149 | release(stop) | ||
| 150 | } | ||
| 151 | point = begin.position | ||
| 152 | let tabs = saveExcursion { searchForward("^[^\\n\\t]+$", bound: stop.position, caseInsensitive: false) == nil } | ||
| 153 | let commas = saveExcursion { searchForward("^[^\\n,]+$", bound: stop.position, caseInsensitive: false) == nil } | ||
| 154 | if !tabs, commas { | ||
| 155 | while point < stop.position { | ||
| 156 | if bolp { | ||
| 157 | insert("| ") | ||
| 158 | } else if let m = lookingAt("[ \\t]*$") { | ||
| 159 | replace(m[0]!, with: " |") | ||
| 160 | point = m[0]!.lowerBound + 2 | ||
| 161 | forwardLine(1) | ||
| 162 | } else if let m = lookingAt("[ \\t]*\"([^\"]*)\"") { | ||
| 163 | let value = substring(m[1]!.lowerBound, m[1]!.upperBound).replacingOccurrences(of: "\n", with: " ") | ||
| 164 | replace(m[0]!, with: value) | ||
| 165 | point = m[0]!.lowerBound + value.utf16.count | ||
| 166 | if character(at: point) == 34 { insert("\"") } | ||
| 167 | } else if let m = lookingAt("[^,\\n]+") { | ||
| 168 | point = m[0]!.upperBound | ||
| 169 | } else if let m = lookingAt("[ \\t]*,") { | ||
| 170 | replace(m[0]!, with: " | ") | ||
| 171 | point = m[0]!.lowerBound + 3 | ||
| 172 | } else { | ||
| 173 | forwardLine(1) | ||
| 174 | } | ||
| 175 | } | ||
| 176 | } else { | ||
| 177 | let pattern = tabs ? "^|\\t" : "^ *| *\\t *| {1,}" | ||
| 178 | while point < stop.position, let m = searchForward(pattern, bound: stop.position, caseInsensitive: false) { | ||
| 179 | replace(m[0]!, with: "| ") | ||
| 180 | point = m[0]!.lowerBound + 2 | ||
| 181 | } | ||
| 182 | } | ||
| 183 | point = begin.position | ||
| 184 | tableAlign() | ||
| 185 | } | ||
| 186 | |||
| 187 | /// `orgtbl-to-tsv` and `orgtbl-to-csv` of the table at point, rules left out. | ||
| 188 | func tableExportText(csv: Bool) -> String { | ||
| 189 | tableToLisp().compactMap { $0 }.map { row in | ||
| 190 | row.map { field in | ||
| 191 | guard csv, field.contains(where: { $0 == "\"" || $0 == "," }) else { return field } | ||
| 192 | return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\"" | ||
| 193 | }.joined(separator: csv ? "," : "\t") | ||
| 194 | }.joined(separator: "\n") | ||
| 195 | } | ||
| 196 | } | ||
| 197 | |||
| 198 | /// C-c ^ in a table: `org-table-sort-lines`. | ||
| 199 | public struct SortTableLines: OrgCommand { | ||
| 200 | public init() {} | ||
| 201 | public var id: String { "org.table.sort" } | ||
| 202 | public var title: String { "Sort Table Lines" } | ||
| 203 | public func applies(in context: EditContext) -> Bool { KeyContext.holds("table", in: context) } | ||
| 204 | |||
| 205 | static let selection = FastSelection(items: [ | ||
| 206 | .option(key: "a", name: "alphabetic"), .option(key: "n", name: "numeric"), .option(key: "t", name: "time"), .newline, | ||
| 207 | .option(key: "A", name: "alphabetic (reversed)"), .option(key: "N", name: "numeric (reversed)"), .option(key: "T", name: "time (reversed)"), | ||
| 208 | ], multiple: false) | ||
| 209 | |||
| 210 | public func run(in context: EditContext) -> CommandStep { | ||
| 211 | let buffer = EmacsBuffer(context.text, point: context.caret) | ||
| 212 | if buffer.tableCurrentColumn() == 0, context.answers["column"] == nil { | ||
| 213 | return .prompt(Prompt(key: "column", message: "Use column N for sorting:")) | ||
| 214 | } | ||
| 215 | guard let key = context.answers["sort"]?.first else { | ||
| 216 | return .prompt(Prompt(key: "sort", message: "Sort Table: [a]lphabetic, [n]umeric, [t]ime. A/N/T means reversed:", fast: Self.selection)) | ||
| 217 | } | ||
| 218 | let column = context.answers["column"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) } | ||
| 219 | return commitBuffer(context) { try $0.tableSortLines(key, column: column) } | ||
| 220 | } | ||
| 221 | } | ||
| 222 | |||
| 223 | /// `org-table-transpose-table-at-point`. | ||
| 224 | public struct TransposeTable: OrgCommand { | ||
| 225 | public init() {} | ||
| 226 | public var id: String { "org.table.transpose" } | ||
| 227 | public var title: String { "Transpose Table" } | ||
| 228 | public func applies(in context: EditContext) -> Bool { KeyContext.holds("table", in: context) } | ||
| 229 | public func run(in context: EditContext) -> CommandStep { | ||
| 230 | commitBuffer(context) { buffer in | ||
| 231 | guard buffer.atTable else { throw EmacsBuffer.UserError(message: "Not at a table") } | ||
| 232 | buffer.tableTranspose() | ||
| 233 | } | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | /// `org-table-import` with a file's text: inserted at point, then made a table. | ||
| 238 | public struct ImportTable: OrgCommand { | ||
| 239 | let contents: String | ||
| 240 | public init(contents: String) { self.contents = contents } | ||
| 241 | public var id: String { "internal.table.import" } | ||
| 242 | public var title: String { "Import Table" } | ||
| 243 | public func applies(in context: EditContext) -> Bool { true } | ||
| 244 | public func run(in context: EditContext) -> CommandStep { | ||
| 245 | commitBuffer(context) { buffer in | ||
| 246 | if !buffer.bolp { buffer.insert("\n") } | ||
| 247 | let start = buffer.point | ||
| 248 | buffer.insert(contents) | ||
| 249 | buffer.tableConvertRegion(start, buffer.point) | ||
| 250 | } | ||
| 251 | } | ||
| 252 | |||
| 253 | /// `org-table-export`'s text for the table at `offset`: CSV for a `.csv` file, else TSV. | ||
| 254 | public static func exportText(_ text: String, at offset: Int, path: String) -> String? { | ||
| 255 | let buffer = EmacsBuffer(text, point: offset) | ||
| 256 | guard buffer.atTable else { return nil } | ||
| 257 | buffer.tableAlign() | ||
| 258 | return buffer.tableExportText(csv: path.lowercased().hasSuffix(".csv")) + "\n" | ||
| 259 | } | ||
| 260 | } | ||
Sources/OrgEditorAppKit/EditorCommands.swift +2
| @@ -75,6 +75,8 @@ public enum EditorCommands { | |||
| 75 | EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app), | 75 | EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app), |
| 76 | EditorCommand(id: "app.clock.goto", title: "Go to Clocked Entry", action: .app), | 76 | EditorCommand(id: "app.clock.goto", title: "Go to Clocked Entry", action: .app), |
| 77 | EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app), | 77 | EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app), |
| 78 | EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app), | ||
| 79 | EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), | ||
| 78 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), | 80 | EditorCommand(id: "app.export.html", title: "Export to HTML", action: .app), |
| 79 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), | 81 | EditorCommand(id: "app.export.html-open", title: "Export to HTML and Open", action: .app), |
| 80 | EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), | 82 | EditorCommand(id: "app.export.markdown", title: "Export to Markdown", action: .app), |
Sources/Orgstar/ContentView.swift +23
| @@ -510,6 +510,29 @@ struct ContentView: View { | |||
| 510 | } | 510 | } |
| 511 | case "app.archive": | 511 | case "app.archive": |
| 512 | if let message = session.archive() { commands.show(message) } | 512 | if let message = session.archive() { commands.show(message) } |
| 513 | case "app.table-import": | ||
| 514 | // `org-table-import`: a TSV, CSV or space-separated file becomes a table at point. | ||
| 515 | guard let editor = commands.editor else { return commands.show("No file is open") } | ||
| 516 | let panel = NSOpenPanel() | ||
| 517 | panel.allowedContentTypes = [.commaSeparatedText, .tabSeparatedText, .plainText] | ||
| 518 | guard panel.runModal() == .OK, let url = panel.url else { return } | ||
| 519 | guard let contents = try? String(contentsOf: url, encoding: .utf8) else { return commands.show("Can't read \(url.lastPathComponent)") } | ||
| 520 | editor.perform(ImportTable(contents: contents)) | ||
| 521 | case "app.table-export": | ||
| 522 | // `org-table-export`: CSV for a .csv name, else TSV. | ||
| 523 | guard let editor = commands.editor else { return commands.show("No file is open") } | ||
| 524 | let context = editor.context() | ||
| 525 | guard ImportTable.exportText(context.text, at: context.caret, path: "") != nil else { return commands.show("No table at point") } | ||
| 526 | let panel = NSSavePanel() | ||
| 527 | panel.nameFieldStringValue = "table.csv" | ||
| 528 | guard panel.runModal() == .OK, let url = panel.url, | ||
| 529 | let text = ImportTable.exportText(context.text, at: context.caret, path: url.path) else { return } | ||
| 530 | do { | ||
| 531 | try text.write(to: url, atomically: true, encoding: .utf8) | ||
| 532 | commands.show("Export done.") | ||
| 533 | } catch { | ||
| 534 | commands.show("File not written: \(error.localizedDescription)") | ||
| 535 | } | ||
| 513 | case "org.subtree.paste": | 536 | case "org.subtree.paste": |
| 514 | guard let text = NSPasteboard.general.string(forType: .string) else { return commands.show("The clipboard is empty") } | 537 | guard let text = NSPasteboard.general.string(forType: .string) else { return commands.show("The clipboard is empty") } |
| 515 | commands.editor?.perform(PasteSubtree(text: text)) | 538 | commands.editor?.perform(PasteSubtree(text: text)) |
Tests/OrgCoreTests/TableTransformTests.swift added +72
| @@ -0,0 +1,72 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct TableTransformTests { | ||
| 6 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | ||
| 7 | |||
| 8 | static let table = """ | ||
| 9 | Intro | ||
| 10 | | Name | Qty | When | Time | | ||
| 11 | |--------+-----+------------------------+------| | ||
| 12 | | pear | 10 | <2026-03-01 Sun> | 1:30 | | ||
| 13 | | Apple | 2 | [2025-12-24 Thu 10:00] | 0:45 | | ||
| 14 | | *fig* | 2.5 | | 2h | | ||
| 15 | | banana | -1 | <2026-01-15 Thu> | | | ||
| 16 | |--------+-----+------------------------+------| | ||
| 17 | | total | | | | | ||
| 18 | After | ||
| 19 | """ + "\n" | ||
| 20 | |||
| 21 | func compare(_ cases: [(text: String, at: String, command: any OrgCommand, answers: [String: String], form: String)]) throws { | ||
| 22 | let emacs = try EmacsOracle.run(cases.map { c in | ||
| 23 | EmacsOracle.Case(text: c.text, point: EmacsOracle.point(NSMaxRange((c.text as NSString).range(of: c.at)), in: c.text), form: c.form) | ||
| 24 | }) | ||
| 25 | for (c, theirs) in zip(cases, emacs) { | ||
| 26 | let ours = runCommand(c.command, c.text, caret: NSMaxRange((c.text as NSString).range(of: c.at)), answers: c.answers) | ||
| 27 | let theirCaret = EmacsOracle.offset(theirs.point, in: theirs.text) | ||
| 28 | #expect(ours.text == theirs.text && ours.caret == theirCaret && ours.failure == nil, | ||
| 29 | "\(c.form) at \(c.at)\nours: \(ours.text.debugDescription) @\(ours.caret) \(ours.failure ?? "")\nemacs: \(theirs.text.debugDescription) @\(theirCaret) \(theirs.error)") | ||
| 30 | } | ||
| 31 | } | ||
| 32 | |||
| 33 | @Test(.enabled(if: oracle)) | ||
| 34 | func sortsAndTransposesLikeOrg() throws { | ||
| 35 | var cases: [(text: String, at: String, command: any OrgCommand, answers: [String: String], form: String)] = [] | ||
| 36 | for (at, key) in [("| pea", "a"), ("| pea", "A"), ("| 10", "n"), ("| 10", "N"), ("Sun", "t"), ("1:3", "t"), ("1:3", "T")] { | ||
| 37 | cases.append((Self.table, at, SortTableLines(), ["sort": key], "(org-table-sort-lines nil ?\(key))")) | ||
| 38 | } | ||
| 39 | cases.append((Self.table, "Appl", TransposeTable(), [:], "(org-table-transpose-table-at-point)")) | ||
| 40 | cases.append(("| a | b | c |\n| 1 | 2 |\n", "| 1", TransposeTable(), [:], "(org-table-transpose-table-at-point)")) | ||
| 41 | try compare(cases) | ||
| 42 | } | ||
| 43 | |||
| 44 | @Test(.enabled(if: oracle)) | ||
| 45 | func convertsRegionsLikeOrg() throws { | ||
| 46 | let inputs = [ | ||
| 47 | "a,b,c\n1,\"x, y\",3\n\"q\"\"r\",,z\n", | ||
| 48 | "a\tb\tc\n1\t2\t3\n", | ||
| 49 | "alpha beta gamma\none two three\n", | ||
| 50 | ] | ||
| 51 | for input in inputs { | ||
| 52 | let text = "Before\n" + input + "After\n" | ||
| 53 | let start = 7 | ||
| 54 | let end = start + (input as NSString).length | ||
| 55 | let emacs = try EmacsOracle.run([EmacsOracle.Case(text: text, point: EmacsOracle.point(start, in: text), form: "(org-table-convert-region \(EmacsOracle.point(start, in: text)) \(EmacsOracle.point(end, in: text)))")]) | ||
| 56 | let buffer = EmacsBuffer(text, point: start) | ||
| 57 | buffer.tableConvertRegion(start, end) | ||
| 58 | #expect(buffer.string == emacs[0].text, "ours: \(buffer.string.debugDescription)\nemacs: \(emacs[0].text.debugDescription)") | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | @Test(.enabled(if: oracle)) | ||
| 63 | func exportsLikeOrg() throws { | ||
| 64 | let text = "| a | b, c | \"q\" |\n|---+------+-----|\n| <5> | | |\n| 1 | x | y |\n" | ||
| 65 | let emacs = try EmacsOracle.evaluate(text, """ | ||
| 66 | (with-current-buffer (find-file-noselect "oracle.org") (goto-char 3) | ||
| 67 | (list (orgtbl-to-csv (org-table-to-lisp) nil) (orgtbl-to-tsv (org-table-to-lisp) nil))) | ||
| 68 | """) | ||
| 69 | #expect(ImportTable.exportText(text, at: 2, path: "t.csv") == emacs[0] + "\n") | ||
| 70 | #expect(ImportTable.exportText(text, at: 2, path: "t.tsv") == emacs[1] + "\n") | ||
| 71 | } | ||
| 72 | } | ||