diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 7f3caa310..9d540bac9 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -253,7 +253,7 @@ AUTOMATION_APPLE_EVENTS = NO; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = D7HJ5TFYCU; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -286,7 +286,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs"; MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 0.1.9; + MARKETING_VERSION = 0.1.10; OTHER_LDFLAGS = ( "-force_load", "$(PROJECT_DIR)/Libs/libmariadb.a", @@ -331,7 +331,7 @@ AUTOMATION_APPLE_EVENTS = NO; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = D7HJ5TFYCU; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -364,7 +364,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs"; MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 0.1.9; + MARKETING_VERSION = 0.1.10; OTHER_LDFLAGS = ( "-force_load", "$(PROJECT_DIR)/Libs/libmariadb.a", diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 5996deda5..733ebd37c 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -397,14 +397,8 @@ struct SQLStatementGenerator { } /// Escape characters that can break SQL strings + /// Delegates to shared SQLEscaping utility for consistent escaping across the codebase private func escapeSQLString(_ str: String) -> String { - var result = str - result = result.replacingOccurrences(of: "\\", with: "\\\\") // Backslash first - result = result.replacingOccurrences(of: "'", with: "''") // Single quote - result = result.replacingOccurrences(of: "\n", with: "\\n") // Newline - result = result.replacingOccurrences(of: "\r", with: "\\r") // Carriage return - result = result.replacingOccurrences(of: "\t", with: "\\t") // Tab - result = result.replacingOccurrences(of: "\0", with: "\\0") // Null byte - return result + SQLEscaping.escapeStringLiteral(str) } } diff --git a/TablePro/Core/Database/MySQLDriver.swift b/TablePro/Core/Database/MySQLDriver.swift index 1c9bab068..b1393b69d 100644 --- a/TablePro/Core/Database/MySQLDriver.swift +++ b/TablePro/Core/Database/MySQLDriver.swift @@ -288,7 +288,16 @@ final class MySQLDriver: DatabaseDriver { } func fetchTableDDL(table: String) async throws -> String { - let query = "SHOW CREATE TABLE `\(table)`" + // The `table` argument must be a valid MySQL/MariaDB table identifier, optionally + // schema-qualified, and is interpolated verbatim into the query. Examples: + // - "users" + // - "`mydb`.`users`" + // - "`users`" + // + // This method does not add any quoting or escaping around `table`. It is the + // caller's responsibility to provide a correctly formatted and safely quoted + // identifier when needed. + let query = "SHOW CREATE TABLE \(table)" let result = try await execute(query: query) // SHOW CREATE TABLE returns 2 columns: Table name and Create Table statement diff --git a/TablePro/Core/Database/SQLEscaping.swift b/TablePro/Core/Database/SQLEscaping.swift new file mode 100644 index 000000000..f3ffca0d7 --- /dev/null +++ b/TablePro/Core/Database/SQLEscaping.swift @@ -0,0 +1,62 @@ +// +// SQLEscaping.swift +// TablePro +// +// Shared utilities for SQL string escaping to prevent SQL injection. +// Used across ExportService, SQLStatementGenerator, and other SQL-generating code. +// + +import Foundation + +/// Centralized SQL escaping utilities to prevent SQL injection vulnerabilities +enum SQLEscaping { + + /// Escape a string value for use in SQL string literals (VALUES, WHERE clauses, etc.) + /// + /// Handles the following special characters: + /// - Backslashes (must be escaped first to avoid double-escaping) + /// - Single quotes (SQL standard: doubled) + /// - Control characters: null, backspace, tab, newline, form feed, carriage return + /// - MySQL EOF marker (\x1A) which can cause parsing issues + /// + /// Example: + /// ```swift + /// let safe = SQLEscaping.escapeStringLiteral("O'Brien\\test") + /// // Result: "O''Brien\\\\test" + /// let sql = "INSERT INTO users (name) VALUES ('\(safe)')" + /// ``` + /// + /// - Parameter str: The raw string to escape + /// - Returns: The escaped string safe for use in SQL string literals + static func escapeStringLiteral(_ str: String) -> String { + var result = str + // IMPORTANT: Escape backslashes FIRST to avoid double-escaping + result = result.replacingOccurrences(of: "\\", with: "\\\\") + // Single quote: SQL standard escaping (double the quote) + result = result.replacingOccurrences(of: "'", with: "''") + // Common control characters + result = result.replacingOccurrences(of: "\n", with: "\\n") + result = result.replacingOccurrences(of: "\r", with: "\\r") + result = result.replacingOccurrences(of: "\t", with: "\\t") + result = result.replacingOccurrences(of: "\0", with: "\\0") + // Additional control characters that can cause issues + result = result.replacingOccurrences(of: "\u{08}", with: "\\b") // Backspace + result = result.replacingOccurrences(of: "\u{0C}", with: "\\f") // Form feed + result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z") // MySQL EOF marker (Ctrl+Z) + return result + } + + /// Escape wildcards in LIKE patterns while preserving intentional wildcards + /// + /// This is useful when building LIKE clauses where the search term should be treated literally. + /// + /// - Parameter value: The value to escape + /// - Returns: The escaped value with %, _, and \ escaped + static func escapeLikeWildcards(_ value: String) -> String { + var result = value + result = result.replacingOccurrences(of: "\\", with: "\\\\") + result = result.replacingOccurrences(of: "%", with: "\\%") + result = result.replacingOccurrences(of: "_", with: "\\_") + return result + } +} diff --git a/TablePro/Core/Services/CreateTableService.swift b/TablePro/Core/Services/CreateTableService.swift index b02aa5d08..f804e23c7 100644 --- a/TablePro/Core/Services/CreateTableService.swift +++ b/TablePro/Core/Services/CreateTableService.swift @@ -545,15 +545,9 @@ struct CreateTableService { } /// Escape characters that can break SQL strings + /// Delegates to shared SQLEscaping utility for consistent escaping across the codebase private func escapeSQLString(_ str: String) -> String { - var result = str - result = result.replacingOccurrences(of: "\\", with: "\\\\") // Backslash first - result = result.replacingOccurrences(of: "'", with: "''") // Single quote (SQL standard) - result = result.replacingOccurrences(of: "\n", with: "\\n") // Newline - result = result.replacingOccurrences(of: "\r", with: "\\r") // Carriage return - result = result.replacingOccurrences(of: "\t", with: "\\t") // Tab - result = result.replacingOccurrences(of: "\0", with: "\\0") // Null byte - return result + SQLEscaping.escapeStringLiteral(str) } } diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift new file mode 100644 index 000000000..3189c96fa --- /dev/null +++ b/TablePro/Core/Services/ExportService.swift @@ -0,0 +1,926 @@ +// +// ExportService.swift +// TablePro +// +// Service responsible for exporting table data to CSV, JSON, and SQL formats. +// Supports configurable options for each format including compression. +// + +import Combine +import Foundation + +// MARK: - Export Error + +/// Errors that can occur during export operations +enum ExportError: LocalizedError { + case notConnected + case noTablesSelected + case exportFailed(String) + case compressionFailed + case fileWriteFailed(String) + case encodingFailed + + var errorDescription: String? { + switch self { + case .notConnected: + return "Not connected to database" + case .noTablesSelected: + return "No tables selected for export" + case .exportFailed(let message): + return "Export failed: \(message)" + case .compressionFailed: + return "Failed to compress data" + case .fileWriteFailed(let path): + return "Failed to write file: \(path)" + case .encodingFailed: + return "Failed to encode content as UTF-8" + } + } +} + +// MARK: - String Extension for Safe Encoding + +private extension String { + /// Safely encode string to UTF-8 data, throwing if encoding fails + func toUTF8Data() throws -> Data { + guard let data = self.data(using: .utf8) else { + throw ExportError.encodingFailed + } + return data + } +} + +// MARK: - Export Service + +/// Service responsible for exporting table data to various formats +@MainActor +final class ExportService: ObservableObject { + + // MARK: - Published State + + @Published var isExporting: Bool = false + @Published var progress: Double = 0.0 + @Published var currentTable: String = "" + @Published var currentTableIndex: Int = 0 + @Published var totalTables: Int = 0 + @Published var processedRows: Int = 0 + @Published var totalRows: Int = 0 + @Published var statusMessage: String = "" + @Published var errorMessage: String? + /// Non-fatal warnings that occurred during export (e.g., DDL fetch failures) + @Published var warningMessage: String? + + // MARK: - DDL Failure Tracking + + /// Tables that failed DDL fetch during SQL export + private var ddlFailures: [String] = [] + + // MARK: - Cancellation + + private let isCancelledLock = NSLock() + private var _isCancelled: Bool = false + private var isCancelled: Bool { + get { + isCancelledLock.lock() + defer { isCancelledLock.unlock() } + return _isCancelled + } + set { + isCancelledLock.lock() + _isCancelled = newValue + isCancelledLock.unlock() + } + } + + // MARK: - Progress Throttling + + /// Number of rows to process before updating UI + private let progressUpdateInterval: Int = 1000 + /// Internal counter for processed rows (updated every row) + private var internalProcessedRows: Int = 0 + + // MARK: - Dependencies + + private let driver: DatabaseDriver + private let databaseType: DatabaseType + + // MARK: - Initialization + + init(driver: DatabaseDriver, databaseType: DatabaseType) { + self.driver = driver + self.databaseType = databaseType + } + + // MARK: - Public API + + /// Cancel the current export operation + func cancelExport() { + isCancelled = true + } + + /// Export selected tables to the specified URL + /// - Parameters: + /// - tables: Array of table items to export (with SQL options for SQL format) + /// - config: Export configuration with format and options + /// - url: Destination file URL + func export( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + guard !tables.isEmpty else { + throw ExportError.noTablesSelected + } + + // Reset state + isExporting = true + isCancelled = false + progress = 0.0 + processedRows = 0 + internalProcessedRows = 0 + totalRows = 0 + totalTables = tables.count + currentTableIndex = 0 + statusMessage = "" + errorMessage = nil + warningMessage = nil + ddlFailures = [] + + defer { + isExporting = false + isCancelled = false + statusMessage = "" + } + + // Fetch total row counts for all tables + totalRows = await fetchTotalRowCount(for: tables) + + do { + switch config.format { + case .csv: + try await exportToCSV(tables: tables, config: config, to: url) + case .json: + try await exportToJSON(tables: tables, config: config, to: url) + case .sql: + try await exportToSQL(tables: tables, config: config, to: url) + } + } catch { + // Clean up partial file on cancellation or error + try? FileManager.default.removeItem(at: url) + errorMessage = error.localizedDescription + throw error + } + } + + /// Fetch total row count for all tables. + /// - Returns: The total row count across all tables. Any failures are logged but do not affect the returned value. + /// - Note: When row count fails for some tables, the statusMessage is updated to inform the user that progress is estimated. + private func fetchTotalRowCount(for tables: [ExportTableItem]) async -> Int { + var total = 0 + var failedCount = 0 + for table in tables { + let tableRef = qualifiedTableRef(for: table) + do { + let result = try await driver.execute(query: "SELECT COUNT(*) FROM \(tableRef)") + if let countStr = result.rows.first?.first, let count = Int(countStr ?? "0") { + total += count + } + } catch { + // Log the error but continue - progress will be less accurate + failedCount += 1 + print("Warning: Failed to get row count for \(table.qualifiedName): \(error.localizedDescription)") + } + } + if failedCount > 0 { + print("Warning: \(failedCount) table(s) failed row count - progress indicator may be inaccurate") + // Update status message so user knows progress is estimated + statusMessage = "Progress estimated (\(failedCount) table\(failedCount > 1 ? "s" : "") could not be counted)" + } + return total + } + + /// Check if export was cancelled and throw if so + private func checkCancellation() throws { + if isCancelled { + throw NSError( + domain: "ExportService", + code: NSUserCancelledError, + userInfo: [NSLocalizedDescriptionKey: "Export cancelled"] + ) + } + } + + /// Increment processed rows with throttled UI updates + /// Only updates @Published properties every `progressUpdateInterval` rows + /// Uses Task.yield() to allow UI to refresh + private func incrementProgress() async { + internalProcessedRows += 1 + + // Only update UI every N rows + if internalProcessedRows % progressUpdateInterval == 0 { + processedRows = internalProcessedRows + if totalRows > 0 { + progress = Double(internalProcessedRows) / Double(totalRows) + } + // Yield to allow UI to update + await Task.yield() + } + } + + /// Finalize progress for current table (ensures UI shows final count) + private func finalizeTableProgress() async { + processedRows = internalProcessedRows + if totalRows > 0 { + progress = Double(internalProcessedRows) / Double(totalRows) + } + // Yield to allow UI to update + await Task.yield() + } + + // MARK: - Helpers + + /// Build fully qualified and quoted table reference (database.table or just table) + private func qualifiedTableRef(for table: ExportTableItem) -> String { + if table.databaseName.isEmpty { + return databaseType.quoteIdentifier(table.name) + } else { + let quotedDb = databaseType.quoteIdentifier(table.databaseName) + let quotedTable = databaseType.quoteIdentifier(table.name) + return "\(quotedDb).\(quotedTable)" + } + } + + /// Sanitize a name for use in SQL comments to prevent comment injection + /// + /// Removes characters that could break out of or nest SQL comments: + /// - Newlines (could start new SQL statements) + /// - Comment sequences (/* */ --) + /// + /// Logs a warning when the name is modified. + private func sanitizeForSQLComment(_ name: String) -> String { + var result = name + // Replace newlines with spaces + result = result.replacingOccurrences(of: "\n", with: " ") + result = result.replacingOccurrences(of: "\r", with: " ") + // Remove comment sequences (both opening and closing) + result = result.replacingOccurrences(of: "/*", with: "") + result = result.replacingOccurrences(of: "*/", with: "") + result = result.replacingOccurrences(of: "--", with: "") + + // Log when sanitization modifies the name + if result != name { + print("Warning: Table name '\(name)' was sanitized to '\(result)' for SQL comment safety") + } + + return result + } + + // MARK: - File Helpers + + /// Create a file at the given URL and return a FileHandle for writing + private func createFileHandle(at url: URL) throws -> FileHandle { + guard FileManager.default.createFile(atPath: url.path, contents: nil) else { + throw ExportError.fileWriteFailed(url.path) + } + return try FileHandle(forWritingTo: url) + } + + /// Close a file handle with error logging instead of silent suppression + /// + /// Used in defer blocks where we can't throw but want visibility into failures. + private func closeFileHandle(_ handle: FileHandle) { + do { + try handle.close() + } catch { + print("Warning: Failed to close export file handle: \(error.localizedDescription)") + } + } + + // MARK: - CSV Export + + private func exportToCSV( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + // Create file and get handle for streaming writes + let fileHandle = try createFileHandle(at: url) + defer { closeFileHandle(fileHandle) } + + let lineBreak = config.csvOptions.lineBreak.value + + for (index, table) in tables.enumerated() { + try checkCancellation() + + currentTableIndex = index + 1 + currentTable = table.qualifiedName + + // Add table header comment if multiple tables + // Sanitize name to prevent newlines from breaking the comment line + if tables.count > 1 { + let sanitizedName = sanitizeForSQLComment(table.qualifiedName) + try fileHandle.write(contentsOf: "# Table: \(sanitizedName)\n".toUTF8Data()) + } + + // Fetch data from table in batches to avoid loading everything into memory + let tableRef = qualifiedTableRef(for: table) + let batchSize = 10_000 + var offset = 0 + var isFirstBatch = true + + while true { + try checkCancellation() + + let query = "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" + let result = try await driver.execute(query: query) + + // No more rows to process + if result.rows.isEmpty { + break + } + + // Stream CSV content for this batch directly to file + // Only include headers on the first batch to avoid duplication + var batchOptions = config.csvOptions + if !isFirstBatch { + batchOptions.includeFieldNames = false + } + + try await writeCSVContentWithProgress( + columns: result.columns, + rows: result.rows, + options: batchOptions, + to: fileHandle + ) + + isFirstBatch = false + offset += batchSize + } + if index < tables.count - 1 { + try fileHandle.write(contentsOf: "\(lineBreak)\(lineBreak)".toUTF8Data()) + } + } + + try checkCancellation() + progress = 1.0 + } + + private func writeCSVContentWithProgress( + columns: [String], + rows: [[String?]], + options: CSVExportOptions, + to fileHandle: FileHandle + ) async throws { + let delimiter = options.delimiter.actualValue + let lineBreak = options.lineBreak.value + + // Header row + if options.includeFieldNames { + let headerLine = columns + .map { escapeCSVField($0, options: options) } + .joined(separator: delimiter) + try fileHandle.write(contentsOf: (headerLine + lineBreak).toUTF8Data()) + } + + // Data rows with progress tracking - stream directly to file + for row in rows { + try checkCancellation() + + let rowLine = row.map { value -> String in + guard let val = value else { + return options.convertNullToEmpty ? "" : "NULL" + } + + var processed = val + + // Check for line breaks BEFORE converting them (for quote detection) + let hadLineBreaks = val.contains("\n") || val.contains("\r") + + // Convert line breaks to space + if options.convertLineBreakToSpace { + processed = processed + .replacingOccurrences(of: "\r\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + } + + // Handle decimal format + if options.decimalFormat == .comma, + processed.range(of: #"^[+-]?\d+\.\d+$"#, options: .regularExpression) != nil { + processed = processed.replacingOccurrences(of: ".", with: ",") + } + + return escapeCSVField(processed, options: options, originalHadLineBreaks: hadLineBreaks) + }.joined(separator: delimiter) + + // Write row directly to file + try fileHandle.write(contentsOf: (rowLine + lineBreak).toUTF8Data()) + + // Update progress (throttled) + await incrementProgress() + } + + // Ensure final count is shown + await finalizeTableProgress() + } + + /// Escape and quote a CSV field according to the specified options + /// - Parameters: + /// - field: The field value to escape + /// - options: CSV export options + /// - originalHadLineBreaks: Whether the original value had line breaks before conversion. + /// Used for proper quote detection when convertLineBreakToSpace is enabled. + private func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> String { + var processed = field + + // Sanitize formula-like prefixes to prevent CSV formula injection + // Values starting with these characters can be executed as formulas in Excel/LibreOffice + if options.sanitizeFormulas { + let dangerousPrefixes: [Character] = ["=", "+", "-", "@", "\t", "\r"] + if let first = processed.first, dangerousPrefixes.contains(first) { + // Prefix with single quote - Excel/LibreOffice treats this as text + processed = "'" + processed + } + } + + switch options.quoteHandling { + case .always: + let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + case .never: + return processed + case .asNeeded: + // Check current content for special characters, OR if original had line breaks + // (important when convertLineBreakToSpace is enabled - original line breaks + // mean the field should still be quoted even after conversion to spaces) + let needsQuotes = processed.contains(options.delimiter.actualValue) || + processed.contains("\"") || + processed.contains("\n") || + processed.contains("\r") || + originalHadLineBreaks + if needsQuotes { + let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + return processed + } + } + + // MARK: - JSON Export + + private func exportToJSON( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + // Stream JSON directly to file to minimize memory usage + let fileHandle = try createFileHandle(at: url) + defer { closeFileHandle(fileHandle) } + + let prettyPrint = config.jsonOptions.prettyPrint + let indent = prettyPrint ? " " : "" + let newline = prettyPrint ? "\n" : "" + + // Opening brace + try fileHandle.write(contentsOf: "{\(newline)".toUTF8Data()) + + for (tableIndex, table) in tables.enumerated() { + try checkCancellation() + + currentTableIndex = tableIndex + 1 + currentTable = table.qualifiedName + + let tableRef = qualifiedTableRef(for: table) + + // Write table key and opening bracket + let escapedTableName = escapeJSONString(table.qualifiedName) + try fileHandle.write(contentsOf: "\(indent)\"\(escapedTableName)\": [\(newline)".toUTF8Data()) + + // Stream rows in batches to avoid loading the entire table into memory + let batchSize = 1000 + var offset = 0 + var hasWrittenRow = false + var columns: [String]? = nil + + batchLoop: while true { + try checkCancellation() + + let result = try await driver.execute( + query: "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" + ) + + if result.rows.isEmpty { + break batchLoop + } + + if columns == nil { + columns = result.columns + } + + for row in result.rows { + try checkCancellation() + + // Stream JSON row object directly to file to avoid building large strings in memory + let rowPrefix = prettyPrint ? "\(indent)\(indent)" : "" + + // Write comma/newline before every row except the first + if hasWrittenRow { + try fileHandle.write(contentsOf: ",\(newline)".toUTF8Data()) + } + + // Write row prefix and opening brace + try fileHandle.write(contentsOf: rowPrefix.toUTF8Data()) + try fileHandle.write(contentsOf: "{".toUTF8Data()) + + if let columns = columns { + var isFirstField = true + for (colIndex, column) in columns.enumerated() { + if colIndex < row.count { + let value = row[colIndex] + if config.jsonOptions.includeNullValues || value != nil { + if !isFirstField { + try fileHandle.write(contentsOf: ", ".toUTF8Data()) + } + isFirstField = false + + let escapedKey = escapeJSONString(column) + let jsonValue = formatJSONValue( + value, + preserveAsString: config.jsonOptions.preserveAllAsStrings + ) + try fileHandle.write(contentsOf: "\"\(escapedKey)\": \(jsonValue)".toUTF8Data()) + } + } + } + } + + // Close row object + try fileHandle.write(contentsOf: "}".toUTF8Data()) + + hasWrittenRow = true + + // Update progress (throttled) + await incrementProgress() + } + + offset += result.rows.count + } + + // Ensure final count is shown for this table + await finalizeTableProgress() + + // Close array + if hasWrittenRow { + try fileHandle.write(contentsOf: newline.toUTF8Data()) + } + let tableSuffix = tableIndex < tables.count - 1 ? ",\(newline)" : newline + try fileHandle.write(contentsOf: "\(indent)]\(tableSuffix)".toUTF8Data()) + } + + // Closing brace + try fileHandle.write(contentsOf: "}".toUTF8Data()) + + try checkCancellation() + progress = 1.0 + } + + /// Escape a string for JSON output per RFC 8259 + /// + /// Escapes: + /// - Quotation mark, backslash (required) + /// - Control characters U+0000 to U+001F (required by spec) + private func escapeJSONString(_ string: String) -> String { + var result = "" + result.reserveCapacity(string.count) + for char in string { + switch char { + case "\"": result += "\\\"" + case "\\": result += "\\\\" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + case "\u{08}": result += "\\b" // Backspace + case "\u{0C}": result += "\\f" // Form feed + default: + // Escape other control characters (U+0000 to U+001F) as \uXXXX + if let scalar = char.unicodeScalars.first, + scalar.value < 0x20 { + result += String(format: "\\u%04X", scalar.value) + } else { + result.append(char) + } + } + } + return result + } + + /// Format a value for JSON output with optional type detection + /// + /// - Parameters: + /// - value: The value to format + /// - preserveAsString: If true, always output as string without type detection + /// (preserves leading zeros in ZIP codes, phone numbers, etc.) + /// + /// - Note: When type detection is enabled (preserveAsString = false), integers beyond + /// JavaScript's Number.MAX_SAFE_INTEGER (2^53-1 = 9007199254740991) may lose precision + /// when parsed by JavaScript. For large IDs or precise numeric data, enable the + /// "Preserve All Values as Strings" option in export settings. + private func formatJSONValue(_ value: String?, preserveAsString: Bool) -> String { + guard let val = value else { return "null" } + + // If preserving all as strings, skip type detection + if preserveAsString { + return "\"\(escapeJSONString(val))\"" + } + + // Try to detect numbers and booleans + // Note: Large integers (> 2^53-1) may lose precision in JavaScript consumers + if let intVal = Int(val) { + return String(intVal) + } + if let doubleVal = Double(val), !val.contains("e") && !val.contains("E") { + // Avoid scientific notation issues + let jsMaxSafeInteger = 9007199254740991.0 // 2^53 - 1, JavaScript's Number.MAX_SAFE_INTEGER + + if doubleVal.truncatingRemainder(dividingBy: 1) == 0 && !val.contains(".") { + // For integral values, only convert to Int when within both Int and JS safe integer bounds + if abs(doubleVal) <= jsMaxSafeInteger, + doubleVal >= Double(Int.min), + doubleVal <= Double(Int.max) { + return String(Int(doubleVal)) + } else { + // Preserve original integral representation to avoid scientific notation / precision changes + return val + } + } + return String(doubleVal) + } + if val.lowercased() == "true" || val.lowercased() == "false" { + return val.lowercased() + } + + // String value - escape and quote + return "\"\(escapeJSONString(val))\"" + } + + // MARK: - SQL Export + + private func exportToSQL( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + // For gzip, write to temp file first then compress + // For non-gzip, stream directly to destination + let targetURL: URL + let tempFileURL: URL? + + if config.sqlOptions.compressWithGzip { + tempFileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".sql") + targetURL = tempFileURL! + } else { + tempFileURL = nil + targetURL = url + } + + // Create file and get handle for streaming writes + let fileHandle = try createFileHandle(at: targetURL) + + do { + // Add header comment + let dateFormatter = ISO8601DateFormatter() + try fileHandle.write(contentsOf: "-- TablePro SQL Export\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Generated: \(dateFormatter.string(from: Date()))\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Database Type: \(databaseType.rawValue)\n\n".toUTF8Data()) + + for (index, table) in tables.enumerated() { + try checkCancellation() + + currentTableIndex = index + 1 + currentTable = table.qualifiedName + + let sqlOptions = table.sqlOptions + let tableRef = qualifiedTableRef(for: table) + + let sanitizedName = sanitizeForSQLComment(table.qualifiedName) + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Table: \(sanitizedName)\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n\n".toUTF8Data()) + + // DROP statement + if sqlOptions.includeDrop { + try fileHandle.write(contentsOf: "DROP TABLE IF EXISTS \(tableRef);\n\n".toUTF8Data()) + } + + // CREATE TABLE (structure) + if sqlOptions.includeStructure { + do { + let ddl = try await driver.fetchTableDDL(table: tableRef) + try fileHandle.write(contentsOf: ddl.toUTF8Data()) + if !ddl.hasSuffix(";") { + try fileHandle.write(contentsOf: ";".toUTF8Data()) + } + try fileHandle.write(contentsOf: "\n\n".toUTF8Data()) + } catch { + // Track the failure for user notification + ddlFailures.append(sanitizedName) + + // Use sanitizedName (already defined above) for safe comment output + let ddlWarning = "Warning: failed to fetch DDL for table \(sanitizedName): \(error)" + print(ddlWarning) + try fileHandle.write(contentsOf: "-- \(sanitizeForSQLComment(ddlWarning))\n\n".toUTF8Data()) + } + } + + // INSERT statements (data) - stream directly to file in batches + if sqlOptions.includeData { + let batchSize = config.sqlOptions.batchSize + var offset = 0 + var wroteAnyRows = false + + while true { + try checkCancellation() + + let query = "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" + let result = try await driver.execute(query: query) + + if result.rows.isEmpty { + break + } + + try await writeInsertStatementsWithProgress( + table: table, + columns: result.columns, + rows: result.rows, + batchSize: batchSize, + to: fileHandle + ) + + wroteAnyRows = true + offset += batchSize + } + + if wroteAnyRows { + try fileHandle.write(contentsOf: "\n".toUTF8Data()) + } + } + } + + try fileHandle.close() + } catch { + closeFileHandle(fileHandle) + if let tempURL = tempFileURL { + try? FileManager.default.removeItem(at: tempURL) + } + throw error + } + + // Handle gzip compression + if config.sqlOptions.compressWithGzip, let tempURL = tempFileURL { + statusMessage = "Compressing..." + await Task.yield() + + do { + defer { + // Always remove the temporary file, regardless of success or failure + try? FileManager.default.removeItem(at: tempURL) + } + + try await compressFileToFile(source: tempURL, destination: url) + } catch { + // Remove the (possibly partially written) destination file on compression failure + try? FileManager.default.removeItem(at: url) + throw error + } + } + + // Surface DDL failures to user as a warning + if !ddlFailures.isEmpty { + let failedTables = ddlFailures.joined(separator: ", ") + warningMessage = "Export completed with warnings: Could not fetch table structure for: \(failedTables)" + } + + progress = 1.0 + } + + private func writeInsertStatementsWithProgress( + table: ExportTableItem, + columns: [String], + rows: [[String?]], + batchSize: Int, + to fileHandle: FileHandle + ) async throws { + let tableRef = qualifiedTableRef(for: table) + let quotedColumns = columns + .map { databaseType.quoteIdentifier($0) } + .joined(separator: ", ") + + let insertPrefix = "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES\n" + + // Effective batch size (<=1 means no batching, one row per INSERT) + let effectiveBatchSize = batchSize <= 1 ? 1 : batchSize + var valuesBatch: [String] = [] + valuesBatch.reserveCapacity(effectiveBatchSize) + + for row in rows { + try checkCancellation() + + let values = row.map { value -> String in + guard let val = value else { return "NULL" } + // Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.) + let escaped = SQLEscaping.escapeStringLiteral(val) + return "'\(escaped)'" + }.joined(separator: ", ") + + valuesBatch.append(" (\(values))") + + // Write batch when full + if valuesBatch.count >= effectiveBatchSize { + let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" + try fileHandle.write(contentsOf: statement.toUTF8Data()) + valuesBatch.removeAll(keepingCapacity: true) + } + + // Update progress (throttled) + await incrementProgress() + } + + // Write remaining rows in final batch + if !valuesBatch.isEmpty { + let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" + try fileHandle.write(contentsOf: statement.toUTF8Data()) + } + + // Ensure final count is shown + await finalizeTableProgress() + } + + // MARK: - Compression + + private func compressFileToFile(source: URL, destination: URL) async throws { + // Run compression on background thread to avoid blocking main thread + try await Task.detached(priority: .userInitiated) { + // Pre-flight check: verify gzip is available + let gzipPath = "/usr/bin/gzip" + guard FileManager.default.isExecutableFile(atPath: gzipPath) else { + throw ExportError.exportFailed( + "Compression unavailable: gzip not found at \(gzipPath). " + + "Please install gzip or disable compression in export options." + ) + } + + // Create output file + guard FileManager.default.createFile(atPath: destination.path, contents: nil) else { + throw ExportError.fileWriteFailed(destination.path) + } + + // Use gzip to compress the file + let process = Process() + process.executableURL = URL(fileURLWithPath: gzipPath) + + // Derive a sanitized, non-encoded filesystem path for the source + let sanitizedSourcePath = source.standardizedFileURL.path(percentEncoded: false) + + // Basic validation to avoid passing obviously malformed paths to the process + if sanitizedSourcePath.contains("\0") || + sanitizedSourcePath.contains(where: { $0.isNewline }) { + throw ExportError.exportFailed("Invalid source path for compression") + } + + process.arguments = ["-c", sanitizedSourcePath] + let outputFile = try FileHandle(forWritingTo: destination) + defer { + try? outputFile.close() + } + process.standardOutput = outputFile + + // Capture stderr to provide detailed error messages on failure + let errorPipe = Pipe() + process.standardError = errorPipe + + try process.run() + process.waitUntilExit() + + let status = process.terminationStatus + guard status == 0 else { + // Explicitly close the file handle before throwing to ensure + // the destination file can be deleted in the error handler + try? outputFile.close() + + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorString = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + let message: String + if errorString.isEmpty { + message = "Compression failed with exit status \(status)" + } else { + message = "Compression failed with exit status \(status): \(errorString)" + } + + throw ExportError.exportFailed(message) + } + }.value + } +} diff --git a/TablePro/Models/ExportModels.swift b/TablePro/Models/ExportModels.swift new file mode 100644 index 000000000..f25fefd5d --- /dev/null +++ b/TablePro/Models/ExportModels.swift @@ -0,0 +1,247 @@ +// +// ExportModels.swift +// TablePro +// +// Models for table export functionality. +// Supports CSV, JSON, and SQL export formats with configurable options. +// + +import Foundation + +// MARK: - Export Format + +/// Supported export file formats +enum ExportFormat: String, CaseIterable, Identifiable { + case csv = "CSV" + case json = "JSON" + case sql = "SQL" + + var id: String { rawValue } + + /// File extension for this format + var fileExtension: String { + switch self { + case .csv: return "csv" + case .json: return "json" + case .sql: return "sql" + } + } +} + +// MARK: - CSV Options + +/// CSV field delimiter options +enum CSVDelimiter: String, CaseIterable, Identifiable { + case comma = "," + case semicolon = ";" + case tab = "\\t" + case pipe = "|" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .comma: return "," + case .semicolon: return ";" + case .tab: return "\\t" + case .pipe: return "|" + } + } + + /// Actual character(s) to use as delimiter + var actualValue: String { + self == .tab ? "\t" : rawValue + } +} + +/// CSV field quoting behavior +enum CSVQuoteHandling: String, CaseIterable, Identifiable { + case always = "Always" + case asNeeded = "Quote if needed" + case never = "Never" + + var id: String { rawValue } +} + +/// Line break format for CSV export +enum CSVLineBreak: String, CaseIterable, Identifiable { + case lf = "\\n" + case crlf = "\\r\\n" + case cr = "\\r" + + var id: String { rawValue } + + /// Actual line break characters + var value: String { + switch self { + case .lf: return "\n" + case .crlf: return "\r\n" + case .cr: return "\r" + } + } +} + +/// Decimal separator format +enum CSVDecimalFormat: String, CaseIterable, Identifiable { + case period = "." + case comma = "," + + var id: String { rawValue } + + var separator: String { rawValue } +} + +/// Options for CSV export +struct CSVExportOptions: Equatable { + var convertNullToEmpty: Bool = true + var convertLineBreakToSpace: Bool = false + var includeFieldNames: Bool = true + var delimiter: CSVDelimiter = .comma + var quoteHandling: CSVQuoteHandling = .asNeeded + var lineBreak: CSVLineBreak = .lf + var decimalFormat: CSVDecimalFormat = .period + /// Sanitize formula-like values to prevent CSV formula injection attacks. + /// When enabled, values starting with =, +, -, @, tab, or carriage return + /// are prefixed with a single quote to prevent execution in spreadsheet applications. + var sanitizeFormulas: Bool = true +} + +// MARK: - JSON Options + +/// Options for JSON export +struct JSONExportOptions: Equatable { + var prettyPrint: Bool = true + var includeNullValues: Bool = true + /// When enabled, all values are exported as strings without type detection. + /// This preserves leading zeros in ZIP codes, phone numbers, and similar data. + var preserveAllAsStrings: Bool = false +} + +// MARK: - SQL Options + +/// Per-table SQL export options (Structure, Drop, Data checkboxes) +struct SQLTableExportOptions: Equatable { + var includeStructure: Bool = true + var includeDrop: Bool = true + var includeData: Bool = true + + /// Returns true if at least one export option is enabled + var hasAnyOption: Bool { + includeStructure || includeDrop || includeData + } +} + +/// Global options for SQL export +struct SQLExportOptions: Equatable { + var compressWithGzip: Bool = false + /// Number of rows per INSERT statement. Default 500. + /// Higher values = fewer statements, smaller file, faster import. + /// Set to 1 for single-row INSERT statements (legacy behavior). + var batchSize: Int = 500 +} + +// MARK: - Export Configuration + +/// Complete export configuration combining format, selection, and options +struct ExportConfiguration { + var format: ExportFormat = .csv + var fileName: String = "export" + var csvOptions: CSVExportOptions = CSVExportOptions() + var jsonOptions: JSONExportOptions = JSONExportOptions() + var sqlOptions: SQLExportOptions = SQLExportOptions() + + /// Full file name including extension + var fullFileName: String { + let ext = compressedExtension ?? format.fileExtension + return "\(fileName).\(ext)" + } + + private var compressedExtension: String? { + if format == .sql && sqlOptions.compressWithGzip { + return "sql.gz" + } + return nil + } +} + +// MARK: - Tree View Models + +/// Represents a table item in the export tree view +struct ExportTableItem: Identifiable, Hashable { + let id: UUID + let name: String + let databaseName: String + let type: TableInfo.TableType + var isSelected: Bool = false + var sqlOptions: SQLTableExportOptions = SQLTableExportOptions() + + init( + id: UUID = UUID(), + name: String, + databaseName: String = "", + type: TableInfo.TableType, + isSelected: Bool = false, + sqlOptions: SQLTableExportOptions = SQLTableExportOptions() + ) { + self.id = id + self.name = name + self.databaseName = databaseName + self.type = type + self.isSelected = isSelected + self.sqlOptions = sqlOptions + } + + /// Fully qualified table name (database.table) + var qualifiedName: String { + databaseName.isEmpty ? name : "\(databaseName).\(name)" + } + + // Hashable conformance excluding mutable state + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: ExportTableItem, rhs: ExportTableItem) -> Bool { + lhs.id == rhs.id + } +} + +/// Represents a database item in the export tree view (contains tables) +struct ExportDatabaseItem: Identifiable { + let id: UUID + let name: String + var tables: [ExportTableItem] + var isExpanded: Bool = true + + init( + id: UUID = UUID(), + name: String, + tables: [ExportTableItem], + isExpanded: Bool = true + ) { + self.id = id + self.name = name + self.tables = tables + self.isExpanded = isExpanded + } + + /// Number of selected tables + var selectedCount: Int { + tables.filter { $0.isSelected }.count + } + + /// Whether all tables are selected + var allSelected: Bool { + !tables.isEmpty && tables.allSatisfy { $0.isSelected } + } + + /// Whether no tables are selected + var noneSelected: Bool { + tables.allSatisfy { !$0.isSelected } + } + + /// Get all selected table items + var selectedTables: [ExportTableItem] { + tables.filter { $0.isSelected } + } +} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index be08be432..2fbcd69b4 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -185,6 +185,14 @@ struct TableProApp: App { } .keyboardShortcut("r", modifiers: .command) .disabled(!appState.isConnected) + + Divider() + + Button("Export...") { + NotificationCenter.default.post(name: .exportTables, object: nil) + } + .keyboardShortcut("e", modifiers: [.command, .shift]) + .disabled(!appState.isConnected) } // Edit menu - Undo/Redo (smart handling for both text editor and data grid) @@ -319,6 +327,9 @@ extension Notification.Name { // Table creation notifications static let createTable = Notification.Name("createTable") + // Export notifications + static let exportTables = Notification.Name("exportTables") + // Window lifecycle notifications static let mainWindowWillClose = Notification.Name("mainWindowWillClose") } diff --git a/TablePro/Views/Export/ExportCSVOptionsView.swift b/TablePro/Views/Export/ExportCSVOptionsView.swift new file mode 100644 index 000000000..fbac5b1a7 --- /dev/null +++ b/TablePro/Views/Export/ExportCSVOptionsView.swift @@ -0,0 +1,106 @@ +// +// ExportCSVOptionsView.swift +// TablePro +// +// Options panel for CSV export format. +// Provides controls for delimiter, quoting, NULL handling, and formatting. +// + +import SwiftUI + +/// Options panel for CSV export +struct ExportCSVOptionsView: View { + @Binding var options: CSVExportOptions + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + // Checkboxes section + VStack(alignment: .leading, spacing: 8) { + Toggle("Convert NULL to EMPTY", isOn: $options.convertNullToEmpty) + .toggleStyle(.checkbox) + + Toggle("Convert line break to space", isOn: $options.convertLineBreakToSpace) + .toggleStyle(.checkbox) + + Toggle("Put field names in the first row", isOn: $options.includeFieldNames) + .toggleStyle(.checkbox) + + Toggle("Sanitize formula-like values", isOn: $options.sanitizeFormulas) + .toggleStyle(.checkbox) + .help("Prevent CSV formula injection by prefixing values starting with =, +, -, @ with a single quote") + } + + Divider() + .padding(.vertical, 4) + + // Dropdowns section + VStack(alignment: .leading, spacing: 10) { + optionRow("Delimiter") { + Picker("", selection: $options.delimiter) { + ForEach(CSVDelimiter.allCases) { delimiter in + Text(delimiter.displayName).tag(delimiter) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 140, alignment: .trailing) + } + + optionRow("Quote") { + Picker("", selection: $options.quoteHandling) { + ForEach(CSVQuoteHandling.allCases) { handling in + Text(handling.rawValue).tag(handling) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 140, alignment: .trailing) + } + + optionRow("Line break") { + Picker("", selection: $options.lineBreak) { + ForEach(CSVLineBreak.allCases) { lineBreak in + Text(lineBreak.rawValue).tag(lineBreak) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 140, alignment: .trailing) + } + + optionRow("Decimal") { + Picker("", selection: $options.decimalFormat) { + ForEach(CSVDecimalFormat.allCases) { format in + Text(format.rawValue).tag(format) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 140, alignment: .trailing) + } + } + } + .font(.system(size: 13)) + } + + private func optionRow( + _ label: String, + @ViewBuilder content: () -> Content + ) -> some View { + HStack { + Text(label) + .foregroundStyle(.primary) + .frame(width: 80, alignment: .leading) + Spacer() + content() + } + } +} + +// MARK: - Preview + +#Preview { + ExportCSVOptionsView(options: .constant(CSVExportOptions())) + .padding() + .frame(width: 280) +} diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift new file mode 100644 index 000000000..8217a8313 --- /dev/null +++ b/TablePro/Views/Export/ExportDialog.swift @@ -0,0 +1,688 @@ +// +// ExportDialog.swift +// TablePro +// +// Main export dialog for exporting tables to CSV, JSON, or SQL formats. +// Features a split layout with table selection tree on the left and format options on the right. +// + +import AppKit +import Combine +import SwiftUI +import UniformTypeIdentifiers + +/// Main export dialog view +struct ExportDialog: View { + @Binding var isPresented: Bool + let connection: DatabaseConnection + let preselectedTables: Set + + // MARK: - State + + @State private var config = ExportConfiguration() + @State private var databaseItems: [ExportDatabaseItem] = [] + @State private var isLoading = true + @State private var isExporting = false + @State private var showError = false + @State private var errorMessage = "" + @State private var showProgressDialog = false + @State private var showSuccessDialog = false + @State private var exportedFileURL: URL? + @State private var currentExportTable = "" + + // MARK: - User Preferences + + @AppStorage("hideExportSuccessDialog") private var hideSuccessDialog = false + + // MARK: - Export Service + + @StateObject private var exportServiceState = ExportServiceState() + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + // Content + HStack(spacing: 0) { + // Left: Table tree view + tableSelectionView + .frame(width: leftPanelWidth) + + Divider() + + // Right: Export options + exportOptionsView + .frame(width: 280) + } + .frame(height: 420) + + Divider() + + // Footer + footerView + } + .frame(width: dialogWidth) + .background(Color(nsColor: .windowBackgroundColor)) + .task { + await loadDatabaseItems() + } + .onExitCommand { + if !isExporting { + isPresented = false + } + } + .alert("Export Error", isPresented: $showError) { + Button("OK") { } + } message: { + Text(errorMessage) + } + .sheet(isPresented: $showProgressDialog) { + ExportProgressView( + tableName: exportServiceState.currentTable, + tableIndex: exportServiceState.currentTableIndex, + totalTables: exportServiceState.totalTables, + processedRows: exportServiceState.processedRows, + totalRows: exportServiceState.totalRows, + statusMessage: exportServiceState.statusMessage, + onStop: { + exportServiceState.service?.cancelExport() + } + ) + .interactiveDismissDisabled() + } + .sheet(isPresented: $showSuccessDialog) { + ExportSuccessView( + onOpenFolder: { + openContainingFolder() + showSuccessDialog = false + isPresented = false + }, + onClose: { + showSuccessDialog = false + isPresented = false + } + ) + } + } + + // MARK: - Layout Constants + + private var leftPanelWidth: CGFloat { + config.format == .sql ? 380 : 240 + } + + private var dialogWidth: CGFloat { + leftPanelWidth + 280 + } + + // MARK: - Table Selection View + + private var tableSelectionView: some View { + VStack(spacing: 0) { + // Header with title and selection count + HStack { + Text("Items") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + + Spacer() + + // Format-specific column headers for SQL + if config.format == .sql { + Text("Structure") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 56, alignment: .center) + + Text("Drop") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .center) + + Text("Data") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .center) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + // Tree view or loading indicator + if isLoading { + VStack { + Spacer() + ProgressView() + .scaleEffect(0.8) + Text("Loading databases...") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + .padding(.top, 8) + Spacer() + } + } else { + ExportTableTreeView( + databaseItems: $databaseItems, + format: config.format + ) + } + } + } + + // MARK: - Export Options View + + private var exportOptionsView: some View { + VStack(alignment: .leading, spacing: 0) { + // Format picker with selection count + VStack(alignment: .leading, spacing: 12) { + HStack { + Spacer() + + Picker("", selection: $config.format) { + ForEach(ExportFormat.allCases) { format in + Text(format.rawValue).tag(format) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 180) + + Spacer() + } + + // Selection count (shows exportable count for SQL format when some tables have no options) + VStack(spacing: 2) { + Text("\(exportableCount) table\(exportableCount == 1 ? "" : "s") to export") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + + // Show warning if some selected tables will be skipped (SQL format only) + if config.format == .sql && exportableCount < selectedCount { + Text("\(selectedCount - exportableCount) skipped (no options)") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.orange) + } + } + .frame(maxWidth: .infinity, alignment: .center) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + + Divider() + + // Format-specific options + ScrollView { + VStack(alignment: .leading, spacing: 0) { + switch config.format { + case .csv: + ExportCSVOptionsView(options: $config.csvOptions) + case .json: + ExportJSONOptionsView(options: $config.jsonOptions) + case .sql: + ExportSQLOptionsView(options: $config.sqlOptions) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + Spacer(minLength: 0) + + Divider() + + // File name section + VStack(alignment: .leading, spacing: 6) { + Text("File name") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + + HStack(spacing: 4) { + TextField("export", text: $config.fileName) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.body)) + + Text(".\(fileExtension)") + .foregroundStyle(.secondary) + .font(.system(size: DesignConstants.FontSize.body, design: .monospaced)) + .lineLimit(1) + .fixedSize() + } + + // Show validation error if filename is invalid + if let validationError = fileNameValidationError { + Text(validationError) + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.red) + } + } + .padding(16) + } + } + + // MARK: - Footer + + private var footerView: some View { + HStack { + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.cancelAction) + .disabled(isExporting) + + Spacer() + + if isExporting { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.7) + + Text(currentExportTable) + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 120) + } + } + + Button("Export...") { + performExport() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: []) + .disabled(exportableCount == 0 || isExporting || !isFileNameValid) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + // MARK: - Computed Properties + + private var selectedCount: Int { + databaseItems.reduce(0) { $0 + $1.selectedCount } + } + + private var selectedTables: [ExportTableItem] { + databaseItems.flatMap { $0.selectedTables } + } + + /// Tables that will actually be exported (filters out SQL tables with no options enabled) + private var exportableTables: [ExportTableItem] { + let tables = selectedTables + // For SQL format, filter out tables with all options disabled (no output would be generated) + if config.format == .sql { + return tables.filter { $0.sqlOptions.hasAnyOption } + } + return tables + } + + /// Count of tables that will actually produce output + private var exportableCount: Int { + exportableTables.count + } + + private var fileExtension: String { + if config.format == .sql && config.sqlOptions.compressWithGzip { + return "sql.gz" + } + return config.format.fileExtension + } + + /// Windows reserved device names (case-insensitive) + private static let windowsReservedNames: Set = [ + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + ] + + /// Returns a validation error message if the filename is invalid, nil if valid + private var fileNameValidationError: String? { + let name = config.fileName.trimmingCharacters(in: .whitespaces) + + if name.isEmpty { + return "Filename cannot be empty" + } + + // Invalid filesystem characters (covers macOS, Windows, and Linux) + let invalidChars = CharacterSet(charactersIn: "/\\:*?\"<>|") + if name.rangeOfCharacter(from: invalidChars) != nil { + return "Filename contains invalid characters: / \\ : * ? \" < > |" + } + + // Prevent path traversal attempts and special directory names + if name == "." || name == ".." || + name.hasPrefix("../") || name.hasPrefix("..\\") || + name.hasSuffix("/..") || name.hasSuffix("\\..") || + name.contains("/../") || name.contains("\\..\\") { + return "Filename cannot be '.' or '..' or contain path traversal" + } + + // Check for Windows reserved device names (case-insensitive) + let baseName = name.components(separatedBy: ".").first ?? name + if Self.windowsReservedNames.contains(baseName.uppercased()) { + return "'\(baseName)' is a reserved Windows device name" + } + + // Check filename length (255 bytes is common limit on most filesystems) + if name.utf8.count > 255 { + return "Filename is too long (max 255 bytes)" + } + + return nil + } + + /// Validates that the filename is not empty and contains no invalid filesystem characters + private var isFileNameValid: Bool { + fileNameValidationError == nil + } + + // MARK: - Actions + + @MainActor + private func loadDatabaseItems() async { + guard let driver = DatabaseManager.shared.activeDriver else { + isLoading = false + errorMessage = "Not connected to database" + showError = true + return + } + + do { + var items: [ExportDatabaseItem] = [] + + switch connection.type { + case .postgresql: + // PostgreSQL: fetch schemas within current database (can't query across databases) + let schemas = try await fetchPostgreSQLSchemas(driver: driver) + for schema in schemas { + let tables = try await fetchTablesForSchema(schema, driver: driver) + let tableItems = tables.map { table in + ExportTableItem( + name: table.name, + databaseName: schema, // schema name for PostgreSQL + type: table.type, + isSelected: schema == "public" && preselectedTables.contains(table.name) + ) + } + if !tableItems.isEmpty { + items.append(ExportDatabaseItem( + name: schema, + tables: tableItems, + isExpanded: schema == "public" + )) + } + } + // Sort: public schema first + items.sort { item1, item2 in + if item1.name == "public" { return true } + if item2.name == "public" { return false } + return item1.name < item2.name + } + + case .sqlite: + // SQLite: only one database, fetch tables directly + let tables = try await driver.fetchTables() + let tableItems = tables.map { table in + ExportTableItem( + name: table.name, + databaseName: "", + type: table.type, + isSelected: preselectedTables.contains(table.name) + ) + } + if !tableItems.isEmpty { + items.append(ExportDatabaseItem( + name: connection.database.isEmpty ? "main" : connection.database, + tables: tableItems, + isExpanded: true + )) + } + + case .mysql, .mariadb: + // MySQL/MariaDB: fetch all databases and their tables + let databases = try await driver.fetchDatabases() + for dbName in databases { + let tables = try await fetchTablesForDatabase(dbName, driver: driver) + let tableItems = tables.map { table in + ExportTableItem( + name: table.name, + databaseName: dbName, + type: table.type, + isSelected: dbName == connection.database && preselectedTables.contains(table.name) + ) + } + if !tableItems.isEmpty { + items.append(ExportDatabaseItem( + name: dbName, + tables: tableItems, + isExpanded: dbName == connection.database + )) + } + } + // Sort: current database first + items.sort { item1, item2 in + if item1.name == connection.database { return true } + if item2.name == connection.database { return false } + return item1.name < item2.name + } + } + + databaseItems = items + isLoading = false + + // Set default filename based on selection + if preselectedTables.count == 1, let first = preselectedTables.first { + config.fileName = first + } else if !connection.database.isEmpty { + config.fileName = connection.database + } + + } catch { + isLoading = false + errorMessage = "Failed to load databases: \(error.localizedDescription)" + showError = true + } + } + + private func fetchPostgreSQLSchemas(driver: DatabaseDriver) async throws -> [String] { + let query = """ + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ORDER BY schema_name + """ + let result = try await driver.execute(query: query) + return result.rows.compactMap { $0[0] } + } + + private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] { + // Fetch tables from information_schema and filter by schema in Swift to avoid SQL interpolation. + let query = """ + SELECT table_schema, table_name, table_type + FROM information_schema.tables + ORDER BY table_name + """ + let result = try await driver.execute(query: query) + return result.rows.compactMap { row in + // Expect: [table_schema, table_name, table_type] + guard row.count >= 2, + let rowSchema = row[0], + rowSchema == schema, + let name = row[1] else { + return nil + } + let typeStr = row.count > 2 ? (row[2] ?? "BASE TABLE") : "BASE TABLE" + let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table + return TableInfo(name: name, type: type, rowCount: nil) + } + } + + private func fetchTablesForDatabase(_ database: String, driver: DatabaseDriver) async throws -> [TableInfo] { + // Fetch tables from information_schema and filter by database in Swift to avoid SQL interpolation. + // MySQL/MariaDB: information_schema.TABLES contains TABLE_SCHEMA, TABLE_NAME, and TABLE_TYPE. + let query = """ + SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE + FROM information_schema.TABLES + ORDER BY TABLE_NAME + """ + let result = try await driver.execute(query: query) + + return result.rows.compactMap { row in + // Expect: [TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE] + guard row.count >= 2, + let rowSchema = row[0], + rowSchema == database, + let name = row[1] else { + return nil + } + let typeStr = row.count > 2 ? (row[2] ?? "BASE TABLE") : "BASE TABLE" + let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table + return TableInfo(name: name, type: type, rowCount: nil) + } + } + + private func performExport() { + // Show save panel + let savePanel = NSSavePanel() + savePanel.canCreateDirectories = true + savePanel.showsTagField = false + + // Configure allowed file types + if config.format == .sql && config.sqlOptions.compressWithGzip { + savePanel.allowedContentTypes = [UTType(filenameExtension: "gz") ?? .data] + savePanel.nameFieldStringValue = "\(config.fileName).sql.gz" + } else { + let utType = UTType(filenameExtension: config.format.fileExtension) ?? .plainText + savePanel.allowedContentTypes = [utType] + savePanel.nameFieldStringValue = config.fullFileName + } + + savePanel.message = "Export \(exportableCount) table(s) to \(config.format.rawValue)" + + savePanel.begin { response in + guard response == .OK, let url = savePanel.url else { return } + + Task { + await startExport(to: url) + } + } + } + + @MainActor + private func startExport(to url: URL) async { + guard let driver = DatabaseManager.shared.activeDriver else { + errorMessage = "Not connected to database" + showError = true + return + } + + isExporting = true + exportedFileURL = url + + let service = ExportService( + driver: driver, + databaseType: connection.type + ) + exportServiceState.service = service + + // Show progress dialog + showProgressDialog = true + + do { + try await service.export( + tables: exportableTables, + config: config, + to: url + ) + + // Export completed successfully + showProgressDialog = false + isExporting = false + + // Show success dialog or close directly based on preference + if hideSuccessDialog { + isPresented = false + } else { + showSuccessDialog = true + } + + } catch { + showProgressDialog = false + isExporting = false + errorMessage = error.localizedDescription + showError = true + } + } + + private func openContainingFolder() { + guard let url = exportedFileURL else { return } + NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: "") + } +} + +// MARK: - Export Service State + +/// Observable wrapper for ExportService to enable SwiftUI bindings +@MainActor +final class ExportServiceState: ObservableObject { + @Published var currentTable: String = "" + @Published var currentTableIndex: Int = 0 + @Published var totalTables: Int = 0 + @Published var processedRows: Int = 0 + @Published var totalRows: Int = 0 + @Published var statusMessage: String = "" + + private var cancellables = Set() + + var service: ExportService? { + didSet { + cancellables.removeAll() + guard let service = service else { return } + + service.$currentTable + .receive(on: DispatchQueue.main) + .assign(to: &$currentTable) + + service.$currentTableIndex + .receive(on: DispatchQueue.main) + .assign(to: &$currentTableIndex) + + service.$totalTables + .receive(on: DispatchQueue.main) + .assign(to: &$totalTables) + + service.$processedRows + .receive(on: DispatchQueue.main) + .assign(to: &$processedRows) + + service.$totalRows + .receive(on: DispatchQueue.main) + .assign(to: &$totalRows) + + service.$statusMessage + .receive(on: DispatchQueue.main) + .assign(to: &$statusMessage) + } + } +} + +// MARK: - Preview + +#Preview { + let connection = DatabaseConnection( + name: "Local MySQL", + host: "localhost", + database: "my_database", + type: .mysql + ) + + return ExportDialog( + isPresented: .constant(true), + connection: connection, + preselectedTables: ["users"] + ) +} diff --git a/TablePro/Views/Export/ExportJSONOptionsView.swift b/TablePro/Views/Export/ExportJSONOptionsView.swift new file mode 100644 index 000000000..ac4d721c2 --- /dev/null +++ b/TablePro/Views/Export/ExportJSONOptionsView.swift @@ -0,0 +1,37 @@ +// +// ExportJSONOptionsView.swift +// TablePro +// +// Options panel for JSON export format. +// Provides controls for formatting and NULL value handling. +// + +import SwiftUI + +/// Options panel for JSON export +struct ExportJSONOptionsView: View { + @Binding var options: JSONExportOptions + + var body: some View { + VStack(alignment: .leading, spacing: DesignConstants.Spacing.xs) { + Toggle("Pretty print (formatted output)", isOn: $options.prettyPrint) + .toggleStyle(.checkbox) + + Toggle("Include NULL values", isOn: $options.includeNullValues) + .toggleStyle(.checkbox) + + Toggle("Preserve all values as strings", isOn: $options.preserveAllAsStrings) + .toggleStyle(.checkbox) + .help("Keep leading zeros in ZIP codes, phone numbers, and IDs by outputting all values as strings") + } + .font(.system(size: DesignConstants.FontSize.body)) + } +} + +// MARK: - Preview + +#Preview { + ExportJSONOptionsView(options: .constant(JSONExportOptions())) + .padding() + .frame(width: 300) +} diff --git a/TablePro/Views/Export/ExportProgressView.swift b/TablePro/Views/Export/ExportProgressView.swift new file mode 100644 index 000000000..f7a513ee8 --- /dev/null +++ b/TablePro/Views/Export/ExportProgressView.swift @@ -0,0 +1,90 @@ +// +// ExportProgressView.swift +// TablePro +// +// Progress dialog shown during table export. +// Displays table name, row progress, progress bar, and stop button. +// + +import SwiftUI + +/// Progress dialog shown during export operation +struct ExportProgressView: View { + let tableName: String + let tableIndex: Int + let totalTables: Int + let processedRows: Int + let totalRows: Int + let statusMessage: String + let onStop: () -> Void + + var body: some View { + VStack(spacing: 20) { + // Title + Text(totalTables > 1 ? "Export multiple tables" : "Export table") + .font(.system(size: 15, weight: .semibold)) + + // Table info and row count + VStack(spacing: 8) { + HStack { + // Show status message if set, otherwise show table name + if !statusMessage.isEmpty { + Text(statusMessage) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } else { + Text("\(tableName) (\(tableIndex)/\(totalTables))") + .font(.system(size: 13)) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + if statusMessage.isEmpty { + Text("\(processedRows.formatted())/\(totalRows.formatted()) rows") + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + + // Progress bar - indeterminate when status message is shown + if !statusMessage.isEmpty { + ProgressView() + .progressViewStyle(.linear) + } else { + ProgressView(value: progressValue) + .progressViewStyle(.linear) + } + } + + // Stop button + Button("Stop") { + onStop() + } + .frame(width: 80) + } + .padding(24) + .frame(width: 400) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private var progressValue: Double { + guard totalRows > 0 else { return 0 } + return Double(processedRows) / Double(totalRows) + } +} + +// MARK: - Preview + +#Preview { + ExportProgressView( + tableName: "users", + tableIndex: 1, + totalTables: 3, + processedRows: 95500, + totalRows: 175787, + statusMessage: "", + onStop: {} + ) +} diff --git a/TablePro/Views/Export/ExportSQLOptionsView.swift b/TablePro/Views/Export/ExportSQLOptionsView.swift new file mode 100644 index 000000000..e8e704082 --- /dev/null +++ b/TablePro/Views/Export/ExportSQLOptionsView.swift @@ -0,0 +1,63 @@ +// +// ExportSQLOptionsView.swift +// TablePro +// +// Options panel for SQL export format. +// Note: Structure, Drop, and Data options are per-table (shown in tree view). +// This view only contains global options like compression. +// + +import SwiftUI + +/// Options panel for SQL export (global options only) +struct ExportSQLOptionsView: View { + @Binding var options: SQLExportOptions + + /// Available batch size options + private static let batchSizeOptions = [1, 100, 500, 1000] + + var body: some View { + VStack(alignment: .leading, spacing: DesignConstants.Spacing.xs) { + // Info text about per-table options + Text("Structure, Drop, and Data options are configured per table in the table list.") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Divider() + .padding(.vertical, DesignConstants.Spacing.xxs) + + // Batch size picker + HStack { + Text("Rows per INSERT") + .font(.system(size: DesignConstants.FontSize.body)) + + Spacer() + + Picker("", selection: $options.batchSize) { + ForEach(Self.batchSizeOptions, id: \.self) { size in + Text(size == 1 ? "1 (no batching)" : "\(size)") + .tag(size) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 130) + } + .help("Higher values create fewer INSERT statements, resulting in smaller files and faster imports") + + // Global compression option + Toggle("Compress the file using Gzip", isOn: $options.compressWithGzip) + .toggleStyle(.checkbox) + .font(.system(size: DesignConstants.FontSize.body)) + } + } +} + +// MARK: - Preview + +#Preview { + ExportSQLOptionsView(options: .constant(SQLExportOptions())) + .padding() + .frame(width: 300) +} diff --git a/TablePro/Views/Export/ExportSuccessView.swift b/TablePro/Views/Export/ExportSuccessView.swift new file mode 100644 index 000000000..c82552079 --- /dev/null +++ b/TablePro/Views/Export/ExportSuccessView.swift @@ -0,0 +1,76 @@ +// +// ExportSuccessView.swift +// TablePro +// +// Success dialog shown after export completes. +// Provides option to open containing folder in Finder. +// + +import SwiftUI + +/// Success dialog shown after export completes +struct ExportSuccessView: View { + let onOpenFolder: () -> Void + let onClose: () -> Void + + @AppStorage("hideExportSuccessDialog") private var dontShowAgain = false + @State private var localDontShowAgain = false + + init(onOpenFolder: @escaping () -> Void, onClose: @escaping () -> Void) { + self.onOpenFolder = onOpenFolder + self.onClose = onClose + } + var body: some View { + VStack(spacing: 20) { + // Success icon + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundStyle(.green) + + // Title and message + VStack(spacing: 6) { + Text("Success") + .font(.system(size: 15, weight: .semibold)) + + Text("Export completed successfully") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + + // Buttons + VStack(spacing: 10) { + Button("Open containing folder") { + onOpenFolder() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button("Close") { + if localDontShowAgain { + dontShowAgain = true + } + onClose() + } + .controlSize(.large) + } + + // Don't show again checkbox + Toggle("Don't show this again", isOn: $localDontShowAgain) + .toggleStyle(.checkbox) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .padding(24) + .frame(width: 300) + .background(Color(nsColor: .windowBackgroundColor)) + } +} + +// MARK: - Preview + +#Preview { + ExportSuccessView( + onOpenFolder: {}, + onClose: {} + ) +} diff --git a/TablePro/Views/Export/ExportTableTreeView.swift b/TablePro/Views/Export/ExportTableTreeView.swift new file mode 100644 index 000000000..80df54d0d --- /dev/null +++ b/TablePro/Views/Export/ExportTableTreeView.swift @@ -0,0 +1,156 @@ +// +// ExportTableTreeView.swift +// TablePro +// +// Tree view for selecting tables to export. +// Shows database hierarchy with checkbox selection. +// When SQL format is selected, displays additional columns for Structure, Drop, and Data options. +// + +import SwiftUI + +/// Tree view for selecting tables to export +struct ExportTableTreeView: View { + @Binding var databaseItems: [ExportDatabaseItem] + let format: ExportFormat + + var body: some View { + List { + ForEach($databaseItems) { $database in + DisclosureGroup(isExpanded: $database.isExpanded) { + ForEach($database.tables) { $table in + tableRow(table: $table) + } + } label: { + databaseRow(database: $database) + } + } + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + } + + // MARK: - Database Row + + private func databaseRow(database: Binding) -> some View { + HStack(spacing: 8) { + // Native tristate checkbox using sources binding + Toggle(sources: database.tables, isOn: \.isSelected) { + EmptyView() + } + .toggleStyle(.checkbox) + .labelsHidden() + + // Database icon + Image(systemName: "cylinder") + .foregroundStyle(.blue) + .font(.system(size: 12)) + + // Database name + Text(database.wrappedValue.name) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + // SQL-specific checkboxes placeholder (hidden for database row) + if format == .sql { + HStack(spacing: 0) { + Color.clear.frame(width: 56) + Color.clear.frame(width: 44) + Color.clear.frame(width: 44) + } + } + } + .contentShape(Rectangle()) + } + + // MARK: - Table Row + + private func tableRow(table: Binding) -> some View { + HStack(spacing: 8) { + // Selection checkbox + Toggle("", isOn: table.isSelected) + .toggleStyle(.checkbox) + .labelsHidden() + + // Table icon + Image(systemName: table.wrappedValue.type == .view ? "eye" : "tablecells") + .foregroundStyle(table.wrappedValue.type == .view ? .purple : .secondary) + .font(.system(size: 12)) + + // Table name + Text(table.wrappedValue.name) + .font(.system(size: 13, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + // SQL-specific checkboxes + if format == .sql { + HStack(spacing: 0) { + // Structure checkbox + Toggle("", isOn: table.sqlOptions.includeStructure) + .toggleStyle(.checkbox) + .labelsHidden() + .frame(width: 56, alignment: .center) + .disabled(!table.wrappedValue.isSelected) + + // Drop checkbox + Toggle("", isOn: table.sqlOptions.includeDrop) + .toggleStyle(.checkbox) + .labelsHidden() + .frame(width: 44, alignment: .center) + .disabled(!table.wrappedValue.isSelected) + + // Data checkbox + Toggle("", isOn: table.sqlOptions.includeData) + .toggleStyle(.checkbox) + .labelsHidden() + .frame(width: 44, alignment: .center) + .disabled(!table.wrappedValue.isSelected) + } + .opacity(table.wrappedValue.isSelected ? 1.0 : 0.4) + } + } + } + +} + +// MARK: - Preview + +#Preview("CSV Format") { + let tables = [ + ExportTableItem(name: "users", type: .table, isSelected: true), + ExportTableItem(name: "posts", type: .table, isSelected: false), + ExportTableItem(name: "comments", type: .table, isSelected: true), + ExportTableItem(name: "user_stats", type: .view, isSelected: false) + ] + + return ExportTableTreeView( + databaseItems: .constant([ + ExportDatabaseItem(name: "my_database", tables: tables) + ]), + format: .csv + ) + .frame(width: 240, height: 400) +} + +#Preview("SQL Format") { + let tables = [ + ExportTableItem(name: "users", type: .table, isSelected: true), + ExportTableItem(name: "posts", type: .table, isSelected: false), + ExportTableItem(name: "comments", type: .table, isSelected: true), + ExportTableItem(name: "user_stats", type: .view, isSelected: false) + ] + + return ExportTableTreeView( + databaseItems: .constant([ + ExportDatabaseItem(name: "my_database", tables: tables) + ]), + format: .sql + ) + .frame(width: 380, height: 400) +} diff --git a/TablePro/Views/Main/Child/MainContentAlerts.swift b/TablePro/Views/Main/Child/MainContentAlerts.swift index 524d59bbe..94248901c 100644 --- a/TablePro/Views/Main/Child/MainContentAlerts.swift +++ b/TablePro/Views/Main/Child/MainContentAlerts.swift @@ -20,6 +20,8 @@ struct MainContentAlerts: ViewModifier { @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set + let tables: [TableInfo] + let selectedTables: Set // MARK: - Environment @@ -55,6 +57,17 @@ struct MainContentAlerts: ViewModifier { .onChange(of: coordinator.showDatabaseSwitcher) { _, isPresented in appState.isSheetPresented = isPresented } + + .sheet(isPresented: $coordinator.showExportDialog) { + ExportDialog( + isPresented: $coordinator.showExportDialog, + connection: connection, + preselectedTables: Set(selectedTables.map { $0.name }) + ) + } + .onChange(of: coordinator.showExportDialog) { _, isPresented in + appState.isSheetPresented = isPresented + } } // MARK: - Computed Properties @@ -85,13 +98,17 @@ extension View { coordinator: MainContentCoordinator, connection: DatabaseConnection, pendingTruncates: Binding>, - pendingDeletes: Binding> + pendingDeletes: Binding>, + tables: [TableInfo], + selectedTables: Set ) -> some View { modifier(MainContentAlerts( coordinator: coordinator, connection: connection, pendingTruncates: pendingTruncates, - pendingDeletes: pendingDeletes + pendingDeletes: pendingDeletes, + tables: tables, + selectedTables: selectedTables )) } } diff --git a/TablePro/Views/Main/Child/QueryTabContentView.swift b/TablePro/Views/Main/Child/QueryTabContentView.swift index 0d3dbdd3c..f8d2df071 100644 --- a/TablePro/Views/Main/Child/QueryTabContentView.swift +++ b/TablePro/Views/Main/Child/QueryTabContentView.swift @@ -42,7 +42,8 @@ struct QueryTabContentView: View { let onLimitChange: (Int) -> Void let onOffsetChange: (Int) -> Void let onPaginationGo: () -> Void - + let onDismissError: () -> Void + @Binding var sortState: SortState @Binding var showStructure: Bool @@ -85,6 +86,7 @@ struct QueryTabContentView: View { onLimitChange: onLimitChange, onOffsetChange: onOffsetChange, onPaginationGo: onPaginationGo, + onDismissError: onDismissError, sortState: $sortState, showStructure: $showStructure ) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index e6dbdcb49..05886ede0 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -45,6 +45,7 @@ final class MainContentCoordinator: ObservableObject { @Published var pendingDiscardAction: DiscardAction? // Removed: showErrorAlert and errorAlertMessage - errors now display inline @Published var showDatabaseSwitcher = false + @Published var showExportDialog = false @Published var needsLazyLoad = false // MARK: - Internal State diff --git a/TablePro/Views/Main/MainContentNotificationHandler.swift b/TablePro/Views/Main/MainContentNotificationHandler.swift index a3e0ae6d4..5201a638e 100644 --- a/TablePro/Views/Main/MainContentNotificationHandler.swift +++ b/TablePro/Views/Main/MainContentNotificationHandler.swift @@ -314,6 +314,13 @@ final class MainContentNotificationHandler: ObservableObject { self?.handleSaveChanges() } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .exportTables) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExportTables() + } + .store(in: &cancellables) } private func handleRefreshData() { @@ -344,6 +351,10 @@ final class MainContentNotificationHandler: ObservableObject { tableOperationOptions.wrappedValue = options } + private func handleExportTables() { + coordinator?.showExportDialog = true + } + // MARK: - UI Operations private func setupUIOperationObservers() { diff --git a/TablePro/Views/MainContentView.swift b/TablePro/Views/MainContentView.swift index b4145d6bb..f06124552 100644 --- a/TablePro/Views/MainContentView.swift +++ b/TablePro/Views/MainContentView.swift @@ -92,7 +92,9 @@ struct MainContentView: View { coordinator: coordinator, connection: connection, pendingTruncates: $pendingTruncates, - pendingDeletes: $pendingDeletes + pendingDeletes: $pendingDeletes, + tables: tables, + selectedTables: selectedTables ) .task { await initializeAndRestoreTabs() } .onChange(of: tabManager.selectedTabId) { oldTabId, newTabId in diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 366a7771a..f0e788db8 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -244,7 +244,7 @@ struct SidebarView: View { ) .tag(table) .contextMenu { - tableContextMenu(for: table) + tableContextMenu(clickedTable: table) } } } header: { @@ -260,7 +260,7 @@ struct SidebarView: View { } .listStyle(.sidebar) .contextMenu { - sidebarContextMenu() + tableContextMenu() } .onDeleteCommand { batchToggleDelete() @@ -270,64 +270,55 @@ struct SidebarView: View { } } + /// Unified context menu for sidebar - used for both table rows and empty space + /// - Parameter clickedTable: The table that was right-clicked, nil if clicking empty space @ViewBuilder - private func tableContextMenu(for table: TableInfo) -> some View { + private func tableContextMenu(clickedTable: TableInfo? = nil) -> some View { + let hasSelection = !selectedTables.isEmpty || clickedTable != nil + Button("Create New Table...") { NotificationCenter.default.post(name: .createTable, object: nil) } - .keyboardShortcut("n", modifiers: [.command, .shift]) - + Divider() - + Button("Copy Name") { - let names = selectedTables.isEmpty ? [table.name] : selectedTables.map { $0.name }.sorted() + let names: [String] + if selectedTables.isEmpty, let table = clickedTable { + names = [table.name] + } else { + names = selectedTables.map { $0.name }.sorted() + } NSPasteboard.general.clearContents() NSPasteboard.general.setString(names.joined(separator: ","), forType: .string) } - .keyboardShortcut("c", modifiers: .command) + .disabled(!hasSelection) + + Button("Export...") { + if selectedTables.isEmpty, let table = clickedTable { + selectedTables.insert(table) + } + NotificationCenter.default.post(name: .exportTables, object: nil) + } + .disabled(!hasSelection) Divider() Button("Truncate") { + if selectedTables.isEmpty, let table = clickedTable { + selectedTables.insert(table) + } batchToggleTruncate() } - .keyboardShortcut(.delete, modifiers: .option) + .disabled(!hasSelection) Button("Delete", role: .destructive) { - batchToggleDelete() - } - .keyboardShortcut(.delete, modifiers: .command) - } - - @ViewBuilder - private func sidebarContextMenu() -> some View { - Button("Create New Table...") { - NotificationCenter.default.post(name: .createTable, object: nil) - } - .keyboardShortcut("n", modifiers: [.command, .shift]) - - if !selectedTables.isEmpty { - Divider() - - Button("Copy Name") { - let names = selectedTables.map { $0.name }.sorted() - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(names.joined(separator: ","), forType: .string) + if selectedTables.isEmpty, let table = clickedTable { + selectedTables.insert(table) } - .keyboardShortcut("c", modifiers: .command) - - Divider() - - Button("Truncate") { - batchToggleTruncate() - } - .keyboardShortcut(.delete, modifiers: .option) - - Button("Delete", role: .destructive) { - batchToggleDelete() - } - .keyboardShortcut(.delete, modifiers: .command) + batchToggleDelete() } + .disabled(!hasSelection) } /// Batch toggle truncate for all selected tables