557 lines
31 KiB
TypeScript
557 lines
31 KiB
TypeScript
import { Action, ActionPanel, Alert, Color, confirmAlert, Form, getSelectedFinderItems, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
|
|
import { promises as fs } from "node:fs";
|
|
import { basename, dirname, extname, join } from "node:path";
|
|
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import type { EntityType, PlaceholderMapping } from "./types";
|
|
import { getEntityTypes, pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "./velum";
|
|
import type { DocumentHit } from "./velum";
|
|
import {
|
|
createRun, digest, forceMasks, listRuns, loadProjectRules, parseProjectRules, replaceSpans,
|
|
loadProjectWhitelist, matchesPreviousOutput, replaceProjectFilesInPlace, resolveWithin, safeRelativePath, saveProjectRules,
|
|
saveProjectWhitelist, saveRun, scanProjectFiles, toUtf16Offsets, whitelistKey,
|
|
} from "./lib/velum-project";
|
|
import type { ProjectFile, ProjectRule, ProjectWhitelistEntry, RunFile } from "./lib/velum-project";
|
|
import {
|
|
canonicalPerson, filenameScanText, groupReviewHits, makeTextBatches, mappingForSelection, mergePeople, spansBySegment,
|
|
withoutWhitelistedHits, withoutWhitelistedRules,
|
|
} from "./lib/project-review";
|
|
|
|
const run = promisify(execFile);
|
|
const MAX_REVIEW_HITS = 50000;
|
|
type Hit = {
|
|
id: string; file: string; source: string; original: string; placeholder: string;
|
|
type: string; score: number; context: string; start: number; end: number;
|
|
kind: "content" | "path";
|
|
};
|
|
type Draft = { file: ProjectFile; hash: string; hits: Hit[] };
|
|
type PathDraft = { key: string; value: string; hits: Hit[] };
|
|
type Preview = { drafts: Draft[]; retained: Array<{ file: ProjectFile; hash: string }>;
|
|
paths: PathDraft[]; mapping: PlaceholderMapping; previousMapping: PlaceholderMapping;
|
|
matchModes?: Record<string, "token" | "anywhere">; hits: Hit[]; entityTypes: EntityType[];
|
|
skipped: number; sourceInPlace: boolean };
|
|
|
|
const typeLabels: Record<string, string> = {
|
|
PERSON: "Personen", ORT: "Orte", ORG: "Organisationen", FIRMA: "Firmen",
|
|
KUNDE: "Kunden", ADRESSE: "Adressen", BEGRIFF: "Eigene Begriffe",
|
|
EMAIL: "E-Mail-Adressen", TELEFON: "Telefonnummern", IBAN: "IBAN",
|
|
UID: "UID-Nummern", FIRMENBUCH: "Firmenbuchnummern", SVNR: "Sozialversicherungsnummern",
|
|
PASSWORT: "Passwörter", BENUTZER: "Benutzerkennungen", PROJEKT: "Projektbezeichnungen",
|
|
DATUM: "Datumsangaben", REFERENZ: "Referenzen", KARTE: "Kreditkarten",
|
|
IP: "IP-Adressen", URL: "Internetadressen", DVR: "DVR-Nummern", GISA: "GISA-Nummern",
|
|
};
|
|
const labelForType = (type: string) => typeLabels[type] ?? type;
|
|
|
|
async function finderFolder(): Promise<string | undefined> {
|
|
try {
|
|
// The user asked for the open Finder folder, even when a file inside it is selected.
|
|
const { stdout } = await run("/usr/bin/osascript", ["-e",
|
|
'tell application "Finder" to get POSIX path of (target of front window as alias)',
|
|
]);
|
|
const path = stdout.trim();
|
|
if (path && (await fs.stat(path)).isDirectory()) return path;
|
|
} catch { /* Finder may have no open window or Automation permission */ }
|
|
try {
|
|
const items = await getSelectedFinderItems();
|
|
if (items.length) {
|
|
const first = items[0].path;
|
|
return (await fs.stat(first)).isDirectory() ? first : dirname(first);
|
|
}
|
|
} catch { /* Finder selection may be empty or unavailable */ }
|
|
return undefined;
|
|
}
|
|
|
|
function textFromBytes(data: Buffer): string {
|
|
return new TextDecoder("utf-8", { fatal: true }).decode(data);
|
|
}
|
|
|
|
function contentHits(file: ProjectFile, spans: Array<{
|
|
start: number; end: number; type: string; score: number; placeholder: string; original: string;
|
|
}>, text: string): Hit[] {
|
|
return toUtf16Offsets(text, spans).map((span, index) => ({
|
|
...span, id: `${file.relPath}:content:${index}`, file: file.relPath,
|
|
source: file.relPath, kind: "content" as const,
|
|
context: text.slice(Math.max(0, span.start - 70), Math.min(text.length, span.end + 70)),
|
|
}));
|
|
}
|
|
|
|
function documentHits(file: ProjectFile, hits: DocumentHit[]): Hit[] {
|
|
return hits.map((hit) => ({ ...hit, id: `${file.relPath}:document:${hit.id}`,
|
|
file: file.relPath, kind: "content" as const }));
|
|
}
|
|
|
|
function pathSegments(files: ProjectFile[]): PathDraft[] {
|
|
const map = new Map<string, PathDraft>();
|
|
for (const file of files) {
|
|
const parts = file.relPath.split("/");
|
|
for (let index = 0; index < parts.length; index++) {
|
|
const key = parts.slice(0, index + 1).join("/");
|
|
const isFile = index === parts.length - 1;
|
|
const value = isFile ? basename(parts[index], extname(parts[index])) : parts[index];
|
|
if (value && !map.has(key)) map.set(key, { key, value, hits: [] });
|
|
}
|
|
}
|
|
return [...map.values()];
|
|
}
|
|
|
|
async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[], whitelist: ProjectWhitelistEntry[], entityTypes: EntityType[],
|
|
skipped: number, onProgress: (message: string) => void): Promise<Preview> {
|
|
await requireProjectCapabilities();
|
|
const masks = forceMasks(withoutWhitelistedRules(rules, whitelist));
|
|
const latestEntry = (await listRuns(root))[0];
|
|
const latest = latestEntry?.run;
|
|
if (latest?.origin === "imported") {
|
|
throw new Error("Dieser Quellordner enthält bereits pseudonymisierte Dateien. Für einen neuen Lauf bitte einen Klartext-Quellordner wählen.");
|
|
}
|
|
const previousMapping: PlaceholderMapping = latest?.mapping ?? {};
|
|
let mapping: PlaceholderMapping = previousMapping;
|
|
const drafts: Draft[] = [];
|
|
const retained: Preview["retained"] = [];
|
|
const draftsByPath = new Map<string, Draft>();
|
|
const hits: Hit[] = [];
|
|
const texts = new Map<string, string>();
|
|
const sourceInPlace = Boolean(latest?.inPlace || latest?.sourceInPlace);
|
|
const previousOutputs = new Set(sourceInPlace ? latest?.files.map((file) => file.output) : []);
|
|
const previousDirectory = latestEntry?.directory;
|
|
for (const file of files) {
|
|
const data = await fs.readFile(file.absPath);
|
|
const hash = digest(data);
|
|
const recheckCommentAuthors = entityTypes.includes("PERSON") && file.kind === "document"
|
|
&& extname(file.relPath).toLowerCase() === ".docx" && !latest?.commentAuthorCoverage;
|
|
if (!recheckCommentAuthors && previousDirectory && previousOutputs.has(file.relPath)
|
|
&& await matchesPreviousOutput(previousDirectory, file.relPath, hash)) {
|
|
retained.push({ file, hash });
|
|
continue;
|
|
}
|
|
const draft: Draft = { file, hash, hits: [] };
|
|
drafts.push(draft);
|
|
draftsByPath.set(file.relPath, draft);
|
|
if (file.kind === "text") texts.set(file.relPath, textFromBytes(data));
|
|
}
|
|
const addHits = (draft: Draft, found: Hit[]) => {
|
|
draft.hits = withoutWhitelistedHits(found, whitelist);
|
|
hits.push(...draft.hits);
|
|
if (hits.length > MAX_REVIEW_HITS) throw new Error("Zu viele Treffer. Bitte weniger Kategorien auswählen.");
|
|
};
|
|
const batches = makeTextBatches([...texts].map(([key, text]) => ({ key, text })));
|
|
for (const [index, batch] of batches.entries()) {
|
|
onProgress(`Textdateien analysieren: Paket ${index + 1}/${batches.length}`);
|
|
const result = await pseudonymize(batch.text, mapping, entityTypes, masks);
|
|
mapping = result.mapping;
|
|
const byFile = spansBySegment(batch, result.spans);
|
|
for (const segment of batch.segments) {
|
|
const draft = draftsByPath.get(segment.key)!;
|
|
addHits(draft, contentHits(draft.file, byFile.get(segment.key) ?? [], segment.text));
|
|
}
|
|
}
|
|
const documents = drafts.filter((draft) => draft.file.kind === "document");
|
|
for (const [index, draft] of documents.entries()) {
|
|
onProgress(`Dokumente analysieren: ${index + 1}/${documents.length}`);
|
|
const data = await fs.readFile(draft.file.absPath);
|
|
const result = await pseudonymizeDocument(basename(draft.file.relPath), data, mapping, masks, undefined, entityTypes);
|
|
mapping = result.mapping;
|
|
addHits(draft, documentHits(draft.file, result.hits));
|
|
}
|
|
// Recheck names even when an in-place file's contents are unchanged. An
|
|
// earlier run may have masked the content but left the filename clear.
|
|
const paths = pathSegments(files);
|
|
const knownTerms = [...Object.keys(mapping), ...Object.values(mapping).map((entry) => entry.original),
|
|
...withoutWhitelistedRules(rules, whitelist).map((rule) => rule.value)];
|
|
const pathBatches = makeTextBatches(paths.map((path) => ({
|
|
key: path.key, text: filenameScanText(path.value, knownTerms),
|
|
})));
|
|
const byPath = new Map(paths.map((path) => [path.key, path]));
|
|
for (const [index, batch] of pathBatches.entries()) {
|
|
onProgress(`Dateinamen analysieren: Paket ${index + 1}/${pathBatches.length}`);
|
|
const result = await pseudonymize(batch.text, mapping, entityTypes, masks);
|
|
mapping = result.mapping;
|
|
const spans = spansBySegment(batch, result.spans);
|
|
for (const segment of batch.segments) {
|
|
const path = byPath.get(segment.key)!;
|
|
path.hits = withoutWhitelistedHits(toUtf16Offsets(segment.text, spans.get(path.key) ?? []).map((span, hitIndex) => ({
|
|
...span, id: `${path.key}:path:${hitIndex}`, file: path.key,
|
|
source: "Datei- oder Ordnername", kind: "path" as const,
|
|
context: path.value,
|
|
})), whitelist);
|
|
hits.push(...path.hits);
|
|
if (hits.length > MAX_REVIEW_HITS) throw new Error("Zu viele Treffer. Bitte weniger Kategorien auswählen.");
|
|
}
|
|
}
|
|
return { drafts, retained, paths, mapping, previousMapping,
|
|
matchModes: latest?.matchModes, hits, entityTypes, skipped, sourceInPlace };
|
|
}
|
|
|
|
function outputPath(relPath: string, paths: PathDraft[], accepted: Set<string>, document: boolean,
|
|
merges: Record<string, string>): string {
|
|
const lookup = new Map(paths.map((item) => [item.key, item]));
|
|
const parts = relPath.split("/");
|
|
const output = parts.map((part, index) => {
|
|
const key = parts.slice(0, index + 1).join("/");
|
|
const path = lookup.get(key);
|
|
if (!path) return part;
|
|
const isFile = index === parts.length - 1;
|
|
const extension = isFile ? (document && extname(part).toLowerCase() === ".pdf" ? ".docx" : extname(part)) : "";
|
|
return replaceSpans(path.value, withPersonMerges(path.hits, merges), accepted) + extension;
|
|
});
|
|
return safeRelativePath(output.join("/"));
|
|
}
|
|
|
|
function withPersonMerges(hits: Hit[], merges: Record<string, string>): Hit[] {
|
|
return hits.map((hit) => hit.type === "PERSON"
|
|
? { ...hit, placeholder: canonicalPerson(hit.placeholder, merges) } : hit);
|
|
}
|
|
|
|
function OccurrenceReview(props: { original: string; placeholder: string; hits: Hit[] }) {
|
|
return <List isShowingDetail navigationTitle={`${props.original} → ${props.placeholder}`}>
|
|
{props.hits.map((hit) => <List.Item key={hit.id} title={hit.source}
|
|
subtitle={hit.kind === "path" ? "Datei- oder Ordnername" : "Dateiinhalt"}
|
|
detail={<List.Item.Detail markdown={hit.context.replace(/</g, "<")} />} />)}
|
|
</List>;
|
|
}
|
|
|
|
function HitReview(props: { root: string; rules: ProjectRule[]; whitelist: ProjectWhitelistEntry[];
|
|
preview: Preview; onWhitelistChanged: (entries: ProjectWhitelistEntry[]) => void }) {
|
|
const groups = useMemo(() => groupReviewHits(props.preview.hits), [props.preview.hits]);
|
|
const [selectedGroups, setSelectedGroups] = useState<Set<string>>(() => new Set(groups.map((group) => group.key)));
|
|
const [stage, setStage] = useState<"selection" | "merge">("selection");
|
|
const [personMerges, setPersonMerges] = useState<Record<string, string>>({});
|
|
const [busy, setBusy] = useState(false);
|
|
const accepted = useMemo(() => new Set(groups.filter((group) => selectedGroups.has(group.key))
|
|
.flatMap((group) => group.hits.map((hit) => hit.id))), [groups, selectedGroups]);
|
|
const selectedOccurrences = accepted.size;
|
|
const byType = useMemo(() => {
|
|
const result = new Map<string, typeof groups>();
|
|
for (const group of groups) (result.get(group.type) ?? (result.set(group.type, []), result.get(group.type)!)).push(group);
|
|
return result;
|
|
}, [groups]);
|
|
const people = useMemo(() => {
|
|
const result = new Map<string, string[]>();
|
|
for (const group of groups) {
|
|
if (group.type !== "PERSON" || !selectedGroups.has(group.key)) continue;
|
|
const originals = result.get(group.placeholder) ?? [];
|
|
if (!originals.includes(group.original)) originals.push(group.original);
|
|
result.set(group.placeholder, originals);
|
|
}
|
|
return [...result].map(([placeholder, originals]) => ({ placeholder, originals }));
|
|
}, [groups, selectedGroups]);
|
|
const earlierPeople = useMemo(() => Object.entries(props.preview.previousMapping)
|
|
.filter(([placeholder, entry]) => entry.type === "PERSON" && !entry.merged_into
|
|
&& !people.some((person) => person.placeholder === placeholder))
|
|
.map(([placeholder, entry]) => ({ placeholder, originals: [entry.original] })),
|
|
[people, props.preview.previousMapping]);
|
|
function toggle(key: string) {
|
|
setSelectedGroups((current) => {
|
|
const next = new Set(current);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
return next;
|
|
});
|
|
}
|
|
async function apply(inPlace = false) {
|
|
if (!props.preview.drafts.length && !accepted.size) {
|
|
try {
|
|
const rejected = groups.map((group) => ({ type: group.type, original: group.original }));
|
|
if (rejected.length) {
|
|
await saveProjectWhitelist(props.root, [...await loadProjectWhitelist(props.root), ...rejected]);
|
|
props.onWhitelistChanged(await loadProjectWhitelist(props.root));
|
|
}
|
|
await showToast({ style: Toast.Style.Success, title: "Keine neuen Änderungen",
|
|
message: rejected.length
|
|
? "Der bestehende Projektstand bleibt erhalten. Abgewählte Begriffe wurden als Ausnahmen gespeichert."
|
|
: "Der bestehende Projektstand bleibt erhalten." });
|
|
} catch (error) {
|
|
await showToast({ style: Toast.Style.Failure, title: "Ausnahmen konnten nicht gespeichert werden",
|
|
message: (error as Error).message });
|
|
}
|
|
return;
|
|
}
|
|
if (inPlace && !(await confirmAlert({
|
|
title: "Originaldateien ersetzen?",
|
|
message: "Die gewählten Dateien und gegebenenfalls ihre Namen werden im Projektordner ersetzt. Die Originale werden vorher außerhalb des Projekts gesichert. PDF-Dateien werden als DOCX ausgegeben.",
|
|
primaryAction: { title: "Originaldateien ersetzen", style: Alert.ActionStyle.Destructive },
|
|
}))) return;
|
|
setBusy(true);
|
|
const toast = await showToast({ style: Toast.Style.Animated,
|
|
title: inPlace ? "Originaldateien werden ersetzt" : "Pseudonymisierte Kopien werden erstellt" });
|
|
let directory: string | undefined;
|
|
let persisted = false;
|
|
try {
|
|
const created = await createRun(props.root);
|
|
directory = created.directory;
|
|
const { run } = created;
|
|
const acceptedHits = groups.filter((group) => selectedGroups.has(group.key))
|
|
.flatMap((group) => group.hits);
|
|
const selectedPeople = new Set(people.map((person) => person.placeholder));
|
|
const availableTargets = new Set([...selectedPeople, ...earlierPeople.map((person) => person.placeholder)]);
|
|
const activeMerges = Object.fromEntries(Object.entries(personMerges).filter(([source, target]) =>
|
|
selectedPeople.has(source) && availableTargets.has(canonicalPerson(target, personMerges))));
|
|
run.mapping = mappingForSelection(props.preview.mapping, props.preview.previousMapping,
|
|
acceptedHits, activeMerges);
|
|
run.commentAuthorCoverage = props.preview.entityTypes.includes("PERSON");
|
|
run.matchModes = props.preview.matchModes;
|
|
run.sourceInPlace = props.preview.sourceInPlace || inPlace;
|
|
const usedPaths = new Set<string>();
|
|
const replacements: RunFile[] = [];
|
|
for (const retained of props.preview.retained) {
|
|
const data = await fs.readFile(retained.file.absPath);
|
|
if (digest(data) !== retained.hash) throw new Error(`Datei wurde seit der Vorschau geändert: ${retained.file.relPath}`);
|
|
const relPath = outputPath(retained.file.relPath, props.preview.paths, accepted,
|
|
retained.file.kind === "document", activeMerges);
|
|
if (usedPaths.has(relPath)) throw new Error(`Zwei Dateien hätten denselben Ausgabenamen: ${relPath}`);
|
|
usedPaths.add(relPath);
|
|
const target = resolveWithin(join(directory, "files"), relPath);
|
|
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
await fs.writeFile(target, data, { mode: 0o600 });
|
|
const file: RunFile = { source: retained.file.relPath, output: relPath, kind: retained.file.kind };
|
|
run.files.push(file);
|
|
if (file.source !== file.output) replacements.push(file);
|
|
}
|
|
for (const draft of props.preview.drafts) {
|
|
const data = await fs.readFile(draft.file.absPath);
|
|
if (digest(data) !== draft.hash) throw new Error(`Datei wurde seit der Vorschau geändert: ${draft.file.relPath}`);
|
|
const relPath = outputPath(draft.file.relPath, props.preview.paths, accepted,
|
|
draft.file.kind === "document", activeMerges);
|
|
if (usedPaths.has(relPath)) throw new Error(`Zwei Dateien hätten denselben Ausgabenamen: ${relPath}`);
|
|
usedPaths.add(relPath);
|
|
const target = resolveWithin(join(directory, "files"), relPath);
|
|
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
let output: Buffer;
|
|
if (draft.file.kind === "document") {
|
|
const docIds = draft.hits.filter((hit) => accepted.has(hit.id)).map((hit) => hit.id.split(":document:")[1]);
|
|
const result = await pseudonymizeDocument(
|
|
basename(draft.file.relPath), data, run.mapping,
|
|
forceMasks(withoutWhitelistedRules(props.rules, props.whitelist)), docIds, props.preview.entityTypes,
|
|
);
|
|
output = Buffer.from(result.file_base64, "base64");
|
|
} else {
|
|
output = Buffer.from(replaceSpans(textFromBytes(data), withPersonMerges(draft.hits, activeMerges), accepted), "utf8");
|
|
}
|
|
await fs.writeFile(target, output, { mode: 0o600 });
|
|
const file: RunFile = { source: draft.file.relPath, output: relPath, kind: draft.file.kind };
|
|
run.files.push(file);
|
|
replacements.push(file);
|
|
}
|
|
await saveRun(directory, run);
|
|
persisted = true;
|
|
if (inPlace) {
|
|
await replaceProjectFilesInPlace(props.root, directory, replacements);
|
|
run.inPlace = true;
|
|
await saveRun(directory, run);
|
|
}
|
|
const selectedTerms = new Set(groups.filter((group) => selectedGroups.has(group.key)).map(whitelistKey));
|
|
const rejected = groups.filter((group) => !selectedGroups.has(group.key)
|
|
&& !selectedTerms.has(whitelistKey(group)))
|
|
.map((group) => ({ type: group.type, original: group.original }));
|
|
if (rejected.length) {
|
|
const nextWhitelist = [...await loadProjectWhitelist(props.root), ...rejected];
|
|
await saveProjectWhitelist(props.root, nextWhitelist);
|
|
props.onWhitelistChanged(await loadProjectWhitelist(props.root));
|
|
}
|
|
toast.style = Toast.Style.Success;
|
|
toast.title = inPlace ? `${replacements.length} Dateien ersetzt` : `${run.files.length} Kopien erstellt`;
|
|
await showInFinder(inPlace ? props.root : join(directory, "files")).catch(() => undefined);
|
|
} catch (error) {
|
|
if (directory && !persisted) await fs.rm(directory, { recursive: true, force: true });
|
|
toast.style = Toast.Style.Failure;
|
|
toast.title = "Pseudonymisierung fehlgeschlagen";
|
|
toast.message = `${(error as Error).message}${persisted && directory ? ` Kopien/Sicherung: ${directory}` : ""}`;
|
|
} finally { setBusy(false); }
|
|
}
|
|
|
|
if (stage === "merge") return <List isLoading={busy} navigationTitle="Personen zusammenfassen"
|
|
searchBarPlaceholder="Person oder Platzhalter suchen">
|
|
<List.Section title="Abschluss">
|
|
<List.Item icon={Icon.Play} title="Pseudonymisierung starten"
|
|
subtitle={`${selectedGroups.size} Zuordnungen · ${people.length} Personen · ${props.preview.retained.length} unveränderte Dateien übernommen`}
|
|
actions={<ActionPanel>
|
|
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply()} />
|
|
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply(true)} />
|
|
<Action title="Zur Trefferauswahl" icon={Icon.ArrowLeft} onAction={() => setStage("selection")} />
|
|
</ActionPanel>} />
|
|
</List.Section>
|
|
<List.Section title="Ausgewählte Personen · optional zusammenfassen">
|
|
{people.map((person) => {
|
|
const canonical = canonicalPerson(person.placeholder, personMerges);
|
|
const targets = [...people, ...earlierPeople]
|
|
.filter((other) => canonicalPerson(other.placeholder, personMerges) !== canonical);
|
|
return <List.Item key={person.placeholder} icon={Icon.Person}
|
|
title={person.originals.join(" · ")}
|
|
subtitle={`→ ${canonical}`}
|
|
accessories={canonical !== person.placeholder ? [{ text: "Zusammengeführt" }] : []}
|
|
actions={<ActionPanel>
|
|
{targets.length > 0 && <ActionPanel.Submenu title="Mit Person zusammenfassen" icon={Icon.Link}>
|
|
{targets.map((target) => <Action key={target.placeholder}
|
|
title={`${target.originals.join(" · ")} → ${canonicalPerson(target.placeholder, personMerges)}`}
|
|
onAction={() => setPersonMerges((current) => mergePeople(current,
|
|
person.placeholder, target.placeholder))} />)}
|
|
</ActionPanel.Submenu>}
|
|
{canonical !== person.placeholder && <Action title="Zusammenführung lösen"
|
|
onAction={() => setPersonMerges((current) => {
|
|
const next = { ...current }; delete next[person.placeholder]; return next;
|
|
})} />}
|
|
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply()} />
|
|
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply(true)} />
|
|
</ActionPanel>} />;
|
|
})}
|
|
</List.Section>
|
|
</List>;
|
|
|
|
return <List isLoading={busy} navigationTitle="Zuordnungen prüfen"
|
|
searchBarPlaceholder="Begriff oder Platzhalter suchen">
|
|
<List.Section title="Abschluss">
|
|
<List.Item key="apply" icon={Icon.Play}
|
|
title={`${selectedGroups.size} von ${groups.length} Zuordnungen ausgewählt`}
|
|
subtitle={`${selectedOccurrences} Vorkommen · ${props.preview.drafts.length} neue/geänderte Dateien · ${props.preview.retained.length} unverändert · ${props.preview.skipped} übersprungen`}
|
|
actions={<ActionPanel>
|
|
<Action title="Auswahl abschließen und Personen prüfen" icon={Icon.ArrowRight}
|
|
onAction={() => setStage("merge")} />
|
|
</ActionPanel>} />
|
|
</List.Section>
|
|
{[...byType].map(([type, entries]) => <List.Section key={type}
|
|
title={`${labelForType(type)} · ${entries.filter((entry) => selectedGroups.has(entry.key)).length}/${entries.length}`}>
|
|
{entries.map((group) => <List.Item key={group.key}
|
|
icon={{ source: selectedGroups.has(group.key) ? Icon.CheckCircle : Icon.Circle,
|
|
tintColor: selectedGroups.has(group.key) ? Color.Green : Color.SecondaryText }}
|
|
title={group.original} subtitle={`→ ${group.placeholder}`}
|
|
accessories={[{ text: `${group.hits.length} Vorkommen` }]}
|
|
keywords={[type, labelForType(type)]}
|
|
actions={<ActionPanel>
|
|
<Action title={selectedGroups.has(group.key) ? "Zuordnung abwählen" : "Zuordnung auswählen"}
|
|
onAction={() => toggle(group.key)} />
|
|
<Action title={`${labelForType(type)} komplett abwählen`}
|
|
onAction={() => setSelectedGroups((current) => {
|
|
const next = new Set(current);
|
|
for (const entry of entries) next.delete(entry.key);
|
|
return next;
|
|
})} />
|
|
<Action title={`${labelForType(type)} komplett auswählen`}
|
|
onAction={() => setSelectedGroups((current) => new Set([...current, ...entries.map((entry) => entry.key)]))} />
|
|
<Action.Push title="Vorkommen ansehen" icon={Icon.Eye}
|
|
target={<OccurrenceReview original={group.original} placeholder={group.placeholder} hits={group.hits} />} />
|
|
<Action title="Auswahl abschließen und Personen prüfen" icon={Icon.ArrowRight}
|
|
onAction={() => setStage("merge")} />
|
|
</ActionPanel>} />)}
|
|
</List.Section>)}
|
|
</List>;
|
|
}
|
|
|
|
function ProjectWhitelist(props: { entries: ProjectWhitelistEntry[]; onRemove: (entry: ProjectWhitelistEntry) => Promise<void> }) {
|
|
return <List navigationTitle="Projekt-Whitelist" searchBarPlaceholder="Ausnahme suchen">
|
|
<List.Section title={`${props.entries.length} nicht mehr vorgeschlagene Begriffe`}>
|
|
{props.entries.map((entry) => <List.Item key={whitelistKey(entry)} title={entry.original}
|
|
subtitle={labelForType(entry.type)} actions={<ActionPanel>
|
|
<Action title="Aus Projekt-Whitelist entfernen" icon={Icon.Trash}
|
|
onAction={() => props.onRemove(entry)} />
|
|
</ActionPanel>} />)}
|
|
</List.Section>
|
|
</List>;
|
|
}
|
|
|
|
export default function Command() {
|
|
const { push } = useNavigation();
|
|
const [root, setRoot] = useState("");
|
|
const [rules, setRules] = useState("");
|
|
const [availableTypes, setAvailableTypes] = useState<EntityType[]>([]);
|
|
const [selectedTypes, setSelectedTypes] = useState<EntityType[]>([]);
|
|
const [whitelist, setWhitelist] = useState<ProjectWhitelistEntry[]>([]);
|
|
const [existingRuns, setExistingRuns] = useState<number | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
useEffect(() => { finderFolder().then((path) => { if (path) setRoot((current) => current || path); }); }, []);
|
|
useEffect(() => {
|
|
getEntityTypes().then((types) => {
|
|
setAvailableTypes(types);
|
|
setSelectedTypes(types.filter((type) => type !== "ORT"));
|
|
}).catch((error: Error) => showToast({ style: Toast.Style.Failure,
|
|
title: "Kategorien nicht verfügbar", message: error.message }));
|
|
}, []);
|
|
useEffect(() => {
|
|
if (!root) return;
|
|
let active = true;
|
|
loadProjectRules(root).then((found) => {
|
|
if (active) setRules(found.map((rule) => `${rule.type}: ${rule.value}`).join("\n"));
|
|
}).catch(() => { if (active) setRules(""); });
|
|
return () => { active = false; };
|
|
}, [root]);
|
|
useEffect(() => {
|
|
if (!root) { setWhitelist([]); return; }
|
|
let active = true;
|
|
loadProjectWhitelist(root).then((found) => { if (active) setWhitelist(found); })
|
|
.catch((error: Error) => showToast({ style: Toast.Style.Failure,
|
|
title: "Projekt-Whitelist nicht lesbar", message: error.message }));
|
|
return () => { active = false; };
|
|
}, [root]);
|
|
useEffect(() => {
|
|
if (!root) { setExistingRuns(null); return; }
|
|
setExistingRuns(null);
|
|
let active = true;
|
|
listRuns(root).then((found) => { if (active) setExistingRuns(found.length); })
|
|
.catch((error: Error) => showToast({ style: Toast.Style.Failure,
|
|
title: "Projektstatus nicht lesbar", message: error.message }));
|
|
return () => { active = false; };
|
|
}, [root]);
|
|
async function removeFromWhitelist(entry: ProjectWhitelistEntry) {
|
|
try {
|
|
const next = (await loadProjectWhitelist(root)).filter((item) => whitelistKey(item) !== whitelistKey(entry));
|
|
await saveProjectWhitelist(root, next);
|
|
setWhitelist(next);
|
|
await showToast({ style: Toast.Style.Success, title: "Begriff wird wieder vorgeschlagen" });
|
|
} catch (error) {
|
|
await showToast({ style: Toast.Style.Failure, title: "Entfernen fehlgeschlagen",
|
|
message: (error as Error).message });
|
|
}
|
|
}
|
|
async function next() {
|
|
setBusy(true);
|
|
const toast = await showToast({ style: Toast.Style.Animated, title: "Projekt wird vorbereitet" });
|
|
try {
|
|
if (!(await fs.stat(root)).isDirectory()) throw new Error("Bitte einen Projektordner wählen.");
|
|
if (!selectedTypes.length) throw new Error("Bitte mindestens eine Kategorie wählen.");
|
|
const parsed = parseProjectRules(rules);
|
|
await saveProjectRules(root, parsed);
|
|
toast.title = "Dateien werden erfasst";
|
|
const scan = await scanProjectFiles(root);
|
|
if (!scan.files.length) throw new Error("Keine geeigneten Dateien im Projektordner gefunden.");
|
|
const currentWhitelist = await loadProjectWhitelist(root);
|
|
const preview = await prepare(root, scan.files, parsed, currentWhitelist, selectedTypes, scan.skipped.length,
|
|
(message) => { toast.title = message; });
|
|
toast.style = Toast.Style.Success;
|
|
toast.title = `${groupReviewHits(preview.hits).length} Zuordnungen zur Prüfung`;
|
|
push(<HitReview root={root} rules={parsed} whitelist={currentWhitelist} preview={preview}
|
|
onWhitelistChanged={setWhitelist} />);
|
|
} catch (error) {
|
|
toast.style = Toast.Style.Failure;
|
|
toast.title = "Analyse fehlgeschlagen";
|
|
toast.message = (error as Error).message;
|
|
} finally { setBusy(false); }
|
|
}
|
|
async function useFinderFolder() {
|
|
const path = await finderFolder();
|
|
if (path) setRoot(path);
|
|
else await showToast({ style: Toast.Style.Failure, title: "Kein Finder-Ordner gefunden",
|
|
message: "Bitte den Ordner im Finder öffnen oder unten manuell auswählen." });
|
|
}
|
|
return <Form isLoading={busy} actions={<ActionPanel>
|
|
<Action title={existingRuns ? "Bestehendes Projekt erneut prüfen" : "Zuordnungen prüfen"} onAction={next} />
|
|
<Action title="Geöffneten Finder-Ordner übernehmen" icon={Icon.Folder} onAction={useFinderFolder} />
|
|
<Action.Push title="Projekt-Whitelist bearbeiten" icon={Icon.List}
|
|
target={<ProjectWhitelist entries={whitelist} onRemove={removeFromWhitelist} />} />
|
|
</ActionPanel>}>
|
|
<Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false}
|
|
canChooseDirectories canChooseFiles={false} value={root ? [root] : []}
|
|
onChange={(paths) => setRoot(paths[0] ?? "")} />
|
|
<Form.Description title="Projektstatus" text={existingRuns === null ? "Projektordner auswählen."
|
|
: existingRuns ? `Bereits angelegt · ${existingRuns} gespeicherte Stände. Ein erneuter Lauf aktualisiert dieses Projekt; es wird kein zweites angelegt.`
|
|
: "Neues Projekt. Der erste Stand wird nach der Prüfung angelegt."} />
|
|
<Form.TextArea id="projectRules" title="Begriffe nur für dieses Projekt" value={rules}
|
|
onChange={setRules} placeholder={"BEGRIFF: KundenSuite\nPERSON: Max Mustermann"}
|
|
info="Ein Begriff pro Zeile. Ohne Typ wird BEGRIFF verwendet. Diese Regeln werden nur für dieses Projekt gespeichert und bei der Velum-Anfrage mitgesendet." />
|
|
<Form.TagPicker id="entityTypes" title="Kategorien pseudonymisieren" value={selectedTypes}
|
|
onChange={setSelectedTypes} info="Orte sind wegen häufiger Fehlalarme zunächst abgewählt. Abgewählte Treffer kommen nach dem Erstellen in die Projekt-Whitelist.">
|
|
{availableTypes.map((type) => <Form.TagPicker.Item key={type} value={type} title={labelForType(type)} />)}
|
|
</Form.TagPicker>
|
|
<Form.Description title="Projekt-Whitelist" text={`${whitelist.length} Begriffe werden bei diesem Ordner nicht mehr vorgeschlagen. Über die Aktionen kannst du sie wieder freigeben.`} />
|
|
<Form.Description title="Ablauf" text="Kategorien wählen, Zuordnungen auswählen, Personen bei Bedarf zusammenfassen und dann Kopien erstellen oder Originale ersetzen." />
|
|
</Form>;
|
|
}
|