Files
bulkhealth/BulkHealthApp/Import/ImportFlowViewModel.swift

302 lines
8.6 KiB
Swift

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")
}
}