Export/import JSON lossless (v3): goals, monthly contributions, journal, ratings
El JSON v2 perdía Goals, source.monthlyContribution, category.allocationTarget y todo el Journal (nota/mood/rating/completionTime). Nuevo formato v3: - source: + monthlyContribution, customFrequencyMonths - category: + allocationTarget - top-level: + goals[] (name/targetAmount/targetDate/isActive/account) - top-level: + journalEntries[] (monthKey/note/mood/rating/completionTime/createdAt) Import parsea y aplica todo, creando Goals y JournalEntry DIRECTAMENTE en el contexto de background (GoalRepository es @MainActor → no usable desde el import). Dedup: goals por nombre+cuenta, journal por monthKey → reimport idempotente. Retrocompatible con JSON v2. CSV sin cambios. Guard: si el import trae journal, se salta el marcado de completions derivado de snapshots (evita JournalEntry duplicados por el desfase de merge viewContext). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L2p3gUZRNWW388rWFRjiU7
This commit is contained in:
@@ -44,11 +44,14 @@ class ImportService {
|
||||
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]
|
||||
}
|
||||
|
||||
@@ -59,6 +62,23 @@ class ImportService {
|
||||
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,
|
||||
@@ -79,7 +99,14 @@ class ImportService {
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
return applyImport(parsed, context: CoreDataStack.shared.viewContext)
|
||||
let goals = parseGoals(content)
|
||||
let journal = parseJournal(content)
|
||||
return applyImport(
|
||||
parsed,
|
||||
context: CoreDataStack.shared.viewContext,
|
||||
goals: goals,
|
||||
journal: journal
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +120,8 @@ class ImportService {
|
||||
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(
|
||||
@@ -106,6 +135,8 @@ class ImportService {
|
||||
allowMultipleAccounts: allowMultipleAccounts,
|
||||
defaultAccountName: defaultAccountName
|
||||
)
|
||||
goals = self.parseGoals(content)
|
||||
journal = self.parseJournal(content)
|
||||
}
|
||||
|
||||
let totalSnapshots = parsed.reduce(0) { total, account in
|
||||
@@ -120,7 +151,12 @@ class ImportService {
|
||||
progress(ImportProgress(completed: 0, total: totalSnapshots, message: "Importing data"))
|
||||
}
|
||||
|
||||
let result = self.applyImport(parsed, context: context) { completed in
|
||||
let result = self.applyImport(
|
||||
parsed,
|
||||
context: context,
|
||||
goals: goals,
|
||||
journal: journal
|
||||
) { completed in
|
||||
DispatchQueue.main.async {
|
||||
progress(ImportProgress(
|
||||
completed: completed,
|
||||
@@ -147,7 +183,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
static func sampleJSON() -> String {
|
||||
return """
|
||||
{
|
||||
"version": 2,
|
||||
"version": 3,
|
||||
"currency": "EUR",
|
||||
"accounts": [{
|
||||
"name": "Personal",
|
||||
@@ -230,9 +266,20 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
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)
|
||||
ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: nil,
|
||||
customFrequencyMonths: nil,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: nil,
|
||||
icon: nil,
|
||||
allocationTarget: nil,
|
||||
sources: importedSources
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedAccount(
|
||||
@@ -274,9 +321,12 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
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,
|
||||
@@ -295,13 +345,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
return ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: monthlyContribution,
|
||||
customFrequencyMonths: customFrequencyMonths,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
allocationTarget: allocationTarget,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
@@ -344,13 +400,19 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedSource(name: sourceName, snapshots: snapshots)
|
||||
return ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: nil,
|
||||
customFrequencyMonths: nil,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: colorHex,
|
||||
icon: icon,
|
||||
allocationTarget: nil,
|
||||
sources: sources
|
||||
)
|
||||
}
|
||||
@@ -371,11 +433,70 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
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)
|
||||
@@ -413,6 +534,8 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
// 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
|
||||
@@ -428,6 +551,7 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
)
|
||||
accountsCreated += 1
|
||||
}
|
||||
accountsByName[importedAccount.name] = account
|
||||
|
||||
for importedCategory in importedAccount.categories {
|
||||
let existingCategory = resolveExistingCategory(
|
||||
@@ -447,6 +571,10 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
))
|
||||
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: {
|
||||
@@ -463,6 +591,13 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
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)
|
||||
@@ -504,6 +639,47 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -512,7 +688,14 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
}
|
||||
}
|
||||
|
||||
if !completionDatesByMonth.isEmpty {
|
||||
// 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'
|
||||
@@ -603,6 +786,37 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
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 {
|
||||
@@ -835,9 +1049,20 @@ Personal,Real Estate,Rental Property,2024-01-01,82000,80000,Estimated value
|
||||
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)
|
||||
ImportedSource(
|
||||
name: sourceName,
|
||||
monthlyContribution: nil,
|
||||
customFrequencyMonths: nil,
|
||||
snapshots: snapshots
|
||||
)
|
||||
}
|
||||
return ImportedCategory(name: categoryName, colorHex: nil, icon: nil, sources: importedSources)
|
||||
return ImportedCategory(
|
||||
name: categoryName,
|
||||
colorHex: nil,
|
||||
icon: nil,
|
||||
allocationTarget: nil,
|
||||
sources: importedSources
|
||||
)
|
||||
}
|
||||
return ImportedAccount(
|
||||
name: accountName,
|
||||
|
||||
Reference in New Issue
Block a user