302 lines
8.6 KiB
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")
|
|
}
|
|
}
|