| @@ -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 | } |