diff --git a/.gitignore b/.gitignore index 2c9051166..1dd4bc8e4 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,5 @@ fix-1322-plugin-abi-and-registry-overhaul.diff # Issue analysis blueprints (local only) .analysis/ +.docs/ +Local.xcconfig 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/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 diff --git a/TablePro/Core/Services/Export/ConnectionExportService.swift b/TablePro/Core/Services/Export/ConnectionExportService.swift index ab21e3c8a..1f2155a3f 100644 --- a/TablePro/Core/Services/Export/ConnectionExportService.swift +++ b/TablePro/Core/Services/Export/ConnectionExportService.swift @@ -69,6 +69,18 @@ struct ConnectionImportPreview { 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 @@ -410,17 +422,30 @@ enum ConnectionExportService { } static func analyzeImport(_ envelope: ConnectionExportEnvelope) -> ConnectionImportPreview { - let existingConnections = ConnectionStorage.shared.loadConnections() - let registeredTypeIds = Set(PluginMetadataRegistry.shared.allRegisteredTypeIds()) + analyzeImport( + envelope, + existingConnections: ConnectionStorage.shared.loadConnections(), + registeredTypeIds: Set(PluginMetadataRegistry.shared.allRegisteredTypeIds()), + 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, + fileExists: (String) -> Bool + ) -> ConnectionImportPreview { + var duplicateMap: [ConnectionImportDuplicateKey: DatabaseConnection] = [:] + for existing in existingConnections { + 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)] if let duplicate { return ImportItem(connection: exportable, status: .duplicate(existing: duplicate)) @@ -432,37 +457,44 @@ 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) { - warnings.append("SSH private key not found: \(ssh.privateKeyPath)") + if !keyPath.isEmpty, !fileExists(keyPath) { + 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, !FileManager.default.fileExists(atPath: jumpKeyPath) { - warnings.append("Jump host key not found: \(jump.privateKeyPath)") + if !jumpKeyPath.isEmpty, !fileExists(jumpKeyPath) { + 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 !FileManager.default.fileExists(atPath: expanded) { - warnings.append("\(label) not found: \(path)") + if !fileExists(expanded) { + 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 { @@ -485,9 +517,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 +531,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 +553,28 @@ enum ConnectionExportService { } } - var importedCount = 0 + let prepared = prepareImport( + preview, + resolutions: resolutions, + existingNames: ConnectionStorage.shared.loadConnections().map(\.name), + tagIdsByName: tagIdsByName(), + groupIdsByName: groupIdsByName() + ) + + return performPreparedImport(prepared) + } + + 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) }) - // 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,66 @@ enum ConnectionExportService { case .importNew, .importAsCopy: let connectionId = UUID() - var name = item.connection.name - if case .importAsCopy = resolution { - name += " (Imported)" + 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, - 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 +780,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 +828,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 +861,82 @@ enum ConnectionExportService { additionalFields: exportable.additionalFields ) } + + 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) -> ConnectionImportDuplicateKey { + ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(connection.host), + String(connection.port), + effectiveDatabaseKey(database: connection.database, redisDatabase: connection.redisDatabase), + normalizedLookupKey(connection.username) + ] + ) + } + + private static func duplicateKey(for connection: DatabaseConnection) -> ConnectionImportDuplicateKey { + ConnectionImportDuplicateKey( + components: [ + normalizedLookupKey(connection.host), + String(connection.port), + 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() { + 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/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/TableProTests/Core/Services/ConnectionImportServiceTests.swift b/TableProTests/Core/Services/ConnectionImportServiceTests.swift new file mode 100644 index 000000000..98d27aaf1 --- /dev/null +++ b/TableProTests/Core/Services/ConnectionImportServiceTests.swift @@ -0,0 +1,434 @@ +import Foundation +import Testing + +@testable import TablePro + +@Suite("Connection Import Service") +@MainActor +struct ConnectionImportServiceTests { + @Test("duplicate matching uses host port database and username case-insensitively") + func duplicateMatchingUsesConnectionDetails() { + 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"]), + 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("different username on same host is not a duplicate") + func differentUsernameSameHostIsNotADuplicate() { + let existing = DatabaseConnection( + name: "Local Postgres", + host: "db.example.com", + port: 5_432, + database: "app", + username: "admin", + type: .postgresql + ) + let imported = ExportableConnection( + name: "Local Postgres", + host: "db.example.com", + port: 5_432, + database: "app", + username: "readonly", + 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 = ConnectionExportService.analyzeImport( + makeEnvelope(with: [imported]), + existingConnections: [existing], + registeredTypeIds: Set(["PostgreSQL"]), + 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("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() + 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("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() + 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**.