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

Filter by extension

Filter by extension

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

### Changed

- Namespaced `disabledPlugins` UserDefaults key to `com.TablePro.disabledPlugins` with automatic migration
- Removed unused plugin capability types (sqlDialect, aiProvider, cellRenderer, sidebarPanel)
- SQLite driver extracted from built-in bundle to downloadable plugin, reducing app size
- Unified error formatting across all database drivers via default `PluginDriverError.errorDescription`, removing 10 per-driver implementations
- Standardized async bridging: 5 queue-based drivers (MySQL, PostgreSQL, MongoDB, Redis, MSSQL) now use shared `pluginDispatchAsync` helper
Expand All @@ -26,6 +28,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Export plugin options (CSV, XLSX, JSON, SQL, MQL) now persist across app restarts
- Plugins can declare settings views rendered in Settings > Plugins
- True prepared statements for MSSQL (`sp_executesql`) and ClickHouse (HTTP query parameters), eliminating string interpolation for parameterized queries
- Batch query operations for MSSQL, Oracle, and ClickHouse, eliminating N+1 query patterns for column, foreign key, and database metadata fetching; SQLite adds a batched `fetchAllForeignKeys` override within PRAGMA limitations
- `PluginDriverError` protocol in TableProPluginKit for structured error reporting from driver plugins, with richer connection error messages showing error codes and SQL states
Expand Down
10 changes: 5 additions & 5 deletions Plugins/CSVExportPlugin/CSVExportModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import Foundation

public enum CSVDelimiter: String, CaseIterable, Identifiable {
public enum CSVDelimiter: String, CaseIterable, Identifiable, Codable {
case comma = ","
case semicolon = ";"
case tab = "\\t"
Expand All @@ -27,7 +27,7 @@ public enum CSVDelimiter: String, CaseIterable, Identifiable {
}
}

public enum CSVQuoteHandling: String, CaseIterable, Identifiable {
public enum CSVQuoteHandling: String, CaseIterable, Identifiable, Codable {
case always = "Always"
case asNeeded = "Quote if needed"
case never = "Never"
Expand All @@ -43,7 +43,7 @@ public enum CSVQuoteHandling: String, CaseIterable, Identifiable {
}
}

public enum CSVLineBreak: String, CaseIterable, Identifiable {
public enum CSVLineBreak: String, CaseIterable, Identifiable, Codable {
case lf = "\\n"
case crlf = "\\r\\n"
case cr = "\\r"
Expand All @@ -59,7 +59,7 @@ public enum CSVLineBreak: String, CaseIterable, Identifiable {
}
}

public enum CSVDecimalFormat: String, CaseIterable, Identifiable {
public enum CSVDecimalFormat: String, CaseIterable, Identifiable, Codable {
case period = "."
case comma = ","

Expand All @@ -68,7 +68,7 @@ public enum CSVDecimalFormat: String, CaseIterable, Identifiable {
public var separator: String { rawValue }
}

public struct CSVExportOptions: Equatable {
public struct CSVExportOptions: Equatable, Codable {
public var convertNullToEmpty: Bool = true
public var convertLineBreakToSpace: Bool = false
public var includeFieldNames: Bool = true
Expand Down
12 changes: 10 additions & 2 deletions Plugins/CSVExportPlugin/CSVExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,17 @@ final class CSVExportPlugin: ExportFormatPlugin {
// swiftlint:disable:next force_try
static let decimalFormatRegex = try! NSRegularExpression(pattern: #"^[+-]?\d+\.\d+$"#)

var options = CSVExportOptions()
private let storage = PluginSettingsStorage(pluginId: "csv")

required init() {}
var options = CSVExportOptions() {
didSet { storage.save(options) }
}

required init() {
if let saved = storage.load(CSVExportOptions.self) {
options = saved
}
}

func optionsView() -> AnyView? {
AnyView(CSVExportOptionsView(plugin: self))
Expand Down
2 changes: 1 addition & 1 deletion Plugins/JSONExportPlugin/JSONExportModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import Foundation

public struct JSONExportOptions: Equatable {
public struct JSONExportOptions: Equatable, Codable {
public var prettyPrint: Bool = true
public var includeNullValues: Bool = true
public var preserveAllAsStrings: Bool = false
Expand Down
12 changes: 10 additions & 2 deletions Plugins/JSONExportPlugin/JSONExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@ final class JSONExportPlugin: ExportFormatPlugin {
static let defaultFileExtension = "json"
static let iconName = "curlybraces"

var options = JSONExportOptions()
private let storage = PluginSettingsStorage(pluginId: "json")

required init() {}
var options = JSONExportOptions() {
didSet { storage.save(options) }
}

required init() {
if let saved = storage.load(JSONExportOptions.self) {
options = saved
}
}

func optionsView() -> AnyView? {
AnyView(JSONExportOptionsView(plugin: self))
Expand Down
2 changes: 1 addition & 1 deletion Plugins/MQLExportPlugin/MQLExportModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import Foundation

public struct MQLExportOptions: Equatable {
public struct MQLExportOptions: Equatable, Codable {
public var batchSize: Int = 500

public init() {}
Expand Down
12 changes: 10 additions & 2 deletions Plugins/MQLExportPlugin/MQLExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,17 @@ final class MQLExportPlugin: ExportFormatPlugin {
PluginExportOptionColumn(id: "data", label: "Data", width: 44)
]

var options = MQLExportOptions()
private let storage = PluginSettingsStorage(pluginId: "mql")

required init() {}
var options = MQLExportOptions() {
didSet { storage.save(options) }
}

required init() {
if let saved = storage.load(MQLExportOptions.self) {
options = saved
}
}

func defaultTableOptionValues() -> [Bool] {
[true, true, true]
Expand Down
2 changes: 1 addition & 1 deletion Plugins/SQLExportPlugin/SQLExportModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import Foundation

public struct SQLExportOptions: Equatable {
public struct SQLExportOptions: Equatable, Codable {
public var compressWithGzip: Bool = false
public var batchSize: Int = 500

Expand Down
13 changes: 11 additions & 2 deletions Plugins/SQLExportPlugin/SQLExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,21 @@ final class SQLExportPlugin: ExportFormatPlugin {
PluginExportOptionColumn(id: "data", label: "Data", width: 44)
]

var options = SQLExportOptions()
private let storage = PluginSettingsStorage(pluginId: "sql")

var options = SQLExportOptions() {
didSet { storage.save(options) }
}

var ddlFailures: [String] = []

private static let logger = Logger(subsystem: "com.TablePro", category: "SQLExportPlugin")

required init() {}
required init() {
if let saved = storage.load(SQLExportOptions.self) {
options = saved
}
}

func defaultTableOptionValues() -> [Bool] {
[true, true, true]
Expand Down
4 changes: 0 additions & 4 deletions Plugins/TableProPluginKit/PluginCapability.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,4 @@ public enum PluginCapability: Int, Codable, Sendable {
case databaseDriver
case exportFormat
case importFormat
case sqlDialect
case aiProvider
case cellRenderer
case sidebarPanel
}
38 changes: 38 additions & 0 deletions Plugins/TableProPluginKit/PluginSettingsStorage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// PluginSettingsStorage.swift
// TableProPluginKit
//

import Foundation

public final class PluginSettingsStorage {
private let pluginId: String
private let defaults = UserDefaults.standard
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()

public init(pluginId: String) {
self.pluginId = pluginId
}

private func key(for optionKey: String) -> String {
"com.TablePro.plugin.\(pluginId).\(optionKey)"
}

public func save<T: Encodable>(_ value: T, forKey optionKey: String = "settings") {
guard let data = try? encoder.encode(value) else { return }
defaults.set(data, forKey: key(for: optionKey))
}

public func load<T: Decodable>(_ type: T.Type, forKey optionKey: String = "settings") -> T? {
guard let data = defaults.data(forKey: key(for: optionKey)) else { return nil }
return try? decoder.decode(type, from: data)
}

public func removeAll() {
let prefix = "com.TablePro.plugin.\(pluginId)."
for key in defaults.dictionaryRepresentation().keys where key.hasPrefix(prefix) {
defaults.removeObject(forKey: key)
}
}
}
2 changes: 1 addition & 1 deletion Plugins/XLSXExportPlugin/XLSXExportModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

import Foundation

public struct XLSXExportOptions: Equatable {
public struct XLSXExportOptions: Equatable, Codable {
public var includeHeaderRow: Bool = true
public var convertNullToEmpty: Bool = true

Expand Down
12 changes: 10 additions & 2 deletions Plugins/XLSXExportPlugin/XLSXExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@ final class XLSXExportPlugin: ExportFormatPlugin {
static let defaultFileExtension = "xlsx"
static let iconName = "tablecells"

var options = XLSXExportOptions()
private let storage = PluginSettingsStorage(pluginId: "xlsx")

required init() {}
var options = XLSXExportOptions() {
didSet { storage.save(options) }
}

required init() {
if let saved = storage.load(XLSXExportOptions.self) {
options = saved
}
}

func optionsView() -> AnyView? {
AnyView(XLSXExportOptionsView(plugin: self))
Expand Down
23 changes: 21 additions & 2 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import TableProPluginKit
final class PluginManager {
static let shared = PluginManager()
static let currentPluginKitVersion = 1
private static let disabledPluginsKey = "com.TablePro.disabledPlugins"
private static let legacyDisabledPluginsKey = "disabledPlugins"

private(set) var plugins: [PluginEntry] = []

Expand All @@ -25,6 +27,8 @@ final class PluginManager {

private(set) var importPlugins: [String: any ImportFormatPlugin] = [:]

private(set) var pluginInstances: [String: any TableProPlugin] = [:]

private var builtInPluginsDir: URL? { Bundle.main.builtInPlugInsURL }

private var userPluginsDir: URL {
Expand All @@ -33,8 +37,8 @@ final class PluginManager {
}

var disabledPluginIds: Set<String> {
get { Set(UserDefaults.standard.stringArray(forKey: "disabledPlugins") ?? []) }
set { UserDefaults.standard.set(Array(newValue), forKey: "disabledPlugins") }
get { Set(UserDefaults.standard.stringArray(forKey: Self.disabledPluginsKey) ?? []) }
set { UserDefaults.standard.set(Array(newValue), forKey: Self.disabledPluginsKey) }
}

private static let logger = Logger(subsystem: "com.TablePro", category: "PluginManager")
Expand All @@ -43,11 +47,22 @@ final class PluginManager {

private init() {}

private func migrateDisabledPluginsKey() {
let defaults = UserDefaults.standard
if let legacy = defaults.stringArray(forKey: Self.legacyDisabledPluginsKey) {
if defaults.stringArray(forKey: Self.disabledPluginsKey) == nil {
defaults.set(legacy, forKey: Self.disabledPluginsKey)
}
defaults.removeObject(forKey: Self.legacyDisabledPluginsKey)
}
}

// MARK: - Loading

/// Discover and load all plugins. Discovery is synchronous (reads Info.plist),
/// then bundle loading is deferred to the next run loop iteration so it doesn't block app launch.
func loadPlugins() {
migrateDisabledPluginsKey()
discoverAllPlugins()
Task { @MainActor in
self.loadPendingPlugins()
Expand Down Expand Up @@ -206,6 +221,7 @@ final class PluginManager {
// MARK: - Capability Registration

private func registerCapabilities(_ instance: any TableProPlugin, pluginId: String) {
pluginInstances[pluginId] = instance
let declared = Set(type(of: instance).capabilities)

if let driver = instance as? any DriverPlugin {
Expand Down Expand Up @@ -265,6 +281,7 @@ final class PluginManager {
}

private func unregisterCapabilities(pluginId: String) {
pluginInstances.removeValue(forKey: pluginId)
driverPlugins = driverPlugins.filter { _, value in
guard let entry = plugins.first(where: { $0.id == pluginId }) else { return true }
if let principalClass = entry.bundle.principalClass as? any DriverPlugin.Type {
Expand Down Expand Up @@ -511,6 +528,8 @@ final class PluginManager {
try fm.removeItem(at: entry.url)
}

PluginSettingsStorage(pluginId: id).removeAll()

var disabled = disabledPluginIds
disabled.remove(id)
disabledPluginIds = disabled
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Views/Settings/Plugins/BrowsePluginsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,9 @@ struct BrowsePluginsView: View {
do {
_ = try await pluginManager.installFromRegistry(plugin) { fraction in
installTracker.updateProgress(pluginId: plugin.id, fraction: fraction)
if fraction >= 1.0 {
installTracker.markInstalling(pluginId: plugin.id)
}
}
installTracker.completeInstall(pluginId: plugin.id)
} catch {
Expand Down
10 changes: 6 additions & 4 deletions TablePro/Views/Settings/Plugins/InstalledPluginsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,12 @@ struct InstalledPluginsView: View {
.foregroundStyle(.secondary)
}

if let exportPlugin = pluginManager.pluginInstances[plugin.id] as? any ExportFormatPlugin,
let exportSettings = exportPlugin.optionsView() {
Divider()
exportSettings
}

if plugin.source == .userInstalled {
HStack {
Spacer()
Expand Down Expand Up @@ -247,10 +253,6 @@ private extension PluginCapability {
case .databaseDriver: String(localized: "Database Driver")
case .exportFormat: String(localized: "Export Format")
case .importFormat: String(localized: "Import Format")
case .sqlDialect: String(localized: "SQL Dialect")
case .aiProvider: String(localized: "AI Provider")
case .cellRenderer: String(localized: "Cell Renderer")
case .sidebarPanel: String(localized: "Sidebar Panel")
}
}
}
Expand Down
Loading