import Foundation import SwiftUI import UniformTypeIdentifiers @MainActor final class ImportFlowViewModel: ObservableObject { enum FieldMappingState { case incomplete case partiallyValid case valid } @Published var template = MoodwellStateOfMindTemplate.template @Published var entries: [MoodwellEntry] = [] @Published var sourceKeys: [String] = [] @Published var mapping = MoodwellStateOfMindTemplate.defaultMapping @Published var fieldFormatters: [String: FieldFormatterConfig] = [:] @Published var moodValenceScale: [Int: Double] = [1: -0.8, 2: -0.25, 3: 0.3, 4: 0.8] @Published var dryRunResult: DryRunResult? @Published var isImporting = false @Published var statusMessage = "" @Published var errorMessage: String? let supportedContentTypes: [UTType] = [.json] private let healthKitService = HealthKitService() init() { ensureFormatterDefaults() loadBundledSample() } func loadBundledSample() { guard let url = Bundle.main.url(forResource: "MoodwellData", withExtension: "json") else { errorMessage = String(localized: "error.sample.missing") return } loadFile(from: url) } func loadFile(from url: URL) { do { let data = try Data(contentsOf: url) let payload = try JSONDecoder().decode(MoodwellPayload.self, from: data) entries = payload.mymoods if let firstEntry = payload.mymoods.first { sourceKeys = Array(firstEntry.sourceDictionary.keys).sorted() } else { sourceKeys = [] } ensureFormatterDefaults() dryRunResult = nil statusMessage = String.localizedStringWithFormat( NSLocalizedString("status.loaded", comment: ""), entries.count ) errorMessage = nil } catch { errorMessage = String(localized: "error.load.file") + " \(error.localizedDescription)" } } func runDryRun() { let result = MoodwellStateOfMindTemplate.makeDrafts( entries: entries, mapping: mapping, moodValenceScale: moodValenceScale, fieldFormatters: fieldFormatters ) dryRunResult = result if result.errors.isEmpty { statusMessage = String.localizedStringWithFormat( NSLocalizedString("status.dryrun.success", comment: ""), result.drafts.count ) } else { statusMessage = String.localizedStringWithFormat( NSLocalizedString("status.dryrun.withErrors", comment: ""), result.errors.count ) } } func commitImport() async { guard let dryRunResult else { errorMessage = String(localized: "error.dryrun.required") return } guard dryRunResult.errors.isEmpty else { errorMessage = String(localized: "error.dryrun.fix") return } isImporting = true defer { isImporting = false } do { try await healthKitService.requestWriteAccessForStateOfMind() try await healthKitService.save(dryRunResult.drafts) statusMessage = String.localizedStringWithFormat( NSLocalizedString("status.import.success", comment: ""), dryRunResult.drafts.count ) errorMessage = nil } catch { errorMessage = String(localized: "error.import.failed") + " \(error.localizedDescription)" } } func sourceExamples(for field: TemplateField, limit: Int = 3) -> [String] { let sourceKey = mapping.sourceKey(for: field.id) guard !sourceKey.isEmpty else { return [] } return entries .compactMap { $0.sourceDictionary[sourceKey]?.displayString() } .filter { !$0.isEmpty } .prefix(limit) .map { $0 } } func formattingPreview(for field: TemplateField) -> FieldFormattingPreview { let sourceKey = mapping.sourceKey(for: field.id) guard !sourceKey.isEmpty else { return FieldFormattingPreview(matched: 0, total: 0, examples: []) } let formatter = formatter(for: field.id) var matched = 0 var total = 0 var examples: [String] = [] for entry in entries { guard let raw = entry.sourceDictionary[sourceKey], raw != .null else { continue } guard let rawString = raw.sourceString() else { continue } total += 1 if let transformed = ImportFieldFormatterEngine.apply(sourceText: rawString, config: formatter), MoodwellStateOfMindTemplate.validateFormattedTarget( fieldID: field.id, formatted: transformed, moodValenceScale: moodValenceScale ) { matched += 1 if examples.count < 3 { examples.append("\(rawString) → \(transformed)") } } } return FieldFormattingPreview(matched: matched, total: total, examples: examples) } func formatter(for fieldID: String) -> FieldFormatterConfig { if let formatter = fieldFormatters[fieldID] { return formatter } guard let field = template.fields.first(where: { $0.id == fieldID }) else { return .default(for: .string) } return .default(for: field.type) } func updateFormatter(for fieldID: String, _ update: (inout FieldFormatterConfig) -> Void) { var config = formatter(for: fieldID) update(&config) fieldFormatters[fieldID] = config } private func ensureFormatterDefaults() { for field in template.fields { if fieldFormatters[field.id] == nil { fieldFormatters[field.id] = .default(for: field.type) } } } func mappedSourceKey(for fieldID: String) -> String { mapping.sourceKey(for: fieldID) } func formatterOptions(for field: TemplateField) -> [FieldFormatterType] { switch field.type { case .date: return [ .dateISO8601ToISO8601, .dateGermanDayMonthYearToISO8601, .dateGermanDayMonthYearHourMinuteToISO8601, .dateUnixSecondsToISO8601 ] default: return [.passthrough] } } func formatterDisplayName(_ type: FieldFormatterType) -> String { switch type { case .passthrough: return String(localized: "formatter.option.passthrough") case .dateISO8601ToISO8601: return String(localized: "formatter.option.date_iso") case .dateGermanDayMonthYearToISO8601: return String(localized: "formatter.option.date_ddmmyyyy") case .dateGermanDayMonthYearHourMinuteToISO8601: return String(localized: "formatter.option.date_ddmmyyyy_hhmm") case .dateUnixSecondsToISO8601: return String(localized: "formatter.option.date_unix_seconds") } } func fieldState(for field: TemplateField) -> FieldMappingState { let sourceKey = mappedSourceKey(for: field.id) guard !sourceKey.isEmpty else { return .incomplete } let preview = formattingPreview(for: field) guard preview.total > 0 else { return .incomplete } return preview.matched == preview.total ? .valid : .partiallyValid } func fieldStateText(for field: TemplateField) -> String { switch fieldState(for: field) { case .incomplete: return String(localized: "state.incomplete") case .partiallyValid: return String(localized: "state.partial") case .valid: return String(localized: "state.valid") } } func previewRows(for field: TemplateField, limit: Int = 5) -> [(input: String, output: String, valid: Bool)] { previewRows(for: field, formatterType: formatter(for: field.id).formatterType, limit: limit) } func previewRows(for field: TemplateField, formatterType: FieldFormatterType, limit: Int = 5) -> [(input: String, output: String, valid: Bool)] { let sourceKey = mappedSourceKey(for: field.id) guard !sourceKey.isEmpty else { return [] } let formatter = FieldFormatterConfig(formatterType: formatterType) var rows: [(String, String, Bool)] = [] for entry in entries { guard let raw = entry.sourceDictionary[sourceKey], raw != .null, let input = raw.sourceString() else { continue } let output = ImportFieldFormatterEngine.apply(sourceText: input, config: formatter) ?? "" let valid = !output.isEmpty && MoodwellStateOfMindTemplate.validateFormattedTarget( fieldID: field.id, formatted: output, moodValenceScale: moodValenceScale ) rows.append((input, output, valid)) if rows.count >= limit { break } } return rows } func formatterMatchSummary(for field: TemplateField, formatterType: FieldFormatterType, limit: Int = 5) -> (matched: Int, total: Int) { let rows = previewRows(for: field, formatterType: formatterType, limit: limit) let matched = rows.filter { $0.valid }.count return (matched, rows.count) } func mappingSummary() -> (validFields: Int, totalFields: Int, validRows: Int, totalRows: Int) { var validFields = 0 var validRows = 0 var totalRows = 0 for field in template.fields { let preview = formattingPreview(for: field) if preview.total > 0 && preview.matched == preview.total { validFields += 1 } validRows += preview.matched totalRows += preview.total } return (validFields, template.fields.count, validRows, totalRows) } func sourceExample(for sourceKey: String) -> String { for entry in entries { if let value = entry.sourceDictionary[sourceKey]?.sourceString(), !value.isEmpty { return value } } return String(localized: "text.no_source_values") } }