initial version
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
|
||||
class ExportService {
|
||||
static let shared = ExportService()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Export Formats
|
||||
|
||||
enum ExportFormat: String, CaseIterable, Identifiable {
|
||||
case csv = "CSV"
|
||||
case json = "JSON"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var fileExtension: String {
|
||||
switch self {
|
||||
case .csv: return "csv"
|
||||
case .json: return "json"
|
||||
}
|
||||
}
|
||||
|
||||
var mimeType: String {
|
||||
switch self {
|
||||
case .csv: return "text/csv"
|
||||
case .json: return "application/json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Data
|
||||
|
||||
func exportToCSV(
|
||||
sources: [InvestmentSource],
|
||||
categories: [Category]
|
||||
) -> String {
|
||||
let currencyCode = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
var csv = "Account,Category,Source,Date,Value (\(currencyCode)),Contribution (\(currencyCode)),Notes\n"
|
||||
|
||||
for source in sources.sorted(by: { $0.name < $1.name }) {
|
||||
let accountName = source.account?.name ?? "Default"
|
||||
let categoryName = source.category?.name ?? "Uncategorized"
|
||||
|
||||
for snapshot in source.snapshotsArray {
|
||||
let date = formatDate(snapshot.date)
|
||||
let value = formatDecimal(snapshot.decimalValue)
|
||||
let contribution = snapshot.contribution != nil
|
||||
? formatDecimal(snapshot.decimalContribution)
|
||||
: ""
|
||||
let notes = escapeCSV(snapshot.notes ?? "")
|
||||
|
||||
csv += "\(escapeCSV(accountName)),\(escapeCSV(categoryName)),\(escapeCSV(source.name)),\(date),\(value),\(contribution),\(notes)\n"
|
||||
}
|
||||
}
|
||||
|
||||
return csv
|
||||
}
|
||||
|
||||
func exportToJSON(
|
||||
sources: [InvestmentSource],
|
||||
categories: [Category]
|
||||
) -> String {
|
||||
var exportData: [String: Any] = [:]
|
||||
exportData["exportDate"] = ISO8601DateFormatter().string(from: Date())
|
||||
exportData["version"] = 2
|
||||
exportData["currency"] = AppSettings.getOrCreate(in: CoreDataStack.shared.viewContext).currency
|
||||
|
||||
let accounts = Dictionary(grouping: sources) { $0.account?.id.uuidString ?? "default" }
|
||||
var accountsArray: [[String: Any]] = []
|
||||
|
||||
for (_, accountSources) in accounts {
|
||||
let account = accountSources.first?.account
|
||||
var accountDict: [String: Any] = [
|
||||
"name": account?.name ?? "Default",
|
||||
"currency": account?.currency ?? exportData["currency"] as? String ?? "EUR",
|
||||
"inputMode": account?.inputMode ?? InputMode.simple.rawValue,
|
||||
"notificationFrequency": account?.notificationFrequency ?? NotificationFrequency.monthly.rawValue,
|
||||
"customFrequencyMonths": account?.customFrequencyMonths ?? 1
|
||||
]
|
||||
|
||||
// Export categories for this account
|
||||
let categoriesById = Dictionary(uniqueKeysWithValues: categories.map { ($0.id, $0) })
|
||||
let sourcesByCategory = Dictionary(grouping: accountSources) { $0.category?.id ?? UUID() }
|
||||
var categoriesArray: [[String: Any]] = []
|
||||
|
||||
for (categoryId, categorySources) in sourcesByCategory {
|
||||
let category = categoriesById[categoryId]
|
||||
var categoryDict: [String: Any] = [
|
||||
"name": category?.name ?? "Uncategorized",
|
||||
"color": category?.colorHex ?? "#3B82F6",
|
||||
"icon": category?.icon ?? "chart.pie.fill"
|
||||
]
|
||||
|
||||
var sourcesArray: [[String: Any]] = []
|
||||
for source in categorySources {
|
||||
var sourceDict: [String: Any] = [
|
||||
"name": source.name,
|
||||
"isActive": source.isActive,
|
||||
"notificationFrequency": source.notificationFrequency
|
||||
]
|
||||
|
||||
var snapshotsArray: [[String: Any]] = []
|
||||
for snapshot in source.snapshotsArray {
|
||||
var snapshotDict: [String: Any] = [
|
||||
"date": ISO8601DateFormatter().string(from: snapshot.date),
|
||||
"value": NSDecimalNumber(decimal: snapshot.decimalValue).doubleValue
|
||||
]
|
||||
|
||||
if snapshot.contribution != nil {
|
||||
snapshotDict["contribution"] = NSDecimalNumber(
|
||||
decimal: snapshot.decimalContribution
|
||||
).doubleValue
|
||||
}
|
||||
|
||||
if let notes = snapshot.notes, !notes.isEmpty {
|
||||
snapshotDict["notes"] = notes
|
||||
}
|
||||
|
||||
snapshotsArray.append(snapshotDict)
|
||||
}
|
||||
|
||||
sourceDict["snapshots"] = snapshotsArray
|
||||
sourcesArray.append(sourceDict)
|
||||
}
|
||||
|
||||
categoryDict["sources"] = sourcesArray
|
||||
categoriesArray.append(categoryDict)
|
||||
}
|
||||
|
||||
accountDict["categories"] = categoriesArray
|
||||
accountsArray.append(accountDict)
|
||||
}
|
||||
|
||||
exportData["accounts"] = accountsArray
|
||||
|
||||
// Add summary
|
||||
let totalValue = sources.reduce(Decimal.zero) { $0 + $1.latestValue }
|
||||
exportData["summary"] = [
|
||||
"totalSources": sources.count,
|
||||
"totalCategories": categories.count,
|
||||
"totalValue": NSDecimalNumber(decimal: totalValue).doubleValue,
|
||||
"totalSnapshots": sources.reduce(0) { $0 + $1.snapshotCount }
|
||||
]
|
||||
|
||||
// Convert to JSON
|
||||
do {
|
||||
let jsonData = try JSONSerialization.data(
|
||||
withJSONObject: exportData,
|
||||
options: [.prettyPrinted, .sortedKeys]
|
||||
)
|
||||
return String(data: jsonData, encoding: .utf8) ?? "{}"
|
||||
} catch {
|
||||
print("JSON export error: \(error)")
|
||||
return "{}"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Share
|
||||
|
||||
func share(
|
||||
format: ExportFormat,
|
||||
sources: [InvestmentSource],
|
||||
categories: [Category],
|
||||
from viewController: UIViewController
|
||||
) {
|
||||
let content: String
|
||||
let fileName: String
|
||||
|
||||
switch format {
|
||||
case .csv:
|
||||
content = exportToCSV(sources: sources, categories: categories)
|
||||
fileName = "investment_tracker_export.csv"
|
||||
case .json:
|
||||
content = exportToJSON(sources: sources, categories: categories)
|
||||
fileName = "investment_tracker_export.json"
|
||||
}
|
||||
|
||||
// Create temporary file
|
||||
let tempURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(fileName)
|
||||
|
||||
do {
|
||||
try content.write(to: tempURL, atomically: true, encoding: .utf8)
|
||||
|
||||
let activityVC = UIActivityViewController(
|
||||
activityItems: [tempURL],
|
||||
applicationActivities: nil
|
||||
)
|
||||
|
||||
// iPad support
|
||||
if let popover = activityVC.popoverPresentationController {
|
||||
popover.sourceView = viewController.view
|
||||
popover.sourceRect = CGRect(
|
||||
x: viewController.view.bounds.midX,
|
||||
y: viewController.view.bounds.midY,
|
||||
width: 0,
|
||||
height: 0
|
||||
)
|
||||
}
|
||||
|
||||
viewController.present(activityVC, animated: true) {
|
||||
FirebaseService.shared.logExportAttempt(
|
||||
format: format.rawValue,
|
||||
success: true
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
print("Export error: \(error)")
|
||||
FirebaseService.shared.logExportAttempt(
|
||||
format: format.rawValue,
|
||||
success: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func formatDecimal(_ decimal: Decimal) -> String {
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .decimal
|
||||
formatter.minimumFractionDigits = 2
|
||||
formatter.maximumFractionDigits = 2
|
||||
formatter.decimalSeparator = "."
|
||||
formatter.groupingSeparator = ""
|
||||
return formatter.string(from: decimal as NSDecimalNumber) ?? "0.00"
|
||||
}
|
||||
|
||||
private func escapeCSV(_ value: String) -> String {
|
||||
var escaped = value
|
||||
if escaped.contains("\"") || escaped.contains(",") || escaped.contains("\n") {
|
||||
escaped = escaped.replacingOccurrences(of: "\"", with: "\"\"")
|
||||
escaped = "\"\(escaped)\""
|
||||
}
|
||||
return escaped
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import Service (Future)
|
||||
|
||||
extension ExportService {
|
||||
func importFromCSV(_ content: String) -> (sources: [ImportedSource], errors: [String]) {
|
||||
// Future implementation for importing data
|
||||
return ([], ["Import not yet implemented"])
|
||||
}
|
||||
|
||||
struct ImportedSource {
|
||||
let name: String
|
||||
let categoryName: String
|
||||
let snapshots: [(date: Date, value: Decimal, contribution: Decimal?, notes: String?)]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user