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 Contact { get; set; } = new(); public List 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 Bullets { get; set; } = new(); public List Tags { get; set; } = new(); public List 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 Bullets { get; set; } = new(); public List 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(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:. 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: rows still append them in their stored custom-section order. var customHiddenByKey = new Dictionary(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(StringComparer.OrdinalIgnoreCase); var ordered = new List(); 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 ReorderByKey(List entries, List order) { var rank = new Dictionary(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 items) => new() { Key = key, Title = title, Kind = "bullets", Bullets = Clean(items) }; private static CvRenderSection TagSection(string key, string title, IEnumerable items) => new() { Key = key, Title = title, Kind = "tags", Tags = Clean(items) }; private static void AddContact(List list, string icon, string? value, Func? 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 sections, string? language) { if (!IsNorwegian(language)) return; var labels = new Dictionary(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 Clean(IEnumerable? items) => (items ?? Array.Empty()).Select(x => x?.Trim() ?? string.Empty).Where(x => x.Length > 0).ToList(); private static string? Trim(string? v) => string.IsNullOrWhiteSpace(v) ? null : v.Trim(); }