diff --git a/CHANGELOG.md b/CHANGELOG.md index a85b3a4c..89ab34f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `SettablePlugin` protocol in TableProPluginKit SDK: unified settings pattern for all plugins with automatic persistence via `loadSettings()`/`saveSettings()`, replacing duplicated boilerplate across export/import/driver plugins - Plugin UI/capability metadata: each driver plugin now self-declares brand color, connection mode, supported features, column types, URL schemes, and grouping strategy via the `DriverPlugin` protocol - Driver plugin settings view support: `DriverPlugin.settingsView()` allows plugins to provide custom settings UI in the Installed Plugins panel - Dynamic connection fields: connection form Advanced tab now renders fields from `DriverPlugin.additionalConnectionFields` instead of hardcoded per-database sections, with support for text, secure, and dropdown field types diff --git a/Plugins/CSVExportPlugin/CSVExportOptionsView.swift b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift index 120a10b9..995c5c3c 100644 --- a/Plugins/CSVExportPlugin/CSVExportOptionsView.swift +++ b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift @@ -11,16 +11,16 @@ struct CSVExportOptionsView: View { var body: some View { VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 8) { - Toggle("Convert NULL to EMPTY", isOn: $plugin.options.convertNullToEmpty) + Toggle("Convert NULL to EMPTY", isOn: $plugin.settings.convertNullToEmpty) .toggleStyle(.checkbox) - Toggle("Convert line break to space", isOn: $plugin.options.convertLineBreakToSpace) + Toggle("Convert line break to space", isOn: $plugin.settings.convertLineBreakToSpace) .toggleStyle(.checkbox) - Toggle("Put field names in the first row", isOn: $plugin.options.includeFieldNames) + Toggle("Put field names in the first row", isOn: $plugin.settings.includeFieldNames) .toggleStyle(.checkbox) - Toggle("Sanitize formula-like values", isOn: $plugin.options.sanitizeFormulas) + Toggle("Sanitize formula-like values", isOn: $plugin.settings.sanitizeFormulas) .toggleStyle(.checkbox) .help("Prevent CSV formula injection by prefixing values starting with =, +, -, @ with a single quote") } @@ -30,7 +30,7 @@ struct CSVExportOptionsView: View { VStack(alignment: .leading, spacing: 10) { optionRow(String(localized: "Delimiter", bundle: .main)) { - Picker("", selection: $plugin.options.delimiter) { + Picker("", selection: $plugin.settings.delimiter) { ForEach(CSVDelimiter.allCases) { delimiter in Text(delimiter.displayName).tag(delimiter) } @@ -41,7 +41,7 @@ struct CSVExportOptionsView: View { } optionRow(String(localized: "Quote", bundle: .main)) { - Picker("", selection: $plugin.options.quoteHandling) { + Picker("", selection: $plugin.settings.quoteHandling) { ForEach(CSVQuoteHandling.allCases) { handling in Text(handling.rawValue).tag(handling) } @@ -52,7 +52,7 @@ struct CSVExportOptionsView: View { } optionRow(String(localized: "Line break", bundle: .main)) { - Picker("", selection: $plugin.options.lineBreak) { + Picker("", selection: $plugin.settings.lineBreak) { ForEach(CSVLineBreak.allCases) { lineBreak in Text(lineBreak.rawValue).tag(lineBreak) } @@ -63,7 +63,7 @@ struct CSVExportOptionsView: View { } optionRow(String(localized: "Decimal", bundle: .main)) { - Picker("", selection: $plugin.options.decimalFormat) { + Picker("", selection: $plugin.settings.decimalFormat) { ForEach(CSVDecimalFormat.allCases) { format in Text(format.rawValue).tag(format) } diff --git a/Plugins/CSVExportPlugin/CSVExportPlugin.swift b/Plugins/CSVExportPlugin/CSVExportPlugin.swift index b55ed369..314f335d 100644 --- a/Plugins/CSVExportPlugin/CSVExportPlugin.swift +++ b/Plugins/CSVExportPlugin/CSVExportPlugin.swift @@ -8,7 +8,7 @@ import SwiftUI import TableProPluginKit @Observable -final class CSVExportPlugin: ExportFormatPlugin { +final class CSVExportPlugin: ExportFormatPlugin, SettablePlugin { static let pluginName = "CSV Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to CSV format" @@ -20,19 +20,16 @@ final class CSVExportPlugin: ExportFormatPlugin { // swiftlint:disable:next force_try static let decimalFormatRegex = try! NSRegularExpression(pattern: #"^[+-]?\d+\.\d+$"#) - private let storage = PluginSettingsStorage(pluginId: "csv") + typealias Settings = CSVExportOptions + static let settingsStorageId = "csv" - var options = CSVExportOptions() { - didSet { storage.save(options) } + var settings = CSVExportOptions() { + didSet { saveSettings() } } - required init() { - if let saved = storage.load(CSVExportOptions.self) { - options = saved - } - } + required init() { loadSettings() } - func optionsView() -> AnyView? { + func settingsView() -> AnyView? { AnyView(CSVExportOptionsView(plugin: self)) } @@ -45,7 +42,7 @@ final class CSVExportPlugin: ExportFormatPlugin { let fileHandle = try PluginExportUtilities.createFileHandle(at: destination) defer { try? fileHandle.close() } - let lineBreak = options.lineBreak.value + let lineBreak = settings.lineBreak.value for (index, table) in tables.enumerated() { try progress.checkCancellation() @@ -73,15 +70,15 @@ final class CSVExportPlugin: ExportFormatPlugin { if result.rows.isEmpty { break } - var batchOptions = options + var batchSettings = settings if !isFirstBatch { - batchOptions.includeFieldNames = false + batchSettings.includeFieldNames = false } try writeCSVContent( columns: result.columns, rows: result.rows, - options: batchOptions, + options: batchSettings, to: fileHandle, progress: progress ) @@ -180,5 +177,4 @@ final class CSVExportPlugin: ExportFormatPlugin { return processed } } - } diff --git a/Plugins/JSONExportPlugin/JSONExportOptionsView.swift b/Plugins/JSONExportPlugin/JSONExportOptionsView.swift index 5a4bfce7..b70574e4 100644 --- a/Plugins/JSONExportPlugin/JSONExportOptionsView.swift +++ b/Plugins/JSONExportPlugin/JSONExportOptionsView.swift @@ -10,13 +10,13 @@ struct JSONExportOptionsView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Toggle("Pretty print (formatted output)", isOn: $plugin.options.prettyPrint) + Toggle("Pretty print (formatted output)", isOn: $plugin.settings.prettyPrint) .toggleStyle(.checkbox) - Toggle("Include NULL values", isOn: $plugin.options.includeNullValues) + Toggle("Include NULL values", isOn: $plugin.settings.includeNullValues) .toggleStyle(.checkbox) - Toggle("Preserve all values as strings", isOn: $plugin.options.preserveAllAsStrings) + Toggle("Preserve all values as strings", isOn: $plugin.settings.preserveAllAsStrings) .toggleStyle(.checkbox) .help("Keep leading zeros in ZIP codes, phone numbers, and IDs by outputting all values as strings") } diff --git a/Plugins/JSONExportPlugin/JSONExportPlugin.swift b/Plugins/JSONExportPlugin/JSONExportPlugin.swift index 10dde193..a1f689b8 100644 --- a/Plugins/JSONExportPlugin/JSONExportPlugin.swift +++ b/Plugins/JSONExportPlugin/JSONExportPlugin.swift @@ -8,7 +8,7 @@ import SwiftUI import TableProPluginKit @Observable -final class JSONExportPlugin: ExportFormatPlugin { +final class JSONExportPlugin: ExportFormatPlugin, SettablePlugin { static let pluginName = "JSON Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to JSON format" @@ -17,19 +17,16 @@ final class JSONExportPlugin: ExportFormatPlugin { static let defaultFileExtension = "json" static let iconName = "curlybraces" - private let storage = PluginSettingsStorage(pluginId: "json") + typealias Settings = JSONExportOptions + static let settingsStorageId = "json" - var options = JSONExportOptions() { - didSet { storage.save(options) } + var settings = JSONExportOptions() { + didSet { saveSettings() } } - required init() { - if let saved = storage.load(JSONExportOptions.self) { - options = saved - } - } + required init() { loadSettings() } - func optionsView() -> AnyView? { + func settingsView() -> AnyView? { AnyView(JSONExportOptionsView(plugin: self)) } @@ -42,7 +39,7 @@ final class JSONExportPlugin: ExportFormatPlugin { let fileHandle = try PluginExportUtilities.createFileHandle(at: destination) defer { try? fileHandle.close() } - let prettyPrint = options.prettyPrint + let prettyPrint = settings.prettyPrint let indent = prettyPrint ? " " : "" let newline = prettyPrint ? "\n" : "" @@ -95,7 +92,7 @@ final class JSONExportPlugin: ExportFormatPlugin { for (colIndex, column) in columns.enumerated() { if colIndex < row.count { let value = row[colIndex] - if options.includeNullValues || value != nil { + if settings.includeNullValues || value != nil { if !isFirstField { rowString += ", " } @@ -104,7 +101,7 @@ final class JSONExportPlugin: ExportFormatPlugin { let escapedKey = PluginExportUtilities.escapeJSONString(column) let jsonValue = formatJSONValue( value, - preserveAsString: options.preserveAllAsStrings + preserveAsString: settings.preserveAllAsStrings ) rowString += "\"\(escapedKey)\": \(jsonValue)" } @@ -167,5 +164,4 @@ final class JSONExportPlugin: ExportFormatPlugin { return "\"\(PluginExportUtilities.escapeJSONString(val))\"" } - } diff --git a/Plugins/MQLExportPlugin/MQLExportOptionsView.swift b/Plugins/MQLExportPlugin/MQLExportOptionsView.swift index 59a1865e..077d4510 100644 --- a/Plugins/MQLExportPlugin/MQLExportOptionsView.swift +++ b/Plugins/MQLExportPlugin/MQLExportOptionsView.swift @@ -26,7 +26,7 @@ struct MQLExportOptionsView: View { Spacer() - Picker("", selection: $plugin.options.batchSize) { + Picker("", selection: $plugin.settings.batchSize) { ForEach(Self.batchSizeOptions, id: \.self) { size in Text("\(size)") .tag(size) diff --git a/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/Plugins/MQLExportPlugin/MQLExportPlugin.swift index 4e3daafb..b98fda8a 100644 --- a/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -8,7 +8,7 @@ import SwiftUI import TableProPluginKit @Observable -final class MQLExportPlugin: ExportFormatPlugin { +final class MQLExportPlugin: ExportFormatPlugin, SettablePlugin { static let pluginName = "MQL Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to MongoDB Query Language format" @@ -24,17 +24,14 @@ final class MQLExportPlugin: ExportFormatPlugin { PluginExportOptionColumn(id: "data", label: "Data", width: 44) ] - private let storage = PluginSettingsStorage(pluginId: "mql") + typealias Settings = MQLExportOptions + static let settingsStorageId = "mql" - var options = MQLExportOptions() { - didSet { storage.save(options) } + var settings = MQLExportOptions() { + didSet { saveSettings() } } - required init() { - if let saved = storage.load(MQLExportOptions.self) { - options = saved - } - } + required init() { loadSettings() } func defaultTableOptionValues() -> [Bool] { [true, true, true] @@ -44,7 +41,7 @@ final class MQLExportPlugin: ExportFormatPlugin { optionValues.contains(true) } - func optionsView() -> AnyView? { + func settingsView() -> AnyView? { AnyView(MQLExportOptionsView(plugin: self)) } @@ -67,7 +64,7 @@ final class MQLExportPlugin: ExportFormatPlugin { } try fileHandle.write(contentsOf: "\n".toUTF8Data()) - let batchSize = options.batchSize + let batchSize = settings.batchSize for (index, table) in tables.enumerated() { try progress.checkCancellation() @@ -219,5 +216,4 @@ final class MQLExportPlugin: ExportFormatPlugin { try fileHandle.write(contentsOf: "\(indexContent)\n".toUTF8Data()) } } - } diff --git a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift index 6ffed8ac..ba6f6e09 100644 --- a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift +++ b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift @@ -26,7 +26,7 @@ struct SQLExportOptionsView: View { Spacer() - Picker("", selection: $plugin.options.batchSize) { + Picker("", selection: $plugin.settings.batchSize) { ForEach(Self.batchSizeOptions, id: \.self) { size in Text(size == 1 ? String(localized: "1 (no batching)", bundle: .main) : "\(size)") .tag(size) @@ -38,7 +38,7 @@ struct SQLExportOptionsView: View { } .help("Higher values create fewer INSERT statements, resulting in smaller files and faster imports") - Toggle("Compress the file using Gzip", isOn: $plugin.options.compressWithGzip) + Toggle("Compress the file using Gzip", isOn: $plugin.settings.compressWithGzip) .toggleStyle(.checkbox) .font(.system(size: 13)) } diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index bfaa6148..00771efd 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -9,7 +9,7 @@ import SwiftUI import TableProPluginKit @Observable -final class SQLExportPlugin: ExportFormatPlugin { +final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin { static let pluginName = "SQL Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to SQL format" @@ -25,21 +25,18 @@ final class SQLExportPlugin: ExportFormatPlugin { PluginExportOptionColumn(id: "data", label: "Data", width: 44) ] - private let storage = PluginSettingsStorage(pluginId: "sql") + typealias Settings = SQLExportOptions + static let settingsStorageId = "sql" - var options = SQLExportOptions() { - didSet { storage.save(options) } + var settings = SQLExportOptions() { + didSet { saveSettings() } } var ddlFailures: [String] = [] private static let logger = Logger(subsystem: "com.TablePro", category: "SQLExportPlugin") - required init() { - if let saved = storage.load(SQLExportOptions.self) { - options = saved - } - } + required init() { loadSettings() } func defaultTableOptionValues() -> [Bool] { [true, true, true] @@ -50,7 +47,7 @@ final class SQLExportPlugin: ExportFormatPlugin { } var currentFileExtension: String { - options.compressWithGzip ? "sql.gz" : "sql" + settings.compressWithGzip ? "sql.gz" : "sql" } var warnings: [String] { @@ -59,7 +56,7 @@ final class SQLExportPlugin: ExportFormatPlugin { return ["Could not fetch table structure for: \(failedTables)"] } - func optionsView() -> AnyView? { + func settingsView() -> AnyView? { AnyView(SQLExportOptionsView(plugin: self)) } @@ -75,7 +72,7 @@ final class SQLExportPlugin: ExportFormatPlugin { let targetURL: URL let tempFileURL: URL? - if options.compressWithGzip { + if settings.compressWithGzip { let tempURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString + ".sql") tempFileURL = tempURL @@ -171,7 +168,7 @@ final class SQLExportPlugin: ExportFormatPlugin { } if includeData { - let batchSize = options.batchSize + let batchSize = settings.batchSize var offset = 0 var wroteAnyRows = false @@ -218,7 +215,7 @@ final class SQLExportPlugin: ExportFormatPlugin { } // Handle gzip compression - if options.compressWithGzip, let tempURL = tempFileURL { + if settings.compressWithGzip, let tempURL = tempFileURL { progress.setStatus("Compressing...") do { diff --git a/Plugins/SQLImportPlugin/SQLImportOptionsView.swift b/Plugins/SQLImportPlugin/SQLImportOptionsView.swift index 922f2210..d72bba92 100644 --- a/Plugins/SQLImportPlugin/SQLImportOptionsView.swift +++ b/Plugins/SQLImportPlugin/SQLImportOptionsView.swift @@ -10,13 +10,13 @@ struct SQLImportOptionsView: View { var body: some View { VStack(alignment: .leading, spacing: 12) { - Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: Bindable(plugin).options.wrapInTransaction) + Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: Bindable(plugin).settings.wrapInTransaction) .font(.system(size: 13)) .help( "Execute all statements in a single transaction. If any statement fails, all changes are rolled back." ) - Toggle("Disable foreign key checks", isOn: Bindable(plugin).options.disableForeignKeyChecks) + Toggle("Disable foreign key checks", isOn: Bindable(plugin).settings.disableForeignKeyChecks) .font(.system(size: 13)) .help( "Temporarily disable foreign key constraints during import. Useful for importing data with circular dependencies." diff --git a/Plugins/SQLImportPlugin/SQLImportPlugin.swift b/Plugins/SQLImportPlugin/SQLImportPlugin.swift index 08e5f9af..84e99b2a 100644 --- a/Plugins/SQLImportPlugin/SQLImportPlugin.swift +++ b/Plugins/SQLImportPlugin/SQLImportPlugin.swift @@ -8,7 +8,7 @@ import SwiftUI import TableProPluginKit @Observable -final class SQLImportPlugin: ImportFormatPlugin { +final class SQLImportPlugin: ImportFormatPlugin, SettablePlugin { static let pluginName = "SQL Import" static let pluginVersion = "1.0.0" static let pluginDescription = "Import data from SQL files" @@ -17,19 +17,16 @@ final class SQLImportPlugin: ImportFormatPlugin { static let acceptedFileExtensions = ["sql", "gz"] static let iconName = "doc.text" - private let storage = PluginSettingsStorage(pluginId: "sql-import") + typealias Settings = SQLImportOptions + static let settingsStorageId = "sql-import" - var options = SQLImportOptions() { - didSet { storage.save(options) } + var settings = SQLImportOptions() { + didSet { saveSettings() } } - required init() { - if let saved = storage.load(SQLImportOptions.self) { - options = saved - } - } + required init() { loadSettings() } - func optionsView() -> AnyView? { + func settingsView() -> AnyView? { AnyView(SQLImportOptionsView(plugin: self)) } @@ -48,12 +45,12 @@ final class SQLImportPlugin: ImportFormatPlugin { do { // Disable FK checks if enabled - if options.disableForeignKeyChecks { + if settings.disableForeignKeyChecks { try await sink.disableForeignKeyChecks() } // Begin transaction if enabled - if options.wrapInTransaction { + if settings.wrapInTransaction { try await sink.beginTransaction() } @@ -77,19 +74,19 @@ final class SQLImportPlugin: ImportFormatPlugin { } // Commit transaction - if options.wrapInTransaction { + if settings.wrapInTransaction { try await sink.commitTransaction() } // Re-enable FK checks - if options.disableForeignKeyChecks { + if settings.disableForeignKeyChecks { try await sink.enableForeignKeyChecks() } } catch { let importError = error // Rollback on error - if options.wrapInTransaction { + if settings.wrapInTransaction { do { try await sink.rollbackTransaction() } catch { @@ -98,7 +95,7 @@ final class SQLImportPlugin: ImportFormatPlugin { } // Re-enable FK checks (best-effort) - if options.disableForeignKeyChecks { + if settings.disableForeignKeyChecks { try? await sink.enableForeignKeyChecks() } diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 65d33c36..d3223af3 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -13,8 +13,6 @@ public protocol DriverPlugin: TableProPlugin { func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver - func settingsView() -> AnyView? - // MARK: - UI/Capability Metadata static var requiresAuthentication: Bool { get } @@ -42,7 +40,6 @@ public extension DriverPlugin { static var additionalConnectionFields: [ConnectionField] { [] } static var additionalDatabaseTypeIds: [String] { [] } static func driverVariant(for databaseTypeId: String) -> String? { nil } - func settingsView() -> AnyView? { nil } // MARK: - UI/Capability Metadata Defaults diff --git a/Plugins/TableProPluginKit/ExportFormatPlugin.swift b/Plugins/TableProPluginKit/ExportFormatPlugin.swift index d7f287f1..eb2e2338 100644 --- a/Plugins/TableProPluginKit/ExportFormatPlugin.swift +++ b/Plugins/TableProPluginKit/ExportFormatPlugin.swift @@ -21,8 +21,6 @@ public protocol ExportFormatPlugin: TableProPlugin { var currentFileExtension: String { get } var warnings: [String] { get } - func optionsView() -> AnyView? - func export( tables: [PluginExportTable], dataSource: any PluginExportDataSource, @@ -40,5 +38,4 @@ public extension ExportFormatPlugin { func isTableExportable(optionValues: [Bool]) -> Bool { true } var currentFileExtension: String { Self.defaultFileExtension } var warnings: [String] { [] } - func optionsView() -> AnyView? { nil } } diff --git a/Plugins/TableProPluginKit/ImportFormatPlugin.swift b/Plugins/TableProPluginKit/ImportFormatPlugin.swift index 58a1eaf9..eb955a89 100644 --- a/Plugins/TableProPluginKit/ImportFormatPlugin.swift +++ b/Plugins/TableProPluginKit/ImportFormatPlugin.swift @@ -14,8 +14,6 @@ public protocol ImportFormatPlugin: TableProPlugin { static var supportedDatabaseTypeIds: [String] { get } static var excludedDatabaseTypeIds: [String] { get } - func optionsView() -> AnyView? - func performImport( source: any PluginImportSource, sink: any PluginImportDataSink, @@ -27,5 +25,4 @@ public extension ImportFormatPlugin { static var capabilities: [PluginCapability] { [.importFormat] } static var supportedDatabaseTypeIds: [String] { [] } static var excludedDatabaseTypeIds: [String] { [] } - func optionsView() -> AnyView? { nil } } diff --git a/Plugins/TableProPluginKit/SettablePlugin.swift b/Plugins/TableProPluginKit/SettablePlugin.swift new file mode 100644 index 00000000..2a2f1725 --- /dev/null +++ b/Plugins/TableProPluginKit/SettablePlugin.swift @@ -0,0 +1,39 @@ +// +// SettablePlugin.swift +// TableProPluginKit +// + +import Foundation +import SwiftUI + +/// Type-erased witness for runtime discovery (needed because SettablePlugin has associated type). +public protocol SettablePluginDiscoverable: AnyObject { + func settingsView() -> AnyView? +} + +/// Opt-in protocol for plugins with user-configurable settings. +public protocol SettablePlugin: SettablePluginDiscoverable { + associatedtype Settings: Codable & Equatable + + /// ID for namespaced UserDefaults keys (matches existing pluginId values). + static var settingsStorageId: String { get } + + /// Current settings. Must be a stored var with `didSet { saveSettings() }`. + var settings: Settings { get set } +} + +public extension SettablePlugin { + func settingsView() -> AnyView? { nil } + + func loadSettings() { + let storage = PluginSettingsStorage(pluginId: Self.settingsStorageId) + if let saved = storage.load(Settings.self) { + settings = saved + } + } + + func saveSettings() { + let storage = PluginSettingsStorage(pluginId: Self.settingsStorageId) + storage.save(settings) + } +} diff --git a/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift b/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift index 159b7c58..aedc41b6 100644 --- a/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift +++ b/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift @@ -10,10 +10,10 @@ struct XLSXExportOptionsView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Toggle("Include column headers", isOn: $plugin.options.includeHeaderRow) + Toggle("Include column headers", isOn: $plugin.settings.includeHeaderRow) .toggleStyle(.checkbox) - Toggle("Convert NULL to empty", isOn: $plugin.options.convertNullToEmpty) + Toggle("Convert NULL to empty", isOn: $plugin.settings.convertNullToEmpty) .toggleStyle(.checkbox) } .font(.system(size: 13)) diff --git a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift index 08b8b326..1b36c685 100644 --- a/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift +++ b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift @@ -8,7 +8,7 @@ import SwiftUI import TableProPluginKit @Observable -final class XLSXExportPlugin: ExportFormatPlugin { +final class XLSXExportPlugin: ExportFormatPlugin, SettablePlugin { static let pluginName = "XLSX Export" static let pluginVersion = "1.0.0" static let pluginDescription = "Export data to Excel format" @@ -17,19 +17,16 @@ final class XLSXExportPlugin: ExportFormatPlugin { static let defaultFileExtension = "xlsx" static let iconName = "tablecells" - private let storage = PluginSettingsStorage(pluginId: "xlsx") + typealias Settings = XLSXExportOptions + static let settingsStorageId = "xlsx" - var options = XLSXExportOptions() { - didSet { storage.save(options) } + var settings = XLSXExportOptions() { + didSet { saveSettings() } } - required init() { - if let saved = storage.load(XLSXExportOptions.self) { - options = saved - } - } + required init() { loadSettings() } - func optionsView() -> AnyView? { + func settingsView() -> AnyView? { AnyView(XLSXExportOptionsView(plugin: self)) } @@ -68,14 +65,14 @@ final class XLSXExportPlugin: ExportFormatPlugin { writer.beginSheet( name: table.name, columns: columns, - includeHeader: options.includeHeaderRow, - convertNullToEmpty: options.convertNullToEmpty + includeHeader: settings.includeHeaderRow, + convertNullToEmpty: settings.convertNullToEmpty ) isFirstBatch = false } autoreleasepool { - writer.addRows(result.rows, convertNullToEmpty: options.convertNullToEmpty) + writer.addRows(result.rows, convertNullToEmpty: settings.convertNullToEmpty) } for _ in result.rows { @@ -92,7 +89,7 @@ final class XLSXExportPlugin: ExportFormatPlugin { name: table.name, columns: [], includeHeader: false, - convertNullToEmpty: options.convertNullToEmpty + convertNullToEmpty: settings.convertNullToEmpty ) writer.finishSheet() } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 0b19af78..175ad2b8 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -255,7 +255,8 @@ struct ExportDialog: View { // Format-specific options ScrollView { VStack(alignment: .leading, spacing: 0) { - if let optionsView = currentPlugin?.optionsView() { + if let settable = currentPlugin as? any SettablePluginDiscoverable, + let optionsView = settable.settingsView() { optionsView } } diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index 0e870ff6..7a0fbc11 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -255,7 +255,8 @@ struct ImportDialog: View { } // Plugin-provided options - if let pluginView = currentPlugin?.optionsView() { + if let settable = currentPlugin as? any SettablePluginDiscoverable, + let pluginView = settable.settingsView() { pluginView } } diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 0f345637..307581a6 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -187,22 +187,10 @@ struct InstalledPluginsView: View { .foregroundStyle(.secondary) } - if let exportPlugin = pluginManager.pluginInstances[plugin.id] as? any ExportFormatPlugin, - let exportSettings = exportPlugin.optionsView() { + if let settable = pluginManager.pluginInstances[plugin.id] as? any SettablePluginDiscoverable, + let pluginSettings = settable.settingsView() { Divider() - exportSettings - } - - if let importPlugin = pluginManager.pluginInstances[plugin.id] as? any ImportFormatPlugin, - let importSettings = importPlugin.optionsView() { - Divider() - importSettings - } - - if let driverPlugin = pluginManager.pluginInstances[plugin.id] as? any DriverPlugin, - let driverSettings = driverPlugin.settingsView() { - Divider() - driverSettings + pluginSettings } if plugin.source == .userInstalled { diff --git a/docs/development/plugin-settings-tracking.md b/docs/development/plugin-settings-tracking.md index d45bb7bb..b54f016c 100644 --- a/docs/development/plugin-settings-tracking.md +++ b/docs/development/plugin-settings-tracking.md @@ -7,7 +7,7 @@ Analysis date: 2026-03-11 (updated). The plugin settings system has two dimensions: 1. **Plugin management** (Settings > Plugins) — enable/disable, install/uninstall. Fully working. -2. **Per-plugin configuration** — export plugins expose `optionsView()` with persistent settings via `PluginSettingsStorage`. Import plugin (SQL) has options UI but no persistence. Driver plugins have zero configurable settings. +2. **Per-plugin configuration** — plugins conforming to `SettablePlugin` protocol get automatic persistence via `loadSettings()`/`saveSettings()` and expose `settingsView()` via the `SettablePluginDiscoverable` type-erased witness. All 5 export plugins and 1 import plugin use this pattern. Driver plugins have zero configurable settings but can adopt `SettablePlugin` when needed. Plugin enable/disable state lives in `UserDefaults["com.TablePro.disabledPlugins"]` (namespaced, with legacy key migration). @@ -40,7 +40,7 @@ Plugin enable/disable state lives in `UserDefaults["com.TablePro.disabledPlugins | SQL | `SQLExportOptions` — gzip, batch size | `SQLExportOptionsView` | `PluginSettingsStorage` (persisted) | Done | | MQL | `MQLExportOptions` | `MQLExportOptionsView` | `PluginSettingsStorage` (persisted) | Done | -All export plugins use `PluginSettingsStorage(pluginId:)` which stores options in `UserDefaults` keyed as `com.TablePro.plugin..settings`, encoded via `JSONEncoder`. +All export plugins conform to `SettablePlugin` which provides automatic `loadSettings()`/`saveSettings()` backed by `PluginSettingsStorage(pluginId:)`. Options are stored in `UserDefaults` keyed as `com.TablePro.plugin..settings`, encoded via `JSONEncoder`. ### Import Plugins @@ -50,7 +50,7 @@ All export plugins use `PluginSettingsStorage(pluginId:)` which stores options i ### Driver Plugins -All 8 driver plugins have zero per-plugin settings. No `optionsView()`, no configuration struct. +All 8 driver plugins have zero per-plugin settings. They can adopt `SettablePlugin` when settings are needed. --- @@ -68,7 +68,7 @@ None — previously tracked medium-priority issues have been resolved. | Issue | Description | Impact | | ---------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| No settings protocol in SDK | `TableProPluginKit` has no `PluginSettingsProtocol` for plugins to declare persistent preferences | Third-party plugins can't define settings | +| _(none)_ | All previously tracked low-priority issues have been resolved | | ### Resolved (since initial analysis) @@ -81,6 +81,7 @@ None — previously tracked medium-priority issues have been resolved. | SQL import options not persisted | `SQLImportOptions` converted to `Codable` struct with `PluginSettingsStorage` persistence | | `additionalConnectionFields` hardcoded | Connection form Advanced tab now dynamically renders fields from `DriverPlugin.additionalConnectionFields` with `ConnectionField.FieldType` support (text, secure, dropdown) | | No driver plugin settings UI | `DriverPlugin.settingsView()` protocol method added with `nil` default; rendered in InstalledPluginsView | +| No settings protocol in SDK | `SettablePlugin` protocol added to `TableProPluginKit` with `loadSettings()`/`saveSettings()` and `SettablePluginDiscoverable` type-erased witness; all 6 plugins migrated | | Hardcoded registry URL | `RegistryClient` now reads custom URL from UserDefaults with ETag invalidation on URL change | | `needsRestart` not persisted | Backed by UserDefaults, cleared on next plugin load cycle | @@ -88,21 +89,11 @@ None — previously tracked medium-priority issues have been resolved. ## Recommended Next Steps -### Step 1 — Persist SQL import options +Steps 1-3 have been completed. All plugins with settings now use the `SettablePlugin` protocol. -- Add `Codable` conformance to `SQLImportOptions` -- Add `PluginSettingsStorage` integration (same pattern as export plugins) +### Future — Driver plugin settings -### Step 2 — Dynamic connection fields rendering - -- Refactor `ConnectionFormView` Advanced tab to iterate `additionalConnectionFields` from `DriverPlugin` instead of hardcoding per-database sections -- Removes need for form changes when a plugin adds new fields - -### Step 3 — Plugin settings protocol (SDK v2) - -- Add `PluginSettingsProtocol` to `TableProPluginKit` with `settingsView() -> AnyView?` and `persistSettings()`/`loadSettings()` -- Render in Settings > Plugins detail expansion for plugins that implement it -- Driver plugins can then expose timeout, SSL, query behavior settings +- When driver plugins need per-plugin configuration (timeout, SSL, query behavior), they can adopt `SettablePlugin` using the same pattern as export/import plugins --- @@ -123,6 +114,7 @@ None — previously tracked medium-priority issues have been resolved. | Download count service | `TablePro/Core/Plugins/Registry/DownloadCountService.swift` | | Plugin models | `TablePro/Core/Plugins/PluginModels.swift` | | Plugin settings storage | `Plugins/TableProPluginKit/PluginSettingsStorage.swift` | +| SDK — settable protocol | `Plugins/TableProPluginKit/SettablePlugin.swift` | | Connection install prompt | `TablePro/Views/Connection/PluginInstallModifier.swift` | | SDK — base protocol | `Plugins/TableProPluginKit/TableProPlugin.swift` | | SDK — driver protocol | `Plugins/TableProPluginKit/DriverPlugin.swift` |