fix: preserve blank CVs and reliable editor undo
This commit is contained in:
@@ -240,6 +240,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
{
|
||||
var render = await _variants.RenderAsync(user.Id, id, Person(user), ct, expectedVersion);
|
||||
if (render is null) return NotFound();
|
||||
if (!render.HasContent) return BadRequest(new { code = "cv_empty_content" });
|
||||
var artifact = await _pdf.ExportAsync(user.Id, new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct);
|
||||
return File(artifact.Bytes, "application/pdf", artifact.FileName);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,10 @@ public sealed class CvVariantSettings
|
||||
public bool ShowPageNumbers { get; set; }
|
||||
public bool ShowIcons { get; set; } = true;
|
||||
|
||||
// Missing flags preserve legacy profile inheritance, including serialized history snapshots.
|
||||
public bool? SectionsExplicit { get; set; }
|
||||
public bool? IncludeProfileContact { get; set; }
|
||||
|
||||
// Section order + visibility + optional renamed heading. Empty => default order, all visible.
|
||||
public List<CvSectionSetting> Sections { get; set; } = new();
|
||||
|
||||
|
||||
@@ -377,7 +377,8 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
|
||||
settings.Overrides.TryGetValue(itemKey, out var itemOverride) ? itemOverride : new CvItemOverride();
|
||||
|
||||
private static bool SectionVisible(CvVariantSettings settings, string key) =>
|
||||
settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase))?.Hidden != true;
|
||||
settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase)) is { } section
|
||||
? !section.Hidden : settings.SectionsExplicit != true;
|
||||
|
||||
private static string ReadJsonArray(string? json)
|
||||
{
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed class CvAiSuggestionService(
|
||||
if (document != (targetId is null)) throw Error(400, "cv_ai_scope_invalid");
|
||||
var snapshot = await SnapshotAsync(owner, id, ct);
|
||||
RequireCurrent(snapshot, version, null);
|
||||
if (document && snapshot.Blocks.Count == 0) throw Error(400, "cv_ai_source_empty");
|
||||
var source = Source(targetId is null ? snapshot.Blocks : snapshot.EditableBlocks, targetId);
|
||||
if (inputText is not null && !string.Equals(ContentLines(inputText), ContentLines(source.Text), StringComparison.Ordinal))
|
||||
throw Error(409, "cv_ai_source_changed");
|
||||
|
||||
@@ -68,22 +68,25 @@ public static class CvVariantResolver
|
||||
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,
|
||||
FullName = settings.IncludeProfileContact == false ? string.Empty : Trim(profile.Contact.FullName) ?? fallbackName,
|
||||
Headline = Trim(settings.Headline) ?? (settings.IncludeProfileContact == false ? null : Trim(profile.Contact.Headline)),
|
||||
PhotoDataUrl = settings.ShowPhoto && settings.IncludeProfileContact != false ? 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)
|
||||
if (settings.IncludeProfileContact != false)
|
||||
{
|
||||
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) });
|
||||
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)
|
||||
@@ -138,10 +141,13 @@ public static class CvVariantResolver
|
||||
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 (settings.SectionsExplicit != true)
|
||||
{
|
||||
if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(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)
|
||||
|
||||
@@ -6,7 +6,10 @@ using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record ThemedCvRenderResult(string ThemeId, string SuggestedFileName, string Html);
|
||||
public sealed record ThemedCvRenderResult(string ThemeId, string SuggestedFileName, string Html)
|
||||
{
|
||||
public bool HasContent { get; init; } = true;
|
||||
}
|
||||
|
||||
public interface IThemedCvRenderer
|
||||
{
|
||||
@@ -45,11 +48,11 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
|
||||
var css = CvDocumentFonts.Embed(headingFont, bodyFont, theme.UtilityFont)
|
||||
+ BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, CvDocumentFonts.Stack(headingFont), CvDocumentFonts.Stack(bodyFont), headingStyle, density, pageDims, layout, twoColumn);
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle);
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle, settings.IncludeProfileContact == false);
|
||||
var body = layout switch
|
||||
{
|
||||
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}<section class=""main"">{mainHtml}</section></div>",
|
||||
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}</div>",
|
||||
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle, settings.IncludeProfileContact == false)}<section class=""main"">{mainHtml}</section></div>",
|
||||
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle, settings.IncludeProfileContact == false)}</div>",
|
||||
_ => $@"{header}<section class=""main"">{mainHtml}</section>",
|
||||
};
|
||||
var html = $@"<!DOCTYPE html>
|
||||
@@ -65,7 +68,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
</html>";
|
||||
|
||||
var fileName = Slug($"{model.FullName}-{theme.Id}") + ".pdf";
|
||||
return new ThemedCvRenderResult(theme.Id, fileName, html);
|
||||
return new ThemedCvRenderResult(theme.Id, fileName, html)
|
||||
{
|
||||
HasContent = model.Sections.Any(section => !section.IsEmpty)
|
||||
|| !string.IsNullOrWhiteSpace(model.FullName) || !string.IsNullOrWhiteSpace(model.Headline)
|
||||
|| model.Contact.Count > 0,
|
||||
};
|
||||
}
|
||||
|
||||
private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, IReadOnlyCollection<string> sidebarKeys, CvVariantSettings settings)
|
||||
@@ -82,20 +90,22 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
return (sidebar.ToString(), RenderSections(mainSections, theme, settings));
|
||||
}
|
||||
|
||||
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection<string> sidebarSections, string headerStyle)
|
||||
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection<string> sidebarSections, string headerStyle, bool hideEmptyHeader)
|
||||
{
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle);
|
||||
var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle, hideEmptyHeader);
|
||||
var contact = sidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
|
||||
? RenderContactBlock(model.Contact, showIcons, sidebar: true)
|
||||
: string.Empty;
|
||||
return $@"<aside class=""sidebar"">{header}{contact}{sectionsHtml}</aside>";
|
||||
}
|
||||
|
||||
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle)
|
||||
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle, bool hideEmptyHeader)
|
||||
{
|
||||
if (hideEmptyHeader && string.IsNullOrWhiteSpace(model.FullName) && string.IsNullOrWhiteSpace(model.Headline)
|
||||
&& model.Contact.Count == 0 && string.IsNullOrWhiteSpace(model.PhotoDataUrl)) return string.Empty;
|
||||
var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape);
|
||||
var kicker = headerStyle == "kicker" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
|
||||
var name = $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
|
||||
var name = hideEmptyHeader && string.IsNullOrWhiteSpace(model.FullName) ? string.Empty : $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
|
||||
var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"<div class=""headline"">{Enc(model.Headline)}</div>";
|
||||
var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false);
|
||||
var headerClass = twoColumn ? "hero" : $"header header-{headerStyle}";
|
||||
|
||||
Reference in New Issue
Block a user