diff --git a/CHANGELOG.md b/CHANGELOG.md index 853e2cc97..e7a7637ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) + +### Changed + +- The welcome screen groups the ways to add an existing connection into one **Add from Existing** menu, next to **Create Connection**. Import from URL now has a home in that menu and in the File menu, where it had none. **Try Sample Database** moved to the connection list, which already offers it when the list is empty. + ### Fixed +- The license activation sheet now opens when you click **Activate License**. It was built and then failed to appear, and once that happened further clicks did nothing at all. +- File > Import from Other App..., Open Project Folder..., Import Connections... and Export Connections... now work when no welcome window is open. They used to do nothing. +- The tooltip on the welcome screen's **+** button shows the shortcut you actually have bound for New Connection instead of always claiming ⌘N. - The AI chat panel stays inside the right panel when you drag it narrow. The model name, tool names, and code block headers now truncate, long chat messages and code wrap, wide tables scroll inside their own box, and the composer text follows the panel width instead of running under the editor. (#1956) ## [0.60.1] - 2026-07-25 diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index a85e63bcd..262341b00 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ 5AEA8B472F6808CA0040461A /* EtcdHttpClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5AEA8B3C2F6808CA0040461A /* EtcdHttpClient.swift */; }; 5AEA8B492F6808E90040461A /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; }; + 5AYAMS00000000000000A003 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = 5AYAMS00000000000000A002 /* Yams */; }; 5E1A500100000000000000A1 /* ElasticsearchConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A1 /* ElasticsearchConnection.swift */; }; 5E1A500100000000000000A2 /* ElasticsearchConsoleParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A2 /* ElasticsearchConsoleParser.swift */; }; 5E1A500100000000000000A3 /* ElasticsearchMappingFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E1A500200000000000000A3 /* ElasticsearchMappingFlattener.swift */; }; @@ -955,6 +956,7 @@ 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */, 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */, 5ACE00012F4F00000000000A /* Sparkle in Frameworks */, + 5AYAMS00000000000000A003 /* Yams in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1480,6 +1482,7 @@ 5ACE00012F4F000000000010 /* TableProAnalytics */, 5A32BBFA2F9D5EAB00BAEB5F /* X509 */, 5A1MPORT000000000000A010 /* TableProImport */, + 5AYAMS00000000000000A002 /* Yams */, ); productName = TablePro; productReference = 5A1091C72EF17EDC0055EA7C /* TablePro.app */; @@ -2271,6 +2274,7 @@ 5ACE00012F4F00000000000E /* XCRemoteSwiftPackageReference "oracle-nio" */, 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */, 5A32BBF92F9D5EAB00BAEB5F /* XCRemoteSwiftPackageReference "swift-certificates" */, + 5AYAMS00000000000000A001 /* XCRemoteSwiftPackageReference "Yams" */, ); preferredProjectObjectVersion = 77; productRefGroup = 5A1091C82EF17EDC0055EA7C /* Products */; @@ -5464,6 +5468,14 @@ revision = 23c77812ed90259243a761d7fdea19db47d3ca41; }; }; + 5AYAMS00000000000000A001 /* XCRemoteSwiftPackageReference "Yams" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/jpsim/Yams"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.1.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -5508,6 +5520,11 @@ package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; productName = TableProMSSQLCore; }; + 5AYAMS00000000000000A002 /* Yams */ = { + isa = XCSwiftPackageProductDependency; + package = 5AYAMS00000000000000A001 /* XCRemoteSwiftPackageReference "Yams" */; + productName = Yams; + }; AC1D0000000000000000F002 /* TableProTrinoCore */ = { isa = XCSwiftPackageProductDependency; package = 5A0000012F4F000000000102 /* XCLocalSwiftPackageReference "Packages/TableProCore" */; diff --git a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3b2a56f63..3a040924b 100644 --- a/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e3a3828307ccec4f2ef033715de2df6c1d530d8772905d223ccdb8d7525b77ad", + "originHash" : "d2a64118b7173c5448dbdddd34fc090bd8c35bf8ef198e84a0d2a409f05bd756", "pins" : [ { "identity" : "bigint", @@ -215,6 +215,15 @@ "revision" : "da6fe9beb4f7f67beb75914ca8e0d48ae48d6406", "version" : "0.25.10" } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "3d6871d5b4a5cd519adf233fbb576e0a2af71c17", + "version" : "5.4.0" + } } ], "version" : 3 diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index dd5f21add..5589f9387 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -21,6 +21,8 @@ final class AppCommands { let exportConnections = PassthroughSubject() let importConnections = PassthroughSubject() let importConnectionsFromApp = PassthroughSubject() + let openProjectFolder = PassthroughSubject() + let importConnectionFromURL = PassthroughSubject() let exportQueryResults = PassthroughSubject() // MARK: - Window / Sheet Commands diff --git a/TablePro/Core/Services/ProjectImport/AppSettingsJsonExtractor.swift b/TablePro/Core/Services/ProjectImport/AppSettingsJsonExtractor.swift new file mode 100644 index 000000000..0329372f0 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/AppSettingsJsonExtractor.swift @@ -0,0 +1,114 @@ +// +// AppSettingsJsonExtractor.swift +// TablePro +// + +import Foundation + +enum AppSettingsJsonExtractor { + static func extract(data: Data, relativePath: String) -> [ScannedConnectionCandidate] { + guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return [] + } + guard let strings = connectionStrings(in: root) else { + return [] + } + return strings.sorted { $0.key < $1.key }.compactMap { name, value in + candidate(name: name, connectionString: value, relativePath: relativePath) + } + } + + private static func connectionStrings(in root: [String: Any]) -> [String: String]? { + for (key, value) in root where key.lowercased() == "connectionstrings" { + guard let nested = value as? [String: Any] else { + continue + } + return nested.compactMapValues { $0 as? String } + } + return nil + } + + static func candidate( + name: String, + connectionString: String, + relativePath: String + ) -> ScannedConnectionCandidate? { + let keywords = parseKeywords(connectionString) + guard let type = provider(for: keywords) else { + return nil + } + var fields = ScannedConnectionFields(type: type) + fields.host = first(keywords, ["host", "server", "data source", "address", "addr", "network address"]) ?? "" + fields.database = first(keywords, ["database", "initial catalog"]) ?? "" + fields.username = first(keywords, ["username", "user id", "uid", "user"]) ?? "" + fields.password = first(keywords, ["password", "pwd"]) ?? "" + fields.connectionName = name + let address = splitHostPort(fields.host, type: type) + fields.host = address.host + fields.port = first(keywords, ["port"]).flatMap { Int($0) } ?? address.port ?? type.defaultPort + return ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: "ConnectionStrings:\(name)", + kind: .appSettingsJson, + tier: .configFile + ) + } + + static func parseKeywords(_ connectionString: String) -> [String: String] { + var keywords: [String: String] = [:] + for segment in connectionString.split(separator: ";") { + guard let equals = segment.firstIndex(of: "=") else { + continue + } + let key = String(segment[segment.startIndex.. DatabaseType? { + if keywords["host"] != nil { + return .postgresql + } + if keywords["initial catalog"] != nil { + return .mssql + } + let sqlServerHints = ["trusted_connection", "encrypt", "trustservercertificate", "integrated security"] + if sqlServerHints.contains(where: { keywords[$0] != nil }) { + return .mssql + } + if keywords["uid"] != nil || keywords["pwd"] != nil { + return .mysql + } + return nil + } + + private static func first(_ keywords: [String: String], _ names: [String]) -> String? { + for name in names { + if let value = keywords[name] { + return value + } + } + return nil + } + + private static func splitHostPort(_ value: String, type: DatabaseType) -> (host: String, port: Int?) { + let separator: Character = type == .mssql ? "," : ":" + guard let index = value.firstIndex(of: separator) else { + return (value, nil) + } + let head = String(value[value.startIndex.. [ScannedConnectionCandidate] { + guard let parsed = YamlMappingSupport.loadMapping(contents), + let root = ComposeInterpolator.interpolate(parsed, environment: environment) as? [String: Any], + let services = YamlMappingSupport.mapping(root["services"]) else { + return [] + } + return services.keys.sorted().compactMap { name in + guard let service = YamlMappingSupport.mapping(services[name]) else { + return nil + } + return candidate(name: name, service: service, relativePath: relativePath) + } + } + + static func candidate( + name: String, + service: [String: Any], + relativePath: String + ) -> ScannedConnectionCandidate? { + guard let image = YamlMappingSupport.string(service["image"]), + let database = databaseKind(for: image) else { + return nil + } + let variables = environmentVariables(service["environment"]) + var fields = ScannedConnectionFields(type: database.type) + fields.host = "127.0.0.1" + fields.connectionName = name + applyCredentials(&fields, type: database.type, variables: variables) + var warnings: [String] = [] + if [fields.username, fields.password, fields.database].contains(where: ComposeInterpolator.isUnresolved) { + warnings.append(String(localized: "Some values are set outside this file")) + } + if let published = publishedPort(service["ports"], containerPort: database.defaultPort) { + fields.port = published + } else { + fields.port = database.defaultPort + warnings.append(String( + localized: "No published port, may be unreachable" + )) + } + return ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: "services.\(name)", + kind: .dockerCompose, + tier: .configFile, + warnings: warnings + ) + } + + static func databaseKind(for image: String) -> ServiceDatabase? { + let name = image.lowercased() + if name.contains("postgres"), !name.contains("postgrest") { + return ServiceDatabase(type: .postgresql, defaultPort: 5_432) + } + if name.contains("mariadb") { + return ServiceDatabase(type: .mariadb, defaultPort: 3_306) + } + if name.contains("mysql") { + return ServiceDatabase(type: .mysql, defaultPort: 3_306) + } + if name.contains("mongo") { + return ServiceDatabase(type: .mongodb, defaultPort: 27_017) + } + if name.contains("redis") { + return ServiceDatabase(type: .redis, defaultPort: 6_379) + } + if name.contains("clickhouse") { + return ServiceDatabase(type: .clickhouse, defaultPort: 8_123) + } + if name.contains("mssql") || name.contains("sql-server") || name.contains("sqlserver") { + return ServiceDatabase(type: .mssql, defaultPort: 1_433) + } + return nil + } + + static func environmentVariables(_ value: Any?) -> [String: String] { + if let mapping = value as? [String: Any] { + return mapping.compactMapValues { YamlMappingSupport.string($0) } + } + guard let list = value as? [Any] else { + return [:] + } + var variables: [String: String] = [:] + for item in list { + guard let text = YamlMappingSupport.string(item), let equals = text.firstIndex(of: "=") else { + continue + } + let key = String(text[text.startIndex.. Int? { + guard let list = value as? [Any] else { + return nil + } + for item in list { + if let mapping = YamlMappingSupport.mapping(item) { + guard YamlMappingSupport.int(mapping["target"]) == containerPort else { + continue + } + if let published = YamlMappingSupport.int(mapping["published"]) { + return published + } + continue + } + guard let text = YamlMappingSupport.string(item) else { + continue + } + let segments = text.split(separator: ":").map(String.init) + guard segments.count >= 2, Int(segments[segments.count - 1]) == containerPort else { + continue + } + if let published = Int(segments[segments.count - 2]) { + return published + } + } + return nil + } + + private static func applyCredentials( + _ fields: inout ScannedConnectionFields, + type: DatabaseType, + variables: [String: String] + ) { + switch type { + case .postgresql: + fields.username = variables["POSTGRES_USER"] ?? "postgres" + fields.password = variables["POSTGRES_PASSWORD"] ?? "" + fields.database = variables["POSTGRES_DB"] ?? fields.username + case .mariadb, .mysql: + let prefix = variables["MARIADB_PASSWORD"] != nil || variables["MARIADB_DATABASE"] != nil + ? "MARIADB" + : "MYSQL" + fields.username = variables["\(prefix)_USER"] ?? "root" + fields.password = variables["\(prefix)_PASSWORD"] ?? variables["\(prefix)_ROOT_PASSWORD"] ?? "" + fields.database = variables["\(prefix)_DATABASE"] ?? "" + case .mongodb: + fields.username = variables["MONGO_INITDB_ROOT_USERNAME"] ?? "" + fields.password = variables["MONGO_INITDB_ROOT_PASSWORD"] ?? "" + fields.database = variables["MONGO_INITDB_DATABASE"] ?? "" + case .clickhouse: + fields.username = variables["CLICKHOUSE_USER"] ?? "default" + fields.password = variables["CLICKHOUSE_PASSWORD"] ?? "" + fields.database = variables["CLICKHOUSE_DB"] ?? "" + case .mssql: + fields.username = "sa" + fields.password = variables["MSSQL_SA_PASSWORD"] ?? variables["SA_PASSWORD"] ?? "" + default: + fields.password = variables["REDIS_PASSWORD"] ?? "" + } + } +} + +enum ComposeInterpolator { + static func interpolate(_ value: Any, environment: DotenvDocument?) -> Any { + if let text = value as? String { + return interpolate(text: text, environment: environment) + } + if let mapping = value as? [String: Any] { + return mapping.mapValues { interpolate($0, environment: environment) } + } + if let list = value as? [Any] { + return list.map { interpolate($0, environment: environment) } + } + return value + } + + static func interpolate(text contents: String, environment: DotenvDocument?) -> String { + guard contents.contains("$") else { + return contents + } + var result = "" + var remainder = Substring(contents) + while let open = remainder.range(of: "${") { + result += remainder[remainder.startIndex.. String { + let separators = [":-", ":?", "-", "?"] + for separator in separators { + guard let range = reference.range(of: separator) else { + continue + } + let name = String(reference[reference.startIndex.. Bool { + value.contains("${") + } + + private static func unresolvedMarker(_ reference: String) -> String { + "${\(reference)}" + } +} diff --git a/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor+DiscreteKeys.swift b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor+DiscreteKeys.swift new file mode 100644 index 000000000..601491682 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor+DiscreteKeys.swift @@ -0,0 +1,193 @@ +// +// DotenvConnectionExtractor+DiscreteKeys.swift +// TablePro +// + +import Foundation + +extension DotenvConnectionExtractor { + static func laravelCandidate(_ context: Context) -> ScannedConnectionCandidate? { + guard let connection = context.document["DB_CONNECTION"]?.lowercased(), + let type = laravelType(connection) else { + return nil + } + var fields = ScannedConnectionFields(type: type) + guard type != .sqlite else { + fields.database = resolveSQLitePath( + context.document["DB_DATABASE"], + directoryURL: context.directoryURL + ) + return candidate(context, fields: fields, key: "DB_CONNECTION") + } + fields.host = context.document["DB_HOST"] ?? "127.0.0.1" + fields.port = context.document["DB_PORT"].flatMap { Int($0) } ?? type.defaultPort + fields.database = context.document["DB_DATABASE"] ?? "" + fields.username = context.document["DB_USERNAME"] ?? "" + fields.password = context.document["DB_PASSWORD"] ?? "" + return candidate(context, fields: fields, key: "DB_CONNECTION", warnings: serviceHostWarnings(fields.host)) + } + + static func dockerRelationalCandidate(_ context: Context) -> ScannedConnectionCandidate? { + if let postgres = dockerPostgresCandidate(context) { + return postgres + } + if let mysql = dockerMySQLCandidate(context) { + return mysql + } + return dockerMSSQLCandidate(context) + } + + static func libpqCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + guard document["PGHOST"] != nil || document["PGDATABASE"] != nil else { + return nil + } + var fields = ScannedConnectionFields(type: .postgresql) + fields.host = document["PGHOST"] ?? "127.0.0.1" + fields.port = document["PGPORT"].flatMap { Int($0) } ?? 5_432 + fields.database = document["PGDATABASE"] ?? "" + fields.username = document["PGUSER"] ?? "" + fields.password = document["PGPASSWORD"] ?? "" + return candidate(context, fields: fields, key: "PGHOST", warnings: serviceHostWarnings(fields.host)) + } + + static func redisDiscreteCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + guard document["REDIS_HOST"] != nil || document["REDIS_PASSWORD"] != nil else { + return nil + } + var fields = ScannedConnectionFields(type: .redis) + fields.host = document["REDIS_HOST"] ?? "127.0.0.1" + fields.port = document["REDIS_PORT"].flatMap { Int($0) } ?? 6_379 + fields.username = document["REDIS_USERNAME"] ?? "" + fields.password = document["REDIS_PASSWORD"] ?? "" + fields.redisDatabase = document["REDIS_DB"].flatMap { Int($0) } + return candidate(context, fields: fields, key: "REDIS_HOST", warnings: serviceHostWarnings(fields.host)) + } + + static func mongoDiscreteCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + let username = document["MONGO_INITDB_ROOT_USERNAME"] ?? document["MONGODB_INITDB_ROOT_USERNAME"] + let password = document["MONGO_INITDB_ROOT_PASSWORD"] ?? document["MONGODB_INITDB_ROOT_PASSWORD"] + guard username != nil || password != nil else { + return nil + } + var fields = ScannedConnectionFields(type: .mongodb) + fields.host = document["MONGO_HOST"] ?? "127.0.0.1" + fields.port = document["MONGO_PORT"].flatMap { Int($0) } ?? 27_017 + fields.database = document["MONGO_INITDB_DATABASE"] ?? document["MONGODB_INITDB_DATABASE"] ?? "" + fields.username = username ?? "" + fields.password = password ?? "" + return candidate( + context, + fields: fields, + key: "MONGO_INITDB_ROOT_USERNAME", + warnings: serviceHostWarnings(fields.host) + ) + } + + private static func dockerPostgresCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + guard document["POSTGRES_PASSWORD"] != nil + || document["POSTGRES_DB"] != nil + || document["POSTGRES_USER"] != nil else { + return nil + } + var fields = ScannedConnectionFields(type: .postgresql) + fields.host = document["POSTGRES_HOST"] ?? "127.0.0.1" + fields.port = document["POSTGRES_PORT"].flatMap { Int($0) } ?? 5_432 + fields.username = document["POSTGRES_USER"] ?? "postgres" + fields.password = document["POSTGRES_PASSWORD"] ?? "" + fields.database = document["POSTGRES_DB"] ?? fields.username + var warnings = serviceHostWarnings(fields.host) + if document["POSTGRES_HOST"] == nil || document["POSTGRES_PORT"] == nil { + warnings.append(assumedAddressWarning) + } + return candidate(context, fields: fields, key: "POSTGRES_PASSWORD", warnings: warnings) + } + + private static func dockerMySQLCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + let usesMariaDB = document["MARIADB_DATABASE"] != nil + || document["MARIADB_USER"] != nil + || document["MARIADB_PASSWORD"] != nil + || document["MARIADB_ROOT_PASSWORD"] != nil + let prefix = usesMariaDB ? "MARIADB" : "MYSQL" + guard document["\(prefix)_DATABASE"] != nil + || document["\(prefix)_USER"] != nil + || document["\(prefix)_PASSWORD"] != nil + || document["\(prefix)_ROOT_PASSWORD"] != nil else { + return nil + } + var fields = ScannedConnectionFields(type: usesMariaDB ? .mariadb : .mysql) + fields.host = document["MYSQL_HOST"] ?? "127.0.0.1" + fields.port = document["MYSQL_TCP_PORT"].flatMap { Int($0) } ?? 3_306 + fields.database = document["\(prefix)_DATABASE"] ?? "" + fields.username = document["\(prefix)_USER"] ?? "root" + fields.password = document["\(prefix)_PASSWORD"] ?? document["\(prefix)_ROOT_PASSWORD"] ?? "" + var warnings = serviceHostWarnings(fields.host) + if document["MYSQL_HOST"] == nil { + warnings.append(assumedAddressWarning) + } + return candidate(context, fields: fields, key: "\(prefix)_PASSWORD", warnings: warnings) + } + + private static func dockerMSSQLCandidate(_ context: Context) -> ScannedConnectionCandidate? { + let document = context.document + guard let password = document["MSSQL_SA_PASSWORD"] ?? document["SA_PASSWORD"] else { + return nil + } + var fields = ScannedConnectionFields(type: .mssql) + fields.host = "127.0.0.1" + fields.port = document["MSSQL_TCP_PORT"].flatMap { Int($0) } ?? 1_433 + fields.database = document["MSSQL_DB"] ?? "" + fields.username = document["MSSQL_USER"] ?? "sa" + fields.password = password + return candidate(context, fields: fields, key: "MSSQL_SA_PASSWORD", warnings: [assumedAddressWarning]) + } + + private static func laravelType(_ connection: String) -> DatabaseType? { + switch connection { + case "sqlite": + return .sqlite + case "mysql": + return .mysql + case "mariadb": + return .mariadb + case "pgsql": + return .postgresql + case "sqlsrv": + return .mssql + default: + return nil + } + } + + static func resolveSQLitePath(_ value: String?, directoryURL: URL) -> String { + guard let value, !value.isEmpty else { + return directoryURL.appendingPathComponent("database/database.sqlite").path + } + guard value != ":memory:" else { + return value + } + guard !value.hasPrefix("/") else { + return value + } + return directoryURL.appendingPathComponent(value).standardizedFileURL.path + } + + private static var assumedAddressWarning: String { + String(localized: "Host and port assumed") + } + + private static func serviceHostWarnings(_ host: String) -> [String] { + let serviceNames: Set = [ + "db", "database", "postgres", "postgresql", "mysql", + "mariadb", "mongo", "mongodb", "redis", "mssql", "pgsql", + ] + guard serviceNames.contains(host.lowercased()) else { + return [] + } + return [String(localized: "Container service name, may be unreachable")] + } +} diff --git a/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift new file mode 100644 index 000000000..90ae1e7e8 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/DotenvConnectionExtractor.swift @@ -0,0 +1,144 @@ +// +// DotenvConnectionExtractor.swift +// TablePro +// + +import Foundation + +enum DotenvConnectionExtractor { + struct Context { + let document: DotenvDocument + let relativePath: String + let tier: ScannedConnectionTier + let directoryURL: URL + } + + private static let relationalURLKeys = [ + "DATABASE_URL_UNPOOLED", + "POSTGRES_URL_NON_POOLING", + "DATABASE_PUBLIC_URL", + "DATABASE_URL", + "DIRECT_URL", + "DIRECT_DATABASE_URL", + "POSTGRES_URL", + "POSTGRES_PRISMA_URL", + "SUPABASE_DB_URL", + "MYSQL_PUBLIC_URL", + "MYSQL_URL", + "JAWSDB_MARIA_URL", + "JAWSDB_URL", + "CLEARDB_DATABASE_URL", + "JDBC_DATABASE_URL", + "SPRING_DATASOURCE_URL", + "DB_URL", + ] + + private static let mongoURLKeys = ["MONGODB_URI", "MONGO_URL", "MONGODB_URL", "MONGO_URI"] + private static let redisURLKeys = ["REDIS_URL", "REDIS_TLS_URL"] + private static let tursoURLKeys = ["TURSO_DATABASE_URL", "TURSO_CONNECTION_URL", "TURSO_URL"] + + static func extract( + document: DotenvDocument, + relativePath: String, + tier: ScannedConnectionTier, + directoryURL: URL + ) -> [ScannedConnectionCandidate] { + let context = Context( + document: document, + relativePath: relativePath, + tier: tier, + directoryURL: directoryURL + ) + var candidates: [ScannedConnectionCandidate] = [] + candidates.append(contentsOf: relationalCandidates(context)) + if let mongo = mongoCandidate(context) { + candidates.append(mongo) + } + if let redis = redisCandidate(context) { + candidates.append(redis) + } + if let turso = firstURLCandidate(context, keys: tursoURLKeys) { + candidates.append(turso) + } + return candidates + } + + static func firstURLCandidate(_ context: Context, keys: [String]) -> ScannedConnectionCandidate? { + for key in keys { + guard let value = context.document[key] else { + continue + } + let candidate = ScannedConnectionURLBuilder.candidate( + fromURL: value, + key: key, + relativePath: context.relativePath, + kind: .dotenv, + tier: context.tier + ) + if let candidate { + return candidate + } + } + return nil + } + + private static func relationalCandidates(_ context: Context) -> [ScannedConnectionCandidate] { + var byType: [String: ScannedConnectionCandidate] = [:] + var order: [String] = [] + for key in relationalURLKeys { + guard let value = context.document[key] else { + continue + } + let candidate = ScannedConnectionURLBuilder.candidate( + fromURL: value, + key: key, + relativePath: context.relativePath, + kind: .dotenv, + tier: context.tier + ) + guard let candidate else { + continue + } + let type = candidate.parsedURL.type.rawValue + guard byType[type] == nil else { + continue + } + byType[type] = candidate + order.append(type) + } + guard order.isEmpty else { + return order.compactMap { byType[$0] } + } + if let laravel = laravelCandidate(context) { + return [laravel] + } + if let docker = dockerRelationalCandidate(context) { + return [docker] + } + return libpqCandidate(context).map { [$0] } ?? [] + } + + private static func mongoCandidate(_ context: Context) -> ScannedConnectionCandidate? { + firstURLCandidate(context, keys: mongoURLKeys) ?? mongoDiscreteCandidate(context) + } + + private static func redisCandidate(_ context: Context) -> ScannedConnectionCandidate? { + firstURLCandidate(context, keys: redisURLKeys) ?? redisDiscreteCandidate(context) + } + + static func candidate( + _ context: Context, + fields: ScannedConnectionFields, + key: String, + warnings: [String] = [] + ) -> ScannedConnectionCandidate { + ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: context.relativePath, + sourceKey: key, + kind: .dotenv, + tier: context.tier, + warnings: warnings + ) + } +} diff --git a/TablePro/Core/Services/ProjectImport/DotenvParser+Interpolation.swift b/TablePro/Core/Services/ProjectImport/DotenvParser+Interpolation.swift new file mode 100644 index 000000000..20e8ca80b --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/DotenvParser+Interpolation.swift @@ -0,0 +1,141 @@ +// +// DotenvParser+Interpolation.swift +// TablePro +// + +import Foundation + +private enum DotenvReference { + case valid(name: String, defaultValue: String?, length: Int) + case malformed(length: Int) +} + +extension DotenvParser { + static func resolve( + _ assignment: DotenvAssignment, + document: DotenvDocument, + processEnvironment: [String: String] + ) -> DotenvEntry { + guard assignment.quoting != .single else { + return DotenvEntry( + key: assignment.key, + value: assignment.rawValue, + isSingleQuoted: true, + hasUnresolvedReference: false + ) + } + let characters = Array(assignment.rawValue) + var value = "" + var unresolved = false + var index = 0 + while index < characters.count { + guard let reference = readReference(characters, at: index) else { + value.append(characters[index]) + index += 1 + continue + } + switch reference { + case .malformed(let length): + value.append(literal(characters, from: index, length: length)) + index += length + unresolved = true + case .valid(let name, let defaultValue, let length): + if let resolved = lookUp(name, document: document, processEnvironment: processEnvironment) { + value.append(resolved) + } else if let defaultValue { + value.append(defaultValue) + } else { + value.append(literal(characters, from: index, length: length)) + unresolved = true + } + index += length + } + } + return DotenvEntry( + key: assignment.key, + value: value, + isSingleQuoted: false, + hasUnresolvedReference: unresolved + ) + } + + private static func literal(_ characters: [Character], from start: Int, length: Int) -> String { + String(characters[start.. String? { + if let entry = document.entry(for: name), !entry.hasUnresolvedReference { + return entry.value + } + return processEnvironment[name] + } + + private static func readReference(_ characters: [Character], at start: Int) -> DotenvReference? { + guard characters[start] == "$", start + 1 < characters.count else { + return nil + } + if characters[start + 1] == "{" { + return readBracedReference(characters, at: start) + } + return readBareReference(characters, at: start) + } + + private static func readBracedReference(_ characters: [Character], at start: Int) -> DotenvReference { + var index = start + 2 + var name = "" + while index < characters.count, characters[index] != "}", characters[index] != ":" { + name.append(characters[index]) + index += 1 + } + var defaultValue: String? + if index < characters.count, characters[index] == ":" { + var cursor = index + 1 + if cursor < characters.count, characters[cursor] == "-" { + cursor += 1 + } + var fallback = "" + while cursor < characters.count, characters[cursor] != "}" { + fallback.append(characters[cursor]) + cursor += 1 + } + defaultValue = fallback + index = cursor + } + guard index < characters.count, characters[index] == "}", isValidReferenceName(name) else { + let consumed = min(index + 1, characters.count) - start + return .malformed(length: max(consumed, 2)) + } + return .valid(name: name, defaultValue: defaultValue, length: index + 1 - start) + } + + private static func readBareReference(_ characters: [Character], at start: Int) -> DotenvReference? { + var index = start + 1 + var name = "" + while index < characters.count { + let character = characters[index] + guard character.isLetter || character.isNumber || character == "_" else { + break + } + if name.isEmpty, character.isNumber { + break + } + name.append(character) + index += 1 + } + guard !name.isEmpty else { + return nil + } + return .valid(name: name, defaultValue: nil, length: index - start) + } + + private static func isValidReferenceName(_ name: String) -> Bool { + guard !name.isEmpty else { + return false + } + return name.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" } + } +} diff --git a/TablePro/Core/Services/ProjectImport/DotenvParser+ValueDecoding.swift b/TablePro/Core/Services/ProjectImport/DotenvParser+ValueDecoding.swift new file mode 100644 index 000000000..0c7a1d1a1 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/DotenvParser+ValueDecoding.swift @@ -0,0 +1,72 @@ +// +// DotenvParser+ValueDecoding.swift +// TablePro +// + +import Foundation + +extension DotenvParser { + static func readQuoted( + _ characters: [Character], + _ index: inout Int, + delimiter: Character, + decodingEscapes: Bool + ) -> String { + index += 1 + var value = "" + while index < characters.count { + let character = characters[index] + if decodingEscapes, character == "\\", index + 1 < characters.count { + value.append(contentsOf: decodeEscape(characters[index + 1])) + index += 2 + continue + } + if character == delimiter { + index += 1 + skipToLineEnd(characters, &index) + return value + } + value.append(character) + index += 1 + } + return value + } + + static func decodeEscape(_ character: Character) -> String { + switch character { + case "n": + return "\n" + case "r": + return "\r" + case "t": + return "\t" + case "\\": + return "\\" + case "\"": + return "\"" + case "'": + return "'" + default: + return "\\\(character)" + } + } + + static func readUnquoted(_ characters: [Character], _ index: inout Int) -> String { + var value = "" + var previous: Character? + while index < characters.count { + let character = characters[index] + if character == "\n" { + break + } + if character == "#", let previous, previous == " " || previous == "\t" { + break + } + value.append(character) + previous = character + index += 1 + } + skipToLineEnd(characters, &index) + return value.trimmingCharacters(in: .whitespaces) + } +} diff --git a/TablePro/Core/Services/ProjectImport/DotenvParser.swift b/TablePro/Core/Services/ProjectImport/DotenvParser.swift new file mode 100644 index 000000000..d0628860d --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/DotenvParser.swift @@ -0,0 +1,167 @@ +// +// DotenvParser.swift +// TablePro +// + +import Foundation + +struct DotenvEntry: Sendable { + let key: String + let value: String + let isSingleQuoted: Bool + let hasUnresolvedReference: Bool +} + +struct DotenvDocument: Sendable { + private(set) var entries: [DotenvEntry] = [] + private var positions: [String: Int] = [:] + + mutating func upsert(_ entry: DotenvEntry) { + if let existing = positions[entry.key] { + entries[existing] = entry + return + } + positions[entry.key] = entries.count + entries.append(entry) + } + + func entry(for key: String) -> DotenvEntry? { + guard let position = positions[key] else { + return nil + } + return entries[position] + } + + subscript(key: String) -> String? { + guard let entry = entry(for: key), !entry.hasUnresolvedReference else { + return nil + } + let trimmed = entry.value.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + } +} + +enum DotenvValueQuoting: Sendable { + case none + case single + case double +} + +struct DotenvAssignment: Sendable { + let key: String + let rawValue: String + let quoting: DotenvValueQuoting +} + +enum DotenvParser { + static let keyAllowedCharacters = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-" + ) + + static func parse( + _ contents: String, + processEnvironment: [String: String] = ProcessInfo.processInfo.environment + ) -> DotenvDocument { + var document = DotenvDocument() + let characters = Array(normalize(contents)) + var index = 0 + while index < characters.count { + skipBlankLinesAndComments(characters, &index) + guard index < characters.count else { + break + } + guard let assignment = readAssignment(characters, &index) else { + continue + } + document.upsert(resolve(assignment, document: document, processEnvironment: processEnvironment)) + } + return document + } + + static func normalize(_ contents: String) -> String { + var text = contents + if text.hasPrefix("\u{FEFF}") { + text.removeFirst() + } + return text + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + } + + static func skipBlankLinesAndComments(_ characters: [Character], _ index: inout Int) { + while index < characters.count { + let character = characters[index] + if character == "\n" || character == " " || character == "\t" { + index += 1 + continue + } + if character == "#" { + skipToLineEnd(characters, &index) + continue + } + return + } + } + + static func skipToLineEnd(_ characters: [Character], _ index: inout Int) { + while index < characters.count, characters[index] != "\n" { + index += 1 + } + } + + static func readAssignment(_ characters: [Character], _ index: inout Int) -> DotenvAssignment? { + var rawKey = "" + while index < characters.count, characters[index] != "=", characters[index] != "\n" { + rawKey.append(characters[index]) + index += 1 + } + guard index < characters.count, characters[index] == "=" else { + skipToLineEnd(characters, &index) + return nil + } + index += 1 + guard let key = normalizeKey(rawKey) else { + skipToLineEnd(characters, &index) + return nil + } + while index < characters.count, characters[index] == " " || characters[index] == "\t" { + index += 1 + } + let value = readValue(characters, &index) + return DotenvAssignment(key: key, rawValue: value.raw, quoting: value.quoting) + } + + static func normalizeKey(_ rawKey: String) -> String? { + var key = rawKey.trimmingCharacters(in: .whitespaces) + if key.hasPrefix("export") { + let afterExport = key.dropFirst("export".count) + if let first = afterExport.first, first == " " || first == "\t" { + key = String(afterExport).trimmingCharacters(in: .whitespaces) + } + } + guard !key.isEmpty else { + return nil + } + guard key.unicodeScalars.allSatisfy({ keyAllowedCharacters.contains($0) }) else { + return nil + } + return key + } + + static func readValue( + _ characters: [Character], + _ index: inout Int + ) -> (raw: String, quoting: DotenvValueQuoting) { + guard index < characters.count else { + return ("", .none) + } + switch characters[index] { + case "\"": + return (readQuoted(characters, &index, delimiter: "\"", decodingEscapes: true), .double) + case "'": + return (readQuoted(characters, &index, delimiter: "'", decodingEscapes: false), .single) + default: + return (readUnquoted(characters, &index), .none) + } + } +} diff --git a/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift b/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift new file mode 100644 index 000000000..ea3437c22 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ParsedConnectionURL+Scanned.swift @@ -0,0 +1,49 @@ +// +// ParsedConnectionURL+Scanned.swift +// TablePro +// + +import Foundation + +extension ParsedConnectionURL { + func with( + username overriddenUsername: String? = nil, + password overriddenPassword: String? = nil, + safeModeLevel overriddenSafeModeLevel: Int? = nil + ) -> ParsedConnectionURL { + ParsedConnectionURL( + type: type, + host: host, + port: port, + database: database, + username: overriddenUsername ?? username, + password: overriddenPassword ?? password, + sslMode: sslMode, + authSource: authSource, + sshHost: sshHost, + sshPort: sshPort, + sshUsername: sshUsername, + sshPassword: sshPassword, + usePrivateKey: usePrivateKey, + useSSHAgent: useSSHAgent, + sshNoAuth: sshNoAuth, + agentSocket: agentSocket, + connectionName: connectionName, + redisDatabase: redisDatabase, + statusColor: statusColor, + envTag: envTag, + schema: schema, + tableName: tableName, + isView: isView, + filterColumn: filterColumn, + filterOperation: filterOperation, + filterValue: filterValue, + filterCondition: filterCondition, + oracleServiceName: oracleServiceName, + safeModeLevel: overriddenSafeModeLevel ?? safeModeLevel, + useSrv: useSrv, + mongoQueryParams: mongoQueryParams, + multiHost: multiHost + ) + } +} diff --git a/TablePro/Core/Services/ProjectImport/PrismaSchemaExtractor.swift b/TablePro/Core/Services/ProjectImport/PrismaSchemaExtractor.swift new file mode 100644 index 000000000..48326067a --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/PrismaSchemaExtractor.swift @@ -0,0 +1,178 @@ +// +// PrismaSchemaExtractor.swift +// TablePro +// + +import Foundation + +enum PrismaSchemaExtractor { + static func extract( + contents: String, + relativePath: String, + directoryURL: URL, + projectRootURL: URL, + environment: DotenvDocument?, + fileManager: FileManager + ) -> [ScannedConnectionCandidate] { + guard let block = datasourceBlock(in: contents) else { + return [] + } + guard let provider = value(of: "provider", in: block), + let type = providerType(provider) else { + return [] + } + guard let rawURL = resolvedURL(in: block, environment: environment) else { + return [] + } + if type == .sqlite || rawURL.lowercased().hasPrefix("file:") { + return sqliteCandidate( + rawURL: rawURL, + relativePath: relativePath, + directoryURL: directoryURL, + projectRootURL: projectRootURL, + fileManager: fileManager + ) + } + let candidate = ScannedConnectionURLBuilder.candidate( + fromURL: rawURL, + key: "datasource.url", + relativePath: relativePath, + kind: .prismaSchema, + tier: .configFile + ) + return candidate.map { [$0] } ?? [] + } + + static func datasourceBlock(in contents: String) -> String? { + guard let start = contents.range(of: "datasource", options: .caseInsensitive) else { + return nil + } + guard let open = contents.range(of: "{", range: start.upperBound.. String? { + for rawLine in block.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + guard line.hasPrefix(key) else { + continue + } + guard let equals = line.firstIndex(of: "=") else { + continue + } + let remainder = line[line.index(after: equals)...].trimmingCharacters(in: .whitespaces) + return remainder.isEmpty ? nil : remainder + } + return nil + } + + private static func resolvedURL(in block: String, environment: DotenvDocument?) -> String? { + guard let raw = value(of: "url", in: block) else { + return nil + } + if let name = environmentVariableName(in: raw) { + return environment?[name] + } + return unquoted(raw) + } + + static func environmentVariableName(in value: String) -> String? { + guard value.hasPrefix("env(") else { + return nil + } + guard let open = value.firstIndex(of: "("), let close = value.lastIndex(of: ")") else { + return nil + } + let inner = String(value[value.index(after: open).. [ScannedConnectionCandidate] { + var path = rawURL + if let colon = path.firstIndex(of: ":"), path.lowercased().hasPrefix("file:") { + path = String(path[path.index(after: colon)...]) + } + if path.hasPrefix("//") { + path = String(path.dropFirst(2)) + } + guard !path.isEmpty else { + return [] + } + var fields = ScannedConnectionFields(type: .sqlite) + fields.database = resolveSQLitePath( + path, + directoryURL: directoryURL, + projectRootURL: projectRootURL, + fileManager: fileManager + ) + let candidate = ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: "datasource.url", + kind: .prismaSchema, + tier: .configFile + ) + return [candidate] + } + + static func resolveSQLitePath( + _ path: String, + directoryURL: URL, + projectRootURL: URL, + fileManager: FileManager + ) -> String { + guard !path.hasPrefix("/") else { + return path + } + let schemaRelative = directoryURL.appendingPathComponent(path).standardizedFileURL + if fileManager.fileExists(atPath: schemaRelative.path) { + return schemaRelative.path + } + let rootRelative = projectRootURL.appendingPathComponent(path).standardizedFileURL + if fileManager.fileExists(atPath: rootRelative.path) { + return rootRelative.path + } + return schemaRelative.path + } + + private static func providerType(_ provider: String) -> DatabaseType? { + switch unquoted(provider).lowercased() { + case "postgresql", "postgres": + return .postgresql + case "mysql": + return .mysql + case "sqlite": + return .sqlite + case "sqlserver": + return .mssql + case "mongodb": + return .mongodb + case "cockroachdb": + return .cockroachdb + default: + return nil + } + } + + private static func unquoted(_ value: String) -> String { + guard value.count >= 2, let first = value.first, let last = value.last else { + return value + } + guard first == last, first == "\"" || first == "'" else { + return value + } + return String(value.dropFirst().dropLast()) + } +} diff --git a/TablePro/Core/Services/ProjectImport/ProjectConfigFileMatcher.swift b/TablePro/Core/Services/ProjectImport/ProjectConfigFileMatcher.swift new file mode 100644 index 000000000..30f9b97d3 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ProjectConfigFileMatcher.swift @@ -0,0 +1,150 @@ +// +// ProjectConfigFileMatcher.swift +// TablePro +// + +import Foundation + +enum ProjectConfigFileKind: Sendable { + case dotenv + case wordPressConfig + case prismaSchema + case springProperties + case springYaml + case appSettingsJson + case railsDatabaseYaml + case dockerCompose +} + +enum ScannedConnectionTier: Int, Comparable, Sendable { + case nearCertain + case likelyReal + case configFile + case lowValue + + static func < (lhs: ScannedConnectionTier, rhs: ScannedConnectionTier) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +struct ProjectConfigFileMatch: Sendable { + let kind: ProjectConfigFileKind + let tier: ScannedConnectionTier +} + +enum ProjectConfigFileMatcher { + private static let placeholderSuffixes = [".example", ".sample", ".template", ".dist", ".schema"] + + private static let nearCertainDotenvNames: Set = [ + ".env", + ".env.local", + ".env.development.local", + ".env.production.local", + ".env.test.local", + ] + + private static let lowValueDotenvNames: Set = [ + ".env.test", + ".env.testing", + ".env.defaults", + ] + + private static let composeNames: Set = [ + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", + ] + + static func classify(relativePath: String) -> ProjectConfigFileMatch? { + let components = relativePath.split(separator: "/").map(String.init) + guard let fileName = components.last, !fileName.isEmpty else { + return nil + } + let parent = components.count >= 2 ? components[components.count - 2].lowercased() : "" + let lowercased = fileName.lowercased() + + if lowercased == "wp-config.php" { + return ProjectConfigFileMatch(kind: .wordPressConfig, tier: .configFile) + } + if lowercased.hasSuffix(".prisma") { + return ProjectConfigFileMatch(kind: .prismaSchema, tier: .configFile) + } + if isSpringProperties(lowercased) { + return ProjectConfigFileMatch(kind: .springProperties, tier: .configFile) + } + if isSpringYaml(lowercased) { + return ProjectConfigFileMatch(kind: .springYaml, tier: .configFile) + } + if isAppSettings(lowercased) { + return ProjectConfigFileMatch(kind: .appSettingsJson, tier: .configFile) + } + if parent == "config", lowercased == "database.yml" || lowercased == "database.yaml" { + return ProjectConfigFileMatch(kind: .railsDatabaseYaml, tier: .configFile) + } + if isCompose(lowercased) { + return ProjectConfigFileMatch(kind: .dockerCompose, tier: .configFile) + } + if let tier = dotenvTier(fileName) { + return ProjectConfigFileMatch(kind: .dotenv, tier: tier) + } + return nil + } + + private static func dotenvTier(_ fileName: String) -> ScannedConnectionTier? { + guard fileName != ".envrc" else { + return nil + } + guard fileName.hasPrefix(".env") || fileName.hasSuffix(".env") else { + return nil + } + let lowercased = fileName.lowercased() + guard !lowercased.hasSuffix(".bak") else { + return nil + } + guard !placeholderSuffixes.contains(where: { lowercased.hasSuffix($0) }) else { + return nil + } + if nearCertainDotenvNames.contains(lowercased) { + return .nearCertain + } + if lowValueDotenvNames.contains(lowercased) { + return .lowValue + } + return .likelyReal + } + + private static func isSpringProperties(_ fileName: String) -> Bool { + if fileName == "application.properties" { + return true + } + return fileName.hasPrefix("application-") && fileName.hasSuffix(".properties") + } + + private static func isSpringYaml(_ fileName: String) -> Bool { + if fileName == "application.yml" || fileName == "application.yaml" { + return true + } + guard fileName.hasPrefix("application-") else { + return false + } + return fileName.hasSuffix(".yml") || fileName.hasSuffix(".yaml") + } + + private static func isAppSettings(_ fileName: String) -> Bool { + if fileName == "appsettings.json" { + return true + } + return fileName.hasPrefix("appsettings.") && fileName.hasSuffix(".json") + } + + private static func isCompose(_ fileName: String) -> Bool { + if composeNames.contains(fileName) { + return true + } + guard fileName.hasPrefix("docker-compose.") || fileName.hasPrefix("compose.") else { + return false + } + return fileName.hasSuffix(".yml") || fileName.hasSuffix(".yaml") + } +} diff --git a/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift b/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift new file mode 100644 index 000000000..bdd76fb9f --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ProjectFolderFileWalker.swift @@ -0,0 +1,138 @@ +// +// ProjectFolderFileWalker.swift +// TablePro +// + +import Foundation + +struct ProjectScanFile { + let url: URL + let relativePath: String + let match: ProjectConfigFileMatch +} + +enum ProjectFolderFileWalker { + static let maxDepth = 4 + static let maxFileSize = 1_048_576 + static let maxVisitedEntries = 20_000 + + private static let excludedDirectories: Set = [ + "node_modules", + ".git", + "vendor", + "venv", + ".venv", + "virtualenv", + "dist", + "build", + "target", + "Pods", + ".next", + ".nuxt", + ".svelte-kit", + "bower_components", + ".yarn", + ".npm", + ".pnpm-store", + ".svn", + ".hg", + "CVS", + ".terraform", + "DerivedData", + ".gradle", + ".mvn", + ] + + static func walk(root: URL, fileManager: FileManager = .default) -> [ProjectScanFile] { + let rootPath = root.standardizedFileURL.path + let keys: [URLResourceKey] = [.isRegularFileKey, .isSymbolicLinkKey, .isDirectoryKey, .fileSizeKey] + guard let enumerator = fileManager.enumerator( + at: root, + includingPropertiesForKeys: keys, + options: [.skipsPackageDescendants], + errorHandler: { _, _ in true } + ) else { + return [] + } + var files: [ProjectScanFile] = [] + var visited = 0 + for case let url as URL in enumerator { + visited += 1 + guard visited <= maxVisitedEntries else { + break + } + guard let values = try? url.resourceValues(forKeys: Set(keys)) else { + continue + } + if values.isDirectory == true { + if shouldSkipDirectory(named: url.lastPathComponent, level: enumerator.level) { + enumerator.skipDescendants() + } + continue + } + guard let file = scanFile(at: url, values: values, rootPath: rootPath) else { + continue + } + files.append(file) + } + return files.sorted { $0.relativePath < $1.relativePath } + } + + static func isDenied(_ standardizedPath: String) -> Bool { + deniedPrefixes.contains { standardizedPath == $0 || standardizedPath.hasPrefix($0 + "/") } + } + + static func relativePath(of standardizedPath: String, rootPath: String) -> String? { + guard standardizedPath.hasPrefix(rootPath + "/") else { + return nil + } + return String(standardizedPath.dropFirst(rootPath.count + 1)) + } + + private static func scanFile( + at url: URL, + values: URLResourceValues, + rootPath: String + ) -> ProjectScanFile? { + guard values.isRegularFile == true, values.isSymbolicLink != true else { + return nil + } + guard (values.fileSize ?? 0) <= maxFileSize else { + return nil + } + let standardized = url.standardizedFileURL.path + guard !isDenied(standardized) else { + return nil + } + guard let relative = relativePath(of: standardized, rootPath: rootPath) else { + return nil + } + guard let match = ProjectConfigFileMatcher.classify(relativePath: relative) else { + return nil + } + return ProjectScanFile(url: url, relativePath: relative, match: match) + } + + private static func shouldSkipDirectory(named name: String, level: Int) -> Bool { + if level > maxDepth { + return true + } + if excludedDirectories.contains(name) { + return true + } + return name.hasSuffix(".dist-info") + } + + private static let deniedPrefixes: [String] = { + let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + return [ + home + "/.ssh", + home + "/.aws", + home + "/.gnupg", + home + "/.docker", + home + "/Library/Keychains", + "/etc", + "/private/etc", + ] + }() +} diff --git a/TablePro/Core/Services/ProjectImport/ProjectFolderScanner.swift b/TablePro/Core/Services/ProjectImport/ProjectFolderScanner.swift new file mode 100644 index 000000000..2b363a9db --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ProjectFolderScanner.swift @@ -0,0 +1,149 @@ +// +// ProjectFolderScanner.swift +// TablePro +// + +import Foundation +import os + +enum ProjectFolderScanError: Error, LocalizedError, Equatable { + case rootUnreadable + + var errorDescription: String? { + switch self { + case .rootUnreadable: + return String(localized: "That folder could not be read.") + } + } +} + +struct ProjectFolderScanResult { + let candidates: [ScannedConnectionCandidate] + let scannedFileCount: Int +} + +enum ProjectFolderScanner { + private static let logger = Logger(subsystem: "com.TablePro", category: "ProjectFolderScanner") + + static func scan( + rootURL: URL, + fileManager: FileManager = .default + ) -> Result { + var isDirectory: ObjCBool = false + guard fileManager.fileExists(atPath: rootURL.path, isDirectory: &isDirectory), isDirectory.boolValue else { + return .failure(.rootUnreadable) + } + let files = ProjectFolderFileWalker.walk(root: rootURL, fileManager: fileManager) + let index = ProjectScanEnvironmentIndex(files: files, rootURL: rootURL) + var candidates: [ScannedConnectionCandidate] = [] + for file in files { + candidates.append(contentsOf: extract( + file: file, + rootURL: rootURL, + index: index, + fileManager: fileManager + )) + } + candidates.append(contentsOf: parentWordPressCandidates(rootURL: rootURL, fileManager: fileManager)) + let ranked = ScannedConnectionRanking.rankAndDeduplicate(candidates) + logger.info( + "Scanned \(files.count, privacy: .public) config files, produced \(ranked.count, privacy: .public) candidates" + ) + return .success(ProjectFolderScanResult(candidates: ranked, scannedFileCount: files.count)) + } + + private static func extract( + file: ProjectScanFile, + rootURL: URL, + index: ProjectScanEnvironmentIndex, + fileManager: FileManager + ) -> [ScannedConnectionCandidate] { + let directoryURL = file.url.deletingLastPathComponent() + switch file.match.kind { + case .dotenv: + guard let document = index.document(forFile: file.url) else { + return [] + } + return DotenvConnectionExtractor.extract( + document: document, + relativePath: file.relativePath, + tier: file.match.tier, + directoryURL: directoryURL + ) + case .wordPressConfig: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return WordPressConfigExtractor.extract(contents: contents, relativePath: file.relativePath) + case .prismaSchema: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return PrismaSchemaExtractor.extract( + contents: contents, + relativePath: file.relativePath, + directoryURL: directoryURL, + projectRootURL: rootURL, + environment: index.environment(near: file.url), + fileManager: fileManager + ) + case .springProperties: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return SpringPropertiesExtractor.extract(contents: contents, relativePath: file.relativePath) + case .springYaml: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return SpringYamlExtractor.extract(contents: contents, relativePath: file.relativePath) + case .appSettingsJson: + guard let data = ProjectFileReader.data(at: file.url) else { + return [] + } + return AppSettingsJsonExtractor.extract(data: data, relativePath: file.relativePath) + case .railsDatabaseYaml: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return RailsDatabaseYamlExtractor.extract( + contents: contents, + relativePath: file.relativePath, + projectRootURL: rootURL, + environment: index.environment(near: file.url) + ) + case .dockerCompose: + guard let contents = ProjectFileReader.text(at: file.url) else { + return [] + } + return DockerComposeExtractor.extract( + contents: contents, + relativePath: file.relativePath, + environment: index.environment(near: file.url) + ) + } + } + + private static func parentWordPressCandidates( + rootURL: URL, + fileManager: FileManager + ) -> [ScannedConnectionCandidate] { + let parent = rootURL.standardizedFileURL.deletingLastPathComponent() + let candidateURL = parent.appendingPathComponent("wp-config.php") + let path = candidateURL.standardizedFileURL.path + guard !ProjectFolderFileWalker.isDenied(path) else { + return [] + } + let keys: Set = [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey] + guard let values = try? candidateURL.resourceValues(forKeys: keys), + values.isRegularFile == true, + values.isSymbolicLink != true, + (values.fileSize ?? 0) <= ProjectFolderFileWalker.maxFileSize else { + return [] + } + guard let contents = ProjectFileReader.text(at: candidateURL) else { + return [] + } + return WordPressConfigExtractor.extract(contents: contents, relativePath: "../wp-config.php") + } +} diff --git a/TablePro/Core/Services/ProjectImport/ProjectScanEnvironmentIndex.swift b/TablePro/Core/Services/ProjectImport/ProjectScanEnvironmentIndex.swift new file mode 100644 index 000000000..b67bfe980 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ProjectScanEnvironmentIndex.swift @@ -0,0 +1,53 @@ +// +// ProjectScanEnvironmentIndex.swift +// TablePro +// + +import Foundation + +enum ProjectFileReader { + static func text(at url: URL) -> String? { + if let utf8 = try? String(contentsOf: url, encoding: .utf8) { + return utf8 + } + return try? String(contentsOf: url, encoding: .isoLatin1) + } + + static func data(at url: URL) -> Data? { + try? Data(contentsOf: url) + } +} + +struct ProjectScanEnvironmentIndex { + private var documentsByFile: [String: DotenvDocument] = [:] + private var documentsByDirectory: [String: DotenvDocument] = [:] + private var rootDocument: DotenvDocument? + + init(files: [ProjectScanFile], rootURL: URL) { + let rootPath = rootURL.standardizedFileURL.path + for file in files where file.match.kind == .dotenv { + guard let contents = ProjectFileReader.text(at: file.url) else { + continue + } + let document = DotenvParser.parse(contents) + let filePath = file.url.standardizedFileURL.path + let directory = file.url.deletingLastPathComponent().standardizedFileURL.path + documentsByFile[filePath] = document + if documentsByDirectory[directory] == nil || file.match.tier == .nearCertain { + documentsByDirectory[directory] = document + } + if directory == rootPath, rootDocument == nil || file.match.tier == .nearCertain { + rootDocument = document + } + } + } + + func document(forFile url: URL) -> DotenvDocument? { + documentsByFile[url.standardizedFileURL.path] + } + + func environment(near url: URL) -> DotenvDocument? { + let directory = url.deletingLastPathComponent().standardizedFileURL.path + return documentsByDirectory[directory] ?? rootDocument + } +} diff --git a/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift b/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift new file mode 100644 index 000000000..d5750e50f --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/RailsDatabaseYamlExtractor.swift @@ -0,0 +1,183 @@ +// +// RailsDatabaseYamlExtractor.swift +// TablePro +// + +import Foundation + +enum RailsDatabaseYamlExtractor { + private static let preferredEnvironments = ["development", "production", "staging", "test"] + + static func extract( + contents: String, + relativePath: String, + projectRootURL: URL, + environment: DotenvDocument? + ) -> [ScannedConnectionCandidate] { + guard let root = YamlMappingSupport.loadMapping(contents) else { + return [] + } + guard let selected = selectEnvironment(root) else { + return [] + } + guard let adapter = YamlMappingSupport.string(selected.mapping["adapter"]), + let type = adapterType(adapter) else { + return [] + } + var warnings: [String] = [] + var fields = ScannedConnectionFields(type: type) + let resolver = ErbValueResolver(environment: environment) + fields.database = resolver.resolve( + YamlMappingSupport.string(selected.mapping["database"]), + warnings: &warnings + ) ?? "" + guard type != .sqlite else { + fields.database = absoluteSQLitePath(fields.database, projectRootURL: projectRootURL) + return [candidate(fields: fields, relativePath: relativePath, key: selected.key, warnings: warnings)] + } + fields.host = resolver.resolve( + YamlMappingSupport.string(selected.mapping["host"]), + warnings: &warnings + ) ?? "127.0.0.1" + fields.username = resolver.resolve( + YamlMappingSupport.string(selected.mapping["username"]), + warnings: &warnings + ) ?? "" + fields.password = resolver.resolve( + YamlMappingSupport.string(selected.mapping["password"]), + warnings: &warnings + ) ?? "" + let port = resolver.resolve(YamlMappingSupport.string(selected.mapping["port"]), warnings: &warnings) + fields.port = port.flatMap { Int($0) } ?? type.defaultPort + return [candidate(fields: fields, relativePath: relativePath, key: selected.key, warnings: warnings)] + } + + static func selectEnvironment(_ root: [String: Any]) -> (key: String, mapping: [String: Any])? { + for name in preferredEnvironments { + guard let mapping = YamlMappingSupport.mapping(root[name]) else { + continue + } + if mapping["adapter"] != nil { + return (name, mapping) + } + if let nested = firstNestedDatabase(mapping) { + return ("\(name).\(nested.key)", nested.mapping) + } + } + return nil + } + + private static func firstNestedDatabase(_ mapping: [String: Any]) -> (key: String, mapping: [String: Any])? { + for key in mapping.keys.sorted() { + guard let nested = YamlMappingSupport.mapping(mapping[key]), nested["adapter"] != nil else { + continue + } + return (key, nested) + } + return nil + } + + private static func candidate( + fields: ScannedConnectionFields, + relativePath: String, + key: String, + warnings: [String] + ) -> ScannedConnectionCandidate { + ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: key, + kind: .railsDatabaseYaml, + tier: .configFile, + warnings: warnings + ) + } + + private static func absoluteSQLitePath(_ path: String, projectRootURL: URL) -> String { + guard !path.isEmpty else { + return projectRootURL.appendingPathComponent("storage/development.sqlite3").path + } + guard !path.hasPrefix("/") else { + return path + } + return projectRootURL.appendingPathComponent(path).standardizedFileURL.path + } + + private static func adapterType(_ adapter: String) -> DatabaseType? { + switch adapter.lowercased() { + case "postgresql", "postgis", "postgres": + return .postgresql + case "mysql2", "mysql", "trilogy": + return .mysql + case "sqlite3", "sqlite": + return .sqlite + case "sqlserver": + return .mssql + default: + return nil + } + } +} + +struct ErbValueResolver { + let environment: DotenvDocument? + + func resolve(_ value: String?, warnings: inout [String]) -> String? { + guard let value else { + return nil + } + guard value.contains("<%") else { + return value + } + guard let name = environmentVariableName(in: value) else { + warnings.append(unresolvedWarning) + return nil + } + if let resolved = environment?[name] { + return resolved + } + if let fallback = fallbackValue(in: value) { + return fallback + } + warnings.append(unresolvedWarning) + return nil + } + + private var unresolvedWarning: String { + String(localized: "Some values come from Ruby code and were not read") + } + + private func environmentVariableName(in value: String) -> String? { + guard let range = value.range(of: #"ENV(?:\.fetch)?\s*[\[(]\s*['"]([^'"]+)['"]"#, options: .regularExpression) else { + return nil + } + let matched = String(value[range]) + guard let quoteStart = matched.range(of: #"['"]"#, options: .regularExpression) else { + return nil + } + let remainder = matched[quoteStart.upperBound...] + guard let quoteEnd = remainder.range(of: #"['"]"#, options: .regularExpression) else { + return nil + } + return String(remainder[remainder.startIndex.. String? { + guard let range = value.range(of: #"ENV\.fetch\s*\(\s*['"][^'"]+['"]\s*,\s*['"]([^'"]*)['"]"#, options: .regularExpression) else { + return nil + } + let matched = String(value[range]) + guard let comma = matched.range(of: ",") else { + return nil + } + let tail = matched[comma.upperBound...] + guard let quoteStart = tail.range(of: #"['"]"#, options: .regularExpression) else { + return nil + } + let remainder = tail[quoteStart.upperBound...] + guard let quoteEnd = remainder.range(of: #"['"]"#, options: .regularExpression) else { + return nil + } + return String(remainder[remainder.startIndex.. = [ + "!changeme!", + "changeme", + "change_me", + "change-me", + "johndoe", + "randompassword", + "username_here", + "password_here", + "database_name_here", + "your_username", + "your_password", + "your_database", + "yourusername", + "yourpassword", + "putyourpasswordhere", + ] + + static func isPlaceholder(username: String, password: String, database: String) -> Bool { + [username, password, database].contains { literals.contains($0.lowercased()) } + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift b/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift new file mode 100644 index 000000000..05ad4cd3e --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedConnectionFields.swift @@ -0,0 +1,62 @@ +// +// ScannedConnectionFields.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +struct ScannedConnectionFields { + var type: DatabaseType + var host = "" + var port: Int? + var database = "" + var username = "" + var password = "" + var sslMode: SSLMode? + var oracleServiceName: String? + var redisDatabase: Int? + var connectionName: String? + var safeModeLevel: Int? + + init(type: DatabaseType) { + self.type = type + } + + func toParsedConnectionURL() -> ParsedConnectionURL { + ParsedConnectionURL( + type: type, + host: host, + port: port, + database: database, + username: username, + password: password, + sslMode: sslMode, + authSource: nil, + sshHost: nil, + sshPort: nil, + sshUsername: nil, + sshPassword: nil, + usePrivateKey: nil, + useSSHAgent: nil, + sshNoAuth: nil, + agentSocket: nil, + connectionName: connectionName, + redisDatabase: redisDatabase, + statusColor: nil, + envTag: nil, + schema: nil, + tableName: nil, + isView: false, + filterColumn: nil, + filterOperation: nil, + filterValue: nil, + filterCondition: nil, + oracleServiceName: oracleServiceName, + safeModeLevel: safeModeLevel, + useSrv: false, + mongoQueryParams: [:], + multiHost: nil + ) + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedConnectionRanking.swift b/TablePro/Core/Services/ProjectImport/ScannedConnectionRanking.swift new file mode 100644 index 000000000..72a47cf56 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedConnectionRanking.swift @@ -0,0 +1,42 @@ +// +// ScannedConnectionRanking.swift +// TablePro +// + +import Foundation + +enum ScannedConnectionRanking { + static func rankAndDeduplicate(_ candidates: [ScannedConnectionCandidate]) -> [ScannedConnectionCandidate] { + let ordered = candidates.sorted { lhs, rhs in + if lhs.tier != rhs.tier { + return lhs.tier < rhs.tier + } + if lhs.sourceRelativePath != rhs.sourceRelativePath { + return lhs.sourceRelativePath < rhs.sourceRelativePath + } + return lhs.sourceKey < rhs.sourceKey + } + var seen: Set = [] + var result: [ScannedConnectionCandidate] = [] + for candidate in ordered { + let key = identity(of: candidate) + guard !seen.contains(key) else { + continue + } + seen.insert(key) + result.append(candidate) + } + return result + } + + static func identity(of candidate: ScannedConnectionCandidate) -> String { + let parsed = candidate.parsedURL + return [ + parsed.type.rawValue, + parsed.host.lowercased(), + String(parsed.port ?? parsed.type.defaultPort), + parsed.database.lowercased(), + parsed.username.lowercased(), + ].joined(separator: "\u{1F}") + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedConnectionURLBuilder.swift b/TablePro/Core/Services/ProjectImport/ScannedConnectionURLBuilder.swift new file mode 100644 index 000000000..2ab924679 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedConnectionURLBuilder.swift @@ -0,0 +1,96 @@ +// +// ScannedConnectionURLBuilder.swift +// TablePro +// + +import Foundation + +enum ScannedConnectionURLBuilder { + private static let schemeAliases: [String: String] = [ + "pgsql": "postgresql", + "psql": "postgresql", + "postgis": "postgresql", + "timescale": "postgresql", + "timescalegis": "postgresql", + "mysql2": "mysql", + "trilogy": "mysql", + "mysql-connector": "mysql", + "mysqlgis": "mysql", + "sqlite3": "sqlite", + "spatialite": "sqlite", + "sqlsrv": "mssql", + "mssqlms": "mssql", + "cockroach": "cockroachdb", + "oraclegis": "oracle", + ] + + private static let rejectedSchemes: Set = ["prisma", "prisma+postgres"] + + static func parse(_ rawValue: String) -> ParsedConnectionURL? { + guard let prepared = prepare(rawValue) else { + return nil + } + guard case .success(let parsed) = ConnectionURLParser.parse(prepared) else { + return nil + } + return parsed + } + + static func prepare(_ rawValue: String) -> String? { + var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + return nil + } + value = strippingQuotes(value) + let lowercased = value.lowercased() + if lowercased.hasPrefix("jdbc:"), + !lowercased.hasPrefix("jdbc:sqlserver:"), + !lowercased.hasPrefix("jdbc:oracle:") { + value = String(value.dropFirst("jdbc:".count)) + } + guard let schemeRange = value.range(of: "://") else { + return nil + } + let scheme = value[value.startIndex.. ScannedConnectionCandidate? { + guard let parsed = parse(rawValue) else { + return nil + } + return ScannedConnectionCandidate( + parsedURL: parsed, + sourceRelativePath: relativePath, + sourceKey: key, + kind: kind, + tier: tier, + warnings: warnings + ) + } + + private static func strippingQuotes(_ value: String) -> String { + guard value.count >= 2 else { + return value + } + let first = value.first + let last = value.last + guard first == last, first == "\"" || first == "'" else { + return value + } + return String(value.dropFirst().dropLast()) + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedProductionHeuristic.swift b/TablePro/Core/Services/ProjectImport/ScannedProductionHeuristic.swift new file mode 100644 index 000000000..bc5a700b6 --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedProductionHeuristic.swift @@ -0,0 +1,19 @@ +// +// ScannedProductionHeuristic.swift +// TablePro +// + +import Foundation + +enum ScannedProductionHeuristic { + private static let markers: Set = ["prod", "production", "live"] + + static func isProduction(relativePath: String, host: String, database: String) -> Bool { + [relativePath, host, database].contains(where: containsMarker) + } + + private static func containsMarker(_ value: String) -> Bool { + let tokens = value.lowercased().split { !$0.isLetter && !$0.isNumber } + return tokens.contains { markers.contains(String($0)) } + } +} diff --git a/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift b/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift new file mode 100644 index 000000000..9852e9d7f --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/ScannedURLNormalizer.swift @@ -0,0 +1,51 @@ +// +// ScannedURLNormalizer.swift +// TablePro +// + +import Foundation + +enum ScannedURLNormalizer { + private static let userInfoAllowed: CharacterSet = { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-._~%") + return allowed + }() + + static func normalize(_ urlString: String) -> String { + let trimmed = urlString.trimmingCharacters(in: .whitespacesAndNewlines) + guard let schemeRange = trimmed.range(of: "://") else { + return trimmed + } + let scheme = String(trimmed[trimmed.startIndex.. String.Index? { + let boundary = remainder.firstIndex { $0 == "?" || $0 == "#" } ?? remainder.endIndex + let searchable = remainder[remainder.startIndex.. String { + guard let colon = userInfo.firstIndex(of: ":") else { + return percentEncoded(userInfo) + } + let username = String(userInfo[userInfo.startIndex.. String { + component.addingPercentEncoding(withAllowedCharacters: userInfoAllowed) ?? component + } +} diff --git a/TablePro/Core/Services/ProjectImport/SpringPropertiesExtractor.swift b/TablePro/Core/Services/ProjectImport/SpringPropertiesExtractor.swift new file mode 100644 index 000000000..82d01e97d --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/SpringPropertiesExtractor.swift @@ -0,0 +1,109 @@ +// +// SpringPropertiesExtractor.swift +// TablePro +// + +import Foundation + +enum SpringPropertiesExtractor { + static func extract( + contents: String, + relativePath: String, + processEnvironment: [String: String] = ProcessInfo.processInfo.environment + ) -> [ScannedConnectionCandidate] { + let properties = parse(contents) + guard let rawURL = properties["spring.datasource.url"] else { + return [] + } + let url = resolvePlaceholders(rawURL, processEnvironment: processEnvironment) + guard let parsed = ScannedConnectionURLBuilder.parse(url) else { + return [] + } + let username = properties["spring.datasource.username"].map { + resolvePlaceholders($0, processEnvironment: processEnvironment) + } ?? "" + let password = properties["spring.datasource.password"].map { + resolvePlaceholders($0, processEnvironment: processEnvironment) + } ?? "" + let merged = parsed.with( + username: parsed.username.isEmpty ? username : nil, + password: parsed.password.isEmpty ? password : nil + ) + let candidate = ScannedConnectionCandidate( + parsedURL: merged, + sourceRelativePath: relativePath, + sourceKey: "spring.datasource.url", + kind: .springProperties, + tier: .configFile + ) + return [candidate] + } + + static func parse(_ contents: String) -> [String: String] { + var properties: [String: String] = [:] + var pending = "" + for rawLine in DotenvParser.normalize(contents).components(separatedBy: "\n") { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if pending.isEmpty, line.isEmpty || line.hasPrefix("#") || line.hasPrefix("!") { + continue + } + if line.hasSuffix("\\") { + pending += String(line.dropLast()) + continue + } + let full = pending + line + pending = "" + guard let pair = splitPair(full) else { + continue + } + properties[pair.key] = pair.value + } + return properties + } + + private static func splitPair(_ line: String) -> (key: String, value: String)? { + let separators: Set = ["=", ":"] + guard let index = line.firstIndex(where: { separators.contains($0) }) else { + return nil + } + let key = String(line[line.startIndex.. String { + guard value.contains("${") else { + return value + } + var result = "" + var remainder = Substring(value) + while let open = remainder.range(of: "${") { + result += remainder[remainder.startIndex.. String { + guard let colon = reference.firstIndex(of: ":") else { + return processEnvironment[reference] ?? "${\(reference)}" + } + let name = String(reference[reference.startIndex.. [ScannedConnectionCandidate] { + for document in YamlMappingSupport.loadDocuments(contents) { + guard let rawURL = value(in: document, path: ["spring", "datasource", "url"]) else { + continue + } + let url = SpringPropertiesExtractor.resolvePlaceholders(rawURL, processEnvironment: processEnvironment) + guard let parsed = ScannedConnectionURLBuilder.parse(url) else { + continue + } + let username = value(in: document, path: ["spring", "datasource", "username"]).map { + SpringPropertiesExtractor.resolvePlaceholders($0, processEnvironment: processEnvironment) + } ?? "" + let password = value(in: document, path: ["spring", "datasource", "password"]).map { + SpringPropertiesExtractor.resolvePlaceholders($0, processEnvironment: processEnvironment) + } ?? "" + let merged = parsed.with( + username: parsed.username.isEmpty ? username : nil, + password: parsed.password.isEmpty ? password : nil + ) + let candidate = ScannedConnectionCandidate( + parsedURL: merged, + sourceRelativePath: relativePath, + sourceKey: "spring.datasource.url", + kind: .springYaml, + tier: .configFile + ) + return [candidate] + } + return [] + } + + static func value(in document: [String: Any], path: [String]) -> String? { + if let flattened = YamlMappingSupport.string(document[path.joined(separator: ".")]) { + return flattened + } + var current: [String: Any] = document + for (offset, key) in path.enumerated() { + let isLast = offset == path.count - 1 + if isLast { + return YamlMappingSupport.string(current[key]) + } + guard let nested = YamlMappingSupport.mapping(current[key]) else { + let remainder = path[offset...].joined(separator: ".") + return YamlMappingSupport.string(current[remainder]) + } + current = nested + } + return nil + } +} diff --git a/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift b/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift new file mode 100644 index 000000000..48737007c --- /dev/null +++ b/TablePro/Core/Services/ProjectImport/WordPressConfigExtractor.swift @@ -0,0 +1,81 @@ +// +// WordPressConfigExtractor.swift +// TablePro +// + +import Foundation + +enum WordPressConfigExtractor { + private static let definePattern = #"define\(\s*['"]([A-Za-z_]+)['"]\s*,\s*['"]([^'"]*)['"]\s*\)"# + + static func extract(contents: String, relativePath: String) -> [ScannedConnectionCandidate] { + let constants = parseConstants(contents) + guard let database = constants["DB_NAME"] else { + return [] + } + var fields = ScannedConnectionFields(type: .mysql) + fields.database = database + fields.username = constants["DB_USER"] ?? "" + fields.password = constants["DB_PASSWORD"] ?? "" + var warnings: [String] = [] + let address = splitHost(constants["DB_HOST"] ?? "localhost") + fields.host = address.host + fields.port = address.port ?? 3_306 + if address.isSocket { + warnings.append(String(localized: "Host looks like a socket path")) + } + let candidate = ScannedConnectionCandidate( + parsedURL: fields.toParsedConnectionURL(), + sourceRelativePath: relativePath, + sourceKey: "DB_NAME", + kind: .wordPressConfig, + tier: .configFile, + warnings: warnings + ) + return [candidate] + } + + static func parseConstants(_ contents: String) -> [String: String] { + guard let regex = try? NSRegularExpression(pattern: definePattern) else { + return [:] + } + var constants: [String: String] = [:] + var inBlockComment = false + for rawLine in contents.components(separatedBy: .newlines) { + let line = rawLine.trimmingCharacters(in: .whitespaces) + if inBlockComment { + if line.contains("*/") { + inBlockComment = false + } + continue + } + if line.hasPrefix("/*") { + inBlockComment = !line.contains("*/") + continue + } + if line.hasPrefix("//") || line.hasPrefix("#") || line.hasPrefix("*") { + continue + } + let range = NSRange(line.startIndex.. (host: String, port: Int?, isSocket: Bool) { + guard let colon = value.firstIndex(of: ":") else { + return (value, nil, false) + } + let head = String(value[value.startIndex.. [String: Any]? { + guard let object = try? Yams.load(yaml: contents) else { + return nil + } + guard let mapping = object as? [String: Any] else { + return nil + } + return expandingMergeKeys(mapping) + } + + static func loadDocuments(_ contents: String) -> [[String: Any]] { + guard let objects = try? Yams.load_all(yaml: contents) else { + return [] + } + return objects.compactMap { $0 as? [String: Any] }.map(expandingMergeKeys) + } + + static func expandingMergeKeys(_ mapping: [String: Any]) -> [String: Any] { + var own: [String: Any] = [:] + var inherited: [String: Any] = [:] + for (key, value) in mapping { + guard key == "<<" else { + own[key] = expand(value) + continue + } + for source in mergeSources(value) where !source.isEmpty { + for (mergeKey, mergeValue) in source where inherited[mergeKey] == nil { + inherited[mergeKey] = mergeValue + } + } + } + var result = inherited + for (key, value) in own { + result[key] = value + } + return result + } + + static func mapping(_ value: Any?) -> [String: Any]? { + guard let mapping = value as? [String: Any] else { + return nil + } + return expandingMergeKeys(mapping) + } + + static func string(_ value: Any?) -> String? { + switch value { + case let text as String: + let trimmed = text.trimmingCharacters(in: .whitespaces) + return trimmed.isEmpty ? nil : trimmed + case let number as Int: + return String(number) + case let flag as Bool: + return flag ? "true" : "false" + default: + return nil + } + } + + static func int(_ value: Any?) -> Int? { + if let number = value as? Int { + return number + } + guard let text = string(value) else { + return nil + } + return Int(text) + } + + private static func expand(_ value: Any) -> Any { + if let mapping = value as? [String: Any] { + return expandingMergeKeys(mapping) + } + if let list = value as? [Any] { + return list.map(expand) + } + return value + } + + private static func mergeSources(_ value: Any) -> [[String: Any]] { + if let mapping = value as? [String: Any] { + return [expandingMergeKeys(mapping)] + } + if let list = value as? [Any] { + return list.compactMap { $0 as? [String: Any] }.map(expandingMergeKeys) + } + return [] + } +} diff --git a/TablePro/Info.plist b/TablePro/Info.plist index 86c1f2480..a8716e9f2 100644 --- a/TablePro/Info.plist +++ b/TablePro/Info.plist @@ -278,6 +278,12 @@ + NSDesktopFolderUsageDescription + TablePro reads database settings from the project folder you choose. + NSDocumentsFolderUsageDescription + TablePro reads database settings from the project folder you choose. + NSDownloadsFolderUsageDescription + TablePro reads database settings from the project folder you choose. NSAppTransportSecurity NSAllowsArbitraryLoads diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 8f26ff4c4..f358f3211 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -355,17 +355,30 @@ struct AppMenuCommands: Commands { Divider() Button(String(localized: "Export Connections...")) { + WindowOpener.shared.openWelcome() AppCommands.shared.exportConnections.send(()) } Button(String(localized: "Import Connections...")) { + WindowOpener.shared.openWelcome() AppCommands.shared.importConnections.send(()) } + Button(String(localized: "Import from URL...")) { + WindowOpener.shared.openWelcome() + AppCommands.shared.importConnectionFromURL.send(()) + } + Button(String(localized: "Import from Other App...")) { + WindowOpener.shared.openWelcome() AppCommands.shared.importConnectionsFromApp.send(()) } + Button(String(localized: "Open Project Folder...")) { + WindowOpener.shared.openWelcome() + AppCommands.shared.openProjectFolder.send(()) + } + Divider() Button("Export...") { diff --git a/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift b/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift new file mode 100644 index 000000000..7470e3644 --- /dev/null +++ b/TablePro/ViewModels/WelcomeViewModel+ProjectFolder.swift @@ -0,0 +1,59 @@ +// +// WelcomeViewModel+ProjectFolder.swift +// TablePro +// + +import AppKit +import Combine +import os + +extension WelcomeViewModel { + private static let projectFolderLogger = Logger( + subsystem: "com.TablePro", + category: "WelcomeProjectFolder" + ) + + func setUpProjectFolderCommand() { + openProjectFolderCancellable = AppCommands.shared.openProjectFolder + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.openProjectFolder() + } + } + + func openProjectFolder() { + Task { @MainActor in + await presentProjectFolderPicker() + } + } + + @MainActor + private func presentProjectFolderPicker() async { + guard let window = Self.hostWindow() else { + Self.projectFolderLogger.error("No window available to present the project folder picker") + return + } + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Choose Folder") + panel.message = String(localized: "Choose a project folder to read its database settings.") + let response = await panel.presentAsSheet(for: window) + guard response == .OK, let url = panel.url else { + return + } + NSDocumentController.shared.noteNewRecentDocumentURL(url) + activeSheet = .projectFolderScan(url) + } + + static func hostWindow() -> NSWindow? { + if let key = NSApp.keyWindow { + return key + } + if let main = NSApp.mainWindow { + return main + } + return NSApp.windows.first { $0.isVisible } + } +} diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift index de4d8bf64..8ddf836d3 100644 --- a/TablePro/ViewModels/WelcomeViewModel.swift +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -16,6 +16,7 @@ enum WelcomeActiveSheet: Identifiable { case importFile(URL) case exportConnections([DatabaseConnection]) case importFromApp + case projectFolderScan(URL) case deeplinkImport(ExportableConnection) var id: String { @@ -25,6 +26,7 @@ enum WelcomeActiveSheet: Identifiable { case .importFile(let u): "importFile-\(u.absoluteString)" case .exportConnections: "exportConnections" case .importFromApp: "importFromApp" + case .projectFolderScan(let u): "projectFolderScan-\(u.absoluteString)" case .deeplinkImport(let c): "deeplinkImport-\(c.type)-\(c.name)-\(c.host)-\(c.port)" } } @@ -95,6 +97,7 @@ final class WelcomeViewModel { @ObservationIgnored private var exportConnectionsCancellable: AnyCancellable? @ObservationIgnored private var importConnectionsCancellable: AnyCancellable? @ObservationIgnored private var importFromAppCancellable: AnyCancellable? + @ObservationIgnored var openProjectFolderCancellable: AnyCancellable? @ObservationIgnored private var welcomeRouterTask: Task? @ObservationIgnored private var searchDebounceTask: Task? private static let searchDebounceNanoseconds: UInt64 = 150_000_000 @@ -211,6 +214,8 @@ final class WelcomeViewModel { self?.activeSheet = .importFromApp } + setUpProjectFolderCommand() + linkedFoldersCancellable = services.appEvents.linkedFoldersDidUpdate .receive(on: RunLoop.main) .sink { [weak self] _ in diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift new file mode 100644 index 000000000..b848130d6 --- /dev/null +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderCandidateList.swift @@ -0,0 +1,134 @@ +// +// ProjectFolderCandidateList.swift +// TablePro +// + +import SwiftUI +import TableProPluginKit + +struct ProjectFolderCandidateList: View { + let candidates: [ScannedConnectionCandidate] + let projectRoot: URL + @Binding var selection: UUID? + + var body: some View { + List(selection: $selection) { + ForEach(candidates) { candidate in + ProjectFolderCandidateRow(candidate: candidate, projectRoot: projectRoot) + .tag(candidate.id) + } + } + .listStyle(.bordered) + } +} + +struct ProjectFolderCandidateRow: View { + let candidate: ScannedConnectionCandidate + let projectRoot: URL + + var body: some View { + HStack(spacing: 8) { + candidate.parsedURL.type.iconImage + .frame(width: 16, height: 16) + + VStack(alignment: .leading, spacing: 1) { + Text(verbatim: destination) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + .help(helpText) + Text(verbatim: provenance) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer(minLength: 8) + + accessories + } + .padding(.vertical, 2) + } + + @ViewBuilder + private var accessories: some View { + HStack(spacing: 4) { + if candidate.placeholderSuspected { + accessory( + "questionmark.circle.fill", + label: String(localized: "Looks like a placeholder value"), + tint: .orange + ) + } + if let warning = candidate.warnings.first { + accessory("exclamationmark.triangle.fill", label: warning, tint: .orange) + } + if candidate.hasPassword { + accessory("key.fill", label: String(localized: "Password found"), tint: nil) + } + } + } + + @ViewBuilder + private func accessory(_ systemName: String, label: String, tint: Color?) -> some View { + let symbol = Image(systemName: systemName) + .imageScale(.medium) + .help(label) + .accessibilityLabel(label) + if let tint { + symbol.selectionAwareForeground(tint) + } else { + symbol.foregroundStyle(.secondary) + } + } + + private var snapshot: PluginMetadataSnapshot? { + PluginMetadataRegistry.shared.snapshot(forTypeId: candidate.parsedURL.type.rawValue) + } + + private var typeName: String { + snapshot?.displayName ?? candidate.parsedURL.type.rawValue + } + + private var destination: String { + let parsed = candidate.parsedURL + switch snapshot?.connectionMode ?? .network { + case .fileBased: + return displayPath(parsed.database) + case .apiOnly: + return parsed.host.isEmpty ? typeName : parsed.host + case .network: + if let multiHost = parsed.multiHost, multiHost.contains(",") { + return multiHost + } + guard !parsed.host.isEmpty else { + return parsed.database.isEmpty ? typeName : parsed.database + } + return parsed.port.map { "\(parsed.host):\($0)" } ?? parsed.host + } + } + + private var provenance: String { + [typeName, candidate.sourceRelativePath, candidate.sourceKey].joined(separator: " · ") + } + + private var helpText: String { + let parsed = candidate.parsedURL + guard (snapshot?.connectionMode ?? .network) == .fileBased, !parsed.database.isEmpty else { + return provenance + } + return parsed.database + } + + private func displayPath(_ path: String) -> String { + guard !path.isEmpty else { + return typeName + } + let root = projectRoot.standardizedFileURL.path + if path.hasPrefix(root + "/") { + return String(path.dropFirst(root.count + 1)) + } + return (path as NSString).abbreviatingWithTildeInPath + } +} diff --git a/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift new file mode 100644 index 000000000..d4f7ae3a2 --- /dev/null +++ b/TablePro/Views/Connection/ProjectFolder/ProjectFolderScanSheet.swift @@ -0,0 +1,169 @@ +// +// ProjectFolderScanSheet.swift +// TablePro +// + +import SwiftUI + +struct ProjectFolderScanSheet: View { + let rootURL: URL + let onSelect: (ParsedConnectionURL) -> Void + let onChooseAnotherFolder: () -> Void + + @State private var step: Step = .scanning + @State private var selection: UUID? + @Environment(\.dismiss) private var dismiss + + private enum Step { + case scanning + case results([ScannedConnectionCandidate]) + case empty + case error(String) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + header + content + footer + } + .padding(20) + .frame(width: 520, height: 440) + .task(id: rootURL) { + await runScan() + } + } + + private var projectName: String { + rootURL.lastPathComponent + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "Open Project Folder")) + .font(.headline) + Text(String(format: String(localized: "Connections found in %@"), projectName)) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var content: some View { + switch step { + case .scanning: + centered { + ProgressView() + Text(String(localized: "Looking for database settings…")) + .font(.subheadline) + .foregroundStyle(.secondary) + } + case .results(let candidates): + VStack(alignment: .leading, spacing: 6) { + ProjectFolderCandidateList( + candidates: candidates, + projectRoot: rootURL, + selection: $selection + ) + detailStrip + } + case .empty: + centered { + Image(systemName: "folder.badge.questionmark") + .font(.largeTitle) + .foregroundStyle(.tertiary) + Text(String(localized: "No database settings found in this folder.")) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + case .error(let message): + centered { + Image(systemName: "exclamationmark.triangle.fill") + .font(.largeTitle) + .foregroundStyle(.orange) + Text(message) + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + } + + private var footer: some View { + HStack { + Button(String(localized: "Choose Another Folder…")) { + dismiss() + onChooseAnotherFolder() + } + Spacer() + Button(String(localized: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(String(localized: "Continue")) { + submit() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(selectedCandidate == nil) + } + } + + private var detailStrip: some View { + Text(verbatim: selectedCandidate.map(Self.notes) ?? "") + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(2) + .frame(maxWidth: .infinity, minHeight: 30, alignment: .topLeading) + } + + private static func notes(for candidate: ScannedConnectionCandidate) -> String { + var notes = candidate.warnings + if candidate.placeholderSuspected { + notes.insert(String(localized: "Looks like a placeholder value"), at: 0) + } + return notes.joined(separator: " · ") + } + + private var selectedCandidate: ScannedConnectionCandidate? { + guard case .results(let candidates) = step, let selection else { + return nil + } + return candidates.first { $0.id == selection } + } + + private func centered(@ViewBuilder _ content: () -> Content) -> some View { + VStack(spacing: 10) { + Spacer() + content() + Spacer() + } + .frame(maxWidth: .infinity) + } + + private func submit() { + guard let candidate = selectedCandidate else { + return + } + dismiss() + onSelect(candidate.parsedURL) + } + + private func runScan() async { + step = .scanning + selection = nil + let url = rootURL + let result = await Task.detached(priority: .userInitiated) { + ProjectFolderScanner.scan(rootURL: url) + }.value + switch result { + case .success(let scan): + step = scan.candidates.isEmpty ? .empty : .results(scan.candidates) + selection = scan.candidates.first?.id + case .failure(let error): + step = .error(error.localizedDescription) + } + } +} diff --git a/TablePro/Views/Connection/WelcomeActionsPanel.swift b/TablePro/Views/Connection/WelcomeActionsPanel.swift index e65285b49..4e9b53bc3 100644 --- a/TablePro/Views/Connection/WelcomeActionsPanel.swift +++ b/TablePro/Views/Connection/WelcomeActionsPanel.swift @@ -8,8 +8,10 @@ import SwiftUI struct WelcomeActionsPanel: View { let onActivateLicense: () -> Void let onCreateConnection: () -> Void + let onImportFromURL: () -> Void let onImportFromApp: () -> Void - let onTrySample: () -> Void + let onOpenProjectFolder: () -> Void + let onImportConnectionsFile: () -> Void private let updaterBridge = UpdaterBridge.shared @@ -44,17 +46,17 @@ struct WelcomeActionsPanel: View { .buttonStyle(.borderedProminent) .controlSize(.large) - Button(action: onImportFromApp) { - Label(String(localized: "Import from Other App..."), systemImage: "square.and.arrow.down.on.square") - .frame(maxWidth: .infinity, alignment: .center) - } - .buttonStyle(.bordered) - .controlSize(.large) - - Button(action: onTrySample) { - Label(String(localized: "Try Sample Database"), systemImage: "cylinder.split.1x2") + Menu { + Button(String(localized: "Import from URL..."), action: onImportFromURL) + Button(String(localized: "Import from Other App..."), action: onImportFromApp) + Button(String(localized: "Open Project Folder..."), action: onOpenProjectFolder) + Divider() + Button(String(localized: "Import Connections..."), action: onImportConnectionsFile) + } label: { + Label(String(localized: "Add from Existing"), systemImage: "square.and.arrow.down") .frame(maxWidth: .infinity, alignment: .center) } + .menuStyle(.button) .buttonStyle(.bordered) .controlSize(.large) } @@ -62,18 +64,7 @@ struct WelcomeActionsPanel: View { Spacer() - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { - SyncStatusIndicator(onActivateLicense: onActivateLicense) - KeyboardHint(keys: "⌘N", label: String(localized: "New")) - KeyboardHint(keys: "⌘,", label: String(localized: "Settings")) - } - HStack(spacing: 8) { - SyncStatusIndicator(onActivateLicense: onActivateLicense) - KeyboardHint(keys: "⌘N", label: String(localized: "New")) - KeyboardHint(keys: "⌘,", label: nil) - } - } + SyncStatusIndicator(onActivateLicense: onActivateLicense) .font(.caption) .foregroundStyle(.tertiary) .padding(.horizontal, 12) @@ -114,24 +105,3 @@ struct WelcomeActionsPanel: View { } } } - -struct KeyboardHint: View { - let keys: String - let label: String? - - var body: some View { - HStack(spacing: 4) { - Text(keys) - .font(.system(.caption, design: .monospaced)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 3) - .fill(.tertiary.opacity(0.4)) - ) - if let label { - Text(label) - } - } - } -} diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 5aee3c1ac..305bd4fce 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -109,6 +109,18 @@ struct WelcomeWindowView: View { vm.pendingImportResultCount = count vm.activeSheet = nil } + case .projectFolderScan(let url): + ProjectFolderScanSheet( + rootURL: url, + onSelect: { parsed in + vm.activeSheet = nil + WindowOpener.shared.openConnectionFormFromURL(parsed) + }, + onChooseAnotherFolder: { + vm.activeSheet = nil + vm.openProjectFolder() + } + ) case .deeplinkImport(let exportable): DeeplinkImportSheet(connection: exportable) { vm.loadConnections() @@ -119,6 +131,9 @@ struct WelcomeWindowView: View { chooserState: $welcomeChooserState, urlImportPresented: $urlImportPresented )) + .onReceive(AppCommands.shared.importConnectionFromURL) { _ in + urlImportPresented = true + } .onReceive(AppCommands.shared.presentDatabaseTypeChooser) { payload in welcomeChooserState = WelcomeChooserState( initialType: payload.initialType, @@ -204,8 +219,10 @@ struct WelcomeWindowView: View { WelcomeActionsPanel( onActivateLicense: { vm.activeSheet = .activation }, onCreateConnection: { WindowOpener.shared.openConnectionForm() }, + onImportFromURL: { urlImportPresented = true }, onImportFromApp: { vm.importConnectionsFromApp() }, - onTrySample: { vm.openSampleDatabase() } + onOpenProjectFolder: { vm.openProjectFolder() }, + onImportConnectionsFile: { vm.importConnectionsFromFile() } ) .frame(width: 240) .themeMaterial(.sidebar, .regularMaterial) @@ -252,6 +269,14 @@ struct WelcomeWindowView: View { .accessibilityHidden(true) } + private var newConnectionHelp: String { + let binding = AppSettingsManager.shared.keyboard.shortcut(for: .newConnection) + guard let displayString = binding?.displayString, !displayString.isEmpty else { + return String(localized: "New Connection") + } + return String(format: String(localized: "New Connection (%@)"), displayString) + } + private var connectionsHeader: some View { HStack(spacing: 8) { Button { @@ -262,7 +287,7 @@ struct WelcomeWindowView: View { } .buttonStyle(.bordered) .controlSize(.large) - .help(String(localized: "New Connection (⌘N)")) + .help(newConnectionHelp) .accessibilityLabel(String(localized: "New Connection")) Button { diff --git a/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift b/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift new file mode 100644 index 000000000..c1d27cbf7 --- /dev/null +++ b/TablePro/Views/ConnectionForm/Components/SelectionAwareForeground.swift @@ -0,0 +1,23 @@ +// +// SelectionAwareForeground.swift +// TablePro +// + +import SwiftUI + +private struct SelectionAwareForeground: ViewModifier { + let standard: Color + @Environment(\.backgroundProminence) private var backgroundProminence + + func body(content: Content) -> some View { + content.foregroundStyle( + backgroundProminence == .increased ? AnyShapeStyle(.secondary) : AnyShapeStyle(standard) + ) + } +} + +extension View { + func selectionAwareForeground(_ standard: Color) -> some View { + modifier(SelectionAwareForeground(standard: standard)) + } +} diff --git a/TablePro/Views/Settings/LicenseActivationSheet.swift b/TablePro/Views/Settings/LicenseActivationSheet.swift index b28e6a4ef..74b83967b 100644 --- a/TablePro/Views/Settings/LicenseActivationSheet.swift +++ b/TablePro/Views/Settings/LicenseActivationSheet.swift @@ -79,7 +79,6 @@ struct LicenseActivationSheet: View { .padding(.bottom, 24) } .frame(width: 400) - .defaultFocus($keyFocused, true) } private func activate() async { diff --git a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift index 11df697bf..b465ef75c 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenCreateSheet.swift @@ -34,7 +34,6 @@ struct MCPTokenCreateSheet: View { .padding() } .frame(minWidth: 480, minHeight: 520) - .defaultFocus($focused, .name) .task { connections = ConnectionStorage.shared.loadConnections() } diff --git a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift index 430bd7501..9ab9bec0a 100644 --- a/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift +++ b/TablePro/Views/Settings/Sections/MCPTokenRevealSheet.swift @@ -35,7 +35,6 @@ struct MCPTokenRevealSheet: View { .padding() } .frame(minWidth: 540, minHeight: 520) - .defaultFocus($doneFocused, true) } private var warningBanner: some View { diff --git a/TableProTests/Core/Services/ProjectImport/DotenvConnectionExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/DotenvConnectionExtractorTests.swift new file mode 100644 index 000000000..471a29bf3 --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/DotenvConnectionExtractorTests.swift @@ -0,0 +1,195 @@ +// +// DotenvConnectionExtractorTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Dotenv Connection Extractor") +struct DotenvConnectionExtractorTests { + private let directory = URL(fileURLWithPath: "/tmp/project") + + private func extract(_ source: String, tier: ScannedConnectionTier = .nearCertain) -> [ScannedConnectionCandidate] { + let document = DotenvParser.parse(source, processEnvironment: [:]) + return DotenvConnectionExtractor.extract( + document: document, + relativePath: ".env", + tier: tier, + directoryURL: directory + ) + } + + @Test("A DATABASE_URL becomes a candidate") + func testDatabaseURL() { + let candidates = extract("DATABASE_URL=postgresql://admin:secret@db.example.com:5432/mydb") + #expect(candidates.count == 1) + #expect(candidates.first?.parsedURL.type == .postgresql) + #expect(candidates.first?.parsedURL.host == "db.example.com") + #expect(candidates.first?.parsedURL.database == "mydb") + #expect(candidates.first?.parsedURL.username == "admin") + #expect(candidates.first?.hasPassword == true) + } + + @Test("The direct URL is preferred over the pooled one") + func testUnpooledPreferred() { + let source = """ + DATABASE_URL=postgresql://u:p@pooled.example.com:6543/app + DATABASE_URL_UNPOOLED=postgresql://u:p@direct.example.com:5432/app + """ + #expect(extract(source).first?.parsedURL.host == "direct.example.com") + } + + @Test("A raw at sign in the password still parses") + func testRawAtSignInPassword() { + let candidates = extract("DATABASE_URL=postgresql://admin:p@ssw0rd@db.example.com:5432/mydb") + #expect(candidates.first?.parsedURL.host == "db.example.com") + #expect(candidates.first?.parsedURL.password == "p@ssw0rd") + } + + @Test("Unresolved indirection produces no candidate") + func testIndirectionSkipped() { + #expect(extract("DATABASE_URL=${{Postgres.DATABASE_URL}}").isEmpty) + } + + @Test("A Prisma placeholder is offered but flagged") + func testPrismaPlaceholderFlagged() { + let source = "DATABASE_URL=postgresql://johndoe:randompassword@localhost:5432/mydb?schema=public" + let candidate = extract(source).first + #expect(candidate != nil) + #expect(candidate?.placeholderSuspected == true) + } + + @Test("A Symfony placeholder is offered but flagged") + func testSymfonyPlaceholderFlagged() { + let source = "DATABASE_URL=postgresql://app:!ChangeMe!@127.0.0.1:5432/app" + #expect(extract(source).first?.placeholderSuspected == true) + } + + @Test("Laravel discrete keys map the driver name to a database type") + func testLaravelDiscreteKeys() { + let source = """ + DB_CONNECTION=pgsql + DB_HOST=127.0.0.1 + DB_PORT=5432 + DB_DATABASE=laravel + DB_USERNAME=sail + DB_PASSWORD=password + """ + let candidate = extract(source).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.database == "laravel") + #expect(candidate?.parsedURL.username == "sail") + } + + @Test("Laravel sqlserver maps to SQL Server") + func testLaravelSqlsrv() { + #expect(extract("DB_CONNECTION=sqlsrv\nDB_HOST=localhost").first?.parsedURL.type == .mssql) + } + + @Test("A relative Laravel SQLite path resolves against the env file directory") + func testLaravelSQLiteRelativePath() { + let source = """ + DB_CONNECTION=sqlite + DB_DATABASE=database/database.sqlite + """ + let candidate = extract(source).first + #expect(candidate?.parsedURL.type == .sqlite) + #expect(candidate?.parsedURL.database == "/tmp/project/database/database.sqlite") + } + + @Test("Docker Postgres variables assume an address and say so") + func testDockerPostgresAssumedAddress() { + let source = """ + POSTGRES_USER=appuser + POSTGRES_PASSWORD=apppass + POSTGRES_DB=appdb + """ + let candidate = extract(source).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.host == "127.0.0.1") + #expect(candidate?.parsedURL.port == 5432) + #expect(candidate?.warnings.isEmpty == false) + } + + @Test("MariaDB variables win over MySQL ones and yield a single candidate") + func testMariaDBPreferred() { + let source = """ + MARIADB_DATABASE=appdb + MARIADB_USER=appuser + MARIADB_PASSWORD=apppass + MYSQL_DATABASE=ignored + """ + let candidates = extract(source) + #expect(candidates.count == 1) + #expect(candidates.first?.parsedURL.type == .mariadb) + #expect(candidates.first?.parsedURL.database == "appdb") + } + + @Test("A container service hostname is flagged as unreachable") + func testServiceHostnameWarning() { + let source = """ + DB_CONNECTION=mysql + DB_HOST=mysql + DB_DATABASE=app + """ + #expect(extract(source).first?.warnings.isEmpty == false) + } + + @Test("Two relational engines in one file each produce a candidate") + func testTwoRelationalEngines() { + let source = """ + DATABASE_URL=postgresql://u:p@localhost:5432/appdb + JAWSDB_URL=mysql://u:p@mysql.example.com:3306/legacy + """ + let candidates = extract(source) + let types = candidates.map(\.parsedURL.type) + #expect(types.contains(.postgresql)) + #expect(types.contains(.mysql)) + } + + @Test("Pooled and direct URLs for one engine collapse to the direct one") + func testPooledAndDirectCollapse() { + let source = """ + DATABASE_URL=postgresql://u:p@pooled.example.com:6543/app + DATABASE_URL_UNPOOLED=postgresql://u:p@direct.example.com:5432/app + POSTGRES_URL=postgresql://u:p@another.example.com:5432/app + """ + let candidates = extract(source).filter { $0.parsedURL.type == .postgresql } + #expect(candidates.count == 1) + #expect(candidates.first?.parsedURL.host == "direct.example.com") + } + + @Test("Separate URLs for different engines each produce a candidate") + func testMultipleEngines() { + let source = """ + DATABASE_URL=postgresql://u:p@localhost:5432/app + REDIS_URL=redis://localhost:6379/0 + MONGODB_URI=mongodb://u:p@localhost:27017/app + """ + let types = extract(source).map(\.parsedURL.type) + #expect(types.contains(.postgresql)) + #expect(types.contains(.redis)) + #expect(types.contains(.mongodb)) + } + + @Test("A production named file raises the safe mode level") + func testProductionRaisesSafeMode() { + let document = DotenvParser.parse("DATABASE_URL=postgresql://u:p@db.example.com:5432/app", processEnvironment: [:]) + let candidates = DotenvConnectionExtractor.extract( + document: document, + relativePath: ".env.production", + tier: .likelyReal, + directoryURL: directory + ) + #expect(candidates.first?.parsedURL.safeModeLevel == 1) + } + + @Test("A JDBC prefixed URL is parsed after the prefix is dropped") + func testJdbcPrefixStripped() { + let candidate = extract("JDBC_DATABASE_URL=jdbc:postgresql://localhost:5432/app").first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.database == "app") + } +} diff --git a/TableProTests/Core/Services/ProjectImport/DotenvParserTests.swift b/TableProTests/Core/Services/ProjectImport/DotenvParserTests.swift new file mode 100644 index 000000000..864b1bbff --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/DotenvParserTests.swift @@ -0,0 +1,172 @@ +// +// DotenvParserTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Dotenv Parser") +struct DotenvParserTests { + + private func value(_ source: String, _ key: String, env: [String: String] = [:]) -> String? { + DotenvParser.parse(source, processEnvironment: env).entry(for: key)?.value + } + + // MARK: - Inline comments + + @Test("A hash with no preceding space stays in the value") + func testHashWithoutSpaceIsValue() { + #expect(value("K=bar#baz", "K") == "bar#baz") + } + + @Test("A hash after whitespace starts a comment") + func testHashAfterWhitespaceIsComment() { + #expect(value("K=bar #baz", "K") == "bar") + #expect(value("K=bar # baz # other", "K") == "bar") + } + + @Test("A value may start with a hash") + func testValueStartingWithHash() { + #expect(value("K=#c", "K") == "#c") + } + + @Test("A password containing a hash survives") + func testPasswordWithHash() { + #expect(value("DB_PASSWORD=p#ssw0rd", "DB_PASSWORD") == "p#ssw0rd") + } + + // MARK: - Quoting and escapes + + @Test("Double quotes decode the supported escapes") + func testDoubleQuoteEscapes() { + #expect(value(#"K="line1\nline2""#, "K") == "line1\nline2") + #expect(value(#"K="a\tb""#, "K") == "a\tb") + #expect(value(#"K="say \"hi\"""#, "K") == "say \"hi\"") + #expect(value(#"K="a\\b""#, "K") == "a\\b") + } + + @Test("An unknown escape keeps its backslash") + func testUnknownEscapePreserved() { + #expect(value(#"K="a\qb""#, "K") == #"a\qb"#) + } + + @Test("Single quotes are literal and block interpolation") + func testSingleQuotesAreLiteral() { + #expect(value(#"K='raw\nstring'"#, "K") == #"raw\nstring"#) + #expect(value("A=1\nK='${A}'", "K") == "${A}") + } + + @Test("A quoted value may span lines") + func testMultilineQuotedValue() { + let source = "K=\"line1\nline2\"\nJ=after" + #expect(value(source, "K") == "line1\nline2") + #expect(value(source, "J") == "after") + } + + @Test("A comment after a quoted value is ignored") + func testTrailingCommentAfterQuotedValue() { + #expect(value(#"K="value" # note"#, "K") == "value") + } + + // MARK: - Keys and structure + + @Test("The export prefix is stripped") + func testExportPrefix() { + #expect(value("export K=value", "K") == "value") + #expect(value("export\tK=value", "K") == "value") + } + + @Test("Keys may contain dots and dashes") + func testKeyCharacterSet() { + #expect(value("MY-KEY=value", "MY-KEY") == "value") + #expect(value("my.key=value", "my.key") == "value") + } + + @Test("A byte order mark is stripped") + func testByteOrderMark() { + #expect(value("\u{FEFF}K=value", "K") == "value") + } + + @Test("CRLF and lone CR are normalized") + func testLineEndings() { + #expect(value("K=value\r\nJ=other", "K") == "value") + #expect(value("K=value\rJ=other", "J") == "other") + } + + @Test("The last duplicate key wins") + func testDuplicateKeyLastWins() { + #expect(value("K=first\nK=second", "K") == "second") + } + + @Test("Whitespace around the equals sign is ignored") + func testWhitespaceAroundEquals() { + #expect(value("K = value", "K") == "value") + } + + @Test("An empty value parses as empty") + func testEmptyValue() { + #expect(value("K=", "K") == "") + } + + @Test("A full line comment is skipped") + func testFullLineComment() { + #expect(value("# K=nope\nK=yes", "K") == "yes") + } + + @Test("One malformed line does not abort the file") + func testMalformedLineRecovery() { + #expect(value("not a pair\nK=value", "K") == "value") + } + + // MARK: - Interpolation + + @Test("References resolve against earlier keys and the process environment") + func testInterpolationSources() { + #expect(value("A=one\nK=${A}-two", "K") == "one-two") + #expect(value("A=one\nK=$A-two", "K") == "one-two") + #expect(value("K=${FROM_ENV}", "K", env: ["FROM_ENV": "outside"]) == "outside") + } + + @Test("A reference default is used when the name is missing") + func testInterpolationDefault() { + #expect(value("K=${MISSING:-fallback}", "K") == "fallback") + } + + @Test("An unresolved reference is flagged and kept literal") + func testUnresolvedReferenceFlagged() { + let document = DotenvParser.parse("K=${NOPE}", processEnvironment: [:]) + #expect(document.entry(for: "K")?.hasUnresolvedReference == true) + #expect(document.entry(for: "K")?.value == "${NOPE}") + #expect(document["K"] == nil) + } + + @Test("A resolved reference is not flagged") + func testResolvedReferenceNotFlagged() { + let document = DotenvParser.parse("A=1\nK=${A}", processEnvironment: [:]) + #expect(document.entry(for: "K")?.hasUnresolvedReference == false) + } + + @Test("Railway style indirection is flagged, not guessed") + func testRailwayIndirectionFlagged() { + let document = DotenvParser.parse("K=${{Postgres.DATABASE_URL}}", processEnvironment: [:]) + #expect(document.entry(for: "K")?.hasUnresolvedReference == true) + #expect(document["K"] == nil) + } + + // MARK: - Security + + @Test("Command substitution is never executed") + func testCommandSubstitutionNotExecuted() { + #expect(value("K=$(whoami)", "K") == "$(whoami)") + #expect(value(#"K="$(whoami)""#, "K") == "$(whoami)") + #expect(value("K=`whoami`", "K") == "`whoami`") + } + + @Test("A URL password containing an at sign is preserved verbatim") + func testAtSignInValue() { + let source = "DATABASE_URL=postgres://u:p@ss@host/db" + #expect(value(source, "DATABASE_URL") == "postgres://u:p@ss@host/db") + } +} diff --git a/TableProTests/Core/Services/ProjectImport/ProjectConfigExtractorTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectConfigExtractorTests.swift new file mode 100644 index 000000000..cc0859b5b --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/ProjectConfigExtractorTests.swift @@ -0,0 +1,357 @@ +// +// ProjectConfigExtractorTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Scanned URL Normalizer") +struct ScannedURLNormalizerTests { + + @Test("A raw at sign in the password is encoded using the last separator") + func testRawAtSignEncoded() { + let normalized = ScannedURLNormalizer.normalize("postgresql://user:p@ss@host:5432/db") + #expect(normalized == "postgresql://user:p%40ss@host:5432/db") + } + + @Test("Already encoded credentials are left alone") + func testIdempotent() { + let encoded = "postgresql://user:p%40ss@host:5432/db" + #expect(ScannedURLNormalizer.normalize(encoded) == encoded) + } + + @Test("A URL without credentials is untouched") + func testNoUserInfo() { + let plain = "postgresql://host:5432/db" + #expect(ScannedURLNormalizer.normalize(plain) == plain) + } + + @Test("An at sign in the query string is not mistaken for a separator") + func testAtSignInQueryIgnored() { + let value = "postgresql://host:5432/db?options=a@b" + #expect(ScannedURLNormalizer.normalize(value) == value) + } + + @Test("A slash in the password does not truncate the authority") + func testSlashInPassword() { + let normalized = ScannedURLNormalizer.normalize("postgresql://user:p/ss@host:5432/db") + #expect(normalized == "postgresql://user:p%2Fss@host:5432/db") + } + + @Test("A file path containing an at sign is left alone") + func testFilePathWithAtSignUntouched() { + let scoped = "sqlite:///Users/dat/@scope/proj/database.sqlite" + #expect(ScannedURLNormalizer.normalize(scoped) == scoped) + let mailbox = "duckdb:///Users/dat/mail@work/warehouse.duckdb" + #expect(ScannedURLNormalizer.normalize(mailbox) == mailbox) + } + + @Test("A unix socket host parameter is left alone") + func testUnixSocketFormUntouched() { + let socket = "postgresql:///appdb?host=/var/run/postgresql" + #expect(ScannedURLNormalizer.normalize(socket) == socket) + } +} + +@Suite("Scanned Production Heuristic") +struct ScannedProductionHeuristicTests { + + @Test("Production markers are detected in the file name, host, and database") + func testMarkersDetected() { + #expect(ScannedProductionHeuristic.isProduction(relativePath: ".env.production", host: "", database: "")) + #expect(ScannedProductionHeuristic.isProduction(relativePath: ".env", host: "db.prod.example.com", database: "")) + #expect(ScannedProductionHeuristic.isProduction(relativePath: ".env", host: "", database: "live")) + } + + @Test("A marker embedded in a longer word does not count") + func testNoSubstringFalsePositives() { + #expect(!ScannedProductionHeuristic.isProduction(relativePath: ".env", host: "products.example.com", database: "")) + #expect(!ScannedProductionHeuristic.isProduction(relativePath: ".env", host: "productivity.io", database: "")) + #expect(!ScannedProductionHeuristic.isProduction(relativePath: ".env.local", host: "localhost", database: "appdb")) + } + + @Test("A separated marker counts, so the safer default wins") + func testSeparatedMarkerCounts() { + #expect(ScannedProductionHeuristic.isProduction(relativePath: ".env", host: "", database: "production_orders")) + #expect(ScannedProductionHeuristic.isProduction(relativePath: ".env", host: "app-prod-01.internal", database: "")) + } +} + +@Suite("YAML Mapping Support") +struct YamlMappingSupportTests { + + @Test("Merge keys are expanded with the owning mapping winning") + func testMergeKeyExpansion() throws { + let contents = """ + base: &base + adapter: postgresql + host: shared.example.com + child: + <<: *base + host: own.example.com + database: appdb + """ + let root = try #require(YamlMappingSupport.loadMapping(contents)) + let child = try #require(YamlMappingSupport.mapping(root["child"])) + #expect(YamlMappingSupport.string(child["adapter"]) == "postgresql") + #expect(YamlMappingSupport.string(child["host"]) == "own.example.com") + #expect(YamlMappingSupport.string(child["database"]) == "appdb") + } + + @Test("Scalars coerce to strings and integers") + func testScalarCoercion() { + #expect(YamlMappingSupport.string(5_432) == "5432") + #expect(YamlMappingSupport.string(true) == "true") + #expect(YamlMappingSupport.string(" spaced ") == "spaced") + #expect(YamlMappingSupport.string("") == nil) + #expect(YamlMappingSupport.int("5432") == 5_432) + #expect(YamlMappingSupport.int("not a port") == nil) + } + + @Test("A document that is not a mapping yields nil") + func testNonMappingDocument() { + #expect(YamlMappingSupport.loadMapping("- just\n- a\n- list") == nil) + } +} + +@Suite("WordPress Config Extractor") +struct WordPressConfigExtractorTests { + + @Test("Standard define calls produce a MySQL candidate") + func testStandardDefines() { + let contents = """ + ScannedConnectionCandidate? { + AppSettingsJsonExtractor.candidate( + name: "Default", + connectionString: connectionString, + relativePath: "appsettings.json" + ) + } + + @Test("An Npgsql connection string maps to PostgreSQL") + func testNpgsql() { + let result = candidate("Host=localhost;Database=appdb;Username=appuser;Password=apppass") + #expect(result?.parsedURL.type == .postgresql) + #expect(result?.parsedURL.database == "appdb") + #expect(result?.parsedURL.username == "appuser") + } + + @Test("A SqlClient connection string maps to SQL Server") + func testSqlClient() { + let result = candidate("Data Source=localhost;Initial Catalog=appdb;User Id=sa;Password=pass") + #expect(result?.parsedURL.type == .mssql) + #expect(result?.parsedURL.database == "appdb") + #expect(result?.parsedURL.port == 1433) + } + + @Test("A MySqlConnector connection string maps to MySQL") + func testMySqlConnector() { + let result = candidate("Server=localhost;Database=appdb;Uid=appuser;Pwd=apppass") + #expect(result?.parsedURL.type == .mysql) + #expect(result?.parsedURL.username == "appuser") + } + + @Test("A Server key alone is not misread as PostgreSQL") + func testAmbiguousStringRejected() { + #expect(candidate("Server=localhost;Database=appdb") == nil) + } + + @Test("Connection strings are read from the ConnectionStrings object") + func testFullDocument() throws { + let json = """ + { + "ConnectionStrings": { + "Default": "Host=localhost;Database=appdb;Username=appuser;Password=apppass" + } + } + """ + let data = try #require(json.data(using: .utf8)) + let candidates = AppSettingsJsonExtractor.extract(data: data, relativePath: "appsettings.json") + #expect(candidates.count == 1) + #expect(candidates.first?.parsedURL.type == .postgresql) + } +} diff --git a/TableProTests/Core/Services/ProjectImport/ProjectConfigFileMatcherTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectConfigFileMatcherTests.swift new file mode 100644 index 000000000..a7fb2134e --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/ProjectConfigFileMatcherTests.swift @@ -0,0 +1,71 @@ +// +// ProjectConfigFileMatcherTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Project Config File Matcher") +struct ProjectConfigFileMatcherTests { + + @Test("Real dotenv files are classified with their tier") + func testDotenvTiers() { + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env")?.tier == .nearCertain) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.local")?.tier == .nearCertain) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.production.local")?.tier == .nearCertain) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.production")?.tier == .likelyReal) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.test")?.tier == .lowValue) + } + + @Test("Placeholder dotenv files are excluded") + func testPlaceholderFilesExcluded() { + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.example") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.sample") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.template") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.dist") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.schema") == nil) + } + + @Test("A direnv script is never classified, even though it starts with .env") + func testEnvrcExcluded() { + #expect(ProjectConfigFileMatcher.classify(relativePath: ".envrc") == nil) + } + + @Test("Backup files are excluded") + func testBackupExcluded() { + #expect(ProjectConfigFileMatcher.classify(relativePath: ".env.bak") == nil) + } + + @Test("Suffix form dotenv names are matched, not only the prefix form") + func testSuffixFormMatched() { + #expect(ProjectConfigFileMatcher.classify(relativePath: "production.env")?.kind == .dotenv) + #expect(ProjectConfigFileMatcher.classify(relativePath: "config/db.env")?.kind == .dotenv) + } + + @Test("Framework config files are classified by name and location") + func testFrameworkConfigs() { + #expect(ProjectConfigFileMatcher.classify(relativePath: "wp-config.php")?.kind == .wordPressConfig) + #expect(ProjectConfigFileMatcher.classify(relativePath: "prisma/schema.prisma")?.kind == .prismaSchema) + #expect(ProjectConfigFileMatcher.classify(relativePath: "config/database.yml")?.kind == .railsDatabaseYaml) + #expect(ProjectConfigFileMatcher.classify( + relativePath: "src/main/resources/application.properties" + )?.kind == .springProperties) + #expect(ProjectConfigFileMatcher.classify(relativePath: "application-dev.yml")?.kind == .springYaml) + #expect(ProjectConfigFileMatcher.classify(relativePath: "appsettings.Development.json")?.kind == .appSettingsJson) + #expect(ProjectConfigFileMatcher.classify(relativePath: "docker-compose.yml")?.kind == .dockerCompose) + #expect(ProjectConfigFileMatcher.classify(relativePath: "compose.yaml")?.kind == .dockerCompose) + } + + @Test("A database.yml outside config is not a Rails database file") + func testDatabaseYamlNeedsConfigParent() { + #expect(ProjectConfigFileMatcher.classify(relativePath: "database.yml") == nil) + } + + @Test("Unrelated files are not classified") + func testUnrelatedFiles() { + #expect(ProjectConfigFileMatcher.classify(relativePath: "README.md") == nil) + #expect(ProjectConfigFileMatcher.classify(relativePath: "src/index.ts") == nil) + } +} diff --git a/TableProTests/Core/Services/ProjectImport/ProjectFolderFileWalkerTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectFolderFileWalkerTests.swift new file mode 100644 index 000000000..b1d551ac9 --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/ProjectFolderFileWalkerTests.swift @@ -0,0 +1,106 @@ +// +// ProjectFolderFileWalkerTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Project Folder File Walker") +struct ProjectFolderFileWalkerTests { + private let root: URL + private let outside: URL + + init() throws { + let base = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("ProjectWalkerTests-\(UUID().uuidString)") + root = base.appendingPathComponent("project") + outside = base.appendingPathComponent("outside") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: outside, withIntermediateDirectories: true) + } + + private func write(_ contents: String, to relativePath: String) throws { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + } + + private func walk() -> [String] { + ProjectFolderFileWalker.walk(root: root).map(\.relativePath) + } + + @Test("A dotenv file at the project root is found") + func testFindsRootDotenv() throws { + try write("DATABASE_URL=postgres://localhost/app", to: ".env") + #expect(walk().contains(".env")) + } + + @Test("Excluded directories are never descended into") + func testExcludedDirectories() throws { + try write("DATABASE_URL=postgres://localhost/app", to: ".env") + try write("DATABASE_URL=postgres://localhost/pkg", to: "node_modules/pkg/.env") + try write("DATABASE_URL=postgres://localhost/vendored", to: "vendor/thing/.env") + let found = walk() + #expect(found == [".env"]) + } + + @Test("A dotenv symlink pointing outside the project is never read") + func testSymlinkedFileRejected() throws { + let target = outside.appendingPathComponent("secret.txt") + try "SECRET=value".write(to: target, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + at: root.appendingPathComponent(".env"), + withDestinationURL: target + ) + #expect(walk().isEmpty) + } + + @Test("A symlinked directory is not descended into") + func testSymlinkedDirectoryNotFollowed() throws { + let nested = outside.appendingPathComponent("hidden") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try "DATABASE_URL=postgres://localhost/x" + .write(to: nested.appendingPathComponent(".env"), atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink( + at: root.appendingPathComponent("linked"), + withDestinationURL: nested + ) + #expect(walk().isEmpty) + } + + @Test("Files over the size cap are skipped") + func testFileSizeCap() throws { + let oversized = String(repeating: "A", count: ProjectFolderFileWalker.maxFileSize + 1024) + try write(oversized, to: ".env") + #expect(walk().isEmpty) + } + + @Test("Files deeper than the depth cap are skipped") + func testDepthCap() throws { + try write("DATABASE_URL=postgres://localhost/shallow", to: "a/.env") + try write("DATABASE_URL=postgres://localhost/deep", to: "a/b/c/d/e/.env") + let found = walk() + #expect(found.contains("a/.env")) + #expect(!found.contains("a/b/c/d/e/.env")) + } + + @Test("Placeholder dotenv files never reach the scanner") + func testPlaceholderFileSkipped() throws { + try write("DATABASE_URL=postgres://user:pass@localhost/app", to: ".env.example") + #expect(walk().isEmpty) + } + + @Test("Sensitive home directories are denied by absolute path") + func testDenyList() { + let home = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path + #expect(ProjectFolderFileWalker.isDenied(home + "/.ssh/config")) + #expect(ProjectFolderFileWalker.isDenied(home + "/.aws/credentials")) + #expect(ProjectFolderFileWalker.isDenied("/etc/passwd")) + #expect(!ProjectFolderFileWalker.isDenied(home + "/Developer/project/.env")) + } +} diff --git a/TableProTests/Core/Services/ProjectImport/ProjectFolderScannerTests.swift b/TableProTests/Core/Services/ProjectImport/ProjectFolderScannerTests.swift new file mode 100644 index 000000000..d0f515870 --- /dev/null +++ b/TableProTests/Core/Services/ProjectImport/ProjectFolderScannerTests.swift @@ -0,0 +1,149 @@ +// +// ProjectFolderScannerTests.swift +// TableProTests +// + +import Foundation +import Testing +@testable import TablePro + +@Suite("Project Folder Scanner") +struct ProjectFolderScannerTests { + private let root: URL + + init() throws { + root = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("ProjectScannerTests-\(UUID().uuidString)") + .appendingPathComponent("my-project") + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + private func write(_ contents: String, to relativePath: String) throws { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + } + + private func scan() throws -> ProjectFolderScanResult { + switch ProjectFolderScanner.scan(rootURL: root) { + case .success(let result): + return result + case .failure(let error): + throw error + } + } + + @Test("A mixed project yields one candidate per distinct database") + func testMixedProject() throws { + try write("DATABASE_URL=postgresql://appuser:apppass@localhost:5432/appdb", to: ".env") + try write("REDIS_URL=redis://localhost:6379/0", to: ".env") + try write("DATABASE_URL=postgresql://johndoe:randompassword@localhost:5432/leaked", to: ".env.example") + try write("DATABASE_URL=postgresql://u:p@localhost:5432/vendored", to: "node_modules/pkg/.env") + try write( + """ + [ScannedConnectionCandidate] { + RailsDatabaseYamlExtractor.extract( + contents: contents, + relativePath: "config/database.yml", + projectRootURL: root, + environment: environment + ) + } + + @Test("Anchors and merge keys are expanded before reading the environment") + func testAnchorsAndMergeKeys() { + let contents = """ + default: &default + adapter: postgresql + encoding: unicode + host: db.example.com + username: railsuser + password: railspass + + development: + <<: *default + database: myapp_development + + test: + <<: *default + database: myapp_test + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.database == "myapp_development") + #expect(candidate?.parsedURL.host == "db.example.com") + #expect(candidate?.parsedURL.username == "railsuser") + } + + @Test("An ERB environment lookup resolves from the project dotenv") + func testErbResolvedFromDotenv() { + let contents = """ + development: + adapter: postgresql + database: myapp + host: <%= ENV["DB_HOST"] %> + """ + let environment = DotenvParser.parse("DB_HOST=resolved.example.com", processEnvironment: [:]) + #expect(extract(contents, environment: environment).first?.parsedURL.host == "resolved.example.com") + } + + @Test("An ERB fetch default is used when the variable is missing") + func testErbFetchDefault() { + let contents = """ + development: + adapter: postgresql + database: myapp + host: <%= ENV.fetch("DB_HOST", "localhost") %> + """ + #expect(extract(contents).first?.parsedURL.host == "localhost") + } + + @Test("An unresolvable ERB value is reported as a warning, not guessed") + func testUnresolvedErbWarns() { + let contents = """ + development: + adapter: postgresql + database: myapp + password: <%= Rails.application.credentials.db_password %> + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.password.isEmpty == true) + #expect(candidate?.warnings.isEmpty == false) + } + + @Test("A multi database development section picks the nested entry") + func testMultiDatabaseSection() { + let contents = """ + development: + primary: + adapter: postgresql + database: myapp_development + host: localhost + """ + #expect(extract(contents).first?.parsedURL.database == "myapp_development") + } + + @Test("A sqlite3 adapter resolves its path against the project root") + func testSQLitePath() { + let contents = """ + development: + adapter: sqlite3 + database: storage/development.sqlite3 + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .sqlite) + #expect(candidate?.parsedURL.database == "/tmp/rails-project/storage/development.sqlite3") + } +} + +@Suite("Docker Compose Extractor") +struct DockerComposeExtractorTests { + + private func extract(_ contents: String, environment: DotenvDocument? = nil) -> [ScannedConnectionCandidate] { + DockerComposeExtractor.extract( + contents: contents, + relativePath: "docker-compose.yml", + environment: environment + ) + } + + @Test("A published port is used instead of the container port") + func testPublishedPort() { + let contents = """ + services: + db: + image: postgres:16 + environment: + POSTGRES_USER: appuser + POSTGRES_PASSWORD: apppass + POSTGRES_DB: appdb + ports: + - "8001:5432" + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.host == "127.0.0.1") + #expect(candidate?.parsedURL.port == 8001) + #expect(candidate?.parsedURL.database == "appdb") + } + + @Test("A service with no published port is flagged as possibly unreachable") + func testUnpublishedPortWarns() { + let contents = """ + services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: apppass + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.port == 5432) + #expect(candidate?.warnings.isEmpty == false) + } + + @Test("A list form environment block is read") + func testListFormEnvironment() { + let contents = """ + services: + db: + image: mysql:8 + environment: + - MYSQL_DATABASE=appdb + - MYSQL_USER=appuser + - MYSQL_PASSWORD=apppass + ports: + - "3307:3306" + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .mysql) + #expect(candidate?.parsedURL.database == "appdb") + #expect(candidate?.parsedURL.port == 3307) + } + + @Test("Interpolation uses the adjacent dotenv file") + func testInterpolationFromDotenv() { + let contents = """ + services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + ports: + - "${DB_PORT}:5432" + """ + let environment = DotenvParser.parse("DB_PASSWORD=frompass\nDB_PORT=15432", processEnvironment: [:]) + let candidate = extract(contents, environment: environment).first + #expect(candidate?.parsedURL.port == 15432) + #expect(candidate?.hasPassword == true) + } + + @Test("An interpolation default is used when the variable is missing") + func testInterpolationDefault() { + let contents = """ + services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD:-!ChangeMe!} + ports: + - "5433:5432" + """ + #expect(extract(contents).first?.placeholderSuspected == true) + } + + @Test("Credentials parked in an extension field anchor are still found") + func testExtensionFieldAnchor() { + let contents = """ + x-db-env: &db-env + POSTGRES_USER: appuser + POSTGRES_PASSWORD: apppass + POSTGRES_DB: appdb + + services: + db: + image: postgres:16 + environment: + <<: *db-env + ports: + - "5432:5432" + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.username == "appuser") + #expect(candidate?.parsedURL.database == "appdb") + } + + @Test("A variable with no value anywhere is reported, not silently emptied") + func testUnresolvedInterpolationWarns() { + let contents = """ + services: + db: + image: postgres:16 + environment: + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: appdb + ports: + - "5432:5432" + """ + let candidate = extract(contents).first + #expect(candidate?.warnings.isEmpty == false) + } + + @Test("Services that are not databases are ignored") + func testNonDatabaseServiceIgnored() { + let contents = """ + services: + web: + image: nginx:latest + ports: + - "80:80" + """ + #expect(extract(contents).isEmpty) + } + + @Test("The long form port syntax is understood") + func testLongFormPorts() { + let contents = """ + services: + db: + image: mariadb:11 + environment: + MARIADB_DATABASE: appdb + MARIADB_PASSWORD: apppass + ports: + - target: 3306 + published: 3399 + """ + let candidate = extract(contents).first + #expect(candidate?.parsedURL.type == .mariadb) + #expect(candidate?.parsedURL.port == 3399) + } +} + +@Suite("Spring YAML Extractor") +struct SpringYamlExtractorTests { + + @Test("A nested datasource block is read") + func testNestedDatasource() { + let contents = """ + spring: + datasource: + url: jdbc:postgresql://localhost:5432/appdb + username: springuser + password: springpass + """ + let candidate = SpringYamlExtractor.extract( + contents: contents, + relativePath: "application.yml", + processEnvironment: [:] + ).first + #expect(candidate?.parsedURL.type == .postgresql) + #expect(candidate?.parsedURL.database == "appdb") + #expect(candidate?.parsedURL.username == "springuser") + } + + @Test("A multi document file is searched past the first document") + func testMultiDocument() { + let contents = """ + spring: + config: + activate: + on-profile: default + --- + spring: + datasource: + url: jdbc:postgresql://second:5432/appdb + """ + let candidate = SpringYamlExtractor.extract( + contents: contents, + relativePath: "application.yml", + processEnvironment: [:] + ).first + #expect(candidate?.parsedURL.host == "second") + } +} diff --git a/docs/databases/connection-urls.mdx b/docs/databases/connection-urls.mdx index 2bd96a3f7..ef50ca92d 100644 --- a/docs/databases/connection-urls.mdx +++ b/docs/databases/connection-urls.mdx @@ -51,6 +51,8 @@ mariadb+ssh:// ## Standard Format +If the URL you want is already in a project's `.env` file, [Open Project Folder](/features/project-folder-import) reads it for you instead. + ``` scheme://[username[:password]@]host[:port][/database][?param=value&...] ``` diff --git a/docs/docs.json b/docs/docs.json index 146d8c415..6167441c6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -144,6 +144,7 @@ "features/quick-switcher", "features/query-history", "features/favorites", + "features/project-folder-import", "features/keyboard-shortcuts" ] }, diff --git a/docs/features/project-folder-import.mdx b/docs/features/project-folder-import.mdx new file mode 100644 index 000000000..93c19326c --- /dev/null +++ b/docs/features/project-folder-import.mdx @@ -0,0 +1,58 @@ +--- +title: Open Project Folder +description: Create a connection from the database settings already in your project's config files +--- + +# Open Project Folder + +Point TablePro at a project folder and it reads the database settings the project already has, so you do not have to copy them out of a `.env` file by hand. + +## Open a folder + +- **File** > **Open Project Folder...** +- The **Open Project Folder...** button on the welcome screen + +Pick the folder in the panel that appears. TablePro scans it and lists what it found. + +## Pick a connection + +Each row shows the database type, host, port, user, and database, plus the file and key it came from. Select one and click **Continue**. The connection form opens filled in, and nothing is saved or connected until you review it and click **Save**. + +Passwords are never shown. A row says **Password found** when the file has one, and the value goes straight to your Keychain when you save. + +Rows can carry extra notes: + +| Note | Meaning | +|------|---------| +| Password found | The file has a password. It is not displayed. | +| Looks like a placeholder value | The value matches a known template, such as `!ChangeMe!` or `password_here`. | +| Container service name | The host is a name like `db` or `postgres`, which usually only resolves inside Docker. | +| Host and port were assumed | The file had credentials but no address, so `127.0.0.1` and the default port were used. | +| Does not publish a port | A Compose service with no `ports:` mapping, so it may not be reachable from your Mac. | + +A connection found in a file named for production starts at the **Alert** [Safe Mode](/features/safe-mode) level rather than Silent. + +## Files it reads + +| File | What it reads | +|------|---------------| +| `.env` and friends | `DATABASE_URL`, `MONGODB_URI`, `REDIS_URL`, `TURSO_DATABASE_URL` and similar URL keys; Laravel `DB_*` keys; Postgres, MySQL, MariaDB, Mongo and SQL Server container variables; `PG*` variables | +| `wp-config.php` | `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST` | +| `prisma/schema.prisma` | The `datasource` provider and url, including `env("...")` references | +| `config/database.yml` | The Rails development section, following anchors and `<<` merge keys, and resolving `ENV["..."]` | +| `docker-compose.yml`, `compose.yaml` | Database services, using the published host port | +| `application.properties`, `application.yml` | `spring.datasource.url`, username, and password | +| `appsettings.json` | The `ConnectionStrings` entries | + +Template files are skipped, so `.env.example`, `.env.sample`, `.env.template`, and `.env.dist` never appear. `.envrc` is a shell script, so TablePro never reads or runs it. + +The scan does not follow symlinks, skips `node_modules`, `.git`, `vendor`, `dist`, `build` and similar directories, goes four levels deep, and ignores files over 1 MB. It reads only the folder you picked, with one exception: WordPress allows `wp-config.php` to sit one level above the site, so that single file is checked in the parent folder too. When it comes from there, the row shows the path as `../wp-config.php`. + +## When a value cannot be read + +Some settings point somewhere else instead of holding a value, like `${{Postgres.DATABASE_URL}}` on Railway or a Rails credential lookup. TablePro leaves those out rather than guessing, so a row you expected may be missing. Fill those connections in by hand, or paste the URL with [Import from URL](/databases/connection-urls). + +## Related + +- [Connection Sharing](/features/connection-sharing) for importing from other database apps +- [Safe Mode](/features/safe-mode) for guarding production connections