Sources/OrgCore/Commands/TableTransforms.swift
260 lines · 11880 bytes
1import 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
7extension 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`.
199public 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`.
224public 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.
238public 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}