Skip to content

Commit 3e13d10

Browse files
authored
Merge pull request #584 from TableProApp/feat/ios-export-clipboard
feat: export to clipboard for iOS
2 parents f79c2ed + 6ccff6b commit 3e13d10

File tree

12 files changed

+288
-93
lines changed

12 files changed

+288
-93
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
- iOS: page-based pagination for data browser
1515
- iOS: filter bar with 16 operators, AND/OR logic
1616
- iOS: persistent query history with timestamps
17+
- iOS: export to clipboard (JSON, CSV, SQL INSERT)
1718

1819
## [0.27.4] - 2026-04-05
1920

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
//
2+
// ClipboardExporter.swift
3+
// TableProMobile
4+
//
5+
6+
import Foundation
7+
import TableProModels
8+
import UIKit
9+
10+
enum ExportFormat: String, CaseIterable, Identifiable {
11+
case json = "JSON"
12+
case csv = "CSV"
13+
case sqlInsert = "SQL INSERT"
14+
var id: String { rawValue }
15+
}
16+
17+
enum ClipboardExporter {
18+
static func exportRow(columns: [ColumnInfo], row: [String?], format: ExportFormat, tableName: String? = nil) -> String {
19+
switch format {
20+
case .json:
21+
return rowToJson(columns: columns, row: row)
22+
case .csv:
23+
return rowToCsv(columns: columns, row: row, includeHeader: true)
24+
case .sqlInsert:
25+
return rowToInsert(columns: columns, row: row, tableName: tableName ?? "table")
26+
}
27+
}
28+
29+
static func exportRows(columns: [ColumnInfo], rows: [[String?]], format: ExportFormat, tableName: String? = nil) -> String {
30+
switch format {
31+
case .json:
32+
let objects = rows.map { rowToJson(columns: columns, row: $0) }
33+
return "[\n" + objects.joined(separator: ",\n") + "\n]"
34+
case .csv:
35+
let header = columns.map { escapeCsvField($0.name) }.joined(separator: ",")
36+
let dataLines = rows.map { row in
37+
columns.indices.map { i in
38+
escapeCsvField(i < row.count ? row[i] ?? "NULL" : "NULL")
39+
}.joined(separator: ",")
40+
}
41+
return ([header] + dataLines).joined(separator: "\n")
42+
case .sqlInsert:
43+
let name = tableName ?? "table"
44+
return rows.map { rowToInsert(columns: columns, row: $0, tableName: name) }.joined(separator: "\n")
45+
}
46+
}
47+
48+
static func copyToClipboard(_ text: String) {
49+
UIPasteboard.general.string = text
50+
}
51+
52+
// MARK: - Private
53+
54+
private static func rowToJson(columns: [ColumnInfo], row: [String?]) -> String {
55+
var pairs: [String] = []
56+
for (i, col) in columns.enumerated() {
57+
let value = i < row.count ? row[i] : nil
58+
let key = " \"\(escapeJsonString(col.name))\""
59+
if let value {
60+
if Int64(value) != nil || Double(value) != nil {
61+
pairs.append("\(key): \(value)")
62+
} else if value == "true" || value == "false" {
63+
pairs.append("\(key): \(value)")
64+
} else {
65+
pairs.append("\(key): \"\(escapeJsonString(value))\"")
66+
}
67+
} else {
68+
pairs.append("\(key): null")
69+
}
70+
}
71+
return "{\n" + pairs.joined(separator: ",\n") + "\n}"
72+
}
73+
74+
private static func rowToCsv(columns: [ColumnInfo], row: [String?], includeHeader: Bool) -> String {
75+
var lines: [String] = []
76+
if includeHeader {
77+
lines.append(columns.map { escapeCsvField($0.name) }.joined(separator: ","))
78+
}
79+
let dataLine = columns.indices.map { i in
80+
escapeCsvField(i < row.count ? row[i] ?? "NULL" : "NULL")
81+
}.joined(separator: ",")
82+
lines.append(dataLine)
83+
return lines.joined(separator: "\n")
84+
}
85+
86+
private static func rowToInsert(columns: [ColumnInfo], row: [String?], tableName: String) -> String {
87+
let cols = columns.map { "\"\($0.name)\"" }.joined(separator: ", ")
88+
let vals = columns.indices.map { i in
89+
let value = i < row.count ? row[i] : nil
90+
guard let value else { return "NULL" }
91+
return "'\(value.replacingOccurrences(of: "'", with: "''"))'"
92+
}.joined(separator: ", ")
93+
return "INSERT INTO \"\(tableName)\" (\(cols)) VALUES (\(vals));"
94+
}
95+
96+
private static func escapeCsvField(_ field: String) -> String {
97+
if field.contains(",") || field.contains("\"") || field.contains("\n") || field.contains("\r") {
98+
return "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\""
99+
}
100+
return field
101+
}
102+
103+
private static func escapeJsonString(_ str: String) -> String {
104+
str.replacingOccurrences(of: "\\", with: "\\\\")
105+
.replacingOccurrences(of: "\"", with: "\\\"")
106+
.replacingOccurrences(of: "\n", with: "\\n")
107+
.replacingOccurrences(of: "\r", with: "\\r")
108+
.replacingOccurrences(of: "\t", with: "\\t")
109+
}
110+
}

TableProMobile/TableProMobile/Views/Components/ConnectionColorPicker.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ struct ConnectionColorPicker: View {
3131
.contentShape(Circle())
3232
}
3333
.buttonStyle(.plain)
34+
.accessibilityLabel(Text(color.rawValue))
3435
}
3536
}
3637
.padding(.vertical, 4)

TableProMobile/TableProMobile/Views/ConnectedView.swift

Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ struct ConnectedView: View {
1919
@State private var tables: [TableInfo] = []
2020
@State private var isConnecting = true
2121
@State private var appError: AppError?
22-
@State private var toastMessage: String?
23-
@State private var toastTask: Task<Void, Never>?
22+
@State private var failureAlertMessage: String?
23+
@State private var showFailureAlert = false
2424
@State private var selectedTab = ConnectedTab.tables
2525
@State private var queryHistory: [QueryHistoryItem] = []
2626
private let historyStorage = QueryHistoryStorage()
@@ -77,28 +77,24 @@ struct ConnectedView: View {
7777
.animation(.default, value: isSwitching)
7878
}
7979
}
80-
.overlay(alignment: .bottom) {
81-
if let toastMessage {
82-
ErrorToast(message: toastMessage)
83-
.onAppear {
84-
toastTask?.cancel()
85-
toastTask = Task {
86-
try? await Task.sleep(nanoseconds: 3_000_000_000)
87-
withAnimation { self.toastMessage = nil }
88-
}
89-
}
90-
.onDisappear {
91-
toastTask?.cancel()
92-
toastTask = nil
93-
}
94-
}
80+
.alert("Error", isPresented: $showFailureAlert) {
81+
Button("OK", role: .cancel) {}
82+
} message: {
83+
Text(failureAlertMessage ?? "")
9584
}
96-
.animation(.default, value: toastMessage)
9785
.navigationTitle(displayName)
9886
.navigationBarTitleDisplayMode(.inline)
9987
.toolbar {
88+
ToolbarItem(placement: .principal) {
89+
Picker("Tab", selection: $selectedTab) {
90+
Text("Tables").tag(ConnectedTab.tables)
91+
Text("Query").tag(ConnectedTab.query)
92+
}
93+
.pickerStyle(.segmented)
94+
.frame(width: 200)
95+
}
10096
if supportsDatabaseSwitching && databases.count > 1 {
101-
ToolbarItem(placement: .principal) {
97+
ToolbarItem(placement: .topBarLeading) {
10298
Menu {
10399
ForEach(databases, id: \.self) { db in
104100
Button {
@@ -114,7 +110,7 @@ struct ConnectedView: View {
114110
} label: {
115111
HStack(spacing: 4) {
116112
Text(activeDatabase)
117-
.font(.headline)
113+
.font(.subheadline)
118114
if isSwitching {
119115
ProgressView()
120116
.controlSize(.mini)
@@ -163,15 +159,6 @@ struct ConnectedView: View {
163159

164160
private var connectedContent: some View {
165161
VStack(spacing: 0) {
166-
Picker("Tab", selection: $selectedTab) {
167-
ForEach(ConnectedTab.allCases, id: \.self) { tab in
168-
Text(tab.rawValue).tag(tab)
169-
}
170-
}
171-
.pickerStyle(.segmented)
172-
.padding(.horizontal)
173-
.padding(.vertical, 8)
174-
175162
switch selectedTab {
176163
case .tables:
177164
TableListView(
@@ -301,7 +288,8 @@ struct ConnectedView: View {
301288
activeSchema = name
302289
self.tables = try await session.driver.fetchTables(schema: name)
303290
} catch {
304-
withAnimation { toastMessage = String(localized: "Failed to switch schema") }
291+
failureAlertMessage = String(localized: "Failed to switch schema")
292+
showFailureAlert = true
305293
}
306294
}
307295

@@ -318,7 +306,8 @@ struct ConnectedView: View {
318306
activeDatabase = name
319307
self.tables = try await session.driver.fetchTables(schema: nil)
320308
} catch {
321-
withAnimation { toastMessage = String(localized: "Failed to switch database") }
309+
failureAlertMessage = String(localized: "Failed to switch database")
310+
showFailureAlert = true
322311
}
323312
}
324313
}
@@ -346,7 +335,8 @@ struct ConnectedView: View {
346335
let fallbackSession = try await appState.connectionManager.connect(connection)
347336
self.session = fallbackSession
348337
self.tables = try await fallbackSession.driver.fetchTables(schema: nil)
349-
withAnimation { toastMessage = String(localized: "Failed to switch database") }
338+
failureAlertMessage = String(localized: "Failed to switch database")
339+
showFailureAlert = true
350340
} catch {
351341
// Both failed — show error view
352342
let context = ErrorContext(
@@ -368,7 +358,8 @@ struct ConnectedView: View {
368358
self.tables = try await session.driver.fetchTables(schema: schema)
369359
} catch {
370360
Self.logger.warning("Failed to refresh tables: \(error.localizedDescription, privacy: .public)")
371-
withAnimation { toastMessage = String(localized: "Failed to refresh tables") }
361+
failureAlertMessage = String(localized: "Failed to refresh tables")
362+
showFailureAlert = true
372363
}
373364
}
374365
}

TableProMobile/TableProMobile/Views/DataBrowserView.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,19 @@ struct DataBrowserView: View {
153153
row: row
154154
)
155155
}
156+
.contextMenu {
157+
Menu("Copy Row") {
158+
ForEach(ExportFormat.allCases) { format in
159+
Button(format.rawValue) {
160+
let text = ClipboardExporter.exportRow(
161+
columns: columns, row: rows[index],
162+
format: format, tableName: table.name
163+
)
164+
ClipboardExporter.copyToClipboard(text)
165+
}
166+
}
167+
}
168+
}
156169
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
157170
if !isView && hasPrimaryKeys {
158171
Button(role: .destructive) {
@@ -165,7 +178,7 @@ struct DataBrowserView: View {
165178
}
166179
}
167180
}
168-
.listStyle(.insetGrouped)
181+
.listStyle(.plain)
169182
.opacity(isPageLoading ? 0.5 : 1)
170183
.allowsHitTesting(!isPageLoading)
171184
.overlay { if isPageLoading { ProgressView() } }
@@ -177,6 +190,24 @@ struct DataBrowserView: View {
177190

178191
@ToolbarContentBuilder
179192
private var topToolbar: some ToolbarContent {
193+
ToolbarItem(placement: .topBarTrailing) {
194+
Menu {
195+
ForEach(ExportFormat.allCases) { format in
196+
Button {
197+
let text = ClipboardExporter.exportRows(
198+
columns: columns, rows: rows,
199+
format: format, tableName: table.name
200+
)
201+
ClipboardExporter.copyToClipboard(text)
202+
} label: {
203+
Label(format.rawValue, systemImage: "doc.on.clipboard")
204+
}
205+
}
206+
} label: {
207+
Image(systemName: "square.and.arrow.up")
208+
}
209+
.disabled(rows.isEmpty)
210+
}
180211
ToolbarItem(placement: .topBarTrailing) {
181212
Button { showFilterSheet = true } label: {
182213
Image(systemName: hasActiveFilters

TableProMobile/TableProMobile/Views/GroupManagementView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ struct GroupManagementView: View {
6464
.navigationTitle("Groups")
6565
.navigationBarTitleDisplayMode(.inline)
6666
.toolbar {
67-
ToolbarItem(placement: .topBarLeading) {
67+
ToolbarItem(placement: .confirmationAction) {
6868
Button("Done") { dismiss() }
6969
}
7070
ToolbarItemGroup(placement: .topBarTrailing) {

TableProMobile/TableProMobile/Views/InsertRowView.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,10 @@ struct InsertRowView: View {
136136
.alert(operationError?.title ?? "Error", isPresented: $showOperationError) {
137137
Button("OK", role: .cancel) {}
138138
} message: {
139-
VStack {
140-
Text(operationError?.message ?? "An unknown error occurred.")
141-
if let recovery = operationError?.recovery {
142-
Text(verbatim: recovery)
143-
}
139+
if let recovery = operationError?.recovery {
140+
Text("\(operationError?.message ?? "")\n\(recovery)")
141+
} else {
142+
Text(operationError?.message ?? "")
144143
}
145144
}
146145
}

TableProMobile/TableProMobile/Views/OnboardingView.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,6 @@ struct OnboardingView: View {
8282
description: String(localized: "Import connections from your Mac")
8383
)
8484
}
85-
.buttonStyle(.plain)
8685

8786
Button(action: addNewConnection) {
8887
actionCard(
@@ -92,7 +91,6 @@ struct OnboardingView: View {
9291
description: String(localized: "Set up a new database connection")
9392
)
9493
}
95-
.buttonStyle(.plain)
9694
}
9795
.padding(.horizontal, 24)
9896

0 commit comments

Comments
 (0)