feat(cv): qualify templates and reliable local import processing

This commit is contained in:
cesnimda
2026-10-28 14:36:12 +01:00
parent 73ea888375
commit 3f4db8ca81
69 changed files with 2597 additions and 180 deletions
@@ -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)
+7 -6
View File
@@ -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();
}
}
+60
View File
@@ -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);
}
}
+9 -2
View File
@@ -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
+27 -9
View File
@@ -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";
+8 -1
View File
@@ -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)