Files
InvestmentTrackerApp/PortfolioJournal/Services/ExportService.swift
T
alexandrev-tibco df9309895a Export/import JSON lossless (v3): goals, monthly contributions, journal, ratings
El JSON v2 perdía Goals, source.monthlyContribution, category.allocationTarget y
todo el Journal (nota/mood/rating/completionTime). Nuevo formato v3:
- source: + monthlyContribution, customFrequencyMonths
- category: + allocationTarget
- top-level: + goals[] (name/targetAmount/targetDate/isActive/account)
- top-level: + journalEntries[] (monthKey/note/mood/rating/completionTime/createdAt)

Import parsea y aplica todo, creando Goals y JournalEntry DIRECTAMENTE en el
contexto de background (GoalRepository es @MainActor → no usable desde el import).
Dedup: goals por nombre+cuenta, journal por monthKey → reimport idempotente.
Retrocompatible con JSON v2. CSV sin cambios.

Guard: si el import trae journal, se salta el marcado de completions derivado de
snapshots (evita JournalEntry duplicados por el desfase de merge viewContext).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
2026-07-23 20:13:21 +02:00

317 lines
12 KiB
Swift

import Foundation
import UIKit
import CoreData
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"] = 3
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"
]
if let target = category?.allocationTarget?.doubleValue, target > 0 {
categoryDict["allocationTarget"] = target
}
var sourcesArray: [[String: Any]] = []
for source in categorySources {
var sourceDict: [String: Any] = [
"name": source.name,
"isActive": source.isActive,
"notificationFrequency": source.notificationFrequency,
"customFrequencyMonths": Int(source.customFrequencyMonths)
]
if let contribution = source.monthlyContribution {
sourceDict["monthlyContribution"] = contribution.doubleValue
}
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
// Goals (all accounts)
let isoFormatter = ISO8601DateFormatter()
let goalContext = CoreDataStack.shared.viewContext
let goalRequest: NSFetchRequest<Goal> = Goal.fetchRequest()
let allGoals = (try? goalContext.fetch(goalRequest)) ?? []
var goalsArray: [[String: Any]] = []
for goal in allGoals {
var goalDict: [String: Any] = [
"name": goal.name,
"isActive": goal.isActive,
"account": goal.account?.name as Any
]
if let amount = goal.targetAmount {
goalDict["targetAmount"] = amount.doubleValue
}
if let date = goal.targetDate {
goalDict["targetDate"] = isoFormatter.string(from: date)
}
goalsArray.append(goalDict)
}
exportData["goals"] = goalsArray
// Journal entries (all)
let journalRequest: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
let allJournalEntries = (try? goalContext.fetch(journalRequest)) ?? []
var journalArray: [[String: Any]] = []
for entry in allJournalEntries {
guard let monthKey = entry.monthKey, !monthKey.isEmpty else { continue }
var entryDict: [String: Any] = ["monthKey": monthKey]
if let note = entry.note, !note.isEmpty {
entryDict["note"] = note
}
if let mood = entry.moodRaw, !mood.isEmpty {
entryDict["mood"] = mood
}
if entry.rating > 0 {
entryDict["rating"] = Int(entry.rating)
}
if let completionTime = entry.completionTime {
entryDict["completionTime"] = isoFormatter.string(from: completionTime)
}
if let createdAt = entry.createdAt {
entryDict["createdAt"] = isoFormatter.string(from: createdAt)
}
journalArray.append(entryDict)
}
exportData["journalEntries"] = journalArray
// 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?)]
}
}