392 lines
11 KiB
Swift
392 lines
11 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
|
|
]
|
|
case .number:
|
|
return [
|
|
.passthrough,
|
|
.integerLookupToNumber
|
|
]
|
|
default:
|
|
return [.passthrough]
|
|
}
|
|
}
|
|
|
|
func formatterDisplayName(_ type: FieldFormatterType) -> String {
|
|
switch type {
|
|
case .passthrough:
|
|
return String(localized: "formatter.option.passthrough")
|
|
case .integerLookupToNumber:
|
|
return String(localized: "formatter.option.integer_lookup")
|
|
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 distinctSourceValues(for field: TemplateField) -> [String] {
|
|
let sourceKey = mappedSourceKey(for: field.id)
|
|
guard !sourceKey.isEmpty else {
|
|
return []
|
|
}
|
|
|
|
let values = entries
|
|
.compactMap { $0.sourceDictionary[sourceKey]?.sourceString() }
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { !$0.isEmpty }
|
|
|
|
let unique = Array(Set(values))
|
|
|
|
return unique.sorted { lhs, rhs in
|
|
if let left = Double(lhs), let right = Double(rhs) {
|
|
return left < right
|
|
}
|
|
return lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending
|
|
}
|
|
}
|
|
|
|
func sourceValueHistogram(for field: TemplateField) -> [(value: String, count: Int)] {
|
|
let sourceKey = mappedSourceKey(for: field.id)
|
|
guard !sourceKey.isEmpty else {
|
|
return []
|
|
}
|
|
|
|
var counts: [String: Int] = [:]
|
|
for entry in entries {
|
|
guard let value = entry.sourceDictionary[sourceKey]?.sourceString() else {
|
|
continue
|
|
}
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else {
|
|
continue
|
|
}
|
|
counts[trimmed, default: 0] += 1
|
|
}
|
|
|
|
return counts
|
|
.map { (key: $0.key, value: $0.value) }
|
|
.sorted { lhs, rhs in
|
|
if let left = Double(lhs.key), let right = Double(rhs.key) {
|
|
return left < right
|
|
}
|
|
return lhs.key.localizedCaseInsensitiveCompare(rhs.key) == .orderedAscending
|
|
}
|
|
.map { (value: $0.key, count: $0.value) }
|
|
}
|
|
|
|
func isValidTargetValue(for field: TemplateField, value: String) -> Bool {
|
|
MoodwellStateOfMindTemplate.validateFormattedTarget(
|
|
fieldID: field.id,
|
|
formatted: value,
|
|
moodValenceScale: moodValenceScale
|
|
)
|
|
}
|
|
|
|
func integerLookupCoverage(for field: TemplateField) -> (mappedDistinct: Int, totalDistinct: Int, mappedRows: Int, totalRows: Int) {
|
|
let histogram = sourceValueHistogram(for: field)
|
|
let totalRows = histogram.reduce(0) { $0 + $1.count }
|
|
let formatter = formatter(for: field.id)
|
|
|
|
guard formatter.formatterType == .integerLookupToNumber else {
|
|
return (0, histogram.count, 0, totalRows)
|
|
}
|
|
|
|
var mappedDistinct = 0
|
|
var mappedRows = 0
|
|
|
|
for item in histogram {
|
|
guard let mapped = formatter.integerLookup[item.value]?.trimmingCharacters(in: .whitespacesAndNewlines), !mapped.isEmpty else {
|
|
continue
|
|
}
|
|
if isValidTargetValue(for: field, value: mapped) {
|
|
mappedDistinct += 1
|
|
mappedRows += item.count
|
|
}
|
|
}
|
|
|
|
return (mappedDistinct, histogram.count, mappedRows, totalRows)
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|