Skip to content

Commit 00da11e

Browse files
authored
feat: add Copy as JSON for selected rows (#331)
* feat: add Copy as JSON for selected rows * fix: add explicit access control, preserve decimal precision, trim JSON whitespace
1 parent 49f3d7b commit 00da11e

File tree

8 files changed

+489
-0
lines changed

8 files changed

+489
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Copy selected rows as JSON from context menu and Edit menu
1213
- iCloud Sync (Pro): sync connections, groups, tags, settings, and query history across Macs via CloudKit
1314
- Pro feature gating system with license-aware UI overlay for Pro-only features
1415
- Sync settings tab with per-category toggles and configurable history sync limit
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
//
2+
// JsonRowConverter.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
internal struct JsonRowConverter {
9+
internal let columns: [String]
10+
internal let columnTypes: [ColumnType]
11+
12+
private static let maxRows = 50_000
13+
14+
func generateJson(rows: [[String?]]) -> String {
15+
let cappedRows = rows.prefix(Self.maxRows)
16+
let rowCount = cappedRows.count
17+
18+
if rowCount == 0 {
19+
return "[]"
20+
}
21+
22+
// Estimate capacity: ~100 bytes per cell as rough heuristic
23+
var result = String()
24+
result.reserveCapacity(rowCount * columns.count * 100)
25+
26+
result.append("[\n")
27+
28+
for (rowIdx, row) in cappedRows.enumerated() {
29+
result.append(" {\n")
30+
31+
for (colIdx, column) in columns.enumerated() {
32+
result.append(" \"")
33+
result.append(escapeString(column))
34+
result.append("\": ")
35+
36+
guard row.indices.contains(colIdx), let value = row[colIdx] else {
37+
result.append("null")
38+
appendPropertySuffix(to: &result, colIdx: colIdx)
39+
continue
40+
}
41+
42+
let colType: ColumnType
43+
if columnTypes.indices.contains(colIdx) {
44+
colType = columnTypes[colIdx]
45+
} else {
46+
colType = .text(rawType: nil)
47+
}
48+
49+
result.append(formatValue(value, type: colType))
50+
appendPropertySuffix(to: &result, colIdx: colIdx)
51+
}
52+
53+
result.append(" }")
54+
if rowIdx < rowCount - 1 {
55+
result.append(",")
56+
}
57+
result.append("\n")
58+
}
59+
60+
result.append("]")
61+
return result
62+
}
63+
64+
private func appendPropertySuffix(to result: inout String, colIdx: Int) {
65+
if colIdx < columns.count - 1 {
66+
result.append(",")
67+
}
68+
result.append("\n")
69+
}
70+
71+
private func formatValue(_ value: String, type: ColumnType) -> String {
72+
switch type {
73+
case .integer:
74+
return formatInteger(value)
75+
case .decimal:
76+
return formatDecimal(value)
77+
case .boolean:
78+
return formatBoolean(value)
79+
case .json:
80+
return formatJson(value)
81+
case .blob:
82+
return formatBlob(value)
83+
case .text, .date, .timestamp, .datetime, .enumType, .set, .spatial:
84+
return quotedEscaped(value)
85+
}
86+
}
87+
88+
private func formatInteger(_ value: String) -> String {
89+
if let intVal = Int64(value) {
90+
return String(intVal)
91+
}
92+
if let doubleVal = Double(value), doubleVal == doubleVal.rounded(.towardZero), !doubleVal.isInfinite, !doubleVal.isNaN {
93+
return String(Int64(doubleVal))
94+
}
95+
return quotedEscaped(value)
96+
}
97+
98+
private func formatDecimal(_ value: String) -> String {
99+
// Emit verbatim if already a valid JSON number — preserves full database precision
100+
if isValidJsonNumber(value) {
101+
return value
102+
}
103+
// Fallback for non-standard formats (e.g., "1.0E5" with leading +)
104+
if let doubleVal = Double(value), !doubleVal.isInfinite, !doubleVal.isNaN {
105+
return String(doubleVal)
106+
}
107+
return quotedEscaped(value)
108+
}
109+
110+
/// Checks whether a string conforms to JSON number grammar (RFC 8259 §6)
111+
private func isValidJsonNumber(_ value: String) -> Bool {
112+
let scalars = value.unicodeScalars
113+
var iter = scalars.makeIterator()
114+
guard var ch = iter.next() else { return false }
115+
116+
// Optional leading minus
117+
if ch == "-" { guard let next = iter.next() else { return false }; ch = next }
118+
119+
// Integer part: "0" or [1-9][0-9]*
120+
guard ch >= "0" && ch <= "9" else { return false }
121+
if ch == "0" {
122+
// "0" must not be followed by another digit
123+
if let next = iter.next() { ch = next } else { return true }
124+
} else {
125+
while true {
126+
guard let next = iter.next() else { return true }
127+
ch = next
128+
guard ch >= "0" && ch <= "9" else { break }
129+
}
130+
}
131+
132+
// Optional fractional part
133+
if ch == "." {
134+
guard let next = iter.next(), next >= "0" && next <= "9" else { return false }
135+
while true {
136+
guard let next = iter.next() else { return true }
137+
ch = next
138+
guard ch >= "0" && ch <= "9" else { break }
139+
}
140+
}
141+
142+
// Optional exponent
143+
if ch == "e" || ch == "E" {
144+
guard var next = iter.next() else { return false }
145+
if next == "+" || next == "-" {
146+
guard let signed = iter.next() else { return false }
147+
next = signed
148+
}
149+
guard next >= "0" && next <= "9" else { return false }
150+
for remaining in IteratorSequence(iter) {
151+
guard remaining >= "0" && remaining <= "9" else { return false }
152+
}
153+
} else {
154+
return false // Unexpected trailing character
155+
}
156+
157+
return true
158+
}
159+
160+
private func formatBoolean(_ value: String) -> String {
161+
switch value.lowercased() {
162+
case "true", "1", "yes", "on":
163+
return "true"
164+
case "false", "0", "no", "off":
165+
return "false"
166+
default:
167+
return quotedEscaped(value)
168+
}
169+
}
170+
171+
private func formatJson(_ value: String) -> String {
172+
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
173+
guard let data = trimmed.data(using: .utf8) else {
174+
return quotedEscaped(value)
175+
}
176+
do {
177+
_ = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed)
178+
return trimmed
179+
} catch {
180+
return quotedEscaped(value)
181+
}
182+
}
183+
184+
private func formatBlob(_ value: String) -> String {
185+
guard let data = value.data(using: .utf8) else {
186+
return quotedEscaped(value)
187+
}
188+
let encoded = data.base64EncodedString()
189+
return "\"\(encoded)\""
190+
}
191+
192+
private func quotedEscaped(_ value: String) -> String {
193+
"\"\(escapeString(value))\""
194+
}
195+
196+
private func escapeString(_ value: String) -> String {
197+
var result = String()
198+
result.reserveCapacity((value as NSString).length)
199+
200+
for scalar in value.unicodeScalars {
201+
switch scalar {
202+
case "\"":
203+
result.append("\\\"")
204+
case "\\":
205+
result.append("\\\\")
206+
case "\n":
207+
result.append("\\n")
208+
case "\r":
209+
result.append("\\r")
210+
case "\t":
211+
result.append("\\t")
212+
default:
213+
if scalar.value < 0x20 {
214+
result.append(String(format: "\\u%04X", scalar.value))
215+
} else {
216+
result.append(Character(scalar))
217+
}
218+
}
219+
}
220+
221+
return result
222+
}
223+
}

TablePro/TableProApp.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,11 @@ struct PasteboardCommands: Commands {
7474
.optionalKeyboardShortcut(shortcut(for: .copyWithHeaders))
7575
.disabled(!appState.hasRowSelection)
7676

77+
Button("Copy as JSON") {
78+
actions?.copySelectedRowsAsJson()
79+
}
80+
.disabled(!appState.hasRowSelection)
81+
7782
Button("Paste") {
7883
let action = PasteboardActionRouter.resolvePasteAction(
7984
firstResponder: NSApp.keyWindow?.firstResponder,

TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,22 @@ extension MainContentCoordinator {
137137
)
138138
}
139139

140+
func copySelectedRowsAsJson(indices: Set<Int>) {
141+
guard let index = tabManager.selectedTabIndex,
142+
!indices.isEmpty else { return }
143+
let tab = tabManager.tabs[index]
144+
let rows = indices.sorted().compactMap { idx -> [String?]? in
145+
guard idx < tab.resultRows.count else { return nil }
146+
return tab.resultRows[idx].values
147+
}
148+
guard !rows.isEmpty else { return }
149+
let converter = JsonRowConverter(
150+
columns: tab.resultColumns,
151+
columnTypes: tab.columnTypes
152+
)
153+
ClipboardService.shared.writeText(converter.generateJson(rows: rows))
154+
}
155+
140156
func pasteRows(selectedRowIndices: inout Set<Int>, editingCell: inout CellPosition?) {
141157
guard !connection.safeModeLevel.blocksAllWrites,
142158
let index = tabManager.selectedTabIndex else { return }

TablePro/Views/Main/MainContentCommandActions.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,11 @@ final class MainContentCommandActions {
244244
coordinator?.copySelectedRowsWithHeaders(indices: indices)
245245
}
246246

247+
func copySelectedRowsAsJson() {
248+
let indices = selectedRowIndices.wrappedValue
249+
coordinator?.copySelectedRowsAsJson(indices: indices)
250+
}
251+
247252
func pasteRows() {
248253
if coordinator?.tabManager.selectedTab?.showStructure == true {
249254
coordinator?.structureActions?.pasteRows?()

TablePro/Views/Results/DataGridView+RowActions.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,15 @@ extension TableViewCoordinator {
135135
ClipboardService.shared.writeText(converter.generateUpdates(rows: rows))
136136
}
137137

138+
func copyRowsAsJson(at indices: Set<Int>) {
139+
let rows = indices.sorted().compactMap { rowProvider.rowValues(at: $0) }
140+
guard !rows.isEmpty else { return }
141+
let columnTypes = (rowProvider as? InMemoryRowProvider)?.columnTypes
142+
?? Array(repeating: ColumnType.text(rawType: nil), count: rowProvider.columns.count)
143+
let converter = JsonRowConverter(columns: rowProvider.columns, columnTypes: columnTypes)
144+
ClipboardService.shared.writeText(converter.generateJson(rows: rows))
145+
}
146+
138147
private func resolveDriver() -> (any DatabaseDriver)? {
139148
guard let connectionId else { return nil }
140149
return DatabaseManager.shared.driver(for: connectionId)

TablePro/Views/Results/TableRowViewWithMenu.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,13 @@ final class TableRowViewWithMenu: NSTableRowView {
6161
copyWithHeadersItem.target = self
6262
copyAsMenu.addItem(copyWithHeadersItem)
6363

64+
let jsonItem = NSMenuItem(
65+
title: String(localized: "JSON"),
66+
action: #selector(copyAsJson),
67+
keyEquivalent: "")
68+
jsonItem.target = self
69+
copyAsMenu.addItem(jsonItem)
70+
6471
if let dbType = coordinator.databaseType,
6572
dbType != .mongodb && dbType != .redis,
6673
coordinator.tableName != nil {
@@ -240,4 +247,12 @@ final class TableRowViewWithMenu: NSTableRowView {
240247
: [rowIndex]
241248
coordinator.copyRowsAsUpdate(at: indices)
242249
}
250+
251+
@objc private func copyAsJson() {
252+
guard let coordinator else { return }
253+
let indices: Set<Int> = !coordinator.selectedRowIndices.isEmpty
254+
? coordinator.selectedRowIndices
255+
: [rowIndex]
256+
coordinator.copyRowsAsJson(at: indices)
257+
}
243258
}

0 commit comments

Comments
 (0)