diff --git a/CHANGELOG.md b/CHANGELOG.md index e92bd94d4..3b783771c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/TablePro/Core/Utilities/SQL/JsonRowConverter.swift b/TablePro/Core/Utilities/SQL/JsonRowConverter.swift new file mode 100644 index 000000000..e1657454f --- /dev/null +++ b/TablePro/Core/Utilities/SQL/JsonRowConverter.swift @@ -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 + } +} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 65b93484a..3a1e94b3d 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -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, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift index 4664fdf92..e4afe6edd 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RowOperations.swift @@ -137,6 +137,22 @@ extension MainContentCoordinator { ) } + func copySelectedRowsAsJson(indices: Set) { + 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, editingCell: inout CellPosition?) { guard !connection.safeModeLevel.blocksAllWrites, let index = tabManager.selectedTabIndex else { return } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 5e413c6c9..168ec64ce 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -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?() diff --git a/TablePro/Views/Results/DataGridView+RowActions.swift b/TablePro/Views/Results/DataGridView+RowActions.swift index c2918c8a6..6298b9d9a 100644 --- a/TablePro/Views/Results/DataGridView+RowActions.swift +++ b/TablePro/Views/Results/DataGridView+RowActions.swift @@ -135,6 +135,15 @@ extension TableViewCoordinator { ClipboardService.shared.writeText(converter.generateUpdates(rows: rows)) } + func copyRowsAsJson(at indices: Set) { + 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) diff --git a/TablePro/Views/Results/TableRowViewWithMenu.swift b/TablePro/Views/Results/TableRowViewWithMenu.swift index 2551b9111..f5a7f2517 100644 --- a/TablePro/Views/Results/TableRowViewWithMenu.swift +++ b/TablePro/Views/Results/TableRowViewWithMenu.swift @@ -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 { @@ -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 = !coordinator.selectedRowIndices.isEmpty + ? coordinator.selectedRowIndices + : [rowIndex] + coordinator.copyRowsAsJson(at: indices) + } } diff --git a/TableProTests/Core/Utilities/JsonRowConverterTests.swift b/TableProTests/Core/Utilities/JsonRowConverterTests.swift new file mode 100644 index 000000000..2d09c101e --- /dev/null +++ b/TableProTests/Core/Utilities/JsonRowConverterTests.swift @@ -0,0 +1,215 @@ +// +// JsonRowConverterTests.swift +// TableProTests +// + +@testable import TablePro +import Testing + +@Suite("JSON Row Converter") +struct JsonRowConverterTests { + private func makeConverter(columns: [String], columnTypes: [ColumnType]) -> JsonRowConverter { + JsonRowConverter(columns: columns, columnTypes: columnTypes) + } + + // MARK: - Basic + + @Test("Empty rows produces empty JSON array") + func emptyRows() { + let converter = makeConverter(columns: ["id"], columnTypes: [.integer(rawType: nil)]) + let result = converter.generateJson(rows: []) + #expect(result == "[]") + } + + @Test("Nil values produce JSON null") + func nilValues() { + let converter = makeConverter(columns: ["name"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateJson(rows: [[nil]]) + #expect(result.contains("null")) + #expect(!result.contains("\"null\"")) + } + + // MARK: - Integer + + @Test("Integer column produces unquoted number") + func integerColumn() { + let converter = makeConverter(columns: ["id"], columnTypes: [.integer(rawType: nil)]) + let result = converter.generateJson(rows: [["42"]]) + #expect(result.contains(": 42")) + #expect(!result.contains("\"42\"")) + } + + @Test("Integer fallback for non-numeric value produces quoted string") + func integerFallback() { + let converter = makeConverter(columns: ["id"], columnTypes: [.integer(rawType: nil)]) + let result = converter.generateJson(rows: [["abc"]]) + #expect(result.contains("\"abc\"")) + } + + // MARK: - Decimal + + @Test("Decimal column produces unquoted number") + func decimalColumn() { + let converter = makeConverter(columns: ["price"], columnTypes: [.decimal(rawType: nil)]) + let result = converter.generateJson(rows: [["3.14"]]) + #expect(result.contains(": 3.14")) + #expect(!result.contains("\"3.14\"")) + } + + @Test("Decimal preserves full precision for high-precision values") + func decimalPrecision() { + let converter = makeConverter(columns: ["amount"], columnTypes: [.decimal(rawType: nil)]) + let result = converter.generateJson(rows: [["123456.789"]]) + #expect(result.contains(": 123456.789")) + } + + @Test("Decimal infinity and NaN produce quoted strings") + func decimalInfinityNaN() { + let converter = makeConverter(columns: ["a", "b"], columnTypes: [.decimal(rawType: nil), .decimal(rawType: nil)]) + let result = converter.generateJson(rows: [["inf", "nan"]]) + #expect(result.contains("\"inf\"")) + #expect(result.contains("\"nan\"")) + } + + // MARK: - Boolean + + @Test("Boolean true variants") + func booleanTrueVariants() { + let converter = makeConverter( + columns: ["a", "b", "c", "d"], + columnTypes: Array(repeating: ColumnType.boolean(rawType: nil), count: 4) + ) + let result = converter.generateJson(rows: [["true", "1", "yes", "on"]]) + let trueCount = result.components(separatedBy: ": true").count - 1 + #expect(trueCount == 4) + } + + @Test("Boolean false variants") + func booleanFalseVariants() { + let converter = makeConverter( + columns: ["a", "b", "c", "d"], + columnTypes: Array(repeating: ColumnType.boolean(rawType: nil), count: 4) + ) + let result = converter.generateJson(rows: [["false", "0", "no", "off"]]) + let falseCount = result.components(separatedBy: ": false").count - 1 + #expect(falseCount == 4) + } + + @Test("Boolean unknown value produces quoted string") + func booleanUnknown() { + let converter = makeConverter(columns: ["flag"], columnTypes: [.boolean(rawType: nil)]) + let result = converter.generateJson(rows: [["maybe"]]) + #expect(result.contains("\"maybe\"")) + } + + // MARK: - JSON + + @Test("Valid JSON column is embedded verbatim") + func validJsonColumn() { + let converter = makeConverter(columns: ["data"], columnTypes: [.json(rawType: nil)]) + let jsonValue = "{\"key\":\"value\"}" + let result = converter.generateJson(rows: [[jsonValue]]) + #expect(result.contains(": {\"key\":\"value\"}")) + } + + @Test("Invalid JSON column produces quoted string") + func invalidJsonColumn() { + let converter = makeConverter(columns: ["data"], columnTypes: [.json(rawType: nil)]) + let result = converter.generateJson(rows: [["{broken"]]) + #expect(result.contains("\"{broken\"")) + } + + @Test("JSON column with trailing whitespace is trimmed before embedding") + func jsonColumnTrimmed() { + let converter = makeConverter(columns: ["data"], columnTypes: [.json(rawType: nil)]) + let result = converter.generateJson(rows: [["{\"k\":1}\n"]]) + #expect(result.contains(": {\"k\":1}")) + #expect(!result.contains(": {\"k\":1}\n\n")) + } + + // MARK: - String escaping + + @Test("Text with double quotes is escaped") + func textWithDoubleQuotes() { + let converter = makeConverter(columns: ["name"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateJson(rows: [["say \"hello\""]]) + #expect(result.contains("say \\\"hello\\\"")) + } + + @Test("Text with backslashes is escaped") + func textWithBackslashes() { + let converter = makeConverter(columns: ["path"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateJson(rows: [["C:\\Users\\test"]]) + #expect(result.contains("C:\\\\Users\\\\test")) + } + + @Test("Text with control characters is escaped") + func textWithControlCharacters() { + let converter = makeConverter(columns: ["text"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateJson(rows: [["line1\nline2\ttab"]]) + #expect(result.contains("line1\\nline2\\ttab")) + } + + // MARK: - Column name escaping + + @Test("Column name with special characters is escaped in key") + func columnNameSpecialChars() { + let converter = makeConverter(columns: ["col\"umn"], columnTypes: [.text(rawType: nil)]) + let result = converter.generateJson(rows: [["value"]]) + #expect(result.contains("\"col\\\"umn\"")) + } + + // MARK: - Row cap + + @Test("Output is capped at 50,000 rows") + func rowCap() { + let converter = makeConverter(columns: ["id"], columnTypes: [.text(rawType: nil)]) + let marker = "MARKER_VAL" + let rows = Array(repeating: [marker] as [String?], count: 50_001) + let result = converter.generateJson(rows: rows) + let count = result.components(separatedBy: marker).count - 1 + #expect(count == 50_000) + } + + // MARK: - Multiple rows + + @Test("Multiple rows are comma-separated") + func multipleRows() { + let converter = makeConverter(columns: ["id"], columnTypes: [.integer(rawType: nil)]) + let result = converter.generateJson(rows: [["1"], ["2"], ["3"]]) + #expect(result.contains("},\n")) + #expect(result.hasSuffix(" }\n]")) + } + + // MARK: - Edge cases + + @Test("columnTypes shorter than columns defaults to text") + func columnTypesShorter() { + let converter = makeConverter(columns: ["id", "name"], columnTypes: [.integer(rawType: nil)]) + let result = converter.generateJson(rows: [["42", "hello"]]) + #expect(result.contains(": 42")) + #expect(result.contains("\"hello\"")) + } + + @Test("Row values shorter than columns produces null for missing") + func rowValuesShorter() { + let converter = makeConverter( + columns: ["a", "b", "c"], + columnTypes: [.text(rawType: nil), .text(rawType: nil), .text(rawType: nil)] + ) + let result = converter.generateJson(rows: [["only_one"]]) + #expect(result.contains("\"only_one\"")) + let nullCount = result.components(separatedBy: "null").count - 1 + #expect(nullCount == 2) + } + + // MARK: - Blob + + @Test("Blob column produces base64 encoded value") + func blobColumn() { + let converter = makeConverter(columns: ["data"], columnTypes: [.blob(rawType: nil)]) + let result = converter.generateJson(rows: [["hello"]]) + // "hello" in base64 is "aGVsbG8=" + #expect(result.contains("\"aGVsbG8=\"")) + } +}