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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Copy selected rows as JSON from context menu and Edit menu
- iCloud Sync (Pro): sync connections, groups, tags, settings, and query history across Macs via CloudKit
- Pro feature gating system with license-aware UI overlay for Pro-only features
- Sync settings tab with per-category toggles and configurable history sync limit
Expand Down
223 changes: 223 additions & 0 deletions TablePro/Core/Utilities/SQL/JsonRowConverter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
//
// JsonRowConverter.swift
// TablePro
//

import Foundation

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

private static let maxRows = 50_000

func generateJson(rows: [[String?]]) -> String {
let cappedRows = rows.prefix(Self.maxRows)
let rowCount = cappedRows.count

if rowCount == 0 {
return "[]"
}

// Estimate capacity: ~100 bytes per cell as rough heuristic
var result = String()
result.reserveCapacity(rowCount * columns.count * 100)

result.append("[\n")

for (rowIdx, row) in cappedRows.enumerated() {
result.append(" {\n")

for (colIdx, column) in columns.enumerated() {
result.append(" \"")
result.append(escapeString(column))
result.append("\": ")

guard row.indices.contains(colIdx), let value = row[colIdx] else {
result.append("null")
appendPropertySuffix(to: &result, colIdx: colIdx)
continue
}

let colType: ColumnType
if columnTypes.indices.contains(colIdx) {
colType = columnTypes[colIdx]
} else {
colType = .text(rawType: nil)
}

result.append(formatValue(value, type: colType))
appendPropertySuffix(to: &result, colIdx: colIdx)
}

result.append(" }")
if rowIdx < rowCount - 1 {
result.append(",")
}
result.append("\n")
}

result.append("]")
return result
}

private func appendPropertySuffix(to result: inout String, colIdx: Int) {
if colIdx < columns.count - 1 {
result.append(",")
}
result.append("\n")
}

private func formatValue(_ value: String, type: ColumnType) -> String {
switch type {
case .integer:
return formatInteger(value)
case .decimal:
return formatDecimal(value)
case .boolean:
return formatBoolean(value)
case .json:
return formatJson(value)
case .blob:
return formatBlob(value)
case .text, .date, .timestamp, .datetime, .enumType, .set, .spatial:
return quotedEscaped(value)
}
}

private func formatInteger(_ value: String) -> String {
if let intVal = Int64(value) {
return String(intVal)
}
if let doubleVal = Double(value), doubleVal == doubleVal.rounded(.towardZero), !doubleVal.isInfinite, !doubleVal.isNaN {
return String(Int64(doubleVal))
}
return quotedEscaped(value)
}

private func formatDecimal(_ value: String) -> String {
// Emit verbatim if already a valid JSON number — preserves full database precision
if isValidJsonNumber(value) {
return value
}
// Fallback for non-standard formats (e.g., "1.0E5" with leading +)
if let doubleVal = Double(value), !doubleVal.isInfinite, !doubleVal.isNaN {
return String(doubleVal)
}
return quotedEscaped(value)
}

/// Checks whether a string conforms to JSON number grammar (RFC 8259 §6)
private func isValidJsonNumber(_ value: String) -> Bool {
let scalars = value.unicodeScalars
var iter = scalars.makeIterator()
guard var ch = iter.next() else { return false }

// Optional leading minus
if ch == "-" { guard let next = iter.next() else { return false }; ch = next }

// Integer part: "0" or [1-9][0-9]*
guard ch >= "0" && ch <= "9" else { return false }
if ch == "0" {
// "0" must not be followed by another digit
if let next = iter.next() { ch = next } else { return true }
} else {
while true {
guard let next = iter.next() else { return true }
ch = next
guard ch >= "0" && ch <= "9" else { break }
}
}

// Optional fractional part
if ch == "." {
guard let next = iter.next(), next >= "0" && next <= "9" else { return false }
while true {
guard let next = iter.next() else { return true }
ch = next
guard ch >= "0" && ch <= "9" else { break }
}
}

// Optional exponent
if ch == "e" || ch == "E" {
guard var next = iter.next() else { return false }
if next == "+" || next == "-" {
guard let signed = iter.next() else { return false }
next = signed
}
guard next >= "0" && next <= "9" else { return false }
for remaining in IteratorSequence(iter) {
guard remaining >= "0" && remaining <= "9" else { return false }
}
} else {
return false // Unexpected trailing character
}

return true
}

private func formatBoolean(_ value: String) -> String {
switch value.lowercased() {
case "true", "1", "yes", "on":
return "true"
case "false", "0", "no", "off":
return "false"
default:
return quotedEscaped(value)
}
}

private func formatJson(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard let data = trimmed.data(using: .utf8) else {
return quotedEscaped(value)
}
do {
_ = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
return trimmed
} catch {
return quotedEscaped(value)
}
}

private func formatBlob(_ value: String) -> String {
guard let data = value.data(using: .utf8) else {
return quotedEscaped(value)
}
let encoded = data.base64EncodedString()
return "\"\(encoded)\""
}

private func quotedEscaped(_ value: String) -> String {
"\"\(escapeString(value))\""
}

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

for scalar in value.unicodeScalars {
switch scalar {
case "\"":
result.append("\\\"")
case "\\":
result.append("\\\\")
case "\n":
result.append("\\n")
case "\r":
result.append("\\r")
case "\t":
result.append("\\t")
default:
if scalar.value < 0x20 {
result.append(String(format: "\\u%04X", scalar.value))
} else {
result.append(Character(scalar))
}
}
}

return result
}
}
5 changes: 5 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ struct PasteboardCommands: Commands {
.optionalKeyboardShortcut(shortcut(for: .copyWithHeaders))
.disabled(!appState.hasRowSelection)

Button("Copy as JSON") {
actions?.copySelectedRowsAsJson()
}
.disabled(!appState.hasRowSelection)

Button("Paste") {
let action = PasteboardActionRouter.resolvePasteAction(
firstResponder: NSApp.keyWindow?.firstResponder,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,22 @@ extension MainContentCoordinator {
)
}

func copySelectedRowsAsJson(indices: Set<Int>) {
guard let index = tabManager.selectedTabIndex,
!indices.isEmpty else { return }
let tab = tabManager.tabs[index]
let rows = indices.sorted().compactMap { idx -> [String?]? in
guard idx < tab.resultRows.count else { return nil }
return tab.resultRows[idx].values
}
guard !rows.isEmpty else { return }
let converter = JsonRowConverter(
columns: tab.resultColumns,
columnTypes: tab.columnTypes
)
ClipboardService.shared.writeText(converter.generateJson(rows: rows))
}

func pasteRows(selectedRowIndices: inout Set<Int>, editingCell: inout CellPosition?) {
guard !connection.safeModeLevel.blocksAllWrites,
let index = tabManager.selectedTabIndex else { return }
Expand Down
5 changes: 5 additions & 0 deletions TablePro/Views/Main/MainContentCommandActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,11 @@ final class MainContentCommandActions {
coordinator?.copySelectedRowsWithHeaders(indices: indices)
}

func copySelectedRowsAsJson() {
let indices = selectedRowIndices.wrappedValue
coordinator?.copySelectedRowsAsJson(indices: indices)
}

func pasteRows() {
if coordinator?.tabManager.selectedTab?.showStructure == true {
coordinator?.structureActions?.pasteRows?()
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Views/Results/DataGridView+RowActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ extension TableViewCoordinator {
ClipboardService.shared.writeText(converter.generateUpdates(rows: rows))
}

func copyRowsAsJson(at indices: Set<Int>) {
let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) }
guard !rows.isEmpty else { return }
let columnTypes = (rowProvider as? InMemoryRowProvider)?.columnTypes
?? Array(repeating: ColumnType.text(rawType: nil), count: rowProvider.columns.count)
let converter = JsonRowConverter(columns: rowProvider.columns, columnTypes: columnTypes)
ClipboardService.shared.writeText(converter.generateJson(rows: rows))
}

private func resolveDriver() -> (any DatabaseDriver)? {
guard let connectionId else { return nil }
return DatabaseManager.shared.driver(for: connectionId)
Expand Down
15 changes: 15 additions & 0 deletions TablePro/Views/Results/TableRowViewWithMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ final class TableRowViewWithMenu: NSTableRowView {
copyWithHeadersItem.target = self
copyAsMenu.addItem(copyWithHeadersItem)

let jsonItem = NSMenuItem(
title: String(localized: "JSON"),
action: #selector(copyAsJson),
keyEquivalent: "")
jsonItem.target = self
copyAsMenu.addItem(jsonItem)

if let dbType = coordinator.databaseType,
dbType != .mongodb && dbType != .redis,
coordinator.tableName != nil {
Expand Down Expand Up @@ -240,4 +247,12 @@ final class TableRowViewWithMenu: NSTableRowView {
: [rowIndex]
coordinator.copyRowsAsUpdate(at: indices)
}

@objc private func copyAsJson() {
guard let coordinator else { return }
let indices: Set<Int> = !coordinator.selectedRowIndices.isEmpty
? coordinator.selectedRowIndices
: [rowIndex]
coordinator.copyRowsAsJson(at: indices)
}
}
Loading
Loading