Files
jobtrackingapp/JobTrackerApi/Models/CvVariantSettings.cs
T
2026-08-24 20:21:23 +02:00

179 lines
7.8 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
namespace JobTrackerApi.Models;
// The "lens" a CvVariant stores. Deliberately all-optional + defaulted so an empty settings object
// renders a sensible default CV straight from the master profile. Overrides are keyed by the master
// profile's stable ItemKeys so career data is referenced, never copied.
public sealed class CvVariantSettings
{
public string ThemeId { get; set; } = "modern";
// Theme overrides (null => use the theme's own value).
public string? AccentColor { get; set; }
public string? HeadingFont { get; set; }
public string? BodyFont { get; set; }
public string? Density { get; set; } // compact | balanced | roomy
public string? PageSize { get; set; } // a4 | letter
public string? DateFormat { get; set; } // display hint for the frontend; renderer uses source strings
public string? Language { get; set; }
public string? Headline { get; set; } // override the contact headline for this variant
// Normalized design overrides. Null keeps the selected theme's value, which means old variants
// retain their exact appearance while new editor controls can be added without a schema change.
public string? TextColor { get; set; }
public string? MutedColor { get; set; }
public string? HeadingColor { get; set; }
public string? BackgroundColor { get; set; }
public double? BaseFontSizePt { get; set; }
public double? HeadingSizePt { get; set; }
public double? LineHeight { get; set; }
public double? PageMarginMm { get; set; }
public double? SectionGapMm { get; set; }
public double? EntryGapMm { get; set; }
public string? HeadingStyle { get; set; } // caps-rule | underline | plain | bar
public string? HeaderStyle { get; set; } // plain | band | centered | kicker
public string? SkillsStyle { get; set; } // tags | text
public string? Layout { get; set; } // single | sidebar-left | sidebar-right | header-band
public double? SidebarWidthMm { get; set; }
public List<string>? SidebarSections { get; set; }
public bool ShowPhoto { get; set; }
public bool ShowPageNumbers { get; set; }
public bool ShowIcons { get; set; } = true;
// Section order + visibility + optional renamed heading. Empty => default order, all visible.
public List<CvSectionSetting> Sections { get; set; } = new();
// Per-item overrides keyed by ItemKey (job/education/project/certification stable id).
public Dictionary<string, CvItemOverride> Overrides { get; set; } = new();
// Extra sections unique to this variant (not in the master profile).
public List<CvCustomSectionSetting> CustomSections { get; set; } = new();
}
public sealed class CvSectionSetting
{
public string Key { get; set; } = string.Empty; // summary|skills|experience|education|projects|certifications|languages|interests|links|custom:<k>
public bool Hidden { get; set; }
public string? Title { get; set; } // renamed heading
// Explicit entry order for this section, as a list of item ItemKeys. Entries not listed keep their
// master order and come last. Null => master profile order. Lets a variant reorder entries without
// touching the master profile.
public List<string>? ItemOrder { get; set; }
}
public sealed class CvItemOverride
{
public bool Hidden { get; set; }
public string? Title { get; set; }
public string? Subtitle { get; set; }
public List<string>? Bullets { get; set; } // null => use master bullets; non-null => replace for this variant only
}
public sealed class CvCustomSectionSetting
{
public string Key { get; set; } = string.Empty;
public string? Title { get; set; }
public List<string> Items { get; set; } = new();
public bool Hidden { get; set; }
}
public static class CvVariantSettingsJson
{
private static readonly HashSet<string> AllowedFonts = new(StringComparer.Ordinal)
{
"'Segoe UI', Roboto, Arial, sans-serif",
"Arial, Helvetica, sans-serif",
"Georgia, 'Times New Roman', serif",
"'Helvetica Neue', Arial, sans-serif",
"'Roboto', Arial, sans-serif",
"'Poppins', 'Segoe UI', Arial, sans-serif",
};
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public static string Serialize(CvVariantSettings? settings) =>
JsonSerializer.Serialize(Normalize(settings), Options);
public static CvVariantSettings Deserialize(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new CvVariantSettings();
try { return Normalize(JsonSerializer.Deserialize<CvVariantSettings>(json, Options)); }
catch { return new CvVariantSettings(); }
}
public static CvVariantSettings Normalize(CvVariantSettings? s)
{
s ??= new CvVariantSettings();
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
s.AccentColor = NormalizeColor(s.AccentColor);
s.TextColor = NormalizeColor(s.TextColor);
s.MutedColor = NormalizeColor(s.MutedColor);
s.HeadingColor = NormalizeColor(s.HeadingColor);
s.BackgroundColor = NormalizeColor(s.BackgroundColor);
s.HeadingFont = NormalizeFont(s.HeadingFont);
s.BodyFont = NormalizeFont(s.BodyFont);
s.BaseFontSizePt = Clamp(s.BaseFontSizePt, 7, 13);
s.HeadingSizePt = Clamp(s.HeadingSizePt, 9, 20);
s.LineHeight = Clamp(s.LineHeight, 1.1, 1.8);
s.PageMarginMm = Clamp(s.PageMarginMm, 8, 28);
s.SectionGapMm = Clamp(s.SectionGapMm, 2, 14);
s.EntryGapMm = Clamp(s.EntryGapMm, 1, 10);
s.SidebarWidthMm = Clamp(s.SidebarWidthMm, 45, 85);
s.HeadingStyle = NormalizeChoice(s.HeadingStyle, "caps-rule", "underline", "plain", "bar");
s.HeaderStyle = NormalizeChoice(s.HeaderStyle, "plain", "band", "centered", "kicker");
s.SkillsStyle = NormalizeChoice(s.SkillsStyle, "tags", "text");
s.Layout = NormalizeChoice(s.Layout, "single", "sidebar-left", "sidebar-right", "header-band");
s.DateFormat = NormalizeChoice(s.DateFormat, "long", "short", "numeric", "year");
if (!string.IsNullOrWhiteSpace(s.Language))
{
var language = s.Language.Trim().ToLowerInvariant();
s.Language = language[..Math.Min(12, language.Length)];
}
else s.Language = null;
s.SidebarSections = s.SidebarSections?
.Where(key => !string.IsNullOrWhiteSpace(key))
.Select(key => key.Trim().ToLowerInvariant())
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(20)
.ToList();
s.Sections ??= new();
s.Overrides ??= new();
s.CustomSections ??= new();
return s;
}
private static string? NormalizeColor(string? value)
{
var candidate = value?.Trim();
return candidate is { Length: 7 }
&& candidate[0] == '#'
&& candidate.Skip(1).All(Uri.IsHexDigit)
? candidate.ToLowerInvariant()
: null;
}
private static string? NormalizeFont(string? value)
{
var candidate = value?.Trim();
return candidate is not null && AllowedFonts.Contains(candidate) ? candidate : null;
}
private static double? Clamp(double? value, double min, double max) =>
value is null || double.IsNaN(value.Value) || double.IsInfinity(value.Value)
? null
: Math.Clamp(value.Value, min, max);
private static string? NormalizeChoice(string? value, params string[] choices)
{
var candidate = value?.Trim().ToLowerInvariant();
return candidate is not null && choices.Contains(candidate, StringComparer.Ordinal) ? candidate : null;
}
}