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
37 changes: 37 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,43 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Notify Telegram
if: success() && env.TELEGRAM_BOT_TOKEN != ''
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}
TELEGRAM_TOPIC_ID: ${{ secrets.TELEGRAM_TOPIC_ID }}
run: |
VERSION=${GITHUB_REF#refs/tags/v}
RELEASE_URL="https://github.com/datlechin/TablePro/releases/tag/v${VERSION}"

# Build message with release notes
NOTES=$(cat release_notes.md 2>/dev/null || echo "Bug fixes and improvements")

read -r -d '' TEXT <<MSG_EOF || true
*TablePro v${VERSION} Released*

${NOTES}

[View Release](${RELEASE_URL})
MSG_EOF

PAYLOAD=$(jq -n \
--arg chat_id "$TELEGRAM_CHAT_ID" \
--arg text "$TEXT" \
--arg parse_mode "Markdown" \
--arg topic_id "$TELEGRAM_TOPIC_ID" \
'{
chat_id: $chat_id,
text: $text,
parse_mode: $parse_mode,
disable_web_page_preview: true
} + (if $topic_id != "" then {message_thread_id: ($topic_id | tonumber)} else {} end)')

curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-H "Content-Type: application/json" \
-d "$PAYLOAD"

- name: Clean up staging directory
if: always()
run: rm -rf "/tmp/tablepro-artifacts-${{ github.sha }}" || true
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Environment color indicator: subtle toolbar tint based on connection color for at-a-glance environment identification
- Import database connections from SSH tunnel URLs (e.g., `mysql+ssh://`, `postgresql+ssh://`)
- Connection groups for organizing database connections into folders with colored headers

### Fixed

Expand Down
12 changes: 9 additions & 3 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ final class ConnectionStorage {
type: connection.type,
sshConfig: connection.sshConfig,
color: connection.color,
tagId: connection.tagId
tagId: connection.tagId,
groupId: connection.groupId
)

// Save the duplicate connection
Expand Down Expand Up @@ -346,9 +347,10 @@ private struct StoredConnection: Codable {
let sslClientCertificatePath: String
let sslClientKeyPath: String

// Color and Tag
// Color, Tag, and Group
let color: String
let tagId: String?
let groupId: String?

// Read-only mode
let isReadOnly: Bool
Expand Down Expand Up @@ -380,9 +382,10 @@ private struct StoredConnection: Codable {
self.sslClientCertificatePath = connection.sslConfig.clientCertificatePath
self.sslClientKeyPath = connection.sslConfig.clientKeyPath

// Color and Tag
// Color, Tag, and Group
self.color = connection.color.rawValue
self.tagId = connection.tagId?.uuidString
self.groupId = connection.groupId?.uuidString

// Read-only mode
self.isReadOnly = connection.isReadOnly
Expand Down Expand Up @@ -422,6 +425,7 @@ private 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)
groupId = try container.decodeIfPresent(String.self, forKey: .groupId)
isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false
aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy)
}
Expand All @@ -446,6 +450,7 @@ private struct StoredConnection: Codable {

let parsedColor = ConnectionColor(rawValue: color) ?? .none
let parsedTagId = tagId.flatMap { UUID(uuidString: $0) }
let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) }
let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) }

return DatabaseConnection(
Expand All @@ -460,6 +465,7 @@ private struct StoredConnection: Codable {
sslConfig: sslConfig,
color: parsedColor,
tagId: parsedTagId,
groupId: parsedGroupId,
isReadOnly: isReadOnly,
aiPolicy: parsedAIPolicy
)
Expand Down
77 changes: 77 additions & 0 deletions TablePro/Core/Storage/GroupStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// GroupStorage.swift
// TablePro
//

import Foundation
import os

/// Service for persisting connection groups
final class GroupStorage {
static let shared = GroupStorage()
private static let logger = Logger(subsystem: "com.TablePro", category: "GroupStorage")

private let groupsKey = "com.TablePro.groups"
private let defaults = UserDefaults.standard
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()

private init() {}

// MARK: - Group CRUD

/// Load all groups
func loadGroups() -> [ConnectionGroup] {
guard let data = defaults.data(forKey: groupsKey) else {
return []
}

do {
return try decoder.decode([ConnectionGroup].self, from: data)
} catch {
Self.logger.error("Failed to load groups: \(error)")
return []
}
}

/// Save all groups
func saveGroups(_ groups: [ConnectionGroup]) {
do {
let data = try encoder.encode(groups)
defaults.set(data, forKey: groupsKey)
} catch {
Self.logger.error("Failed to save groups: \(error)")
}
}

/// Add a new group
func addGroup(_ group: ConnectionGroup) {
var groups = loadGroups()
guard !groups.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else {
return
}
groups.append(group)
saveGroups(groups)
}

/// Update an existing group
func updateGroup(_ group: ConnectionGroup) {
var groups = loadGroups()
if let index = groups.firstIndex(where: { $0.id == group.id }) {
groups[index] = group
saveGroups(groups)
}
}

/// Delete a group
func deleteGroup(_ group: ConnectionGroup) {
var groups = loadGroups()
groups.removeAll { $0.id == group.id }
saveGroups(groups)
}

/// Get group by ID
func group(for id: UUID) -> ConnectionGroup? {
loadGroups().first { $0.id == id }
}
}
41 changes: 34 additions & 7 deletions TablePro/Core/Utilities/ConnectionURLParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ struct ConnectionURLParser {
var sshHostPort: String
if let atIndex = sshPart.firstIndex(of: "@") {
sshUsername = String(sshPart[sshPart.startIndex..<atIndex])
.removingPercentEncoding
sshHostPort = String(sshPart[sshPart.index(after: atIndex)...])
} else {
sshHostPort = sshPart
Expand All @@ -203,9 +204,9 @@ struct ConnectionURLParser {

var sshHost: String
var sshPort: Int?
if let colonIndex = sshHostPort.firstIndex(of: ":") {
sshHost = String(sshHostPort[sshHostPort.startIndex..<colonIndex])
sshPort = Int(sshHostPort[sshHostPort.index(after: colonIndex)...])
if let (h, p) = parseHostPort(sshHostPort) {
sshHost = h
sshPort = p
} else {
sshHost = sshHostPort
}
Expand All @@ -221,9 +222,11 @@ struct ConnectionURLParser {

if let colonIndex = credentials.firstIndex(of: ":") {
dbUsername = String(credentials[credentials.startIndex..<colonIndex])
.removingPercentEncoding ?? ""
dbPassword = String(credentials[credentials.index(after: colonIndex)...])
.removingPercentEncoding ?? ""
} else {
dbUsername = credentials
dbUsername = credentials.removingPercentEncoding ?? credentials
}

if let slashIndex = afterAt.firstIndex(of: "/") {
Expand All @@ -243,9 +246,9 @@ struct ConnectionURLParser {

var host: String
var port: Int?
if let colonIndex = dbHostPort.lastIndex(of: ":") {
host = String(dbHostPort[dbHostPort.startIndex..<colonIndex])
port = Int(dbHostPort[dbHostPort.index(after: colonIndex)...])
if let (h, p) = parseHostPort(dbHostPort) {
host = h
port = p
} else {
host = dbHostPort
}
Expand Down Expand Up @@ -302,6 +305,30 @@ struct ConnectionURLParser {
))
}

/// Parse a host:port string, handling IPv6 bracket notation ([::1]:port).
/// Returns nil if the string is empty or contains only a bare host with no port.
private static func parseHostPort(_ hostPort: String) -> (host: String, port: Int?)? {
guard !hostPort.isEmpty else { return nil }

if hostPort.hasPrefix("["), let closeBracket = hostPort.firstIndex(of: "]") {
let host = String(hostPort[hostPort.index(after: hostPort.startIndex)..<closeBracket])
let afterBracket = hostPort.index(after: closeBracket)
if afterBracket < hostPort.endIndex, hostPort[afterBracket] == ":" {
let port = Int(hostPort[hostPort.index(after: afterBracket)...])
return (host, port)
}
return (host, nil)
}

if let colonIndex = hostPort.lastIndex(of: ":") {
let host = String(hostPort[hostPort.startIndex..<colonIndex])
let port = Int(hostPort[hostPort.index(after: colonIndex)...])
return (host, port)
}

return (hostPort, nil)
}

private static func parseSSLMode(_ value: String) -> SSLMode? {
switch value.lowercased() {
case "disable", "disabled":
Expand Down
19 changes: 19 additions & 0 deletions TablePro/Models/ConnectionGroup.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// ConnectionGroup.swift
// TablePro
//

import Foundation

/// A named group (folder) for organizing database connections
struct ConnectionGroup: Identifiable, Hashable, Codable {
let id: UUID
var name: String
var color: ConnectionColor

init(id: UUID = UUID(), name: String, color: ConnectionColor = .none) {
self.id = id
self.name = name
self.color = color
}
}
3 changes: 3 additions & 0 deletions TablePro/Models/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var sslConfig: SSLConfiguration
var color: ConnectionColor
var tagId: UUID?
var groupId: UUID?
var isReadOnly: Bool
var aiPolicy: AIConnectionPolicy?
var mongoReadPreference: String?
Expand All @@ -265,6 +266,7 @@ struct DatabaseConnection: Identifiable, Hashable {
sslConfig: SSLConfiguration = SSLConfiguration(),
color: ConnectionColor = .none,
tagId: UUID? = nil,
groupId: UUID? = nil,
isReadOnly: Bool = false,
aiPolicy: AIConnectionPolicy? = nil,
mongoReadPreference: String? = nil,
Expand All @@ -281,6 +283,7 @@ struct DatabaseConnection: Identifiable, Hashable {
self.sslConfig = sslConfig
self.color = color
self.tagId = tagId
self.groupId = groupId
self.isReadOnly = isReadOnly
self.aiPolicy = aiPolicy
self.mongoReadPreference = mongoReadPreference
Expand Down
30 changes: 30 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -1466,6 +1466,9 @@
}
}
}
},
"Change Color" : {

},
"Change File" : {
"localizations" : {
Expand Down Expand Up @@ -2393,6 +2396,12 @@
}
}
}
},
"Create New Group" : {

},
"Create New Group..." : {

},
"Create New Tag" : {
"localizations" : {
Expand Down Expand Up @@ -2948,6 +2957,9 @@
}
}
}
},
"Delete Group" : {

},
"Delete Index" : {
"extractionState" : "stale",
Expand Down Expand Up @@ -3383,6 +3395,9 @@
}
}
}
},
"Enter a new name for the group." : {

},
"Enter database name" : {
"localizations" : {
Expand Down Expand Up @@ -4294,6 +4309,12 @@
}
}
}
},
"Group" : {

},
"Group name" : {

},
"Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : {
"localizations" : {
Expand Down Expand Up @@ -5324,6 +5345,9 @@
}
}
}
},
"New Group" : {

},
"New query tab" : {
"extractionState" : "stale",
Expand Down Expand Up @@ -6878,6 +6902,12 @@
}
}
}
},
"Rename" : {

},
"Rename Group" : {

},
"Reopen Last Session" : {
"localizations" : {
Expand Down
Loading