Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Right-click a column header to copy all its values from the loaded rows (#1325)
- Copy as submenu on the row context menu now offers CSV, CSV with Headers, Markdown table, and IN Clause for SQL `WHERE id IN (...)` lookups (#1325)

### Fixed

- DuckDB Spatial `GEOMETRY` columns render as WKT, not NULL (#1324)
Expand Down
13 changes: 10 additions & 3 deletions TablePro/Core/Services/Infrastructure/ClipboardService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,22 @@
// ClipboardService.swift
// TablePro
//
// Abstraction over clipboard operations for testability.
// Provides protocol-based access to pasteboard data.
//

import AppKit
import UniformTypeIdentifiers

protocol ClipboardProvider {
func readText() -> String?
func writeText(_ text: String)
func writeCsv(_ csv: String)
func writeRows(tsv: String, html: String?)
var hasText: Bool { get }
var hasGridRows: Bool { get }
}

struct NSPasteboardClipboardProvider: ClipboardProvider {
private static let tsvType = NSPasteboard.PasteboardType("public.utf8-tab-separated-values-text")
private static let csvType = NSPasteboard.PasteboardType("public.comma-separated-values-text")
private static let gridRowsType = NSPasteboard.PasteboardType("com.TablePro.gridRows")

func readText() -> String? {
Expand All @@ -32,6 +31,14 @@ struct NSPasteboardClipboardProvider: ClipboardProvider {
pb.setString(text, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier))
}

func writeCsv(_ csv: String) {
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(csv, forType: .string)
pb.setString(csv, forType: NSPasteboard.PasteboardType(UTType.utf8PlainText.identifier))
pb.setString(csv, forType: Self.csvType)
}

func writeRows(tsv: String, html: String?) {
let pb = NSPasteboard.general
pb.clearContents()
Expand Down
58 changes: 58 additions & 0 deletions TablePro/Core/Utilities/SQL/CsvRowConverter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// CsvRowConverter.swift
// TablePro
//

import Foundation
import TableProPluginKit

internal struct CsvRowConverter {
internal let columns: [String]
internal let columnTypes: [ColumnType]

private static let maxRows = 50_000

func generateCsv(rows: [[PluginCellValue]], includeHeaders: Bool) -> String {
let cappedRows = rows.prefix(Self.maxRows)
let columnCount = columns.count

var result = String()
result.reserveCapacity(cappedRows.count * columnCount * 16)

if includeHeaders {
for (idx, header) in columns.enumerated() {
if idx > 0 { result.append(",") }
result.append(Self.encode(header))
}
result.append("\n")
}

for row in cappedRows {
for idx in 0..<columnCount {
if idx > 0 { result.append(",") }
guard row.indices.contains(idx) else { continue }

let columnType = columnTypes.indices.contains(idx) ? columnTypes[idx] : nil
guard let text = RowValueCopyFormatter.copyText(cell: row[idx], columnType: columnType) else {
continue
}
result.append(Self.encode(text))
}
result.append("\n")
}

return result
}

static func encode(_ value: String) -> String {
let needsQuoting = value.contains(",")
|| value.contains("\"")
|| value.contains("\n")
|| value.contains("\r")
|| value.hasPrefix(" ")
|| value.hasSuffix(" ")
guard needsQuoting else { return value }
let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\""
}
}
66 changes: 66 additions & 0 deletions TablePro/Core/Utilities/SQL/InClauseConverter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// InClauseConverter.swift
// TablePro
//

import Foundation
import TableProPluginKit

internal struct InClauseConverter {
internal let columnIndex: Int
internal let columnTypes: [ColumnType]
internal let escapeStringLiteral: ((String) -> String)?

private static let maxRows = 50_000

func generateInClause(rows: [[PluginCellValue]]) -> String {
let cappedRows = rows.prefix(Self.maxRows)
let columnType: ColumnType = columnTypes.indices.contains(columnIndex)
? columnTypes[columnIndex]
: .text(rawType: nil)

let values: [String] = cappedRows.compactMap { row in
guard row.indices.contains(columnIndex) else { return nil }
return format(cell: row[columnIndex], type: columnType)
}

guard !values.isEmpty else { return "()" }
return "(\(values.joined(separator: ", ")))"
}

private func format(cell: PluginCellValue, type: ColumnType) -> String? {
switch cell {
case .null, .bytes:
return nil
case .text(let value):
return formatScalar(value, type: type)
}
}

private func formatScalar(_ value: String, type: ColumnType) -> String {
switch type {
case .integer:
if RowValueCopyFormatter.isIntegerLiteral(value) { return value }
return quoted(value)
case .decimal:
if Double(value) != nil { return value }
return quoted(value)
case .boolean:
switch value.lowercased() {
case "true", "1", "yes", "on":
return "TRUE"
case "false", "0", "no", "off":
return "FALSE"
default:
return quoted(value)
}
case .blob, .text, .date, .timestamp, .datetime, .json, .enumType, .set, .spatial:
return quoted(value)
}
}

private func quoted(_ value: String) -> String {
let escaped = escapeStringLiteral?(value) ?? value.replacingOccurrences(of: "'", with: "''")
return "'\(escaped)'"
}
}
70 changes: 70 additions & 0 deletions TablePro/Core/Utilities/SQL/MarkdownTableConverter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// MarkdownTableConverter.swift
// TablePro
//

import Foundation
import TableProPluginKit

internal struct MarkdownTableConverter {
internal let columns: [String]
internal let columnTypes: [ColumnType]

private static let maxRows = 50_000

func generateMarkdown(rows: [[PluginCellValue]]) -> String {
let cappedRows = rows.prefix(Self.maxRows)
let columnCount = columns.count
guard columnCount > 0 else { return "" }

var result = String()
result.reserveCapacity(cappedRows.count * columnCount * 16)

result.append("| ")
result.append(columns.map(Self.encode).joined(separator: " | "))
result.append(" |\n")

result.append("|")
for _ in 0..<columnCount {
result.append(" --- |")
}
result.append("\n")

for row in cappedRows {
result.append("| ")
for idx in 0..<columnCount {
if idx > 0 { result.append(" | ") }

guard row.indices.contains(idx) else {
result.append("NULL")
continue
}

let columnType = columnTypes.indices.contains(idx) ? columnTypes[idx] : nil
let text = RowValueCopyFormatter.copyText(cell: row[idx], columnType: columnType) ?? "NULL"
result.append(Self.encode(text))
}
result.append(" |\n")
}

return result
}

static func encode(_ value: String) -> String {
var result = String()
result.reserveCapacity((value as NSString).length)

for scalar in value.unicodeScalars {
switch scalar {
case "|":
result.append("\\|")
case "\n", "\r":
result.append("<br>")
default:
result.append(Character(scalar))
}
}

return result
}
}
37 changes: 37 additions & 0 deletions TablePro/Core/Utilities/SQL/RowValueCopyFormatter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// RowValueCopyFormatter.swift
// TablePro
//

import Foundation
import TableProPluginKit

internal enum RowValueCopyFormatter {
static func copyText(cell: PluginCellValue, columnType: ColumnType?) -> String? {
switch cell {
case .null:
return nil
case .text(let value):
if columnType?.isBlobType ?? false {
return value.formattedAsCompactHex() ?? value
}
return value
case .bytes(let data):
return String(data: data, encoding: .isoLatin1)?.formattedAsCompactHex() ?? ""
}
}

static func isIntegerLiteral(_ value: String) -> Bool {
var iter = value.unicodeScalars.makeIterator()
guard var first = iter.next() else { return false }
if first == "-" {
guard let next = iter.next() else { return false }
first = next
}
guard first >= "0" && first <= "9" else { return false }
while let next = iter.next() {
guard next >= "0" && next <= "9" else { return false }
}
return true
}
}
15 changes: 15 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -12254,6 +12254,9 @@
}
}
}
},
"Copy Column Values" : {

},
"Copy Connection ID" : {

Expand Down Expand Up @@ -13443,6 +13446,12 @@
}
}
}
},
"CSV" : {

},
"CSV with Headers" : {

},
"CURDATE()" : {
"extractionState" : "stale",
Expand Down Expand Up @@ -24238,6 +24247,9 @@
}
}
}
},
"IN Clause" : {

},
"in list" : {
"localizations" : {
Expand Down Expand Up @@ -27986,6 +27998,9 @@
}
}
}
},
"Markdown" : {

},
"Massive" : {
"extractionState" : "stale",
Expand Down
Loading
Loading