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:
alexandrev-tibco
2026-07-23 20:13:21 +02:00
parent b60e3041cb
commit df9309895a
2 changed files with 295 additions and 12 deletions
+235 -10
View File
@@ -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,