
Data Export
- 2 installs
- 591 repo stars
- Updated July 24, 2026
- rshankras/claude-code-apple-skills
Generates data export/import infrastructure for JSON, CSV, and PDF with GDPR data portability, file import, and share sheet integration, no third-party dependencies.
About
Generates export and import for user data across JSON, CSV, and PDF with proper escaping, GDPR full-export, and share sheet support. A developer uses it to add data portability, backups, or competitor-import to an iOS app.
- JSON/CSV/PDF export plus UTType-based file import
- GDPR-compliant full data export via share sheet, no dependencies
Data Export by the numbers
- 2 all-time installs (skills.sh)
- Ranked #888 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/rshankras/claude-code-apple-skills --skill data-exportAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 591 |
| Last updated | July 24, 2026 |
| Repository | rshankras/claude-code-apple-skills ↗ |
What it does
Generates data export/import infrastructure for JSON, CSV, and PDF with GDPR data portability, file import, and share sheet integration, no third-party dependencies.
Files
Data Export Generator
Generate production data export and import infrastructure -- JSON export via Codable, CSV generation with proper escaping, PDF report rendering with UIGraphicsPDFRenderer, GDPR-compliant full data export, file import with UTType-based picker, and share sheet integration. No third-party dependencies.
When This Skill Activates
Use this skill when the user:
- Asks to "add data export" or "export user data"
- Wants "CSV export" or "JSON export" or "PDF export"
- Mentions "GDPR data portability" or "right to data portability"
- Asks about "exporting all user data" for compliance
- Wants to "import data" from files or competitor apps
- Mentions "share sheet" for exporting data
- Asks about "data backup" or "data download"
Pre-Generation Checks
1. Project Context Detection
- [ ] Check deployment target (iOS 16+ / macOS 13+)
- [ ] Check Swift version (requires Swift 5.9+)
- [ ] Identify data model layer (SwiftData, Core Data, custom structs)
- [ ] Identify source file locations
2. Existing Export Detection
Search for existing export code:
Glob: **/*Export*.swift, **/*Import*.swift, **/*CSV*.swift, **/*PDF*.swift
Grep: "UIGraphicsPDFRenderer" or "CSVExport" or "UIActivityViewController" or "ShareLink" or "fileExporter"If existing export code found:
- Ask if user wants to replace or add additional formats
- Identify which formats are already supported
3. Data Model Detection
Search for data models that need exporting:
Grep: "@Model" or "NSManagedObject" or "struct.*Codable" or "class.*Codable"Identify the models to build export conformances for.
Configuration Questions
Ask user via AskUserQuestion:
1. Export formats needed?
- JSON (structured, machine-readable, best for GDPR)
- CSV (tabular data, spreadsheet-compatible)
- PDF (formatted reports with headers, tables, branding)
- Multiple (select which combination)
2. What data needs exporting?
- All user data -- GDPR compliance (every piece of stored user data)
- Specific data types (user selects which models to export)
- Reports/summaries (aggregated data, not raw records)
3. Do you need import capability?
- No -- export only
- Yes -- from files (JSON, CSV via file picker)
- Yes -- from competitor apps (custom format parsing)
4. How should users trigger export?
- Share sheet (system share UI with multiple destinations)
- Settings screen (dedicated export section)
- Export button (inline in content views)
- Automatic backup (periodic export to iCloud/local)
Generation Process
Step 1: Read Templates
Read templates.md for production Swift code.
Step 2: Create Core Files
Generate these files: 1. DataExportManager.swift -- Central export coordinator with format routing 2. DataExportable.swift -- Protocol for models that support export
Step 3: Create Format-Specific Files
Based on configuration: 3. CSVExporter.swift -- If CSV format selected 4. PDFExporter.swift -- If PDF format selected
Step 4: Create Import Files
If import capability selected: 5. DataImporter.swift -- File picker and format parser
Step 5: Determine File Location
Check project structure:
- If
Sources/exists ->Sources/DataExport/ - If
App/exists ->App/DataExport/ - Otherwise ->
DataExport/
Output Format
After generation, provide:
Files Created
DataExport/
├── DataExportable.swift # Protocol for exportable models
├── DataExportManager.swift # Central export coordinator
├── CSVExporter.swift # CSV generation (optional)
├── PDFExporter.swift # PDF rendering (optional)
└── DataImporter.swift # File import (optional)Integration with Data Models
Make a model exportable:
struct Expense: Codable, DataExportable {
let id: UUID
let title: String
let amount: Double
let date: Date
let category: String
// DataExportable conformance
static var csvHeaders: [String] {
["ID", "Title", "Amount", "Date", "Category"]
}
var csvRow: [String] {
[id.uuidString, title, String(format: "%.2f", amount),
ISO8601DateFormatter().string(from: date), category]
}
var pdfDescription: String {
"\(title) - $\(String(format: "%.2f", amount)) (\(category))"
}
}Export from a view:
struct ExpenseListView: View {
let expenses: [Expense]
@State private var exportURL: URL?
@State private var showShareSheet = false
var body: some View {
List(expenses) { expense in
ExpenseRow(expense: expense)
}
.toolbar {
Menu {
Button("Export as JSON") {
Task { await exportAs(.json) }
}
Button("Export as CSV") {
Task { await exportAs(.csv) }
}
Button("Export as PDF") {
Task { await exportAs(.pdf) }
}
} label: {
Label("Export", systemImage: "square.and.arrow.up")
}
}
.sheet(isPresented: $showShareSheet) {
if let exportURL {
ShareSheet(activityItems: [exportURL])
}
}
}
private func exportAs(_ format: DataExportManager.ExportFormat) async {
do {
exportURL = try await DataExportManager.shared.export(
expenses, format: format, filename: "expenses"
)
showShareSheet = true
} catch {
// Handle error
}
}
}SwiftUI ShareLink (iOS 16+):
if let url = exportURL {
ShareLink(item: url) {
Label("Share Export", systemImage: "square.and.arrow.up")
}
}Import from file picker:
struct ImportView: View {
@State private var showFilePicker = false
@State private var importedItems: [Expense] = []
var body: some View {
Button("Import Data") { showFilePicker = true }
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: DataImporter.supportedTypes
) { result in
Task {
let url = try result.get()
importedItems = try await DataImporter.importFile(
from: url, as: Expense.self
)
}
}
}
}GDPR Data Portability
For full GDPR compliance, export ALL user data:
func exportAllUserData() async throws -> URL {
let allData = GDPRExportData(
profile: try await fetchUserProfile(),
expenses: try await fetchAllExpenses(),
settings: try await fetchUserSettings(),
exportDate: Date(),
appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(allData)
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("user-data-export.json")
try data.write(to: url)
return url
}Testing
@Test
func jsonExportRoundTrip() async throws {
let expenses = [
Expense(id: UUID(), title: "Coffee", amount: 4.50,
date: Date(), category: "Food")
]
let url = try await DataExportManager.shared.export(
expenses, format: .json, filename: "test"
)
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode([Expense].self, from: data)
#expect(decoded.count == 1)
#expect(decoded.first?.title == "Coffee")
}
@Test
func csvExportFormatsCorrectly() {
let expenses = [
Expense(id: UUID(), title: "Coffee, Large", amount: 4.50,
date: Date(), category: "Food")
]
let csv = CSVExporter.generate(
from: expenses,
headers: Expense.csvHeaders,
rowMapper: { $0.csvRow }
)
#expect(csv.contains("\"Coffee, Large\"")) // Commas escaped with quotes
#expect(csv.hasPrefix("ID,Title,Amount,Date,Category"))
}
@Test
func importParsesCSV() async throws {
let csvContent = "ID,Title,Amount\n1,Coffee,4.50\n2,Lunch,12.00"
let url = FileManager.default.temporaryDirectory.appendingPathComponent("test.csv")
try csvContent.write(to: url, atomically: true, encoding: .utf8)
let rows = try CSVExporter.parse(from: url)
#expect(rows.count == 2)
#expect(rows.first?["Title"] == "Coffee")
}Common Patterns
Export to Temporary File for Sharing
Always export to a temporary directory, then present via share sheet:
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("export.\(format.fileExtension)")
try data.write(to: url)
// Present via UIActivityViewController or ShareLinkGDPR Export Should Include Everything
For GDPR Article 20 compliance, export must include:
- All personal data the user provided
- All data generated about the user
- In a structured, machine-readable format (JSON recommended)
- With clear field descriptions
CSV Must Handle Special Characters
Commas, quotes, and newlines in field values must be properly escaped:
- Fields containing commas: wrap in double quotes
- Fields containing quotes: escape with double-double quotes
- Fields containing newlines: wrap in double quotes
Gotchas
Temporary Files Cleanup
Files in FileManager.default.temporaryDirectory are cleaned up by the system periodically, but not immediately. For large exports, delete the file after sharing completes to free disk space.
PDF Rendering on Background Thread
UIGraphicsPDFRenderer must be used on the main thread if it references UIKit views. For data-only PDF generation (text, lines, rectangles), it is safe to render on a background thread.
Large Dataset Memory
For exporting thousands of records, stream the output instead of building the entire string/data in memory. Write CSV line-by-line to a file handle. For JSON, use JSONSerialization with streams.
ShareLink vs UIActivityViewController
SwiftUI ShareLink (iOS 16+) is simpler but less configurable. UIActivityViewController gives full control over excluded activities, completion handlers, and custom activities.
File Import Security Scoped URLs
When using .fileImporter, the returned URL is security-scoped. You must call url.startAccessingSecurityScopedResource() before reading and url.stopAccessingSecurityScopedResource() after.
References
- templates.md -- All production Swift templates for data export/import
- UIGraphicsPDFRenderer
- UIActivityViewController
- UniformTypeIdentifiers
- GDPR Article 20 - Right to Data Portability
- Related:
generators/settings-screen-- Settings screen with export button - Related:
generators/account-deletion-- Account deletion includes data export option
Data Export Code Templates
Production-ready Swift templates for data export and import infrastructure. All code targets iOS 16+ / macOS 13+ and uses modern Swift concurrency. No third-party dependencies.
DataExportable.swift
import Foundation
/// Protocol for models that support data export in multiple formats.
///
/// Conform your data models to this protocol to enable JSON, CSV,
/// and PDF export through `DataExportManager`.
///
/// Usage:
/// ```swift
/// struct Expense: Codable, DataExportable {
/// let id: UUID
/// let title: String
/// let amount: Double
/// let date: Date
/// let category: String
///
/// static var csvHeaders: [String] {
/// ["ID", "Title", "Amount", "Date", "Category"]
/// }
///
/// var csvRow: [String] {
/// [id.uuidString, title, String(format: "%.2f", amount),
/// ISO8601DateFormatter().string(from: date), category]
/// }
///
/// var pdfDescription: String {
/// "\(title) - $\(String(format: "%.2f", amount)) (\(category))"
/// }
/// }
/// ```
protocol DataExportable: Codable {
/// Column headers for CSV export.
static var csvHeaders: [String] { get }
/// Values for a single CSV row, matching the order of `csvHeaders`.
var csvRow: [String] { get }
/// A human-readable description for PDF report line items.
var pdfDescription: String { get }
}DataExportManager.swift
import Foundation
import os
/// Central coordinator for exporting data in multiple formats.
///
/// Routes export requests to the appropriate format-specific exporter
/// and returns a file URL suitable for sharing via UIActivityViewController
/// or SwiftUI ShareLink.
///
/// Usage:
/// ```swift
/// let url = try await DataExportManager.shared.export(
/// expenses, format: .csv, filename: "expenses"
/// )
/// // Present url via share sheet
/// ```
final class DataExportManager: Sendable {
static let shared = DataExportManager()
private let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.app",
category: "DataExport"
)
/// Supported export formats.
enum ExportFormat: String, CaseIterable, Identifiable {
case json
case csv
case pdf
var id: String { rawValue }
/// File extension for this format.
var fileExtension: String {
switch self {
case .json: return "json"
case .csv: return "csv"
case .pdf: return "pdf"
}
}
/// MIME type for this format.
var mimeType: String {
switch self {
case .json: return "application/json"
case .csv: return "text/csv"
case .pdf: return "application/pdf"
}
}
/// Display name for UI.
var displayName: String {
switch self {
case .json: return "JSON"
case .csv: return "CSV"
case .pdf: return "PDF"
}
}
}
/// Export an array of items in the specified format.
///
/// - Parameters:
/// - items: The items to export.
/// - format: The output format (JSON, CSV, or PDF).
/// - filename: The base filename (without extension).
/// - Returns: A URL to the exported file in the temporary directory.
func export<T: DataExportable>(
_ items: [T],
format: ExportFormat,
filename: String
) async throws -> URL {
let data: Data
switch format {
case .json:
data = try exportAsJSON(items)
case .csv:
let csvString = CSVExporter.generate(
from: items,
headers: T.csvHeaders,
rowMapper: { $0.csvRow }
)
guard let csvData = csvString.data(using: .utf8) else {
throw ExportError.encodingFailed
}
data = csvData
case .pdf:
data = PDFExporter.generateReport(
title: filename.capitalized,
items: items.map { $0.pdfDescription }
)
}
let url = temporaryFileURL(filename: filename, format: format)
try data.write(to: url, options: .atomic)
logger.info("Exported \(items.count) items as \(format.rawValue) to \(url.lastPathComponent)")
return url
}
/// Export raw Codable data as JSON (for GDPR full export).
///
/// - Parameters:
/// - value: Any Encodable value to export.
/// - filename: The base filename.
/// - Returns: A URL to the exported JSON file.
func exportJSON<T: Encodable>(_ value: T, filename: String) throws -> URL {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(value)
let url = temporaryFileURL(filename: filename, format: .json)
try data.write(to: url, options: .atomic)
return url
}
// MARK: - Private
private func exportAsJSON<T: Encodable>(_ items: [T]) throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
return try encoder.encode(items)
}
private func temporaryFileURL(filename: String, format: ExportFormat) -> URL {
let sanitizedName = filename.replacingOccurrences(of: " ", with: "-")
let timestamp = ISO8601DateFormatter().string(from: Date())
.replacingOccurrences(of: ":", with: "-")
return FileManager.default.temporaryDirectory
.appendingPathComponent("\(sanitizedName)-\(timestamp).\(format.fileExtension)")
}
/// Export errors.
enum ExportError: LocalizedError {
case encodingFailed
case renderingFailed
case noData
var errorDescription: String? {
switch self {
case .encodingFailed: return "Failed to encode data for export"
case .renderingFailed: return "Failed to render PDF"
case .noData: return "No data to export"
}
}
}
}CSVExporter.swift
import Foundation
/// CSV generation and parsing with proper field escaping.
///
/// Handles special characters in field values:
/// - Fields containing commas are wrapped in double quotes
/// - Fields containing double quotes have quotes doubled (RFC 4180)
/// - Fields containing newlines are wrapped in double quotes
///
/// Usage:
/// ```swift
/// let csv = CSVExporter.generate(
/// from: expenses,
/// headers: ["Title", "Amount", "Category"],
/// rowMapper: { [$0.title, String($0.amount), $0.category] }
/// )
/// ```
enum CSVExporter {
/// Generate a CSV string from an array of items.
///
/// - Parameters:
/// - items: The source items to export.
/// - headers: Column header names.
/// - rowMapper: Closure that converts each item to an array of string values.
/// - Returns: A complete CSV string with headers and data rows.
static func generate<T>(
from items: [T],
headers: [String],
rowMapper: (T) -> [String]
) -> String {
var lines: [String] = []
// Header row
lines.append(headers.map { escapeField($0) }.joined(separator: ","))
// Data rows
for item in items {
let values = rowMapper(item)
let escapedValues = values.map { escapeField($0) }
lines.append(escapedValues.joined(separator: ","))
}
return lines.joined(separator: "\n")
}
/// Generate a CSV string using column definitions.
///
/// A more declarative API where columns are defined with name and value extractor.
///
/// Usage:
/// ```swift
/// let csv = CSVExporter.generate(from: expenses, columns: [
/// ("Title", { $0.title }),
/// ("Amount", { String(format: "%.2f", $0.amount) }),
/// ("Category", { $0.category }),
/// ])
/// ```
static func generate<T>(
from items: [T],
columns: [(String, (T) -> String)]
) -> String {
let headers = columns.map(\.0)
return generate(from: items, headers: headers) { item in
columns.map { $0.1(item) }
}
}
// MARK: - Parsing
/// Parse a CSV file into an array of dictionaries.
///
/// Each dictionary maps column header names to row values.
/// Handles quoted fields and escaped quotes.
///
/// - Parameter url: The CSV file URL.
/// - Returns: An array of dictionaries, one per data row.
static func parse(from url: URL) throws -> [[String: String]] {
let content = try String(contentsOf: url, encoding: .utf8)
return parse(content: content)
}
/// Parse a CSV string into an array of dictionaries.
static func parse(content: String) -> [[String: String]] {
let lines = content.components(separatedBy: .newlines)
.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }
guard let headerLine = lines.first else { return [] }
let headers = parseRow(headerLine)
return lines.dropFirst().map { line in
let values = parseRow(line)
var row: [String: String] = [:]
for (index, header) in headers.enumerated() {
if index < values.count {
row[header] = values[index]
}
}
return row
}
}
// MARK: - Field Escaping (RFC 4180)
/// Escape a CSV field value according to RFC 4180.
///
/// - If the field contains a comma, newline, or double quote, wrap in quotes.
/// - Double quotes within the field are escaped as two double quotes.
private static func escapeField(_ field: String) -> String {
let needsQuoting = field.contains(",") ||
field.contains("\"") ||
field.contains("\n") ||
field.contains("\r")
if needsQuoting {
let escaped = field.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\""
}
return field
}
/// Parse a single CSV row into an array of field values.
///
/// Handles quoted fields with embedded commas and escaped quotes.
private static func parseRow(_ line: String) -> [String] {
var fields: [String] = []
var current = ""
var inQuotes = false
var iterator = line.makeIterator()
while let char = iterator.next() {
if inQuotes {
if char == "\"" {
// Check for escaped quote (double-double quote)
// Peek at next character
if let next = iterator.next() {
if next == "\"" {
current.append("\"")
} else {
inQuotes = false
if next == "," {
fields.append(current)
current = ""
} else {
current.append(next)
}
}
} else {
inQuotes = false
}
} else {
current.append(char)
}
} else {
if char == "\"" {
inQuotes = true
} else if char == "," {
fields.append(current)
current = ""
} else {
current.append(char)
}
}
}
fields.append(current)
return fields
}
}PDFExporter.swift
import UIKit
import os
/// PDF generation using UIGraphicsPDFRenderer.
///
/// Generates formatted PDF reports with title, metadata, and
/// line items. Uses standard US Letter page size (8.5" x 11").
///
/// Usage:
/// ```swift
/// let data = PDFExporter.generateReport(
/// title: "Expense Report",
/// items: expenses.map { "\($0.title) - $\($0.amount)" }
/// )
/// try data.write(to: url)
/// ```
enum PDFExporter {
/// Standard US Letter page size in points (72 points per inch).
private static let pageWidth: CGFloat = 612 // 8.5"
private static let pageHeight: CGFloat = 792 // 11"
private static let margin: CGFloat = 50
private static let lineSpacing: CGFloat = 20
/// Generate a PDF report with a title and line items.
///
/// - Parameters:
/// - title: The report title displayed at the top.
/// - subtitle: Optional subtitle (e.g., date range).
/// - items: Array of string descriptions, one per line.
/// - Returns: The rendered PDF as Data.
static func generateReport(
title: String,
subtitle: String? = nil,
items: [String]
) -> Data {
let pageRect = CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)
let renderer = UIGraphicsPDFRenderer(bounds: pageRect)
return renderer.pdfData { context in
var currentY: CGFloat = 0
var isFirstPage = true
func beginNewPage() {
context.beginPage()
currentY = margin
if isFirstPage {
currentY = drawHeader(title: title, subtitle: subtitle, at: currentY)
isFirstPage = false
} else {
// Continuation header
currentY = drawContinuationHeader(title: title, at: currentY)
}
}
beginNewPage()
// Draw each line item
for (index, item) in items.enumerated() {
let itemHeight = heightForText(item, maxWidth: pageWidth - (margin * 2))
// Check if we need a new page
if currentY + itemHeight + lineSpacing > pageHeight - margin {
// Draw page number on current page
drawPageNumber(context: context, pageRect: pageRect)
beginNewPage()
}
// Alternate row background
if index % 2 == 0 {
let rowRect = CGRect(
x: margin - 4,
y: currentY - 2,
width: pageWidth - (margin * 2) + 8,
height: itemHeight + 4
)
UIColor.systemGray6.setFill()
UIBezierPath(roundedRect: rowRect, cornerRadius: 4).fill()
}
// Draw item text
let textRect = CGRect(
x: margin,
y: currentY,
width: pageWidth - (margin * 2),
height: itemHeight
)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byWordWrapping
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 11),
.foregroundColor: UIColor.label,
.paragraphStyle: paragraphStyle,
]
item.draw(in: textRect, withAttributes: attributes)
currentY += itemHeight + lineSpacing
}
// Draw footer on last page
drawPageNumber(context: context, pageRect: pageRect)
drawFooter(pageRect: pageRect)
}
}
/// Generate a PDF with tabular data (rows and columns).
///
/// - Parameters:
/// - title: The report title.
/// - headers: Column header names.
/// - rows: Array of row arrays matching header count.
/// - Returns: The rendered PDF as Data.
static func generateTable(
title: String,
headers: [String],
rows: [[String]]
) -> Data {
let pageRect = CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)
let renderer = UIGraphicsPDFRenderer(bounds: pageRect)
let contentWidth = pageWidth - (margin * 2)
let columnWidth = contentWidth / CGFloat(headers.count)
return renderer.pdfData { context in
context.beginPage()
var currentY = margin
// Title
currentY = drawHeader(title: title, subtitle: nil, at: currentY)
// Table headers
let headerFont = UIFont.boldSystemFont(ofSize: 10)
let headerAttributes: [NSAttributedString.Key: Any] = [
.font: headerFont,
.foregroundColor: UIColor.white,
]
// Header background
let headerRect = CGRect(
x: margin, y: currentY,
width: contentWidth, height: 24
)
UIColor.systemBlue.setFill()
UIBezierPath(roundedRect: headerRect, cornerRadius: 4).fill()
for (colIndex, header) in headers.enumerated() {
let cellRect = CGRect(
x: margin + CGFloat(colIndex) * columnWidth + 4,
y: currentY + 4,
width: columnWidth - 8,
height: 16
)
header.draw(in: cellRect, withAttributes: headerAttributes)
}
currentY += 28
// Table rows
let cellFont = UIFont.systemFont(ofSize: 10)
let cellAttributes: [NSAttributedString.Key: Any] = [
.font: cellFont,
.foregroundColor: UIColor.label,
]
for (rowIndex, row) in rows.enumerated() {
if currentY + 24 > pageHeight - margin {
drawPageNumber(context: context, pageRect: pageRect)
context.beginPage()
currentY = margin
}
// Alternating row color
if rowIndex % 2 == 0 {
let rowBgRect = CGRect(
x: margin, y: currentY,
width: contentWidth, height: 20
)
UIColor.systemGray6.setFill()
UIBezierPath(rect: rowBgRect).fill()
}
for (colIndex, value) in row.enumerated() {
guard colIndex < headers.count else { break }
let cellRect = CGRect(
x: margin + CGFloat(colIndex) * columnWidth + 4,
y: currentY + 3,
width: columnWidth - 8,
height: 14
)
value.draw(in: cellRect, withAttributes: cellAttributes)
}
currentY += 20
}
drawPageNumber(context: context, pageRect: pageRect)
drawFooter(pageRect: pageRect)
}
}
// MARK: - Drawing Helpers
private static func drawHeader(
title: String,
subtitle: String?,
at startY: CGFloat
) -> CGFloat {
var y = startY
// Title
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 22),
.foregroundColor: UIColor.label,
]
let titleRect = CGRect(
x: margin, y: y,
width: pageWidth - (margin * 2), height: 30
)
title.draw(in: titleRect, withAttributes: titleAttributes)
y += 34
// Subtitle
if let subtitle {
let subtitleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor.secondaryLabel,
]
let subtitleRect = CGRect(
x: margin, y: y,
width: pageWidth - (margin * 2), height: 18
)
subtitle.draw(in: subtitleRect, withAttributes: subtitleAttributes)
y += 22
}
// Date
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .long
let dateString = "Generated: \(dateFormatter.string(from: Date()))"
let dateAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 10),
.foregroundColor: UIColor.tertiaryLabel,
]
let dateRect = CGRect(
x: margin, y: y,
width: pageWidth - (margin * 2), height: 14
)
dateString.draw(in: dateRect, withAttributes: dateAttributes)
y += 20
// Separator line
let separatorPath = UIBezierPath()
separatorPath.move(to: CGPoint(x: margin, y: y))
separatorPath.addLine(to: CGPoint(x: pageWidth - margin, y: y))
UIColor.separator.setStroke()
separatorPath.lineWidth = 0.5
separatorPath.stroke()
y += 16
return y
}
private static func drawContinuationHeader(title: String, at startY: CGFloat) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 10, weight: .medium),
.foregroundColor: UIColor.secondaryLabel,
]
let rect = CGRect(x: margin, y: startY, width: pageWidth - (margin * 2), height: 14)
"\(title) (continued)".draw(in: rect, withAttributes: attributes)
return startY + 24
}
private static func drawPageNumber(context: UIGraphicsPDFRendererContext, pageRect: CGRect) {
let pageNumber = context.pdfContextBounds
let text = "Page"
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 9),
.foregroundColor: UIColor.tertiaryLabel,
]
let textSize = text.size(withAttributes: attributes)
let textRect = CGRect(
x: pageRect.width - margin - textSize.width,
y: pageRect.height - margin + 10,
width: textSize.width,
height: textSize.height
)
_ = pageNumber
text.draw(in: textRect, withAttributes: attributes)
}
private static func drawFooter(pageRect: CGRect) {
let appName = Bundle.main.infoDictionary?["CFBundleName"] as? String ?? "App"
let text = "Exported from \(appName)"
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 9),
.foregroundColor: UIColor.tertiaryLabel,
]
let rect = CGRect(
x: margin,
y: pageRect.height - margin + 10,
width: 200,
height: 14
)
text.draw(in: rect, withAttributes: attributes)
}
private static func heightForText(_ text: String, maxWidth: CGFloat) -> CGFloat {
let attributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 11),
]
let boundingRect = (text as NSString).boundingRect(
with: CGSize(width: maxWidth, height: .greatestFiniteMagnitude),
options: [.usesLineFragmentOrigin, .usesFontLeading],
attributes: attributes,
context: nil
)
return ceil(boundingRect.height)
}
}DataImporter.swift
import Foundation
import UniformTypeIdentifiers
import os
/// File import with UTType-based file picker and format-specific parsing.
///
/// Supports importing JSON and CSV files selected via `.fileImporter`
/// or drag-and-drop. Handles security-scoped URL access automatically.
///
/// Usage:
/// ```swift
/// // In a SwiftUI view with .fileImporter:
/// .fileImporter(
/// isPresented: $showPicker,
/// allowedContentTypes: DataImporter.supportedTypes
/// ) { result in
/// Task {
/// let url = try result.get()
/// let items: [Expense] = try await DataImporter.importFile(
/// from: url, as: Expense.self
/// )
/// }
/// }
/// ```
enum DataImporter {
/// Content types supported for import.
static let supportedTypes: [UTType] = [.json, .commaSeparatedText]
private static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier ?? "com.app",
category: "DataImport"
)
/// Import a file and decode its contents to the specified type.
///
/// Automatically detects the format from the file extension
/// and parses accordingly. Handles security-scoped URL access.
///
/// - Parameters:
/// - url: The file URL (may be security-scoped from file picker).
/// - type: The Decodable type to parse into.
/// - Returns: An array of decoded items.
static func importFile<T: Decodable>(
from url: URL,
as type: T.Type
) async throws -> [T] {
// Access security-scoped resource
let didStartAccess = url.startAccessingSecurityScopedResource()
defer {
if didStartAccess {
url.stopAccessingSecurityScopedResource()
}
}
let data = try Data(contentsOf: url)
// Detect format from file extension
switch url.pathExtension.lowercased() {
case "json":
return try importJSON(data: data, as: type)
case "csv":
logger.warning("CSV import requires DataExportable conformance for column mapping")
throw ImportError.unsupportedFormat("CSV import requires custom mapping")
default:
throw ImportError.unsupportedFormat(url.pathExtension)
}
}
/// Import JSON data as an array of items.
///
/// Handles both array `[{...}, {...}]` and single object `{...}` formats.
static func importJSON<T: Decodable>(
data: Data,
as type: T.Type
) throws -> [T] {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
// Try array first
if let items = try? decoder.decode([T].self, from: data) {
logger.info("Imported \(items.count) items from JSON array")
return items
}
// Try single object
if let item = try? decoder.decode(T.self, from: data) {
logger.info("Imported single item from JSON object")
return [item]
}
throw ImportError.decodingFailed("Could not decode JSON as \(T.self) or [\(T.self)]")
}
/// Import CSV data using a custom row mapper.
///
/// Parses the CSV into dictionaries (header -> value) and applies
/// the provided mapper to convert each row into the target type.
///
/// Usage:
/// ```swift
/// let expenses = try DataImporter.importCSV(from: url) { row in
/// Expense(
/// id: UUID(uuidString: row["ID"] ?? "") ?? UUID(),
/// title: row["Title"] ?? "",
/// amount: Double(row["Amount"] ?? "0") ?? 0,
/// date: ISO8601DateFormatter().date(from: row["Date"] ?? "") ?? Date(),
/// category: row["Category"] ?? ""
/// )
/// }
/// ```
static func importCSV<T>(
from url: URL,
rowMapper: ([String: String]) throws -> T
) throws -> [T] {
let didStartAccess = url.startAccessingSecurityScopedResource()
defer {
if didStartAccess {
url.stopAccessingSecurityScopedResource()
}
}
let rows = try CSVExporter.parse(from: url)
logger.info("Parsed \(rows.count) rows from CSV")
return try rows.map { row in
try rowMapper(row)
}
}
/// Validate that a file can be imported before attempting.
///
/// Checks file existence, size, and format support.
static func validate(url: URL) -> ImportValidation {
let didStartAccess = url.startAccessingSecurityScopedResource()
defer {
if didStartAccess {
url.stopAccessingSecurityScopedResource()
}
}
guard FileManager.default.fileExists(atPath: url.path) else {
return .invalid("File not found")
}
guard let attributes = try? FileManager.default.attributesOfItem(atPath: url.path),
let fileSize = attributes[.size] as? Int64 else {
return .invalid("Cannot read file attributes")
}
// Warn for large files (> 50 MB)
let maxSize: Int64 = 50 * 1024 * 1024
if fileSize > maxSize {
return .warning("File is \(fileSize / 1024 / 1024) MB. Import may be slow.")
}
let ext = url.pathExtension.lowercased()
guard ext == "json" || ext == "csv" else {
return .invalid("Unsupported format: .\(ext)")
}
return .valid
}
// MARK: - Types
/// Result of file validation before import.
enum ImportValidation {
case valid
case warning(String)
case invalid(String)
var isUsable: Bool {
switch self {
case .valid, .warning: return true
case .invalid: return false
}
}
}
/// Import errors.
enum ImportError: LocalizedError {
case unsupportedFormat(String)
case decodingFailed(String)
case fileTooLarge(Int64)
var errorDescription: String? {
switch self {
case .unsupportedFormat(let format):
return "Unsupported import format: \(format)"
case .decodingFailed(let detail):
return "Failed to decode imported data: \(detail)"
case .fileTooLarge(let size):
return "File too large (\(size / 1024 / 1024) MB)"
}
}
}
}