457 lines
13 KiB
Swift
457 lines
13 KiB
Swift
import SwiftUI
|
|
|
|
struct FieldMappingListView: View {
|
|
@ObservedObject var viewModel: ImportFlowViewModel
|
|
@State private var fieldStates: [String: ImportFlowViewModel.FieldMappingState] = [:]
|
|
|
|
var body: some View {
|
|
List {
|
|
ForEach(viewModel.template.fields) { field in
|
|
NavigationLink {
|
|
FieldMappingDetailView(viewModel: viewModel, field: field)
|
|
} label: {
|
|
let state = fieldStates[field.id] ?? .incomplete
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(field.title)
|
|
.font(.headline)
|
|
Text(field.targetFormat)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Text(fieldStateText(state))
|
|
.font(.caption2)
|
|
.foregroundStyle(color(for: state))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "screen.title.field_mapping"))
|
|
.onAppear {
|
|
refreshFieldStates()
|
|
}
|
|
.onChange(of: viewModel.entries.count) { _, _ in
|
|
refreshFieldStates()
|
|
}
|
|
.onChange(of: viewModel.mapping) { _, _ in
|
|
refreshFieldStates()
|
|
}
|
|
.onChange(of: viewModel.fieldFormatters) { _, _ in
|
|
refreshFieldStates()
|
|
}
|
|
.onChange(of: viewModel.moodValenceScale) { _, _ in
|
|
refreshFieldStates()
|
|
}
|
|
}
|
|
|
|
private func color(for state: ImportFlowViewModel.FieldMappingState) -> Color {
|
|
switch state {
|
|
case .incomplete: return .secondary
|
|
case .partiallyValid: return .yellow
|
|
case .valid: return .green
|
|
}
|
|
}
|
|
|
|
private func fieldStateText(_ state: ImportFlowViewModel.FieldMappingState) -> String {
|
|
switch state {
|
|
case .incomplete:
|
|
return String(localized: "state.incomplete")
|
|
case .partiallyValid:
|
|
return String(localized: "state.partial")
|
|
case .valid:
|
|
return String(localized: "state.valid")
|
|
}
|
|
}
|
|
|
|
private func refreshFieldStates() {
|
|
var next: [String: ImportFlowViewModel.FieldMappingState] = [:]
|
|
for field in viewModel.template.fields {
|
|
next[field.id] = viewModel.fieldState(for: field)
|
|
}
|
|
fieldStates = next
|
|
}
|
|
}
|
|
|
|
struct FieldMappingDetailView: View {
|
|
@ObservedObject var viewModel: ImportFlowViewModel
|
|
let field: TemplateField
|
|
|
|
var body: some View {
|
|
Form {
|
|
targetSection
|
|
sourceSection
|
|
formatterSection
|
|
}
|
|
.navigationTitle(field.title)
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
|
|
private var targetSection: some View {
|
|
Section(
|
|
header: Text(String(localized: "section.target")),
|
|
footer: Text(field.id == "date" ? String(localized: "field.date.helper") : "")
|
|
) {
|
|
Text(
|
|
field.targetExplanation + " " + String.localizedStringWithFormat(
|
|
NSLocalizedString("label.example_value", comment: ""),
|
|
field.targetExample
|
|
)
|
|
)
|
|
if let allowed = field.targetAllowedValues, !allowed.isEmpty {
|
|
Text(allowed)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var sourceSection: some View {
|
|
Section(header: Text(String(localized: "section.mapping"))) {
|
|
if viewModel.sourceKeys.isEmpty {
|
|
Text(String(localized: "text.mapping.unavailable"))
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(viewModel.sourceKeys, id: \.self) { sourceKey in
|
|
Button {
|
|
viewModel.mapping.sourceKeyByFieldID[field.id] = sourceKey
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack {
|
|
Text(sourceKey)
|
|
Spacer()
|
|
if viewModel.mappedSourceKey(for: field.id) == sourceKey {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(.green)
|
|
}
|
|
}
|
|
Text(viewModel.sourceExample(for: sourceKey))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var formatterSection: some View {
|
|
Section(header: Text(String(localized: "section.formatting"))) {
|
|
NavigationLink(String(localized: "button.select_formatter")) {
|
|
FormatterSelectionView(viewModel: viewModel, field: field)
|
|
}
|
|
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.formatter_coverage", comment: ""),
|
|
viewModel.formattingPreview(for: field).matched,
|
|
viewModel.formattingPreview(for: field).total
|
|
))
|
|
.foregroundStyle(viewModel.formattingPreview(for: field).isPartial ? .yellow : .secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
struct FormatterSelectionView: View {
|
|
@ObservedObject var viewModel: ImportFlowViewModel
|
|
let field: TemplateField
|
|
@State private var lookupDraft: [String: String] = [:]
|
|
@State private var matchSummaryCache: [FieldFormatterType: (matched: Int, total: Int)] = [:]
|
|
|
|
var body: some View {
|
|
let formatterOptions = viewModel.formatterOptions(for: field)
|
|
let selectedFormatter = viewModel.formatter(for: field.id)
|
|
let selectedFormatterType = selectedFormatter.formatterType
|
|
let previewRows = selectedFormatterType == .integerLookupToNumber ? [] : viewModel.previewRows(for: field, limit: 5)
|
|
let previewMatchedCount = previewRows.filter { $0.valid }.count
|
|
let previewCoverageColor: Color = previewRows.isEmpty ? .secondary : (previewMatchedCount == previewRows.count ? .green : .yellow)
|
|
let sourceHistogram = selectedFormatterType == .integerLookupToNumber ? viewModel.sourceValueHistogram(for: field) : []
|
|
let lookupCoverage = lookupCoverage(from: sourceHistogram)
|
|
|
|
Form {
|
|
Section(header: Text(String(localized: "section.formatting"))) {
|
|
ForEach(formatterOptions) { formatterType in
|
|
Button {
|
|
if selectedFormatterType == .integerLookupToNumber {
|
|
syncLookupDraftToModel()
|
|
}
|
|
viewModel.updateFormatter(for: field.id) { $0.formatterType = formatterType }
|
|
if formatterType == .integerLookupToNumber {
|
|
loadLookupDraftFromModel()
|
|
}
|
|
} label: {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(viewModel.formatterDisplayName(formatterType))
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.formatter_matches", comment: ""),
|
|
matchSummaryCache[formatterType]?.matched ?? 0,
|
|
matchSummaryCache[formatterType]?.total ?? 0
|
|
))
|
|
.font(.caption2)
|
|
.foregroundStyle(matchColor(for: formatterType, summary: matchSummaryCache))
|
|
}
|
|
Spacer()
|
|
if selectedFormatterType == formatterType {
|
|
Image(systemName: "checkmark")
|
|
.foregroundStyle(.green)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
if selectedFormatterType == .integerLookupToNumber {
|
|
Section(
|
|
header: Text(String(localized: "section.value_mapping")),
|
|
footer: Text(String(localized: "text.value_mapping_footer"))
|
|
) {
|
|
if sourceHistogram.isEmpty {
|
|
Text(String(localized: "text.no_source_values"))
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(sourceHistogram, id: \.value) { item in
|
|
HStack(spacing: 12) {
|
|
Text(item.value)
|
|
.frame(width: 44, alignment: .leading)
|
|
Text("x\(item.count)")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Spacer()
|
|
TextField(
|
|
String(localized: "placeholder.target_number"),
|
|
text: bindingForMappedValue(item.value)
|
|
)
|
|
.multilineTextAlignment(.trailing)
|
|
.keyboardType(.default)
|
|
.frame(maxWidth: 120)
|
|
statusIcon(for: item.value)
|
|
}
|
|
}
|
|
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.lookup_distinct_progress", comment: ""),
|
|
lookupCoverage.mappedDistinct,
|
|
lookupCoverage.totalDistinct
|
|
))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.lookup_rows_progress", comment: ""),
|
|
lookupCoverage.mappedRows,
|
|
lookupCoverage.totalRows
|
|
))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
} else {
|
|
Section(header: Text(String(localized: "section.test_area"))) {
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.formatter_matches", comment: ""),
|
|
previewMatchedCount,
|
|
previewRows.count
|
|
))
|
|
.font(.caption)
|
|
.foregroundStyle(previewCoverageColor)
|
|
|
|
if previewRows.isEmpty {
|
|
Text(String(localized: "text.no_source_values"))
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(Array(previewRows.enumerated()), id: \.offset) { _, row in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(row.input)
|
|
.font(.caption)
|
|
Text(row.output.isEmpty ? String(localized: "text.formatter_empty") : row.output)
|
|
.font(.caption2)
|
|
.foregroundStyle(row.valid ? .green : .red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "screen.title.formatter"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.onAppear {
|
|
refreshMatchSummary()
|
|
if selectedFormatterType == .integerLookupToNumber {
|
|
loadLookupDraftFromModel()
|
|
}
|
|
}
|
|
.onDisappear {
|
|
syncLookupDraftToModel()
|
|
}
|
|
}
|
|
|
|
private func matchColor(
|
|
for formatterType: FieldFormatterType,
|
|
summary: [FieldFormatterType: (matched: Int, total: Int)]
|
|
) -> Color {
|
|
let item = summary[formatterType] ?? (0, 0)
|
|
return item.matched == item.total && item.total > 0 ? .green : .secondary
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func statusIcon(for sourceValue: String) -> some View {
|
|
let mapped = lookupDraft[sourceValue]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
if mapped.isEmpty {
|
|
Image(systemName: "circle")
|
|
.foregroundStyle(.secondary)
|
|
} else if viewModel.isValidTargetValue(for: field, value: mapped) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(.green)
|
|
} else {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.foregroundStyle(.yellow)
|
|
}
|
|
}
|
|
|
|
private func bindingForMappedValue(_ sourceValue: String) -> Binding<String> {
|
|
Binding(
|
|
get: {
|
|
lookupDraft[sourceValue, default: ""]
|
|
},
|
|
set: { newValue in
|
|
lookupDraft[sourceValue] = newValue
|
|
}
|
|
)
|
|
}
|
|
|
|
private func loadLookupDraftFromModel() {
|
|
lookupDraft = viewModel.formatter(for: field.id).integerLookup
|
|
}
|
|
|
|
private func syncLookupDraftToModel() {
|
|
viewModel.updateFormatter(for: field.id) { config in
|
|
var cleaned: [String: String] = [:]
|
|
for (key, value) in lookupDraft {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
if !trimmed.isEmpty {
|
|
cleaned[key] = trimmed
|
|
}
|
|
}
|
|
config.integerLookup = cleaned
|
|
}
|
|
refreshMatchSummary()
|
|
}
|
|
|
|
private func lookupCoverage(from histogram: [(value: String, count: Int)]) -> (mappedDistinct: Int, totalDistinct: Int, mappedRows: Int, totalRows: Int) {
|
|
let totalRows = histogram.reduce(0) { $0 + $1.count }
|
|
guard !histogram.isEmpty else {
|
|
return (0, 0, 0, 0)
|
|
}
|
|
|
|
var mappedDistinct = 0
|
|
var mappedRows = 0
|
|
|
|
for item in histogram {
|
|
let mapped = lookupDraft[item.value]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !mapped.isEmpty else {
|
|
continue
|
|
}
|
|
if viewModel.isValidTargetValue(for: field, value: mapped) {
|
|
mappedDistinct += 1
|
|
mappedRows += item.count
|
|
}
|
|
}
|
|
|
|
return (mappedDistinct, histogram.count, mappedRows, totalRows)
|
|
}
|
|
|
|
private func refreshMatchSummary() {
|
|
var cache: [FieldFormatterType: (matched: Int, total: Int)] = [:]
|
|
for formatterType in viewModel.formatterOptions(for: field) {
|
|
cache[formatterType] = viewModel.formatterMatchSummary(for: field, formatterType: formatterType)
|
|
}
|
|
matchSummaryCache = cache
|
|
}
|
|
}
|
|
|
|
struct ImportPlanView: View {
|
|
@ObservedObject var viewModel: ImportFlowViewModel
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section(String(localized: "section.plan_summary")) {
|
|
let summary = viewModel.mappingSummary()
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.fields_ready", comment: ""),
|
|
summary.validFields,
|
|
summary.totalFields
|
|
))
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.rows_ready", comment: ""),
|
|
summary.validRows,
|
|
summary.totalRows
|
|
))
|
|
}
|
|
|
|
Section(String(localized: "section.template")) {
|
|
ForEach(viewModel.template.fields) { field in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(field.title)
|
|
.font(.headline)
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.source_for_field", comment: ""),
|
|
viewModel.mappedSourceKey(for: field.id)
|
|
))
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section(String(localized: "section.commit")) {
|
|
Button(String(localized: "button.run_dry_run")) {
|
|
viewModel.runDryRun()
|
|
}
|
|
|
|
if let result = viewModel.dryRunResult {
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.preview_count", comment: ""),
|
|
result.drafts.count
|
|
))
|
|
if !result.errors.isEmpty {
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.errors_count", comment: ""),
|
|
result.errors.count
|
|
))
|
|
.foregroundStyle(.red)
|
|
}
|
|
if !result.warnings.isEmpty {
|
|
Text(String.localizedStringWithFormat(
|
|
NSLocalizedString("label.warnings_count", comment: ""),
|
|
result.warnings.count
|
|
))
|
|
.foregroundStyle(.orange)
|
|
}
|
|
}
|
|
|
|
Button {
|
|
Task {
|
|
await viewModel.commitImport()
|
|
}
|
|
} label: {
|
|
if viewModel.isImporting {
|
|
ProgressView()
|
|
} else {
|
|
Text(String(localized: "button.commit"))
|
|
}
|
|
}
|
|
.disabled(viewModel.isImporting)
|
|
}
|
|
}
|
|
.navigationTitle(String(localized: "screen.title.import_plan"))
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
}
|