using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using JobTrackerApi.Models; namespace JobTrackerApi.Services; public sealed record CvImportMappedField(string Name, string Value, string SourceBlockId, bool Corrected = false); public sealed record CvImportRecordMapping(string ClientId, string Category, int? TargetIndex, IReadOnlyList Fields); public sealed record CvImportMappingField(string Name, bool Multiple, string? CurrentValue = null); public sealed record CvImportMappingCategory(string Category, IReadOnlyList Fields); public sealed record CvImportMappingTarget(string Category, int Index, string Label, IReadOnlyDictionary? Values = null); // Only these canonical leaves can be authored; source identity and destination paths stay server-owned. public static class CvImportMapping { private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); private static CvImportMappingCategory Category(string name, string scalars, string lists = "") => new(name, scalars.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(f => new CvImportMappingField(f, false)) .Concat(lists.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(f => new CvImportMappingField(f, true))).ToArray()); public static IReadOnlyList Categories { get; } = new[] { Category("contact", "fullName headline email phone location website linkedIn gitHub linkLabel linkUrl"), Category("jobs", "title company location start end isCurrent", "bullets skills"), Category("education", "qualification qualificationLevel institution location start end", "details"), Category("certifications", "name issuer location date", "details"), Category("projects", "name role location start end", "bullets skills"), Category("languages", "name level notes"), Category("otherSections", "title", "items"), Category("summary", "", "value"), Category("skills", "", "value"), Category("interests", "", "value"), Category("awards", "", "value"), Category("publications", "", "value"), Category("organisations", "", "value"), Category("references", "", "value"), }; private static readonly HashSet ScalarLists = new(StringComparer.Ordinal) { "summary", "skills", "interests", "awards", "publications", "organisations", "references" }; private static string Clean(string text) => Regex.Replace(text, @"\s+", " ").Trim(); private static JsonObject Node(StructuredCvProfile profile) => JsonSerializer.SerializeToNode(profile, Json)!.AsObject(); public static IReadOnlyList Catalog(StructuredCvProfile profile) { var node = Node(profile); return Categories.Select(c => c with { Fields = c.Fields.Select(f => f with { CurrentValue = c.Category == "contact" ? f.Name switch { "linkLabel" => string.Join("\n", profile.Contact.Links.Select(l => l.Label)), "linkUrl" => string.Join("\n", profile.Contact.Links.Select(l => l.Url)), _ => node["contact"]?[f.Name]?.ToString(), } : ScalarLists.Contains(c.Category) ? string.Join("\n", node[c.Category]!.AsArray().Select(v => v?.ToString())) : null, }).ToArray() }).ToArray(); } public static IReadOnlyList Targets(StructuredCvProfile profile) { var node = Node(profile); return Categories.Where(c => c.Category != "contact" && !ScalarLists.Contains(c.Category)).SelectMany(c => node[c.Category]!.AsArray().Select((item, index) => new CvImportMappingTarget(c.Category, index, string.Join(" ยท ", new[] { "title", "company", "qualification", "institution", "name", "role", "issuer", "start", "end", "date" } .Select(k => item?[k]?.ToString()).Where(v => !string.IsNullOrWhiteSpace(v))), c.Fields.ToDictionary(f => f.Name, f => f.Multiple ? string.Join("\n", (item?[f.Name] as JsonArray ?? new JsonArray()).Select(v => v?.ToString())) : item?[f.Name]?.ToString(), StringComparer.Ordinal)))).ToArray(); } public static void ValidateTargets(StructuredCvProfile current, IReadOnlyList mappings) { var node = Node(current); if (mappings.Count > 25 || mappings.Any(m => m is null || string.IsNullOrWhiteSpace(m.ClientId) || m.ClientId.Length > 100 || m.ClientId.Any(char.IsControl)) || mappings.Select(m => m.ClientId).Distinct(StringComparer.Ordinal).Count() != mappings.Count) throw new ArgumentException("Invalid or excessive record mappings."); foreach (var mapping in mappings) { if (!Categories.Any(c => c.Category == mapping.Category)) throw new ArgumentException("Unsupported mapping category."); if (mapping.TargetIndex is int index && (mapping.Category == "contact" || ScalarLists.Contains(mapping.Category) || index < 0 || index >= node[mapping.Category]!.AsArray().Count)) throw new ArgumentException("The selected mapping target is unavailable. Refresh the comparison."); } } public static StructuredCvProfile Apply(StructuredCvProfile current, IReadOnlyList mappings, CvImportDocument document, int? artifactId, int extractionRunId, string? sha256) { ValidateTargets(current, mappings); if (mappings.Any(m => m.Fields is null) || mappings.Sum(m => (long)m.Fields.Count) > 200) throw new ArgumentException("Too many mapped fields."); var result = Node(current); var metadata = result["metadata"]!["fields"]!.AsObject(); var scalarWrites = new HashSet(StringComparer.Ordinal); foreach (var mapping in mappings) { var catalog = Categories.Single(c => c.Category == mapping.Category); if (mapping.Fields.Count == 0) throw new ArgumentException("A record mapping must contain reviewed fields."); foreach (var field in mapping.Fields) { if (field is null || !catalog.Fields.Any(f => f.Name == field.Name) || string.IsNullOrWhiteSpace(field.Value) || field.Value.Length > 8000 || field.Value.Any(c => char.IsControl(c) && c is not '\n' and not '\r' and not '\t') || string.IsNullOrWhiteSpace(field.SourceBlockId)) throw new ArgumentException("Invalid mapped field."); var block = document.Blocks.SingleOrDefault(b => b.Id == field.SourceBlockId) ?? throw new ArgumentException("Mapped evidence is not part of this source document."); if (field.Name == "isCurrent" && !bool.TryParse(field.Value, out _)) throw new ArgumentException("isCurrent must be true or false."); if (!field.Corrected && !Regex.IsMatch(Clean(block.Text), @"(? f.Name == field.Name).Multiple && mapping.Fields.Count(f => f.Name == field.Name) != 1) throw new ArgumentException("Duplicate mapped scalar fields."); } var scalarList = ScalarLists.Contains(mapping.Category); if (mapping.TargetIndex is null && mapping.Category != "contact" && !scalarList) { var identity = mapping.Category switch { "jobs" => new[] { "title", "company" }, "education" => new[] { "qualification", "institution" }, "otherSections" => new[] { "title" }, _ => new[] { "name" } }; if (!mapping.Fields.Any(f => identity.Contains(f.Name))) throw new ArgumentException("A new mapped record needs an identifying name, title, company or institution."); } JsonNode target; string prefix; if (mapping.Category == "contact") { target = result["contact"]!; prefix = "contact"; } else if (scalarList) { target = result[mapping.Category]!; prefix = mapping.Category; } else { var records = result[mapping.Category]!.AsArray(); var index = mapping.TargetIndex ?? records.Count; if (index == records.Count) records.Add(new JsonObject()); target = records[index]!; prefix = mapping.Category + "." + index; } if (mapping.Category == "contact" && mapping.Fields.Any(f => f.Name is "linkLabel" or "linkUrl")) { var label = mapping.Fields.SingleOrDefault(f => f.Name == "linkLabel"); var url = mapping.Fields.SingleOrDefault(f => f.Name == "linkUrl"); if (label is null || url is null || !Uri.TryCreate(url.Value.Trim(), UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) throw new ArgumentException("A mapped contact link needs a label and an HTTP(S) URL."); var links = target["links"]!.AsArray(); if (links.Any(l => string.Equals(l?["url"]?.ToString(), url.Value.Trim(), StringComparison.OrdinalIgnoreCase)) || links.Count >= 12) throw new ArgumentException("The mapped contact link is duplicated or exceeds the supported limit."); var linkIndex = links.Count; links.Add(new JsonObject { ["label"] = label.Value.Trim(), ["url"] = url.Value.Trim() }); Evidence($"contact.links.{linkIndex}.label", label); Evidence($"contact.links.{linkIndex}.url", url); } foreach (var field in mapping.Fields.Where(f => f.Name is not ("linkLabel" or "linkUrl"))) { var multiple = catalog.Fields.Single(f => f.Name == field.Name).Multiple; var path = prefix + (scalarList ? "" : "." + field.Name); if (multiple) { JsonArray list; if (scalarList) list = target.AsArray(); else { target[field.Name] ??= new JsonArray(); list = target[field.Name]!.AsArray(); } var index = Enumerable.Range(0, list.Count).FirstOrDefault(i => string.Equals(list[i]?.ToString(), field.Value.Trim(), StringComparison.OrdinalIgnoreCase), -1); // An already present list value keeps its original evidence. if (index >= 0) continue; index = list.Count; list.Add(field.Value.Trim()); path += "." + index; } else { if (!scalarWrites.Add(path)) throw new ArgumentException("Multiple mappings cannot overwrite the same scalar field."); target[field.Name] = field.Name == "isCurrent" ? JsonValue.Create(bool.Parse(field.Value)) : JsonValue.Create(field.Value.Trim()); } Evidence(path, field); } } var profile = result.Deserialize(Json)!; var linkCount = profile.Contact.Links.Count; profile = StructuredCvProfileJson.NormalizeForPersistence(profile); if (profile.Contact.Links.Count != linkCount) throw new ArgumentException("A mapped contact link is invalid."); return profile; void Evidence(string path, CvImportMappedField field) { var block = document.Blocks.Single(b => b.Id == field.SourceBlockId); metadata[path] = JsonSerializer.SerializeToNode(new StructuredCvFieldMetadata { Method = field.Corrected ? "user-correction" : "user-source-mapping", ReviewState = field.Corrected ? "corrected" : "accepted", SourceSnippet = field.Corrected ? block.Text[..Math.Min(500, block.Text.Length)] : field.Value.Trim()[..Math.Min(500, field.Value.Trim().Length)], SourceBlockId = block.Id, SourcePage = block.Page, SourceArtifactId = artifactId, SourceExtractionRunId = extractionRunId, SourceFileSha256 = sha256, LastUpdatedAtUtc = DateTimeOffset.UtcNow, }, Json); } } }