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() var plannedSourceKeys = Set() // "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() 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() 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.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.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.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.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.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.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.. [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] } }