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, null); AddContact(model.Contact, "location", profile.Contact.Location, null); AddContact(model.Contact, "web", profile.Contact.Website, AsUrl); AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl); 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), }; // 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.Start, job.End, job.IsCurrent), 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.Start, ed.End, false), 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.Start, pr.End, false), 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) { var s = Trim(start); var e = Trim(end); if (s is null && e is null) return null; if (s is null) return e; return $"{s} โ€“ {(isCurrent ? "Present" : e ?? "Present")}"; } 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(); }