679 lines
25 KiB
Swift
679 lines
25 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 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)
|
|
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)
|
|
}
|
|
|
|
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 : "Personal"
|
|
|
|
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) -> [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 name = allowMultipleAccounts ? rawName : "Personal"
|
|
|
|
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
|
|
)
|
|
}
|
|
|
|
return [
|
|
ImportedAccount(
|
|
name: "Personal",
|
|
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 errors: [String] = []
|
|
|
|
var categoryLookup = buildCategoryLookup(from: categoryRepository.categories)
|
|
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] = [:]
|
|
|
|
for importedAccount in accounts {
|
|
let existingAccount = accountRepository.accounts.first(where: { $0.name == importedAccount.name })
|
|
let account = existingAccount ?? accountRepository.createAccount(
|
|
name: importedAccount.name,
|
|
currency: importedAccount.currency,
|
|
inputMode: importedAccount.inputMode,
|
|
notificationFrequency: importedAccount.notificationFrequency,
|
|
customFrequencyMonths: importedAccount.customFrequencyMonths
|
|
)
|
|
|
|
if existingAccount == nil {
|
|
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 existingSource = sourceRepository.sources.first(where: {
|
|
$0.name == importedSource.name && $0.account?.id == account.id
|
|
})
|
|
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 {
|
|
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,
|
|
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 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 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]
|
|
}
|
|
}
|