Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- 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

- Custom keyboard shortcuts now work on non-US keyboard layouts, and shifted symbols like Cmd+[ record correctly.
Expand Down
253 changes: 208 additions & 45 deletions TablePro/Core/Services/Export/ConnectionExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,19 @@ struct ConnectionImportPreview {
let items: [ImportItem]
}

enum PreparedImportOperation {
case add(DatabaseConnection)
case replace(DatabaseConnection)
}

struct PreparedConnectionImport {
let operations: [PreparedImportOperation]
let connectionIdMap: [Int: UUID]
let newConnectionIdMap: [Int: UUID]

var importedCount: Int { operations.count }
}

// MARK: - Connection Export Service

@MainActor
Expand Down Expand Up @@ -416,17 +429,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<String>,
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))
Expand All @@ -438,37 +464,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 {
Expand All @@ -483,17 +516,17 @@ enum ConnectionExportService {

struct ImportResult {
let importedCount: Int
let connectionIdMap: [Int: UUID] // envelope index -> new connection UUID
let connectionIdMap: [Int: UUID] // envelope index -> connection UUID (added and replaced)
let newConnectionIdMap: [Int: UUID] // envelope index -> UUID, added connections only
}

@discardableResult
static func performImport(
_ 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()
Expand All @@ -506,9 +539,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()
Expand All @@ -529,10 +561,29 @@ 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 newConnectionIdMap: [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) }
)
Expand All @@ -547,37 +598,69 @@ 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)
} else {
name = item.connection.name
}
takenNames.insert(normalizedLookupKey(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
newConnectionIdMap[envelopeIndex] = connectionId

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,
newConnectionIdMap: newConnectionIdMap
)
}

@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,
newConnectionIdMap: prepared.newConnectionIdMap
)
}

// MARK: - Deeplink Builder
Expand Down Expand Up @@ -709,7 +792,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
Expand Down Expand Up @@ -755,10 +840,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) }
Expand Down Expand Up @@ -788,4 +873,82 @@ enum ConnectionExportService {
additionalFields: exportable.additionalFields
)
}

private static func uniqueCopyName(for baseName: String, taken: Set<String>) -> 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)
Comment on lines +899 to +902

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include Redis DB in duplicate keys

For Redis connections the selected database is stored in redisDatabase (and connection setup falls back to it when database is empty), but this new duplicate key only compares database. Importing a Redis connection for DB 1 when an otherwise identical DB 0 connection already exists will be marked as a duplicate, so choosing Replace can overwrite the wrong saved connection or the distinct DB is skipped by default.

Useful? React with 👍 / 👎.

]
)
}

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() ?? ""
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ struct ImportFromAppPreviewStep: View {
if preview.envelope.credentials != nil {
ConnectionExportService.restoreCredentials(
from: preview.envelope,
connectionIdMap: result.connectionIdMap
connectionIdMap: result.newConnectionIdMap

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore credentials for replaced imports

When importing from another app with credentials and the user selects Replace for a duplicate, prepareImport only records added rows in newConnectionIdMap, so passing this map makes restoreCredentials skip the replaced connection entirely. The connection settings are overwritten, but the saved password/SSH/plugin secrets remain stale or missing for the existing UUID; use the full connectionIdMap for replacements that should receive the imported credentials.

Useful? React with 👍 / 👎.

)
}

Expand Down
Loading
Loading