@@ -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}
0 commit comments