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 @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- iOS: add data to a table from the Shortcuts app. Two new shortcuts, Add Row to Table and Add Rows to Table, pick a saved connection, database or schema, and table, then insert from JSON or CSV. They run without opening the app. (#1788)

### Fixed

- Restored table tabs no longer reload all at once or flood failure dialogs on launch. Only the frontmost tab loads immediately; other restored tabs load when you switch to them, and a load failure now shows inline in the tab instead of a dialog. (#1796)
Expand Down
107 changes: 107 additions & 0 deletions TableProMobile/TableProMobile/Intents/AddRowIntents.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import AppIntents
import Foundation
import TableProModels
import UniformTypeIdentifiers

protocol RowInsertingIntent: AppIntent {
var connection: ConnectionEntity { get }
var database: DatabaseEntity? { get }
var table: TableEntity { get }
}

extension RowInsertingIntent {
func insert(rows: [PayloadRow]) async throws -> Int {
guard let savedConnection = IntentConnectionLoader.connection(id: connection.id) else {
throw IntentDataError.connectionNotFound
}
switch savedConnection.safeModeLevel.writePermission {
case .blocked:
throw IntentDataError.readOnly(savedConnection.name.isEmpty ? savedConnection.host : savedConnection.name)
case .requiresConfirmation:
let noun = rows.count == 1 ? "row" : "rows"
try await requestConfirmation(
actionName: .add,
dialog: "Add \(rows.count) \(noun) to \(table.name)?"
)
case .proceed:
break
}
return try await IntentDatabaseSession.with(connection: savedConnection) { session in
try await session.insertRows(namespace: database?.id, table: table.name, rows: rows)
}
}
}

struct AddRowToTableIntent: RowInsertingIntent {
static var title: LocalizedStringResource = "Add Row to Table"
static var description = IntentDescription(
"Add one row to a table on a saved connection. Provide the row as a JSON object or a CSV row."
)
static var openAppWhenRun = false
static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication

@Parameter(title: "Connection")
var connection: ConnectionEntity

@Parameter(title: "Database or Schema")
var database: DatabaseEntity?

@Parameter(title: "Table")
var table: TableEntity

@Parameter(title: "Row (JSON or CSV)")
var data: String

static var parameterSummary: some ParameterSummary {
Summary("Add a row to \(\.$table)") {
\.$connection
\.$database
\.$data
}
}

func perform() async throws -> some IntentResult & ReturnsValue<Int> & ProvidesDialog {
let rows = try await RowPayload.parseSingle(data: data, file: nil)
let count = try await insert(rows: rows)
return .result(value: count, dialog: "Added \(count) row to \(table.name).")
}
}

struct AddRowsToTableIntent: RowInsertingIntent {
static var title: LocalizedStringResource = "Add Rows to Table"
static var description = IntentDescription(
"Add multiple rows to a table on a saved connection. Provide the rows as a JSON array, CSV text, or a file."
)
static var openAppWhenRun = false
static var authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication

@Parameter(title: "Connection")
var connection: ConnectionEntity

@Parameter(title: "Database or Schema")
var database: DatabaseEntity?

@Parameter(title: "Table")
var table: TableEntity

@Parameter(title: "Rows (JSON or CSV)")
var data: String?

@Parameter(title: "File", supportedContentTypes: [.commaSeparatedText, .json, .plainText, .data])
var file: IntentFile?

static var parameterSummary: some ParameterSummary {
Summary("Add rows to \(\.$table)") {
\.$connection
\.$database
\.$data
\.$file
}
}

func perform() async throws -> some IntentResult & ReturnsValue<Int> & ProvidesDialog {
let rows = try await RowPayload.parse(data: data, file: file)
let count = try await insert(rows: rows)
return .result(value: count, dialog: "Added \(count) rows to \(table.name).")
}
}
17 changes: 17 additions & 0 deletions TableProMobile/TableProMobile/Intents/ConnectionEntity.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppIntents
import Foundation
import TableProModels

struct ConnectionEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Connection")
Expand All @@ -16,4 +17,20 @@ struct ConnectionEntity: AppEntity {
subtitle: "\(databaseType) · \(host)"
)
}

init(id: UUID, name: String, host: String, databaseType: String) {
self.id = id
self.name = name
self.host = host
self.databaseType = databaseType
}

init(connection: DatabaseConnection) {
self.init(
id: connection.id,
name: connection.name.isEmpty ? connection.host : connection.name,
host: connection.host,
databaseType: connection.type.rawValue
)
}
}
41 changes: 5 additions & 36 deletions TableProMobile/TableProMobile/Intents/ConnectionEntityQuery.swift
Original file line number Diff line number Diff line change
@@ -1,46 +1,15 @@
import AppIntents
import Foundation
import TableProModels

struct ConnectionEntityQuery: EntityQuery {
func entities(for identifiers: [UUID]) async throws -> [ConnectionEntity] {
let all = loadConnections()
return all.filter { identifiers.contains($0.id) }
IntentConnectionLoader.load()
.filter { identifiers.contains($0.id) }
.map(ConnectionEntity.init(connection:))
}

func suggestedEntities() async throws -> [ConnectionEntity] {
loadConnections()
}

private func loadConnections() -> [ConnectionEntity] {
guard let dir = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
return []
}
let fileURL = dir
.appendingPathComponent("TableProMobile", isDirectory: true)
.appendingPathComponent("connections.json")
guard let data = try? Data(contentsOf: fileURL) else { return [] }

struct StoredConnection: Decodable {
let id: UUID
let name: String
let host: String
let type: String
}

guard let connections = try? JSONDecoder().decode([StoredConnection].self, from: data) else {
return []
}

return connections.map { conn in
ConnectionEntity(
id: conn.id,
name: conn.name.isEmpty ? conn.host : conn.name,
host: conn.host,
databaseType: conn.type
)
}
IntentConnectionLoader.load().map(ConnectionEntity.init(connection:))
}
}
44 changes: 44 additions & 0 deletions TableProMobile/TableProMobile/Intents/DatabaseEntity.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import AppIntents
import Foundation

struct DatabaseEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Database or Schema")
static var defaultQuery = DatabaseEntityQuery()

var id: String
var name: String
var kind: Kind

enum Kind: String, Sendable {
case database
case schema
}

var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(name)")
}
}

struct DatabaseEntityQuery: EntityQuery {
@IntentParameterDependency<AddRowToTableIntent>(\.$connection)
var addRow

@IntentParameterDependency<AddRowsToTableIntent>(\.$connection)
var addRows

func entities(for identifiers: [String]) async throws -> [DatabaseEntity] {
identifiers.map { DatabaseEntity(id: $0, name: $0, kind: .database) }
}

func suggestedEntities() async throws -> [DatabaseEntity] {
guard let connection = selectedConnection else { return [] }
let namespaces = try? await IntentDatabaseSession.with(connectionId: connection.id) {
try await $0.namespaces()
}
return namespaces ?? []
}

private var selectedConnection: ConnectionEntity? {
addRow?.connection ?? addRows?.connection
}
}
41 changes: 41 additions & 0 deletions TableProMobile/TableProMobile/Intents/IntentConnectionLoader.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import Foundation
import TableProModels

enum IntentConnectionLoader {
static func load() -> [DatabaseConnection] {
guard let fileURL else { return [] }
guard let data = try? Data(contentsOf: fileURL) else { return [] }
return decode(data)
}

static func connection(id: UUID) -> DatabaseConnection? {
load().first { $0.id == id }
}

static func decode(_ data: Data) -> [DatabaseConnection] {
guard let elements = try? JSONDecoder().decode([FailableConnection].self, from: data) else {
return []
}
return elements.compactMap(\.value)
}

private struct FailableConnection: Decodable {
let value: DatabaseConnection?

init(from decoder: Decoder) throws {
value = try? DatabaseConnection(from: decoder)
}
}

private static var fileURL: URL? {
guard let directory = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
return nil
}
return directory
.appendingPathComponent("TableProMobile", isDirectory: true)
.appendingPathComponent("connections.json")
}
}
43 changes: 43 additions & 0 deletions TableProMobile/TableProMobile/Intents/IntentDataError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import AppIntents
import Foundation

enum IntentDataError: Error, CustomLocalizedStringResourceConvertible {
case connectionNotFound
case unsupportedDatabaseType(String)
case connectionFailed(String)
case readOnly(String)
case noColumns(String)
case noInsertableValues(String)
case unknownColumns([String], String)
case emptyPayload
case expectedSingleRow
case malformedPayload(String)
case tooManyRows(Int)

var localizedStringResource: LocalizedStringResource {
switch self {
case .connectionNotFound:
return "That connection no longer exists in TablePro."
case .unsupportedDatabaseType(let type):
return "\(type) connections do not support adding rows from Shortcuts."
case .connectionFailed(let message):
return "Could not connect to the database: \(message)"
case .readOnly(let name):
return "\(name) is read-only, so rows cannot be added."
case .noColumns(let table):
return "Could not read the columns of \(table)."
case .noInsertableValues(let table):
return "The data has no values to insert into \(table)."
case .unknownColumns(let columns, let table):
return "\(table) has no column named \(columns.joined(separator: ", "))."
case .emptyPayload:
return "No data was provided to add."
case .expectedSingleRow:
return "Add Row to Table expects one row. Use Add Rows to Table for multiple rows."
case .malformedPayload(let message):
return "The data could not be read: \(message)"
case .tooManyRows(let limit):
return "Too many rows. Add up to \(limit) rows at a time."
}
}
}
Loading
Loading