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
60 changes: 60 additions & 0 deletions .github/plugin-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,18 @@
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/etcd"
},
"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",
Expand Down Expand Up @@ -187,6 +199,18 @@
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/libsql"
},
"markdown": {
"target": "MarkdownExport",
"bundleName": "MarkdownExport",
"bundleId": "com.TablePro.MarkdownExportPlugin",
"bundled": true,
"displayName": "Markdown Export",
"summary": "Export data as Markdown tables",
"databaseTypeIds": null,
"icon": "text.alignleft",
"category": "export-format",
"homepage": "https://docs.tablepro.app/features/import-export"
},
"mongodb": {
"target": "MongoDBDriver",
"bundleName": "MongoDBDriver",
Expand Down Expand Up @@ -241,6 +265,18 @@
"category": "database-driver",
"homepage": "https://docs.tablepro.app/databases/oracle"
},
"parquet": {
"target": "ParquetExport",
"bundleName": "ParquetExport",
"bundleId": "com.TablePro.ParquetExportPlugin",
"bundled": false,
"displayName": "Parquet Export",
"summary": "Export data to Apache Parquet",
"databaseTypeIds": null,
"icon": "square.grid.3x3",
"category": "export-format",
"homepage": "https://docs.tablepro.app/features/import-export"
},
"redis": {
"target": "RedisDriver",
"bundleName": "RedisDriver",
Expand Down Expand Up @@ -348,6 +384,30 @@
"icon": "tablecells",
"category": "export-format",
"homepage": "https://docs.tablepro.app/features/import-export"
},
"xlsximport": {
"target": "XLSXImport",
"bundleName": "XLSXImport",
"bundleId": "com.TablePro.XLSXImportPlugin",
"bundled": true,
"displayName": "XLSX Import",
"summary": "Import data from Excel workbooks",
"databaseTypeIds": null,
"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"
}
}
}
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Breakdown of a query's time into server, first row and transfer, behind the toolbar's duration readout. (#2503)
- Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516)
- Jump to Column in the grid, a fuzzy search over the result's columns with their type and position. (#2495)
- Views, routines, triggers, user-defined types and privileges in the export tree, grouped by kind. (#2618)
- Per-table `WHERE`, row limit and column subset in the export tree. (#2618)
- Insert mode for SQL exports: skip, replace or update rows that already exist. (#2618)
- Split a SQL export into numbered parts at a chosen size. (#2618)
- Read every table at one snapshot during a SQL export. (#2618)
- Backup and restore for MySQL, MariaDB, MongoDB, SQLite and libSQL, using each engine's own tools. (#2618)
- Transfer To, copying table rows straight into another open connection with no file in between. (#2618)
- NDJSON layout for JSON exports, one row per line. (#2618)
- Saved export selections, reapplied from the export tree's bookmark menu. (#2618)
- Save Report on an import that skipped rows, listing each one's line and error as CSV. (#2618)
- Markdown, HTML and XML export. (#2618)
- Parquet export, through a plugin installed from Settings > Plugins. (#2618)
- XLSX import, reading the first worksheet of a workbook. (#2618)
- Server-Side Export for Oracle, Snowflake and BigQuery, which unload to a server directory, a stage or a bucket. (#2618)
- Backup and restore for SQL Server through SqlPackage. (#2618)
- MySQL events and PostgreSQL sequences in the export tree. (#2618)
- Connection groups in Switch Connection, with `Cmd`-click to open a saved connection in a new window. (#1311)

### Changed
Expand All @@ -26,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- DuckDB aggregate, JSON and Parquet functions failing on a Mac that cannot reach `extensions.duckdb.org`. (#2626)
- Last line of a helper process's output lost when it exits right after writing it.
- Structure and trigger edits committing or rolling back a transaction left open in a query tab on the same connection.
- Composite, range and extension-typed PostgreSQL columns labelled `ENUM(…)` in the structure editor.
Expand Down
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ TablePro is a native macOS database client (SwiftUI + AppKit), a fast, lightweig

- **Source**: `TablePro/` holds `Core/` (business logic, services), `Views/` (UI), `Models/` (data structures), `ViewModels/`, `Extensions/` and `Theme/`
- **Plugins**: `Plugins/` holds the `.tableplugin` bundles plus the `TableProPluginKit` shared framework.
- **Bundled in app** (the 14 targets in the app's `copy: { destination: plugins }` phase in `project.yml`): MySQL, PostgreSQL, SQLite, ClickHouse, Redis, CSV export, JSON export, SQL export, XLSX export, MQL export, SQL import, JSON import, CSV import, CSV inspector. These ship inside the app bundle and their updates normally ride with the next app release. Six of them (`sqlite`, `clickhouse`, `redis`, `xlsx`, `mql`, `sqlimport`) also have registry arms in `build-plugin.yml`, so a bundled plugin can be published when users on an already-shipped app need the fix sooner. `scripts/build-plugin.sh:10` explains the flag that makes that work.
- **Registry-only** (the other 18): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL, Snowflake, Elasticsearch, Beancount, SurrealDB, Teradata, Trino, Dameng, Kafka. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory.
- **Bundled in app** (the 18 targets in the app's `copy: { destination: plugins }` phase in `project.yml`): MySQL, PostgreSQL, SQLite, ClickHouse, Redis, CSV export, JSON export, SQL export, XLSX export, Markdown export, HTML export, XML export, MQL export, SQL import, JSON import, CSV import, XLSX import, CSV inspector. These ship inside the app bundle and their updates normally ride with the next app release. Six of them (`sqlite`, `clickhouse`, `redis`, `xlsx`, `mql`, `sqlimport`) also have registry arms in `build-plugin.yml`, so a bundled plugin can be published when users on an already-shipped app need the fix sooner. `scripts/build-plugin.sh:10` explains the flag that makes that work.
- **Registry-only** (the other 19): MongoDB, Oracle, DuckDB, MSSQL, Cassandra, Etcd, CloudflareD1, DynamoDB, BigQuery, LibSQL, Snowflake, Elasticsearch, Beancount, SurrealDB, Teradata, Trino, Dameng, Kafka, Parquet export. Parquet is registry-only because it links its own copy of DuckDB, which does the encoding, and that is too large to ship in the app for one format. Distributed via [TableProApp/plugins](https://github.com/TableProApp/plugins) `plugins.json`, installed into the user plugins directory.
- **C bridges**: Each plugin contains its own C bridge module (e.g., `Plugins/MySQLDriverPlugin/CMariaDB/`, `Plugins/PostgreSQLDriverPlugin/CLibPQ/`)
- **Static libs**: `Libs/` holds pre-built `.a` files and `Libs/ios/` holds the iOS xcframeworks. Both are downloaded by `scripts/download-libs.sh` and are not in git.
- **SPM deps**: declared in `project.yml`. Vendored local packages under `LocalPackages/` (CodeEditSourceEditor, CodeEditTextView, CodeEditLanguages) and `Packages/` (TableProCore, TableProOracle); remote packages are Sparkle, swift-certificates and Yams. Revisions are pinned by the tracked `Package.resolved` inside each generated `.xcodeproj`.
Expand Down Expand Up @@ -103,7 +103,7 @@ git add Libs/ios/checksums.sha256 && git commit -m "build: update iOS xcframewor

Run `scripts/generate-project.sh` after editing any of those, and after adding, moving, or deleting a source file: XcodeGen globs sources at generation time, so a new file is not in the project until you regenerate. Changing signing in the Xcode UI is pointless, because the next generate discards it; set `TABLEPRO_DEVELOPMENT_TEAM` and `TABLEPRO_APP_BUNDLE_IDENTIFIER` in `Configs/Secrets.xcconfig` instead.

The 32 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh <PluginTarget> [arm64|x86_64|both] [version]` builds. The `AllPlugins` aggregate target compile-checks all 32, including the registry-only ones the app does not embed, and PR CI runs it: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds that scheme whenever the change touches `Plugins/` or any other watched path. What PR CI still does not cover is plugin packaging, signing and notarization, which only `build-plugin.yml` does and only on a release tag.
The 37 plugin bundles share one `DriverPlugin` target template; a plugin declares only its folder, principal class, and any C-library link flags. Every target gets a shared scheme named after it, which is what `scripts/build-plugin.sh <PluginTarget> [arm64|x86_64|both] [version]` builds. The `AllPlugins` aggregate target compile-checks all 37, including the registry-only ones the app does not embed, and PR CI runs it: the `Compile every plugin` step in the `app-tests` job of `.github/workflows/macos-tests.yml` builds that scheme whenever the change touches `Plugins/` or any other watched path. What PR CI still does not cover is plugin packaging, signing and notarization, which only `build-plugin.yml` does and only on a release tag.

### Plugin System

Expand Down
8 changes: 4 additions & 4 deletions Libs/checksums.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ a891a67c2619e2ac1dce64dafc6a24bfde9cabe15312dac6b70a19385664ea84 Libs/libcrypto
732adf315bc49f77e2511a9293e49a65e18eb54a3e6d01d8a24eee2d671d2a8a Libs/libcrypto_universal.a
965ccd38fea5cd97bc878dbf58567e4eed2b2337120f8d46a2da62c094b3c821 Libs/libcrypto_x86_64.a
732adf315bc49f77e2511a9293e49a65e18eb54a3e6d01d8a24eee2d671d2a8a Libs/libcrypto.a
1ef4f456b99285dca4fd8cfedef1a2f4936b0a9567a7e332baebdba04bf89d77 Libs/libduckdb_arm64.a
68a13d3a915acc08b59a4b982525afa143b588ec6e19c8e470e2ff97cf0df3d9 Libs/libduckdb_universal.a
e809dd7c7ec05a8218d273b3350a9a20e15a6ddf1e392ccbe5560395166a6b26 Libs/libduckdb_x86_64.a
68a13d3a915acc08b59a4b982525afa143b588ec6e19c8e470e2ff97cf0df3d9 Libs/libduckdb.a
e325cc7f47ad2ac91f777d075e5533b0ef34769e16cd728496d3f9229ed6ff9b Libs/libduckdb_arm64.a
b578aa2a73b0b84ba36e436d0cade00d5e98f1cb1ebbc849374758e9054985a9 Libs/libduckdb_universal.a
76737d65affec9b13676e0a39d3bd1054ee17642a04fa2ce34b9c2e998693fa0 Libs/libduckdb_x86_64.a
b578aa2a73b0b84ba36e436d0cade00d5e98f1cb1ebbc849374758e9054985a9 Libs/libduckdb.a
7e63017fa22c2eb7744eccad13857361a5088aa7b2772ab02cd026c8c7b78341 Libs/libhiredis_arm64.a
f1cfc36a7ab47361e9705fe32b1c919b318f606989478e91a808707d93db55a5 Libs/libhiredis_ssl_arm64.a
fb7a32c2c724cb4f3f880030cb19afbbc7db52121ad8e35e00a2e818da9562cf Libs/libhiredis_ssl_universal.a
Expand Down
51 changes: 27 additions & 24 deletions Plugins/CSVExportPlugin/CSVExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,33 +146,36 @@ final class CSVExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send
try fileHandle.write(contentsOf: (rowLine + lineBreak).toUTF8Data())
}

/// Escaping and quoting live in `PluginRowWriters`, so this format, the other export formats
/// and the MCP tool spell a value the same way. Only the option mapping is this plugin's own.
///
/// `originalHadLineBreaks` says the value's breaks were already replaced with spaces upstream,
/// and it still forces quoting: the source text spanned lines, and a reader that splits on the
/// delimiter has no way to know the space it now sees was one.
private func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> String {
var processed = field

if options.sanitizeFormulas {
let dangerousPrefixes: [Character] = ["=", "+", "-", "@"]
if let first = processed.first, dangerousPrefixes.contains(first) {
processed = "'" + processed
}
let escaped = PluginRowWriters.csvField(field, options: writeOptions(options))
guard originalHadLineBreaks, options.quoteHandling == .asNeeded, !escaped.hasPrefix("\"") else {
return escaped
}
return "\"\(escaped.replacingOccurrences(of: "\"", with: "\"\""))\""
}

switch options.quoteHandling {
case .always:
let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\""
case .never:
return processed
case .asNeeded:
let needsQuotes = processed.contains(options.delimiter.actualValue) ||
processed.contains("\"") ||
processed.contains("\n") ||
processed.contains("\r") ||
originalHadLineBreaks
if needsQuotes {
let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\""
}
return processed
private func writeOptions(_ options: CSVExportOptions) -> PluginCsvWriteOptions {
PluginCsvWriteOptions(
delimiter: options.delimiter.actualValue,
quoteHandling: quoteHandling(options.quoteHandling),
lineEnding: options.lineBreak.value,
nullAsEmpty: true,
sanitizesFormulas: options.sanitizeFormulas,
flattensLineBreaks: false
)
}

private func quoteHandling(_ handling: CSVQuoteHandling) -> PluginCsvWriteOptions.QuoteHandling {
switch handling {
case .always: return .always
case .never: return .never
case .asNeeded: return .asNeeded
}
}
}
55 changes: 55 additions & 0 deletions Plugins/HTMLExportPlugin/HTMLExportModels.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
//
// HTMLExportModels.swift
// HTMLExportPlugin
//

import Foundation

public struct HTMLExportOptions: Equatable, Codable {
/// Wraps the tables in a full document with a stylesheet. Off writes bare `<table>` 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>` reaches a file
/// someone opens in a browser. Escaping the five markup characters is what keeps the export a table
/// rather than a page that runs what a row happened to contain.
public enum HTMLEscaping {
public static func text(_ value: String) -> String {
var escaped = ""
escaped.reserveCapacity(value.count)
for character in value {
switch character {
case "&": escaped += "&amp;"
case "<": escaped += "&lt;"
case ">": escaped += "&gt;"
case "\"": escaped += "&quot;"
case "'": escaped += "&#39;"
default: escaped.append(character)
}
}
return escaped
}
}
26 changes: 26 additions & 0 deletions Plugins/HTMLExportPlugin/HTMLExportOptionsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// HTMLExportOptionsView.swift
// HTMLExportPlugin
//

import SwiftUI

struct HTMLExportOptionsView: View {
@Bindable var plugin: HTMLExportPlugin

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Toggle("Write a full HTML document", isOn: $plugin.settings.writesFullDocument)
.toggleStyle(.checkbox)
.help("Off writes bare table elements, for pasting into a page that already has its own styling")

Toggle("Write each table's name as a heading", isOn: $plugin.settings.includesTableNames)
.toggleStyle(.checkbox)

Toggle("Mark NULL cells", isOn: $plugin.settings.marksNulls)
.toggleStyle(.checkbox)
.help("An empty cell and a cell holding an empty string look the same, so a null is written as dimmed NULL text")
}
.font(.system(size: 13))
}
}
Loading
Loading