using TXTextControl; namespace TxFormFieldMapper; internal sealed class FormFieldMapper(IReadOnlyList jsonFieldNames, double minimumScore) { private const double AmbiguityMargin = 0.08; private readonly IReadOnlyList _jsonCandidates = jsonFieldNames .Distinct(StringComparer.OrdinalIgnoreCase) .Select(FieldNameCandidate.Create) .ToList(); public IReadOnlyList RenameFormFields(FormFieldCollection formFields) { var results = new List(); var cachedMatches = new Dictionary(StringComparer.OrdinalIgnoreCase); for (var i = 1; i <= formFields.Count; i++) { var field = formFields[i]; if (field is null) { continue; } var originalName = field.Name ?? string.Empty; if (!cachedMatches.TryGetValue(originalName, out var decision)) { decision = FindBestMatch(originalName); cachedMatches[originalName] = decision; } if (decision.ShouldRename && !string.Equals(field.Name, decision.JsonName, StringComparison.Ordinal)) { field.Name = decision.JsonName; } results.Add(new MapResult(originalName, decision.JsonName, decision.Score, decision.Status)); } return results; } private MatchDecision FindBestMatch(string formFieldName) { if (string.IsNullOrWhiteSpace(formFieldName)) { return MatchDecision.Skipped(formFieldName, "skipped: empty field name"); } var source = FieldNameCandidate.Create(formFieldName); var scored = _jsonCandidates .Select(candidate => new { Candidate = candidate, Score = FieldNameScorer.Score(source, candidate) }) .OrderByDescending(item => item.Score) .ToList(); if (scored.Count == 0) { return MatchDecision.Skipped(formFieldName, "skipped: no unused JSON fields left"); } var best = scored[0]; var secondScore = scored.Count > 1 ? scored[1].Score : 0; if (best.Score < minimumScore) { return MatchDecision.Skipped(best.Candidate.Original, "skipped: below threshold"); } if (best.Score < 1 && best.Score - secondScore < AmbiguityMargin) { return MatchDecision.Skipped(best.Candidate.Original, "skipped: ambiguous"); } var status = string.Equals(formFieldName, best.Candidate.Original, StringComparison.Ordinal) ? "already mapped" : "renamed"; return new MatchDecision(best.Candidate.Original, best.Score, status, ShouldRename: true); } }