initial version
This commit is contained in:
@@ -0,0 +1,678 @@
|
||||
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]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user