Files
jobtrackingapp/JobTrackerApi/Services/CvRenderModel.cs
T
cesnimda 4cf26405f6
CI and Deploy / test (push) Failing after 1m6s
CI and Deploy / deploy (push) Has been skipped
feat: complete phase 3 career workspace
2026-07-30 22:19:13 +02:00

285 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, 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<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),
};
// 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) };
}
// Determine order + visibility from settings, falling back to the default order then any extras.
var settingByKey = settings.Sections.ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase);
var ordered = settings.Sections.Count > 0
? settings.Sections.Select(s => s.Key).ToList()
: DefaultOrder.ToList();
foreach (var key in built.Keys)
{
if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key);
}
foreach (var key in ordered)
{
if (key.StartsWith("custom:", StringComparison.OrdinalIgnoreCase)) continue; // handled below
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);
}
}
if (!section.IsEmpty) model.Sections.Add(section);
}
// Variant-only custom sections, placed by their position in the order list if present.
foreach (var custom in settings.CustomSections)
{
if (custom.Hidden) continue;
var items = Clean(custom.Items);
if (items.Count == 0) continue;
model.Sections.Add(new CvRenderSection
{
Key = $"custom:{custom.Key}",
Title = Trim(custom.Title) ?? "Additional",
Kind = "bullets",
Bullets = items,
});
}
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<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)
{
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<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();
}