165 lines
12 KiB
C#
165 lines
12 KiB
C#
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<CvImportMappedField> Fields);
|
|
public sealed record CvImportMappingField(string Name, bool Multiple, string? CurrentValue = null);
|
|
public sealed record CvImportMappingCategory(string Category, IReadOnlyList<CvImportMappingField> Fields);
|
|
public sealed record CvImportMappingTarget(string Category, int Index, string Label, IReadOnlyDictionary<string, string?>? 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<CvImportMappingCategory> 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<string> 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<CvImportMappingCategory> 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<CvImportMappingTarget> 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<CvImportRecordMapping> 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<CvImportRecordMapping> 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<string>(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), @"(?<![\p{L}\p{N}\p{M}_])" + Regex.Escape(Clean(field.Value)) + @"(?![\p{L}\p{N}\p{M}_])", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)) throw new ArgumentException("A mapped value must appear as complete tokens in its selected evidence block or be explicitly corrected.");
|
|
if (!catalog.Fields.Single(f => 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<StructuredCvProfile>(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);
|
|
}
|
|
}
|
|
}
|