mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
feat(policies): enforce run-on-export policies on all PDF exit paths (#6788)
> **Draft / WIP** — print enforcement is still to come (see below). ## Goal A "run on export" policy must enforce on **every** path where a PDF leaves the editor, not just the main Download/Export button. This routes the remaining exits through the existing export-policy gateway (`downloadFileWithPolicy`), which runs `enforceExportPolicies` before the file leaves and is a no-op when no export policy is active. ## Audit of exit paths | Path | Status | |---|---| | Web download / export, page-editor, file-editor, thumbnails | ✅ already covered (gateway) | | **Form-fill download** (`FormSaveBar`) | ✅ fixed here — was a raw `createObjectURL` download | | **Desktop Ctrl+S save** (`useSaveShortcut`) | ✅ fixed here — was raw `downloadService` | | **Desktop save-operation-results** (`operationResultsSaveService`) | ✅ fixed here — was raw `downloadService` | | Viewer `saveAsCopy` (annotations/redactions) | n/a — in-memory version saves, not exits | | **Print** (`printActions.print`) | ⏳ pending — enforce-then-print (below) | | Web operation-results (`downloadFromUrl`) | ⏳ pending — URL-stream, needs a fetch→enforce wrapper | | Share link | excluded by design (enforce at share-creation, not recipient download) | ## In this PR All three fixes are the same pattern — route the raw download through `downloadFileWithPolicy` instead of `URL.createObjectURL` / the raw download service. ## Still to come (why it's a draft) - **Print** — enforce-then-print: on print, run the same `enforceExportPolicies`; if it changed the doc, swap the viewer to the enforced version (new version in history) and toast *"PDF updated by policy enforcement — review, then print again"* rather than silently printing a different doc; if unchanged, print. Covers Ctrl+P, the toolbar button, and embedded PDF-JS print. - **Web operation-results** (`downloadFromUrl`) — fetch the result to a blob, enforce, then download. ## Verification Typecheck (core/proprietary) + prettier clean for the changes here; desktop tsc clean for the touched files. The print UX, once added, needs a manual run with an active export policy — there's no automated path for it.
This commit is contained in:
@@ -5914,12 +5914,40 @@ statDocsEnforced = "Docs enforced"
|
||||
statusActive = "Active"
|
||||
statusPaused = "Paused"
|
||||
|
||||
[policies.enforcement]
|
||||
applying = "Applying {{names}}"
|
||||
applyingProgress = "Applying {{names}} ({{done}} of {{total}})"
|
||||
exportFailureBody = "Security policies couldn't be applied. Files were exported as-is."
|
||||
exportFailureTitle = "Exported without enforcement"
|
||||
failureBody = "{{failures}} of {{total}} file(s) couldn't be processed and were exported as-is."
|
||||
failureTitle = "Exported without full enforcement"
|
||||
printPolicyAppliedBody = "This PDF was updated to meet a policy. Review the changes, then print again."
|
||||
printPolicyAppliedTitle = "Policy applied before printing"
|
||||
queued = "+{{count}} queued"
|
||||
successTitle = "{{names}} applied"
|
||||
summaryMore = "{{first}}, {{second}} and {{more}} more"
|
||||
summaryTwo = "{{first}} and {{second}}"
|
||||
|
||||
[policies.enforcement.triggerVerb]
|
||||
convert = "Enforcing before convert"
|
||||
default = "Enforcing"
|
||||
export = "Enforcing before export"
|
||||
input = "Enforcing on import"
|
||||
print = "Enforcing before print"
|
||||
|
||||
[policies.fields]
|
||||
selectedCount = "{{count}} selected"
|
||||
|
||||
[policies.pii]
|
||||
account = "Account numbers (labelled)"
|
||||
card = "Credit / debit cards"
|
||||
email = "Email addresses"
|
||||
fieldLabel = "PII to redact"
|
||||
iban = "IBANs"
|
||||
phone = "Phone numbers"
|
||||
placeholder = "Select PII types"
|
||||
routing = "US routing numbers (ABA)"
|
||||
ssn = "Social Security numbers"
|
||||
|
||||
[policies.sidebar]
|
||||
activeCount = "{{count}} active"
|
||||
@@ -5942,6 +5970,11 @@ setup = "Set up"
|
||||
enableAriaLabel = "Enable {{tool}}"
|
||||
infoAriaLabel = "What does {{tool}} do?"
|
||||
|
||||
[policies.toolConfig.info]
|
||||
redact = "Automatically finds and blacks out sensitive details — like Social Security and card numbers — so they can't be read in the document."
|
||||
sanitize = "Removes hidden JavaScript from the file, so nothing can run automatically when someone opens it."
|
||||
watermark = "Stamps a visible mark (e.g. \"Confidential\") across every page."
|
||||
|
||||
[policies.wizard]
|
||||
allDocTypesDescription = "Enable the Classification policy to filter by document type."
|
||||
allDocTypesTitle = "All document types"
|
||||
|
||||
@@ -30,6 +30,9 @@ import { WorkbenchType, isBaseWorkbench } from "@app/types/workbench";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
import { downloadFile as downloadRaw } from "@app/services/downloadService";
|
||||
import { alert as showAlert } from "@app/components/toast";
|
||||
import {
|
||||
WorkbenchBarButtonConfig,
|
||||
WorkbenchBarRenderContext,
|
||||
@@ -171,13 +174,35 @@ export default function WorkbenchBar({
|
||||
|
||||
const filesToExport =
|
||||
selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
for (const file of filesToExport) {
|
||||
const stub = isStirlingFile(file)
|
||||
const stubs = filesToExport.map((file) =>
|
||||
isStirlingFile(file)
|
||||
? selectors.getStirlingFileStub(file.fileId)
|
||||
: undefined;
|
||||
: undefined,
|
||||
);
|
||||
|
||||
// Enforce all files in one batch so the toast shows progress across the
|
||||
// whole set (e.g. "report.pdf (2 of 5)") rather than N invisible solo runs.
|
||||
let enforced: File[];
|
||||
try {
|
||||
enforced = await enforceExportPolicies(
|
||||
filesToExport as File[],
|
||||
stubs.map((s) => s?.id),
|
||||
);
|
||||
} catch {
|
||||
enforced = filesToExport as File[];
|
||||
showAlert({
|
||||
alertType: "warning",
|
||||
title: t("policies.enforcement.exportFailureTitle"),
|
||||
body: t("policies.enforcement.exportFailureBody"),
|
||||
});
|
||||
}
|
||||
|
||||
for (let idx = 0; idx < filesToExport.length; idx++) {
|
||||
const file = filesToExport[idx];
|
||||
const stub = stubs[idx];
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
const result = await downloadRaw({
|
||||
data: enforced[idx],
|
||||
filename: file.name,
|
||||
localPath: forceNewFile ? undefined : stub?.localFilePath,
|
||||
fileId: stub?.id,
|
||||
|
||||
@@ -11,6 +11,10 @@ import React, {
|
||||
import { useNavigation } from "@app/contexts/NavigationContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { isStirlingFile } from "@app/types/fileContext";
|
||||
import type { FileId } from "@app/types/file";
|
||||
import { enforceExportPolicies } from "@app/services/policyExport";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { alert } from "@app/components/toast";
|
||||
import {
|
||||
preferencesService,
|
||||
type PdfRenderMode,
|
||||
@@ -216,6 +220,7 @@ interface ViewerProviderProps {
|
||||
}
|
||||
|
||||
export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
// UI state - only state directly managed by this context
|
||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] =
|
||||
useState(false);
|
||||
@@ -537,6 +542,45 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
triggerImmediateZoomUpdate,
|
||||
});
|
||||
|
||||
// Printing is an exit path, so a "run on export" policy must enforce here too.
|
||||
// Enforce the current file through the same path export uses: when a policy
|
||||
// rewrites it, that path versions the in-editor file to the enforced output
|
||||
// and marks it enforced, so a follow-up print of the unedited result prints
|
||||
// it as-is instead of re-running the (non-idempotent) policy. Ask the user to
|
||||
// review the updated doc before printing again, rather than printing bytes
|
||||
// they haven't seen. With no active export policy this is a no-op and print
|
||||
// runs straight away.
|
||||
const printWithPolicy = useCallback(async () => {
|
||||
const file = activeFileId
|
||||
? selectors.getFiles([activeFileId as FileId])[0]
|
||||
: undefined;
|
||||
if (!activeFileId || !file) {
|
||||
printActions.print();
|
||||
return;
|
||||
}
|
||||
const [enforced] = await enforceExportPolicies(
|
||||
[file],
|
||||
[activeFileId],
|
||||
"print",
|
||||
);
|
||||
// Original file back means no policy rewrote it (no active policy, already
|
||||
// enforced, or graceful failure fallback) — nothing new to review, print it.
|
||||
if (!enforced || enforced === file) {
|
||||
printActions.print();
|
||||
return;
|
||||
}
|
||||
alert({
|
||||
alertType: "warning",
|
||||
title: t("policies.enforcement.printPolicyAppliedTitle"),
|
||||
body: t("policies.enforcement.printPolicyAppliedBody"),
|
||||
});
|
||||
}, [activeFileId, selectors, printActions]);
|
||||
|
||||
const enforcedPrintActions = useMemo<PrintActions>(
|
||||
() => ({ print: printWithPolicy }),
|
||||
[printWithPolicy],
|
||||
);
|
||||
|
||||
const value: ViewerContextType = {
|
||||
// UI state
|
||||
isThumbnailSidebarVisible,
|
||||
@@ -610,7 +654,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
exportActions,
|
||||
bookmarkActions,
|
||||
attachmentActions,
|
||||
printActions,
|
||||
printActions: enforcedPrintActions,
|
||||
|
||||
// Bridge registration
|
||||
registerBridge,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export async function enforceExportPolicies(
|
||||
files: File[],
|
||||
_fileIds?: (string | undefined)[],
|
||||
_trigger?: "export" | "print" | "convert" | "input",
|
||||
): Promise<File[]> {
|
||||
return files;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import DownloadIcon from "@mui/icons-material/Download";
|
||||
import SaveIcon from "@mui/icons-material/Save";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import { useFormFill } from "@app/tools/formFill/FormFillContext";
|
||||
import { downloadFileWithPolicy } from "@app/services/exportWithPolicy";
|
||||
|
||||
interface FormSaveBarProps {
|
||||
/** The current file being viewed */
|
||||
@@ -77,15 +78,12 @@ export function FormSaveBar({
|
||||
setSaving(true);
|
||||
try {
|
||||
const blob = await submitForm(file, false);
|
||||
// Trigger browser download
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file instanceof File ? file.name : "filled-form.pdf";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
// Route through the export gateway so a "run on export" policy enforces on
|
||||
// the filled PDF before it leaves the app (no-op when no such policy is set).
|
||||
await downloadFileWithPolicy({
|
||||
data: blob,
|
||||
filename: file instanceof File ? file.name : "filled-form.pdf",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[FormSaveBar] Download failed:", err);
|
||||
} finally {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect } from "react";
|
||||
import { useFileState, useFileActions } from "@app/contexts/FileContext";
|
||||
import { downloadFile } from "@app/services/downloadService";
|
||||
// Save through the export gateway so a "run on export" policy enforces before
|
||||
// the file is written out (no-op when no such policy is active).
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
|
||||
/**
|
||||
* Desktop-only keyboard shortcut: Ctrl/Cmd+S to save selected files
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { FileId } from "@app/types/fileContext";
|
||||
import type { OperationSaveContext } from "@core/services/operationResultsSaveService";
|
||||
import {
|
||||
downloadFile,
|
||||
downloadFromUrl,
|
||||
DownloadResult,
|
||||
} from "@app/services/downloadService";
|
||||
import { downloadFromUrl, DownloadResult } from "@app/services/downloadService";
|
||||
// Save through the export gateway so a "run on export" policy enforces before
|
||||
// the file is written out (no-op when no such policy is active).
|
||||
import { downloadFileWithPolicy as downloadFile } from "@app/services/exportWithPolicy";
|
||||
|
||||
export type { OperationSaveContext };
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Compact status row for the {@link enforcementQueue}, shown in the Policies
|
||||
* panel whenever enforcement jobs are pending or running. The queue is serial,
|
||||
* so a slow policy run would otherwise be invisible — this surfaces what's being
|
||||
* enforced (before export, print, convert, …) and how many jobs are waiting.
|
||||
*/
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Group, Text, Loader } from "@mantine/core";
|
||||
import { useEnforcementQueue } from "@app/components/policies/enforcementQueue";
|
||||
|
||||
export function EnforcementQueueStatus() {
|
||||
const { t } = useTranslation();
|
||||
const jobs = useEnforcementQueue();
|
||||
const active = jobs.filter(
|
||||
(j) => j.status === "pending" || j.status === "running",
|
||||
);
|
||||
if (active.length === 0) return null;
|
||||
|
||||
// The running job leads the row; everything else is still queued behind it.
|
||||
const lead = active.find((j) => j.status === "running") ?? active[0];
|
||||
const queued = active.length - 1;
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
px="sm"
|
||||
py={6}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader size="xs" />
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{t(`policies.enforcement.triggerVerb.${lead.trigger}`, {
|
||||
defaultValue: t("policies.enforcement.triggerVerb.default"),
|
||||
})}
|
||||
: {lead.label}
|
||||
{queued > 0
|
||||
? ` · ${t("policies.enforcement.queued", { count: queued })}`
|
||||
: "…"}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default EnforcementQueueStatus;
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Serial enforcement queue. Every policy enforcement — before export, before
|
||||
* print, before a convert/extract, and (later) as files arrive — runs through
|
||||
* here, one at a time. The backend rejects concurrent policy runs under load,
|
||||
* and a single in-flight run keeps the queue the user sees honest.
|
||||
*
|
||||
* Jobs carry their {@link EnforcementTrigger} and a status so the UI can show
|
||||
* what's pending/running. Input enforcement reuses this contract unchanged: it
|
||||
* just submits jobs with `trigger: "input"`.
|
||||
*/
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type EnforcementTrigger = "export" | "print" | "convert" | "input";
|
||||
export type EnforcementStatus = "pending" | "running" | "done" | "failed";
|
||||
|
||||
export interface EnforcementJob {
|
||||
id: string;
|
||||
/** Human-readable label, e.g. the policy/file name, shown in the queue UI. */
|
||||
label: string;
|
||||
trigger: EnforcementTrigger;
|
||||
status: EnforcementStatus;
|
||||
}
|
||||
|
||||
/** How long a finished job lingers in the list before it's dropped. */
|
||||
const DONE_LINGER_MS = 2500;
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
let jobs: EnforcementJob[] = [];
|
||||
// The tail of the serial chain — each new job runs after this resolves.
|
||||
let tail: Promise<unknown> = Promise.resolve();
|
||||
let seq = 0;
|
||||
|
||||
function emit() {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
|
||||
function setStatus(id: string, status: EnforcementStatus) {
|
||||
jobs = jobs.map((j) => (j.id === id ? { ...j, status } : j));
|
||||
emit();
|
||||
}
|
||||
|
||||
function scheduleRemoval(id: string) {
|
||||
setTimeout(() => {
|
||||
jobs = jobs.filter((j) => j.id !== id);
|
||||
emit();
|
||||
}, DONE_LINGER_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` after every job queued before it has finished, tracking its status
|
||||
* for the UI. The returned promise resolves/rejects with the task's result, so
|
||||
* callers can `await runQueued(...)` exactly as they would the bare work.
|
||||
*/
|
||||
export function runQueued<T>(
|
||||
meta: { label: string; trigger: EnforcementTrigger },
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const id = `enf-${++seq}`;
|
||||
jobs = [
|
||||
...jobs,
|
||||
{ id, label: meta.label, trigger: meta.trigger, status: "pending" },
|
||||
];
|
||||
emit();
|
||||
|
||||
const run = tail.then(async () => {
|
||||
setStatus(id, "running");
|
||||
try {
|
||||
const result = await task();
|
||||
setStatus(id, "done");
|
||||
return result;
|
||||
} catch (error) {
|
||||
setStatus(id, "failed");
|
||||
throw error;
|
||||
} finally {
|
||||
scheduleRemoval(id);
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the chain alive when a task rejects so the next job still runs; callers
|
||||
// still see the rejection through `run`.
|
||||
tail = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
export function getQueueJobs(): EnforcementJob[] {
|
||||
return jobs;
|
||||
}
|
||||
|
||||
export function subscribeQueue(listener: Listener): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** React view of the live queue (pending + running + briefly-lingering jobs). */
|
||||
export function useEnforcementQueue(): EnforcementJob[] {
|
||||
return useSyncExternalStore(subscribeQueue, getQueueJobs, getQueueJobs);
|
||||
}
|
||||
@@ -17,10 +17,18 @@ import {
|
||||
getPolicyRun,
|
||||
downloadPolicyOutput,
|
||||
} from "@app/services/policyApi";
|
||||
import { recordRunStart } from "@app/components/policies/policyRunStore";
|
||||
import {
|
||||
recordRunStart,
|
||||
isDispatched,
|
||||
} from "@app/components/policies/policyRunStore";
|
||||
import {
|
||||
runQueued,
|
||||
type EnforcementTrigger,
|
||||
} from "@app/components/policies/enforcementQueue";
|
||||
import { ROW_ACCENT } from "@app/components/policies/policyStatus";
|
||||
import { alert, updateToast, dismissToast } from "@app/components/toast";
|
||||
import { POLICIES_ENABLED } from "@app/constants/featureFlags";
|
||||
import i18n from "@app/i18n";
|
||||
|
||||
/** Poll cadence + cap for a single export run (≈2.5 min worst case). */
|
||||
const POLL_MS = 2000;
|
||||
@@ -98,10 +106,29 @@ async function runToCompletion(
|
||||
if (view.status === "FAILED" || view.status === "CANCELLED") {
|
||||
throw new Error(view.error || `policy run ${view.status.toLowerCase()}`);
|
||||
}
|
||||
if (view.status === "WAITING_FOR_INPUT") {
|
||||
throw new Error(
|
||||
"policy requires interactive input and cannot run automatically",
|
||||
);
|
||||
}
|
||||
}
|
||||
throw new Error("policy run timed out");
|
||||
}
|
||||
|
||||
function enforcedFilesSummary(names: string[]): string {
|
||||
if (names.length === 1) return names[0];
|
||||
if (names.length === 2)
|
||||
return i18n.t("policies.enforcement.summaryTwo", {
|
||||
first: names[0],
|
||||
second: names[1],
|
||||
});
|
||||
return i18n.t("policies.enforcement.summaryMore", {
|
||||
first: names[0],
|
||||
second: names[1],
|
||||
more: names.length - 2,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce every active export-policy on each PDF just before export, returning
|
||||
* the files in order (enforced, or the original on failure). `fileIds[i]` is the
|
||||
@@ -113,80 +140,119 @@ async function runToCompletion(
|
||||
export async function enforceExportPolicies(
|
||||
files: File[],
|
||||
fileIds?: (string | undefined)[],
|
||||
trigger: EnforcementTrigger = "export",
|
||||
): Promise<File[]> {
|
||||
const active = activeExportPolicies();
|
||||
const targets = files.flatMap((f, i) => (isPdf(f) ? [i] : []));
|
||||
if (!active.length || targets.length === 0) return files;
|
||||
|
||||
// Policies that haven't already enforced this exact file version. Enforcing
|
||||
// versions the in-editor file to the policy's output and marks that output
|
||||
// dispatched, so an unedited re-export skips re-running — re-applying a
|
||||
// non-idempotent policy would stack watermarks/flattens. Editing produces a
|
||||
// new file id that isn't dispatched, so an edited file enforces afresh.
|
||||
const pendingFor = (fileId: string | undefined) =>
|
||||
active.filter((p) => !(fileId && isDispatched(p.categoryId, fileId)));
|
||||
if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files;
|
||||
|
||||
const names = active.map((p) => p.label).join(", ");
|
||||
const toastId = alert({
|
||||
alertType: "neutral",
|
||||
title: `Applying ${names}`,
|
||||
body: `Enforcing ${
|
||||
targets.length === 1 ? "your file" : `${targets.length} files`
|
||||
} before export…`,
|
||||
isPersistentPopup: true,
|
||||
expandable: false,
|
||||
glowColor: active[0].accent,
|
||||
});
|
||||
|
||||
const out = [...files];
|
||||
let failures = 0;
|
||||
for (const i of targets) {
|
||||
const file = files[i];
|
||||
const fileId = fileIds?.[i];
|
||||
try {
|
||||
let current = file;
|
||||
// The last "new version" policy's output is what versions the editor file
|
||||
// (recording every policy would double-consume the same input).
|
||||
let versionRun: PolicyRunResult & { categoryId: string };
|
||||
let hasVersionRun = false;
|
||||
for (const policy of active) {
|
||||
const result = await runToCompletion(policy.backendId, current);
|
||||
current = result.file;
|
||||
if (policy.outputMode === "new_version" && fileId) {
|
||||
versionRun = { ...result, categoryId: policy.categoryId };
|
||||
hasVersionRun = true;
|
||||
// Serialise through the enforcement queue: one policy run in flight at a time
|
||||
// (the backend rejects concurrent runs under load), and the user can see
|
||||
// what's pending. Export, print and convert all share this queue.
|
||||
return runQueued({ label: names, trigger }, async () => {
|
||||
// An earlier queued job may have just enforced these same files and marked
|
||||
// them dispatched, so re-check at run time before doing (or announcing) work.
|
||||
if (!targets.some((i) => pendingFor(fileIds?.[i]).length > 0)) return files;
|
||||
|
||||
const pending = targets.filter((i) => pendingFor(fileIds?.[i]).length > 0);
|
||||
const total = pending.length;
|
||||
const progressTitle = (done: number) =>
|
||||
total === 1
|
||||
? i18n.t("policies.enforcement.applying", { names })
|
||||
: i18n.t("policies.enforcement.applyingProgress", {
|
||||
names,
|
||||
done: done + 1,
|
||||
total,
|
||||
});
|
||||
const progressBody = (done: number) => files[pending[done]].name;
|
||||
|
||||
const toastId = alert({
|
||||
alertType: "neutral",
|
||||
title: progressTitle(0),
|
||||
body: progressBody(0),
|
||||
isPersistentPopup: true,
|
||||
expandable: false,
|
||||
glowColor: active[0].accent,
|
||||
});
|
||||
|
||||
const out = [...files];
|
||||
let failures = 0;
|
||||
let done = 0;
|
||||
for (const i of pending) {
|
||||
const file = files[i];
|
||||
const fileId = fileIds?.[i];
|
||||
const toRun = pendingFor(fileId);
|
||||
try {
|
||||
let current = file;
|
||||
// The last "new version" policy's output is what versions the editor
|
||||
// file (recording every policy would double-consume the same input).
|
||||
let versionRun: (PolicyRunResult & { categoryId: string }) | undefined;
|
||||
for (const policy of toRun) {
|
||||
const result = await runToCompletion(policy.backendId, current);
|
||||
current = result.file;
|
||||
if (policy.outputMode === "new_version" && fileId) {
|
||||
versionRun = { ...result, categoryId: policy.categoryId };
|
||||
}
|
||||
}
|
||||
out[i] = current;
|
||||
done += 1;
|
||||
if (done < total)
|
||||
updateToast(toastId, {
|
||||
title: progressTitle(done),
|
||||
body: progressBody(done),
|
||||
});
|
||||
if (versionRun && fileId) {
|
||||
recordRunStart({
|
||||
runId: versionRun.runId,
|
||||
categoryId: versionRun.categoryId,
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
status: "COMPLETED",
|
||||
outputs: versionRun.outputs,
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
failures += 1; // leave out[i] as the original — never hard-block.
|
||||
}
|
||||
out[i] = current;
|
||||
if (hasVersionRun && fileId) {
|
||||
recordRunStart({
|
||||
runId: versionRun!.runId,
|
||||
categoryId: versionRun!.categoryId,
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
status: "COMPLETED",
|
||||
outputs: versionRun!.outputs,
|
||||
error: null,
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
failures += 1; // leave out[i] as the original — never hard-block.
|
||||
}
|
||||
}
|
||||
|
||||
updateToast(
|
||||
toastId,
|
||||
failures
|
||||
? {
|
||||
alertType: "warning",
|
||||
title: "Exported without full enforcement",
|
||||
body: `${failures} of ${targets.length} file(s) couldn't be processed and were exported as-is.`,
|
||||
isPersistentPopup: false,
|
||||
glowColor: undefined,
|
||||
}
|
||||
: {
|
||||
alertType: "success",
|
||||
title: `${names} applied`,
|
||||
body: "Enforced before export.",
|
||||
isPersistentPopup: false,
|
||||
glowColor: undefined,
|
||||
},
|
||||
);
|
||||
// update() doesn't reschedule auto-dismiss, so fade the result out explicitly.
|
||||
window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS);
|
||||
return out;
|
||||
updateToast(
|
||||
toastId,
|
||||
failures
|
||||
? {
|
||||
alertType: "warning",
|
||||
title: i18n.t("policies.enforcement.failureTitle"),
|
||||
body: i18n.t("policies.enforcement.failureBody", {
|
||||
failures,
|
||||
total,
|
||||
}),
|
||||
isPersistentPopup: false,
|
||||
glowColor: undefined,
|
||||
}
|
||||
: {
|
||||
alertType: "success",
|
||||
title: i18n.t("policies.enforcement.successTitle", { names }),
|
||||
body: enforcedFilesSummary(pending.map((i) => files[i].name)),
|
||||
isPersistentPopup: false,
|
||||
glowColor: undefined,
|
||||
},
|
||||
);
|
||||
// update() doesn't reschedule auto-dismiss, so fade the result out explicitly.
|
||||
window.setTimeout(() => dismissToast(toastId), TOAST_LINGER_MS);
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user