InvestmentTrackerApp/PortfolioJournal/Services/ImportService.swift

693 lines
25 KiB
Swift

import Foundation
import CoreData
import Combine
class ImportService {
static let shared = ImportService()
private init() {}
struct ImportResult {
let accountsCreated: Int
let sourcesCreated: Int
let snapshotsCreated: Int
let errors: [String]
}
struct ImportProgress {
let completed: Int
let total: Int
let message: String
var fraction: Double {
guard total > 0 else { return 0 }
return min(max(Double(completed) / Double(total), 0), 1)
}
}
enum ImportFormat {
case csv
case json
}
struct ImportedAccount {
let name: String
let currency: String?
let inputMode: InputMode
let notificationFrequency: NotificationFrequency
let customFrequencyMonths: Int
let categories: [ImportedCategory]
}
struct ImportedCategory {
let name: String
let colorHex: String?
let icon: String?
let sources: [ImportedSource]
}
struct ImportedSource {
let name: String
let snapshots: [ImportedSnapshot]
}
struct ImportedSnapshot {
let date: Date
let value: Decimal
let contribution: Decimal?
let notes: String?
}
func importData(
content: String,
format: ImportFormat,
allowMultipleAccounts: Bool,
defaultAccountName: String? = nil
) -> ImportResult {
switch format {
case .csv:
let parsed = parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
case .json:
let parsed = parseJSON(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
}
}
func importDataAsync(
content: String,
format: ImportFormat,
allowMultipleAccounts: Bool,
defaultAccountName: String? = nil,
progress: @escaping (ImportProgress) -> Void
) async -> ImportResult {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let parsed: [ImportedAccount]
switch format {
case .csv:
parsed = self.parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
case .json:
parsed = self.parseJSON(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
}
let totalSnapshots = parsed.reduce(0) { total, account in
total + account.categories.reduce(0) { subtotal, category in
subtotal + category.sources.reduce(0) { sourceTotal, source in
sourceTotal + source.snapshots.count
}
}
}
DispatchQueue.main.async {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(parsed, context: context) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
total: totalSnapshots,
message: "Imported \(completed) of \(totalSnapshots) snapshots"
))
}
}
continuation.resume(returning: result)
}
}
}
static func sampleCSV() -> String {
return """
Account,Category,Source,Date,Value,Contribution,Notes
Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
Personal,Crypto,BTC,2024-01-01,3200,,Cold storage
Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
"""
}
static func sampleJSON() -> String {
return """
{
"version": 2,
"currency": "EUR",
"accounts": [{
"name": "Personal",
"inputMode": "simple",
"notificationFrequency": "monthly",
"categories": [{
"name": "Stocks",
"color": "#3B82F6",
"icon": "chart.line.uptrend.xyaxis",
"sources": [{
"name": "Index Fund",
"snapshots": [{
"date": "2024-01-01T00:00:00Z",
"value": 15000,
"contribution": 12000
}]
}]
}]
}]
}
"""
}
// MARK: - Parsing
private func parseCSV(
_ content: String,
allowMultipleAccounts: Bool,
defaultAccountName: String?
) -> [ImportedAccount] {
let rows = parseCSVRows(content)
guard rows.count > 1 else { return [] }
let headers = rows[0].map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
let indexOfAccount = headers.firstIndex(of: "account")
let indexOfCategory = headers.firstIndex(of: "category")
let indexOfSource = headers.firstIndex(of: "source")
let indexOfDate = headers.firstIndex(of: "date")
let indexOfValue = headers.firstIndex(where: { $0.hasPrefix("value") })
let indexOfContribution = headers.firstIndex(where: { $0.hasPrefix("contribution") })
let indexOfNotes = headers.firstIndex(of: "notes")
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
for row in rows.dropFirst() {
let providedAccount = indexOfAccount.flatMap { row.safeValue(at: $0) }
let fallbackAccount = defaultAccountName ?? "Personal"
let normalizedAccount = (providedAccount ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
let rawAccountName = normalizedAccount.isEmpty ? fallbackAccount : normalizedAccount
let accountName = allowMultipleAccounts ? rawAccountName : fallbackAccount
guard let categoryName = indexOfCategory.flatMap({ row.safeValue(at: $0) }), !categoryName.isEmpty,
let sourceName = indexOfSource.flatMap({ row.safeValue(at: $0) }), !sourceName.isEmpty,
let dateString = indexOfDate.flatMap({ row.safeValue(at: $0) }),
let valueString = indexOfValue.flatMap({ row.safeValue(at: $0) }) else {
continue
}
guard let date = parseDate(dateString),
let value = parseDecimal(valueString) else { continue }
let contribution = indexOfContribution
.flatMap { row.safeValue(at: $0) }
.flatMap(parseDecimal)
let notes = indexOfNotes
.flatMap { row.safeValue(at: $0) }
.flatMap { $0.isEmpty ? nil : $0 }
let snapshot = ImportedSnapshot(
date: date,
value: value,
contribution: contribution,
notes: notes
)
grouped[accountName, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
}
return grouped.map { accountName, categories in
let importedCategories = categories.map { categoryName, sources in
let importedSources = sources.map { sourceName, snapshots in
ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
}
return ImportedAccount(
name: accountName,
currency: nil,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: importedCategories
)
}
}
private func parseJSON(
_ content: String,
allowMultipleAccounts: Bool,
defaultAccountName: String?
) -> [ImportedAccount] {
guard let data = content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return []
}
if let accountsArray = json["accounts"] as? [[String: Any]] {
return accountsArray.compactMap { accountDict in
let rawName = accountDict["name"] as? String ?? "Personal"
let fallbackName = defaultAccountName ?? "Personal"
let name = allowMultipleAccounts ? rawName : fallbackName
let currency = accountDict["currency"] as? String
let inputMode = InputMode(rawValue: accountDict["inputMode"] as? String ?? "") ?? .simple
let notificationFrequency = NotificationFrequency(
rawValue: accountDict["notificationFrequency"] as? String ?? ""
) ?? .monthly
let customFrequencyMonths = accountDict["customFrequencyMonths"] as? Int ?? 1
let categoriesArray = accountDict["categories"] as? [[String: Any]] ?? []
let categories = categoriesArray.map { categoryDict in
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
let colorHex = categoryDict["color"] as? String
let icon = categoryDict["icon"] as? String
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
let sources = sourcesArray.map { sourceDict in
let sourceName = sourceDict["name"] as? String ?? "Source"
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
guard let dateString = snapshotDict["date"] as? String,
let date = ISO8601DateFormatter().date(from: dateString),
let value = snapshotDict["value"] as? Double else {
return nil
}
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
let notes = snapshotDict["notes"] as? String
return ImportedSnapshot(
date: date,
value: Decimal(value),
contribution: contribution,
notes: notes
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
sources: sources
)
}
return ImportedAccount(
name: name,
currency: currency,
inputMode: inputMode,
notificationFrequency: notificationFrequency,
customFrequencyMonths: customFrequencyMonths,
categories: categories
)
}
}
// Legacy JSON: categories only
if let categoriesArray = json["categories"] as? [[String: Any]] {
let categories = categoriesArray.map { categoryDict in
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
let colorHex = categoryDict["color"] as? String
let icon = categoryDict["icon"] as? String
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
let sources = sourcesArray.map { sourceDict in
let sourceName = sourceDict["name"] as? String ?? "Source"
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
guard let dateString = snapshotDict["date"] as? String,
let date = ISO8601DateFormatter().date(from: dateString),
let value = snapshotDict["value"] as? Double else {
return nil
}
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
let notes = snapshotDict["notes"] as? String
return ImportedSnapshot(
date: date,
value: Decimal(value),
contribution: contribution,
notes: notes
)
}
return ImportedSource(name: sourceName, snapshots: snapshots)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
sources: sources
)
}
let fallbackName = allowMultipleAccounts ? "Personal" : (defaultAccountName ?? "Personal")
return [
ImportedAccount(
name: fallbackName,
currency: json["currency"] as? String,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: categories
)
]
}
return []
}
// MARK: - Apply Import
private func applyImport(
_ accounts: [ImportedAccount],
context: NSManagedObjectContext,
snapshotProgress: ((Int) -> Void)? = nil
) -> ImportResult {
let accountRepository = AccountRepository(context: context)
let categoryRepository = CategoryRepository(context: context)
let sourceRepository = InvestmentSourceRepository(context: context)
let snapshotRepository = SnapshotRepository(context: context)
var accountsCreated = 0
var sourcesCreated = 0
var snapshotsCreated = 0
var errors: [String] = []
var categoryLookup = buildCategoryLookup(from: categoryRepository.categories)
let otherCategory = resolveExistingCategory(named: "Other", lookup: categoryLookup) ??
categoryRepository.createCategory(
name: "Other",
colorHex: "#64748B",
icon: "ellipsis.circle.fill"
)
categoryLookup[normalizedCategoryName(otherCategory.name)] = otherCategory
var completionDatesByMonth: [String: Date] = [:]
for importedAccount in accounts {
let existingAccount = accountRepository.accounts.first(where: { $0.name == importedAccount.name })
let account = existingAccount ?? accountRepository.createAccount(
name: importedAccount.name,
currency: importedAccount.currency,
inputMode: importedAccount.inputMode,
notificationFrequency: importedAccount.notificationFrequency,
customFrequencyMonths: importedAccount.customFrequencyMonths
)
if existingAccount == nil {
accountsCreated += 1
}
for importedCategory in importedAccount.categories {
let existingCategory = resolveExistingCategory(
named: importedCategory.name,
lookup: categoryLookup
)
let shouldUseOther = existingCategory == nil &&
importedCategory.colorHex == nil &&
importedCategory.icon == nil
let resolvedName = canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
let category = existingCategory ?? (shouldUseOther
? otherCategory
: categoryRepository.createCategory(
name: resolvedName,
colorHex: importedCategory.colorHex ?? "#3B82F6",
icon: importedCategory.icon ?? "chart.pie.fill"
))
categoryLookup[normalizedCategoryName(category.name)] = category
for importedSource in importedCategory.sources {
let existingSource = sourceRepository.sources.first(where: {
$0.name == importedSource.name && $0.account?.id == account.id
})
let source = existingSource ?? sourceRepository.createSource(
name: importedSource.name,
category: category,
notificationFrequency: importedAccount.notificationFrequency,
customFrequencyMonths: importedAccount.customFrequencyMonths
)
source.account = account
if existingSource == nil {
sourcesCreated += 1
}
for snapshot in importedSource.snapshots {
snapshotRepository.createSnapshot(
for: source,
date: snapshot.date,
value: snapshot.value,
contribution: snapshot.contribution,
notes: snapshot.notes
)
snapshotsCreated += 1
snapshotProgress?(snapshotsCreated)
let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date)
if let existingDate = completionDatesByMonth[monthKey] {
if snapshot.date > existingDate {
completionDatesByMonth[monthKey] = snapshot.date
}
} else {
completionDatesByMonth[monthKey] = snapshot.date
}
}
}
}
}
if context.hasChanges {
do {
try context.save()
} catch {
errors.append("Failed to save imported data.")
}
}
if !completionDatesByMonth.isEmpty {
for (monthKey, completionDate) in completionDatesByMonth {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
if let monthDate = formatter.date(from: monthKey) {
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
}
}
}
return ImportResult(
accountsCreated: accountsCreated,
sourcesCreated: sourcesCreated,
snapshotsCreated: snapshotsCreated,
errors: errors
)
}
private func resolveExistingCategory(
named rawName: String,
lookup: [String: Category]
) -> Category? {
if let canonical = canonicalCategoryName(for: rawName) {
let canonicalKey = normalizedCategoryName(canonical)
if let match = lookup[canonicalKey] {
return match
}
}
return lookup[normalizedCategoryName(rawName)]
}
private func buildCategoryLookup(from categories: [Category]) -> [String: Category] {
var lookup: [String: Category] = [:]
for category in categories {
lookup[normalizedCategoryName(category.name)] = category
}
return lookup
}
private func canonicalCategoryName(for rawName: String) -> String? {
let normalized = normalizedCategoryName(rawName)
for mapping in categoryAliasMappings {
if mapping.aliases.contains(where: { normalizedCategoryName($0) == normalized }) {
return mapping.canonical
}
}
return nil
}
private func normalizedCategoryName(_ value: String) -> String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let normalized = trimmed.folding(
options: [.diacriticInsensitive, .caseInsensitive],
locale: .current
)
return normalized.replacingOccurrences(
of: "\\s+",
with: " ",
options: .regularExpression
)
}
private var categoryAliasMappings: [(canonical: String, aliases: [String])] {
[
(
canonical: "Stocks",
aliases: ["Stocks", "category_stocks", String(localized: "category_stocks"), "Acciones"]
),
(
canonical: "Bonds",
aliases: ["Bonds", "category_bonds", String(localized: "category_bonds"), "Bonos"]
),
(
canonical: "Real Estate",
aliases: ["Real Estate", "category_real_estate", String(localized: "category_real_estate"), "Inmobiliario"]
),
(
canonical: "Crypto",
aliases: ["Crypto", "category_crypto", String(localized: "category_crypto"), "Cripto"]
),
(
canonical: "Cash",
aliases: ["Cash", "category_cash", String(localized: "category_cash"), "Efectivo"]
),
(
canonical: "ETFs",
aliases: ["ETFs", "category_etfs", String(localized: "category_etfs"), "ETF"]
),
(
canonical: "Retirement",
aliases: ["Retirement", "category_retirement", String(localized: "category_retirement"), "Jubilación"]
),
(
canonical: "Other",
aliases: [
"Other",
"category_other",
String(localized: "category_other"),
"Uncategorized",
"uncategorized",
String(localized: "uncategorized"),
"Otros",
"Sin categoría"
]
)
]
}
// MARK: - CSV Helpers
private func parseCSVRows(_ content: String) -> [[String]] {
var rows: [[String]] = []
var currentRow: [String] = []
var currentField = ""
var insideQuotes = false
for char in content {
if char == "\"" {
insideQuotes.toggle()
continue
}
if char == "," && !insideQuotes {
currentRow.append(currentField)
currentField = ""
continue
}
if char == "\n" && !insideQuotes {
currentRow.append(currentField)
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
currentRow = []
currentField = ""
continue
}
currentField.append(char)
}
if !currentField.isEmpty || !currentRow.isEmpty {
currentRow.append(currentField)
rows.append(currentRow.map { $0.trimmingCharacters(in: .whitespaces) })
}
return rows
}
private func parseDate(_ value: String) -> Date? {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if let iso = ISO8601DateFormatter().date(from: trimmed) {
return iso
}
let formats = [
"yyyy-MM-dd",
"yyyy/MM/dd",
"dd/MM/yyyy",
"MM/dd/yyyy",
"dd-MM-yyyy",
"MM-dd-yyyy",
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm",
"yyyy/MM/dd HH:mm:ss",
"dd/MM/yyyy HH:mm",
"dd/MM/yyyy HH:mm:ss",
"MM/dd/yyyy HH:mm",
"MM/dd/yyyy HH:mm:ss",
"dd-MM-yyyy HH:mm",
"dd-MM-yyyy HH:mm:ss",
"MM-dd-yyyy HH:mm",
"MM-dd-yyyy HH:mm:ss",
"dd/MM/yyyy h:mm a",
"dd/MM/yyyy h:mm:ss a",
"MM/dd/yyyy h:mm a",
"MM/dd/yyyy h:mm:ss a",
"dd-MM-yyyy h:mm a",
"dd-MM-yyyy h:mm:ss a",
"MM-dd-yyyy h:mm a",
"MM-dd-yyyy h:mm:ss a"
]
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = .current
for format in formats {
formatter.dateFormat = format
if let date = formatter.date(from: trimmed) {
return date
}
}
return nil
}
private func parseDecimal(_ value: String) -> Decimal? {
let cleaned = value
.replacingOccurrences(of: ",", with: ".")
.trimmingCharacters(in: .whitespaces)
guard !cleaned.isEmpty else { return nil }
return Decimal(string: cleaned)
}
}
private extension Array where Element == String {
func safeValue(at index: Int) -> String? {
guard index >= 0, index < count else { return nil }
return self[index]
}
}