84d9240ee6
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
1399 lines
55 KiB
Swift
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]
|
|
}
|
|
}
|