Skip to content

Commit 4726e55

Browse files
J2TeamNNLdatlechin
andauthored
feat(import): dedup dialog when importing duplicate connections (#1462)
* feat(import): add dedup dialog for duplicate connections on import * feat(import): fix dedup key, collision naming, credential gate, localization, add tests * feat(import): include redisDatabase in dedup key for Redis connections * refactor(connections): make import credential-restore path explicit and tested --------- Co-authored-by: Ngo Quoc Dat <datlechin@gmail.com>
1 parent 790b61b commit 4726e55

5 files changed

Lines changed: 720 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- 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.
13+
1014
### Changed
1115

1216
- Custom keyboard shortcuts now work on non-US keyboard layouts, and shifted symbols like Cmd+[ record correctly.

TablePro/Core/Services/Export/ConnectionExportService.swift

Lines changed: 208 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ struct ConnectionImportPreview {
6969
let items: [ImportItem]
7070
}
7171

72+
enum PreparedImportOperation {
73+
case add(DatabaseConnection)
74+
case replace(DatabaseConnection)
75+
}
76+
77+
struct PreparedConnectionImport {
78+
let operations: [PreparedImportOperation]
79+
let connectionIdMap: [Int: UUID]
80+
let newConnectionIdMap: [Int: UUID]
81+
82+
var importedCount: Int { operations.count }
83+
}
84+
7285
// MARK: - Connection Export Service
7386

7487
@MainActor
@@ -416,17 +429,30 @@ enum ConnectionExportService {
416429
}
417430

418431
static func analyzeImport(_ envelope: ConnectionExportEnvelope) -> ConnectionImportPreview {
419-
let existingConnections = ConnectionStorage.shared.loadConnections()
420-
let registeredTypeIds = Set(PluginMetadataRegistry.shared.allRegisteredTypeIds())
432+
analyzeImport(
433+
envelope,
434+
existingConnections: ConnectionStorage.shared.loadConnections(),
435+
registeredTypeIds: Set(PluginMetadataRegistry.shared.allRegisteredTypeIds()),
436+
fileExists: { FileManager.default.fileExists(atPath: $0) }
437+
)
438+
}
421439

422-
let items: [ImportItem] = envelope.connections.map { exportable in
423-
// Check for duplicate by matching key fields
424-
let duplicate = existingConnections.first { existing in
425-
existing.name.lowercased() == exportable.name.lowercased()
426-
&& existing.host.lowercased() == exportable.host.lowercased()
427-
&& existing.port == exportable.port
428-
&& existing.type.rawValue.lowercased() == exportable.type.lowercased()
440+
static func analyzeImport(
441+
_ envelope: ConnectionExportEnvelope,
442+
existingConnections: [DatabaseConnection],
443+
registeredTypeIds: Set<String>,
444+
fileExists: (String) -> Bool
445+
) -> ConnectionImportPreview {
446+
var duplicateMap: [ConnectionImportDuplicateKey: DatabaseConnection] = [:]
447+
for existing in existingConnections {
448+
let key = duplicateKey(for: existing)
449+
if duplicateMap[key] == nil {
450+
duplicateMap[key] = existing
429451
}
452+
}
453+
454+
let items: [ImportItem] = envelope.connections.map { exportable in
455+
let duplicate = duplicateMap[duplicateKey(for: exportable)]
430456

431457
if let duplicate {
432458
return ImportItem(connection: exportable, status: .duplicate(existing: duplicate))
@@ -438,37 +464,44 @@ enum ConnectionExportService {
438464
// SSH key path check
439465
if let ssh = exportable.sshConfig {
440466
let keyPath = PathPortability.expandHome(ssh.privateKeyPath)
441-
if !keyPath.isEmpty, !FileManager.default.fileExists(atPath: keyPath) {
442-
warnings.append("SSH private key not found: \(ssh.privateKeyPath)")
467+
if !keyPath.isEmpty, !fileExists(keyPath) {
468+
warnings.append(String(
469+
format: String(localized: "SSH private key not found: %@"),
470+
ssh.privateKeyPath
471+
))
443472
}
444-
// Jump host key paths
445473
for jump in ssh.jumpHosts ?? [] {
446474
let jumpKeyPath = PathPortability.expandHome(jump.privateKeyPath)
447-
if !jumpKeyPath.isEmpty, !FileManager.default.fileExists(atPath: jumpKeyPath) {
448-
warnings.append("Jump host key not found: \(jump.privateKeyPath)")
475+
if !jumpKeyPath.isEmpty, !fileExists(jumpKeyPath) {
476+
warnings.append(String(
477+
format: String(localized: "Jump host key not found: %@"),
478+
jump.privateKeyPath
479+
))
449480
}
450481
}
451482
}
452483

453484
// SSL cert paths check
454485
if let ssl = exportable.sslConfig {
455-
for (path, label) in [
456-
(ssl.caCertificatePath, "CA certificate"),
457-
(ssl.clientCertificatePath, "Client certificate"),
458-
(ssl.clientKeyPath, "Client key")
486+
for (path, format) in [
487+
(ssl.caCertificatePath, String(localized: "CA certificate not found: %@")),
488+
(ssl.clientCertificatePath, String(localized: "Client certificate not found: %@")),
489+
(ssl.clientKeyPath, String(localized: "Client key not found: %@"))
459490
] {
460491
if let path, !path.isEmpty {
461492
let expanded = PathPortability.expandHome(path)
462-
if !FileManager.default.fileExists(atPath: expanded) {
463-
warnings.append("\(label) not found: \(path)")
493+
if !fileExists(expanded) {
494+
warnings.append(String(format: format, path))
464495
}
465496
}
466497
}
467498
}
468499

469-
// Database type check
470500
if !registeredTypeIds.contains(exportable.type) {
471-
warnings.append("Database type \"\(exportable.type)\" is not installed")
501+
warnings.append(String(
502+
format: String(localized: "Database type \"%@\" is not installed"),
503+
exportable.type
504+
))
472505
}
473506

474507
if !warnings.isEmpty {
@@ -483,17 +516,17 @@ enum ConnectionExportService {
483516

484517
struct ImportResult {
485518
let importedCount: Int
486-
let connectionIdMap: [Int: UUID] // envelope index -> new connection UUID
519+
let connectionIdMap: [Int: UUID] // envelope index -> connection UUID (added and replaced)
520+
let newConnectionIdMap: [Int: UUID] // envelope index -> UUID, added connections only
487521
}
488522

489523
@discardableResult
490524
static func performImport(
491525
_ preview: ConnectionImportPreview,
492526
resolutions: [UUID: ImportResolution]
493527
) -> ImportResult {
494-
// Create missing groups
495-
let existingGroups = GroupStorage.shared.loadGroups()
496528
if let envelopeGroups = preview.envelope.groups {
529+
let existingGroups = GroupStorage.shared.loadGroups()
497530
for exportGroup in envelopeGroups {
498531
let alreadyExists = existingGroups.contains {
499532
$0.name.lowercased() == exportGroup.name.lowercased()
@@ -506,9 +539,8 @@ enum ConnectionExportService {
506539
}
507540
}
508541

509-
// Create missing tags
510-
let existingTags = TagStorage.shared.loadTags()
511542
if let envelopeTags = preview.envelope.tags {
543+
let existingTags = TagStorage.shared.loadTags()
512544
for exportTag in envelopeTags {
513545
let alreadyExists = existingTags.contains {
514546
$0.name.lowercased() == exportTag.name.lowercased()
@@ -529,10 +561,29 @@ enum ConnectionExportService {
529561
}
530562
}
531563

532-
var importedCount = 0
564+
let prepared = prepareImport(
565+
preview,
566+
resolutions: resolutions,
567+
existingNames: ConnectionStorage.shared.loadConnections().map(\.name),
568+
tagIdsByName: tagIdsByName(),
569+
groupIdsByName: groupIdsByName()
570+
)
571+
572+
return performPreparedImport(prepared)
573+
}
574+
575+
static func prepareImport(
576+
_ preview: ConnectionImportPreview,
577+
resolutions: [UUID: ImportResolution],
578+
existingNames: [String] = [],
579+
tagIdsByName: [String: UUID],
580+
groupIdsByName: [String: UUID]
581+
) -> PreparedConnectionImport {
582+
var operations: [PreparedImportOperation] = []
533583
var connectionIdMap: [Int: UUID] = [:]
584+
var newConnectionIdMap: [Int: UUID] = [:]
585+
var takenNames = Set(existingNames.map { normalizedLookupKey($0) })
534586

535-
// Build a lookup from item.id to envelope index
536587
let itemIndexMap: [UUID: Int] = Dictionary(
537588
uniqueKeysWithValues: preview.items.enumerated().map { ($1.id, $0) }
538589
)
@@ -547,37 +598,69 @@ enum ConnectionExportService {
547598

548599
case .importNew, .importAsCopy:
549600
let connectionId = UUID()
550-
var name = item.connection.name
551-
if case .importAsCopy = resolution {
552-
name += " (Imported)"
601+
let name: String
602+
if resolution == .importAsCopy {
603+
name = uniqueCopyName(for: item.connection.name, taken: takenNames)
604+
} else {
605+
name = item.connection.name
553606
}
607+
takenNames.insert(normalizedLookupKey(name))
554608
let connection = buildDatabaseConnection(
555609
id: connectionId,
556610
from: item.connection,
557-
name: name
611+
name: name,
612+
tagIdsByName: tagIdsByName,
613+
groupIdsByName: groupIdsByName
558614
)
559-
ConnectionStorage.shared.addConnection(connection, password: nil)
615+
operations.append(.add(connection))
560616
connectionIdMap[envelopeIndex] = connectionId
561-
importedCount += 1
617+
newConnectionIdMap[envelopeIndex] = connectionId
562618

563619
case .replace(let existingId):
564620
let connection = buildDatabaseConnection(
565621
id: existingId,
566622
from: item.connection,
567-
name: item.connection.name
623+
name: item.connection.name,
624+
tagIdsByName: tagIdsByName,
625+
groupIdsByName: groupIdsByName
568626
)
569-
ConnectionStorage.shared.updateConnection(connection, password: nil)
627+
operations.append(.replace(connection))
570628
connectionIdMap[envelopeIndex] = existingId
571-
importedCount += 1
572629
}
573630
}
574631

575-
if importedCount > 0 {
576-
AppEvents.shared.connectionUpdated.send(nil)
577-
logger.info("Imported \(importedCount) connections")
632+
return PreparedConnectionImport(
633+
operations: operations,
634+
connectionIdMap: connectionIdMap,
635+
newConnectionIdMap: newConnectionIdMap
636+
)
637+
}
638+
639+
@discardableResult
640+
static func performPreparedImport(
641+
_ prepared: PreparedConnectionImport,
642+
connectionStorage: ConnectionStorage = .shared,
643+
notifyConnectionsChanged: () -> Void = { AppEvents.shared.connectionUpdated.send(nil) }
644+
) -> ImportResult {
645+
for operation in prepared.operations {
646+
switch operation {
647+
case .add(let connection):
648+
connectionStorage.addConnection(connection, password: nil)
649+
case .replace(let connection):
650+
connectionStorage.updateConnection(connection, password: nil)
651+
}
652+
}
653+
654+
if prepared.importedCount > 0 {
655+
notifyConnectionsChanged()
656+
logger.info("Imported \(prepared.importedCount) connections")
578657
}
579658

580-
return ImportResult(importedCount: importedCount, connectionIdMap: connectionIdMap)
659+
return ImportResult(
660+
importedCount: prepared.importedCount,
661+
connectionIdMap: prepared.connectionIdMap,
662+
newConnectionIdMap: prepared.newConnectionIdMap
663+
)
581664
}
582665

583666
// MARK: - Deeplink Builder
@@ -709,7 +792,9 @@ enum ConnectionExportService {
709792
static func buildDatabaseConnection(
710793
id: UUID,
711794
from exportable: ExportableConnection,
712-
name: String
795+
name: String,
796+
tagIdsByName: [String: UUID],
797+
groupIdsByName: [String: UUID]
713798
) -> DatabaseConnection {
714799
// Build SSH configuration
715800
let sshConfig: SSHConfiguration
@@ -755,10 +840,10 @@ enum ConnectionExportService {
755840

756841
// Resolve tag and group by name
757842
let tagId = exportable.tagName.flatMap { name in
758-
TagStorage.shared.loadTags().first { $0.name.lowercased() == name.lowercased() }?.id
843+
tagIdsByName[normalizedLookupKey(name)]
759844
}
760845
let groupId = exportable.groupName.flatMap { name in
761-
GroupStorage.shared.loadGroups().first { $0.name.lowercased() == name.lowercased() }?.id
846+
groupIdsByName[normalizedLookupKey(name)]
762847
}
763848

764849
let parsedSSHProfileId = exportable.sshProfileId.flatMap { UUID(uuidString: $0) }
@@ -788,4 +873,82 @@ enum ConnectionExportService {
788873
additionalFields: exportable.additionalFields
789874
)
790875
}
876+
877+
private static func uniqueCopyName(for baseName: String, taken: Set<String>) -> String {
878+
let firstCandidate = "\(baseName) (Imported)"
879+
if !taken.contains(normalizedLookupKey(firstCandidate)) {
880+
return firstCandidate
881+
}
882+
var suffix = 2
883+
while true {
884+
let candidate = "\(baseName) (Imported \(suffix))"
885+
if !taken.contains(normalizedLookupKey(candidate)) {
886+
return candidate
887+
}
888+
suffix += 1
889+
}
890+
}
891+
892+
private struct ConnectionImportDuplicateKey: Hashable {
893+
let components: [String]
894+
}
895+
896+
private static func duplicateKey(for connection: ExportableConnection) -> ConnectionImportDuplicateKey {
897+
ConnectionImportDuplicateKey(
898+
components: [
899+
normalizedLookupKey(connection.host),
900+
String(connection.port),
901+
effectiveDatabaseKey(database: connection.database, redisDatabase: connection.redisDatabase),
902+
normalizedLookupKey(connection.username)
903+
]
904+
)
905+
}
906+
907+
private static func duplicateKey(for connection: DatabaseConnection) -> ConnectionImportDuplicateKey {
908+
ConnectionImportDuplicateKey(
909+
components: [
910+
normalizedLookupKey(connection.host),
911+
String(connection.port),
912+
effectiveDatabaseKey(database: connection.database, redisDatabase: connection.redisDatabase),
913+
normalizedLookupKey(connection.username)
914+
]
915+
)
916+
}
917+
918+
private static func effectiveDatabaseKey(database: String?, redisDatabase: Int?) -> String {
919+
let normalized = normalizedLookupKey(database)
920+
if !normalized.isEmpty {
921+
return normalized
922+
}
923+
if let redisDatabase {
924+
return String(redisDatabase)
925+
}
926+
return ""
927+
}
928+
929+
private static func tagIdsByName() -> [String: UUID] {
930+
var idsByName: [String: UUID] = [:]
931+
for tag in TagStorage.shared.loadTags() {
932+
let key = normalizedLookupKey(tag.name)
933+
if idsByName[key] == nil {
934+
idsByName[key] = tag.id
935+
}
936+
}
937+
return idsByName
938+
}
939+
940+
private static func groupIdsByName() -> [String: UUID] {
941+
var idsByName: [String: UUID] = [:]
942+
for group in GroupStorage.shared.loadGroups() {
943+
let key = normalizedLookupKey(group.name)
944+
if idsByName[key] == nil {
945+
idsByName[key] = group.id
946+
}
947+
}
948+
return idsByName
949+
}
950+
951+
private static func normalizedLookupKey(_ value: String?) -> String {
952+
value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
953+
}
791954
}

TablePro/Views/Connection/ImportFromApp/ImportFromAppPreviewStep.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ struct ImportFromAppPreviewStep: View {
127127
if preview.envelope.credentials != nil {
128128
ConnectionExportService.restoreCredentials(
129129
from: preview.envelope,
130-
connectionIdMap: result.connectionIdMap
130+
connectionIdMap: result.newConnectionIdMap
131131
)
132132
}
133133

0 commit comments

Comments
 (0)