Files
InvestmentTrackerApp/PortfolioJournal/Services/ImportService.swift
T
alexandrev-tibco 84d9240ee6 1.5.0: aviso de huecos + resumen mensual compartible + import con vista previa
Feature 1 — Aviso de huecos: SnapshotGapDetector detecta huecos internos por
source (meses sin datos entre dos snapshots). Banner desechable en Dashboard +
DataGapsSheet para rellenar (abre AddSnapshotView con el mes que falta). Complementa
la interpolación de gráficas: la línea se ve suave pero el usuario sabe que faltan
datos reales.

Feature 2 — Resumen mensual compartible: MonthlySummaryShareView (tarjeta con marca:
valor, variación desde el check-in, racha, mood/rating) renderizada con ImageRenderer
y compartida vía ShareService. Botón en la fila "check-in done" del Dashboard.

Feature 3 — Import con vista previa: ImportPreview + previewImportAsync (dry-run
READ-ONLY, no muta nada) cuenta qué se creará/actualizará usando los mismos checks
de existencia que applyImport. ImportDataView muestra la confirmación antes de
importar. El path real de import (applyImport/importDataAsync) NO se toca.

Localización en los 7 idiomas.

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

1399 lines
55 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 snapshotsUpdated: 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
}
/// Read-only summary of what an import WOULD do, computed against existing
/// data without mutating anything. Presented to the user for confirmation
/// before the real import runs.
struct ImportPreview {
var sourcesToCreate: Int = 0
var snapshotsToCreate: Int = 0
var snapshotsToUpdate: Int = 0
var categoriesToCreate: Int = 0
var goalsToCreate: Int = 0
var journalToCreate: Int = 0
var journalToUpdate: Int = 0
var hasAnything: Bool {
sourcesToCreate > 0 || snapshotsToCreate > 0 || snapshotsToUpdate > 0 ||
categoriesToCreate > 0 || goalsToCreate > 0 ||
journalToCreate > 0 || journalToUpdate > 0
}
}
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 allocationTarget: Double?
let sources: [ImportedSource]
}
struct ImportedSource {
let name: String
let monthlyContribution: Decimal?
let customFrequencyMonths: Int?
let snapshots: [ImportedSnapshot]
}
struct ImportedSnapshot {
let date: Date
let value: Decimal
let contribution: Decimal?
let notes: String?
}
struct ImportedGoal {
let name: String
let targetAmount: Decimal?
let targetDate: Date?
let isActive: Bool
let accountName: String?
}
struct ImportedJournalEntry {
let monthKey: String
let note: String?
let mood: String?
let rating: Int?
let completionTime: Date?
let createdAt: Date?
}
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
)
let goals = parseGoals(content)
let journal = parseJournal(content)
return applyImport(
parsed,
context: CoreDataStack.shared.viewContext,
goals: goals,
journal: journal
)
}
}
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]
var goals: [ImportedGoal] = []
var journal: [ImportedJournalEntry] = []
switch format {
case .csv:
parsed = self.parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
case .json:
parsed = self.parseJSON(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
goals = self.parseGoals(content)
journal = self.parseJournal(content)
}
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,
goals: goals,
journal: journal
) { 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": 3,
"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: - Dry-Run Preview (read-only)
/// Parses the content and counts what an import WOULD do against existing
/// data, WITHOUT mutating anything. Uses the same parse functions and the
/// same existence checks as `applyImport`, but only reads.
func previewImportAsync(
content: String,
format: ImportFormat,
allowMultipleAccounts: Bool,
defaultAccountName: String? = nil
) async -> ImportPreview {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let parsed: [ImportedAccount]
var goals: [ImportedGoal] = []
var journal: [ImportedJournalEntry] = []
switch format {
case .csv:
parsed = self.parseCSV(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
case .json:
parsed = self.parseJSON(
content,
allowMultipleAccounts: allowMultipleAccounts,
defaultAccountName: defaultAccountName
)
goals = self.parseGoals(content)
journal = self.parseJournal(content)
}
let preview = self.computePreview(
accounts: parsed,
goals: goals,
journal: journal,
context: context
)
continuation.resume(returning: preview)
}
}
}
/// Dry-run for the CSV column-mapping flow.
func previewCSVWithMappingAsync(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?
) async -> ImportPreview {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let preview = self.previewCSV(content)
let accounts = self.parseCSVWithMapping(
preview: preview,
mapping: mapping,
defaultAccountName: defaultAccountName
)
let result = self.computePreview(
accounts: accounts,
goals: [],
journal: [],
context: context
)
continuation.resume(returning: result)
}
}
}
/// Read-only counting pass. Mirrors the existence checks in `applyImport`
/// (same source name+account, same-day snapshot, category name, goal
/// name+account, journal monthKey) but never writes. Because it never
/// saves, it may not perfectly disambiguate multiple new sources/categories
/// that collapse to the same name within one import; it errs toward the
/// user-facing counts `applyImport` would produce for typical files.
private func computePreview(
accounts: [ImportedAccount],
goals: [ImportedGoal],
journal: [ImportedJournalEntry],
context: NSManagedObjectContext
) -> ImportPreview {
var preview = ImportPreview()
let existingCategories = fetchCategories(in: context)
let categoryLookup = buildCategoryLookup(from: existingCategories)
let existingAccountsLookup = fetchAccountsLookup(in: context)
// Track names we would create so duplicates within the same import file
// aren't double-counted.
var plannedCategoryKeys = Set<String>()
var plannedSourceKeys = Set<String>() // "accountName|sourceName"
var accountsByName: [String: Account] = existingAccountsLookup
// "Other" is created on demand by applyImport when a source has no
// resolvable category; only count it if we actually need it.
var otherCategoryNeeded = false
let otherExists = resolveExistingCategory(named: "Other", lookup: categoryLookup) != nil
for importedAccount in accounts {
let account = existingAccountsLookup[importedAccount.name]
accountsByName[importedAccount.name] = account
for importedCategory in importedAccount.categories {
let existingCategory = resolveExistingCategory(
named: importedCategory.name,
lookup: categoryLookup
)
let shouldUseOther = existingCategory == nil &&
importedCategory.colorHex == nil &&
importedCategory.icon == nil
if existingCategory == nil {
if shouldUseOther {
otherCategoryNeeded = otherCategoryNeeded || !otherExists
} else {
let key = normalizedCategoryName(
canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
)
if !plannedCategoryKeys.contains(key) {
plannedCategoryKeys.insert(key)
preview.categoriesToCreate += 1
}
}
}
for importedSource in importedCategory.sources {
let accountId = account?.safeId
let existingSource = fetchSource(
named: importedSource.name,
accountId: accountId,
in: context
)
let sourceKey = "\(importedAccount.name)|\(importedSource.name)"
if existingSource == nil, !plannedSourceKeys.contains(sourceKey) {
plannedSourceKeys.insert(sourceKey)
preview.sourcesToCreate += 1
}
for snapshot in importedSource.snapshots {
// Only an EXISTING source can have a same-day snapshot to update.
if let existing = existingSource,
fetchSnapshot(for: existing, date: snapshot.date, in: context) != nil {
preview.snapshotsToUpdate += 1
} else {
preview.snapshotsToCreate += 1
}
}
}
}
}
if otherCategoryNeeded {
preview.categoriesToCreate += 1
}
// Goals dedup by name within account (case-insensitive), same as applyImport.
var plannedGoalKeys = Set<String>()
for importedGoal in goals {
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty, importedGoal.targetAmount != nil else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
let key = "\(trimmedName.lowercased())|\(resolvedAccount?.name ?? "")"
if !plannedGoalKeys.contains(key) {
plannedGoalKeys.insert(key)
preview.goalsToCreate += 1
}
}
}
// Journal dedup by monthKey, same as applyImport.
var plannedJournalKeys = Set<String>()
for importedEntry in journal {
guard !plannedJournalKeys.contains(importedEntry.monthKey) else { continue }
plannedJournalKeys.insert(importedEntry.monthKey)
if fetchJournalEntry(monthKey: importedEntry.monthKey, in: context) != nil {
preview.journalToUpdate += 1
} else {
preview.journalToCreate += 1
}
}
return preview
}
/// Read-only fetch of an existing source by name within an account (or with
/// no account). Mirrors applyImport's `name && account?.id` match.
private func fetchSource(
named name: String,
accountId: UUID?,
in context: NSManagedObjectContext
) -> InvestmentSource? {
let request: NSFetchRequest<InvestmentSource> = InvestmentSource.fetchRequest()
request.predicate = NSPredicate(format: "name == %@", name)
let matches = (try? context.fetch(request)) ?? []
return matches.first { $0.account?.id == accountId }
}
// 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,
monthlyContribution: nil,
customFrequencyMonths: nil,
snapshots: snapshots
)
}
return ImportedCategory(
name: categoryName,
colorHex: nil,
icon: nil,
allocationTarget: 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 allocationTarget = categoryDict["allocationTarget"] as? Double
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
let sources = sourcesArray.map { sourceDict in
let sourceName = sourceDict["name"] as? String ?? "Source"
let monthlyContribution = (sourceDict["monthlyContribution"] as? Double).map { Decimal($0) }
let customFrequencyMonths = sourceDict["customFrequencyMonths"] as? Int
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,
monthlyContribution: monthlyContribution,
customFrequencyMonths: customFrequencyMonths,
snapshots: snapshots
)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
allocationTarget: allocationTarget,
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,
monthlyContribution: nil,
customFrequencyMonths: nil,
snapshots: snapshots
)
}
return ImportedCategory(
name: categoryName,
colorHex: colorHex,
icon: icon,
allocationTarget: nil,
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 []
}
private func parseGoals(_ content: String) -> [ImportedGoal] {
guard let data = content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let goalsArray = json["goals"] as? [[String: Any]] else {
return []
}
return goalsArray.compactMap { goalDict -> ImportedGoal? in
guard let name = goalDict["name"] as? String,
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return nil
}
let targetAmount = (goalDict["targetAmount"] as? Double).map { Decimal($0) }
let targetDate = (goalDict["targetDate"] as? String)
.flatMap { ISO8601DateFormatter().date(from: $0) }
let isActive = goalDict["isActive"] as? Bool ?? true
let accountName = goalDict["account"] as? String
return ImportedGoal(
name: name,
targetAmount: targetAmount,
targetDate: targetDate,
isActive: isActive,
accountName: accountName
)
}
}
private func parseJournal(_ content: String) -> [ImportedJournalEntry] {
guard let data = content.data(using: .utf8),
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let journalArray = json["journalEntries"] as? [[String: Any]] else {
return []
}
return journalArray.compactMap { entryDict -> ImportedJournalEntry? in
guard let monthKey = entryDict["monthKey"] as? String,
!monthKey.isEmpty else {
return nil
}
let note = entryDict["note"] as? String
let mood = entryDict["mood"] as? String
let rating = entryDict["rating"] as? Int
let completionTime = (entryDict["completionTime"] as? String)
.flatMap { ISO8601DateFormatter().date(from: $0) }
let createdAt = (entryDict["createdAt"] as? String)
.flatMap { ISO8601DateFormatter().date(from: $0) }
return ImportedJournalEntry(
monthKey: monthKey,
note: note,
mood: mood,
rating: rating,
completionTime: completionTime,
createdAt: createdAt
)
}
}
// MARK: - Apply Import
private func applyImport(
_ accounts: [ImportedAccount],
context: NSManagedObjectContext,
goals: [ImportedGoal] = [],
journal: [ImportedJournalEntry] = [],
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)
// Batch import: suppress the per-row widget refresh (each does a synchronous
// WAL checkpoint on the main context) to avoid hanging the main thread on
// large imports. Refresh once when everything is done.
CoreDataStack.shared.suppressWidgetRefresh = true
defer {
CoreDataStack.shared.suppressWidgetRefresh = false
CoreDataStack.shared.refreshWidgetData()
}
var accountsCreated = 0
var sourcesCreated = 0
var snapshotsCreated = 0
var snapshotsUpdated = 0
var errors: [String] = []
let existingCategories = fetchCategories(in: context)
var categoryLookup = buildCategoryLookup(from: existingCategories)
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] = [:]
// Build lookup of existing accounts by fetching directly from database
let existingAccountsLookup = fetchAccountsLookup(in: context)
var accountsByName: [String: Account] = existingAccountsLookup
for importedAccount in accounts {
let existingAccount = existingAccountsLookup[importedAccount.name]
let account: Account
if let existing = existingAccount {
account = existing
} else {
account = accountRepository.createAccount(
name: importedAccount.name,
currency: importedAccount.currency,
inputMode: importedAccount.inputMode,
notificationFrequency: importedAccount.notificationFrequency,
customFrequencyMonths: importedAccount.customFrequencyMonths
)
accountsCreated += 1
}
accountsByName[importedAccount.name] = account
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
if let allocationTarget = importedCategory.allocationTarget {
category.allocationTarget = NSNumber(value: allocationTarget)
}
for importedSource in importedCategory.sources {
let accountId = account.safeId
let existingSource = sourceRepository.sources.first(where: {
$0.name == importedSource.name && $0.account?.id == accountId
})
let source = existingSource ?? sourceRepository.createSource(
name: importedSource.name,
category: category,
notificationFrequency: importedAccount.notificationFrequency,
customFrequencyMonths: importedAccount.customFrequencyMonths
)
source.account = account
if existingSource == nil {
sourcesCreated += 1
}
if let monthlyContribution = importedSource.monthlyContribution {
source.monthlyContribution = NSDecimalNumber(decimal: monthlyContribution)
}
if let customFrequencyMonths = importedSource.customFrequencyMonths {
source.customFrequencyMonths = Int16(customFrequencyMonths)
}
for snapshot in importedSource.snapshots {
// Check if a snapshot with the same date already exists for this source
let existingSnapshot = fetchSnapshot(for: source, date: snapshot.date, in: context)
if let existing = existingSnapshot {
// Update existing snapshot instead of creating a duplicate
existing.value = NSDecimalNumber(decimal: snapshot.value)
if let contribution = snapshot.contribution {
existing.contribution = NSDecimalNumber(decimal: contribution)
}
if let notes = snapshot.notes, !notes.isEmpty {
existing.notes = notes
}
snapshotsUpdated += 1
} else {
// Create new snapshot
snapshotRepository.createSnapshot(
for: source,
date: snapshot.date,
value: snapshot.value,
contribution: snapshot.contribution,
notes: snapshot.notes,
batch: true
)
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
}
}
}
}
}
// Goals: create directly on the background context. Dedup by name within
// the same account (case-insensitive), mirroring GoalRepository behaviour.
for importedGoal in goals {
let trimmedName = importedGoal.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedName.isEmpty else { continue }
guard let targetAmount = importedGoal.targetAmount else { continue }
let resolvedAccount = importedGoal.accountName.flatMap { accountsByName[$0] }
if fetchGoal(named: trimmedName, account: resolvedAccount, in: context) == nil {
let goal = Goal(context: context)
goal.name = trimmedName
goal.targetAmount = NSDecimalNumber(decimal: targetAmount)
goal.targetDate = importedGoal.targetDate
goal.account = resolvedAccount
goal.isActive = importedGoal.isActive
}
}
// Journal entries: create directly on the background context. Dedup by
// monthKey; update fields when an entry already exists.
for importedEntry in journal {
let entry = fetchJournalEntry(monthKey: importedEntry.monthKey, in: context)
?? JournalEntry(context: context)
entry.monthKey = importedEntry.monthKey
if let note = importedEntry.note, !note.isEmpty {
entry.note = note
}
if let mood = importedEntry.mood, !mood.isEmpty {
entry.moodRaw = mood
}
if let rating = importedEntry.rating {
entry.rating = Int16(rating)
}
if let completionTime = importedEntry.completionTime {
entry.completionTime = completionTime
}
if let createdAt = importedEntry.createdAt {
entry.createdAt = createdAt
}
}
if context.hasChanges {
do {
try context.save()
} catch {
errors.append("Failed to save imported data.")
}
}
// Snapshot-derived completion marking is a FALLBACK for imports that carry no
// journal (CSV, or JSON without journalEntries): it marks months that have
// snapshots as "checked in" so the Home card looks right. When the import DOES
// bring journal entries, they already carry their real completionTime and were
// created on the background context above running setCompletionDate here (on
// viewContext, which hasn't merged those yet) would create DUPLICATE
// JournalEntry rows for the same month. So skip it when journal was imported.
if journal.isEmpty, !completionDatesByMonth.isEmpty {
// MonthlyCheckInStore mutates CoreDataStack.shared.viewContext (main queue).
// applyImport runs on a BACKGROUND context, so calling setCompletionDate
// here directly was a Core Data concurrency violation the imported months'
// JournalEntry.completionTime never persisted, leaving the "next check-in"
// stuck on an old entry. Run it on the viewContext's own queue.
CoreDataStack.shared.viewContext.performAndWait {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM"
for (monthKey, completionDate) in completionDatesByMonth {
if let monthDate = formatter.date(from: monthKey) {
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
}
}
}
}
return ImportResult(
accountsCreated: accountsCreated,
sourcesCreated: sourcesCreated,
snapshotsCreated: snapshotsCreated,
snapshotsUpdated: snapshotsUpdated,
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 fetchCategories(in context: NSManagedObjectContext) -> [Category] {
let request: NSFetchRequest<Category> = Category.fetchRequest()
request.sortDescriptors = [
NSSortDescriptor(keyPath: \Category.sortOrder, ascending: true),
NSSortDescriptor(keyPath: \Category.name, ascending: true)
]
return (try? context.fetch(request)) ?? []
}
private func fetchAccountsLookup(in context: NSManagedObjectContext) -> [String: Account] {
let request: NSFetchRequest<Account> = Account.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(keyPath: \Account.createdAt, ascending: true)]
let accounts = (try? context.fetch(request)) ?? []
var lookup: [String: Account] = [:]
for account in accounts {
lookup[account.name] = account
}
return lookup
}
/// Fetch a snapshot for a given source and date (same day)
private func fetchSnapshot(
for source: InvestmentSource,
date: Date,
in context: NSManagedObjectContext
) -> Snapshot? {
let request: NSFetchRequest<Snapshot> = Snapshot.fetchRequest()
// Match snapshots on the same day (ignore time component)
let calendar = Calendar.current
let startOfDay = calendar.startOfDay(for: date)
let endOfDay = calendar.date(byAdding: .day, value: 1, to: startOfDay)!
request.predicate = NSPredicate(
format: "source == %@ AND date >= %@ AND date < %@",
source,
startOfDay as NSDate,
endOfDay as NSDate
)
request.fetchLimit = 1
return try? context.fetch(request).first
}
/// Fetch an existing goal by name (case-insensitive) within the same account.
private func fetchGoal(
named name: String,
account: Account?,
in context: NSManagedObjectContext
) -> Goal? {
let request: NSFetchRequest<Goal> = Goal.fetchRequest()
if let account = account {
request.predicate = NSPredicate(
format: "name ==[c] %@ AND account == %@", name, account
)
} else {
request.predicate = NSPredicate(
format: "name ==[c] %@ AND account == nil", name
)
}
request.fetchLimit = 1
return try? context.fetch(request).first
}
/// Fetch an existing journal entry by monthKey.
private func fetchJournalEntry(
monthKey: String,
in context: NSManagedObjectContext
) -> JournalEntry? {
let request: NSFetchRequest<JournalEntry> = JournalEntry.fetchRequest()
request.predicate = NSPredicate(format: "monthKey == %@", monthKey)
request.fetchLimit = 1
return try? context.fetch(request).first
}
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 Column Mapping
/// Parsed preview data for the mapping UI
struct CSVPreview {
let rows: [[String]]
let columnCount: Int
func headers(hasHeaderRow: Bool) -> [String] {
if hasHeaderRow, let first = rows.first {
return first
}
return (0..<columnCount).map { "Column \($0 + 1)" }
}
func sampleRow(hasHeaderRow: Bool) -> [String]? {
let start = hasHeaderRow ? 1 : 0
return rows.count > start ? rows[start] : nil
}
}
func previewCSV(_ content: String) -> CSVPreview {
let rows = parseCSVRows(content)
let columnCount = rows.first?.count ?? 0
return CSVPreview(rows: rows, columnCount: columnCount)
}
// Maps each app field to a CSV column index or a constant value
struct CSVMappingConfig {
// -1 = not mapped, -2 = constant, -3 = use today (date only), 0+ = column index
static let notMapped = -1
static let constant = -2
static let useToday = -3
var hasHeaderRow: Bool = true
var sourceIndex: Int = notMapped
var sourceConstant: String = ""
var valueIndex: Int = notMapped
var dateIndex: Int = useToday // default: use today
var categoryIndex: Int = constant
var categoryConstant: String = "Other" // default category name when constant
var contributionIndex: Int = notMapped
var notesIndex: Int = notMapped
var isValid: Bool {
let sourceOk: Bool = {
if sourceIndex == Self.constant { return !sourceConstant.isEmpty }
return sourceIndex >= 0
}()
let valueOk = valueIndex >= 0
return sourceOk && valueOk
}
}
func importCSVWithMapping(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> ImportResult {
let preview = previewCSV(content)
let accounts = parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
return applyImport(accounts, context: CoreDataStack.shared.viewContext)
}
func importCSVWithMappingAsync(
content: String,
mapping: CSVMappingConfig,
defaultAccountName: String?,
progress: @escaping (ImportProgress) -> Void
) async -> ImportResult {
await withCheckedContinuation { continuation in
CoreDataStack.shared.performBackgroundTask { context in
let preview = self.previewCSV(content)
let accounts = self.parseCSVWithMapping(preview: preview, mapping: mapping, defaultAccountName: defaultAccountName)
let totalSnapshots = accounts.reduce(0) { t, a in
t + a.categories.reduce(0) { s, c in s + c.sources.reduce(0) { $0 + $1.snapshots.count } }
}
DispatchQueue.main.async {
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
}
let result = self.applyImport(accounts, context: context) { completed in
DispatchQueue.main.async {
progress(ImportProgress(
completed: completed,
total: totalSnapshots,
message: "Imported \(completed) of \(totalSnapshots) snapshots"
))
}
}
continuation.resume(returning: result)
}
}
}
private func parseCSVWithMapping(
preview: CSVPreview,
mapping: CSVMappingConfig,
defaultAccountName: String?
) -> [ImportedAccount] {
let dataRows = mapping.hasHeaderRow ? Array(preview.rows.dropFirst()) : preview.rows
let fallbackAccount = defaultAccountName ?? "Personal"
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
for row in dataRows {
// Source name
let sourceName: String
if mapping.sourceIndex == CSVMappingConfig.constant {
guard !mapping.sourceConstant.isEmpty else { continue }
sourceName = mapping.sourceConstant
} else if mapping.sourceIndex >= 0 {
guard let v = row.safeValue(at: mapping.sourceIndex), !v.isEmpty else { continue }
sourceName = v
} else {
continue
}
// Value
guard mapping.valueIndex >= 0,
let valStr = row.safeValue(at: mapping.valueIndex),
let value = parseDecimal(valStr) else { continue }
// Date
let date: Date
if mapping.dateIndex == CSVMappingConfig.useToday {
date = Calendar.current.startOfDay(for: Date())
} else if mapping.dateIndex >= 0,
let dateStr = row.safeValue(at: mapping.dateIndex),
let parsed = parseDate(dateStr) {
date = parsed
} else {
date = Calendar.current.startOfDay(for: Date())
}
// Category
let categoryName: String
if mapping.categoryIndex == CSVMappingConfig.constant {
categoryName = mapping.categoryConstant.isEmpty ? "Other" : mapping.categoryConstant
} else if mapping.categoryIndex >= 0,
let v = row.safeValue(at: mapping.categoryIndex), !v.isEmpty {
categoryName = v
} else {
categoryName = "Other"
}
// Contribution
let contribution: Decimal? = mapping.contributionIndex >= 0
? row.safeValue(at: mapping.contributionIndex).flatMap(parseDecimal)
: nil
// Notes
let notes: String? = mapping.notesIndex >= 0
? row.safeValue(at: mapping.notesIndex).flatMap { $0.isEmpty ? nil : $0 }
: nil
let snapshot = ImportedSnapshot(date: date, value: value, contribution: contribution, notes: notes)
grouped[fallbackAccount, 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,
monthlyContribution: nil,
customFrequencyMonths: nil,
snapshots: snapshots
)
}
return ImportedCategory(
name: categoryName,
colorHex: nil,
icon: nil,
allocationTarget: nil,
sources: importedSources
)
}
return ImportedAccount(
name: accountName,
currency: nil,
inputMode: .simple,
notificationFrequency: .monthly,
customFrequencyMonths: 1,
categories: importedCategories
)
}
}
// 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]
}
}