From c173aed701110799ad631c95f68134d5eefa2bbd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 12 Oct 2026 15:47:46 +0700 Subject: [PATCH 1/2] refactor(plugins)!: remove HTML and XML export --- .github/plugin-registry.json | 24 --- CHANGELOG.md | 1 + .../HTMLExportPlugin/HTMLExportModels.swift | 55 ------ .../HTMLExportOptionsView.swift | 26 --- .../HTMLExportPlugin/HTMLExportPlugin.swift | 162 ------------------ Plugins/HTMLExportPlugin/Info.plist | 12 -- Plugins/XMLExportPlugin/Info.plist | 12 -- Plugins/XMLExportPlugin/XMLExportModels.swift | 90 ---------- .../XMLExportOptionsView.swift | 30 ---- Plugins/XMLExportPlugin/XMLExportPlugin.swift | 130 -------------- .../Services/Export/ExportFormatCatalog.swift | 10 +- TablePro/Resources/Localizable.xcstrings | 20 --- TablePro/Views/Export/ExportDialog.swift | 4 +- .../Plugins/ExportFormatEscapingTests.swift | 71 -------- project.yml | 28 --- 15 files changed, 5 insertions(+), 670 deletions(-) delete mode 100644 Plugins/HTMLExportPlugin/HTMLExportModels.swift delete mode 100644 Plugins/HTMLExportPlugin/HTMLExportOptionsView.swift delete mode 100644 Plugins/HTMLExportPlugin/HTMLExportPlugin.swift delete mode 100644 Plugins/HTMLExportPlugin/Info.plist delete mode 100644 Plugins/XMLExportPlugin/Info.plist delete mode 100644 Plugins/XMLExportPlugin/XMLExportModels.swift delete mode 100644 Plugins/XMLExportPlugin/XMLExportOptionsView.swift delete mode 100644 Plugins/XMLExportPlugin/XMLExportPlugin.swift diff --git a/.github/plugin-registry.json b/.github/plugin-registry.json index 64970e0149..4ec1097f63 100644 --- a/.github/plugin-registry.json +++ b/.github/plugin-registry.json @@ -187,18 +187,6 @@ "category": "database-driver", "homepage": "https://docs.tablepro.app/databases/sap-hana" }, - "html": { - "target": "HTMLExport", - "bundleName": "HTMLExport", - "bundleId": "com.TablePro.HTMLExportPlugin", - "bundled": true, - "displayName": "HTML Export", - "summary": "Export data as an HTML table", - "databaseTypeIds": null, - "icon": "chevron.left.forwardslash.chevron.right", - "category": "export-format", - "homepage": "https://docs.tablepro.app/features/import-export" - }, "kafka": { "target": "KafkaDriverPlugin", "bundleName": "KafkaDriverPlugin", @@ -467,18 +455,6 @@ "icon": "tablecells", "category": "import-format", "homepage": "https://docs.tablepro.app/features/import-export" - }, - "xml": { - "target": "XMLExport", - "bundleName": "XMLExport", - "bundleId": "com.TablePro.XMLExportPlugin", - "bundled": true, - "displayName": "XML Export", - "summary": "Export data as XML", - "databaseTypeIds": null, - "icon": "doc.badge.gearshape", - "category": "export-format", - "homepage": "https://docs.tablepro.app/features/import-export" } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3944d16251..326e103396 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Interface, Sidebar and Toolbar color groups from the theme editor and the theme file. - Fonts from the theme file. +- HTML and XML export. ### Fixed diff --git a/Plugins/HTMLExportPlugin/HTMLExportModels.swift b/Plugins/HTMLExportPlugin/HTMLExportModels.swift deleted file mode 100644 index 315cef8214..0000000000 --- a/Plugins/HTMLExportPlugin/HTMLExportModels.swift +++ /dev/null @@ -1,55 +0,0 @@ -// -// HTMLExportModels.swift -// HTMLExportPlugin -// - -import Foundation - -public struct HTMLExportOptions: Equatable, Codable { - /// Wraps the tables in a full document with a stylesheet. Off writes bare `` elements, - /// which is what pasting into an existing page wants. - public var writesFullDocument: Bool = true - - public var includesTableNames: Bool = true - - /// Renders a null as a dimmed `NULL` rather than an empty cell, which is otherwise identical to - /// a cell holding an empty string. - public var marksNulls: Bool = true - - public init() {} - - /// A synthesized `init(from:)` throws `keyNotFound` for a key the saved payload predates and - /// never falls back to the property's default, so adding one would reset what a user chose. - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let defaults = HTMLExportOptions() - writesFullDocument = try container.decodeIfPresent(Bool.self, forKey: .writesFullDocument) - ?? defaults.writesFullDocument - includesTableNames = try container.decodeIfPresent(Bool.self, forKey: .includesTableNames) - ?? defaults.includesTableNames - marksNulls = try container.decodeIfPresent(Bool.self, forKey: .marksNulls) ?? defaults.marksNulls - } -} - -/// Escapes text for HTML. -/// -/// Every value in an export comes from the database, so a value holding `") - == "<script>alert(1)</script>") - } - - /// The ampersand has to be replaced first, or the escapes written after it are themselves - /// escaped and the value renders as `&lt;`. - @Test("An ampersand is escaped once, not twice") - func ampersandEscapedOnce() { - #expect(HTMLEscaping.text("&") == "&") - #expect(HTMLEscaping.text("<") == "&lt;") - #expect(HTMLEscaping.text("a & b < c") == "a & b < c") - } - - @Test("Quotes are escaped so a value cannot break out of an attribute") - func quotesAreEscaped() { - #expect(HTMLEscaping.text("\"x\"") == ""x"") - #expect(HTMLEscaping.text("'x'") == "'x'") - } - - @Test("Ordinary text is unchanged") - func plainTextUnchanged() { - #expect(HTMLEscaping.text("Ada Lovelace") == "Ada Lovelace") - #expect(HTMLEscaping.text("") == "") - } -} - -struct XMLExportEscapingTests { - @Test("The five predefined entities are escaped") - func entitiesAreEscaped() { - #expect(XMLEscaping.text("") == "<a & b>") - #expect(XMLEscaping.text("\"'") == ""'") - } - - /// XML 1.0 accepts tab, newline and carriage return and no other control character. A stray - /// 0x00 out of a binary column would otherwise make the whole document unparseable. - @Test("Illegal control characters are dropped, legal whitespace is kept") - func controlCharactersAreDropped() { - #expect(XMLEscaping.text("a\u{0}b") == "ab") - #expect(XMLEscaping.text("a\u{1}\u{1F}b") == "ab") - #expect(XMLEscaping.text("a\tb\nc\rd") == "a\tb\nc\rd") - } - - /// A column name is not automatically a legal element name: XML forbids a leading digit and - /// restricts the character set, and a database column has neither limit. - @Test("A column name becomes a legal element name") - func elementNamesAreLegal() { - #expect(XMLEscaping.elementName("name") == "name") - #expect(XMLEscaping.elementName("first_name") == "first_name") - #expect(XMLEscaping.elementName("2024_total") == "_2024_total") - #expect(XMLEscaping.elementName("order total") == "order_total") - #expect(XMLEscaping.elementName("a-b.c") == "a-b.c") - } - - /// Names beginning `xml` in any case are reserved by the specification. - @Test("A reserved xml prefix is renamed") - func reservedPrefixIsRenamed() { - #expect(XMLEscaping.elementName("xmlData") == "_xmlData") - #expect(XMLEscaping.elementName("XMLData") == "_XMLData") - } - - @Test("A name with nothing legal in it falls back rather than producing invalid XML") - func emptyNameFallsBack() { - #expect(XMLEscaping.elementName("") == "column") - } -} - struct ParquetTypeMapperTests { private func mapped(_ typeName: String, on databaseTypeId: String = "PostgreSQL") -> String { ParquetTypeMapper.duckDBType(forColumnType: typeName, databaseTypeId: databaseTypeId) diff --git a/project.yml b/project.yml index 0a1cbf5bf0..a4fed288e7 100644 --- a/project.yml +++ b/project.yml @@ -247,12 +247,6 @@ targets: - target: MarkdownExport embed: true copy: { destination: plugins } - - target: HTMLExport - embed: true - copy: { destination: plugins } - - target: XMLExport - embed: true - copy: { destination: plugins } - target: XLSXImport embed: true copy: { destination: plugins } @@ -575,7 +569,6 @@ targets: - Plugins/TypesenseDriverPlugin/TypesenseQueryBuilder.swift - Plugins/TypesenseDriverPlugin/TypesenseSchema.swift - Plugins/TypesenseDriverPlugin/TypesenseStatementGenerator.swift - - Plugins/HTMLExportPlugin/HTMLExportModels.swift - Plugins/JSONExportPlugin/JSONExportModels.swift - Plugins/MarkdownExportPlugin/MarkdownExportModels.swift - Plugins/ParquetExportPlugin/ParquetExportModels.swift @@ -881,7 +874,6 @@ targets: - Plugins/XLSXExportPlugin/XLSXWriter.swift - Plugins/XLSXImportPlugin/XLSXImportOptions.swift - Plugins/XLSXImportPlugin/XLSXSheetParser.swift - - Plugins/XMLExportPlugin/XMLExportModels.swift - Plugins/SnowflakeDriverPlugin/PluginCellValueBox.swift - Plugins/SnowflakeDriverPlugin/SnowflakeAuth.swift - Plugins/SnowflakeDriverPlugin/SnowflakeBindingEncoder.swift @@ -1126,15 +1118,6 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.TablePro.MarkdownExportPlugin - HTMLExport: - templates: [DriverPlugin] - templateAttributes: - folder: HTMLExportPlugin - principalClass: HTMLExportPlugin - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.TablePro.HTMLExportPlugin - XLSXImport: templates: [DriverPlugin] templateAttributes: @@ -1147,15 +1130,6 @@ targets: base: PRODUCT_BUNDLE_IDENTIFIER: com.TablePro.XLSXImportPlugin - XMLExport: - templates: [DriverPlugin] - templateAttributes: - folder: XMLExportPlugin - principalClass: XMLExportPlugin - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: com.TablePro.XMLExportPlugin - ParquetExport: templates: [DriverPlugin] templateAttributes: @@ -1665,12 +1639,10 @@ aggregateTargets: - OracleDriver - PostgreSQLDriver - RedisDriver - - HTMLExport - MarkdownExport - ParquetExport - SQLExport - XLSXImport - - XMLExport - SQLImport - SQLiteDriver - SnowflakeDriverPlugin From 6dd395b8726381b0ea8e4099ce49d31129d21ddb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 12 Oct 2026 18:45:03 +0700 Subject: [PATCH 2/2] feat(plugins): offer Parquet export in the Export dialog --- CHANGELOG.md | 1 + .../Plugins/MissingDriverPluginPrompt.swift | 13 ++--- .../Plugins/MissingExportFormatPrompt.swift | 43 +++++++++++++++ .../PluginInstallProgressPresenter.swift | 16 +++++- .../Plugins/PluginManager+Registration.swift | 39 +++++++++++--- .../Core/Plugins/PluginSettingsSnapshot.swift | 6 +++ .../Services/Export/ExportFormatCatalog.swift | 19 +++++++ TablePro/Resources/Localizable.xcstrings | 9 ++++ TablePro/Views/Export/ExportDialog.swift | 46 +++++++++++++++- .../Export/ExportFormatCatalogTests.swift | 10 +++- .../Plugins/PluginSettingsSnapshotTests.swift | 30 ++++++++++- .../RegistryClientFreshnessTests.swift | 54 +++++++++++++++++++ 12 files changed, 264 insertions(+), 22 deletions(-) create mode 100644 TablePro/Core/Plugins/MissingExportFormatPrompt.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 326e103396..f1f741c03d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Panel and status colors in the theme editor, covering the results, inspector, structure, compare and query plan panes. - Themes that name a system color for a slot, so the built-in themes keep the system's own contrast settings. - Reason shown in Settings > Appearance for a theme file that could not be loaded. +- Install Parquet… in the Export dialog's format menu when the Parquet plugin is not installed. ### Changed diff --git a/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift b/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift index 6e4bbffe4c..f3417b8713 100644 --- a/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift +++ b/TablePro/Core/Plugins/MissingDriverPluginPrompt.swift @@ -37,20 +37,15 @@ internal enum MissingDriverPluginPrompt { ) guard confirmed else { return false } - let presenter = PluginInstallProgressPresenter() - presenter.begin(title: String(format: String(localized: "Downloading the %@ plugin…"), displayName)) - do { - try await PluginManager.shared.installMissingPlugin(for: type) { fraction in - presenter.update(fraction: fraction) + try await PluginInstallProgressPresenter.run( + title: String(format: String(localized: "Downloading the %@ plugin…"), displayName) + ) { progress in + try await PluginManager.shared.installMissingPlugin(for: type, progress: progress) } - presenter.end() logger.info("Installed \(type.rawValue, privacy: .public) to open a file") return true } catch { - /// Ended before the failure is presented, because both are sheets on the same window - /// and the second would queue behind the first. - presenter.end() logger.error("Install failed for \(type.rawValue, privacy: .public): \(error.publicLogShape, privacy: .public)") AlertHelper.showErrorSheet( title: String(localized: "Plugin Installation Failed"), diff --git a/TablePro/Core/Plugins/MissingExportFormatPrompt.swift b/TablePro/Core/Plugins/MissingExportFormatPrompt.swift new file mode 100644 index 0000000000..4ddb2da3f0 --- /dev/null +++ b/TablePro/Core/Plugins/MissingExportFormatPrompt.swift @@ -0,0 +1,43 @@ +// +// MissingExportFormatPrompt.swift +// TablePro +// + +import AppKit +import os + +@MainActor +internal enum MissingExportFormatPrompt { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "MissingExportFormatPrompt") + + internal static func ensureInstalled(_ format: InstallableExportFormat, window: NSWindow?) async -> Bool { + let confirmed = await AlertHelper.confirm( + title: String(format: String(localized: "Install the %@ plugin?"), format.displayName), + message: String(localized: "TablePro writes this format with a plugin it downloads from the plugin registry."), + confirmButton: String(localized: "Install"), + window: window + ) + guard confirmed else { return false } + + do { + try await PluginInstallProgressPresenter.run( + title: String(format: String(localized: "Downloading the %@ plugin…"), format.displayName), + window: window + ) { progress in + try await PluginManager.shared.installMissingExportFormat(format, progress: progress) + } + logger.info("Installed the \(format.formatId, privacy: .public) export format") + return true + } catch { + logger.error( + "Install failed for \(format.formatId, privacy: .public): \(error.publicLogShape, privacy: .public)" + ) + AlertHelper.showErrorSheet( + title: String(localized: "Plugin Installation Failed"), + message: error.localizedDescription, + window: window + ) + return false + } + } +} diff --git a/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift b/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift index 9ef6e10cd8..100cb512bb 100644 --- a/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift +++ b/TablePro/Core/Plugins/PluginInstallProgressPresenter.swift @@ -25,15 +25,27 @@ internal final class PluginInstallProgressPresenter { internal init() {} + // Ended before the caller presents a failure, since a second sheet on the window would queue. + internal static func run( + title: String, + window: NSWindow? = nil, + _ install: (_ progress: @escaping @MainActor @Sendable (Double) -> Void) async throws -> Void + ) async throws { + let presenter = PluginInstallProgressPresenter() + presenter.begin(title: title, window: window) + defer { presenter.end() } + try await install { fraction in presenter.update(fraction: fraction) } + } + /// Presented as a sheet on the window the user was working in, and as a free-standing panel /// when there is none, which is how a Finder open before any window exists reaches the screen. - internal func begin(title: String) { + internal func begin(title: String, window: NSWindow? = nil) { guard panel == nil else { return } label.stringValue = title let panel = makePanel() self.panel = panel - guard let parent = AlertHelper.resolveWindow(nil) else { + guard let parent = AlertHelper.resolveWindow(window) else { panel.center() panel.makeKeyAndOrderFront(nil) return diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 9d6077762f..29f9504e0c 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -774,12 +774,40 @@ extension PluginManager { } } + let entry = try await installFromManifest(registryClient: registryClient, progress: progress) { manifest in + Self.registryPlugin(forTypeId: pluginTypeId, in: manifest) + } + Self.logger.info("Installed missing plugin '\(entry.name)' for database type '\(databaseType.rawValue)'") + } + + func installMissingExportFormat( + _ format: InstallableExportFormat, + registryClient: RegistryClient = .shared, + progress: @escaping @MainActor @Sendable (Double) -> Void + ) async throws { + if let existingEntry = plugins.first(where: { $0.id == format.registryPluginId }) { + if !existingEntry.isEnabled { + setEnabled(true, pluginId: existingEntry.id) + } + return + } + let entry = try await installFromManifest(registryClient: registryClient, progress: progress) { manifest in + manifest?.plugins.first { $0.id == format.registryPluginId } + } + Self.logger.info("Installed missing plugin '\(entry.name)' for export format '\(format.formatId)'") + } + + private func installFromManifest( + registryClient: RegistryClient, + progress: @escaping @MainActor @Sendable (Double) -> Void, + lookup: (RegistryManifest?) -> RegistryPlugin? + ) async throws -> PluginEntry { await registryClient.ensureManifest(.ifStale) - var registryPlugin = Self.registryPlugin(forTypeId: pluginTypeId, in: registryClient.manifest) + var registryPlugin = lookup(registryClient.manifest) if registryPlugin == nil { await registryClient.ensureManifest(.mustBeCurrent) - registryPlugin = Self.registryPlugin(forTypeId: pluginTypeId, in: registryClient.manifest) + registryPlugin = lookup(registryClient.manifest) } guard let registryPlugin else { @@ -789,10 +817,7 @@ extension PluginManager { throw PluginError.notFound } - /// Published to the tracker as well as to the caller's handler. `PluginInstallStatusRow` - /// is built to draw the fraction and reaches it only through the tracker, so without this - /// every install started from a connection or a file fell back to its indeterminate - /// spinner while the determinate bar beside it was never fed. + // PluginInstallStatusRow reads the fraction only through the tracker. let tracker = PluginInstallTracker.shared tracker.beginInstall(pluginId: registryPlugin.id) do { @@ -805,7 +830,7 @@ extension PluginManager { } ) tracker.completeInstall(pluginId: registryPlugin.id) - Self.logger.info("Installed missing plugin '\(entry.name)' for database type '\(databaseType.rawValue)'") + return entry } catch { tracker.failInstall(pluginId: registryPlugin.id, error: error.localizedDescription) throw error diff --git a/TablePro/Core/Plugins/PluginSettingsSnapshot.swift b/TablePro/Core/Plugins/PluginSettingsSnapshot.swift index a228bcd1e6..01833fc981 100644 --- a/TablePro/Core/Plugins/PluginSettingsSnapshot.swift +++ b/TablePro/Core/Plugins/PluginSettingsSnapshot.swift @@ -22,6 +22,12 @@ struct PluginSettingsSnapshot { } } + mutating func include(_ plugin: any SettablePluginDiscoverable) { + guard !entries.contains(where: { $0.plugin === plugin }), + let data = plugin.snapshotSettingsData() else { return } + entries.append((plugin, data)) + } + mutating func recapture(_ plugin: any SettablePluginDiscoverable) { guard let index = entries.firstIndex(where: { $0.plugin === plugin }), let data = plugin.snapshotSettingsData() else { return } diff --git a/TablePro/Core/Services/Export/ExportFormatCatalog.swift b/TablePro/Core/Services/Export/ExportFormatCatalog.swift index 750fbc323d..03f5386b0b 100644 --- a/TablePro/Core/Services/Export/ExportFormatCatalog.swift +++ b/TablePro/Core/Services/Export/ExportFormatCatalog.swift @@ -6,11 +6,30 @@ import Foundation import TableProPluginKit +internal struct InstallableExportFormat: Equatable, Sendable { + internal let formatId: String + internal let displayName: String + internal let registryPluginId: String +} + internal enum ExportFormatCatalog { private static let displayOrder = [ "csv", "json", "sql", "xlsx", "md", "parquet", "mql" ] + // The registry manifest names no format id, so the app keeps the one mapping it offers. + internal static let installable = [ + InstallableExportFormat( + formatId: "parquet", + displayName: "Parquet", + registryPluginId: "com.TablePro.ParquetExportPlugin" + ) + ] + + internal static func notInstalled(installedFormatIds: Set) -> [InstallableExportFormat] { + installable.filter { !installedFormatIds.contains($0.formatId) } + } + internal static func available( _ plugins: [any ExportFormatPlugin], forDatabaseTypeId databaseTypeId: String diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 15315f098e..a2f744b3dd 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -93123,6 +93123,9 @@ } } } + }, + "Install %@…" : { + }, "Install it with “brew install libpq” and link it." : { "localizations" : { @@ -93329,6 +93332,9 @@ } } } + }, + "Install the %@ plugin?" : { + }, "Install Theme" : { "localizations" : { @@ -177556,6 +177562,9 @@ } } } + }, + "TablePro writes this format with a plugin it downloads from the plugin registry." : { + }, "TABLEPRO_BEANCOUNT_PYTHON points to a Python executable that cannot import beancount at %@" : { "extractionState" : "manual", diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 444115e558..99269734f9 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -222,6 +222,40 @@ struct ExportDialog: View { pluginManager.exportPlugin(forFormat: config.formatId) } + private var formatsToOffer: [InstallableExportFormat] { + let installed = Set(pluginManager.allExportPlugins().map { type(of: $0).formatId }) + return ExportFormatCatalog.notInstalled(installedFormatIds: installed) + } + + private static func offerTag(for format: InstallableExportFormat) -> String { + "install:\(format.formatId)" + } + + // Choosing an offered format asks to install it and keeps the current format until it lands. + private var formatSelection: Binding { + Binding( + get: { config.formatId }, + set: { newValue in + guard let format = formatsToOffer.first(where: { Self.offerTag(for: $0) == newValue }) else { + config.formatId = newValue + return + } + Task { await installOfferedFormat(format) } + } + ) + } + + @MainActor + private func installOfferedFormat(_ format: InstallableExportFormat) async { + guard await MissingExportFormatPrompt.ensureInstalled(format, window: hostWindow), + let plugin = pluginManager.exportPlugin(forFormat: format.formatId) + else { return } + if let settable = plugin as? any SettablePluginDiscoverable { + settingsSnapshot?.include(settable) + } + config.formatId = format.formatId + } + private var currentOptionColumnCount: Int { guard let plugin = currentPlugin else { return 0 } return type(of: plugin).perTableOptionColumns.count @@ -427,7 +461,7 @@ struct ExportDialog: View { .frame(maxWidth: .infinity, alignment: .center) } - if availableFormats.isEmpty { + if availableFormats.isEmpty && formatsToOffer.isEmpty { HStack { Spacer() Text("No export formats available. Enable export plugins in Settings > Plugins.") @@ -439,12 +473,20 @@ struct ExportDialog: View { HStack { Spacer() - Picker(String(localized: "Format"), selection: $config.formatId) { + Picker(String(localized: "Format"), selection: formatSelection) { ForEach(availableFormatIds, id: \.self) { formatId in if let plugin = pluginManager.exportPlugin(forFormat: formatId) { Text(type(of: plugin).formatDisplayName).tag(formatId) } } + let offered = formatsToOffer + if !offered.isEmpty { + Divider() + ForEach(offered, id: \.formatId) { format in + Text(String(format: String(localized: "Install %@…"), format.displayName)) + .tag(Self.offerTag(for: format)) + } + } } .labelsHidden() diff --git a/TableProTests/Core/Export/ExportFormatCatalogTests.swift b/TableProTests/Core/Export/ExportFormatCatalogTests.swift index 77baf70430..326db9491e 100644 --- a/TableProTests/Core/Export/ExportFormatCatalogTests.swift +++ b/TableProTests/Core/Export/ExportFormatCatalogTests.swift @@ -73,7 +73,6 @@ private final class StubAardvark: StubExportFormat, @unchecked Sendable { } struct ExportFormatCatalogTests { - private func ids(_ plugins: [any ExportFormatPlugin]) -> [String] { plugins.map { type(of: $0).formatId } } @@ -118,4 +117,13 @@ struct ExportFormatCatalogTests { #expect(forwards == backwards) #expect(forwards == ["csv", "sql", "parquet", "zebra"]) } + + @Test("Parquet is offered for install until it is installed") + func parquetIsOfferedUntilInstalled() { + let offered = ExportFormatCatalog.notInstalled(installedFormatIds: ["csv", "sql"]) + #expect(offered.map(\.formatId) == ["parquet"]) + #expect(offered.first?.registryPluginId == "com.TablePro.ParquetExportPlugin") + + #expect(ExportFormatCatalog.notInstalled(installedFormatIds: ["csv", "parquet"]).isEmpty) + } } diff --git a/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift b/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift index 389ce75a1d..3b97c6c47b 100644 --- a/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift +++ b/TableProTests/Core/Plugins/PluginSettingsSnapshotTests.swift @@ -5,9 +5,9 @@ import Foundation import SwiftUI +@testable import TablePro import TableProPluginKit import Testing -@testable import TablePro @Suite("PluginSettingsSnapshot", .serialized) struct PluginSettingsSnapshotTests { @@ -90,4 +90,32 @@ struct PluginSettingsSnapshotTests { #expect(plugin.settings == TestOptions(flag: true, count: 3)) } + + @Test("A plugin installed after the capture is rolled back too") + func includeAddsALaterPlugin() { + defer { cleanup() } + var snapshot = PluginSettingsSnapshot(plugins: []) + let plugin = TestPlugin() + plugin.settings = TestOptions(flag: true, count: 4) + + snapshot.include(plugin) + plugin.settings = TestOptions(flag: false, count: 8) + snapshot.restore() + + #expect(plugin.settings == TestOptions(flag: true, count: 4)) + } + + @Test("Including a captured plugin keeps its original baseline") + func includeKeepsTheFirstBaseline() { + defer { cleanup() } + let plugin = TestPlugin() + plugin.settings = TestOptions(flag: true, count: 1) + var snapshot = PluginSettingsSnapshot(plugins: [plugin]) + + plugin.settings = TestOptions(flag: false, count: 6) + snapshot.include(plugin) + snapshot.restore() + + #expect(plugin.settings == TestOptions(flag: true, count: 1)) + } } diff --git a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift index 8ab4fe11df..48d33bc437 100644 --- a/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift +++ b/TableProTests/Core/Plugins/RegistryClientFreshnessTests.swift @@ -236,6 +236,60 @@ struct RegistryClientFreshnessTests { } } + @Test("an offered export format is found in the registry by its plugin id") + func exportFormatResolvesByRegistryId() async throws { + let fresh = manifestData(includingTestPlugin: true) + MockRegistryProtocol.reset(responses: [(200, fresh), (200, fresh), (200, fresh)]) + let (client, defaults, tempDir) = try makeEnvironment() + let manager = PluginManager( + userDefaults: defaults, + builtInPluginsURL: nil, + userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) + ) + let format = InstallableExportFormat( + formatId: "freshness", + displayName: "Freshness", + registryPluginId: "com.test.freshness-driver" + ) + + do { + try await manager.installMissingExportFormat(format, registryClient: client) { _ in } + Issue.record("Expected install to fail at compatibility validation") + } catch let error as PluginError { + guard case .incompatibleWithCurrentApp = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + + @Test("an offered export format missing from the registry reports not found") + func exportFormatMissingFromRegistry() async throws { + let empty = manifestData(includingTestPlugin: false) + MockRegistryProtocol.reset(responses: [(200, empty), (200, empty)]) + let (client, defaults, tempDir) = try makeEnvironment() + let manager = PluginManager( + userDefaults: defaults, + builtInPluginsURL: nil, + userPluginsDir: tempDir.appendingPathComponent("Plugins", isDirectory: true) + ) + let format = InstallableExportFormat( + formatId: "freshness", + displayName: "Freshness", + registryPluginId: "com.test.freshness-driver" + ) + + do { + try await manager.installMissingExportFormat(format, registryClient: client) { _ in } + Issue.record("Expected install to fail") + } catch let error as PluginError { + guard case .notFound = error else { + Issue.record("Unexpected error: \(error)") + return + } + } + } + @Test("registryPlugin resolves by database type id") func registryPluginLookup() throws { let manifest = try JSONDecoder().decode(RegistryManifest.self, from: manifestData(includingTestPlugin: true))