337 lines
16 KiB
C#
337 lines
16 KiB
C#
using System.Globalization;
|
||
using JobTrackerApi.Models;
|
||
|
||
namespace JobTrackerApi.Services;
|
||
|
||
// The resolved, presentation-ready shape a theme renders. Built by CvVariantResolver from the master
|
||
// StructuredCvProfile + a variant's CvVariantSettings. This is a projection — it owns no data.
|
||
public sealed class CvRenderModel
|
||
{
|
||
public string FullName { get; set; } = string.Empty;
|
||
public string? Headline { get; set; }
|
||
public string? PhotoDataUrl { get; set; }
|
||
public List<CvContactItem> Contact { get; set; } = new();
|
||
public List<CvRenderSection> Sections { get; set; } = new();
|
||
}
|
||
|
||
public sealed class CvContactItem
|
||
{
|
||
public string Icon { get; set; } = string.Empty; // "email" | "phone" | "location" | "web" | "linkedin"
|
||
public string Value { get; set; } = string.Empty;
|
||
public string? Href { get; set; }
|
||
}
|
||
|
||
public sealed class CvRenderSection
|
||
{
|
||
public string Key { get; set; } = string.Empty;
|
||
public string Title { get; set; } = string.Empty;
|
||
// bullets | tags | entries
|
||
public string Kind { get; set; } = "entries";
|
||
public List<string> Bullets { get; set; } = new();
|
||
public List<string> Tags { get; set; } = new();
|
||
public List<CvRenderEntry> Entries { get; set; } = new();
|
||
|
||
public bool IsEmpty => Bullets.Count == 0 && Tags.Count == 0 && Entries.Count == 0;
|
||
}
|
||
|
||
public sealed class CvRenderEntry
|
||
{
|
||
public string? Key { get; set; } // master ItemKey, for override + reorder targeting
|
||
public string? Title { get; set; }
|
||
public string? Subtitle { get; set; }
|
||
public string? Meta { get; set; }
|
||
public List<string> Bullets { get; set; } = new();
|
||
public List<string> Tags { get; set; } = new();
|
||
}
|
||
|
||
// Turns the master profile + a variant lens into a CvRenderModel. All section order/visibility and
|
||
// per-item overrides are applied here; the renderer only draws. Overrides are keyed by ItemKey so
|
||
// the master profile is referenced, never copied.
|
||
public static class CvVariantResolver
|
||
{
|
||
private static readonly string[] DefaultOrder =
|
||
{
|
||
"summary", "experience", "education", "projects", "skills", "certifications", "languages", "interests",
|
||
};
|
||
|
||
public static CvRenderModel Build(StructuredCvProfile profile, CvVariantSettings settings, string fallbackName, string? photoDataUrl)
|
||
{
|
||
settings = CvVariantSettingsJson.Normalize(settings);
|
||
var model = new CvRenderModel
|
||
{
|
||
FullName = Trim(profile.Contact.FullName) ?? fallbackName,
|
||
Headline = Trim(settings.Headline) ?? Trim(profile.Contact.Headline),
|
||
PhotoDataUrl = settings.ShowPhoto ? Trim(photoDataUrl) : null,
|
||
};
|
||
|
||
AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}");
|
||
AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}");
|
||
AddContact(model.Contact, "location", profile.Contact.Location, null);
|
||
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
|
||
AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl);
|
||
AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl);
|
||
foreach (var link in profile.Contact.Links)
|
||
{
|
||
var url = Trim(link.Url);
|
||
if (url is null) continue;
|
||
model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) });
|
||
}
|
||
|
||
var built = new Dictionary<string, CvRenderSection>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["summary"] = BulletSection("summary", "Professional Summary", profile.Summary),
|
||
["skills"] = TagSection("skills", "Skills", profile.Skills),
|
||
["experience"] = ExperienceSection(profile, settings),
|
||
["education"] = EducationSection(profile, settings),
|
||
["projects"] = ProjectSection(profile, settings),
|
||
["certifications"] = CertificationSection(profile, settings),
|
||
["languages"] = LanguageSection(profile),
|
||
["interests"] = TagSection("interests", "Interests", profile.Interests),
|
||
["awards"] = BulletSection("awards", "Awards", profile.Awards),
|
||
["publications"] = BulletSection("publications", "Publications", profile.Publications),
|
||
["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations),
|
||
["references"] = BulletSection("references", "References", profile.References),
|
||
};
|
||
ApplyLanguageLabels(built, settings.Language);
|
||
|
||
// OtherSections from the master profile become body sections keyed other:<n>.
|
||
for (var i = 0; i < profile.OtherSections.Count; i++)
|
||
{
|
||
var other = profile.OtherSections[i];
|
||
var key = $"other:{i}";
|
||
built[key] = new CvRenderSection { Key = key, Title = Trim(other.Title) ?? "Additional", Kind = "bullets", Bullets = Clean(other.Items) };
|
||
}
|
||
|
||
// Custom sections use the same order list as profile-backed sections. Older variants that do
|
||
// not yet contain custom:<key> rows still append them in their stored custom-section order.
|
||
var customHiddenByKey = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var custom in settings.CustomSections)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(custom.Key)) continue;
|
||
var key = $"custom:{custom.Key}";
|
||
customHiddenByKey[key] = custom.Hidden;
|
||
built[key] = new CvRenderSection
|
||
{
|
||
Key = key,
|
||
Title = Trim(custom.Title) ?? "Additional",
|
||
Kind = "bullets",
|
||
Bullets = Clean(custom.Items),
|
||
};
|
||
}
|
||
|
||
// Determine order + visibility from settings, falling back to the default order then any
|
||
// extras. Tolerate malformed/legacy duplicate keys instead of failing the whole render.
|
||
var settingByKey = new Dictionary<string, CvSectionSetting>(StringComparer.OrdinalIgnoreCase);
|
||
var ordered = new List<string>();
|
||
foreach (var section in settings.Sections)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(section.Key)) continue;
|
||
settingByKey[section.Key] = section;
|
||
if (!ordered.Contains(section.Key, StringComparer.OrdinalIgnoreCase)) ordered.Add(section.Key);
|
||
}
|
||
if (ordered.Count == 0) ordered.AddRange(DefaultOrder);
|
||
foreach (var key in built.Keys)
|
||
{
|
||
if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key);
|
||
}
|
||
|
||
foreach (var key in ordered)
|
||
{
|
||
if (!built.TryGetValue(key, out var section)) continue;
|
||
if (settingByKey.TryGetValue(key, out var cfg))
|
||
{
|
||
if (cfg.Hidden) continue;
|
||
if (!string.IsNullOrWhiteSpace(cfg.Title)) section.Title = cfg.Title!.Trim();
|
||
if (cfg.ItemOrder is { Count: > 0 } && section.Entries.Count > 1)
|
||
{
|
||
section.Entries = ReorderByKey(section.Entries, cfg.ItemOrder);
|
||
}
|
||
}
|
||
else if (customHiddenByKey.TryGetValue(key, out var customHidden) && customHidden)
|
||
{
|
||
continue;
|
||
}
|
||
if (!section.IsEmpty) model.Sections.Add(section);
|
||
}
|
||
|
||
return model;
|
||
}
|
||
|
||
private static CvRenderSection ExperienceSection(StructuredCvProfile profile, CvVariantSettings settings)
|
||
{
|
||
var section = new CvRenderSection { Key = "experience", Title = "Professional Experience", Kind = "entries" };
|
||
foreach (var job in profile.Jobs)
|
||
{
|
||
var ov = OverrideFor(settings, job.Id);
|
||
if (ov?.Hidden == true) continue;
|
||
section.Entries.Add(new CvRenderEntry
|
||
{
|
||
Key = job.Id,
|
||
Title = Trim(ov?.Title) ?? Trim(job.Title),
|
||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(job.Company, job.Location),
|
||
Meta = DateRange(job.StartDate ?? job.Start, job.EndDate ?? job.End, job.IsCurrent, settings),
|
||
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(job.Bullets),
|
||
Tags = Clean(job.Skills),
|
||
});
|
||
}
|
||
return section;
|
||
}
|
||
|
||
private static CvRenderSection EducationSection(StructuredCvProfile profile, CvVariantSettings settings)
|
||
{
|
||
var section = new CvRenderSection { Key = "education", Title = "Education", Kind = "entries" };
|
||
foreach (var ed in profile.Education)
|
||
{
|
||
var ov = OverrideFor(settings, ed.Id);
|
||
if (ov?.Hidden == true) continue;
|
||
var title = string.IsNullOrWhiteSpace(ed.QualificationLevel) ? Trim(ed.Qualification) : $"{ed.Qualification} ({ed.QualificationLevel})";
|
||
section.Entries.Add(new CvRenderEntry
|
||
{
|
||
Key = ed.Id,
|
||
Title = Trim(ov?.Title) ?? title,
|
||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(ed.Institution, ed.Location),
|
||
Meta = DateRange(ed.StartDate ?? ed.Start, ed.EndDate ?? ed.End, false, settings),
|
||
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(ed.Details),
|
||
});
|
||
}
|
||
return section;
|
||
}
|
||
|
||
private static CvRenderSection ProjectSection(StructuredCvProfile profile, CvVariantSettings settings)
|
||
{
|
||
var section = new CvRenderSection { Key = "projects", Title = "Projects", Kind = "entries" };
|
||
foreach (var pr in profile.Projects)
|
||
{
|
||
var ov = OverrideFor(settings, pr.Id);
|
||
if (ov?.Hidden == true) continue;
|
||
section.Entries.Add(new CvRenderEntry
|
||
{
|
||
Key = pr.Id,
|
||
Title = Trim(ov?.Title) ?? Trim(pr.Name),
|
||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(pr.Role, pr.Location),
|
||
Meta = DateRange(pr.StartDate ?? pr.Start, pr.EndDate ?? pr.End, false, settings),
|
||
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(pr.Bullets),
|
||
Tags = Clean(pr.Skills),
|
||
});
|
||
}
|
||
return section;
|
||
}
|
||
|
||
private static CvRenderSection CertificationSection(StructuredCvProfile profile, CvVariantSettings settings)
|
||
{
|
||
var section = new CvRenderSection { Key = "certifications", Title = "Certifications", Kind = "entries" };
|
||
foreach (var c in profile.Certifications)
|
||
{
|
||
var ov = OverrideFor(settings, c.Id);
|
||
if (ov?.Hidden == true) continue;
|
||
section.Entries.Add(new CvRenderEntry
|
||
{
|
||
Key = c.Id,
|
||
Title = Trim(ov?.Title) ?? Trim(c.Name),
|
||
Subtitle = Trim(ov?.Subtitle) ?? JoinDot(c.Issuer, c.Location),
|
||
Meta = Trim(c.Date),
|
||
Bullets = ov?.Bullets != null ? Clean(ov.Bullets) : Clean(c.Details),
|
||
});
|
||
}
|
||
return section;
|
||
}
|
||
|
||
private static CvRenderSection LanguageSection(StructuredCvProfile profile)
|
||
{
|
||
var section = new CvRenderSection { Key = "languages", Title = "Languages", Kind = "tags" };
|
||
foreach (var l in profile.Languages)
|
||
{
|
||
var name = Trim(l.Name);
|
||
if (name is null) continue;
|
||
var level = Trim(l.Level);
|
||
section.Tags.Add(level is null ? name : $"{name} — {level}");
|
||
}
|
||
return section;
|
||
}
|
||
|
||
// Sort entries by the variant's explicit ItemOrder; entries whose key isn't listed (or is null)
|
||
// keep their master order and come after the ordered ones.
|
||
private static List<CvRenderEntry> ReorderByKey(List<CvRenderEntry> entries, List<string> order)
|
||
{
|
||
var rank = new Dictionary<string, int>(StringComparer.Ordinal);
|
||
for (var i = 0; i < order.Count; i++) if (!string.IsNullOrWhiteSpace(order[i])) rank[order[i]] = i;
|
||
return entries
|
||
.Select((e, i) => (e, primary: e.Key != null && rank.TryGetValue(e.Key, out var r) ? r : int.MaxValue, i))
|
||
.OrderBy(x => x.primary).ThenBy(x => x.i)
|
||
.Select(x => x.e).ToList();
|
||
}
|
||
|
||
private static CvItemOverride? OverrideFor(CvVariantSettings settings, string? itemKey)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(itemKey)) return null;
|
||
return settings.Overrides.TryGetValue(itemKey, out var ov) ? ov : null;
|
||
}
|
||
|
||
private static CvRenderSection BulletSection(string key, string title, IEnumerable<string> items) =>
|
||
new() { Key = key, Title = title, Kind = "bullets", Bullets = Clean(items) };
|
||
|
||
private static CvRenderSection TagSection(string key, string title, IEnumerable<string> items) =>
|
||
new() { Key = key, Title = title, Kind = "tags", Tags = Clean(items) };
|
||
|
||
private static void AddContact(List<CvContactItem> list, string icon, string? value, Func<string, string>? href)
|
||
{
|
||
var v = Trim(value);
|
||
if (v is null) return;
|
||
list.Add(new CvContactItem { Icon = icon, Value = v, Href = href?.Invoke(v) });
|
||
}
|
||
|
||
private static string AsUrl(string v) => v.StartsWith("http", StringComparison.OrdinalIgnoreCase) ? v : $"https://{v}";
|
||
|
||
private static string? JoinDot(params string?[] parts)
|
||
{
|
||
var joined = string.Join(" · ", parts.Select(Trim).Where(x => x != null));
|
||
return string.IsNullOrWhiteSpace(joined) ? null : joined;
|
||
}
|
||
|
||
private static string? DateRange(string? start, string? end, bool isCurrent, CvVariantSettings settings)
|
||
{
|
||
var s = FormatDate(Trim(start), settings);
|
||
var e = FormatDate(Trim(end), settings);
|
||
if (s is null && e is null) return null;
|
||
if (s is null) return e;
|
||
var present = IsNorwegian(settings.Language) ? "nå" : "Present";
|
||
return $"{s} – {(isCurrent ? present : e ?? present)}";
|
||
}
|
||
|
||
private static string? FormatDate(string? value, CvVariantSettings settings)
|
||
{
|
||
if (value is null || settings.DateFormat is null) return value;
|
||
if (!DateTime.TryParseExact(value, new[] { "yyyy-MM", "yyyy-M", "yyyy-MM-dd", "yyyy" }, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) return value;
|
||
var culture = IsNorwegian(settings.Language) ? CultureInfo.GetCultureInfo("nb-NO") : CultureInfo.GetCultureInfo("en-GB");
|
||
return settings.DateFormat switch
|
||
{
|
||
"year" => date.ToString("yyyy", culture),
|
||
"numeric" => date.ToString("MM/yyyy", culture),
|
||
"long" => date.ToString("MMMM yyyy", culture),
|
||
_ => date.ToString("MMM yyyy", culture),
|
||
};
|
||
}
|
||
|
||
private static bool IsNorwegian(string? language) => language?.StartsWith("no", StringComparison.OrdinalIgnoreCase) == true
|
||
|| language?.StartsWith("nb", StringComparison.OrdinalIgnoreCase) == true
|
||
|| language?.StartsWith("nn", StringComparison.OrdinalIgnoreCase) == true;
|
||
|
||
private static void ApplyLanguageLabels(Dictionary<string, CvRenderSection> sections, string? language)
|
||
{
|
||
if (!IsNorwegian(language)) return;
|
||
var labels = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||
{
|
||
["summary"] = "Profil", ["experience"] = "Arbeidserfaring", ["education"] = "Utdanning",
|
||
["projects"] = "Prosjekter", ["skills"] = "Ferdigheter", ["certifications"] = "Sertifiseringer",
|
||
["languages"] = "Språk", ["interests"] = "Interesser", ["awards"] = "Priser og utmerkelser",
|
||
["publications"] = "Publikasjoner", ["organisations"] = "Organisasjoner", ["references"] = "Referanser",
|
||
};
|
||
foreach (var (key, label) in labels) if (sections.TryGetValue(key, out var section)) section.Title = label;
|
||
}
|
||
|
||
private static List<string> Clean(IEnumerable<string>? items) =>
|
||
(items ?? Array.Empty<string>()).Select(x => x?.Trim() ?? string.Empty).Where(x => x.Length > 0).ToList();
|
||
|
||
private static string? Trim(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim();
|
||
}
|