feat(cv): qualify templates and reliable local import processing
This commit is contained in:
@@ -79,9 +79,13 @@ public sealed class ApplicationAssetsController : ControllerBase
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (request.DocumentSettings is { SchemaVersion: not 1 }) return BadRequest("Unsupported letter settings schema.");
|
||||
var result = await _assets.SaveCoverLetterAsync(
|
||||
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct, request?.DocumentSettings);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
try
|
||||
{
|
||||
var result = await _assets.SaveCoverLetterAsync(
|
||||
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct, request?.DocumentSettings);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); }
|
||||
}
|
||||
|
||||
[HttpPost("cover-letter/preview")]
|
||||
@@ -90,8 +94,12 @@ public sealed class ApplicationAssetsController : ControllerBase
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
if (request.DocumentSettings is { SchemaVersion: not 1 }) return BadRequest("Unsupported letter settings schema.");
|
||||
var result = await _assets.PreviewCoverLetterAsync(userId, jobId, request.Text, request.DocumentSettings, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
try
|
||||
{
|
||||
var result = await _assets.PreviewCoverLetterAsync(userId, jobId, request.Text, request.DocumentSettings, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); }
|
||||
}
|
||||
|
||||
[HttpPost("cover-letter/export-pdf")]
|
||||
@@ -118,8 +126,12 @@ public sealed class ApplicationAssetsController : ControllerBase
|
||||
{
|
||||
var userId = await CurrentUserIdAsync();
|
||||
if (userId is null) return Unauthorized();
|
||||
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
try
|
||||
{
|
||||
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); }
|
||||
}
|
||||
|
||||
[HttpGet("submitted-packages")]
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace JobTrackerApi.Controllers;
|
||||
[Route("api/auth")]
|
||||
public sealed class AuthController : ControllerBase
|
||||
{
|
||||
private static readonly SemaphoreSlim AvatarProcessingSlots = new(2, 2);
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly UserManager<ApplicationUser> _users;
|
||||
private readonly ITokenService _tokens;
|
||||
@@ -933,9 +934,16 @@ public sealed class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
using var memory = new MemoryStream();
|
||||
await stream.CopyToAsync(memory);
|
||||
var bytes = memory.ToArray();
|
||||
var bytes = new byte[MaxAvatarBytes + 1];
|
||||
var length = 0;
|
||||
while (length < bytes.Length)
|
||||
{
|
||||
var read = await stream.ReadAsync(bytes.AsMemory(length), HttpContext.RequestAborted);
|
||||
if (read == 0) break;
|
||||
length += read;
|
||||
}
|
||||
if (length > MaxAvatarBytes) return BadRequest("Avatar image is too large.");
|
||||
Array.Resize(ref bytes, length);
|
||||
|
||||
var detectedContentType = DetectAvatarContentType(bytes);
|
||||
if (detectedContentType is null)
|
||||
@@ -943,15 +951,40 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
|
||||
}
|
||||
|
||||
user.AvatarImageDataUrl = await AvatarStorage.StoreAsync(_avatarDataRoot, user.Id, bytes, detectedContentType, HttpContext.RequestAborted);
|
||||
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
if (!await AvatarProcessingSlots.WaitAsync(0, HttpContext.RequestAborted))
|
||||
return StatusCode(503, new { code = "avatar_busy", message = "Image processing is busy. Try again shortly." });
|
||||
try
|
||||
{
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
byte[]? normalized;
|
||||
try { normalized = AvatarImageProcessor.Normalize(bytes); }
|
||||
catch (Exception exception) when (exception is DllNotFoundException or TypeInitializationException or EntryPointNotFoundException or BadImageFormatException)
|
||||
{
|
||||
return StatusCode(503, new { code = "avatar_decoder_unavailable", message = "Image processing is unavailable. Your previous photo has been preserved." });
|
||||
}
|
||||
if (normalized is null) return BadRequest("Select a valid, still PNG, JPEG or WebP image up to 2048 pixels per side and 4 megapixels. Crop or resize larger images.");
|
||||
var previous = user.AvatarImageDataUrl;
|
||||
var staged = await AvatarStorage.StageAsync(_avatarDataRoot, user.Id, normalized, HttpContext.RequestAborted);
|
||||
try
|
||||
{
|
||||
user.AvatarImageDataUrl = staged;
|
||||
var result = await _users.UpdateAsync(user);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
user.AvatarImageDataUrl = previous;
|
||||
AvatarStorage.TryDelete(staged);
|
||||
return BadRequest("The photo could not be saved. Your previous photo has been preserved.");
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
user.AvatarImageDataUrl = previous;
|
||||
// An acknowledgement failure may follow a committed identity write; retention checks references.
|
||||
throw;
|
||||
}
|
||||
AvatarStorage.TryDelete(previous);
|
||||
return Ok(new { avatarImageDataUrl = $"data:image/png;base64,{Convert.ToBase64String(normalized)}" });
|
||||
}
|
||||
|
||||
return Ok(new { avatarImageDataUrl = AvatarStorage.Resolve(user.AvatarImageDataUrl) });
|
||||
finally { AvatarProcessingSlots.Release(); }
|
||||
}
|
||||
|
||||
[HttpDelete("avatar")]
|
||||
@@ -972,7 +1005,7 @@ public sealed class AuthController : ControllerBase
|
||||
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
|
||||
}
|
||||
|
||||
AvatarStorage.Delete(storedAvatar);
|
||||
AvatarStorage.TryDelete(storedAvatar);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
var proThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes;
|
||||
var themes = CvThemeCatalog.Themes.Select(t => new
|
||||
var themes = CvThemeCatalog.RevisedThemes.Select(t => new
|
||||
{
|
||||
id = t.Id,
|
||||
name = t.Name,
|
||||
@@ -67,6 +67,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
supportedSettings = t.SupportedSettings,
|
||||
pageMarginMm = t.PageMarginMm,
|
||||
bodyFont = t.BodyFont,
|
||||
headingFont = t.HeadingFont,
|
||||
baseFontSizePt = t.BodySizePt,
|
||||
lineHeight = t.LineHeight,
|
||||
});
|
||||
@@ -82,7 +83,7 @@ public sealed class CvVariantController : ControllerBase
|
||||
if (user is null) return Unauthorized();
|
||||
if (!CvThemeCatalog.Exists(themeId)) return NotFound();
|
||||
var theme = CvThemeCatalog.Resolve(themeId);
|
||||
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, ShowIcons = true });
|
||||
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, DesignVersion = 2, ShowIcons = true });
|
||||
var render = _renderer.Render(TemplatePreviewModel(), theme, settings);
|
||||
return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="SkiaSharp" Version="4.153.1" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.153.1" />
|
||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||
<PackageReference Include="Stripe.net" Version="52.2.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
@@ -6,6 +7,10 @@ namespace JobTrackerApi.Models;
|
||||
public sealed class CoverLetterDocumentSettings
|
||||
{
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? DesignVersion { get; set; }
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? HeadingFont { get; set; }
|
||||
public string Language { get; set; } = "en";
|
||||
public string SenderName { get; set; } = "";
|
||||
public string SenderTitle { get; set; } = "";
|
||||
@@ -38,16 +43,18 @@ public static class CoverLetterDocumentSettingsJson
|
||||
public static CoverLetterDocumentSettings Normalize(CoverLetterDocumentSettings? value)
|
||||
{
|
||||
value ??= new();
|
||||
var theme = CvThemeCatalog.Resolve(value.ThemeId);
|
||||
var theme = CvThemeCatalog.Resolve(value.ThemeId, value.DesignVersion);
|
||||
var style = CvVariantSettingsJson.Normalize(new()
|
||||
{
|
||||
AccentColor = value.AccentColor, BodyFont = value.BodyFont,
|
||||
AccentColor = value.AccentColor, BodyFont = value.BodyFont, HeadingFont = value.HeadingFont,
|
||||
BaseFontSizePt = value.BaseFontSizePt, LineHeight = value.LineHeight, PageMarginMm = value.PageMarginMm,
|
||||
});
|
||||
static string Text(string? text, int limit) => (text ?? "")[..Math.Min(text?.Length ?? 0, limit)];
|
||||
return new()
|
||||
{
|
||||
SchemaVersion = 1, ThemeId = theme.Id,
|
||||
DesignVersion = value.DesignVersion is 1 or 2 ? value.DesignVersion : null,
|
||||
HeadingFont = value.DesignVersion == 2 ? style.HeadingFont ?? theme.HeadingFont : null,
|
||||
Language = value.Language?.Trim().ToLowerInvariant() is "nb" or "nb-no" ? "nb-NO" : "en",
|
||||
SenderName = Text(value.SenderName, 300), SenderTitle = Text(value.SenderTitle, 300),
|
||||
SenderEmail = Text(value.SenderEmail, 300), SenderPhone = Text(value.SenderPhone, 100), SenderLocation = Text(value.SenderLocation, 300),
|
||||
|
||||
@@ -38,6 +38,8 @@ public sealed class CvTheme
|
||||
public double PageMarginMm { get; init; } = 16;
|
||||
public double SectionGapMm { get; init; } = 6;
|
||||
public double EntryGapMm { get; init; } = 4.5;
|
||||
public double NameGapMm { get; init; }
|
||||
public double HeaderContentGapMm { get; init; }
|
||||
|
||||
// plain | band | centered | kicker
|
||||
public string HeaderStyle { get; init; } = "plain";
|
||||
@@ -159,6 +161,45 @@ public static class CvThemeCatalog
|
||||
},
|
||||
};
|
||||
|
||||
// Versioned data keeps existing saved lenses and frozen output on their original design.
|
||||
public static readonly IReadOnlyList<CvTheme> RevisedThemes = Themes.Select(Revised).ToArray();
|
||||
|
||||
private static CvTheme Revised(CvTheme source)
|
||||
{
|
||||
var (nameSize, sectionGap, entryGap) = source.Id switch
|
||||
{
|
||||
"modern" => (25d, 5.8, 4.0),
|
||||
"minimal" => (25d, 7.5, 4.8),
|
||||
"executive" => (28d, 6.5, 4.6),
|
||||
"technical" => (24d, 4.8, 3.2),
|
||||
"ats-classic" => (23d, 5.0, 3.4),
|
||||
"nordic" => (23d, 6.0, 4.2),
|
||||
"elegant" => (27d, 7.5, 5.0),
|
||||
"creative" => (20d, 5.5, 3.8),
|
||||
_ => (25.5, 5.2, 3.6),
|
||||
};
|
||||
return new CvTheme
|
||||
{
|
||||
Id = source.Id, Name = source.Name, Category = source.Category, Description = source.Description,
|
||||
Layout = source.Layout, SidebarWidthMm = 68,
|
||||
Accent = source.Accent, Ink = source.Ink, Muted = source.Muted, Line = source.Line,
|
||||
Paper = source.Paper, SidebarBg = source.SidebarBg, SidebarInk = source.SidebarInk, HeadingColor = source.HeadingColor,
|
||||
HeadingFont = source.HeadingFont, BodyFont = source.BodyFont, UtilityFont = source.UtilityFont,
|
||||
NameSizePt = nameSize, HeadingSizePt = source.HeadingSizePt, BodySizePt = source.BodySizePt, LineHeight = source.LineHeight,
|
||||
PageMarginMm = source.PageMarginMm, SectionGapMm = sectionGap, EntryGapMm = entryGap,
|
||||
NameGapMm = source.Id is "modern" or "ats-classic" or "code" ? 3 : 0,
|
||||
HeaderContentGapMm = source.Id is "modern" or "ats-classic" or "code" ? 5 : 0,
|
||||
HeaderStyle = source.HeaderStyle, HeadingStyle = source.HeadingStyle, PhotoShape = source.PhotoShape,
|
||||
DefaultIcons = source.DefaultIcons, AtsFriendly = source.AtsFriendly, Premium = source.Premium,
|
||||
NumberSections = source.NumberSections, DefaultSkillsStyle = source.DefaultSkillsStyle,
|
||||
SupportedSettings = new(source.SupportedSettings), SidebarSections = new(source.SidebarSections),
|
||||
};
|
||||
}
|
||||
|
||||
public static CvTheme Resolve(string? id, int? designVersion) => designVersion == 2
|
||||
? RevisedThemes.FirstOrDefault(theme => theme.Id == (id ?? string.Empty).Trim().ToLowerInvariant()) ?? RevisedThemes[0]
|
||||
: Resolve(id);
|
||||
|
||||
public static CvTheme Resolve(string? id)
|
||||
{
|
||||
var key = (id ?? string.Empty).Trim().ToLowerInvariant();
|
||||
|
||||
@@ -9,6 +9,8 @@ namespace JobTrackerApi.Models;
|
||||
public sealed class CvVariantSettings
|
||||
{
|
||||
public string ThemeId { get; set; } = "modern";
|
||||
// Missing or version 1 retains the original template output.
|
||||
public int? DesignVersion { get; set; }
|
||||
|
||||
// Theme overrides (null => use the theme's own value).
|
||||
public string? AccentColor { get; set; }
|
||||
@@ -132,6 +134,7 @@ public static class CvVariantSettingsJson
|
||||
{
|
||||
s ??= new CvVariantSettings();
|
||||
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
|
||||
s.DesignVersion = s.DesignVersion is 1 or 2 ? s.DesignVersion : null;
|
||||
s.AccentColor = NormalizeColor(s.AccentColor);
|
||||
s.TextColor = NormalizeColor(s.TextColor);
|
||||
s.MutedColor = NormalizeColor(s.MutedColor);
|
||||
|
||||
@@ -187,7 +187,7 @@ builder.Services.AddHttpClient("ai-service", client =>
|
||||
?? builder.Configuration["Summarizer:BaseUrl"]
|
||||
?? "http://127.0.0.1:8001";
|
||||
client.BaseAddress = new Uri(baseUrl);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(builder.Configuration.GetValue("Ai:RequestTimeoutSeconds", 30), 5, 300));
|
||||
|
||||
var serviceToken = builder.Configuration["Ai:ServiceToken"];
|
||||
if (!string.IsNullOrWhiteSpace(serviceToken))
|
||||
|
||||
@@ -48,6 +48,11 @@ public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths pat
|
||||
|
||||
var avatar = await db.Users.AsNoTracking().Where(item => item.Id == ownerUserId).Select(item => item.AvatarImageDataUrl).FirstOrDefaultAsync(cancellationToken);
|
||||
AddAvatar(files, warnings, ownerUserId, avatar);
|
||||
foreach (var portrait in AvatarStorage.PortraitFiles(paths.DataRoot, AppPaths.GetOwnerStorageKey(ownerUserId)))
|
||||
{
|
||||
if (files.Any(file => string.Equals(file.SourcePath, Path.GetFullPath(portrait), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) continue;
|
||||
AddPath(files, warnings, portrait, $"files/avatar/{SafeSegment(Path.GetFileName(portrait))}", "avatar", candidate => IsManagedPath(paths.DataRoot, candidate));
|
||||
}
|
||||
AddDirectory(files, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv");
|
||||
AddDirectory(files, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export");
|
||||
if (includeAccountExports)
|
||||
|
||||
@@ -155,13 +155,13 @@ public sealed class AiOperationWorker(
|
||||
private readonly IReadOnlyDictionary<string, IAiOperationHandler> _handlers = registeredHandlers
|
||||
.ToDictionary(handler => handler.TaskType, StringComparer.Ordinal);
|
||||
|
||||
public async Task<bool> RunOnceAsync(CancellationToken stoppingToken)
|
||||
public async Task<bool> RunOnceAsync(CancellationToken stoppingToken, bool documentImportsOnly = false)
|
||||
{
|
||||
if (_handlers.Count == 0) return false;
|
||||
var leaseSeconds = Math.Clamp(configuration.GetValue("AiQueue:LeaseSeconds", 120), 30, 1800);
|
||||
await using var claimScope = scopes.CreateAsyncScope();
|
||||
var lease = await claimScope.ServiceProvider.GetRequiredService<UserOperationStore>()
|
||||
.ClaimNextAsync(TimeSpan.FromSeconds(leaseSeconds), stoppingToken, _handlers.Keys.ToArray());
|
||||
.ClaimNextAsync(TimeSpan.FromSeconds(leaseSeconds), stoppingToken, _handlers.Keys.ToArray(), documentImportsOnly);
|
||||
if (lease is null) return false;
|
||||
|
||||
await using var ownerScope = scopes.CreateAsyncScope();
|
||||
@@ -255,17 +255,18 @@ public sealed class AiOperationHostedService(AiOperationWorker worker, IConfigur
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!configuration.GetValue("Workers:AiOperationsEnabled", false)) return;
|
||||
var aiEnabled = configuration.GetValue("Workers:AiOperationsEnabled", false);
|
||||
if (!aiEnabled && !configuration.GetValue("Workers:CvDocumentImportsEnabled", false)) return;
|
||||
var concurrency = Math.Clamp(configuration.GetValue("AiQueue:WorkerConcurrency", 1), 1, 4);
|
||||
await Task.WhenAll(Enumerable.Range(0, concurrency).Select(_ => RunWorkerAsync(stoppingToken)));
|
||||
await Task.WhenAll(Enumerable.Range(0, concurrency).Select(_ => RunWorkerAsync(stoppingToken, !aiEnabled)));
|
||||
}
|
||||
|
||||
private async Task RunWorkerAsync(CancellationToken stoppingToken)
|
||||
private async Task RunWorkerAsync(CancellationToken stoppingToken, bool documentImportsOnly)
|
||||
{
|
||||
var idleDelayMs = Math.Clamp(configuration.GetValue("AiQueue:IdleDelayMs", 1000), 100, 10_000);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (!await worker.RunOnceAsync(stoppingToken))
|
||||
if (!await worker.RunOnceAsync(stoppingToken, documentImportsOnly))
|
||||
await Task.Delay(idleDelayMs, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ public sealed record CoverLetterDto(
|
||||
int Revision = 1,
|
||||
CoverLetterDocumentSettings? DocumentSettings = null);
|
||||
|
||||
public sealed class CoverLetterThemeAccessException() : Exception("This theme requires Pro.");
|
||||
|
||||
public interface IApplicationAssetsService
|
||||
{
|
||||
Task<ApplicationCvDto?> GetCvAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
|
||||
@@ -293,7 +295,9 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
public async Task<CoverLetterRenderDto?> PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct)
|
||||
{
|
||||
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
|
||||
return job is null ? null : _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
|
||||
if (job is null) return null;
|
||||
await RequireLetterThemeAsync(_db, job, settings, ct);
|
||||
return _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
|
||||
}
|
||||
|
||||
public async Task<CoverLetterRenderDto?> RenderSavedCoverLetterAsync(string ownerUserId, int jobApplicationId, int? expectedVersion, CancellationToken ct)
|
||||
@@ -331,6 +335,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
internal static async Task StageCoverLetterAsync(JobTrackerContext db, JobApplication job, string? text,
|
||||
string source, string? aiAction, CancellationToken ct, CoverLetterDocumentSettings? settings = null)
|
||||
{
|
||||
await RequireLetterThemeAsync(db, job, settings, ct);
|
||||
var currentSettings = CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
|
||||
var nextSettings = settings is null ? currentSettings : CoverLetterDocumentSettingsJson.Serialize(settings);
|
||||
if (string.Equals(job.CoverLetterText, text, StringComparison.Ordinal) && nextSettings == currentSettings) return;
|
||||
@@ -362,6 +367,21 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
|
||||
if (settings is not null) job.CoverLetterDocumentSettingsJson = nextSettings;
|
||||
}
|
||||
|
||||
private static async Task RequireLetterThemeAsync(JobTrackerContext db, JobApplication job, CoverLetterDocumentSettings? settings, CancellationToken ct)
|
||||
{
|
||||
if (settings is null) return;
|
||||
var next = CoverLetterDocumentSettingsJson.Normalize(settings);
|
||||
if (next.DesignVersion != 2 || CvThemeCatalog.CanUse(next.ThemeId, false)) return;
|
||||
var current = CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson);
|
||||
if (current.DesignVersion == 2 && current.ThemeId == next.ThemeId) return;
|
||||
var roles = await (from userRole in db.UserRoles.AsNoTracking()
|
||||
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
|
||||
where userRole.UserId == job.OwnerUserId && role.Name != null
|
||||
select role.Name!).ToListAsync(ct);
|
||||
if (!CvThemeCatalog.CanUse(next.ThemeId, AccountPlans.ForRoles(roles).ProThemes))
|
||||
throw new CoverLetterThemeAccessException();
|
||||
}
|
||||
|
||||
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
|
||||
_db.JobApplications.AsNoTracking()
|
||||
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using SkiaSharp;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public static class AvatarImageProcessor
|
||||
{
|
||||
public static byte[]? Normalize(byte[] source)
|
||||
{
|
||||
if (source.Length is 0 or > 1_000_000) return null;
|
||||
using var data = SKData.CreateCopy(source);
|
||||
using var codec = SKCodec.Create(data);
|
||||
if (codec is null || codec.EncodedFormat is not (SKEncodedImageFormat.Png or SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Webp)) return null;
|
||||
var info = codec.Info;
|
||||
if (info.Width is < 1 or > 2048 || info.Height is < 1 or > 2048 || (long)info.Width * info.Height > 4_000_000 || codec.FrameCount > 1) return null;
|
||||
using var pixels = new SKBitmap(info.Width, info.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
if (codec.GetPixels(pixels.Info, pixels.GetPixels()) != SKCodecResult.Success) return null;
|
||||
var rotated = codec.EncodedOrigin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom;
|
||||
using var oriented = new SKBitmap(rotated ? info.Height : info.Width, rotated ? info.Width : info.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
|
||||
using (var canvas = new SKCanvas(oriented))
|
||||
{
|
||||
switch (codec.EncodedOrigin)
|
||||
{
|
||||
case SKEncodedOrigin.TopRight: canvas.Translate(info.Width, 0); canvas.Scale(-1, 1); break;
|
||||
case SKEncodedOrigin.BottomRight: canvas.Translate(info.Width, info.Height); canvas.RotateDegrees(180); break;
|
||||
case SKEncodedOrigin.BottomLeft: canvas.Translate(0, info.Height); canvas.Scale(1, -1); break;
|
||||
case SKEncodedOrigin.LeftTop: canvas.RotateDegrees(90); canvas.Scale(1, -1); break;
|
||||
case SKEncodedOrigin.RightTop: canvas.Translate(info.Height, 0); canvas.RotateDegrees(90); break;
|
||||
case SKEncodedOrigin.RightBottom: canvas.Translate(info.Height, info.Width); canvas.RotateDegrees(90); canvas.Scale(-1, 1); break;
|
||||
case SKEncodedOrigin.LeftBottom: canvas.Translate(0, info.Width); canvas.RotateDegrees(270); break;
|
||||
}
|
||||
canvas.DrawBitmap(pixels, 0, 0, new SKSamplingOptions(SKFilterMode.Nearest), null);
|
||||
}
|
||||
// Re-encoding decoded pixels drops source metadata and trailing payloads.
|
||||
using var image = SKImage.FromBitmap(oriented);
|
||||
using var encoded = image.Encode(SKEncodedImageFormat.Png, 100);
|
||||
return encoded is null || encoded.Size > 1_000_000 ? null : encoded.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
@@ -7,6 +9,64 @@ public static class AvatarStorage
|
||||
{
|
||||
private const string FilePrefix = "file:";
|
||||
|
||||
public static async Task<string> StageAsync(string dataRoot, string userId, byte[] bytes, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant();
|
||||
var folder = Path.Combine(dataRoot, "Avatars", key);
|
||||
Directory.CreateDirectory(folder);
|
||||
var path = Path.Combine(folder, $"avatar-{Guid.NewGuid():N}.png");
|
||||
try { await File.WriteAllBytesAsync(path, bytes, cancellationToken); }
|
||||
catch
|
||||
{
|
||||
TryDelete(FilePrefix + path);
|
||||
throw;
|
||||
}
|
||||
return FilePrefix + path;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> PortraitFiles(string dataRoot, string ownerKey)
|
||||
{
|
||||
var root = Path.Combine(dataRoot, "Avatars");
|
||||
var folder = Path.Combine(root, ownerKey);
|
||||
if (ownerKey.Length != 64 || !ownerKey.All(Uri.IsHexDigit)
|
||||
|| !Directory.Exists(folder) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0
|
||||
|| (File.GetAttributes(folder) & FileAttributes.ReparsePoint) != 0) yield break;
|
||||
foreach (var path in Directory.EnumerateFiles(folder, "avatar*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true }))
|
||||
{
|
||||
var name = Path.GetFileName(path);
|
||||
if (name is "avatar.png" or "avatar.jpg" or "avatar.webp"
|
||||
|| (name.StartsWith("avatar-", StringComparison.Ordinal) && name.EndsWith(".png", StringComparison.Ordinal)
|
||||
&& name.Length == 43 && Guid.TryParseExact(name[7..^4], "N", out _))) yield return path;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<int> SweepOrphansAsync(JobTrackerContext db, string dataRoot, DateTimeOffset now, CancellationToken token)
|
||||
{
|
||||
var root = Path.Combine(dataRoot, "Avatars");
|
||||
if (!Directory.Exists(root) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) return 0;
|
||||
var candidates = Directory.EnumerateDirectories(root, "*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true })
|
||||
.Take(1000).SelectMany(folder => PortraitFiles(dataRoot, Path.GetFileName(folder)).Take(1000))
|
||||
.Where(path => File.GetLastWriteTimeUtc(path) <= now.AddDays(-1).UtcDateTime)
|
||||
.Take(1000).Select(path => FilePrefix + Path.GetFullPath(path)).ToList();
|
||||
var referenced = (await db.Users.AsNoTracking().Where(user => candidates.Contains(user.AvatarImageDataUrl!))
|
||||
.Select(user => user.AvatarImageDataUrl!).ToListAsync(token))
|
||||
.ToHashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
|
||||
var removed = 0;
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (!referenced.Contains(candidate) && TryDelete(candidate)) removed++;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
public static bool TryDelete(string? value)
|
||||
{
|
||||
try { Delete(value); return true; }
|
||||
catch (IOException) { return false; }
|
||||
catch (UnauthorizedAccessException) { return false; }
|
||||
}
|
||||
|
||||
public static async Task<string> StoreAsync(string dataRoot, string userId, byte[] bytes, string contentType, CancellationToken cancellationToken)
|
||||
{
|
||||
var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant();
|
||||
|
||||
@@ -15,6 +15,8 @@ public sealed class CoverLetterDocumentRenderer
|
||||
static string E(string? value) => WebUtility.HtmlEncode(value ?? "");
|
||||
static string F(double? value) => value!.Value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
var font = s.BodyFont!;
|
||||
var theme = CvThemeCatalog.Resolve(s.ThemeId, s.DesignVersion);
|
||||
var fonts = s.DesignVersion == 2 ? CvDocumentFonts.Embed(font, s.HeadingFont!, theme.UtilityFont) : CvDocumentFonts.Embed(font);
|
||||
var size = s.PageSize == "letter" ? "215.9mm 279.4mm" : "210mm 297mm";
|
||||
var width = s.PageSize == "letter" ? "215.9mm" : "210mm";
|
||||
var height = s.PageSize == "letter" ? "279.4mm" : "297mm";
|
||||
@@ -40,7 +42,7 @@ public sealed class CoverLetterDocumentRenderer
|
||||
<!DOCTYPE html><html lang="{{E(s.Language)}}"><head><meta charset="utf-8">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; font-src data:; base-uri 'none'">
|
||||
<title>{{E(string.IsNullOrEmpty(title) ? letterLabel : title)}}</title><style>
|
||||
{{CvDocumentFonts.Embed(font)}}
|
||||
{{fonts}}
|
||||
:root{--cv-page-margin:{{F(s.PageMarginMm)}}mm}
|
||||
*{box-sizing:border-box}html,body{margin:0;padding:0;color:#111827;background:#fff}
|
||||
body{font-family:{{CvDocumentFonts.Stack(font)}};font-size:{{F(s.BaseFontSizePt)}}pt;line-height:{{F(s.LineHeight)}}}
|
||||
@@ -56,6 +58,35 @@ public sealed class CoverLetterDocumentRenderer
|
||||
<div class="date">{{E(s.Date)}}</div><div class="recipient">{{E(s.Recipient)}}</div>{{subjectHeading}}
|
||||
<main aria-label="{{E(bodyLabel)}}">{{body}}</main></article></body></html>
|
||||
""";
|
||||
if (s.DesignVersion == 2)
|
||||
{
|
||||
var css = $"h1,h2{{font-family:{CvDocumentFonts.Stack(s.HeadingFont!)};}}h1{{font-size:{F(theme.NameSizePt - 2)}pt;}}header{{border-bottom-width:.4mm;}}";
|
||||
css += theme.HeaderStyle switch
|
||||
{
|
||||
"band" when theme.Layout == "header-band" => $"header{{background:{s.AccentColor};color:{ThemedCvRenderer.ContrastInk(s.AccentColor!)};padding:6mm;margin-bottom:8mm;border:0;}}header h1{{color:inherit;}}",
|
||||
"centered" => "header{text-align:center;padding-bottom:7mm;margin-bottom:9mm;}h1{font-weight:600;}",
|
||||
"kicker" => $"header::before{{content:'{letterLabel}';display:block;letter-spacing:.2em;text-transform:uppercase;color:{s.AccentColor};font-size:8pt;margin-bottom:3mm;}}",
|
||||
_ => "header{padding-bottom:6mm;margin-bottom:8mm;}",
|
||||
};
|
||||
if (theme.HeadingStyle == "plain" && theme.HeaderStyle == "plain") css += "header{border:0;}";
|
||||
if (theme.NumberSections) css += $".contact,.date{{font-family:{CvDocumentFonts.Stack(theme.UtilityFont)};font-size:9pt;}}";
|
||||
if (theme.Layout is "sidebar-left" or "sidebar-right")
|
||||
{
|
||||
var right = theme.Layout == "sidebar-right";
|
||||
var edge = right ? "right" : "left";
|
||||
var headerColumn = right ? 2 : 1;
|
||||
var contentColumn = right ? 1 : 2;
|
||||
var columns = right ? $"minmax(0,1fr) {F(theme.SidebarWidthMm)}mm" : $"{F(theme.SidebarWidthMm)}mm minmax(0,1fr)";
|
||||
css += $".letter-paper{{display:grid;grid-template-columns:{columns};grid-template-rows:auto auto auto auto;align-content:start;padding:0;background:transparent;position:relative;z-index:1;}}" +
|
||||
$".letter-paper>header{{grid-column:{headerColumn};grid-row:1 / span 4;padding:8mm;color:{theme.SidebarInk};border:0;margin:0;}}header h1{{color:inherit;}}" +
|
||||
$".letter-paper>.date,.letter-paper>.recipient,.letter-paper>h2,.letter-paper>main{{grid-column:{contentColumn};padding-left:{F(s.PageMarginMm)}mm;padding-right:{F(s.PageMarginMm)}mm;background:white;min-width:0;}}" +
|
||||
$"body::before{{content:'';position:fixed;{edge}:0;top:0;bottom:0;width:{F(theme.SidebarWidthMm)}mm;background:{theme.SidebarBg};}}" +
|
||||
$"@page{{@top-{edge}{{content:'';background:{theme.SidebarBg};width:{F(theme.SidebarWidthMm)}mm;}}@bottom-{edge}{{content:'';background:{theme.SidebarBg};width:{F(theme.SidebarWidthMm)}mm;}}}}" +
|
||||
"@media print{.letter-paper{padding:0;}}";
|
||||
}
|
||||
html = html.Replace("</style>", css + "</style>", StringComparison.Ordinal)
|
||||
.Replace("class=\"page letter-paper\"", $"class=\"page letter-paper letter-theme-{theme.Id}\"", StringComparison.Ordinal);
|
||||
}
|
||||
return new(html, s.ThemeId, "cover-letter.pdf");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,6 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes,
|
||||
catch (UnauthorizedAccessException) { }
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
return removed + await AvatarStorage.SweepOrphansAsync(db, paths.DataRoot, now, token);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,19 +23,26 @@ public interface ICvProcessingQueue
|
||||
/// Compatibility name for the CV producer bridge. Durable scheduling and execution are owned by
|
||||
/// the shared AI operation queue; this type does not keep an in-memory CV queue.
|
||||
/// </summary>
|
||||
public sealed class CvProcessingQueue(AiOperationAdmission admission) : ICvProcessingQueue
|
||||
public sealed class CvProcessingQueue(AiOperationAdmission admission, JobTrackerContext db, IConfiguration configuration) : ICvProcessingQueue
|
||||
{
|
||||
public const string TaskType = "cv.process";
|
||||
public const string SubjectType = "cv_extraction_run";
|
||||
|
||||
public async Task<AiOperationAdmissionResult?> EnqueueAsync(int runId, CancellationToken cancellationToken = default)
|
||||
=> await admission.EnqueueAsync(
|
||||
{
|
||||
var document = await db.CvExtractionRuns.AnyAsync(run => run.Id == runId &&
|
||||
run.ParserVersion == ProfileCvController.DocumentParserVersion && run.Trigger == "document_import", cancellationToken);
|
||||
if (document && !configuration.GetValue("Workers:AiOperationsEnabled", false) &&
|
||||
!configuration.GetValue("Workers:CvDocumentImportsEnabled", false))
|
||||
throw new AiOperationAdmissionException("cv_worker_disabled", "CV processing is disabled on this server. Ask the administrator to enable document processing, then retry the upload.", StatusCodes.Status503ServiceUnavailable);
|
||||
return await admission.EnqueueAsync(
|
||||
TaskType,
|
||||
$"run:{runId}",
|
||||
SubjectType,
|
||||
runId.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
AiOperationPriorities.UserVisible,
|
||||
cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class NoOpCvProcessingQueue : ICvProcessingQueue
|
||||
|
||||
@@ -24,6 +24,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
public ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings)
|
||||
{
|
||||
settings = CvVariantSettingsJson.Normalize(settings);
|
||||
if (settings.DesignVersion == 2) theme = CvThemeCatalog.Resolve(theme.Id, settings.DesignVersion);
|
||||
var accent = Override(settings.AccentColor, theme.Accent);
|
||||
var headerInk = ContrastInk(accent);
|
||||
var ink = Override(settings.TextColor, theme.Ink);
|
||||
@@ -238,6 +239,12 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt;
|
||||
var lineHeight = settings.LineHeight ?? t.LineHeight;
|
||||
var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm;
|
||||
var sidebarPadding = settings.DesignVersion == 2 ? "8" : margin;
|
||||
var sidebarBackground = settings.DesignVersion == 2 ? "var(--cv-sidebar-bg)" : "var(--cv-accent-color)";
|
||||
var sidebarInk = settings.DesignVersion == 2 ? "var(--cv-sidebar-ink)" : "var(--cv-accent-ink)";
|
||||
var sidebarVariables = settings.DesignVersion == 2 ? $"--cv-sidebar-bg:{t.SidebarBg};--cv-sidebar-ink:{t.SidebarInk};" : string.Empty;
|
||||
var sidebarDetailCss = settings.DesignVersion == 2
|
||||
? ".sidebar .bullets li::marker,.sidebar .skill-group-name{color:var(--cv-sidebar-ink);}" : string.Empty;
|
||||
var headingCss = headingStyle switch
|
||||
{
|
||||
"underline" => ".section-title{border-bottom:1.5px solid var(--cv-accent-soft);padding-bottom:1.5mm;}",
|
||||
@@ -250,13 +257,13 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
: $"{F(sidebarWidth)}mm minmax(0,1fr)";
|
||||
var layoutCss = twoColumn
|
||||
? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}}
|
||||
.sidebar{{background:var(--cv-accent-color);color:var(--cv-accent-ink);padding:{margin}mm;}}
|
||||
.sidebar .section-title{{color:var(--cv-accent-ink);border-color:color-mix(in srgb,var(--cv-accent-ink) 38%,transparent);}}
|
||||
.sidebar .tag{{border-color:color-mix(in srgb,var(--cv-accent-ink) 45%,transparent);}}
|
||||
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:var(--cv-accent-ink);}}
|
||||
.sidebar{{background:{sidebarBackground};color:{sidebarInk};padding:{sidebarPadding}mm;}}
|
||||
.sidebar .section-title{{color:{sidebarInk};border-color:color-mix(in srgb,{sidebarInk} 38%,transparent);}}
|
||||
.sidebar .tag{{border-color:color-mix(in srgb,{sidebarInk} 45%,transparent);}}
|
||||
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{sidebarInk};}}
|
||||
.sidebar a{{color:inherit;}}
|
||||
.main{{padding:{margin}mm;}}
|
||||
.hero .name{{color:var(--cv-accent-ink);}}"
|
||||
.hero .name{{color:{sidebarInk};}}"
|
||||
: $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}}
|
||||
.header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}}
|
||||
.header-band{{background:{accent};color:{headerInk};}}
|
||||
@@ -269,9 +276,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{CvDocumentFonts.Stack(t.UtilityFont)};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}"
|
||||
: string.Empty;
|
||||
|
||||
var revisedCss = settings.DesignVersion == 2
|
||||
? $".header .name{{margin-bottom:{F(t.NameGapMm * density)}mm;}}.header + .main{{padding-top:{F(t.HeaderContentGapMm * density)}mm;}}" : string.Empty;
|
||||
if (settings.DesignVersion == 2 && twoColumn)
|
||||
{
|
||||
var edge = layout == "sidebar-right" ? "right" : "left";
|
||||
// Margin boxes paint the sidebar bleed without changing the repeated text safe area.
|
||||
revisedCss += $"@page{{@top-{edge}{{content:\"\";background:{t.SidebarBg};width:{F(sidebarWidth)}mm;}}@bottom-{edge}{{content:\"\";background:{t.SidebarBg};width:{F(sidebarWidth)}mm;}}}}" +
|
||||
$"body::before{{content:\"\";position:fixed;{edge}:0;top:0;bottom:0;width:{F(sidebarWidth)}mm;background:{t.SidebarBg};z-index:0;}}" +
|
||||
".page{position:relative;background:transparent;}.cols{position:relative;z-index:1;}.main{background:var(--cv-paper);}.sidebar{background:transparent;}";
|
||||
}
|
||||
|
||||
return $@"
|
||||
*{{box-sizing:border-box;}}
|
||||
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};--cv-page-margin:{margin}mm;}}
|
||||
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};--cv-page-margin:{margin}mm;{sidebarVariables}}}
|
||||
html,body{{min-width:0;}}
|
||||
body{{margin:0;background:#e9edf2;color:var(--cv-ink);font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
|
||||
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:var(--cv-paper);overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
|
||||
@@ -325,7 +343,7 @@ h1,h2{{font-family:{headingFont};}}
|
||||
.entry-meta{{color:{muted};font-size:{F(bodySize - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
|
||||
.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
|
||||
.entry-tags{{margin-top:1.4mm;}}
|
||||
{layoutCss}
|
||||
{layoutCss}{sidebarDetailCss}
|
||||
{numberedSections}
|
||||
/* Print quality: keep normal entries whole, but allow intentionally classified long entries and
|
||||
long list items to flow. An unsplittable block taller than a page is otherwise clipped. */
|
||||
@@ -349,7 +367,7 @@ body{{background:transparent;}}
|
||||
.main,.sidebar{{padding-top:0;padding-bottom:0;}}
|
||||
}}
|
||||
@page{{size:{page.w} {page.h};margin:{margin}mm 0;}}
|
||||
";
|
||||
" + revisedCss;
|
||||
}
|
||||
|
||||
private static double DensityScale(string? density) => (density ?? "balanced").Trim().ToLowerInvariant() switch
|
||||
@@ -374,7 +392,7 @@ body{{background:transparent;}}
|
||||
|
||||
private static string Override(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
|
||||
|
||||
private static string ContrastInk(string background)
|
||||
internal static string ContrastInk(string background)
|
||||
{
|
||||
if (background.Length != 7 || background[0] != '#' || !background.Skip(1).All(Uri.IsHexDigit)) return "#000";
|
||||
|
||||
|
||||
@@ -107,7 +107,8 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
public async Task<UserOperationLease?> ClaimNextAsync(
|
||||
TimeSpan leaseDuration,
|
||||
CancellationToken cancellationToken,
|
||||
IReadOnlyCollection<string>? allowedTaskTypes = null)
|
||||
IReadOnlyCollection<string>? allowedTaskTypes = null,
|
||||
bool documentImportsOnly = false)
|
||||
{
|
||||
if (db.CurrentUserId is not null) throw new InvalidOperationException("Worker claims require a neutral background scope.");
|
||||
ValidateLeaseDuration(leaseDuration);
|
||||
@@ -123,6 +124,12 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
|
||||
(operation.DeadlineAtUtc == null || operation.DeadlineAtUtc > now));
|
||||
if (allowedTaskTypes is { Count: > 0 })
|
||||
candidates = candidates.Where(operation => allowedTaskTypes.Contains(operation.TaskType));
|
||||
if (documentImportsOnly)
|
||||
candidates = candidates.Where(operation => operation.TaskType == CvProcessingQueue.TaskType &&
|
||||
operation.SubjectType == CvProcessingQueue.SubjectType &&
|
||||
db.CvExtractionRuns.IgnoreQueryFilters().Any(run => run.OwnerUserId == operation.OwnerUserId &&
|
||||
run.Id.ToString() == operation.SubjectId && run.ParserVersion == Controllers.ProfileCvController.DocumentParserVersion &&
|
||||
run.Trigger == "document_import"));
|
||||
var candidateIds = await candidates
|
||||
.OrderByDescending(operation => operation.Priority)
|
||||
.ThenBy(operation => operation.CreatedAtUtc)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"Workers": {
|
||||
"CvDocumentImportsEnabled": true
|
||||
},
|
||||
"Database": {
|
||||
"Provider": "mariadb",
|
||||
"ServerVersion": "12.2.2"
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"SavedJobSearchAlertsEnabled": true,
|
||||
"DailyExportEnabled": false,
|
||||
"JobEnrichmentEnabled": false,
|
||||
"AiOperationsEnabled": false
|
||||
"AiOperationsEnabled": false,
|
||||
"CvDocumentImportsEnabled": false
|
||||
},
|
||||
"JobDiscovery": {
|
||||
"SavedSearchIntervalHours": 6
|
||||
|
||||
@@ -112,6 +112,22 @@
|
||||
"System.Drawing.Common": "6.0.0"
|
||||
}
|
||||
},
|
||||
"SkiaSharp": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.153.1, )",
|
||||
"resolved": "4.153.1",
|
||||
"contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==",
|
||||
"dependencies": {
|
||||
"SkiaSharp.NativeAssets.Win32": "4.153.1",
|
||||
"SkiaSharp.NativeAssets.macOS": "4.153.1"
|
||||
}
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Linux.NoDependencies": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.153.1, )",
|
||||
"resolved": "4.153.1",
|
||||
"contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ=="
|
||||
},
|
||||
"SQLitePCLRaw.lib.e_sqlite3": {
|
||||
"type": "Direct",
|
||||
"requested": "[2.1.12, )",
|
||||
@@ -473,6 +489,16 @@
|
||||
"resolved": "13.0.3",
|
||||
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.macOS": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.153.1",
|
||||
"contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A=="
|
||||
},
|
||||
"SkiaSharp.NativeAssets.Win32": {
|
||||
"type": "Transitive",
|
||||
"resolved": "4.153.1",
|
||||
"contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q=="
|
||||
},
|
||||
"SQLitePCLRaw.bundle_e_sqlite3": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.1.10",
|
||||
|
||||
Reference in New Issue
Block a user