Add CV template preview and PDF export pipeline
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public static class TailoredCvDraftJson
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public static TailoredCvDocument Empty() => Normalize(new TailoredCvDocument());
|
||||
|
||||
public static TailoredCvDocument FromDraft(TailoredCvDraft? draft)
|
||||
{
|
||||
if (draft is null) return Empty();
|
||||
|
||||
var document = new TailoredCvDocument
|
||||
{
|
||||
TemplateId = string.IsNullOrWhiteSpace(draft.TemplateId) ? "base" : draft.TemplateId.Trim(),
|
||||
Headline = TrimOrNull(draft.Headline),
|
||||
Summary = DeserializeList(draft.SummaryJson),
|
||||
SelectedSkills = DeserializeList(draft.SelectedSkillsJson),
|
||||
Experience = DeserializeList<TailoredCvExperienceItem>(draft.ExperienceJson)
|
||||
.Select(NormalizeExperience)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || !string.IsNullOrWhiteSpace(item.Company) || item.Bullets.Count > 0)
|
||||
.ToList(),
|
||||
Education = DeserializeList<TailoredCvEducationItem>(draft.EducationJson)
|
||||
.Select(NormalizeEducation)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Qualification) || !string.IsNullOrWhiteSpace(item.Institution) || item.Details.Count > 0)
|
||||
.ToList(),
|
||||
CustomSections = DeserializeList<TailoredCvCustomSection>(draft.CustomSectionsJson)
|
||||
.Select(NormalizeCustomSection)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || item.Items.Count > 0)
|
||||
.ToList(),
|
||||
RenderOptions = DeserializeObject<TailoredCvRenderOptions>(draft.RenderOptionsJson) ?? new TailoredCvRenderOptions(),
|
||||
};
|
||||
|
||||
return Normalize(document);
|
||||
}
|
||||
|
||||
public static void ApplyToDraft(TailoredCvDraft draft, TailoredCvDocument? document)
|
||||
{
|
||||
var normalized = Normalize(document);
|
||||
draft.TemplateId = normalized.TemplateId;
|
||||
draft.Headline = TrimOrNull(normalized.Headline);
|
||||
draft.SummaryJson = JsonSerializer.Serialize(normalized.Summary, SerializerOptions);
|
||||
draft.SelectedSkillsJson = JsonSerializer.Serialize(normalized.SelectedSkills, SerializerOptions);
|
||||
draft.ExperienceJson = JsonSerializer.Serialize(normalized.Experience, SerializerOptions);
|
||||
draft.EducationJson = JsonSerializer.Serialize(normalized.Education, SerializerOptions);
|
||||
draft.CustomSectionsJson = JsonSerializer.Serialize(normalized.CustomSections, SerializerOptions);
|
||||
draft.RenderOptionsJson = JsonSerializer.Serialize(normalized.RenderOptions, SerializerOptions);
|
||||
}
|
||||
|
||||
public static TailoredCvDocument Normalize(TailoredCvDocument? document)
|
||||
{
|
||||
document ??= new TailoredCvDocument();
|
||||
document.TemplateId = string.IsNullOrWhiteSpace(document.TemplateId) ? "ats-minimal" : document.TemplateId.Trim();
|
||||
document.Headline = TrimOrNull(document.Headline);
|
||||
document.Summary = CleanList(document.Summary);
|
||||
document.SelectedSkills = CleanList(document.SelectedSkills);
|
||||
document.Experience = (document.Experience ?? new List<TailoredCvExperienceItem>())
|
||||
.Select(NormalizeExperience)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || !string.IsNullOrWhiteSpace(item.Company) || item.Bullets.Count > 0)
|
||||
.ToList();
|
||||
document.Education = (document.Education ?? new List<TailoredCvEducationItem>())
|
||||
.Select(NormalizeEducation)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Qualification) || !string.IsNullOrWhiteSpace(item.Institution) || item.Details.Count > 0)
|
||||
.ToList();
|
||||
document.CustomSections = (document.CustomSections ?? new List<TailoredCvCustomSection>())
|
||||
.Select(NormalizeCustomSection)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.Title) || item.Items.Count > 0)
|
||||
.ToList();
|
||||
document.RenderOptions ??= new TailoredCvRenderOptions();
|
||||
document.RenderOptions.PageMode = string.IsNullOrWhiteSpace(document.RenderOptions.PageMode) ? "one-page" : document.RenderOptions.PageMode.Trim();
|
||||
document.RenderOptions.AccentColor = string.IsNullOrWhiteSpace(document.RenderOptions.AccentColor) ? "slate" : document.RenderOptions.AccentColor.Trim();
|
||||
document.RenderOptions.BulletDensity = string.IsNullOrWhiteSpace(document.RenderOptions.BulletDensity) ? "balanced" : document.RenderOptions.BulletDensity.Trim();
|
||||
document.RenderOptions.SectionOrder = CleanList(document.RenderOptions.SectionOrder);
|
||||
if (document.RenderOptions.SectionOrder.Count == 0)
|
||||
{
|
||||
document.RenderOptions.SectionOrder = new List<string> { "summary", "skills", "experience", "education", "custom" };
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
public static string RenderPlainText(TailoredCvDocument? document)
|
||||
{
|
||||
var normalized = Normalize(document);
|
||||
var lines = new List<string>();
|
||||
|
||||
AddLine(lines, normalized.Headline);
|
||||
if (normalized.Summary.Count > 0)
|
||||
{
|
||||
AddBlock(lines, "Professional Summary", normalized.Summary.Select(item => $"- {item}"));
|
||||
}
|
||||
|
||||
if (normalized.SelectedSkills.Count > 0)
|
||||
{
|
||||
AddBlock(lines, "Core Skills", normalized.SelectedSkills);
|
||||
}
|
||||
|
||||
if (normalized.Experience.Count > 0)
|
||||
{
|
||||
var block = new List<string>();
|
||||
foreach (var item in normalized.Experience)
|
||||
{
|
||||
AddLine(block, item.Title);
|
||||
var meta = string.Join(" | ", new[]
|
||||
{
|
||||
item.Company,
|
||||
item.Location,
|
||||
FormatDateRange(item.Start, item.End, item.IsCurrent)
|
||||
}.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddLine(block, meta);
|
||||
foreach (var bullet in item.Bullets)
|
||||
{
|
||||
AddLine(block, $"- {bullet}");
|
||||
}
|
||||
AddLine(block, string.Empty);
|
||||
}
|
||||
AddBlock(lines, "Experience", block);
|
||||
}
|
||||
|
||||
if (normalized.Education.Count > 0)
|
||||
{
|
||||
var block = new List<string>();
|
||||
foreach (var item in normalized.Education)
|
||||
{
|
||||
AddLine(block, item.Qualification);
|
||||
var meta = string.Join(" | ", new[]
|
||||
{
|
||||
item.Institution,
|
||||
item.Location,
|
||||
FormatDateRange(item.Start, item.End, false)
|
||||
}.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddLine(block, meta);
|
||||
foreach (var detail in item.Details)
|
||||
{
|
||||
AddLine(block, $"- {detail}");
|
||||
}
|
||||
AddLine(block, string.Empty);
|
||||
}
|
||||
AddBlock(lines, "Education", block);
|
||||
}
|
||||
|
||||
foreach (var section in normalized.CustomSections)
|
||||
{
|
||||
AddBlock(lines, section.Title ?? "Additional Information", section.Items);
|
||||
}
|
||||
|
||||
return string.Join("\n\n", lines.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())).Trim();
|
||||
}
|
||||
|
||||
private static TailoredCvExperienceItem NormalizeExperience(TailoredCvExperienceItem? item)
|
||||
{
|
||||
item ??= new TailoredCvExperienceItem();
|
||||
item.Title = TrimOrNull(item.Title);
|
||||
item.Company = TrimOrNull(item.Company);
|
||||
item.Location = TrimOrNull(item.Location);
|
||||
item.Start = TrimOrNull(item.Start);
|
||||
item.End = TrimOrNull(item.End);
|
||||
item.Bullets = CleanList(item.Bullets);
|
||||
item.IsCurrent = item.IsCurrent || string.Equals(item.End, "present", StringComparison.OrdinalIgnoreCase) || string.Equals(item.End, "current", StringComparison.OrdinalIgnoreCase);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static TailoredCvEducationItem NormalizeEducation(TailoredCvEducationItem? item)
|
||||
{
|
||||
item ??= new TailoredCvEducationItem();
|
||||
item.Qualification = TrimOrNull(item.Qualification);
|
||||
item.Institution = TrimOrNull(item.Institution);
|
||||
item.Location = TrimOrNull(item.Location);
|
||||
item.Start = TrimOrNull(item.Start);
|
||||
item.End = TrimOrNull(item.End);
|
||||
item.Details = CleanList(item.Details);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static TailoredCvCustomSection NormalizeCustomSection(TailoredCvCustomSection? item)
|
||||
{
|
||||
item ??= new TailoredCvCustomSection();
|
||||
item.Title = TrimOrNull(item.Title);
|
||||
item.Items = CleanList(item.Items);
|
||||
return item;
|
||||
}
|
||||
|
||||
private static List<string> DeserializeList(string? json)
|
||||
{
|
||||
return DeserializeList<string>(json)
|
||||
.Select(value => value?.Trim() ?? string.Empty)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static List<T> DeserializeList<T>(string? json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(json)
|
||||
? new List<T>()
|
||||
: JsonSerializer.Deserialize<List<T>>(json, SerializerOptions) ?? new List<T>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<T>();
|
||||
}
|
||||
}
|
||||
|
||||
private static T? DeserializeObject<T>(string? json) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(json) ? null : JsonSerializer.Deserialize<T>(json, SerializerOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> CleanList(IEnumerable<string>? values)
|
||||
{
|
||||
return (values ?? Array.Empty<string>())
|
||||
.Select(value => value?.Trim() ?? string.Empty)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? TrimOrNull(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? FormatDateRange(string? start, string? end, bool isCurrent)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(start) && string.IsNullOrWhiteSpace(end)) return null;
|
||||
if (string.IsNullOrWhiteSpace(start)) return end;
|
||||
return $"{start} - {(isCurrent ? "Present" : end ?? "Present")}";
|
||||
}
|
||||
|
||||
private static void AddBlock(List<string> lines, string title, IEnumerable<string> body)
|
||||
{
|
||||
var content = string.Join("\n", body.Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())).Trim();
|
||||
if (string.IsNullOrWhiteSpace(content)) return;
|
||||
lines.Add($"{title}\n{content}");
|
||||
}
|
||||
|
||||
private static void AddLine(List<string> lines, string? value)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value)) lines.Add(value.Trim());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user