From a764ef6fcb6a7febb18a0ef08e85a25075f4331f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 18:27:33 +0700 Subject: [PATCH 1/6] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2c9051166..9c535254a 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,4 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ +.docs/ From bc530fd9b063dab48a44295d79403a48a8d1ddb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Mon, 25 May 2026 19:25:09 +0700 Subject: [PATCH 2/6] Update CLAUDE.md --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 027755123..f7c9d6caa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,6 +168,7 @@ Missing a case produces a wrong "{Language} Query" title on the first frame. | Tab state | JSON persistence | `TabPersistenceService` / `TabStateStorage` | | Filter presets | UserDefaults | `FilterSettingsStorage` | | Per-table filters | UserDefaults | `FilterSettingsStorage` (saves `appliedFilters` only) | +| Favorite tables | UserDefaults | `FavoriteTablesStorage` (global, by table name) | ### Logging & Debugging From 97c7f1175275837eb0f0032ce67f47c10818b46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 20:44:04 +0700 Subject: [PATCH 3/6] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9c535254a..1dd4bc8e4 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,4 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ .docs/ +Local.xcconfig From d4d3f776d58b514d0ad9743b0065ccf4e3a6d39d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Wed, 27 May 2026 21:59:23 +0700 Subject: [PATCH 4/6] feat(import): add dedup dialog for duplicate connections on import --- CHANGELOG.md | 1 + .../Export/ConnectionExportService.swift | 227 ++++++++++--- .../ImportFromApp/ImportFromAppSheet.swift | 5 +- .../ConnectionImportServiceTests.swift | 302 ++++++++++++++++++ docs/features/connection-sharing.mdx | 2 +- 5 files changed, 499 insertions(+), 38 deletions(-) create mode 100644 TableProTests/Core/Services/ConnectionImportServiceTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b66a87740..2e7103861 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Favorite a connection from the welcome screen. Starred connections appear in a Favorites section at the top of the list. (#1302) - Text-column cells holding JSON or PHP serialized values open in the structured viewer automatically. - Add and remove buttons in the table structure editor, with Cmd+Shift+N to add and Cmd+Delete to remove. Empty Indexes or Foreign Keys tabs show a labelled add button. (#1319) +- Importing connections from other apps now detects duplicates by host, port, database, and username, and lets you replace, add a copy, or skip each one before import. ### Changed diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index ab21e3c8a..aab4b76d5 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -64,11 +64,28 @@ enum ImportResolution: Hashable { case importAsCopy } +enum ConnectionImportDuplicateStrategy { + case connectionShare + case foreignApp +} + struct ConnectionImportPreview { let envelope: ConnectionExportEnvelope let items: [ImportItem] } +enum PreparedImportOperation: Hashable { + case add(DatabaseConnection) + case replace(DatabaseConnection) +} + +struct PreparedConnectionImport { + let operations: [PreparedImportOperation] + let connectionIdMap: [Int: UUID] + + var importedCount: Int { operations.count } +} + // MARK: - Connection Export Service @MainActor @@ -409,18 +426,36 @@ enum ConnectionExportService { return envelope } - static func analyzeImport(_ envelope: ConnectionExportEnvelope) -> ConnectionImportPreview { - let existingConnections = ConnectionStorage.shared.loadConnections() - let registeredTypeIds = Set(PluginMetadataRegistry.shared.allRegisteredTypeIds()) + static func analyzeImport( + _ envelope: ConnectionExportEnvelope, + duplicateStrategy: ConnectionImportDuplicateStrategy = .connectionShare + ) -> ConnectionImportPreview { + analyzeImport( + envelope, + existingConnections: ConnectionStorage.shared.loadConnections(), + registeredTypeIds: Set(PluginMetadataRegistry.shared.allRegisteredTypeIds()), + duplicateStrategy: duplicateStrategy, + fileExists: { FileManager.default.fileExists(atPath: $0) } + ) + } - let items: [ImportItem] = envelope.connections.map { exportable in - // Check for duplicate by matching key fields - let duplicate = existingConnections.first { existing in - existing.name.lowercased() == exportable.name.lowercased() - && existing.host.lowercased() == exportable.host.lowercased() - && existing.port == exportable.port - && existing.type.rawValue.lowercased() == exportable.type.lowercased() + static func analyzeImport( + _ envelope: ConnectionExportEnvelope, + existingConnections: [DatabaseConnection], + registeredTypeIds: Set, + duplicateStrategy: ConnectionImportDuplicateStrategy, + fileExists: (String) -> Bool + ) -> ConnectionImportPreview { + var duplicateMap: [ConnectionImportDuplicateKey: DatabaseConnection] = [:] + for existing in existingConnections { + let key = duplicateKey(for: existing, strategy: duplicateStrategy) + if duplicateMap[key] == nil { + duplicateMap[key] = existing } + } + + let items: [ImportItem] = envelope.connections.map { exportable in + let duplicate = duplicateMap[duplicateKey(for: exportable, strategy: duplicateStrategy)] if let duplicate { return ImportItem(connection: exportable, status: .duplicate(existing: duplicate)) @@ -432,13 +467,13 @@ enum ConnectionExportService { // SSH key path check if let ssh = exportable.sshConfig { let keyPath = PathPortability.expandHome(ssh.privateKeyPath) - if !keyPath.isEmpty, !FileManager.default.fileExists(atPath: keyPath) { + if !keyPath.isEmpty, !fileExists(keyPath) { warnings.append("SSH private key not found: \(ssh.privateKeyPath)") } // Jump host key paths for jump in ssh.jumpHosts ?? [] { let jumpKeyPath = PathPortability.expandHome(jump.privateKeyPath) - if !jumpKeyPath.isEmpty, !FileManager.default.fileExists(atPath: jumpKeyPath) { + if !jumpKeyPath.isEmpty, !fileExists(jumpKeyPath) { warnings.append("Jump host key not found: \(jump.privateKeyPath)") } } @@ -453,7 +488,7 @@ enum ConnectionExportService { ] { if let path, !path.isEmpty { let expanded = PathPortability.expandHome(path) - if !FileManager.default.fileExists(atPath: expanded) { + if !fileExists(expanded) { warnings.append("\(label) not found: \(path)") } } @@ -485,9 +520,8 @@ enum ConnectionExportService { _ preview: ConnectionImportPreview, resolutions: [UUID: ImportResolution] ) -> ImportResult { - // Create missing groups - let existingGroups = GroupStorage.shared.loadGroups() if let envelopeGroups = preview.envelope.groups { + let existingGroups = GroupStorage.shared.loadGroups() for exportGroup in envelopeGroups { let alreadyExists = existingGroups.contains { $0.name.lowercased() == exportGroup.name.lowercased() @@ -500,9 +534,8 @@ enum ConnectionExportService { } } - // Create missing tags - let existingTags = TagStorage.shared.loadTags() if let envelopeTags = preview.envelope.tags { + let existingTags = TagStorage.shared.loadTags() for exportTag in envelopeTags { let alreadyExists = existingTags.contains { $0.name.lowercased() == exportTag.name.lowercased() @@ -523,10 +556,25 @@ enum ConnectionExportService { } } - var importedCount = 0 + let prepared = prepareImport( + preview, + resolutions: resolutions, + tagIdsByName: tagIdsByName(), + groupIdsByName: groupIdsByName() + ) + + return performPreparedImport(prepared) + } + + static func prepareImport( + _ preview: ConnectionImportPreview, + resolutions: [UUID: ImportResolution], + tagIdsByName: [String: UUID], + groupIdsByName: [String: UUID] + ) -> PreparedConnectionImport { + var operations: [PreparedImportOperation] = [] var connectionIdMap: [Int: UUID] = [:] - // Build a lookup from item.id to envelope index let itemIndexMap: [UUID: Int] = Dictionary( uniqueKeysWithValues: preview.items.enumerated().map { ($1.id, $0) } ) @@ -541,37 +589,60 @@ enum ConnectionExportService { case .importNew, .importAsCopy: let connectionId = UUID() - var name = item.connection.name - if case .importAsCopy = resolution { - name += " (Imported)" - } + let name = resolution == .importAsCopy ? "\(item.connection.name) (Imported)" : item.connection.name let connection = buildDatabaseConnection( id: connectionId, from: item.connection, - name: name + name: name, + tagIdsByName: tagIdsByName, + groupIdsByName: groupIdsByName ) - ConnectionStorage.shared.addConnection(connection, password: nil) + operations.append(.add(connection)) connectionIdMap[envelopeIndex] = connectionId - importedCount += 1 case .replace(let existingId): let connection = buildDatabaseConnection( id: existingId, from: item.connection, - name: item.connection.name + name: item.connection.name, + tagIdsByName: tagIdsByName, + groupIdsByName: groupIdsByName ) - ConnectionStorage.shared.updateConnection(connection, password: nil) + operations.append(.replace(connection)) connectionIdMap[envelopeIndex] = existingId - importedCount += 1 } } - if importedCount > 0 { - AppEvents.shared.connectionUpdated.send(nil) - logger.info("Imported \(importedCount) connections") + return PreparedConnectionImport( + operations: operations, + connectionIdMap: connectionIdMap + ) + } + + @discardableResult + static func performPreparedImport( + _ prepared: PreparedConnectionImport, + connectionStorage: ConnectionStorage = .shared, + notifyConnectionsChanged: () -> Void = { AppEvents.shared.connectionUpdated.send(nil) } + ) -> ImportResult { + for operation in prepared.operations { + switch operation { + case .add(let connection): + connectionStorage.addConnection(connection, password: nil) + case .replace(let connection): + connectionStorage.updateConnection(connection, password: nil) + } + } + + if prepared.importedCount > 0 { + notifyConnectionsChanged() + logger.info("Imported \(prepared.importedCount) connections") } - return ImportResult(importedCount: importedCount, connectionIdMap: connectionIdMap) + return ImportResult( + importedCount: prepared.importedCount, + connectionIdMap: prepared.connectionIdMap + ) } // MARK: - Deeplink Builder @@ -703,7 +774,9 @@ enum ConnectionExportService { static func buildDatabaseConnection( id: UUID, from exportable: ExportableConnection, - name: String + name: String, + tagIdsByName: [String: UUID], + groupIdsByName: [String: UUID] ) -> DatabaseConnection { // Build SSH configuration let sshConfig: SSHConfiguration @@ -749,10 +822,10 @@ enum ConnectionExportService { // Resolve tag and group by name let tagId = exportable.tagName.flatMap { name in - TagStorage.shared.loadTags().first { $0.name.lowercased() == name.lowercased() }?.id + tagIdsByName[normalizedLookupKey(name)] } let groupId = exportable.groupName.flatMap { name in - GroupStorage.shared.loadGroups().first { $0.name.lowercased() == name.lowercased() }?.id + groupIdsByName[normalizedLookupKey(name)] } let parsedSSHProfileId = exportable.sshProfileId.flatMap { UUID(uuidString: $0) } @@ -782,4 +855,86 @@ enum ConnectionExportService { additionalFields: exportable.additionalFields ) } + + private struct ConnectionImportDuplicateKey: Hashable { + let components: [String] + } + + private static func duplicateKey( + for connection: ExportableConnection, + strategy: ConnectionImportDuplicateStrategy + ) -> ConnectionImportDuplicateKey { + switch strategy { + case .connectionShare: + return ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(connection.name), + normalizedLookupKey(connection.host), + String(connection.port), + normalizedLookupKey(connection.type) + ] + ) + case .foreignApp: + return ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(connection.host), + String(connection.port), + normalizedLookupKey(connection.database), + normalizedLookupKey(connection.username) + ] + ) + } + } + + private static func duplicateKey( + for connection: DatabaseConnection, + strategy: ConnectionImportDuplicateStrategy + ) -> ConnectionImportDuplicateKey { + switch strategy { + case .connectionShare: + return ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(connection.name), + normalizedLookupKey(connection.host), + String(connection.port), + normalizedLookupKey(connection.type.rawValue) + ] + ) + case .foreignApp: + return ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(connection.host), + String(connection.port), + normalizedLookupKey(connection.database), + normalizedLookupKey(connection.username) + ] + ) + } + } + + private static func tagIdsByName() -> [String: UUID] { + var idsByName: [String: UUID] = [:] + for tag in TagStorage.shared.loadTags() { + let key = normalizedLookupKey(tag.name) + if idsByName[key] == nil { + idsByName[key] = tag.id + } + } + return idsByName + } + + private static func groupIdsByName() -> [String: UUID] { + var idsByName: [String: UUID] = [:] + for group in GroupStorage.shared.loadGroups() { + let key = normalizedLookupKey(group.name) + if idsByName[key] == nil { + idsByName[key] = group.id + } + } + return idsByName + } + + private static func normalizedLookupKey(_ value: String?) -> String { + value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + } } diff --git a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift index aeb222b26..13a64dbc5 100644 --- a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift +++ b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift @@ -139,7 +139,10 @@ struct ImportFromAppSheet: View { do { let result = try importer.importConnections(includePasswords: includePasswords) try Task.checkCancellation() - let preview = await ConnectionExportService.analyzeImport(result.envelope) + let preview = await ConnectionExportService.analyzeImport( + result.envelope, + duplicateStrategy: .foreignApp + ) try Task.checkCancellation() await MainActor.run { step = .preview(preview, result.sourceName, result.credentialsAborted) diff --git a/TableProTests/Core/Services/ConnectionImportServiceTests.swift b/TableProTests/Core/Services/ConnectionImportServiceTests.swift new file mode 100644 index 000000000..fbf259b83 --- /dev/null +++ b/TableProTests/Core/Services/ConnectionImportServiceTests.swift @@ -0,0 +1,302 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("Connection Import Service") +@MainActor +struct ConnectionImportServiceTests { + @Test("foreign app duplicate matching uses host port database and username") + func foreignAppDuplicateMatchingUsesConnectionDetails() { + let existing = DatabaseConnection( + name: "Local Postgres", + host: "db.example.com", + port: 5_432, + database: "app", + username: "admin", + type: .postgresql + ) + let imported = ExportableConnection( + name: "Different Name", + host: " db.example.com ", + port: 5_432, + database: " app ", + username: " ADMIN ", + type: "MySQL", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + + let preview = ConnectionExportService.analyzeImport( + makeEnvelope(with: [imported]), + existingConnections: [existing], + registeredTypeIds: Set(["MySQL", "PostgreSQL"]), + duplicateStrategy: .foreignApp, + fileExists: { _ in true } + ) + + guard case .duplicate(let matched) = preview.items.first?.status else { + Issue.record("Expected duplicate status") + return + } + + #expect(matched.id == existing.id) + } + + @Test("connection share duplicate matching still uses name and type") + func connectionShareDuplicateMatchingStillUsesNameAndType() { + let existing = DatabaseConnection( + name: "Local Postgres", + host: "db.example.com", + port: 5_432, + database: "app", + username: "admin", + type: .postgresql + ) + let imported = ExportableConnection( + name: "Different Name", + host: "db.example.com", + port: 5_432, + database: "app", + username: "admin", + type: "MySQL", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + + let preview = ConnectionExportService.analyzeImport( + makeEnvelope(with: [imported]), + existingConnections: [existing], + registeredTypeIds: Set(["MySQL", "PostgreSQL"]), + duplicateStrategy: .connectionShare, + fileExists: { _ in true } + ) + + guard let item = preview.items.first else { + Issue.record("Expected preview item") + return + } + + if case .duplicate = item.status { + Issue.record("Expected non-duplicate status") + } + } + + @Test("replace updates the existing connection") + func replaceUpdatesTheExistingConnection() { + let storage = makeStorage() + let existing = DatabaseConnection(name: "Existing", host: "old.example.com", port: 5_432, type: .postgresql) + storage.addConnection(existing) + + let imported = ExportableConnection( + name: "Imported", + host: "new.example.com", + port: 5_433, + database: "app", + username: "admin", + type: "PostgreSQL", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + + let (preview, item) = makeDuplicatePreview(imported: imported, existing: existing) + let prepared = ConnectionExportService.prepareImport( + preview, + resolutions: [item.id: .replace(existingId: existing.id)], + tagIdsByName: [:], + groupIdsByName: [:] + ) + let result = ConnectionExportService.performPreparedImport( + prepared, + connectionStorage: storage, + notifyConnectionsChanged: {} + ) + + let saved = storage.loadConnections() + let replacedId = result.connectionIdMap[0] + #expect(result.importedCount == 1) + #expect(replacedId == .some(existing.id)) + #expect(saved.count == 1) + #expect(saved[0].id == existing.id) + #expect(saved[0].name == "Imported") + #expect(saved[0].host == "new.example.com") + #expect(saved[0].port == 5_433) + #expect(saved[0].database == "app") + #expect(saved[0].username == "admin") + } + + @Test("as copy imports a renamed duplicate") + func asCopyImportsARenamedDuplicate() { + let storage = makeStorage() + let existing = DatabaseConnection(name: "Existing", host: "db.example.com", port: 5_432, type: .postgresql) + storage.addConnection(existing) + + let imported = ExportableConnection( + name: "Imported", + host: "db.example.com", + port: 5_432, + database: "app", + username: "admin", + type: "PostgreSQL", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + + let (preview, item) = makeDuplicatePreview(imported: imported, existing: existing) + let prepared = ConnectionExportService.prepareImport( + preview, + resolutions: [item.id: .importAsCopy], + tagIdsByName: [:], + groupIdsByName: [:] + ) + let result = ConnectionExportService.performPreparedImport( + prepared, + connectionStorage: storage, + notifyConnectionsChanged: {} + ) + + let saved = storage.loadConnections() + let importedId = result.connectionIdMap[0] + #expect(result.importedCount == 1) + #expect(importedId != nil) + #expect(importedId != .some(existing.id)) + #expect(saved.count == 2) + #expect(saved.contains { $0.id == existing.id && $0.name == "Existing" }) + if let importedId { + #expect(saved.contains { $0.id == importedId && $0.name == "Imported (Imported)" }) + } else { + Issue.record("Expected imported connection id") + } + } + + @Test("skip leaves the existing connection untouched") + func skipLeavesTheExistingConnectionUntouched() { + let storage = makeStorage() + let existing = DatabaseConnection(name: "Existing", host: "db.example.com", port: 5_432, type: .postgresql) + storage.addConnection(existing) + + let imported = ExportableConnection( + name: "Imported", + host: "db.example.com", + port: 5_432, + database: "app", + username: "admin", + type: "PostgreSQL", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + + let (preview, item) = makeDuplicatePreview(imported: imported, existing: existing) + let prepared = ConnectionExportService.prepareImport( + preview, + resolutions: [item.id: .skip], + tagIdsByName: [:], + groupIdsByName: [:] + ) + let result = ConnectionExportService.performPreparedImport( + prepared, + connectionStorage: storage, + notifyConnectionsChanged: {} + ) + + let saved = storage.loadConnections() + #expect(result.importedCount == 0) + #expect(result.connectionIdMap.isEmpty) + #expect(saved.count == 1) + #expect(saved[0] == existing) + } + + private func makeStorage() -> ConnectionStorage { + let unique = UUID().uuidString + let fileURL = FileManager.default.temporaryDirectory + .appendingPathComponent("tablepro-tests") + .appendingPathComponent("connection-import-\(unique).json") + try? FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + guard let syncDefaults = UserDefaults(suiteName: "com.TablePro.tests.ConnectionImport.Sync.\(unique)") else { + fatalError("Expected sync defaults suite") + } + let metadata = SyncMetadataStorage(userDefaults: syncDefaults) + let tracker = SyncChangeTracker(metadataStorage: metadata) + guard let defaults = UserDefaults(suiteName: "com.TablePro.tests.ConnectionImport.\(unique)") else { + fatalError("Expected defaults suite") + } + return ConnectionStorage(fileURL: fileURL, userDefaults: defaults, syncTracker: tracker) + } + + private func makeDuplicatePreview( + imported: ExportableConnection, + existing: DatabaseConnection + ) -> (ConnectionImportPreview, ImportItem) { + let item = ImportItem(connection: imported, status: .duplicate(existing: existing)) + let preview = ConnectionImportPreview( + envelope: makeEnvelope(with: [imported]), + items: [item] + ) + return (preview, item) + } + + private func makeEnvelope(with connections: [ExportableConnection]) -> ConnectionExportEnvelope { + ConnectionExportEnvelope( + formatVersion: 1, + exportedAt: Date(), + appVersion: "Tests", + connections: connections, + groups: nil, + tags: nil, + credentials: nil + ) + } +} diff --git a/docs/features/connection-sharing.mdx b/docs/features/connection-sharing.mdx index 2d608f1e9..32e176f05 100644 --- a/docs/features/connection-sharing.mdx +++ b/docs/features/connection-sharing.mdx @@ -62,7 +62,7 @@ Bring your connections over from TablePlus, Sequel Ace, DBeaver, or DataGrip. Pa 1. **File** > **Import from Other App...** 2. Pick the source app and click **Continue**. -3. Review the list, uncheck anything you don't want, then click **Import**. +3. Review the list. Duplicates match by host, port, database, and username. Uncheck anything you don't want, or choose **As Copy**, **Replace**, or **Skip** for duplicates, then click **Import**. Date: Wed, 27 May 2026 22:19:54 +0700 Subject: [PATCH 5/6] feat(import): fix dedup key, collision naming, credential gate, localization, add tests --- .../Export/ConnectionExportService.swift | 135 ++++++++---------- .../ImportFromAppPreviewStep.swift | 13 +- .../ImportFromApp/ImportFromAppSheet.swift | 5 +- .../ConnectionImportServiceTests.swift | 58 ++++++-- 4 files changed, 124 insertions(+), 87 deletions(-) diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index aab4b76d5..cef54a51a 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -64,11 +64,6 @@ enum ImportResolution: Hashable { case importAsCopy } -enum ConnectionImportDuplicateStrategy { - case connectionShare - case foreignApp -} - struct ConnectionImportPreview { let envelope: ConnectionExportEnvelope let items: [ImportItem] @@ -426,15 +421,11 @@ enum ConnectionExportService { return envelope } - static func analyzeImport( - _ envelope: ConnectionExportEnvelope, - duplicateStrategy: ConnectionImportDuplicateStrategy = .connectionShare - ) -> ConnectionImportPreview { + static func analyzeImport(_ envelope: ConnectionExportEnvelope) -> ConnectionImportPreview { analyzeImport( envelope, existingConnections: ConnectionStorage.shared.loadConnections(), registeredTypeIds: Set(PluginMetadataRegistry.shared.allRegisteredTypeIds()), - duplicateStrategy: duplicateStrategy, fileExists: { FileManager.default.fileExists(atPath: $0) } ) } @@ -443,19 +434,18 @@ enum ConnectionExportService { _ envelope: ConnectionExportEnvelope, existingConnections: [DatabaseConnection], registeredTypeIds: Set, - duplicateStrategy: ConnectionImportDuplicateStrategy, fileExists: (String) -> Bool ) -> ConnectionImportPreview { var duplicateMap: [ConnectionImportDuplicateKey: DatabaseConnection] = [:] for existing in existingConnections { - let key = duplicateKey(for: existing, strategy: duplicateStrategy) + let key = duplicateKey(for: existing) if duplicateMap[key] == nil { duplicateMap[key] = existing } } let items: [ImportItem] = envelope.connections.map { exportable in - let duplicate = duplicateMap[duplicateKey(for: exportable, strategy: duplicateStrategy)] + let duplicate = duplicateMap[duplicateKey(for: exportable)] if let duplicate { return ImportItem(connection: exportable, status: .duplicate(existing: duplicate)) @@ -468,36 +458,43 @@ enum ConnectionExportService { if let ssh = exportable.sshConfig { let keyPath = PathPortability.expandHome(ssh.privateKeyPath) if !keyPath.isEmpty, !fileExists(keyPath) { - warnings.append("SSH private key not found: \(ssh.privateKeyPath)") + warnings.append(String( + format: String(localized: "SSH private key not found: %@"), + ssh.privateKeyPath + )) } - // Jump host key paths for jump in ssh.jumpHosts ?? [] { let jumpKeyPath = PathPortability.expandHome(jump.privateKeyPath) if !jumpKeyPath.isEmpty, !fileExists(jumpKeyPath) { - warnings.append("Jump host key not found: \(jump.privateKeyPath)") + warnings.append(String( + format: String(localized: "Jump host key not found: %@"), + jump.privateKeyPath + )) } } } // SSL cert paths check if let ssl = exportable.sslConfig { - for (path, label) in [ - (ssl.caCertificatePath, "CA certificate"), - (ssl.clientCertificatePath, "Client certificate"), - (ssl.clientKeyPath, "Client key") + for (path, format) in [ + (ssl.caCertificatePath, String(localized: "CA certificate not found: %@")), + (ssl.clientCertificatePath, String(localized: "Client certificate not found: %@")), + (ssl.clientKeyPath, String(localized: "Client key not found: %@")) ] { if let path, !path.isEmpty { let expanded = PathPortability.expandHome(path) if !fileExists(expanded) { - warnings.append("\(label) not found: \(path)") + warnings.append(String(format: format, path)) } } } } - // Database type check if !registeredTypeIds.contains(exportable.type) { - warnings.append("Database type \"\(exportable.type)\" is not installed") + warnings.append(String( + format: String(localized: "Database type \"%@\" is not installed"), + exportable.type + )) } if !warnings.isEmpty { @@ -559,6 +556,7 @@ enum ConnectionExportService { let prepared = prepareImport( preview, resolutions: resolutions, + existingNames: ConnectionStorage.shared.loadConnections().map(\.name), tagIdsByName: tagIdsByName(), groupIdsByName: groupIdsByName() ) @@ -569,11 +567,13 @@ enum ConnectionExportService { static func prepareImport( _ preview: ConnectionImportPreview, resolutions: [UUID: ImportResolution], + existingNames: [String] = [], tagIdsByName: [String: UUID], groupIdsByName: [String: UUID] ) -> PreparedConnectionImport { var operations: [PreparedImportOperation] = [] var connectionIdMap: [Int: UUID] = [:] + var takenNames = Set(existingNames.map { normalizedLookupKey($0) }) let itemIndexMap: [UUID: Int] = Dictionary( uniqueKeysWithValues: preview.items.enumerated().map { ($1.id, $0) } @@ -589,7 +589,13 @@ enum ConnectionExportService { case .importNew, .importAsCopy: let connectionId = UUID() - let name = resolution == .importAsCopy ? "\(item.connection.name) (Imported)" : item.connection.name + let name: String + if resolution == .importAsCopy { + name = uniqueCopyName(for: item.connection.name, taken: takenNames) + takenNames.insert(normalizedLookupKey(name)) + } else { + name = item.connection.name + } let connection = buildDatabaseConnection( id: connectionId, from: item.connection, @@ -856,60 +862,45 @@ enum ConnectionExportService { ) } + private static func uniqueCopyName(for baseName: String, taken: Set) -> String { + let firstCandidate = "\(baseName) (Imported)" + if !taken.contains(normalizedLookupKey(firstCandidate)) { + return firstCandidate + } + var suffix = 2 + while true { + let candidate = "\(baseName) (Imported \(suffix))" + if !taken.contains(normalizedLookupKey(candidate)) { + return candidate + } + suffix += 1 + } + } + private struct ConnectionImportDuplicateKey: Hashable { let components: [String] } - private static func duplicateKey( - for connection: ExportableConnection, - strategy: ConnectionImportDuplicateStrategy - ) -> ConnectionImportDuplicateKey { - switch strategy { - case .connectionShare: - return ConnectionImportDuplicateKey( - components: [ - normalizedLookupKey(connection.name), - normalizedLookupKey(connection.host), - String(connection.port), - normalizedLookupKey(connection.type) - ] - ) - case .foreignApp: - return ConnectionImportDuplicateKey( - components: [ - normalizedLookupKey(connection.host), - String(connection.port), - normalizedLookupKey(connection.database), - normalizedLookupKey(connection.username) - ] - ) - } + private static func duplicateKey(for connection: ExportableConnection) -> ConnectionImportDuplicateKey { + ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(connection.host), + String(connection.port), + normalizedLookupKey(connection.database), + normalizedLookupKey(connection.username) + ] + ) } - private static func duplicateKey( - for connection: DatabaseConnection, - strategy: ConnectionImportDuplicateStrategy - ) -> ConnectionImportDuplicateKey { - switch strategy { - case .connectionShare: - return ConnectionImportDuplicateKey( - components: [ - normalizedLookupKey(connection.name), - normalizedLookupKey(connection.host), - String(connection.port), - normalizedLookupKey(connection.type.rawValue) - ] - ) - case .foreignApp: - return ConnectionImportDuplicateKey( - components: [ - normalizedLookupKey(connection.host), - String(connection.port), - normalizedLookupKey(connection.database), - normalizedLookupKey(connection.username) - ] - ) - } + private static func duplicateKey(for connection: DatabaseConnection) -> ConnectionImportDuplicateKey { + ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(connection.host), + String(connection.port), + normalizedLookupKey(connection.database), + normalizedLookupKey(connection.username) + ] + ) } private static func tagIdsByName() -> [String: UUID] { diff --git a/TablePro/Views/Connection/ImportFromApp/ImportFromAppPreviewStep.swift b/TablePro/Views/Connection/ImportFromApp/ImportFromAppPreviewStep.swift index 9d350cb84..aea2fd68c 100644 --- a/TablePro/Views/Connection/ImportFromApp/ImportFromAppPreviewStep.swift +++ b/TablePro/Views/Connection/ImportFromApp/ImportFromAppPreviewStep.swift @@ -125,13 +125,24 @@ struct ImportFromAppPreviewStep: View { let result = ConnectionExportService.performImport(preview, resolutions: resolutions) if preview.envelope.credentials != nil { + let replacedIndices = Set( + preview.items.enumerated() + .filter { isReplace(resolutions[$0.element.id]) } + .map(\.offset) + ) + let newConnectionIdMap = result.connectionIdMap.filter { !replacedIndices.contains($0.key) } ConnectionExportService.restoreCredentials( from: preview.envelope, - connectionIdMap: result.connectionIdMap + connectionIdMap: newConnectionIdMap ) } dismiss() onImported?(result.importedCount) } + + private func isReplace(_ resolution: ImportResolution?) -> Bool { + if case .replace = resolution { return true } + return false + } } diff --git a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift index 13a64dbc5..aeb222b26 100644 --- a/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift +++ b/TablePro/Views/Connection/ImportFromApp/ImportFromAppSheet.swift @@ -139,10 +139,7 @@ struct ImportFromAppSheet: View { do { let result = try importer.importConnections(includePasswords: includePasswords) try Task.checkCancellation() - let preview = await ConnectionExportService.analyzeImport( - result.envelope, - duplicateStrategy: .foreignApp - ) + let preview = await ConnectionExportService.analyzeImport(result.envelope) try Task.checkCancellation() await MainActor.run { step = .preview(preview, result.sourceName, result.credentialsAborted) diff --git a/TableProTests/Core/Services/ConnectionImportServiceTests.swift b/TableProTests/Core/Services/ConnectionImportServiceTests.swift index fbf259b83..429653829 100644 --- a/TableProTests/Core/Services/ConnectionImportServiceTests.swift +++ b/TableProTests/Core/Services/ConnectionImportServiceTests.swift @@ -6,8 +6,8 @@ import Testing @Suite("Connection Import Service") @MainActor struct ConnectionImportServiceTests { - @Test("foreign app duplicate matching uses host port database and username") - func foreignAppDuplicateMatchingUsesConnectionDetails() { + @Test("duplicate matching uses host port database and username case-insensitively") + func duplicateMatchingUsesConnectionDetails() { let existing = DatabaseConnection( name: "Local Postgres", host: "db.example.com", @@ -41,7 +41,6 @@ struct ConnectionImportServiceTests { makeEnvelope(with: [imported]), existingConnections: [existing], registeredTypeIds: Set(["MySQL", "PostgreSQL"]), - duplicateStrategy: .foreignApp, fileExists: { _ in true } ) @@ -53,8 +52,8 @@ struct ConnectionImportServiceTests { #expect(matched.id == existing.id) } - @Test("connection share duplicate matching still uses name and type") - func connectionShareDuplicateMatchingStillUsesNameAndType() { + @Test("different username on same host is not a duplicate") + func differentUsernameSameHostIsNotADuplicate() { let existing = DatabaseConnection( name: "Local Postgres", host: "db.example.com", @@ -64,12 +63,12 @@ struct ConnectionImportServiceTests { type: .postgresql ) let imported = ExportableConnection( - name: "Different Name", + name: "Local Postgres", host: "db.example.com", port: 5_432, database: "app", - username: "admin", - type: "MySQL", + username: "readonly", + type: "PostgreSQL", sshConfig: nil, sslConfig: nil, color: nil, @@ -87,8 +86,7 @@ struct ConnectionImportServiceTests { let preview = ConnectionExportService.analyzeImport( makeEnvelope(with: [imported]), existingConnections: [existing], - registeredTypeIds: Set(["MySQL", "PostgreSQL"]), - duplicateStrategy: .connectionShare, + registeredTypeIds: Set(["PostgreSQL"]), fileExists: { _ in true } ) @@ -209,6 +207,46 @@ struct ConnectionImportServiceTests { } } + @Test("as copy resolves name collisions with a numeric suffix") + func asCopyResolvesNameCollisions() { + let imported = ExportableConnection( + name: "Imported", + host: "db.example.com", + port: 5_432, + database: "app", + username: "admin", + type: "PostgreSQL", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: nil, + startupCommands: nil, + localOnly: nil + ) + + let existing = DatabaseConnection(name: "Imported", host: "db.example.com", port: 5_432, type: .postgresql) + let (preview, item) = makeDuplicatePreview(imported: imported, existing: existing) + let prepared = ConnectionExportService.prepareImport( + preview, + resolutions: [item.id: .importAsCopy], + existingNames: ["Imported", "Imported (Imported)", "Imported (Imported 2)"], + tagIdsByName: [:], + groupIdsByName: [:] + ) + + guard case .add(let connection) = prepared.operations.first else { + Issue.record("Expected an add operation") + return + } + #expect(connection.name == "Imported (Imported 3)") + } + @Test("skip leaves the existing connection untouched") func skipLeavesTheExistingConnectionUntouched() { let storage = makeStorage() From 2dbcdccd82e02ef16efea3e420b41d6d8907ab3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nguy=E1=BB=85n=20Nam=20Long?= Date: Thu, 28 May 2026 20:51:28 +0700 Subject: [PATCH 6/6] feat(import): include redisDatabase in dedup key for Redis connections --- .../Export/ConnectionExportService.swift | 15 ++- .../ConnectionImportServiceTests.swift | 94 +++++++++++++++++++ 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index cef54a51a..1f2155a3f 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -886,7 +886,7 @@ enum ConnectionExportService { components: [ normalizedLookupKey(connection.host), String(connection.port), - normalizedLookupKey(connection.database), + effectiveDatabaseKey(database: connection.database, redisDatabase: connection.redisDatabase), normalizedLookupKey(connection.username) ] ) @@ -897,12 +897,23 @@ enum ConnectionExportService { components: [ normalizedLookupKey(connection.host), String(connection.port), - normalizedLookupKey(connection.database), + effectiveDatabaseKey(database: connection.database, redisDatabase: connection.redisDatabase), normalizedLookupKey(connection.username) ] ) } + private static func effectiveDatabaseKey(database: String?, redisDatabase: Int?) -> String { + let normalized = normalizedLookupKey(database) + if !normalized.isEmpty { + return normalized + } + if let redisDatabase { + return String(redisDatabase) + } + return "" + } + private static func tagIdsByName() -> [String: UUID] { var idsByName: [String: UUID] = [:] for tag in TagStorage.shared.loadTags() { diff --git a/TableProTests/Core/Services/ConnectionImportServiceTests.swift b/TableProTests/Core/Services/ConnectionImportServiceTests.swift index 429653829..98d27aaf1 100644 --- a/TableProTests/Core/Services/ConnectionImportServiceTests.swift +++ b/TableProTests/Core/Services/ConnectionImportServiceTests.swift @@ -100,6 +100,100 @@ struct ConnectionImportServiceTests { } } + @Test("redis connections with different database indices are not duplicates") + func redisConnectionsWithDifferentDatabaseIndicesAreNotDuplicates() { + let existing = DatabaseConnection( + name: "Redis DB 0", + host: "redis.example.com", + port: 6_379, + username: "cache", + type: .redis, + redisDatabase: 0 + ) + let imported = ExportableConnection( + name: "Redis DB 1", + host: "redis.example.com", + port: 6_379, + database: "", + username: "cache", + type: "Redis", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: 1, + startupCommands: nil, + localOnly: nil + ) + + let preview = ConnectionExportService.analyzeImport( + makeEnvelope(with: [imported]), + existingConnections: [existing], + registeredTypeIds: Set(["Redis"]), + fileExists: { _ in true } + ) + + guard let item = preview.items.first else { + Issue.record("Expected preview item") + return + } + + if case .duplicate = item.status { + Issue.record("Expected non-duplicate status for different Redis database indices") + } + } + + @Test("redis connections with matching database indices are duplicates") + func redisConnectionsWithMatchingDatabaseIndicesAreDuplicates() { + let existing = DatabaseConnection( + name: "Redis DB 0", + host: "redis.example.com", + port: 6_379, + username: "cache", + type: .redis, + redisDatabase: 0 + ) + let imported = ExportableConnection( + name: "Redis DB 0 Copy", + host: "redis.example.com", + port: 6_379, + database: "", + username: "cache", + type: "Redis", + sshConfig: nil, + sslConfig: nil, + color: nil, + tagName: nil, + groupName: nil, + sshProfileId: nil, + safeModeLevel: nil, + aiPolicy: nil, + additionalFields: nil, + redisDatabase: 0, + startupCommands: nil, + localOnly: nil + ) + + let preview = ConnectionExportService.analyzeImport( + makeEnvelope(with: [imported]), + existingConnections: [existing], + registeredTypeIds: Set(["Redis"]), + fileExists: { _ in true } + ) + + guard case .duplicate(let matched) = preview.items.first?.status else { + Issue.record("Expected duplicate status for matching Redis database indices") + return + } + + #expect(matched.id == existing.id) + } + @Test("replace updates the existing connection") func replaceUpdatesTheExistingConnection() { let storage = makeStorage()