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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Connections can now have more than one tag. Assign several tags in the connection form, and filter the welcome list by tag with Match Any or Match All. (#744)
- Elasticsearch support. Connect to Elasticsearch 7.x and 8.x, browse indices, run Query DSL requests in a console, and edit documents in the data grid. Install from Settings > Plugins. (#1529)

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ public struct ExportableConnection: Codable {
public let sslConfig: ExportableSSLConfig?
public let color: String?
public let tagName: String?
public let tagNames: [String]?
public let groupName: String?
public let sshProfileId: String?
public let safeModeLevel: String?
Expand All @@ -104,6 +105,7 @@ public struct ExportableConnection: Codable {
sslConfig: ExportableSSLConfig?,
color: String?,
tagName: String?,
tagNames: [String]? = nil,
groupName: String?,
sshProfileId: String?,
safeModeLevel: String?,
Expand All @@ -123,6 +125,7 @@ public struct ExportableConnection: Codable {
self.sslConfig = sslConfig
self.color = color
self.tagName = tagName
self.tagNames = tagNames
self.groupName = groupName
self.sshProfileId = sshProfileId
self.safeModeLevel = safeModeLevel
Expand All @@ -137,7 +140,7 @@ public struct ExportableConnection: Codable {
ExportableConnection(
name: newName, host: host, port: port, database: database,
username: username, type: type, sshConfig: sshConfig,
sslConfig: sslConfig, color: color, tagName: tagName,
sslConfig: sslConfig, color: color, tagName: tagName, tagNames: tagNames,
groupName: groupName, sshProfileId: sshProfileId,
safeModeLevel: safeModeLevel, aiPolicy: aiPolicy,
additionalFields: additionalFields, redisDatabase: redisDatabase,
Expand All @@ -156,7 +159,7 @@ public extension ExportableConnection {
return ExportableConnection(
name: name, host: host, port: port, database: database,
username: username, type: type, sshConfig: sshConfig,
sslConfig: sslConfig, color: color, tagName: tagName,
sslConfig: sslConfig, color: color, tagName: tagName, tagNames: tagNames,
groupName: groupName, sshProfileId: sshProfileId,
safeModeLevel: safeModeLevel, aiPolicy: aiPolicy,
additionalFields: allowed.isEmpty ? nil : allowed, redisDatabase: redisDatabase,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,14 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable {
public var sslConfiguration: SSLConfiguration?

public var groupId: UUID?
public var tagId: UUID?
public var tagIds: [UUID]
public var sortOrder: Int

public var tagId: UUID? {
get { tagIds.first }
set { tagIds = newValue.map { [$0] } ?? [] }
}

public init(
id: UUID = UUID(),
name: String = "",
Expand All @@ -43,7 +48,7 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable {
sslEnabled: Bool = false,
sslConfiguration: SSLConfiguration? = nil,
groupId: UUID? = nil,
tagId: UUID? = nil,
tagIds: [UUID] = [],
sortOrder: Int = 0
) {
self.id = id
Expand All @@ -63,15 +68,15 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable {
self.sslEnabled = sslEnabled
self.sslConfiguration = sslConfiguration
self.groupId = groupId
self.tagId = tagId
self.tagIds = tagIds
self.sortOrder = sortOrder
}

private enum CodingKeys: String, CodingKey {
case id, name, type, host, port, username, database, colorTag
case isReadOnly, safeModeLevel, queryTimeoutSeconds, additionalFields
case sshEnabled, sshConfiguration, sslEnabled, sslConfiguration
case groupId, tagId, sortOrder
case groupId, tagId, tagIds, sortOrder
}
}

Expand Down Expand Up @@ -99,7 +104,12 @@ extension DatabaseConnection: Codable {
sslEnabled = try container.decodeIfPresent(Bool.self, forKey: .sslEnabled) ?? false
sslConfiguration = try container.decodeIfPresent(SSLConfiguration.self, forKey: .sslConfiguration)
groupId = try container.decodeIfPresent(UUID.self, forKey: .groupId)
tagId = try container.decodeIfPresent(UUID.self, forKey: .tagId)
let decodedTagIds = try container.decodeIfPresent([UUID].self, forKey: .tagIds) ?? []
if decodedTagIds.isEmpty {
tagIds = try container.decodeIfPresent(UUID.self, forKey: .tagId).map { [$0] } ?? []
} else {
tagIds = decodedTagIds
}
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
}

Expand All @@ -122,7 +132,9 @@ extension DatabaseConnection: Codable {
try container.encode(sslEnabled, forKey: .sslEnabled)
try container.encodeIfPresent(sslConfiguration, forKey: .sslConfiguration)
try container.encodeIfPresent(groupId, forKey: .groupId)
try container.encodeIfPresent(tagId, forKey: .tagId)
if !tagIds.isEmpty {
try container.encode(tagIds, forKey: .tagIds)
}
Comment on lines +135 to +137

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 Dual-write the legacy tagId for mobile persistence

TableProMobile persists [DatabaseConnection] by JSON-encoding this public model, but tagged connections now serialize only tagIds. The previous mobile model decoded only tagId, so opening the same on-disk data with an older build drops even the first tag, unlike the macOS stored model and sync/export paths that dual-write the legacy field for compatibility. Encode tagIds[0] under .tagId here when the array is non-empty.

Useful? React with 👍 / 👎.

try container.encode(sortOrder, forKey: .sortOrder)
}
}
24 changes: 18 additions & 6 deletions Packages/TableProCore/Sources/TableProSync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ public enum SyncRecordMapper {
if let groupId = connection.groupId {
record["groupId"] = groupId.uuidString as CKRecordValue
}
if let tagId = connection.tagId {
record["tagId"] = tagId.uuidString as CKRecordValue
if !connection.tagIds.isEmpty {
let tagIdStrings = connection.tagIds.map { $0.uuidString }
record["tagIds"] = tagIdStrings as CKRecordValue
record["tagId"] = tagIdStrings[0] as CKRecordValue
}
if let queryTimeout = connection.queryTimeoutSeconds {
record["queryTimeoutSeconds"] = Int64(queryTimeout) as CKRecordValue
Expand Down Expand Up @@ -109,7 +111,14 @@ public enum SyncRecordMapper {
let username = record["username"] as? String ?? ""
let colorTag = record["color"] as? String ?? record["colorTag"] as? String
let groupId = (record["groupId"] as? String).flatMap { UUID(uuidString: $0) }
let tagId = (record["tagId"] as? String).flatMap { UUID(uuidString: $0) }
let tagIds: [UUID]
if let rawIds = record["tagIds"] as? [String], !rawIds.isEmpty {
tagIds = rawIds.compactMap { UUID(uuidString: $0) }
} else if let single = (record["tagId"] as? String).flatMap({ UUID(uuidString: $0) }) {
tagIds = [single]
} else {
tagIds = []
}
let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0
let isReadOnly = (record["isReadOnly"] as? Int64 ?? 0) != 0
let safeModeLevel = safeModeLevel(fromWire: record["safeModeLevel"] as? String, isReadOnly: isReadOnly)
Expand Down Expand Up @@ -161,7 +170,7 @@ public enum SyncRecordMapper {
sslEnabled: sslEnabled,
sslConfiguration: sslConfig,
groupId: groupId,
tagId: tagId,
tagIds: tagIds,
sortOrder: sortOrder
)
}
Expand Down Expand Up @@ -206,9 +215,12 @@ public enum SyncRecordMapper {
record["groupId"] = nil
}

if let tagId = connection.tagId {
record["tagId"] = tagId.uuidString as CKRecordValue
if !connection.tagIds.isEmpty {
let tagIdStrings = connection.tagIds.map { $0.uuidString }
record["tagIds"] = tagIdStrings as CKRecordValue
record["tagId"] = tagIdStrings[0] as CKRecordValue
} else {
record["tagIds"] = nil
record["tagId"] = nil
}

Expand Down
23 changes: 10 additions & 13 deletions TablePro/Core/Services/Export/ConnectionExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,8 @@ enum ConnectionExportService {
sshConfig = connection.sshConfig
}

let tagName: String?
if let tagId = connection.tagId {
tagName = TagStorage.shared.tag(for: tagId)?.name
} else {
tagName = nil
}
let resolvedTagNames = TagStorage.shared.tags(for: connection.tagIds).map { $0.name }
let tagName = resolvedTagNames.first

let groupName: String?
if let groupId = connection.groupId {
Expand Down Expand Up @@ -139,6 +135,7 @@ enum ConnectionExportService {
sslConfig: exportableSSL,
color: color,
tagName: tagName,
tagNames: resolvedTagNames.isEmpty ? nil : resolvedTagNames,
groupName: groupName,
sshProfileId: connection.sshProfileId?.uuidString,
safeModeLevel: safeModeLevel,
Expand All @@ -151,7 +148,7 @@ enum ConnectionExportService {

exportableConnections.append(exportable)

if let name = tagName { tagNames.insert(name) }
resolvedTagNames.forEach { tagNames.insert($0) }
if let name = groupName { groupNames.insert(name) }
}

Expand Down Expand Up @@ -589,7 +586,8 @@ enum ConnectionExportService {
if let color = exportable.color {
queryItems.append(URLQueryItem(name: "color", value: color))
}
if let tagName = exportable.tagName {
let exportableTagNames = exportable.tagNames ?? exportable.tagName.map { [$0] } ?? []
for tagName in exportableTagNames {
queryItems.append(URLQueryItem(name: "tagName", value: tagName))
}
if let groupName = exportable.groupName {
Expand Down Expand Up @@ -689,10 +687,9 @@ enum ConnectionExportService {
sslConfig = SSLConfiguration()
}

// Resolve tag and group by name
let tagId = exportable.tagName.flatMap { name in
tagIdsByName[normalizedLookupKey(name)]
}
// Resolve tags and group by name
let resolvedTagNames = exportable.tagNames ?? exportable.tagName.map { [$0] } ?? []
let tagIds = resolvedTagNames.compactMap { tagIdsByName[normalizedLookupKey($0)] }
let groupId = exportable.groupName.flatMap { name in
groupIdsByName[normalizedLookupKey(name)]
}
Expand All @@ -713,7 +710,7 @@ enum ConnectionExportService {
sshConfig: sshConfig,
sslConfig: sslConfig,
color: exportable.color.flatMap { ConnectionColor(rawValue: $0) } ?? .none,
tagId: tagId,
tagIds: tagIds,
groupId: groupId,
sshProfileId: parsedSSHProfileId,
safeModeLevel: exportable.safeModeLevel.flatMap { SafeModeLevel(rawValue: $0) } ?? .silent,
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Services/Infrastructure/DeeplinkParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ internal enum DeeplinkParser {
func value(_ key: String) -> String? {
queryItems.first(where: { $0.name == key })?.value
}
func values(_ key: String) -> [String] {
queryItems.filter { $0.name == key }.compactMap { $0.value }
}

guard let name = value("name"), !name.isEmpty else {
return .failure(.missingRequiredParam("name"))
Expand Down Expand Up @@ -323,6 +326,7 @@ internal enum DeeplinkParser {
sslConfig: sslConfig,
color: value("color"),
tagName: value("tagName"),
tagNames: values("tagName").isEmpty ? nil : values("tagName"),

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 Create every tag from repeated deeplink parameters

For a deeplink such as tablepro://import?...&tagName=prod&tagName=readonly on a device where readonly does not already exist, this captures both names in tagNames, but DeeplinkImportSheet still builds the import envelope with tags: connection.tagName.map { ... }, which includes only the first tag. performImport only creates tags listed in that envelope, so buildDatabaseConnection cannot resolve the second name and the imported connection loses it unless the tag was pre-existing.

Useful? React with 👍 / 👎.

groupName: value("groupName"),
sshProfileId: nil,
safeModeLevel: value("safeModeLevel"),
Expand Down
14 changes: 13 additions & 1 deletion TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,18 @@ final class ConnectionStorage {
return true
}

@discardableResult
func removeTagId(_ tagId: UUID) -> Bool {
let affected = loadConnections()
.filter { $0.tagIds.contains(tagId) }
.map { connection -> DatabaseConnection in
var updated = connection
updated.tagIds.removeAll { $0 == tagId }
return updated
}
return updateConnections(affected)
}

@discardableResult
func updateSafeModeLevel(_ level: SafeModeLevel, for connectionId: UUID) -> Bool {
var connections = loadConnections()
Expand Down Expand Up @@ -316,7 +328,7 @@ final class ConnectionStorage {
sshConfig: connection.sshConfig,
sslConfig: connection.sslConfig,
color: connection.color,
tagId: connection.tagId,
tagIds: connection.tagIds,
groupId: connection.groupId,
sshProfileId: connection.sshProfileId,
sshTunnelMode: connection.sshTunnelMode,
Expand Down
19 changes: 15 additions & 4 deletions TablePro/Core/Storage/StoredConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ struct StoredConnection: Codable {

let color: String
let tagId: String?
let tagIds: [String]?
let groupId: String?
let sshProfileId: String?

Expand Down Expand Up @@ -111,7 +112,8 @@ struct StoredConnection: Codable {
self.sslClientKeyPath = connection.sslConfig.clientKeyPath

self.color = connection.color.rawValue
self.tagId = connection.tagId?.uuidString
self.tagId = connection.tagIds.first?.uuidString
self.tagIds = connection.tagIds.isEmpty ? nil : connection.tagIds.map { $0.uuidString }
self.groupId = connection.groupId?.uuidString
self.sshProfileId = connection.sshProfileId?.uuidString

Expand Down Expand Up @@ -165,7 +167,7 @@ struct StoredConnection: Codable {
case sshAgentSocketPath
case totpMode, totpAlgorithm, totpDigits, totpPeriod
case sslMode, sslCaCertificatePath, sslClientCertificatePath, sslClientKeyPath
case color, tagId, groupId, sshProfileId
case color, tagId, tagIds, groupId, sshProfileId
case safeModeLevel
case externalAccess
case isReadOnly // Legacy key for migration reading only
Expand Down Expand Up @@ -209,6 +211,7 @@ struct StoredConnection: Codable {
try container.encode(sslClientKeyPath, forKey: .sslClientKeyPath)
try container.encode(color, forKey: .color)
try container.encodeIfPresent(tagId, forKey: .tagId)
try container.encodeIfPresent(tagIds, forKey: .tagIds)
try container.encodeIfPresent(groupId, forKey: .groupId)
try container.encodeIfPresent(sshProfileId, forKey: .sshProfileId)
try container.encode(safeModeLevel, forKey: .safeModeLevel)
Expand Down Expand Up @@ -269,6 +272,7 @@ struct StoredConnection: Codable {
// Migration: use defaults if fields are missing
color = try container.decodeIfPresent(String.self, forKey: .color) ?? ConnectionColor.none.rawValue
tagId = try container.decodeIfPresent(String.self, forKey: .tagId)
tagIds = try container.decodeIfPresent([String].self, forKey: .tagIds)
groupId = try container.decodeIfPresent(String.self, forKey: .groupId)
sshProfileId = try container.decodeIfPresent(String.self, forKey: .sshProfileId)
// Migration: read new safeModeLevel first, fall back to old isReadOnly boolean
Expand Down Expand Up @@ -355,7 +359,14 @@ struct StoredConnection: Codable {
)

let parsedColor = ConnectionColor(rawValue: color) ?? .none
let parsedTagId = tagId.flatMap { UUID(uuidString: $0) }
let parsedTagIds: [UUID]
if let ids = tagIds, !ids.isEmpty {
parsedTagIds = ids.compactMap { UUID(uuidString: $0) }
} else if let single = tagId.flatMap({ UUID(uuidString: $0) }) {
parsedTagIds = [single]
} else {
parsedTagIds = []
}
let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) }
let parsedSSHProfileId = sshProfileId.flatMap { UUID(uuidString: $0) }
let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) }
Expand Down Expand Up @@ -388,7 +399,7 @@ struct StoredConnection: Codable {
sshConfig: sshConfig,
sslConfig: sslConfig,
color: parsedColor,
tagId: parsedTagId,
tagIds: parsedTagIds,
groupId: parsedGroupId,
sshProfileId: parsedSSHProfileId,
sshTunnelMode: resolvedTunnelMode,
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Core/Storage/TagStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,14 @@ final class TagStorage {
SyncChangeTracker.shared.markDeleted(.tag, id: tag.id.uuidString)
}

/// Delete a custom tag and clear it from every connection that referenced it.
/// Connections are persisted before the tag tombstone fires (sync delete-ordering invariant).
func deleteTag(_ tag: ConnectionTag, clearingFrom connectionStorage: ConnectionStorage) {
guard !tag.isPreset else { return }
connectionStorage.removeTagId(tag.id)
deleteTag(tag)
}

/// Get tag by ID
func tag(for id: UUID) -> ConnectionTag? {
loadTags().first { $0.id == id }
Expand Down
Loading
Loading