Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73ea888375 | |||
| 663023a088 | |||
| 5d4b8f23ce |
@@ -0,0 +1,146 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.MariaDb.Tests;
|
||||
|
||||
public sealed class CvBlankPersistenceTests
|
||||
{
|
||||
private const string Owner = "rd21-owner";
|
||||
private static CvVariantService Service(JobTrackerContext db) => new(db, new CareerProfileService(db), new ThemedCvRenderer());
|
||||
private static CvVariantSettings Blank() => new() { SectionsExplicit = true, IncludeProfileContact = false };
|
||||
|
||||
private static async Task SeedProfile(JobTrackerContext db)
|
||||
{
|
||||
db.Users.Add(new() { Id = Owner, UserName = "synthetic-cv-blank" });
|
||||
await db.SaveChangesAsync();
|
||||
await new CareerProfileService(db).SaveVersionAsync(Owner, new()
|
||||
{
|
||||
Contact = new() { FullName = "Canonical Person", Headline = "Canonical headline", Email = "synthetic@example.test" },
|
||||
Summary = { "Canonical summary" },
|
||||
Jobs = { new() { Id = "canonical-role", Title = "Canonical role", Company = "Synthetic company", Bullets = { "Canonical evidence" } } },
|
||||
Skills = { "Canonical skill" },
|
||||
}, "manual", 0, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Blank_json_readback_duplicate_design_and_restore_preserve_canonical_profile_on_mariadb()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
int id;
|
||||
string initialJson;
|
||||
string canonicalJson;
|
||||
string? identityJson;
|
||||
await using (var seed = database.Context())
|
||||
{
|
||||
await SeedProfile(seed);
|
||||
canonicalJson = (await seed.CareerProfiles.SingleAsync()).ProfileJson;
|
||||
identityJson = (await seed.Users.SingleAsync()).ProfileCvStructureJson;
|
||||
var created = await Service(seed).CreateAsync(Owner, "Blank", null, Blank(), default);
|
||||
id = created.Id;
|
||||
initialJson = created.SettingsJson;
|
||||
}
|
||||
|
||||
int copyId;
|
||||
await using (var edit = database.Context())
|
||||
{
|
||||
var service = Service(edit);
|
||||
var read = (await service.GetAsync(Owner, id, default))!;
|
||||
Assert.Equal(initialJson, read.SettingsJson);
|
||||
var settings = CvVariantSettingsJson.Deserialize(read.SettingsJson);
|
||||
Assert.True(settings.SectionsExplicit);
|
||||
Assert.False(settings.IncludeProfileContact);
|
||||
Assert.Empty(settings.Sections);
|
||||
var rendered = await service.RenderAsync(Owner, id, new("Fallback identity", "data:image/png;base64,AA=="), default, 1);
|
||||
Assert.False(rendered!.HasContent);
|
||||
Assert.DoesNotContain("Canonical", rendered.Html);
|
||||
Assert.DoesNotContain("Fallback identity", rendered.Html);
|
||||
|
||||
var copy = (await service.DuplicateAsync(Owner, id, "Blank copy", default))!;
|
||||
copyId = copy.Id;
|
||||
Assert.Equal(initialJson, copy.SettingsJson);
|
||||
settings.ThemeId = "minimal";
|
||||
settings.AccentColor = "#123456";
|
||||
await service.SaveAsync(Owner, id, null, settings, "manual", 1, default);
|
||||
}
|
||||
|
||||
await using (var reopen = database.Context())
|
||||
{
|
||||
var service = Service(reopen);
|
||||
var settings = CvVariantSettingsJson.Deserialize((await service.GetAsync(Owner, id, default))!.SettingsJson);
|
||||
Assert.Equal("minimal", settings.ThemeId);
|
||||
Assert.True(settings.SectionsExplicit);
|
||||
Assert.False(settings.IncludeProfileContact);
|
||||
Assert.False((await service.RenderAsync(Owner, id, new("Fallback", null), default, 2))!.HasContent);
|
||||
settings.Headline = "Authored CV-only headline";
|
||||
await service.SaveAsync(Owner, id, null, settings, "manual", 2, default);
|
||||
var restored = (await service.RestoreVersionAsync(Owner, id, 1, 3, default))!;
|
||||
Assert.Equal(4, restored.Version);
|
||||
Assert.Equal(initialJson, restored.SettingsJson);
|
||||
}
|
||||
|
||||
await using var verify = database.Context();
|
||||
Assert.Equal(initialJson, (await verify.CvVariants.SingleAsync(cv => cv.Id == id)).SettingsJson);
|
||||
Assert.Equal(initialJson, (await verify.CvVariants.SingleAsync(cv => cv.Id == copyId)).SettingsJson);
|
||||
Assert.Equal(new[] { 1, 2, 3, 4 }, await verify.CvVariantVersions.Where(row => row.CvVariantId == id).OrderBy(row => row.Version).Select(row => row.Version).ToArrayAsync());
|
||||
Assert.Single(await verify.CvVariantVersions.Where(row => row.CvVariantId == copyId).ToListAsync());
|
||||
Assert.Equal(canonicalJson, (await verify.CareerProfiles.SingleAsync()).ProfileJson);
|
||||
Assert.Equal(identityJson, (await verify.Users.SingleAsync()).ProfileCvStructureJson);
|
||||
Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version);
|
||||
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
|
||||
Assert.Equal("Canonical role", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
var profile = await new CareerProfileService(verify).LoadStructuredAsync(Owner, default);
|
||||
Assert.Equal("Canonical summary", Assert.Single(profile.Summary));
|
||||
Assert.Equal("Canonical skill", Assert.Single(profile.Skills));
|
||||
Assert.Equal("Canonical evidence", Assert.Single(Assert.Single(profile.Jobs).Bullets));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Competing_blank_edits_and_foreign_access_cannot_overwrite_winner_or_append_history_on_mariadb()
|
||||
{
|
||||
await using var database = await MariaDbDatabase.CreateAsync();
|
||||
int id;
|
||||
await using (var seed = database.Context())
|
||||
{
|
||||
await SeedProfile(seed);
|
||||
id = (await Service(seed).CreateAsync(Owner, "Blank", null, Blank(), default)).Id;
|
||||
}
|
||||
await using var winner = database.Context();
|
||||
await using var staleSave = database.Context();
|
||||
await using var staleRestore = database.Context();
|
||||
Assert.Equal(1, (await Service(staleSave).GetAsync(Owner, id, default))!.Version);
|
||||
Assert.Equal(1, (await Service(staleRestore).GetAsync(Owner, id, default))!.Version);
|
||||
var settings = Blank();
|
||||
settings.ThemeId = "minimal";
|
||||
settings.Headline = "Acknowledged CV-only winner";
|
||||
await Service(winner).SaveAsync(Owner, id, "Winner", settings, "manual", 1, default);
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Service(staleSave).SaveAsync(Owner, id, "Stale name", Blank(), "autosave", 1, default));
|
||||
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Service(staleRestore).RestoreVersionAsync(Owner, id, 1, 1, default));
|
||||
|
||||
await using (var foreign = database.Context("foreign"))
|
||||
{
|
||||
var service = Service(foreign);
|
||||
Assert.Null(await service.GetAsync("foreign", id, default));
|
||||
Assert.Null(await service.SaveAsync("foreign", id, "Foreign", Blank(), "manual", 2, default));
|
||||
Assert.Null(await service.DuplicateAsync("foreign", id, null, default));
|
||||
Assert.Null(await service.RestoreVersionAsync("foreign", id, 1, 2, default));
|
||||
Assert.Null(await service.RenderAsync("foreign", id, new("Foreign", null), default, 2));
|
||||
Assert.Empty(await service.ListVersionsAsync("foreign", id, default));
|
||||
}
|
||||
|
||||
await using var verify = database.Context();
|
||||
var saved = await verify.CvVariants.SingleAsync();
|
||||
Assert.Equal(2, saved.Version);
|
||||
Assert.Equal("Winner", saved.Name);
|
||||
Assert.Equal(CvVariantSettingsJson.Serialize(settings), saved.SettingsJson);
|
||||
var history = await verify.CvVariantVersions.OrderBy(row => row.Version).ToListAsync();
|
||||
Assert.Equal(new[] { 1, 2 }, history.Select(row => row.Version));
|
||||
Assert.Equal(CvVariantSettingsJson.Serialize(Blank()), history[0].SettingsJson);
|
||||
Assert.Equal(saved.SettingsJson, history[1].SettingsJson);
|
||||
Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version);
|
||||
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
|
||||
Assert.Equal("Canonical role", (await verify.CareerExperiences.SingleAsync()).Title);
|
||||
}
|
||||
}
|
||||
@@ -364,6 +364,20 @@ public sealed class ApplicationIntelligenceTests
|
||||
Assert.Empty(match.RelevantExperience);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_does_not_inherit_experience_from_an_explicit_blank_cv()
|
||||
{
|
||||
var (db, intelligence, _) = New("user-1");
|
||||
await using var dispose = db;
|
||||
var job = await SeedJobAsync(db, "user-1");
|
||||
await SeedProfileAsync(db, "user-1");
|
||||
await AttachCvAsync(db, "user-1", job.Id, CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}"));
|
||||
var match = await intelligence.MatchAsync("user-1", job.Id, default);
|
||||
Assert.True(match!.HasSelectedCv);
|
||||
Assert.Empty(match.RelevantExperience);
|
||||
Assert.Empty(match.RelevantProjects);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Match_is_not_readable_for_another_users_application()
|
||||
{
|
||||
|
||||
@@ -19,6 +19,20 @@ public sealed class CvAiReadinessTests
|
||||
{
|
||||
private const string Source = "Built and maintained internal web applications.";
|
||||
private const string Paraphrase = "Developed and maintained internal web applications.";
|
||||
|
||||
[Fact]
|
||||
public async Task Explicit_blank_document_AI_does_not_send_unselected_profile_facts()
|
||||
{
|
||||
await using var fixture = new Fixture();
|
||||
await fixture.Open();
|
||||
await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null,
|
||||
CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}"), "manual", 1, default);
|
||||
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.GenerateAsync(
|
||||
"owner", fixture.Cv.Id, 2, null, "document-improve", null, null, "Rewrite", default));
|
||||
Assert.Equal("cv_ai_source_empty", error.Code);
|
||||
fixture.Ai.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
private sealed class Fixture : IAsyncDisposable
|
||||
{
|
||||
public readonly SqliteConnection Connection = new("Data Source=:memory:");
|
||||
|
||||
@@ -28,6 +28,72 @@ public sealed class CvBuilderTests
|
||||
|
||||
// ---- Resolver (the lens over the master profile) ----
|
||||
|
||||
[Fact]
|
||||
public void Explicit_blank_roundtrips_without_inheriting_profile_content_or_header()
|
||||
{
|
||||
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[],\"showPhoto\":true}");
|
||||
var json = CvVariantSettingsJson.Serialize(settings);
|
||||
Assert.Contains("\"sectionsExplicit\":true", json);
|
||||
Assert.Contains("\"includeProfileContact\":false", json);
|
||||
var model = CvVariantResolver.Build(Rich(), CvVariantSettingsJson.Deserialize(json), "Fallback", "data:image/png;base64,AA==");
|
||||
Assert.Empty(model.Sections);
|
||||
Assert.Empty(model.FullName);
|
||||
Assert.Null(model.Headline);
|
||||
Assert.Empty(model.Contact);
|
||||
Assert.Null(model.PhotoDataUrl);
|
||||
foreach (var theme in CvThemeCatalog.Themes)
|
||||
{
|
||||
var html = new ThemedCvRenderer().Render(model, theme, settings).Html;
|
||||
Assert.DoesNotContain("<header", html);
|
||||
Assert.DoesNotContain("Ada Lovelace", html);
|
||||
Assert.DoesNotContain("Fallback", html);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Explicit_sections_include_only_selected_content_and_allow_authored_headline()
|
||||
{
|
||||
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"headline\":\"Authored headline\",\"sections\":[{\"key\":\"skills\"}]}");
|
||||
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
|
||||
Assert.Equal("skills", Assert.Single(model.Sections).Key);
|
||||
Assert.Equal("Authored headline", model.Headline);
|
||||
Assert.Empty(model.FullName);
|
||||
Assert.Empty(model.Contact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Legacy_inclusion_flags_remain_absent_and_preserve_profile_inheritance()
|
||||
{
|
||||
var settings = CvVariantSettingsJson.Deserialize("{\"sections\":[]}");
|
||||
var json = CvVariantSettingsJson.Serialize(settings);
|
||||
Assert.DoesNotContain("sectionsExplicit", json);
|
||||
Assert.DoesNotContain("includeProfileContact", json);
|
||||
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
|
||||
Assert.Equal("Ada Lovelace", model.FullName);
|
||||
Assert.NotEmpty(model.Sections);
|
||||
Assert.NotEmpty(model.Contact);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Explicit_blank_survives_save_duplicate_restore_and_design_change()
|
||||
{
|
||||
var (db, service) = NewService("user-1");
|
||||
await using var dispose = db;
|
||||
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}");
|
||||
var created = await service.CreateAsync("user-1", "Blank", null, settings, default);
|
||||
settings.ThemeId = "clean";
|
||||
await service.SaveAsync("user-1", created.Id, null, settings, "manual", 1, default);
|
||||
var copy = await service.DuplicateAsync("user-1", created.Id, null, default);
|
||||
var restored = await service.RestoreVersionAsync("user-1", created.Id, 1, 2, default);
|
||||
foreach (var document in new[] { copy!, restored! })
|
||||
{
|
||||
var stored = CvVariantSettingsJson.Deserialize(document.SettingsJson);
|
||||
Assert.Contains("\"sectionsExplicit\":true", document.SettingsJson);
|
||||
Assert.Contains("\"includeProfileContact\":false", document.SettingsJson);
|
||||
Assert.Empty(CvVariantResolver.Build(Rich(), stored, "Fallback", null).Sections);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolver_defaults_include_every_populated_section()
|
||||
{
|
||||
|
||||
@@ -16,6 +16,27 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class CvExportRevisionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Explicit_blank_export_is_rejected_before_pdf_allocation()
|
||||
{
|
||||
var current = new Mock<ICurrentUserService>();
|
||||
current.SetupGet(x => x.UserId).Returns("owner");
|
||||
await using var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object);
|
||||
var variants = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer());
|
||||
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}");
|
||||
var cv = await variants.CreateAsync("owner", "Blank", null, settings, default);
|
||||
var user = new ApplicationUser { Id = "owner", FirstName = "Inherited name" };
|
||||
var users = TestHostFactory.CreateUserManager(user);
|
||||
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
var pdf = new Mock<ICvPdfExporter>(MockBehavior.Strict);
|
||||
var controller = new CvVariantController(users.Object, variants, pdf.Object, Mock.Of<ISummarizerService>())
|
||||
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
|
||||
var result = Assert.IsType<BadRequestObjectResult>(await controller.ExportPdf(cv.Id, default, 1));
|
||||
Assert.Equal("cv_empty_content", JsonSerializer.SerializeToElement(result.Value).GetProperty("code").GetString());
|
||||
pdf.VerifyNoOtherCalls();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "owner", 409)]
|
||||
[InlineData(0, "owner", 409)]
|
||||
|
||||
@@ -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}";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Local workers and models
|
||||
|
||||
This overlay targets the separately configured `jobjakt-dev` stack in the primary checkout. It does not replace that checkout's standalone compose file. Production services, databases and model caches are excluded.
|
||||
|
||||
The local runtime uses Ollama 0.40.11 and `qwen2.5:7b` (Q4_K_M, Apache-2.0), matching the model name in the repository's production configuration. Exact deployed digest parity has not been established. The frozen official registry manifest is in `model-manifests/qwen2.5-7b.json`; its SHA256 is `845dbda0ea48ed749caafd9e6037047aa19acfcfd82e704d7ca97d631a0b697e`. The image digest and resource limits are pinned in `ollama.compose.yml`. CPU inference is limited to four CPUs and 8GB; cloud processing is disabled. Model files use a dedicated development volume; the API binds only `127.0.0.1:11434`.
|
||||
|
||||
Initial local setup, explicitly authorised by the owner on 27 October 2026:
|
||||
|
||||
```powershell
|
||||
rtk proxy docker compose -f dev/ollama.compose.yml up -d --wait
|
||||
rtk proxy docker compose -f dev/ollama.compose.yml exec ollama ollama pull qwen2.5:7b
|
||||
rtk proxy python dev/verify_ollama.py --manifest dev/model-manifests/qwen2.5-7b.json --output .artifacts/pre-phase10-ux/local-ai/verification.json
|
||||
```
|
||||
|
||||
Verification checks the actual image, dedicated cache and every model/config/template/license blob against the frozen manifest before inference. A future changed upstream tag must fail verification; review the artifact before updating the pin. The download network remains available for explicit model maintenance; no cloud provider is enabled.
|
||||
|
||||
The existing approved DistilBART CPU checkpoint is reused through a read-only mount after all files are checked against `docs/redesign/phase-9/p9-sec-02/full-model/ARTIFACTS.json`. Transformers is offline, so this operation cannot fetch replacement weights. The parser-capable sidecar is also checked against its previously qualified local image ID.
|
||||
|
||||
Preflight and apply (run from this worktree; replace paths only with the verified development checkout and approved artifact):
|
||||
|
||||
```powershell
|
||||
rtk proxy python dev/enable_local_workers.py --checkout "E:/Storage/Projects/Active/Job tracker" --bart-artifact "E:/Storage/Projects/Active/Job tracker/.artifacts/p9-sec-02/full-model/artifact" --output .artifacts/pre-phase10-ux/local-ai/worker-preflight.json
|
||||
rtk proxy python dev/enable_local_workers.py --checkout "E:/Storage/Projects/Active/Job tracker" --bart-artifact "E:/Storage/Projects/Active/Job tracker/.artifacts/p9-sec-02/full-model/artifact" --output .artifacts/pre-phase10-ux/local-ai/workers-applied.json --apply
|
||||
rtk proxy python dev/check_local_ai.py --output .artifacts/pre-phase10-ux/local-ai/sidecar-smoke.json
|
||||
```
|
||||
|
||||
The apply helper validates project, Development environment, loopback backend ports, development database, current backend image, offline model mount and Mailpit settings. It recreates only the development backend/sidecar without a build or pull, preserving the database and volumes. It enables rules, reminders, saved-search alerts, daily exports, job enrichment and queued AI processing. These features still respect account entitlements, AI consent, configured schedules and available input. Email goes only to Mailpit at `http://localhost:8026`; it is not delivered externally.
|
||||
|
||||
This configuration operation does not publish source changes. In particular, the running older backend image cannot use the new `Ai:RequestTimeoutSeconds=240` setting until built from the validated feature source. Its old 30-second limit can interrupt CPU generation. Source builds and application checks have separate evidence.
|
||||
|
||||
OAuth/mail synchronisation, billing and external data providers need their own approved credentials and configuration. Enabling workers cannot provide them. CV document import uses deterministic Docling; generative CV-import assistance stays disabled pending its separate source-grounding/accuracy qualification.
|
||||
|
||||
## Evidence, 27 October 2026
|
||||
|
||||
The local setup and worker apply passed. Independent model-blob verification passed. A real constrained JSON CPU response passed in 20.546 seconds including model loading. Authenticated sidecar checks passed: trained local job summarisation in 2.518 seconds and an Ollama-backed CV rewrite in 40.997 seconds. These checks assert actual nonempty content, not semantic correctness. No production digest parity or CV extraction accuracy is claimed.
|
||||
|
||||
Official sources: [Ollama model](https://ollama.com/library/qwen2.5:7b), [frozen registry source](https://registry.ollama.ai/v2/library/qwen2.5/manifests/7b), [Ollama Docker guidance](https://docs.ollama.com/docker), [local/cloud configuration](https://docs.ollama.com/faq).
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Exercise the isolated development sidecar with fictional data only."""
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
# Authentication stays inside the container; neither secrets nor model text are logged.
|
||||
program = r'''
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
records = []
|
||||
def request(path, payload, field):
|
||||
started = time.monotonic()
|
||||
req = urllib.request.Request("http://127.0.0.1:8001" + path,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json", "X-AI-Service-Token": os.environ["AI_SERVICE_TOKEN"]})
|
||||
with urllib.request.urlopen(req, timeout=240) as response:
|
||||
result = json.load(response)
|
||||
value = result.get(field)
|
||||
assert isinstance(value, str) and value.strip(), "Missing generated content: " + field
|
||||
records.append({"path": path, "status": "PASS", "seconds": round(time.monotonic() - started, 3), "characters": len(value)})
|
||||
|
||||
assert os.environ["OLLAMA_BASE_URL"] == "http://jobjakt-dev-ollama:11434"
|
||||
assert os.environ["AI_ROUTING_MODE"] == "local_only"
|
||||
assert os.environ["EXTERNAL_AI_ENABLED"].lower() == "false"
|
||||
request("/summarize", {"text": "Fictional Systems seeks a software engineer. Responsibilities include developing C# APIs, writing SQL queries and maintaining Docker services. Candidates should communicate clearly and work with a small team. This fictional advertisement is solely a development check.", "max_length": 60, "min_length": 10}, "summary")
|
||||
request("/cv/rewrite", {"instruction": "Correct grammar only. Return exactly one sentence. Preserve the facts and add no headings.", "text": "A fictional engineer build reliable software.", "max_length": 48, "min_length": 8}, "rewritten_text")
|
||||
print(json.dumps({"status": "PASS", "model": os.environ["OLLAMA_MODEL"], "routing": "local_only", "checks": records,
|
||||
"limits": "Synthetic sidecar connectivity and nonempty response only; no factual extraction accuracy or production parity claim."}))
|
||||
'''
|
||||
identity = json.loads(subprocess.check_output(["rtk", "proxy", "docker", "inspect", "jobjakt-dev-ai-service-1"], text=True))[0]
|
||||
assert identity["Config"]["Labels"]["com.docker.compose.project"] == "jobjakt-dev"
|
||||
assert identity["Image"] == "sha256:8a29cb90fcc2817b0d784826d55a172c943f5ad7e1b6105e690a2d79ee15fe87"
|
||||
result = subprocess.run(["rtk", "proxy", "docker", "exec", "-i", "jobjakt-dev-ai-service-1", "python", "-"],
|
||||
input=program, text=True, capture_output=True, timeout=500)
|
||||
if result.returncode:
|
||||
# Avoid forwarding exception bodies which might contain request data.
|
||||
raise SystemExit("Local synthetic sidecar check failed; exit " + str(result.returncode))
|
||||
record = json.loads(result.stdout)
|
||||
assert record["status"] == "PASS" and len(record["checks"]) == 2
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(record))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Guard and apply worker settings to the existing isolated local development stack."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
SIDECAR = "sha256:8a29cb90fcc2817b0d784826d55a172c943f5ad7e1b6105e690a2d79ee15fe87"
|
||||
|
||||
|
||||
def capture(arguments, environment):
|
||||
return subprocess.check_output(["rtk", "proxy", *arguments], env=environment, text=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--checkout", type=Path, required=True)
|
||||
parser.add_argument("--bart-artifact", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
checkout = args.checkout.resolve()
|
||||
artifact = args.bart_artifact.resolve()
|
||||
environment = {**os.environ, "JOBTRACKER_DEV_BART_ARTIFACT": str(artifact)}
|
||||
manifest = json.loads((ROOT / "docs/redesign/phase-9/p9-sec-02/full-model/ARTIFACTS.json").read_text())["weights-only"]
|
||||
for row in manifest["files"]:
|
||||
path = artifact / row["file"]
|
||||
assert path.is_file() and path.stat().st_size == row["bytes"], "Local model file missing or wrong size"
|
||||
with path.open("rb") as stream:
|
||||
assert hashlib.file_digest(stream, "sha256").hexdigest() == row["sha256"], "Local model integrity mismatch"
|
||||
assert json.loads((ROOT / ".artifacts/pre-phase10-ux/local-ai/verification.json").read_text())["status"] == "PASS"
|
||||
compose = ["docker", "compose", "--project-directory", str(checkout), "--env-file", str(checkout / ".env.dev"),
|
||||
"-f", str(checkout / "docker-compose.dev.yml"), "-f", str(ROOT / "dev/local-workers.override.yml")]
|
||||
config = json.loads(capture([*compose, "config", "--format", "json"], environment))
|
||||
assert config["name"] == "jobjakt-dev", "Only the local development project is permitted"
|
||||
backend = config["services"]["backend"]
|
||||
sidecar = config["services"]["ai-service"]
|
||||
settings = backend["environment"]
|
||||
assert settings["ASPNETCORE_ENVIRONMENT"] == "Development"
|
||||
connection = settings["ConnectionStrings__JobTracker"].lower()
|
||||
assert "server=mariadb;" in connection and "database=jobtracker_dev;" in connection
|
||||
assert settings["Ai__ExternalProcessingEnabled"].lower() == "false"
|
||||
assert settings["Email__SmtpHost"] == "mailpit" and settings["Email__SmtpPort"] == "1025"
|
||||
assert not settings["Email__SmtpUser"] and not settings["Email__SmtpPassword"]
|
||||
assert all(port.get("host_ip") == "127.0.0.1" for port in backend.get("ports", []))
|
||||
assert sidecar["environment"]["HF_HUB_OFFLINE"] == "1"
|
||||
assert sidecar["environment"]["TRANSFORMERS_OFFLINE"] == "1"
|
||||
assert sidecar["environment"]["EXTERNAL_AI_ENABLED"].lower() == "false"
|
||||
assert sidecar["environment"]["OLLAMA_BASE_URL"] == "http://jobjakt-dev-ollama:11434"
|
||||
assert any(volume.get("source") == str(artifact) and volume.get("read_only") for volume in sidecar["volumes"])
|
||||
assert json.loads(capture(["docker", "image", "inspect", sidecar["image"]], environment))[0]["Id"] == SIDECAR
|
||||
current = json.loads(capture(["docker", "inspect", "jobjakt-dev-backend-1"], environment))[0]
|
||||
assert current["Config"]["Labels"]["com.docker.compose.project"] == "jobjakt-dev"
|
||||
assert json.loads(capture(["docker", "image", "inspect", backend["image"]], environment))[0]["Id"] == current["Image"], "This operation changes worker configuration, not backend code"
|
||||
record = {"project": config["name"], "verifiedBartRevision": manifest["sourceRevision"], "sidecarImage": SIDECAR,
|
||||
"enabledWorkers": {key: value for key, value in settings.items() if key.startswith("Workers__")},
|
||||
"emailSink": "Mailpit", "externalAI": False, "applied": False}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
if args.apply:
|
||||
with args.output.with_suffix(".log").open("w", encoding="utf-8") as log:
|
||||
result = subprocess.run(["rtk", "proxy", *compose, "up", "-d", "--no-deps", "--no-build", "--pull", "never",
|
||||
"--wait", "--wait-timeout", "120", "backend", "ai-service"], env=environment, stdout=log, stderr=subprocess.STDOUT)
|
||||
record["exitCode"] = result.returncode
|
||||
record["applied"] = result.returncode == 0
|
||||
args.output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(record))
|
||||
if args.apply:
|
||||
raise SystemExit(record["exitCode"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,47 @@
|
||||
# Apply only to the separately verified standalone jobjakt-dev stack.
|
||||
services:
|
||||
backend:
|
||||
environment:
|
||||
Workers__RulesEnabled: "true"
|
||||
Workers__FollowUpRemindersEnabled: "true"
|
||||
Workers__SavedJobSearchAlertsEnabled: "true"
|
||||
Workers__DailyExportEnabled: "true"
|
||||
Workers__JobEnrichmentEnabled: "true"
|
||||
Workers__AiOperationsEnabled: "true"
|
||||
Workers__CvDocumentImportsEnabled: "true"
|
||||
Exports__DailyEnabled: "true"
|
||||
Email__FollowUpReminders__Enabled: "true"
|
||||
Email__Enabled: "true"
|
||||
Email__SmtpHost: mailpit
|
||||
Email__SmtpPort: "1025"
|
||||
Email__SmtpEnableSsl: "false"
|
||||
Email__SmtpUser: ""
|
||||
Email__SmtpPassword: ""
|
||||
Ai__ExternalProcessingEnabled: "false"
|
||||
Ai__RequestTimeoutSeconds: "240"
|
||||
ai-service:
|
||||
# Previously qualified local image contains both the legacy and isolated Docling runtimes.
|
||||
image: jobtracker-cv-import-sidecar:dev-04
|
||||
environment:
|
||||
OLLAMA_BASE_URL: http://jobjakt-dev-ollama:11434
|
||||
OLLAMA_MODEL: qwen2.5:7b
|
||||
AI_PROVIDER: ollama
|
||||
AI_ROUTING_MODE: local_only
|
||||
EXTERNAL_AI_ENABLED: "false"
|
||||
CV_IMPORT_AI_ENABLED: "false"
|
||||
AI_SERVICE_SKIP_MODEL_LOAD: "0"
|
||||
SKIP_MODEL_LOAD: "0"
|
||||
HF_HOME: /models
|
||||
HF_HUB_OFFLINE: "1"
|
||||
TRANSFORMERS_OFFLINE: "1"
|
||||
HF_HUB_DISABLE_TELEMETRY: "1"
|
||||
OMP_NUM_THREADS: "2"
|
||||
MKL_NUM_THREADS: "2"
|
||||
volumes:
|
||||
- type: bind
|
||||
source: ${JOBTRACKER_DEV_BART_ARTIFACT:?Verify the isolated trained checkpoint first}
|
||||
target: /models/hub/models--sshleifer--distilbart-cnn-12-6/snapshots/a4f8f3ea906ed274767e9906dbaede7531d660ff
|
||||
read_only: true
|
||||
mem_limit: 4g
|
||||
cpus: 2
|
||||
pids_limit: 256
|
||||
@@ -0,0 +1 @@
|
||||
{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:2f15b3218f0552c60647ce60ada83632d2c09755b16259b13e3e4458e9ae419d","size":487},"layers":[{"mediaType":"application/vnd.ollama.image.model","digest":"sha256:2bada8a7450677000f678be90653b85d364de7db25eb5ea54136ada5f3933730","size":4683073952},{"mediaType":"application/vnd.ollama.image.system","digest":"sha256:66b9ea09bd5b7099cbb4fc820f31b575c0366fa439b08245566692c6784e281e","size":68},{"mediaType":"application/vnd.ollama.image.template","digest":"sha256:eb4402837c7829a690fa845de4d7f3fd842c2adee476d5341da8a46ea9255175","size":1482},{"mediaType":"application/vnd.ollama.image.license","digest":"sha256:832dd9e00a68dd83b3c3fb9f5588dad7dcf337a0db50f7d9483f310cd292e92e","size":11343}]}
|
||||
@@ -0,0 +1,47 @@
|
||||
# Local development only. Uses the existing isolated jobjakt-dev AI network.
|
||||
name: jobjakt-dev-ollama
|
||||
services:
|
||||
ollama:
|
||||
image: ollama/ollama:0.40.11@sha256:459985308d740b99071719baec2740a60f5b2b2d9b835c171991ae25690203da
|
||||
environment:
|
||||
OLLAMA_HOST: 0.0.0.0:11434
|
||||
OLLAMA_NO_CLOUD: "1"
|
||||
OLLAMA_NUM_PARALLEL: "1"
|
||||
OLLAMA_MAX_LOADED_MODELS: "1"
|
||||
OLLAMA_CONTEXT_LENGTH: "4096"
|
||||
OLLAMA_KEEP_ALIVE: 5m
|
||||
ports:
|
||||
- "127.0.0.1:11434:11434"
|
||||
volumes:
|
||||
- models:/root/.ollama
|
||||
networks:
|
||||
ai_internal:
|
||||
aliases: [jobjakt-dev-ollama]
|
||||
model_download: {}
|
||||
mem_limit: 8g
|
||||
cpus: 4
|
||||
pids_limit: 256
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=256m,mode=1777
|
||||
cap_drop: [ALL]
|
||||
security_opt: [no-new-privileges:true]
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [CMD, ollama, list]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
logging:
|
||||
options:
|
||||
max-size: 10m
|
||||
max-file: "3"
|
||||
networks:
|
||||
ai_internal:
|
||||
external: true
|
||||
name: jobjakt-dev_ai_internal
|
||||
model_download: {}
|
||||
volumes:
|
||||
models:
|
||||
name: jobjakt-dev-ollama-models
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Verify the dedicated development model cache before local inference."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
MODEL = "qwen2.5:7b"
|
||||
MANIFEST = "845dbda0ea48ed749caafd9e6037047aa19acfcfd82e704d7ca97d631a0b697e"
|
||||
IMAGE = "ollama/ollama:0.40.11@sha256:459985308d740b99071719baec2740a60f5b2b2d9b835c171991ae25690203da"
|
||||
CONTAINER = "jobjakt-dev-ollama-ollama-1"
|
||||
|
||||
|
||||
def run(*arguments):
|
||||
return subprocess.check_output(["rtk", "proxy", *arguments], text=True).strip()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--manifest", required=True, type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
source = args.manifest.read_bytes()
|
||||
assert hashlib.sha256(source).hexdigest() == MANIFEST, "Registry manifest changed"
|
||||
manifest = json.loads(source)
|
||||
container = json.loads(run("docker", "inspect", CONTAINER))[0]
|
||||
assert container["Config"]["Labels"]["com.docker.compose.project"] == "jobjakt-dev-ollama"
|
||||
assert container["Image"] == json.loads(run("docker", "image", "inspect", IMAGE))[0]["Id"]
|
||||
assert "OLLAMA_NO_CLOUD=1" in container["Config"]["Env"]
|
||||
assert container["HostConfig"]["ReadonlyRootfs"]
|
||||
assert container["HostConfig"]["Memory"] == 8 * 1024**3
|
||||
assert all(mount.get("Name") == "jobjakt-dev-ollama-models" for mount in container["Mounts"] if mount["Type"] == "volume")
|
||||
descriptors = [manifest["config"], *manifest["layers"], {"digest": "sha256:" + MANIFEST}]
|
||||
checked = []
|
||||
for descriptor in descriptors:
|
||||
digest = descriptor["digest"]
|
||||
assert re.fullmatch(r"sha256:[a-f0-9]{64}", digest)
|
||||
path = "/root/.ollama/models/blobs/" + digest.replace(":", "-")
|
||||
actual = run("docker", "exec", CONTAINER, "sha256sum", path).split()[0]
|
||||
assert actual == digest[7:], "Downloaded model blob changed"
|
||||
checked.append(digest)
|
||||
with urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=5) as response:
|
||||
models = json.load(response)["models"]
|
||||
actual = next(model for model in models if model["name"] == MODEL)
|
||||
assert actual["digest"] == MANIFEST
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
record = {"status": "PASS", "model": MODEL, "manifestSha256": MANIFEST, "checkedBlobs": checked,
|
||||
"image": IMAGE, "details": actual["details"], "limits": "Local artifact verification only; no inference, production digest parity or extraction accuracy claim."}
|
||||
args.output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(record))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,38 @@
|
||||
# A: blank CV and editor history acceptance
|
||||
|
||||
27 October 2026. Corrective branch: `codex/cv-builder-blank-undo`, from completed CV-QA `4a8c907`. Audit/plan checkpoint: `5d4b8f2`. This is development qualification; it does not reopen production or live inference.
|
||||
|
||||
## Implemented
|
||||
|
||||
- New Blank creation contains zero sections and no inherited identity/contact/photo/headline. Profile creation requires explicit section/contact selection. Existing settings without nullable inclusion flags retain legacy behaviour.
|
||||
- Editor, shared render resolver, document AI and application matcher agree on selected content. AI rejects an empty source before provider access. No live inference was performed.
|
||||
- Empty resolved export returns `cv_empty_content` before allocating a PDF worker, with English/Bokmål recovery copy. Design changes, save/reload, duplicate and historical restore preserve explicit selection.
|
||||
- Session undo/redo includes name and settings, groups focused typing bursts, records structural/design actions, invalidates redo after new edits, preserves history across autosave and rejects history actions during revision conflict. Native text undo remains native; document shortcuts support Ctrl/Cmd+Z, Ctrl+Y and Ctrl/Cmd+Shift+Z outside editable controls.
|
||||
- Document loads clear old controls and previews. Late save, preview, restore and history responses are scoped to their editor session. Explicitly selected profile `other:n` sections are reachable through Add content.
|
||||
|
||||
## Evidence
|
||||
|
||||
Evidence root: `.artifacts/pre-phase10-ux/`. Original failing runs are retained; overlapping runs are never added together.
|
||||
|
||||
| Lane | Result | Evidence / scope |
|
||||
|---|---|---|
|
||||
| Actual Next development reproduction | Both reported defects reproduced | `runtime-red/`; blank inherited four canonical markers; document Ctrl+Z failed for non-text section reorder |
|
||||
| Focused backend | 108 passed, 0 failed/skipped | `blank-green/blank-green.trx`; resolver, legacy serialization, save/duplicate/restore, empty export allocation, bound AI and matching |
|
||||
| Frontend | 98 passed in 4 suites, 0 failed/skipped | `a-ui-qualified.json`; includes Strict Mode typing, naming, branch redo, conflicts, document switch, restore isolation and explicit additional profile sections |
|
||||
| Development browser | 4 passed, 0 failed/skipped/flaky | `a-browser/results-final.json`; EN1440/NB390 blank save/reload/template Apply, UI+API empty-export rejection, unchanged canonical source, persisted section-order shortcuts and native text undo |
|
||||
| Accessibility automation | Four scoped scans: 0 violations | Each retains one serious contrast **incomplete**; this does not pass manual contrast/AT acceptance |
|
||||
| TypeScript | Exit 0 | `tsc --noEmit --incremental false`, current source |
|
||||
| Focused ESLint | Exit 0 | Editor/library/types/tests/translations; full lint remains final integration work |
|
||||
| MariaDB + RD21 | 82 passed, 0 failed/skipped | `a-mariadb-final/mariadb.trx`; full repository helper, generated loopback MariaDB12.2.2 schemas, locked restore and cleanup exit0. Includes2 new blank persistence/design/restore, stale-writer and ownership cases |
|
||||
|
||||
Browser evidence precedes the final additional-section title fallback by one localized catalogue-line change; that addition has its own failing-then-passing frontend test and is also included in the final integrated browser source. No claim of human testing follows from automated widths or screenshot inspection.
|
||||
|
||||
Fresh review identified late restore/history isolation and additional profile-section reachability. Both received new RED tests before correction. The catalogue finding was narrowed to actual nonstandard `other:n` keys: the standard list already includes awards/publications/organisations/references. No standard section was missing. A previous assertion that renaming creates no undo state was updated to verify the new requested transaction and that rejected AI adds no extra history step.
|
||||
|
||||
## Decisions and limits
|
||||
|
||||
Blank creation and undo/redo: development GO. CV integration preservation: conditional; focused legacy and revision paths pass, full regression/PDF qualification remains scheduled on the combined integration branch. B may start after this checkpoint is committed/pushed/remote-verified.
|
||||
|
||||
The initial MariaDB run also passed82, but its test binary preceded the fixture correction from an unknown design ID to actual `minimal`. The final run above rebuilt that source and is authoritative; these runs are not additive. No provider fallback or production connection was used.
|
||||
|
||||
Session history is bounded to60 steps and is not restored after refresh; the editor explains this. Historical settings revisions remain available separately. Human accessibility, physical devices, native-language review, contrast and printing remain open. CV-model compatibility/accuracy remain BLOCKED. Production remains NO-GO. Phase10 cleanup has not begun.
|
||||
@@ -0,0 +1,29 @@
|
||||
# CV Builder reproduction and correction
|
||||
|
||||
Date: 2026-10-27. Baseline: accepted CV-QA `4a8c907`; corrective branch `codex/cv-builder-blank-undo`. Audit checkpoint `5d4b8f2` is pushed. Production and live-model gates remain closed.
|
||||
|
||||
## Observed failures before application changes
|
||||
|
||||
Actual development browser used the repository Playwright configuration: Next development server (`next dev --webpack`), local Release API, isolated synthetic SQLite data, disabled AI/background workers, and loopback-only browser requests. No production resources or personal documents were used.
|
||||
|
||||
- Creating a new CV inherited four independent canonical markers: name, headline, summary and employment role. The creation dialog had no explicit Blank choice. Career Profile itself remained unchanged.
|
||||
- After a section reorder was saved and read back, Ctrl+Z with the Undo toolbar button focused left the sections reordered. Expected document undo did not occur.
|
||||
- An initial headline-only keyboard check passed through native browser text undo. This is retained as an observation, not used to prove document shortcuts worked. The authoritative failure uses a non-text reorder.
|
||||
|
||||
Raw runtime evidence: `.artifacts/pre-phase10-ux/runtime-red/results.json` (original one failure and one native-text pass); `reorder-results.json` (one focused failure), with screenshots and Playwright traces in their result directories. These are separate runs, not additive suite totals.
|
||||
|
||||
Backend RED: six requirement failures and one legacy-preservation pass in `blank-red/blank-red.trx`. Library RED: two failures in `blank-ui-red.json`. The initial four-suite frontend run included those two newly added failures: 87 passed/2 failed, so it is not a fully green starting baseline.
|
||||
|
||||
Editor RED reproduced typing-burst history, missing document shortcuts/name history, enabled Undo after revision conflict, previous document controls remaining visible while the next document loads, and automatic section hydration despite explicit empty settings. A subsequent historical-restore test reproduced a late response setting a conflict on another document. Harness syntax/navigation setup failures are retained but do not count as product reproductions.
|
||||
|
||||
## Correction contract
|
||||
|
||||
New creation defaults to Blank: explicit empty section selection and excluded profile contact. From Career Profile offers actual available sections and optional contact, initially unselected. Existing documents without the new nullable flags retain historical inheritance. Design selection changes presentation; duplication and restore preserve the stored selection. Empty export fails with a translated explanation before allocating a PDF worker.
|
||||
|
||||
Interactive history records immutable name/settings snapshots, bounded to 60 undo steps within the current editor session. Changes in one focused text field within one second form a typing burst; focus changes and separate controls end that burst. Structural actions are individual steps. New edits clear redo. Autosave acknowledgements preserve history; reload, historical restore and opening another document reset it. History is not a frozen document revision.
|
||||
|
||||
Outside editable controls, Ctrl/Cmd+Z undoes, Ctrl+Y or Ctrl/Cmd+Shift+Z redoes. Native input/textarea/contenteditable shortcuts remain native. Revision conflicts disable document history and retain the draft for explicit recovery. Save success still requires the established version-aware server acknowledgement; no history operation bypasses RD21.
|
||||
|
||||
## Qualification in progress
|
||||
|
||||
Focused backend: 108 passed, zero failed/skipped (`blank-green/blank-green.trx`). TypeScript: exit zero. This does not substitute for real MariaDB, RD21, the browser result, full regression or human/device acceptance. Final checkpoint evidence and independent verdicts will be recorded after those applicable lanes complete.
|
||||
@@ -0,0 +1,45 @@
|
||||
# PRE-P10-UX design and scope
|
||||
|
||||
27 October 2026. Owner specification: CV-QA Addendum/PRE-P10-UX and appended administrator attribution/failure-diagnostics requirements, supplied in the attachment `03bb825f-4d45-40ea-8574-548831f24611/Pasted text.txt`.
|
||||
|
||||
The owner explicitly authorises autonomous safe implementation, validation, commits and pushes in the stated sequence. Routine design choices below are engineering decisions under that authorisation, not a claim that the owner separately reviewed this document. No additional design approval loop is required. Production, main merge, live-model activation and original Phase10 cleanup remain excluded.
|
||||
|
||||
## Verified entry
|
||||
|
||||
The completed CV-QA baseline is `4a8c907d70cbed235728f7d690b5409e72b93abe`, with remote equality verified. Its conditional acceptance permits development/QA; extraction, human/device/print and production qualifications remain limited. It contains `ef425c2` and CV-UX `4a0af62`. An attached managed worktree isolates unrelated local Docker/tooling/security work. Corrective branch: `codex/cv-builder-blank-undo`.
|
||||
|
||||
## A: blank creation and interactive history
|
||||
|
||||
Root causes found in source: `sections=[]` means inherit defaults on both server resolver and editor hydration; contact is always inherited; document AI/application matching also treat absent rows as visible. Undo mutates history inside React state updater callbacks, contains settings but not the document name, and has no document-level keyboard handling. Runtime reproduction and failing tests must precede fixes.
|
||||
|
||||
Use nullable settings-JSON flags `sectionsExplicit` and `includeProfileContact`. Missing flags retain legacy behavior and serialization/no-op compatibility. Blank explicitly selects zero sections and suppresses inherited contact/header/photo. Manual custom sections remain CV-specific content; adding existing Career Profile content is a deliberate action. From Career Profile exposes actual available-section/contact choices. A template changes presentation; duplicate preserves content and flags. Every resolver/context consumer must use the same inclusion semantics. Empty resolved content receives clear export validation before PDF allocation; no fictional placeholders are inserted.
|
||||
|
||||
Alternatives rejected: changing the meaning of every existing empty list would rewrite old CVs; hiding seeded defaults only in the UI would leave render/AI/matching leaks. A new master-profile or revision store is unnecessary.
|
||||
|
||||
Interactive undo/redo uses immutable document snapshots, pure state updates and bounded session-only stacks. Text editing groups by focus/edit burst; structural/design actions are individual transactions. New edits discard redo. Autosave uses the existing queue and expected server revision; success is acknowledged only after durable save. Conflict reload/restore/new-document hydration deliberately resets session history, while ordinary saves preserve it. Native input/contenteditable undo remains native; document shortcuts operate outside editable controls. History cannot cross document IDs, and delayed saves/previews must not rehydrate another document.
|
||||
|
||||
## B: versioned template quality and optional photographs
|
||||
|
||||
Keep all nine existing IDs for compatibility; this is the justified alternative to a numerical6–8 target. The catalogue already spans the requested layout families. Improve actual distinctness/spacing rather than add near-duplicates. Audit short/medium/long/dense equivalents across all nine, render PDF page images and inspect them.
|
||||
|
||||
Known source defects/limits: sidebar CSS ignores configured sidebar palette; Technical's usable identity width is30mm after padding, causing aggressive word wrapping; conservative section keep-together rules can produce sparse pages. Measure before changing shared pagination. Introduce an opt-in design revision if new styling would alter legacy lens renders; missing revision stays legacy. New creation/explicit template Apply may opt into revised styling. Duplicate/restore retain the chosen revision; frozen stored HTML/bytes remain untouched. Reuse licensed embedded fonts and existing photo upload/storage/crop pipeline; add accessible crop alternatives and explicit CV entry points where needed. No generated portraits or second image store.
|
||||
|
||||
## C: interactive dashboard
|
||||
|
||||
Replace form-heavy arrangement with an edit mode on the actual widgets: drag handles, resizing, accessible visibility/catalogue controls, save/discard/reset, keyboard movement/resizing and status announcements. Keep widget data/actions and the existing preference hook/owner/revision contract. Assess the current official React-Grid-Layout API/licence/React compatibility/bundle cost before selecting it; avoid reimplementing a collision engine without evidence.
|
||||
|
||||
Schema v2 stays in existing preference JSON/revision columns. Deterministic migration preserves v1 stored order, size and visibility; no automatic reset or write on viewport change. Persist bounded geometry with responsive behavior and per-widget minima; retain hidden geometry and future-widget rules. Old-schema clients cannot overwrite newer stored layout data. Phone/touch interactions preserve scrolling and functional widget controls; drag is never the only authoring method. No dashboard SQL migration is needed.
|
||||
|
||||
## D: administrator processing monitor
|
||||
|
||||
Reuse real `UserOperation`, queue/leases, CV runs and actual task-specific job-subject decoding. Current rows provide latest state, not historical transitions. Add a bounded owned operation-event table with monotonic cursor, UTC time, attempt and allowlisted event/status/stage/error codes. Append only successful transitions atomically; do not save unrelated tracked entities or log every heartbeat. Do not invent history for old rows. Include additive migration, retention, account erasure/export and actual MariaDB tests.
|
||||
|
||||
Dedicated server-admin-authorised list/detail/event APIs project safe metadata. Existing admin audit permits cross-owner identity/job metadata; ordinary APIs remain owner-scoped. Every association is checked against the operation owner. Display original user ID plus current permitted name/email; CV imports show no linked application, safe retained file metadata and known page count only. Deleted/mismatched relationships remain unavailable. No existing user-detail route is assumed.
|
||||
|
||||
Use bounded cursor polling with pause/resume/reconnect, filters, pagination and finite retention; SSE is optional, not required infrastructure. No raw document/prompt/completion/request/exception/stack/connection/lease data enters events or responses. Sanitisation occurs before storage/transmission. Unknown failures remain unknown. Operation GUID is the correlation key; propagate safe correlation across actual worker/backend/sidecar boundaries. Read-only monitoring avoids new privileged cancellation/retry paths unless separately proven safe. Audit any sensitive administrative action through existing owners. Disabled/disconnected model assistance is shown honestly; no live inference is started.
|
||||
|
||||
## Publication, preservation and acceptance
|
||||
|
||||
After validated A, create `feature/cv-template-library-review` from accepted A; then `feature/dashboard-interactive-grid` from accepted B; then `feature/admin-processing-monitor` from accepted C. Consolidate on `test/pre-phase10-ux-acceptance` from accepted D. No disconnected branch omits earlier work. Each checkpoint has focused RED/GREEN evidence, review, documentation, commit/push and remote check. Final regression serialises native build/browser/provider lanes and retains independent counts/source/runtime identities.
|
||||
|
||||
Preserve all57 capability groups/33 routes, canonical Career Profile, overrides, document/history/letter/application revisions, ownership/authentication, v1 dashboard data and licensed physical rendering. Keep old evidence/goldens; if intentional new design output differs, use separate versioned specimens rather than conceal regressions. Human/device/native-language/print/live-model/deployed acceptance is only recorded when actually performed. Final independent verdicts cover all requested areas, with owner actions and Phase10-vs-production scopes. Stop after the new Phase10 handoff; do not start cleanup.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Owner feedback and acceptance register
|
||||
|
||||
27 October 2026. Baseline4a8c907. Entries are requested work, not passing claims.
|
||||
|
||||
| ID | Owner requirement | Entry evidence / intended verification | State |
|
||||
|---|---|---|---|
|
||||
| A1 | Truly blank CV, zero content sections/header facts, no fictional defaults | Actual development RED followed by explicit inclusion, shared resolver/AI/matcher, design/copy/restore/provider and empty-export checks. See A_ACCEPTANCE.md. | Development GO; integrated preservation pending |
|
||||
| A2 | Trustworthy undo/redo and native shortcuts | Actual non-text shortcut RED, Strict Mode grouping, naming, autosave/conflict/session isolation, persisted EN/NB shortcuts and RD21 provider checks. | Development GO; human/device and combined regression pending |
|
||||
| B1 | Audit every template and fix spacing/alignment/page flow | Nine current IDs; sidebar palette ignored, narrow identity geometry and conservative packing identified. Actual per-template rendering pending. | Audit prepared |
|
||||
| B2 | Six–eight distinct quality styles or justified count | Retain9 existing IDs for compatibility; improve identities rather than add duplicates. Need actual visual proof. | Engineering count decision; qualification pending |
|
||||
| B3 | Optional secure portrait/upload/crop/preview/remove/PDF | Existing shared avatar/crop/storage pipeline; keyboard crop controls absent. | Reuse identified; new acceptance pending |
|
||||
| C1 | Direct draggable/resizable dashboard with catalogue/reset/view/edit | Existing v1 order/standard/full preferences and revision-safe hook; no arbitrary geometry. | Audit prepared |
|
||||
| C2 | Preserve responsive/hidden/old/concurrent preferences | v2 migration and no viewport-triggered writes/reset; real pointer/keyboard/provider tests required. | Pending |
|
||||
| D1 | Admin-only actual processing task/status/stage/stats/filter view | Existing UserOperation latest rows; admin identity precedent; no user detail route. | Audit prepared |
|
||||
| D2 | Safe live event viewer, bounded retained actual history | No durable operation history; retries wipe current-state diagnostics. Add atomic typed events and cursor polling, never raw logs. | Pending |
|
||||
| D3 | User attribution, actual job/import linkage, failure detail/correlation | Operation GUID/owner immutable; subject decoders differ; CV run has no job association. Safe identity/file/error policies and missing/deleted cases required. | Pending |
|
||||
| INT | Full preservation and independent acceptance | Inherit57 groups/33 routes and all prior qualifications. No production/main/live-model/cleanup work. | Pending |
|
||||
|
||||
Original CV-QA/CV-UX acceptance records remain historical. Current final verdicts will be issued only from actual evidence; no owner-approved exception to A1/A2 has been requested or inferred.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Owner additions, 27 October 2026
|
||||
|
||||
The owner extended the active workstream with these requirements; they supplement the original PRE-P10-UX plan.
|
||||
|
||||
- More space below the main title/header in Classic (`ats-classic`), Modern and Code.
|
||||
- Full-height sidebars on every rendered page.
|
||||
- Actual server-rendered CV template previews in the selection gallery.
|
||||
- Matching cover-letter styles for the CV catalogue.
|
||||
- Repair uploads accepted into the queue without extraction.
|
||||
- Make existing local-development options/workers operational, including AI operations and job enrichment.
|
||||
- Create a separate local Ollama instance matching the production model configuration; local downloads are now explicitly authorised. This does not authorise production downloads, cache changes, model replacement or production qualification claims.
|
||||
|
||||
## Initial findings (before local remediation)
|
||||
|
||||
The running local stack is `jobjakt-dev`, from the primary checkout's independently modified standalone development compose file. Backend and frontend bind loopback8081/80, with an isolated MariaDB and Mailpit. Its six automation flags are all false. This file is unrelated existing local work and must not be replaced by the older compose override tracked in this worktree.
|
||||
|
||||
The local sidecar points at `http://host.docker.internal:11434` on an internal Docker network. A read-only tags request failed with network unreachable; no native local Ollama executable, process or model manifest cache was found. Thus enabling a boolean alone cannot establish working inference.
|
||||
|
||||
Repository production `.env` names `qwen2.5:7b` and a Gemini provider; these are configuration observations, not verification of the deployed server. Local inference will remain self-hosted. No external keys or production personal data are copied. Exact deployed model-digest parity remains unverified until read-only current metadata establishes it.
|
||||
|
||||
The official `qwen2.5:7b` manifest was fetched from the Ollama registry. Manifest SHA256: `845dbda0ea48ed749caafd9e6037047aa19acfcfd82e704d7ca97d631a0b697e`; model layer SHA256: `2bada8a7450677000f678be90653b85d364de7db25eb5ea54136ada5f3933730`; layers total4,683,086,845bytes. Downloaded bytes must be independently verified before inference. This is local configuration parity with the recorded model name, not a permanent model selection or an AI-accuracy acceptance.
|
||||
|
||||
Local runtime target: official Ollama0.40.11 linux/amd64 image digest `sha256:459985308d740b99071719baec2740a60f5b2b2d9b835c171991ae25690203da`. Docker has approximately16GB RAM and16CPUs; host approximately32GB. Use bounded CPU execution/concurrency and independent dev storage. Do not alter production services or host drivers.
|
||||
|
||||
## Checkpoint additions
|
||||
|
||||
- [ ] B-owner: qualify title spacing/full-page sidebar backgrounds and real template thumbnails; match letter designs through versioned settings and existing revision owners.
|
||||
- [ ] Import-recovery: document-only worker can consume prior document imports while model jobs remain disabled; disabled workers return an actionable503; real Docling journey verifies completion.
|
||||
- [x] Local-options configuration: guarded standalone dev stack identity/loopback/network/storage boundaries; authorised workers enabled; email forced to Mailpit. Account entitlements, schedules and missing provider credentials remain applicable. This is configuration activation, not full feature acceptance.
|
||||
- [x] Local-Ollama: independent pinned container/cache; all official manifest blobs verified; actual constrained JSON CPU inference and authenticated sidecar summary/rewrite checks passed. Deployed digest parity and real CV extraction accuracy remain unqualified.
|
||||
|
||||
Original dashboard/admin/final integration checkpoints remain required. No Phase10 cleanup, main merge or production deployment is authorised.
|
||||
|
||||
## Local runtime result
|
||||
|
||||
`dev/README.md` records reproducible setup, guardrails, dependency pins and measured synthetic checks. Raw records are `.artifacts/pre-phase10-ux/local-ai/{verification,inference-smoke,worker-preflight,workers-applied,sidecar-smoke}.json`. Actual summarisation2.518s and CV rewrite40.997s returned nonempty content. This required the qualified parser-capable sidecar and the previously approved trained DistilBART artifact mounted read-only, with Transformers offline. No production service or cache was accessed.
|
||||
|
||||
The running backend/frontend retain their previous image identities at this configuration checkpoint. A validated source build is still needed for the new configurable backend AI timeout and UI changes. CV-import generative interpretation remains off; real inference here does not qualify source-grounded factual extraction.
|
||||
@@ -0,0 +1,43 @@
|
||||
# PRE-P10-UX implementation plan
|
||||
|
||||
> **For agentic workers:** Use superpowers:executing-plans or subagent-driven-development. Root owns integration, Editor history, serial native evidence and Git. Scoped workers own assigned files only; preserve other edits. Steps use checkbox syntax.
|
||||
|
||||
**Goal:** Deliver correct blank/history authoring, qualified distinct template designs, direct dashboard arrangement and a safe administrator processing/event monitor before cleanup.
|
||||
|
||||
**Architecture:** Extend existing settings/renderer, canonical preference/revision and durable operation owners. No duplicate master profile, image store or queue.
|
||||
|
||||
**Tech Stack:** Existing .NET9/EF/Pomelo/MariaDB, React19/MUI7/TypeScript, Playwright, pinned model-free sidecar/Docling and licensed font/PDF infrastructure.
|
||||
|
||||
**Spec:** Owner addendum and DESIGN.md.
|
||||
|
||||
## Global constraints
|
||||
|
||||
No main merge, production service/database/storage/model/cache/deployment operation, destructive migration, FINN/NAV/LinkedIn initiative or Phase10 cleanup. The owner's 27 October local-model authorisation supersedes the original blanket live-inference restriction for a verified isolated local runtime and synthetic checks only; see OWNER_ADDENDUM.md. All feature branches cumulatively contain4a8c907. Use safe synthetic fixtures, isolated resources and exact independent counts. Commit/push/verify every validated checkpoint. No false human/device/model acceptance.
|
||||
|
||||
## Review focus
|
||||
|
||||
- Explicit empty must survive normalize/save/reopen/design/apply/duplicate/restore and cannot leak master facts into AI/matching.
|
||||
- Strict Mode, native typing undo, burst grouping, in-flight autosave, stale revisions and document switches cannot corrupt history or save another document.
|
||||
- New template spacing must not alter legacy/frozen output; genuine content completeness and actual physical pages outrank compact page counts.
|
||||
- v1 preferences, hidden/future widgets and breakpoint changes must survive v2 geometry without automatic resets or lost concurrent updates.
|
||||
- Admin joins, failure data and event transactions must fail closed without storing sensitive content, exposing foreign ordinary-user APIs or manufacturing missing history/model jobs.
|
||||
|
||||
## Checkpoints
|
||||
|
||||
- [x] P10-UX-00: read owner specification, verify latest CV-QA ancestry/remote/entry, isolate managed worktree, audit four owners and choose compatibility-first design. Create feedback register and branch plan.
|
||||
- [x] A-01: reproduce blank hydration/header/export and Strict Mode history failures in focused tests plus actual local development browser. Save RED evidence; no application fix before reproduction.
|
||||
- [x] A-02: nullable explicit inclusion flags across resolver/editor/AI/matcher, deliberate creation/profile selection, empty export validation; pure immutable grouped session history/shortcuts/epoch guards; focused unit/provider/browser and RD21 checks, review, docs, commit/push/verify. Evidence: A_ACCEPTANCE.md, backend108/frontend98/browser4/MariaDB82, TypeScript/lint0; fresh review fixes have RED/GREEN tests. Commit is the source-bearing checkpoint immediately following5d4b8f2.
|
||||
- [ ] B-00: branch from accepted A; inventory9 IDs, render equivalent short/medium/long/dense samples, establish measured spacing/pagination defects and legacy source/PDF proof.
|
||||
- [ ] B-01: opt-in revised design semantics and template-specific spacing/palette/identity fixes; portrait controls reuse secure existing storage/crop with keyboard alternatives; tests and per-template Windows/Linux visual evidence; commit/push/verify.
|
||||
- [ ] C-00: branch from accepted B; verify official grid API/licence/compatibility/bundle, define v2 JSON and v1 migration tests.
|
||||
- [ ] C-01: interactive real widget grid/edit/catalogue with geometry, min sizes, keyboard/touch alternatives, conflict-safe persistence; unit/provider/drag/resize/reflow/browser coverage; commit/push/verify.
|
||||
- [ ] D-00: branch from accepted C; define exact safe diagnostic/event schema and admin identity/association policies; tests first.
|
||||
- [ ] D-01: atomic structured events/correlation/retention and account rights, safe admin list/stats/detail/events, polling filters/reconnect/pause, EN/Bokmål and responsive UI; security/provider/browser validation; commit/push/verify.
|
||||
- [ ] UX-INT-00: branch `test/pre-phase10-ux-acceptance` from accepted D. Fresh full backend/frontend/ordinary and added browsers/real MariaDB/RD21/Python/Docling/import/contracts/security/type/lint/build/Windows/Linux PDF/preservation; exact source/discovery/native exits, retain failures and fix regressions.
|
||||
- [ ] UX-INT-01: independent feature/overall/Phase10/production verdicts, all57/33 preservation carry-forward, accepted branch commit register, human/operator debt and cleanup-only handoff. Commit/push/remote equality/own-work clean; preserve unrelated primary checkout work; stop.
|
||||
|
||||
## Execution commands and ownership
|
||||
|
||||
Use repository SDK `C:/Users/Connor/.dotnet/dotnet.exe`, existing npm/Jest/Playwright configs, `scripts/mariadb-integration.py`, unchanged original PDF proof helper and model-free sidecar. Native build/browser/provider lanes remain serial; unit-only Node may run independently. Graph-first where a local graph exists, followed by AST-only updates after code changes. New worktree dependencies are installed offline from cached lock files, without touching primary node_modules.
|
||||
|
||||
Root owns `CvBuilderEditor.tsx` and Editor history tests; A worker may own settings/resolver/creation/matching/export and library/types/tests/translation additions after explicit assignment. Template and dashboard/admin audits are read-only until preceding feature entry gates pass. Later file ownership will be recorded at dispatch.
|
||||
@@ -0,0 +1,148 @@
|
||||
import { expect, test, type APIRequestContext, type Page } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import { emptyStructuredCv } from '../src/profileCv';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
|
||||
async function login(request: APIRequestContext, email: string) {
|
||||
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await request.get(`${api}/auth/me`)).json();
|
||||
return { 'X-CSRF-TOKEN': (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value,
|
||||
'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
|
||||
async function owner(page: Page, admin: APIRequestContext, language: 'en' | 'nb' = 'en') {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `pre-p10-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
const profile = { ...emptyStructuredCv(), contact: { ...emptyStructuredCv().contact,
|
||||
fullName: 'Canonical Identity Marker', headline: 'Canonical Headline Marker', email: 'canonical-contact@example.test' },
|
||||
summary: ['Canonical Summary Marker'], jobs: [{ id: 'pre-p10-job', title: 'Canonical Role Marker', company: 'Synthetic Employer', bullets: ['Canonical Detail Marker'] }] };
|
||||
const before = await (await page.request.get(`${api}/career/profile`)).json();
|
||||
expect((await page.request.put(`${api}/career/profile`, { headers, data: { expectedVersion: before.revision, profile } })).status()).toBe(200);
|
||||
expect((await page.request.put(`${api}/auth/preferences/language`, { headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.addInitScript(language => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', 'light'); }, language);
|
||||
await page.setViewportSize({ width: language === 'nb' ? 390 : 1440, height: 1000 });
|
||||
return headers;
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ context }) => {
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
|
||||
? route.continue() : route.abort();
|
||||
});
|
||||
});
|
||||
|
||||
for (const language of ['en', 'nb'] as const) {
|
||||
test(`[PRE-P10 A] ${language} blank CV survives rename, reload, template apply and empty export validation`, async ({ page, playwright }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const t = translations[language];
|
||||
const admin = await playwright.request.newContext();
|
||||
let headers: Record<string, string>;
|
||||
try { headers = await owner(page, admin, language); } finally { await admin.dispose(); }
|
||||
const sourceBefore = await (await page.request.get(`${api}/career/profile`)).json();
|
||||
await page.goto('/career/builder');
|
||||
await page.getByRole('button', { name: t.cvDashboardNewCv, exact: true }).click();
|
||||
const dialog = page.getByRole('dialog', { name: t.cvDashboardCreateTitle, exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true }).check();
|
||||
await expect(dialog.getByRole('radio', { name: t.cvDashboardStartBlank, exact: true })).toBeChecked();
|
||||
const dialogAxe = await new AxeBuilder({ page }).include('[role="dialog"]').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach('create-dialog-axe', { body: JSON.stringify({ violations: dialogAxe.violations, incomplete: dialogAxe.incomplete }), contentType: 'application/json' });
|
||||
expect(dialogAxe.violations).toEqual([]);
|
||||
await dialog.getByLabel(t.cvEditorName, { exact: false }).fill('New blank CV');
|
||||
const createdResponse = page.waitForResponse(response => response.url() === `${api}/cv/variants` && response.request().method() === 'POST');
|
||||
await dialog.getByRole('button', { name: t.cvDashboardCreateCv, exact: true }).click();
|
||||
const response = await createdResponse;
|
||||
expect(response.status()).toBe(200);
|
||||
const created = await response.json();
|
||||
await expect(page).toHaveURL(new RegExp(`/career/builder/${created.id}$`));
|
||||
const read = async () => (await page.request.get(`${api}/cv/variants/${created.id}`)).json();
|
||||
const assertBlank = async () => {
|
||||
const saved = await read();
|
||||
expect(saved.settings).toMatchObject({ sectionsExplicit: true, includeProfileContact: false, sections: [] });
|
||||
const preview = await page.request.get(`${api}/cv/variants/${created.id}/preview`);
|
||||
expect(preview.status()).toBe(200);
|
||||
const rendered = await preview.json();
|
||||
for (const marker of ['Canonical Identity Marker', 'Canonical Headline Marker', 'Canonical Summary Marker', 'Canonical Role Marker', 'canonical-contact@example.test']) {
|
||||
expect(rendered.html.includes(marker), `Blank CV must not inherit ${marker}`).toBe(false);
|
||||
}
|
||||
return saved;
|
||||
};
|
||||
await expect(page.getByText(t.cvEditorEmptyBody, { exact: true })).toBeVisible();
|
||||
await assertBlank();
|
||||
await page.getByLabel(t.cvEditorName, { exact: true }).fill('Saved empty CV');
|
||||
await expect.poll(async () => (await read()).name).toBe('Saved empty CV');
|
||||
await page.reload();
|
||||
await expect(page.getByLabel(t.cvEditorName, { exact: true })).toHaveValue('Saved empty CV');
|
||||
await expect(page.getByRole('checkbox', { name: t.cvEditorIncludeProfileContact, exact: true })).not.toBeChecked();
|
||||
await assertBlank();
|
||||
await page.getByRole('tab', { name: t.cvEditorTemplate, exact: true }).click();
|
||||
await page.getByRole('button', { name: t.cvTemplateMinimalName, exact: true }).click();
|
||||
await page.getByRole('button', { name: t.cvEditorApplyTemplate, exact: true }).click();
|
||||
await expect.poll(async () => (await read()).settings.themeId).toBe('minimal');
|
||||
await assertBlank();
|
||||
await page.getByRole('tab', { name: t.cvEditorContent, exact: true }).click();
|
||||
const editorAxe = await new AxeBuilder({ page }).include('#cv-studio-panel-1').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach('blank-content-axe', { body: JSON.stringify({ violations: editorAxe.violations, incomplete: editorAxe.incomplete }), contentType: 'application/json' });
|
||||
expect(editorAxe.violations).toEqual([]);
|
||||
const exportResponse = page.waitForResponse(response => new URL(response.url()).pathname === `/api/cv/variants/${created.id}/export-pdf` && response.request().method() === 'POST');
|
||||
await page.getByRole('button', { name: t.cvEditorDownloadPdf, exact: true }).click();
|
||||
const exported = await exportResponse;
|
||||
expect(exported.status()).toBe(400);
|
||||
expect(await exported.json()).toEqual({ code: 'cv_empty_content' });
|
||||
await expect(page.getByText(t.cvEmptyContent, { exact: true })).toBeVisible();
|
||||
const saved = await assertBlank();
|
||||
const independent = await page.request.post(`${api}/cv/variants/${created.id}/export-pdf?expectedVersion=${saved.version}`, { headers });
|
||||
expect(independent.status()).toBe(400);
|
||||
expect(await independent.json()).toEqual({ code: 'cv_empty_content' });
|
||||
expect(await (await page.request.get(`${api}/career/profile`)).json()).toEqual(sourceBefore);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await info.attach('blank-roundtrip', { body: JSON.stringify({ language, created, saved, exportStatus: exported.status(), sourceUnchanged: true }), contentType: 'application/json' });
|
||||
await page.screenshot({ path: info.outputPath('blank-roundtrip.png'), fullPage: true });
|
||||
});
|
||||
|
||||
test(`[PRE-P10 A] ${language} document undo and both redo shortcuts preserve native text undo`, async ({ page, playwright }, info) => {
|
||||
test.setTimeout(180_000);
|
||||
const t = translations[language];
|
||||
const admin = await playwright.request.newContext();
|
||||
let headers: Record<string, string>;
|
||||
try { headers = await owner(page, admin, language); } finally { await admin.dispose(); }
|
||||
const response = await page.request.post(`${api}/cv/variants`, { headers, data: { name: 'Keyboard undo runtime reproduction', settings: { themeId: 'minimal', sections: [{ key: 'summary' }, { key: 'experience' }] } } });
|
||||
expect(response.status()).toBe(200);
|
||||
const cv = await response.json();
|
||||
await page.goto(`/career/builder/${cv.id}`);
|
||||
const headline = page.getByLabel(t.cvEditorHeadlineOverride, { exact: true });
|
||||
await expect(headline).toBeVisible();
|
||||
const readOrder = async () => (await (await page.request.get(`${api}/cv/variants/${cv.id}`)).json()).settings.sections.slice(0, 2).map((section: { key: string }) => section.key);
|
||||
await page.getByRole('button', { name: t.cvEditorMoveSectionDown.replace('{section}', t.cvSectionSummary), exact: true }).click();
|
||||
const undo = page.getByRole('button', { name: t.cvEditorUndo, exact: true });
|
||||
const redo = page.getByRole('button', { name: t.cvEditorRedo, exact: true });
|
||||
await expect(undo).toBeEnabled();
|
||||
await expect.poll(readOrder).toEqual(['experience', 'summary']);
|
||||
for (const shortcut of ['Control+z', 'Control+y', 'Control+z', 'Control+Shift+z']) {
|
||||
const reverting = shortcut === 'Control+z';
|
||||
await (reverting ? undo : redo).focus();
|
||||
await page.keyboard.press(shortcut);
|
||||
await expect.poll(readOrder, { message: shortcut }).toEqual(reverting ? ['summary', 'experience'] : ['experience', 'summary']);
|
||||
}
|
||||
await headline.focus();
|
||||
await headline.pressSequentially('Native input marker');
|
||||
await expect(headline).toHaveValue('Native input marker');
|
||||
await page.evaluate(() => { document.addEventListener('keydown', event => {
|
||||
if (event.ctrlKey && event.key.toLowerCase() === 'z') document.documentElement.dataset.nativeUndoPrevented = String(event.defaultPrevented);
|
||||
}); });
|
||||
await page.keyboard.press('Control+z');
|
||||
await expect(headline).not.toHaveValue('Native input marker');
|
||||
await expect(headline).toBeFocused();
|
||||
expect(await page.evaluate(() => document.documentElement.dataset.nativeUndoPrevented)).toBe('false');
|
||||
expect(await readOrder()).toEqual(['experience', 'summary']);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await info.attach('shortcut-roundtrip', { body: JSON.stringify({ language, finalOrder: await readOrder(), nativeInputValue: await headline.inputValue(), nativeUndoPrevented: false }), contentType: 'application/json' });
|
||||
await page.screenshot({ path: info.outputPath('shortcut-roundtrip.png'), fullPage: true });
|
||||
});
|
||||
}
|
||||
@@ -26,6 +26,8 @@ jest.mock('./api', () => ({
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const originalRequest = global.Request;
|
||||
const originalAbortController = global.AbortController;
|
||||
|
||||
const variant = {
|
||||
id: 3,
|
||||
@@ -38,12 +40,12 @@ const variant = {
|
||||
updatedAtUtc: '2026-07-19T10:00:00Z',
|
||||
};
|
||||
|
||||
function renderAt(id: number) {
|
||||
function renderAt(id: number, strict = false) {
|
||||
const router = createMemoryRouter([
|
||||
{ path: '/career/builder/:id', element: <CvBuilderEditor /> },
|
||||
{ path: '/career/builder', element: <div>CV list destination</div> },
|
||||
], { initialEntries: [`/career/builder/${id}`] });
|
||||
return render(
|
||||
const tree = (
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<ConfirmProvider>
|
||||
@@ -52,8 +54,9 @@ function renderAt(id: number) {
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</ToastProvider>
|
||||
</I18nProvider>,
|
||||
</I18nProvider>
|
||||
);
|
||||
return { ...render(strict ? <React.StrictMode>{tree}</React.StrictMode> : tree), router };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -65,7 +68,11 @@ beforeEach(() => {
|
||||
mockedApi.post.mockResolvedValue({ data: { themeId: 'nordic', html: '<p>cv</p>', suggestedFileName: 'cv.pdf' } } as any);
|
||||
});
|
||||
|
||||
afterEach(() => window.localStorage.removeItem('uiLanguage'));
|
||||
afterEach(() => {
|
||||
window.localStorage.removeItem('uiLanguage');
|
||||
global.Request = originalRequest;
|
||||
global.AbortController = originalAbortController;
|
||||
});
|
||||
|
||||
function routeGet(onVariant: () => Promise<any>, outline: any = { sections: [] }, history: any[] = []) {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
@@ -82,6 +89,143 @@ async function openContentTab() {
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
}
|
||||
|
||||
test('Strict Mode groups a typing burst into one undo step and preserves redo through autosave', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3, true);
|
||||
await openContentTab();
|
||||
const headline = screen.getByLabelText('Headline override');
|
||||
act(() => headline.focus());
|
||||
fireEvent.change(headline, { target: { value: 'D' } });
|
||||
fireEvent.change(headline, { target: { value: 'Developer' } });
|
||||
act(() => headline.blur());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Saved');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(headline).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redo' }));
|
||||
expect(headline).toHaveValue('Developer');
|
||||
});
|
||||
|
||||
test('document shortcuts undo and redo renaming, preserve native input undo and discard branched redo', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3, true);
|
||||
await openContentTab();
|
||||
const name = screen.getByDisplayValue('Backend CV');
|
||||
act(() => name.focus());
|
||||
fireEvent.change(name, { target: { value: 'Frontend CV' } });
|
||||
fireEvent.keyDown(name, { key: 'z', ctrlKey: true });
|
||||
expect(name).toHaveValue('Frontend CV');
|
||||
act(() => name.blur());
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
expect(name).toHaveValue('Backend CV');
|
||||
fireEvent.keyDown(document, { key: 'y', ctrlKey: true });
|
||||
expect(name).toHaveValue('Frontend CV');
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
fireEvent.keyDown(document, { key: 'Z', ctrlKey: true, shiftKey: true });
|
||||
expect(name).toHaveValue('Frontend CV');
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'New branch' } });
|
||||
expect(screen.getByRole('button', { name: 'Redo' })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('a revision conflict disables document history without silently writing an undo', async () => {
|
||||
mockedApi.put.mockRejectedValue({ response: { status: 409, data: { code: 'cv_revision_conflict' } } });
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Retained local edit' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByRole('button', { name: 'Reload saved CV' });
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('Retained local edit');
|
||||
expect(mockedApi.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('switching documents hides the previous draft while loading and starts fresh history', async () => {
|
||||
// JSDOM omits Node's fetch globals, which React Router needs for navigation.
|
||||
global.Request = jest.requireActual('node:vm').runInThisContext('Request');
|
||||
global.AbortController = jest.requireActual('node:vm').runInThisContext('AbortController');
|
||||
let loadNext!: (value: any) => void;
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
const previousGet = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string, ...args: any[]) => url === '/cv/variants/4'
|
||||
? new Promise(resolve => { loadNext = resolve; }) : previousGet(url, ...args));
|
||||
const { router } = renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.change(screen.getByLabelText('Headline override'), { target: { value: 'Only first document' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
await screen.findByText('Saved');
|
||||
await act(async () => { await router.navigate('/career/builder/4'); });
|
||||
expect(screen.queryByDisplayValue('Backend CV')).not.toBeInTheDocument();
|
||||
expect(screen.queryByDisplayValue('Only first document')).not.toBeInTheDocument();
|
||||
await act(async () => loadNext({ data: { ...variant, id: 4, name: 'Second CV' } }));
|
||||
await screen.findByDisplayValue('Second CV');
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
||||
fireEvent.keyDown(document, { key: 'z', ctrlKey: true });
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('');
|
||||
});
|
||||
|
||||
test('explicit blank editing excludes master content and includes a profile section only after an Add action', async () => {
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
|
||||
sectionsExplicit: true, includeProfileContact: false,
|
||||
} } }), { headline: 'Private master title', sections: [
|
||||
{ key: 'summary', kind: 'bullets', title: 'Professional Summary', bullets: ['Private master summary'] },
|
||||
] });
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
expect(screen.queryByRole('button', { name: 'Expand Professional Summary' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox', { name: 'Go to section' })).toHaveAttribute('aria-disabled', 'true');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add content' }));
|
||||
const card = screen.getByText('Professional Summary', { selector: 'h6' }).parentElement!;
|
||||
fireEvent.click(within(card).getByRole('button', { name: 'Add' }));
|
||||
expect(await screen.findByRole('button', { name: 'Expand Professional Summary' })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(screen.queryByRole('button', { name: 'Expand Professional Summary' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a blank CV can deliberately add an additional Career Profile section', async () => {
|
||||
routeGet(() => Promise.resolve({ data: { ...variant, settings: { ...variant.settings,
|
||||
sectionsExplicit: true, includeProfileContact: false,
|
||||
} } }), { sections: [
|
||||
{ key: 'other:0', kind: 'bullets', title: 'Community service', bullets: ['Explicitly sourced volunteering'] },
|
||||
] });
|
||||
renderAt(3);
|
||||
await openContentTab();
|
||||
expect(screen.queryByRole('button', { name: 'Expand Community service' })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add content' }));
|
||||
const card = screen.getByText('Community service', { selector: 'h6' }).parentElement!;
|
||||
fireEvent.click(within(card).getByRole('button', { name: 'Add' }));
|
||||
expect(await screen.findByRole('button', { name: 'Expand Community service' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('a historical restore response from the previous document cannot change the newly opened CV', async () => {
|
||||
global.Request = jest.requireActual('node:vm').runInThisContext('Request');
|
||||
global.AbortController = jest.requireActual('node:vm').runInThisContext('AbortController');
|
||||
let finishRestore!: (value: any) => void;
|
||||
routeGet(() => Promise.resolve({ data: variant }), { sections: [] }, [
|
||||
{ version: 3, source: 'manual', createdAtUtc: variant.updatedAtUtc, isCurrent: false },
|
||||
]);
|
||||
const previousGet = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string, ...args: any[]) => url === '/cv/variants/4'
|
||||
? Promise.resolve({ data: { ...variant, id: 4, name: 'Second CV' } } as any) : previousGet(url, ...args));
|
||||
mockedApi.post.mockImplementation((url: string) => url.endsWith('/restore')
|
||||
? new Promise(resolve => { finishRestore = resolve; })
|
||||
: Promise.resolve({ data: { html: '<p>CV</p>' } } as any));
|
||||
const { router } = renderAt(3);
|
||||
await openContentTab();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'History' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Restore version 3' }));
|
||||
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', { name: 'Restore version' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants/3/versions/3/restore', { expectedVersion: 4 }));
|
||||
await act(async () => { await router.navigate('/career/builder/4'); });
|
||||
await screen.findByDisplayValue('Second CV');
|
||||
await act(async () => finishRestore({ data: { ...variant, name: 'Old restored name', version: 5 } }));
|
||||
expect(screen.getByDisplayValue('Second CV')).toBeInTheDocument();
|
||||
expect(screen.queryByText('CV changed elsewhere')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('content sections follow the editing-region heading without mutating CV settings', async () => {
|
||||
routeGet(() => Promise.resolve({ data: variant }));
|
||||
renderAt(3);
|
||||
@@ -999,6 +1143,11 @@ test('a newer draft is flushed before stale proposal acceptance fails without re
|
||||
expect(screen.getByDisplayValue('Newer retained name')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Content' }));
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('');
|
||||
// Renaming is now an editor transaction; the rejected proposal must add no history step.
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeEnabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Undo' }));
|
||||
expect(screen.getByDisplayValue('Backend CV')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Headline override')).toHaveValue('');
|
||||
expect(screen.getByRole('button', { name: 'Undo' })).toBeDisabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ToastProvider } from './toast';
|
||||
import { api } from './api';
|
||||
import { ConfirmProvider } from './confirm';
|
||||
import { PromptProvider } from './prompt';
|
||||
import { emptyCvVariantSettings } from './cvBuilder';
|
||||
import { emptyCvVariantSettings, getCvExportFailureKey } from './cvBuilder';
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
@@ -82,6 +82,73 @@ async function selectAction(name: string) {
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
test('blank creation explicitly excludes all profile sections and contact even after template selection', async () => {
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
|
||||
renderPage();
|
||||
await screen.findByText('No CVs yet');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Clean' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({
|
||||
settings: expect.objectContaining({ themeId: 'clean', sectionsExplicit: true, includeProfileContact: false, sections: [], customSections: [], overrides: {} }),
|
||||
})));
|
||||
});
|
||||
|
||||
test('profile creation includes only deliberately selected available sections and contact', async () => {
|
||||
mockedApi.get.mockImplementation(async (url) => ({ data: url === '/cv/themes' ? themes : url === '/cv/outline'
|
||||
? { fullName: 'Synthetic Person', sections: [
|
||||
{ key: 'skills', title: 'Skills', kind: 'tags', tags: ['TypeScript'], bullets: [], entries: [] },
|
||||
{ key: 'experience', title: 'Experience', kind: 'entries', tags: [], bullets: [], entries: [{ key: 'job-1', title: 'Engineer', bullets: [], tags: [] }] },
|
||||
] } : [] }) as any);
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
|
||||
renderPage();
|
||||
await screen.findByText('No CVs yet');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: 'From Career Profile' }));
|
||||
const skills = await within(dialog).findByRole('checkbox', { name: 'Skills' });
|
||||
expect(skills).not.toBeChecked();
|
||||
expect(within(dialog).getByRole('checkbox', { name: 'Experience' })).not.toBeChecked();
|
||||
const contact = within(dialog).getByRole('checkbox', { name: 'Include Career Profile contact details' });
|
||||
expect(contact).not.toBeChecked();
|
||||
fireEvent.click(skills);
|
||||
fireEvent.click(contact);
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({
|
||||
settings: expect.objectContaining({ sectionsExplicit: true, includeProfileContact: true, sections: [{ key: 'skills' }] }),
|
||||
})));
|
||||
});
|
||||
|
||||
test('unavailable profile sections block profile creation and allow an explicit blank fallback', async () => {
|
||||
mockedApi.get.mockImplementation(async (url) => {
|
||||
if (url === '/cv/outline') throw new Error('Synthetic profile read failure');
|
||||
return { data: url === '/cv/themes' ? themes : [] } as any;
|
||||
});
|
||||
mockedApi.post.mockResolvedValueOnce({ data: { id: 42 } } as any);
|
||||
renderPage();
|
||||
await screen.findByText('No CVs yet');
|
||||
fireEvent.click(screen.getAllByRole('button', { name: 'New CV' })[0]);
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Create a CV' });
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: 'From Career Profile' }));
|
||||
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Could not load Career Profile sections.');
|
||||
expect(within(dialog).getByRole('button', { name: 'Create CV' })).toBeDisabled();
|
||||
fireEvent.click(within(dialog).getByRole('radio', { name: 'Start blank' }));
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({
|
||||
settings: expect.objectContaining({ sections: [], includeProfileContact: false }),
|
||||
})));
|
||||
});
|
||||
|
||||
test('export validation decodes structured and blob errors without relabelling unknown failures', async () => {
|
||||
expect(await getCvExportFailureKey({ response: { status: 400, data: { code: 'cv_empty_content' } } })).toBe('cvEmptyContent');
|
||||
const body = new Blob();
|
||||
Object.defineProperty(body, 'text', { value: async () => '{"code":"cv_empty_content"}' });
|
||||
expect(await getCvExportFailureKey({ response: { status: 400, data: body } })).toBe('cvEmptyContent');
|
||||
expect(await getCvExportFailureKey({ response: { status: 400, data: { code: 'unrelated' } } })).toBeUndefined();
|
||||
expect(await getCvExportFailureKey(new Error('network'))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the CV library has a section heading and announces its held initial read without showing an empty result', async () => {
|
||||
const read = deferred<any>();
|
||||
mockedApi.get.mockImplementation((url) => url === '/cv/variants' ? read.promise : Promise.resolve({ data: themes }) as any);
|
||||
@@ -168,7 +235,7 @@ test('held CV creation locks the draft and gallery, then keeps a failed draft fo
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: emptyCvVariantSettings('clean') }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenLastCalledWith('/cv/variants', { name: 'Application-specific CV', settings: { ...emptyCvVariantSettings('clean'), sectionsExplicit: true, includeProfileContact: false } }));
|
||||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/career/builder/42'));
|
||||
});
|
||||
|
||||
@@ -185,7 +252,7 @@ test('a locked CV template remains identifiable and cannot change the creation r
|
||||
fireEvent.keyDown(premium, { key: 'Enter' });
|
||||
expect(within(dialog).getByRole('button', { name: 'Modern' })).toHaveAttribute('aria-pressed', 'true');
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create CV' }));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: emptyCvVariantSettings('modern') })));
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/cv/variants', expect.objectContaining({ settings: { ...emptyCvVariantSettings('modern'), sectionsExplicit: true, includeProfileContact: false } })));
|
||||
});
|
||||
|
||||
test('a held duplicate blocks another action for the same CV and shows the persisted refreshed copy', async () => {
|
||||
|
||||
@@ -51,6 +51,8 @@ export type CvVariantSettings = {
|
||||
showPhoto: boolean;
|
||||
showPageNumbers: boolean;
|
||||
showIcons: boolean;
|
||||
sectionsExplicit?: boolean | null;
|
||||
includeProfileContact?: boolean | null;
|
||||
sections: CvSectionSetting[];
|
||||
overrides: Record<string, CvItemOverride>;
|
||||
customSections: CvCustomSectionSetting[];
|
||||
@@ -241,6 +243,17 @@ export function emptyCvVariantSettings(themeId = "modern"): CvVariantSettings {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCvExportFailureKey(error: unknown): Promise<"cvEmptyContent" | "cvPdfBusy" | "cvPdfTimedOut" | undefined> {
|
||||
const response = (error as { response?: { status?: number; data?: unknown } })?.response;
|
||||
if (response?.status === 503) return "cvPdfBusy";
|
||||
if (response?.status === 504) return "cvPdfTimedOut";
|
||||
if (response?.status !== 400) return undefined;
|
||||
try {
|
||||
const data = response.data instanceof Blob ? JSON.parse(await response.data.text()) : response.data;
|
||||
return (data as { code?: string })?.code === "cv_empty_content" ? "cvEmptyContent" : undefined;
|
||||
} catch { return undefined; }
|
||||
}
|
||||
|
||||
// --- API ---
|
||||
// --- Pure helpers (unit-tested) ---
|
||||
|
||||
|
||||
@@ -887,7 +887,7 @@ export const translations = {
|
||||
cvEditorEditMasterProfile: "Open Career Profile",
|
||||
cvEditorMasterProfileHelp: "Career Profile is your source. Editing wording, order or inclusion here changes only this CV.",
|
||||
cvEditorHeadlineOverride: "Headline override",
|
||||
cvEditorHeadlineHelp: "Blank uses your Career Profile headline. Changes here affect only this CV.",
|
||||
cvEditorHeadlineHelp: "An empty field uses your Career Profile headline only when profile contact is included. Changes here affect only this CV.",
|
||||
cvEditorSections: "Sections",
|
||||
cvEditorSectionsHelp: "Drag to reorder, or use the arrows. Omitted content stays editable.",
|
||||
cvEditorUsingSource: "Using Career Profile",
|
||||
@@ -1109,7 +1109,19 @@ export const translations = {
|
||||
cvDashboardOpen: "Open",
|
||||
cvDashboardDelete: "Delete",
|
||||
cvDashboardCreateTitle: "Create a CV",
|
||||
cvDashboardCreateHelp: "Start with your saved career profile, choose a visual direction, then tailor what appears.",
|
||||
cvDashboardCreateHelp: "Choose your content source and a template. Templates change presentation only.",
|
||||
cvDashboardContentSource: "CV content source",
|
||||
cvDashboardStartBlank: "Start blank",
|
||||
cvDashboardFromProfile: "From Career Profile",
|
||||
cvDashboardBlankHelp: "Start with no sections or inherited contact details. Add your own content in the editor.",
|
||||
cvDashboardProfileSelectionHelp: "Select only the Career Profile sections and contact details you want to include. Nothing is selected automatically.",
|
||||
cvDashboardProfileLoadFailed: "Could not load Career Profile sections. Try again or start blank.",
|
||||
cvDashboardProfileEmpty: "Your Career Profile has no sections yet. You can add content in the editor.",
|
||||
cvEditorUndoSessionHelp: "Undo and redo apply to this editing session. Saved versions remain available in History.",
|
||||
cvEditorEmptyBody: "Your CV has no sections yet. Add a section to begin.",
|
||||
cvEditorProfileContentHint: "Adding a Career Profile section includes its saved content. Custom sections start empty.",
|
||||
cvEditorIncludeProfileContact: "Include Career Profile contact details",
|
||||
cvEmptyContent: "Add content to your CV before exporting a PDF.",
|
||||
cvDashboardEnterName: "Enter a name.",
|
||||
cvDashboardNameExample: "For example: Backend Engineer — Acme",
|
||||
cvDashboardChooseTemplate: "Choose a template",
|
||||
@@ -3887,7 +3899,7 @@ export const translations = {
|
||||
cvEditorEditMasterProfile: "Åpne Karriereprofil",
|
||||
cvEditorMasterProfileHelp: "Karriereprofil er kilden din. Endringer i tekst, rekkefølge og synlighet her gjelder bare denne CV-en.",
|
||||
cvEditorHeadlineOverride: "Egen overskrift",
|
||||
cvEditorHeadlineHelp: "Tomt felt bruker overskriften fra Karriereprofil. Endringer her gjelder bare denne CV-en.",
|
||||
cvEditorHeadlineHelp: "Et tomt felt bruker overskriften fra Karriereprofil bare når profilens kontaktinformasjon er inkludert. Endringer her gjelder bare denne CV-en.",
|
||||
cvEditorSections: "Seksjoner",
|
||||
cvEditorSectionsHelp: "Dra for å endre rekkefølgen, eller bruk pilene. Utelatt innhold kan fortsatt redigeres.",
|
||||
cvEditorUsingSource: "Bruker Karriereprofil",
|
||||
@@ -4109,7 +4121,19 @@ export const translations = {
|
||||
cvDashboardOpen: "Åpne",
|
||||
cvDashboardDelete: "Slett",
|
||||
cvDashboardCreateTitle: "Opprett en CV",
|
||||
cvDashboardCreateHelp: "Start med den lagrede karriereprofilen, velg en visuell retning og tilpass deretter hva som skal vises.",
|
||||
cvDashboardCreateHelp: "Velg innholdskilde og mal. Maler endrer bare utformingen.",
|
||||
cvDashboardContentSource: "Innholdskilde for CV",
|
||||
cvDashboardStartBlank: "Start med en tom CV",
|
||||
cvDashboardFromProfile: "Fra karriereprofilen",
|
||||
cvDashboardBlankHelp: "Start uten seksjoner eller kontaktopplysninger fra profilen. Legg til eget innhold i redigeringsverktøyet.",
|
||||
cvDashboardProfileSelectionHelp: "Velg seksjonene og kontaktopplysningene fra karriereprofilen som du vil ta med. Ingenting velges automatisk.",
|
||||
cvDashboardProfileLoadFailed: "Kunne ikke laste seksjonene fra karriereprofilen. Prøv igjen eller start med en tom CV.",
|
||||
cvDashboardProfileEmpty: "Karriereprofilen har ingen seksjoner ennå. Du kan legge til innhold i redigeringsverktøyet.",
|
||||
cvEditorUndoSessionHelp: "Angre og gjør om gjelder denne redigeringsøkten. Lagrede versjoner er fortsatt tilgjengelige i historikken.",
|
||||
cvEditorEmptyBody: "CV-en har ingen seksjoner ennå. Legg til en seksjon for å begynne.",
|
||||
cvEditorProfileContentHint: "Når du legger til en seksjon fra karriereprofilen, tas det lagrede innholdet med. Egendefinerte seksjoner starter tomme.",
|
||||
cvEditorIncludeProfileContact: "Ta med kontaktopplysninger fra karriereprofilen",
|
||||
cvEmptyContent: "Legg til innhold i CV-en før du eksporterer en PDF.",
|
||||
cvDashboardEnterName: "Skriv inn et navn.",
|
||||
cvDashboardNameExample: "For eksempel: Backendutvikler — Acme",
|
||||
cvDashboardChooseTemplate: "Velg en mal",
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Link as RouterLink, useBlocker, useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, IconButton,
|
||||
Alert, Box, Button, Checkbox, Chip, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton,
|
||||
Menu, MenuItem, Paper, Skeleton, Stack, Tab, Tabs, TextField, Tooltip, Typography,
|
||||
} from "@mui/material";
|
||||
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||
@@ -36,7 +36,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import {
|
||||
CUSTOM_SECTION_PRESETS, CvAiCallbacks, CvAiInput, CvAiProposal, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
||||
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
|
||||
cvBuilderApi, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
|
||||
cvBuilderApi, getCvExportFailureKey, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
|
||||
} from "../cvBuilder";
|
||||
import { previewDocumentHtml } from "../cvPreview";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
@@ -46,6 +46,11 @@ import ViewStateNotice from "../components/ViewStateNotice";
|
||||
|
||||
const MIN_PREVIEW_ZOOM = 0.2;
|
||||
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict";
|
||||
type EditorSnapshot = { name: string; settings: CvVariantSettings };
|
||||
|
||||
function isTextInput(element: Element | null) {
|
||||
return !!element?.matches('textarea,input:not([type=checkbox]):not([type=radio]):not([type=range]),[contenteditable=true]');
|
||||
}
|
||||
|
||||
export default function CvBuilderEditor() {
|
||||
const { id } = useParams();
|
||||
@@ -101,15 +106,27 @@ export default function CvBuilderEditor() {
|
||||
const loadRegion = useRef<HTMLDivElement | null>(null);
|
||||
const retryFocus = useRef(false);
|
||||
const blockerPromptOpen = useRef(false);
|
||||
const undoStack = useRef<CvVariantSettings[]>([]);
|
||||
const redoStack = useRef<CvVariantSettings[]>([]);
|
||||
const undoStack = useRef<EditorSnapshot[]>([]);
|
||||
const redoStack = useRef<EditorSnapshot[]>([]);
|
||||
const typingGroup = useRef<{ element: Element; time: number } | null>(null);
|
||||
const loadedVariantId = useRef<number | null>(null);
|
||||
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
|
||||
const editorReady = settings !== null;
|
||||
const editorReady = settings !== null && loadedVariantId.current === variantId && !opening;
|
||||
const previewBusy = previewing || (!previewError && (!html || measuredHtml !== html));
|
||||
const previewReady = !!html && measuredHtml === html && !previewBusy && !previewError;
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
loadedVariantId.current = null;
|
||||
latestSettings.current = null;
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
previewRequest.current += 1;
|
||||
setSettings(null);
|
||||
setHtml("");
|
||||
setMeasuredHtml(null);
|
||||
setVersions([]);
|
||||
setSaveState("idle");
|
||||
setOpening(true);
|
||||
(async () => {
|
||||
try {
|
||||
@@ -132,6 +149,8 @@ export default function CvBuilderEditor() {
|
||||
return () => {
|
||||
alive = false;
|
||||
editorEpoch.current += 1;
|
||||
previewRequest.current += 1;
|
||||
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||
};
|
||||
}, [variantId, loadAttempt]);
|
||||
|
||||
@@ -148,6 +167,7 @@ export default function CvBuilderEditor() {
|
||||
editorEpoch.current += 1;
|
||||
serverVersion.current = variant.version;
|
||||
revisionConflict.current = false;
|
||||
loadedVariantId.current = variant.id;
|
||||
setName(variant.name);
|
||||
setSettings(variant.settings);
|
||||
latestName.current = variant.name;
|
||||
@@ -158,12 +178,13 @@ export default function CvBuilderEditor() {
|
||||
setSaveState("saved");
|
||||
undoStack.current = [];
|
||||
redoStack.current = [];
|
||||
typingGroup.current = null;
|
||||
setHistoryRevision((value) => value + 1);
|
||||
};
|
||||
|
||||
// Debounced live preview.
|
||||
useEffect(() => {
|
||||
if (!settings) return;
|
||||
if (!editorReady || !settings) return;
|
||||
const request = ++previewRequest.current;
|
||||
setPreviewing(true);
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
@@ -183,7 +204,7 @@ export default function CvBuilderEditor() {
|
||||
return () => {
|
||||
if (previewTimer.current) clearTimeout(previewTimer.current);
|
||||
};
|
||||
}, [settings, previewRevision]);
|
||||
}, [settings, previewRevision, editorReady]);
|
||||
|
||||
const performSave = useCallback(async (next: CvVariantSettings, nextName: string, revision: number) => {
|
||||
const epoch = editorEpoch.current;
|
||||
@@ -260,7 +281,7 @@ export default function CvBuilderEditor() {
|
||||
|
||||
const acceptAi = async (proposal: CvAiProposal, result?: string) => {
|
||||
const { epoch, revision } = await flushAiSource();
|
||||
const previous = latestSettings.current!;
|
||||
const previous = { name: latestName.current, settings: latestSettings.current! };
|
||||
const apply = async () => {
|
||||
if (epoch !== editorEpoch.current || revision !== saveRevision.current || revisionConflict.current) throw new Error(t("cvAiFailed"));
|
||||
try {
|
||||
@@ -274,6 +295,7 @@ export default function CvBuilderEditor() {
|
||||
}
|
||||
undoStack.current = [...undoStack.current.slice(-59), previous];
|
||||
redoStack.current = [];
|
||||
typingGroup.current = null;
|
||||
saveRevision.current += 1;
|
||||
persistedRevision.current = saveRevision.current;
|
||||
latestSettings.current = saved.settings;
|
||||
@@ -299,46 +321,67 @@ export default function CvBuilderEditor() {
|
||||
await queued;
|
||||
};
|
||||
|
||||
const editDocument = (next: CvVariantSettings, nextName = latestName.current) => {
|
||||
const previous = latestSettings.current;
|
||||
if (!editorReady || !previous || (nextName === latestName.current && JSON.stringify(next) === JSON.stringify(previous))) return;
|
||||
const element = document.activeElement;
|
||||
const time = Date.now();
|
||||
const grouped = isTextInput(element) && typingGroup.current?.element === element && time - typingGroup.current.time < 1000;
|
||||
if (!grouped) undoStack.current = [...undoStack.current.slice(-59), { name: latestName.current, settings: previous }];
|
||||
typingGroup.current = isTextInput(element) ? { element: element!, time } : null;
|
||||
redoStack.current = [];
|
||||
setSettings(next);
|
||||
setName(nextName);
|
||||
scheduleSave(next, nextName);
|
||||
setHistoryRevision(value => value + 1);
|
||||
};
|
||||
|
||||
const update = (patch: Partial<CvVariantSettings>) => {
|
||||
setSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const next = { ...prev, ...patch };
|
||||
undoStack.current = [...undoStack.current.slice(-59), prev];
|
||||
redoStack.current = [];
|
||||
setHistoryRevision((value) => value + 1);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
if (latestSettings.current) editDocument({ ...latestSettings.current, ...patch });
|
||||
};
|
||||
|
||||
const undo = () => {
|
||||
setSettings((current) => {
|
||||
const previous = undoStack.current.pop();
|
||||
if (!current || !previous) return current;
|
||||
redoStack.current.push(current);
|
||||
setHistoryRevision((value) => value + 1);
|
||||
scheduleSave(previous);
|
||||
return previous;
|
||||
});
|
||||
if (!editorReady || revisionConflict.current || !latestSettings.current) return;
|
||||
const previous = undoStack.current.pop();
|
||||
if (!previous) return;
|
||||
redoStack.current.push({ name: latestName.current, settings: latestSettings.current });
|
||||
typingGroup.current = null;
|
||||
setSettings(previous.settings);
|
||||
setName(previous.name);
|
||||
scheduleSave(previous.settings, previous.name);
|
||||
setHistoryRevision(value => value + 1);
|
||||
};
|
||||
|
||||
const redo = () => {
|
||||
setSettings((current) => {
|
||||
const next = redoStack.current.pop();
|
||||
if (!current || !next) return current;
|
||||
undoStack.current.push(current);
|
||||
setHistoryRevision((value) => value + 1);
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
if (!editorReady || revisionConflict.current || !latestSettings.current) return;
|
||||
const next = redoStack.current.pop();
|
||||
if (!next) return;
|
||||
undoStack.current.push({ name: latestName.current, settings: latestSettings.current });
|
||||
typingGroup.current = null;
|
||||
setSettings(next.settings);
|
||||
setName(next.name);
|
||||
scheduleSave(next.settings, next.name);
|
||||
setHistoryRevision(value => value + 1);
|
||||
};
|
||||
|
||||
const renameVariant = (value: string) => {
|
||||
setName(value);
|
||||
latestName.current = value;
|
||||
if (settings) scheduleSave(settings, value);
|
||||
if (latestSettings.current) editDocument(latestSettings.current, value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const shortcut = (event: KeyboardEvent) => {
|
||||
const target = event.target instanceof Element ? event.target : document.activeElement;
|
||||
if (event.defaultPrevented || event.isComposing || event.altKey || !(event.ctrlKey || event.metaKey)
|
||||
|| target?.closest('input,textarea,select,[contenteditable=true]') || !editorReady || revisionConflict.current) return;
|
||||
const key = event.key.toLowerCase();
|
||||
const wantsRedo = key === "y" || (key === "z" && event.shiftKey);
|
||||
if (wantsRedo && redoStack.current.length) { event.preventDefault(); redo(); }
|
||||
else if (key === "z" && !event.shiftKey && undoStack.current.length) { event.preventDefault(); undo(); }
|
||||
};
|
||||
document.addEventListener("keydown", shortcut);
|
||||
return () => document.removeEventListener("keydown", shortcut);
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = saveState === "unsaved" || saveState === "saving" || saveState === "error" || saveState === "conflict";
|
||||
|
||||
const downloadUnsavedCopy = () => {
|
||||
@@ -353,10 +396,11 @@ export default function CvBuilderEditor() {
|
||||
const reloadSavedVariant = async () => {
|
||||
if (!(await confirmAction(t("cvEditorConflictReloadMessage"), { title: t("cvEditorConflictReload"), confirmLabel: t("cvEditorConflictReload"), destructive: true }))) return;
|
||||
const revision = saveRevision.current;
|
||||
const epoch = editorEpoch.current;
|
||||
await saveQueue.current;
|
||||
try {
|
||||
const saved = await cvBuilderApi.get(variantId);
|
||||
if (saveRevision.current === revision) applyVariant(saved);
|
||||
if (editorEpoch.current === epoch && saveRevision.current === revision) applyVariant(saved);
|
||||
}
|
||||
catch (error) { toast(getApiErrorMessage(error, t("cvEditorOpenFailed")), "error"); }
|
||||
};
|
||||
@@ -438,8 +482,8 @@ export default function CvBuilderEditor() {
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
toast(status === 503 ? t("cvPdfBusy") : status === 504 ? t("cvPdfTimedOut") : getApiErrorMessage(err, t("cvEditorPdfFailed")), "error");
|
||||
const key = await getCvExportFailureKey(err);
|
||||
toast(key ? t(key) : getApiErrorMessage(err, t("cvEditorPdfFailed")), "error");
|
||||
} finally {
|
||||
exportInFlight.current = false;
|
||||
setExporting(false);
|
||||
@@ -461,26 +505,32 @@ export default function CvBuilderEditor() {
|
||||
};
|
||||
|
||||
const loadVersions = async () => {
|
||||
const epoch = editorEpoch.current;
|
||||
try {
|
||||
setVersions(await cvBuilderApi.versions(variantId));
|
||||
const result = await cvBuilderApi.versions(variantId);
|
||||
if (epoch === editorEpoch.current) setVersions(result);
|
||||
} catch (err) {
|
||||
toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error");
|
||||
if (epoch === editorEpoch.current) toast(getApiErrorMessage(err, t("cvEditorHistoryFailed")), "error");
|
||||
}
|
||||
};
|
||||
|
||||
const restore = async (version: number) => {
|
||||
const epoch = editorEpoch.current;
|
||||
if (!(await confirmAction(t("cvEditorRestoreMessage", { version }), {
|
||||
title: t("cvEditorRestoreTitle", { version }),
|
||||
confirmLabel: t("cvEditorRestoreVersion"),
|
||||
}))) return;
|
||||
if (epoch !== editorEpoch.current) return;
|
||||
if (hasUnsavedChanges && !(await retrySave())) {
|
||||
toast(t("cvEditorSaveBeforeRestore"), "error");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveQueue.current;
|
||||
if (epoch !== editorEpoch.current) return;
|
||||
const revision = saveRevision.current;
|
||||
const updated = await cvBuilderApi.restore(variantId, version, serverVersion.current);
|
||||
if (epoch !== editorEpoch.current) return;
|
||||
if (saveRevision.current === revision) applyVariant(updated);
|
||||
else {
|
||||
// Edits made while restore was in flight remain recoverable; never replace them on response.
|
||||
@@ -490,6 +540,7 @@ export default function CvBuilderEditor() {
|
||||
await loadVersions();
|
||||
toast(t("cvEditorRestoredVersion", { version }), "success");
|
||||
} catch (err) {
|
||||
if (epoch !== editorEpoch.current) return;
|
||||
const code = (err as { response?: { data?: { code?: string } } })?.response?.data?.code;
|
||||
if (code === "cv_revision_conflict" || code === "cv_revision_required") {
|
||||
revisionConflict.current = true;
|
||||
@@ -550,13 +601,14 @@ export default function CvBuilderEditor() {
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (!settings) return <EditorSkeleton />;
|
||||
if (!settings || !editorReady) return <EditorSkeleton />;
|
||||
|
||||
const canUndo = historyRevision >= 0 && undoStack.current.length > 0;
|
||||
const canRedo = historyRevision >= 0 && redoStack.current.length > 0;
|
||||
const canUndo = !revisionConflict.current && historyRevision >= 0 && undoStack.current.length > 0;
|
||||
const canRedo = !revisionConflict.current && historyRevision >= 0 && redoStack.current.length > 0;
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5} data-content-density="comfortable" sx={{
|
||||
<Stack spacing={1.5} data-content-density="comfortable" onBlurCapture={() => { typingGroup.current = null; }}
|
||||
onPointerDownCapture={event => { if ((event.target as Element).closest('button,[role=button]')) typingGroup.current = null; }} sx={{
|
||||
minWidth: 0,
|
||||
"--cv-pane-top": "calc(var(--app-shell-header-height, 76px) + 12px)",
|
||||
"& [tabindex], & button, & input, & textarea, & [role=combobox]": { scrollMarginTop: "var(--cv-pane-top)" },
|
||||
@@ -612,6 +664,7 @@ export default function CvBuilderEditor() {
|
||||
<Tooltip describeChild title={t("cvEditorRedo")}><span><IconButton size="small" aria-label={t("cvEditorRedo")} disabled={!canRedo} onClick={redo}><RedoIcon fontSize="small" /></IconButton></span></Tooltip>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ px: 1.5 }}>{t("cvEditorUndoSessionHelp")}</Typography>
|
||||
<Tabs aria-label={t("cvEditorStudioNavigation")} value={tab} onChange={(_, v) => { setTab(v); if (v === 4) loadVersions(); }} variant="scrollable" scrollButtons="auto" allowScrollButtonsMobile sx={{ borderBottom: "1px solid", borderColor: "divider" }}>
|
||||
{[1, 0, 2, 3, 4].map((value) => <Tab key={value} value={value} id={`cv-studio-tab-${value}`} aria-controls={`cv-studio-panel-${value}`} label={t((["cvEditorTemplate", "cvEditorContent", "cvEditorCustomize", "cvEditorAiTools", "cvEditorHistory"] as const)[value])} />)}
|
||||
</Tabs>
|
||||
@@ -755,12 +808,13 @@ type DocumentAiBlock = {
|
||||
function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null): DocumentAiBlock[] {
|
||||
const blocks: DocumentAiBlock[] = [];
|
||||
const configured = new Map(settings.sections.map((section) => [section.key, section]));
|
||||
if ((settings.headline ?? outline?.headline)?.trim()) {
|
||||
blocks.push({ id: "headline", items: [(settings.headline ?? outline?.headline ?? "").trim()] });
|
||||
const headline = settings.headline ?? (settings.includeProfileContact !== false ? outline?.headline : null);
|
||||
if (headline?.trim()) {
|
||||
blocks.push({ id: "headline", items: [headline.trim()] });
|
||||
}
|
||||
for (const section of outline?.sections ?? []) {
|
||||
const sectionSetting = configured.get(section.key);
|
||||
if (sectionSetting?.hidden) continue;
|
||||
if (sectionSetting?.hidden || (settings.sectionsExplicit && !sectionSetting)) continue;
|
||||
const title = sectionSetting?.title ?? section.title;
|
||||
if (section.kind === "entries") {
|
||||
const entryKeys = Array.from(new Set(section.entries.map((entry, index) => entry.key ?? `${index}`)));
|
||||
@@ -786,6 +840,7 @@ function documentAiBlocks(settings: CvVariantSettings, outline: CvOutline | null
|
||||
}
|
||||
}
|
||||
for (const section of settings.customSections ?? []) {
|
||||
if (settings.sectionsExplicit && !configured.has(`custom:${section.key}`)) continue;
|
||||
if (!(configured.get(`custom:${section.key}`)?.hidden ?? section.hidden)) blocks.push({ id: `custom:${section.key}`, title: section.title, items: section.items });
|
||||
}
|
||||
return blocks.filter((block) => block.title?.trim() || block.subtitle?.trim() || block.items?.some((item) => item.trim()));
|
||||
@@ -982,8 +1037,9 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(() => new Set());
|
||||
const [reorderMessage, setReorderMessage] = useState("");
|
||||
const sectionButtons = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
// Full section list = configured order (once touched) else default, always including every known key.
|
||||
// Legacy documents inherit profile sections; new explicit documents include only chosen keys.
|
||||
const sectionRows: CvSectionSetting[] = useMemo(() => {
|
||||
if (settings.sectionsExplicit) return settings.sections;
|
||||
const populated = new Set((outline?.sections ?? []).map((section) => section.key));
|
||||
const base: CvSectionSetting[] = settings.sections.length ? [...settings.sections] : DEFAULT_SECTION_ORDER.map((key) => ({ key, hidden: !populated.has(key) }));
|
||||
const have = new Set(base.map((s) => s.key));
|
||||
@@ -1000,7 +1056,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
}
|
||||
}
|
||||
return base;
|
||||
}, [outline?.sections, settings.customSections, settings.sections]);
|
||||
}, [outline?.sections, settings.customSections, settings.sections, settings.sectionsExplicit]);
|
||||
|
||||
const writeSections = (rows: CvSectionSetting[]) => update({ sections: rows });
|
||||
const reorderSections = (from: number, to: number, retainFocus = false) => {
|
||||
@@ -1060,7 +1116,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
setAddContentOpen(false);
|
||||
};
|
||||
const addBuiltIn = (key: string) => {
|
||||
patchSection(key, { hidden: false });
|
||||
if (sectionRows.some(row => row.key === key)) patchSection(key, { hidden: false });
|
||||
else writeSections([...sectionRows, { key, title: outlineByKey[key]?.title }]);
|
||||
setAddContentOpen(false);
|
||||
};
|
||||
const updateCustom = (key: string, patch: Partial<CvCustomSectionSetting>) =>
|
||||
@@ -1081,7 +1138,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
<Stack spacing={2}>
|
||||
<Stack direction="row" gap={1} alignItems="center" flexWrap="wrap">
|
||||
<Box component="nav" aria-label={t("cvEditorSectionNavigation")} sx={{ flex: "1 1 14rem", minWidth: 0 }}>
|
||||
<TextField select fullWidth size="small" label={t("cvEditorGoToSection")}
|
||||
<TextField select fullWidth size="small" disabled={!sectionRows.length} label={t("cvEditorGoToSection")}
|
||||
value={sectionRows.some((row) => row.key === activeSection) ? activeSection : ""}
|
||||
onChange={(event) => setActiveSection(event.target.value)}>
|
||||
{sectionRows.map((row) => <MenuItem key={row.key} value={row.key} onClick={() => goToSection(row.key)}>
|
||||
@@ -1094,6 +1151,8 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
<Alert severity="info" sx={{ flexWrap: "wrap", "& .MuiAlert-message": { flex: "1 1 12rem", minWidth: 0, overflowWrap: "anywhere" }, "& .MuiAlert-action": { ml: "auto" } }} action={<Button component={RouterLink} to="/career?section=profile" size="small">{t("cvEditorEditMasterProfile")}</Button>}>
|
||||
{t("cvEditorMasterProfileHelp")}
|
||||
</Alert>
|
||||
<FormControlLabel control={<Checkbox checked={settings.includeProfileContact !== false}
|
||||
onChange={(_, checked) => update({ includeProfileContact: checked })} />} label={t("cvEditorIncludeProfileContact")} />
|
||||
<TextField label={t("cvEditorHeadlineOverride")} size="small" fullWidth value={settings.headline ?? ""}
|
||||
onChange={(e) => update({ headline: e.target.value || null })}
|
||||
helperText={t("cvEditorHeadlineHelp")} />
|
||||
@@ -1102,6 +1161,7 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
<Typography component="h3" variant="subtitle2" sx={{ fontWeight: 800, mb: 0.5 }}>{t("cvEditorSections")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("cvEditorSectionsHelp")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" role="status" aria-label={t("cvEditorSectionOrderStatus")} sx={{ display: "block", minHeight: "1.5em" }}>{reorderMessage}</Typography>
|
||||
{!sectionRows.length && <Alert severity="info">{t("cvEditorEmptyBody")}</Alert>}
|
||||
<Stack spacing={0.5} sx={{ mt: 1 }}>
|
||||
{sectionRows.map((row, i) => (
|
||||
<SectionRow
|
||||
@@ -1146,11 +1206,12 @@ function ContentTab({ settings, update, outline, showPreviewEntry, onFocusPrevie
|
||||
<DialogTitle id="cv-add-content-title" sx={{ fontWeight: 850 }}>{t("cvEditorAddContent")}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t("cvEditorAddContentHelp")}</Typography>
|
||||
{settings.sectionsExplicit && <Alert severity="info" sx={{ mb: 2 }}>{t("cvEditorProfileContentHint")}</Alert>}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", md: "repeat(3, minmax(0, 1fr))" }, gap: 1 }}>
|
||||
{DEFAULT_SECTION_ORDER.map((key) => {
|
||||
{Array.from(new Set([...DEFAULT_SECTION_ORDER, ...(outline?.sections ?? []).map(section => section.key)])).map((key) => {
|
||||
const row = sectionRows.find((section) => section.key === key);
|
||||
const added = !!row && !row.hidden;
|
||||
const label = sectionName(key, SECTION_LABELS[key] ?? key, t);
|
||||
const label = sectionName(key, outlineByKey[key]?.title ?? SECTION_LABELS[key] ?? key, t);
|
||||
return <AddContentCard key={key} title={label} description={t("cvEditorAddBuiltInHelp", { section: label })} added={added} onAdd={() => addBuiltIn(key)} />;
|
||||
})}
|
||||
{CUSTOM_SECTION_PRESETS.map((preset) => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Menu, MenuItem, Paper, Stack, TextField, Typography,
|
||||
Alert, Box, Button, Checkbox, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, IconButton, Menu, MenuItem, Paper, Radio, RadioGroup, Stack, TextField, Typography,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import MoreVertIcon from "@mui/icons-material/MoreVert";
|
||||
@@ -10,7 +10,7 @@ import PublicIcon from "@mui/icons-material/Public";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings } from "../cvBuilder";
|
||||
import { CvOutline, CvTheme, CvVariantSummary, cvBuilderApi, emptyCvVariantSettings, getCvExportFailureKey } from "../cvBuilder";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import CvTemplateGallery from "../components/CvTemplateGallery";
|
||||
import CvTemplateThumbnail from "../components/CvTemplateThumbnail";
|
||||
@@ -45,6 +45,20 @@ export default function CvBuilderPage() {
|
||||
const [newName, setNewName] = useState(t("cvDashboardUntitled"));
|
||||
const [newTheme, setNewTheme] = useState("modern");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [contentSource, setContentSource] = useState("blank");
|
||||
const [profileOutline, setProfileOutline] = useState<CvOutline | null>(null);
|
||||
const [profileLoading, setProfileLoading] = useState(false);
|
||||
const [profileFailed, setProfileFailed] = useState(false);
|
||||
const [selectedSections, setSelectedSections] = useState<string[]>([]);
|
||||
const [includeContact, setIncludeContact] = useState(false);
|
||||
|
||||
const loadProfile = async () => {
|
||||
setProfileLoading(true);
|
||||
setProfileFailed(false);
|
||||
try { setProfileOutline(await cvBuilderApi.outline()); }
|
||||
catch { setProfileFailed(true); }
|
||||
finally { setProfileLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (pending) contentRef.current?.focus();
|
||||
@@ -77,7 +91,12 @@ export default function CvBuilderPage() {
|
||||
setCreateError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: emptyCvVariantSettings(newTheme) });
|
||||
const variant = await cvBuilderApi.create({ name: newName.trim(), settings: {
|
||||
...emptyCvVariantSettings(newTheme),
|
||||
sectionsExplicit: true,
|
||||
includeProfileContact: contentSource === "profile" && includeContact,
|
||||
sections: contentSource === "profile" ? selectedSections.map((key) => ({ key })) : [],
|
||||
} });
|
||||
setCreateOpen(false);
|
||||
navigate(`/career/builder/${variant.id}`);
|
||||
} catch (err) {
|
||||
@@ -144,8 +163,8 @@ export default function CvBuilderPage() {
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status;
|
||||
setActionError({ error: status === 503 || status === 504 ? undefined : err, key: status === 503 ? "cvPdfBusy" : status === 504 ? "cvPdfTimedOut" : "cvDashboardDownloadFailed" });
|
||||
const key = await getCvExportFailureKey(err);
|
||||
setActionError({ error: key ? undefined : err, key: key ?? "cvDashboardDownloadFailed" });
|
||||
} finally { finishAction(); }
|
||||
};
|
||||
|
||||
@@ -216,10 +235,27 @@ export default function CvBuilderPage() {
|
||||
<Typography id="create-cv-help" color="text.secondary" sx={{ mb: 2 }}>{t("cvDashboardCreateHelp")}</Typography>
|
||||
{createError && <Alert id="create-cv-error" severity="error" sx={{ mb: 2 }}>{getApiErrorMessage(createError.error, t("cvDashboardCreateFailed"))}</Alert>}
|
||||
<TextField autoFocus fullWidth disabled={creating} required label={t("cvEditorName")} value={newName} onChange={(event) => setNewName(event.target.value)} error={!newName.trim()} helperText={!newName.trim() ? t("cvDashboardEnterName") : t("cvDashboardNameExample")} sx={{ mb: 2, "& .MuiFormHelperText-root.Mui-disabled": { color: "text.secondary" } }} />
|
||||
<RadioGroup aria-label={t("cvDashboardContentSource")} value={contentSource} onChange={(event) => {
|
||||
setContentSource(event.target.value);
|
||||
if (event.target.value === "profile" && !profileOutline && !profileLoading) void loadProfile();
|
||||
}}>
|
||||
<FormControlLabel value="blank" control={<Radio disabled={creating} />} label={t("cvDashboardStartBlank")} />
|
||||
<FormControlLabel value="profile" control={<Radio disabled={creating} />} label={t("cvDashboardFromProfile")} />
|
||||
</RadioGroup>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>{t(contentSource === "profile" ? "cvDashboardProfileSelectionHelp" : "cvDashboardBlankHelp")}</Typography>
|
||||
{contentSource === "profile" && <Stack sx={{ mb: 2 }}>
|
||||
{profileLoading && <Typography role="status">{t("loading")}</Typography>}
|
||||
{profileFailed && <Alert severity="error" action={<Button onClick={() => void loadProfile()}>{t("retry")}</Button>}>{t("cvDashboardProfileLoadFailed")}</Alert>}
|
||||
{profileOutline && <>
|
||||
<FormControlLabel control={<Checkbox disabled={creating} checked={includeContact} onChange={(event) => setIncludeContact(event.target.checked)} />} label={t("cvEditorIncludeProfileContact")} />
|
||||
{profileOutline.sections.length === 0 && <Typography>{t("cvDashboardProfileEmpty")}</Typography>}
|
||||
{profileOutline.sections.map((section) => <FormControlLabel key={section.key} label={section.title} control={<Checkbox disabled={creating} checked={selectedSections.includes(section.key)} onChange={(event) => setSelectedSections((previous) => event.target.checked ? [...previous, section.key] : previous.filter((key) => key !== section.key))} />} />)}
|
||||
</>}
|
||||
</Stack>}
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ mb: 1 }}>{t("cvDashboardChooseTemplate")}</Typography>
|
||||
<CvTemplateGallery disabled={creating} themes={themes.length ? themes : [{ id: "modern", name: t("cvDashboardDefaultTemplate"), category: t("cvEditorTemplate"), description: "", layout: "header-band", accent: "#3157d5", photoShape: "none", supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ["#3157d5", "#eef1f4", "#fff"] }]} selectedId={newTheme} onSelect={(theme) => { if (!actionBusy.current) setNewTheme(theme.id); }} previewHeight={125} showDescriptions={false} />
|
||||
</DialogContent>
|
||||
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim()} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
|
||||
<DialogActions><Button onClick={() => setCreateOpen(false)} disabled={creating}>{t("cancel")}</Button><Button variant="contained" disabled={creating || !newName.trim() || (contentSource === "profile" && (!profileOutline || profileLoading))} onClick={() => void confirmCreate()}>{creating ? t("cvDashboardCreating") : t("cvDashboardCreateCv")}</Button></DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user