refine field mapping ux and fix iso8601 fractional-second parsing
This commit is contained in:
@@ -3,11 +3,18 @@ import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@MainActor
|
||||
final class ImportFlowViewModel: ObservableObject {
|
||||
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
|
||||
@@ -19,6 +26,7 @@ import UniformTypeIdentifiers
|
||||
private let healthKitService = HealthKitService()
|
||||
|
||||
init() {
|
||||
ensureFormatterDefaults()
|
||||
loadBundledSample()
|
||||
}
|
||||
|
||||
@@ -30,8 +38,8 @@ import UniformTypeIdentifiers
|
||||
loadFile(from: url)
|
||||
}
|
||||
|
||||
func loadFile(from url: URL) {
|
||||
do {
|
||||
func loadFile(from url: URL) {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
let payload = try JSONDecoder().decode(MoodwellPayload.self, from: data)
|
||||
entries = payload.mymoods
|
||||
@@ -39,33 +47,39 @@ import UniformTypeIdentifiers
|
||||
sourceKeys = Array(firstEntry.sourceDictionary.keys).sorted()
|
||||
} else {
|
||||
sourceKeys = []
|
||||
}
|
||||
dryRunResult = nil
|
||||
statusMessage = String.localizedStringWithFormat(
|
||||
NSLocalizedString("status.loaded", comment: ""),
|
||||
entries.count
|
||||
)
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = String(localized: "error.load.file") + " \(error.localizedDescription)"
|
||||
}
|
||||
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)
|
||||
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 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 {
|
||||
@@ -80,16 +94,208 @@ import UniformTypeIdentifiers
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user