krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgCore/Commands/TableTransforms.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/OrgCore/Commands/TableTransforms.swift history · blame · raw

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}