b48a47ce10
- 1A: Notificación mensual de resumen de portfolio (día 5 de cada mes) - 1B: Badge de racha mensual en Dashboard (streak counter) - 1C: Empty states mejorados en Goals y Journal con CTAs claros - 2A: Quick Update sheet — actualiza todas las fuentes desde una pantalla - 2B: Notificación batch update redirige al Quick Update sheet - 2C: Widget deep link portfoliojournal://quickupdate abre Quick Update - 3A: App Store version check banner en Settings - 3C: What's New sheet en primer launch de versión nueva - Localización completa en 7 idiomas para todas las nuevas strings Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
941 lines
35 KiB
Swift
941 lines
35 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
|
|
}
|
|
|
|
struct ImportedAccount {
|
|
let name: String
|
|
let currency: String?
|
|
let inputMode: InputMode
|
|
let notificationFrequency: NotificationFrequency
|
|
let customFrequencyMonths: Int
|
|
let categories: [ImportedCategory]
|
|
}
|
|
|
|
struct ImportedCategory {
|
|
let name: String
|
|
let colorHex: String?
|
|
let icon: String?
|
|
let sources: [ImportedSource]
|
|
}
|
|
|
|
struct ImportedSource {
|
|
let name: String
|
|
let snapshots: [ImportedSnapshot]
|
|
}
|
|
|
|
struct ImportedSnapshot {
|
|
let date: Date
|
|
let value: Decimal
|
|
let contribution: Decimal?
|
|
let notes: String?
|
|
}
|
|
|
|
func importData(
|
|
content: String,
|
|
format: ImportFormat,
|
|
allowMultipleAccounts: Bool,
|
|
defaultAccountName: String? = nil
|
|
) -> ImportResult {
|
|
switch format {
|
|
case .csv:
|
|
let parsed = parseCSV(
|
|
content,
|
|
allowMultipleAccounts: allowMultipleAccounts,
|
|
defaultAccountName: defaultAccountName
|
|
)
|
|
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
|
|
case .json:
|
|
let parsed = parseJSON(
|
|
content,
|
|
allowMultipleAccounts: allowMultipleAccounts,
|
|
defaultAccountName: defaultAccountName
|
|
)
|
|
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
|
|
}
|
|
}
|
|
|
|
func importDataAsync(
|
|
content: String,
|
|
format: ImportFormat,
|
|
allowMultipleAccounts: Bool,
|
|
defaultAccountName: String? = nil,
|
|
progress: @escaping (ImportProgress) -> Void
|
|
) async -> ImportResult {
|
|
await withCheckedContinuation { continuation in
|
|
CoreDataStack.shared.performBackgroundTask { context in
|
|
let parsed: [ImportedAccount]
|
|
switch format {
|
|
case .csv:
|
|
parsed = self.parseCSV(
|
|
content,
|
|
allowMultipleAccounts: allowMultipleAccounts,
|
|
defaultAccountName: defaultAccountName
|
|
)
|
|
case .json:
|
|
parsed = self.parseJSON(
|
|
content,
|
|
allowMultipleAccounts: allowMultipleAccounts,
|
|
defaultAccountName: defaultAccountName
|
|
)
|
|
}
|
|
|
|
let totalSnapshots = parsed.reduce(0) { total, account in
|
|
total + account.categories.reduce(0) { subtotal, category in
|
|
subtotal + category.sources.reduce(0) { sourceTotal, source in
|
|
sourceTotal + source.snapshots.count
|
|
}
|
|
}
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
|
|
}
|
|
|
|
let result = self.applyImport(parsed, context: context) { completed in
|
|
DispatchQueue.main.async {
|
|
progress(ImportProgress(
|
|
completed: completed,
|
|
total: totalSnapshots,
|
|
message: "Imported \(completed) of \(totalSnapshots) snapshots"
|
|
))
|
|
}
|
|
}
|
|
|
|
continuation.resume(returning: result)
|
|
}
|
|
}
|
|
}
|
|
|
|
static func sampleCSV() -> String {
|
|
return """
|
|
Account,Category,Source,Date,Value,Contribution,Notes
|
|
Personal,Stocks,Index Fund,2024-01-01,15000,12000,Long-term
|
|
Personal,Crypto,BTC,2024-01-01,3200,,Cold storage
|
|
Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
|
"""
|
|
}
|
|
|
|
static func sampleJSON() -> String {
|
|
return """
|
|
{
|
|
"version": 2,
|
|
"currency": "EUR",
|
|
"accounts": [{
|
|
"name": "Personal",
|
|
"inputMode": "simple",
|
|
"notificationFrequency": "monthly",
|
|
"categories": [{
|
|
"name": "Stocks",
|
|
"color": "#3B82F6",
|
|
"icon": "chart.line.uptrend.xyaxis",
|
|
"sources": [{
|
|
"name": "Index Fund",
|
|
"snapshots": [{
|
|
"date": "2024-01-01T00:00:00Z",
|
|
"value": 15000,
|
|
"contribution": 12000
|
|
}]
|
|
}]
|
|
}]
|
|
}]
|
|
}
|
|
"""
|
|
}
|
|
|
|
// MARK: - Parsing
|
|
|
|
private func parseCSV(
|
|
_ content: String,
|
|
allowMultipleAccounts: Bool,
|
|
defaultAccountName: String?
|
|
) -> [ImportedAccount] {
|
|
let rows = parseCSVRows(content)
|
|
guard rows.count > 1 else { return [] }
|
|
|
|
let headers = rows[0].map { $0.lowercased().trimmingCharacters(in: .whitespaces) }
|
|
let indexOfAccount = headers.firstIndex(of: "account")
|
|
let indexOfCategory = headers.firstIndex(of: "category")
|
|
let indexOfSource = headers.firstIndex(of: "source")
|
|
let indexOfDate = headers.firstIndex(of: "date")
|
|
let indexOfValue = headers.firstIndex(where: { $0.hasPrefix("value") })
|
|
let indexOfContribution = headers.firstIndex(where: { $0.hasPrefix("contribution") })
|
|
let indexOfNotes = headers.firstIndex(of: "notes")
|
|
|
|
var grouped: [String: [String: [String: [ImportedSnapshot]]]] = [:]
|
|
|
|
for row in rows.dropFirst() {
|
|
let providedAccount = indexOfAccount.flatMap { row.safeValue(at: $0) }
|
|
let fallbackAccount = defaultAccountName ?? "Personal"
|
|
let normalizedAccount = (providedAccount ?? "")
|
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let rawAccountName = normalizedAccount.isEmpty ? fallbackAccount : normalizedAccount
|
|
let accountName = allowMultipleAccounts ? rawAccountName : fallbackAccount
|
|
|
|
guard let categoryName = indexOfCategory.flatMap({ row.safeValue(at: $0) }), !categoryName.isEmpty,
|
|
let sourceName = indexOfSource.flatMap({ row.safeValue(at: $0) }), !sourceName.isEmpty,
|
|
let dateString = indexOfDate.flatMap({ row.safeValue(at: $0) }),
|
|
let valueString = indexOfValue.flatMap({ row.safeValue(at: $0) }) else {
|
|
continue
|
|
}
|
|
|
|
guard let date = parseDate(dateString),
|
|
let value = parseDecimal(valueString) else { continue }
|
|
|
|
let contribution = indexOfContribution
|
|
.flatMap { row.safeValue(at: $0) }
|
|
.flatMap(parseDecimal)
|
|
let notes = indexOfNotes
|
|
.flatMap { row.safeValue(at: $0) }
|
|
.flatMap { $0.isEmpty ? nil : $0 }
|
|
|
|
let snapshot = ImportedSnapshot(
|
|
date: date,
|
|
value: value,
|
|
contribution: contribution,
|
|
notes: notes
|
|
)
|
|
|
|
grouped[accountName, default: [:]][categoryName, default: [:]][sourceName, default: []].append(snapshot)
|
|
}
|
|
|
|
return grouped.map { accountName, categories in
|
|
let importedCategories = categories.map { categoryName, sources in
|
|
let importedSources = sources.map { sourceName, snapshots in
|
|
ImportedSource(name: sourceName, snapshots: snapshots)
|
|
}
|
|
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
|
|
}
|
|
|
|
return ImportedAccount(
|
|
name: accountName,
|
|
currency: nil,
|
|
inputMode: .simple,
|
|
notificationFrequency: .monthly,
|
|
customFrequencyMonths: 1,
|
|
categories: importedCategories
|
|
)
|
|
}
|
|
}
|
|
|
|
private func parseJSON(
|
|
_ content: String,
|
|
allowMultipleAccounts: Bool,
|
|
defaultAccountName: String?
|
|
) -> [ImportedAccount] {
|
|
guard let data = content.data(using: .utf8),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
return []
|
|
}
|
|
|
|
if let accountsArray = json["accounts"] as? [[String: Any]] {
|
|
return accountsArray.compactMap { accountDict in
|
|
let rawName = accountDict["name"] as? String ?? "Personal"
|
|
let fallbackName = defaultAccountName ?? "Personal"
|
|
let name = allowMultipleAccounts ? rawName : fallbackName
|
|
|
|
let currency = accountDict["currency"] as? String
|
|
let inputMode = InputMode(rawValue: accountDict["inputMode"] as? String ?? "") ?? .simple
|
|
let notificationFrequency = NotificationFrequency(
|
|
rawValue: accountDict["notificationFrequency"] as? String ?? ""
|
|
) ?? .monthly
|
|
let customFrequencyMonths = accountDict["customFrequencyMonths"] as? Int ?? 1
|
|
|
|
let categoriesArray = accountDict["categories"] as? [[String: Any]] ?? []
|
|
let categories = categoriesArray.map { categoryDict in
|
|
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
|
|
let colorHex = categoryDict["color"] as? String
|
|
let icon = categoryDict["icon"] as? String
|
|
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
|
|
let sources = sourcesArray.map { sourceDict in
|
|
let sourceName = sourceDict["name"] as? String ?? "Source"
|
|
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
|
|
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
|
|
guard let dateString = snapshotDict["date"] as? String,
|
|
let date = ISO8601DateFormatter().date(from: dateString),
|
|
let value = snapshotDict["value"] as? Double else {
|
|
return nil
|
|
}
|
|
|
|
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
|
|
let notes = snapshotDict["notes"] as? String
|
|
return ImportedSnapshot(
|
|
date: date,
|
|
value: Decimal(value),
|
|
contribution: contribution,
|
|
notes: notes
|
|
)
|
|
}
|
|
|
|
return ImportedSource(name: sourceName, snapshots: snapshots)
|
|
}
|
|
|
|
return ImportedCategory(
|
|
name: categoryName,
|
|
colorHex: colorHex,
|
|
icon: icon,
|
|
sources: sources
|
|
)
|
|
}
|
|
|
|
return ImportedAccount(
|
|
name: name,
|
|
currency: currency,
|
|
inputMode: inputMode,
|
|
notificationFrequency: notificationFrequency,
|
|
customFrequencyMonths: customFrequencyMonths,
|
|
categories: categories
|
|
)
|
|
}
|
|
}
|
|
|
|
// Legacy JSON: categories only
|
|
if let categoriesArray = json["categories"] as? [[String: Any]] {
|
|
let categories = categoriesArray.map { categoryDict in
|
|
let categoryName = categoryDict["name"] as? String ?? "Uncategorized"
|
|
let colorHex = categoryDict["color"] as? String
|
|
let icon = categoryDict["icon"] as? String
|
|
let sourcesArray = categoryDict["sources"] as? [[String: Any]] ?? []
|
|
let sources = sourcesArray.map { sourceDict in
|
|
let sourceName = sourceDict["name"] as? String ?? "Source"
|
|
let snapshotsArray = sourceDict["snapshots"] as? [[String: Any]] ?? []
|
|
let snapshots = snapshotsArray.compactMap { snapshotDict -> ImportedSnapshot? in
|
|
guard let dateString = snapshotDict["date"] as? String,
|
|
let date = ISO8601DateFormatter().date(from: dateString),
|
|
let value = snapshotDict["value"] as? Double else {
|
|
return nil
|
|
}
|
|
|
|
let contribution = (snapshotDict["contribution"] as? Double).map { Decimal($0) }
|
|
let notes = snapshotDict["notes"] as? String
|
|
return ImportedSnapshot(
|
|
date: date,
|
|
value: Decimal(value),
|
|
contribution: contribution,
|
|
notes: notes
|
|
)
|
|
}
|
|
|
|
return ImportedSource(name: sourceName, snapshots: snapshots)
|
|
}
|
|
|
|
return ImportedCategory(
|
|
name: categoryName,
|
|
colorHex: colorHex,
|
|
icon: icon,
|
|
sources: sources
|
|
)
|
|
}
|
|
|
|
let fallbackName = allowMultipleAccounts ? "Personal" : (defaultAccountName ?? "Personal")
|
|
return [
|
|
ImportedAccount(
|
|
name: fallbackName,
|
|
currency: json["currency"] as? String,
|
|
inputMode: .simple,
|
|
notificationFrequency: .monthly,
|
|
customFrequencyMonths: 1,
|
|
categories: categories
|
|
)
|
|
]
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
// MARK: - Apply Import
|
|
|
|
private func applyImport(
|
|
_ accounts: [ImportedAccount],
|
|
context: NSManagedObjectContext,
|
|
snapshotProgress: ((Int) -> Void)? = nil
|
|
) -> ImportResult {
|
|
let accountRepository = AccountRepository(context: context)
|
|
let categoryRepository = CategoryRepository(context: context)
|
|
let sourceRepository = InvestmentSourceRepository(context: context)
|
|
let snapshotRepository = SnapshotRepository(context: context)
|
|
|
|
var accountsCreated = 0
|
|
var sourcesCreated = 0
|
|
var snapshotsCreated = 0
|
|
var 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)
|
|
|
|
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
|
|
}
|
|
|
|
for importedCategory in importedAccount.categories {
|
|
let existingCategory = resolveExistingCategory(
|
|
named: importedCategory.name,
|
|
lookup: categoryLookup
|
|
)
|
|
let shouldUseOther = existingCategory == nil &&
|
|
importedCategory.colorHex == nil &&
|
|
importedCategory.icon == nil
|
|
let resolvedName = canonicalCategoryName(for: importedCategory.name) ?? importedCategory.name
|
|
let category = existingCategory ?? (shouldUseOther
|
|
? otherCategory
|
|
: categoryRepository.createCategory(
|
|
name: resolvedName,
|
|
colorHex: importedCategory.colorHex ?? "#3B82F6",
|
|
icon: importedCategory.icon ?? "chart.pie.fill"
|
|
))
|
|
categoryLookup[normalizedCategoryName(category.name)] = category
|
|
|
|
for importedSource in importedCategory.sources {
|
|
let 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
|
|
}
|
|
|
|
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
|
|
)
|
|
snapshotsCreated += 1
|
|
}
|
|
snapshotProgress?(snapshotsCreated)
|
|
|
|
let monthKey = MonthlyCheckInStore.monthKey(for: snapshot.date)
|
|
if let existingDate = completionDatesByMonth[monthKey] {
|
|
if snapshot.date > existingDate {
|
|
completionDatesByMonth[monthKey] = snapshot.date
|
|
}
|
|
} else {
|
|
completionDatesByMonth[monthKey] = snapshot.date
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if context.hasChanges {
|
|
do {
|
|
try context.save()
|
|
} catch {
|
|
errors.append("Failed to save imported data.")
|
|
}
|
|
}
|
|
|
|
if !completionDatesByMonth.isEmpty {
|
|
for (monthKey, completionDate) in completionDatesByMonth {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "yyyy-MM"
|
|
if let monthDate = formatter.date(from: monthKey) {
|
|
MonthlyCheckInStore.setCompletionDate(completionDate, for: monthDate)
|
|
}
|
|
}
|
|
}
|
|
|
|
return ImportResult(
|
|
accountsCreated: accountsCreated,
|
|
sourcesCreated: sourcesCreated,
|
|
snapshotsCreated: snapshotsCreated,
|
|
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
|
|
}
|
|
|
|
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, snapshots: snapshots)
|
|
}
|
|
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
|
|
}
|
|
return ImportedAccount(
|
|
name: accountName,
|
|
currency: nil,
|
|
inputMode: .simple,
|
|
notificationFrequency: .monthly,
|
|
customFrequencyMonths: 1,
|
|
categories: importedCategories
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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]
|
|
}
|
|
}
|