import { useEffect, useMemo, useRef, useState } from "react"; import { formatBytes, triggerBlobDownload } from "@add-ideas/toolbox-helpers"; import { createBatchArchive, createBatchReport, createPolicyEvidence, createSafeShareReport, genericOutputName, policyById, SELECTIVE_REMOVAL_POLICIES, sanitizeStaticImage, scanFilesInWorker, serializeReport, type FindingCategory, type ImageScanResult, type SelectiveRemovalPolicy, type SanitizedAsset, } from "../privacy"; interface FileRecord { file: File; scan: ImageScanResult; asset?: SanitizedAsset; sanitizing?: boolean; error?: string; } interface Progress { completed: number; total: number; currentName: string; } const CATEGORY_LABELS: Readonly> = Object.freeze({ location: "Location", identity: "People, authorship & rights", timestamp: "Dates & times", device: "Device, serial & lens", software: "Software & history", "document-id": "Document identifiers", comment: "Comments, titles & keywords", thumbnail: "Embedded previews & extra images", "colour-profile": "Colour profiles", provenance: "Provenance & signatures", technical: "Technical metadata", unknown: "Unclassified metadata", }); export function Workbench() { const [records, setRecords] = useState([]); const [busy, setBusy] = useState<"scan" | "sanitize" | "archive" | null>( null, ); const [progress, setProgress] = useState(null); const [error, setError] = useState(""); const [dragging, setDragging] = useState(false); const [genericNames, setGenericNames] = useState(true); const [policyId, setPolicyId] = useState("safe-share"); const abortRef = useRef(null); const inputRef = useRef(null); const cleanable = useMemo( () => records.filter((record) => record.scan.cleanable), [records], ); const assets = useMemo( () => records.flatMap((record) => (record.asset ? [record.asset] : [])), [records], ); const policy = useMemo(() => policyById(policyId), [policyId]); const policyEvidence = useMemo( () => createPolicyEvidence( records.map((record) => record.scan), assets, policy, ), [assets, policy, records], ); useEffect( () => () => { abortRef.current?.abort(); }, [], ); const importFiles = async (selection: FileList | readonly File[]) => { const files = Array.from(selection); if (files.length === 0) return; abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; setBusy("scan"); setProgress({ completed: 0, total: files.length, currentName: files[0]?.name ?? "", }); setError(""); setRecords([]); try { const scans = await scanFilesInWorker( files, setProgress, controller.signal, ); if (controller.signal.aborted) return; setRecords(scans.map((scan, index) => ({ file: files[index]!, scan }))); } catch (caught) { if (!isAbort(caught)) setError(errorMessage(caught)); } finally { if (abortRef.current === controller) { abortRef.current = null; setBusy(null); setProgress(null); } if (inputRef.current) inputRef.current.value = ""; } }; const sanitizeOne = async (id: string) => { const record = records.find((item) => item.scan.id === id); if (!record?.scan.cleanable) return; const controller = new AbortController(); abortRef.current = controller; setBusy("sanitize"); setError(""); setRecords((current) => current.map((item) => item.scan.id === id ? { ...item, sanitizing: true, error: undefined } : item, ), ); try { const asset = await sanitizeStaticImage(record.file, record.scan, { signal: controller.signal, outputName: genericNames ? genericOutputName( records.findIndex((item) => item.scan.id === id), record.scan.identity.detectedKind, ) : undefined, }); setRecords((current) => current.map((item) => item.scan.id === id ? { ...item, asset, sanitizing: false, error: undefined } : item, ), ); } catch (caught) { if (!isAbort(caught)) setRecords((current) => current.map((item) => item.scan.id === id ? { ...item, sanitizing: false, error: errorMessage(caught), } : item, ), ); } finally { if (abortRef.current === controller) abortRef.current = null; setBusy(null); } }; const sanitizeAll = async () => { const pending = records.filter( (record) => record.scan.cleanable && !record.asset, ); if (pending.length === 0) return; const controller = new AbortController(); abortRef.current = controller; setBusy("sanitize"); setError(""); for (let index = 0; index < pending.length; index += 1) { const record = pending[index]; if (!record || controller.signal.aborted) break; setProgress({ completed: index, total: pending.length, currentName: record.file.name, }); setRecords((current) => current.map((item) => item.scan.id === record.scan.id ? { ...item, sanitizing: true, error: undefined } : item, ), ); try { const asset = await sanitizeStaticImage(record.file, record.scan, { signal: controller.signal, outputName: genericNames ? genericOutputName( records.findIndex((item) => item.scan.id === record.scan.id), record.scan.identity.detectedKind, ) : undefined, }); setRecords((current) => current.map((item) => item.scan.id === record.scan.id ? { ...item, asset, sanitizing: false } : item, ), ); } catch (caught) { if (isAbort(caught)) break; setRecords((current) => current.map((item) => item.scan.id === record.scan.id ? { ...item, sanitizing: false, error: errorMessage(caught), } : item, ), ); } } if (abortRef.current === controller) abortRef.current = null; setRecords((current) => current.map((record) => record.sanitizing ? { ...record, sanitizing: false } : record, ), ); setProgress(null); setBusy(null); }; const downloadReport = () => { setError(""); try { const report = createBatchReport( records.map((record) => record.scan), assets, ); triggerBlobDownload( new Blob([serializeReport(report)], { type: "application/json" }), "privacy-tools-report.json", ); } catch (caught) { setError(errorMessage(caught)); } }; const downloadSafeReport = () => { setError(""); try { const report = createSafeShareReport( records.map((record) => record.scan), assets, ); triggerBlobDownload( new Blob([serializeReport(report)], { type: "application/json" }), "privacy-tools-safe-share-report.json", ); } catch (caught) { setError(errorMessage(caught)); } }; const downloadPolicyEvidence = () => { setError(""); try { triggerBlobDownload( new Blob([serializeReport(policyEvidence)], { type: "application/json", }), `privacy-tools-${policy.id}-policy-evidence.json`, ); } catch (caught) { setError(errorMessage(caught)); } }; const downloadArchive = async () => { if (assets.length === 0) return; setBusy("archive"); setError(""); try { const blob = await createBatchArchive( records.map((record) => record.scan), assets, undefined, "safe-share", ); triggerBlobDownload(blob, "privacy-tools-re-encoded-images.zip"); } catch (caught) { setError(errorMessage(caught)); } finally { setBusy(null); } }; const clear = () => { abortRef.current?.abort(); abortRef.current = null; setRecords([]); setBusy(null); setProgress(null); setError(""); }; return (

Image metadata workbench

Privacy Tools

Inventory files, inspect static-image metadata, then create and independently re-scan pixel-only sharing copies—all in this browser.

Local & ephemeral

Step 1

Choose files to inspect

100 files · 128 MiB each · 512 MiB batch
{ if (event.currentTarget.files) void importFiles(event.currentTarget.files); }} /> {busy && progress ? (
{busy === "scan" ? "Inspecting" : "Re-encoding"}{" "} {progress.currentName} · {progress.completed} of {progress.total}
) : null} {error ? (

{error}

) : null}
{records.length > 0 ? ( <>

Step 2

Batch inventory

Detection uses file bytes, not just the extension or browser claim.

{busy === "scan" || busy === "sanitize" ? ( ) : null}
{records.map((record) => ( ))}
File Claimed Detected Findings Clean copy
{record.scan.name} {formatBytes(record.scan.size)} ·{" "} {record.scan.sha256.slice(0, 12)}… {record.scan.identity.claimedType || "not claimed"} .{record.scan.identity.extension || "none"} {record.scan.identity.detectedType} {record.scan.findings.length} {record.scan.coverage.projectScanner} project scan {record.asset ? ( ) : record.scan.cleanable ? ( ) : ( Inspect only )}
{records.map((record) => ( void sanitizeOne(record.scan.id)} /> ))}

Step 3

Export deliberately

Safe-share reports omit names, hashes, exact sizes, values and offsets. Detailed reports retain that evidence and may themselves be sensitive.

Reusable policy evidence

Selective-removal policy

{policy.description}

{policyEvidence.files.map((file) => ( ))}
Evidence ID Format Decision Operation Remove / preserve / review
{file.fileId} {file.detectedKind} {file.availableOperation} {file.findings.remove} / {file.findings.preserve} /{" "} {file.findings.review}

A policy can require preservation that the pixel re-encoder cannot guarantee. In that case the evidence says “policy-not-executable” instead of silently discarding metadata. Unsupported formats remain inspect-only.

) : null}
); } function FileResult({ record, busy, onSanitize, }: { record: FileRecord; busy: boolean; onSanitize(): void; }) { const grouped = groupFindings(record.scan); return (

{record.scan.identity.detectedKind.toUpperCase()}

{record.scan.name}

{record.scan.width && record.scan.height ? `${record.scan.width} × ${record.scan.height} pixels · ` : ""} SHA-256 {record.scan.sha256}

{record.scan.cleanable && !record.asset ? ( ) : null} {record.asset ? ( ) : null}
{record.error ? (

{record.error}

) : null} {record.scan.warnings.length > 0 ? (
    {record.scan.warnings.map((warning) => (
  • {warning}
  • ))}
) : null}
{grouped.length === 0 ? (

No recognized metadata findings were reported within scanner coverage.

) : ( grouped.map(([category, findings]) => (
finding.risk === "sensitive")} > {CATEGORY_LABELS[category]} {findings.length}
{findings.slice(0, 50).map((finding) => (
{finding.label} {finding.source}
{finding.value || "(empty)"}
))} {findings.length > 50 ? (

{findings.length - 50} more findings are included in the JSON report.

) : null}
)) )}
{record.asset ? : null}
); } function Verification({ report }: { report: SanitizedAsset["report"] }) { return (

Mandatory output re-scan

{report.summary}

Output hash
{report.outputSha256}
Metadata
{report.removed.length} removed · {report.preserved.length}{" "} preserved · {report.generated.length} generated
Orientation
{report.orientationNormalized ? "Normalized" : "Review required"}
Pixel sample
{report.pixelComparison.identical ? "Identical" : "Changed after encoding"}
{report.generated.length > 0 ? (
Generated or preserved output metadata
    {report.generated.map((finding) => (
  • {finding.label}: {finding.value}
  • ))}
) : null} {[...report.unsupported, ...report.incomplete].length > 0 ? (
    {[...report.unsupported, ...report.incomplete].map((note) => (
  • {note}
  • ))}
) : null}

{report.disclaimer}

); } function StatusBadge({ status }: { status: string }) { const normalized = status.replace(/[^a-z]+/gu, "-"); return {status}; } function groupFindings( scan: ImageScanResult, ): Array<[FindingCategory, ImageScanResult["findings"]]> { const groups = new Map(); for (const finding of scan.findings) { const values = groups.get(finding.category) ?? []; values.push(finding); groups.set(finding.category, values); } return [...groups.entries()]; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : "The operation failed."; } function isAbort(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; }