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

- iOS: connection groups and tags

## [0.27.4] - 2026-04-05

### Added
Expand Down
16 changes: 16 additions & 0 deletions Packages/TableProCore/Sources/TableProModels/ConnectionColor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation

public enum ConnectionColor: String, CaseIterable, Identifiable, Codable, Sendable {
case none = "None"
case red = "Red"
case orange = "Orange"
case yellow = "Yellow"
case green = "Green"
case blue = "Blue"
case purple = "Purple"
case pink = "Pink"
case gray = "Gray"

public var id: String { rawValue }
public var isDefault: Bool { self == .none }
}
23 changes: 21 additions & 2 deletions Packages/TableProCore/Sources/TableProModels/ConnectionGroup.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
import Foundation

public struct ConnectionGroup: Identifiable, Codable, Sendable {
public struct ConnectionGroup: Identifiable, Codable, Hashable, Sendable {
public var id: UUID
public var name: String
public var sortOrder: Int
public var color: ConnectionColor
public var parentId: UUID?

public init(
id: UUID = UUID(),
name: String = "",
sortOrder: Int = 0
sortOrder: Int = 0,
color: ConnectionColor = .none,
parentId: UUID? = nil
) {
self.id = id
self.name = name
self.sortOrder = sortOrder
self.color = color
self.parentId = parentId
}

private enum CodingKeys: String, CodingKey {
case id, name, sortOrder, color, parentId
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) ?? .none
parentId = try container.decodeIfPresent(UUID.self, forKey: .parentId)
}
}
79 changes: 75 additions & 4 deletions Packages/TableProCore/Sources/TableProModels/ConnectionTag.swift
Original file line number Diff line number Diff line change
@@ -1,17 +1,88 @@
import Foundation

public struct ConnectionTag: Identifiable, Codable, Sendable {
public struct ConnectionTag: Identifiable, Codable, Hashable, Sendable {
public var id: UUID
public var name: String
public var colorHex: String
public var color: ConnectionColor
public var isPreset: Bool

public init(
id: UUID = UUID(),
name: String = "",
colorHex: String = "#808080"
isPreset: Bool = false,
color: ConnectionColor = .gray
) {
self.id = id
self.name = name
self.colorHex = colorHex
self.isPreset = isPreset
self.color = color
}

private enum CodingKeys: String, CodingKey {
case id, name, color, colorHex, isPreset
}

public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(UUID.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
isPreset = try container.decodeIfPresent(Bool.self, forKey: .isPreset) ?? false

if let color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) {
self.color = color
} else if let hex = try container.decodeIfPresent(String.self, forKey: .colorHex) {
self.color = ConnectionTag.colorFromHex(hex)
} else {
self.color = .gray
}
}

public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(name, forKey: .name)
try container.encode(color, forKey: .color)
try container.encode(isPreset, forKey: .isPreset)
}

private static func colorFromHex(_ hex: String) -> ConnectionColor {
let normalized = hex.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: "#"))
switch normalized {
case "ff0000", "ff3b30", "cc0000": return .red
case "ff9500", "ff8c00", "ffa500": return .orange
case "ffcc00", "ffff00", "ffd700": return .yellow
case "34c759", "28cd41", "00ff00", "008000": return .green
case "007aff", "0000ff", "5856d6": return .blue
case "af52de", "800080", "9b59b6": return .purple
case "ff2d55", "ff69b4", "ffc0cb": return .pink
default: return .gray
}
}

public static let presets: [ConnectionTag] = [
ConnectionTag(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000001") ?? UUID(),
name: "local",
isPreset: true,
color: .green
),
ConnectionTag(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000002") ?? UUID(),
name: "development",
isPreset: true,
color: .blue
),
ConnectionTag(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000003") ?? UUID(),
name: "production",
isPreset: true,
color: .red
),
ConnectionTag(
id: UUID(uuidString: "00000000-0000-0000-0000-000000000004") ?? UUID(),
name: "testing",
isPreset: true,
color: .orange
)
]
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable {
public var sslConfiguration: SSLConfiguration?

public var groupId: UUID?
public var tagId: UUID?
public var sortOrder: Int

public init(
Expand All @@ -39,6 +40,7 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable {
sslEnabled: Bool = false,
sslConfiguration: SSLConfiguration? = nil,
groupId: UUID? = nil,
tagId: UUID? = nil,
sortOrder: Int = 0
) {
self.id = id
Expand All @@ -57,6 +59,7 @@ public struct DatabaseConnection: Identifiable, Codable, Hashable, Sendable {
self.sslEnabled = sslEnabled
self.sslConfiguration = sslConfiguration
self.groupId = groupId
self.tagId = tagId
self.sortOrder = sortOrder
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public enum SyncRecordMapper {
switch type {
case .connection: recordName = "Connection_\(id)"
case .group: recordName = "Group_\(id)"
case .tag: recordName = "Tag_\(id)"
}
return CKRecord.ID(recordName: recordName, zoneID: zone)
}
Expand Down Expand Up @@ -47,6 +48,9 @@ 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 let queryTimeout = connection.queryTimeoutSeconds {
record["queryTimeoutSeconds"] = Int64(queryTimeout) as CKRecordValue
}
Expand Down Expand Up @@ -104,6 +108,7 @@ 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 sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0
let isReadOnly = (record["isReadOnly"] as? Int64 ?? 0) != 0
let queryTimeout = (record["queryTimeoutSeconds"] as? Int64).map { Int($0) }
Expand Down Expand Up @@ -153,6 +158,7 @@ public enum SyncRecordMapper {
sslEnabled: sslEnabled,
sslConfiguration: sslConfig,
groupId: groupId,
tagId: tagId,
sortOrder: sortOrder
)
}
Expand Down Expand Up @@ -186,6 +192,12 @@ public enum SyncRecordMapper {
record["groupId"] = nil
}

if let tagId = connection.tagId {
record["tagId"] = tagId.uuidString as CKRecordValue
} else {
record["tagId"] = nil
}

if let queryTimeout = connection.queryTimeoutSeconds {
record["queryTimeoutSeconds"] = Int64(queryTimeout) as CKRecordValue
} else {
Expand Down Expand Up @@ -229,6 +241,10 @@ public enum SyncRecordMapper {

record["groupId"] = group.id.uuidString as CKRecordValue
record["name"] = group.name as CKRecordValue
record["color"] = group.color.rawValue as CKRecordValue
if let parentId = group.parentId {
record["parentId"] = parentId.uuidString as CKRecordValue
}
record["sortOrder"] = Int64(group.sortOrder) as CKRecordValue
record["modifiedAtLocal"] = Date() as CKRecordValue
record["schemaVersion"] = schemaVersion as CKRecordValue
Expand All @@ -248,7 +264,67 @@ public enum SyncRecordMapper {
}

let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0
let color = (record["color"] as? String).flatMap { ConnectionColor(rawValue: $0) } ?? .none
let parentId = (record["parentId"] as? String).flatMap { UUID(uuidString: $0) }

return ConnectionGroup(id: id, name: name, sortOrder: sortOrder, color: color, parentId: parentId)
}

// MARK: - Update Existing CKRecord with Group

public static func updateRecord(_ record: CKRecord, with group: ConnectionGroup) {
record["groupId"] = group.id.uuidString as CKRecordValue
record["name"] = group.name as CKRecordValue
record["color"] = group.color.rawValue as CKRecordValue
if let parentId = group.parentId {
record["parentId"] = parentId.uuidString as CKRecordValue
} else {
record["parentId"] = nil
}
record["sortOrder"] = Int64(group.sortOrder) as CKRecordValue
record["modifiedAtLocal"] = Date() as CKRecordValue
}

// MARK: - Tag -> CKRecord

public static func toRecord(_ tag: ConnectionTag, zoneID: CKRecordZone.ID) -> CKRecord {
let id = recordID(type: .tag, id: tag.id.uuidString, in: zoneID)
let record = CKRecord(recordType: SyncRecordType.tag.rawValue, recordID: id)

return ConnectionGroup(id: id, name: name, sortOrder: sortOrder)
record["tagId"] = tag.id.uuidString as CKRecordValue
record["name"] = tag.name as CKRecordValue
record["isPreset"] = Int64(tag.isPreset ? 1 : 0) as CKRecordValue
record["color"] = tag.color.rawValue as CKRecordValue
record["modifiedAtLocal"] = Date() as CKRecordValue
record["schemaVersion"] = schemaVersion as CKRecordValue

return record
}

// MARK: - CKRecord -> Tag

public static func toTag(_ record: CKRecord) -> ConnectionTag? {
guard let tagIdStr = record["tagId"] as? String,
let tagId = UUID(uuidString: tagIdStr),
let name = record["name"] as? String
else {
logger.warning("Failed to decode tag from CKRecord: missing required fields")
return nil
}

let isPreset = (record["isPreset"] as? Int64 ?? 0) != 0
let color = (record["color"] as? String).flatMap { ConnectionColor(rawValue: $0) } ?? .gray

return ConnectionTag(id: tagId, name: name, isPreset: isPreset, color: color)
}

// MARK: - Update Existing CKRecord with Tag

public static func updateRecord(_ record: CKRecord, with tag: ConnectionTag) {
record["tagId"] = tag.id.uuidString as CKRecordValue
record["name"] = tag.name as CKRecordValue
record["isPreset"] = Int64(tag.isPreset ? 1 : 0) as CKRecordValue
record["color"] = tag.color.rawValue as CKRecordValue
record["modifiedAtLocal"] = Date() as CKRecordValue
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ import Foundation
public enum SyncRecordType: String, CaseIterable, Sendable {
case connection = "Connection"
case group = "ConnectionGroup"
case tag = "ConnectionTag"
}
Loading
Loading