|
|
|
@@ -3,17 +3,19 @@ import { promises as fs } from "node:fs";
|
|
|
|
import { basename, dirname, extname, join } from "node:path";
|
|
|
|
import { basename, dirname, extname, join } from "node:path";
|
|
|
|
import { execFile } from "node:child_process";
|
|
|
|
import { execFile } from "node:child_process";
|
|
|
|
import { promisify } from "node:util";
|
|
|
|
import { promisify } from "node:util";
|
|
|
|
import { useEffect, useState } from "react";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
|
|
import type { PlaceholderMapping } from "./types";
|
|
|
|
import type { EntityType, PlaceholderMapping } from "./types";
|
|
|
|
import { pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "./velum";
|
|
|
|
import { getEntityTypes, pseudonymize, pseudonymizeDocument, requireProjectCapabilities } from "./velum";
|
|
|
|
import type { DocumentHit } from "./velum";
|
|
|
|
import type { DocumentHit } from "./velum";
|
|
|
|
import {
|
|
|
|
import {
|
|
|
|
createRun, digest, forceMasks, listRuns, loadProjectRules, parseProjectRules, replaceSpans,
|
|
|
|
createRun, digest, forceMasks, listRuns, loadProjectRules, parseProjectRules, replaceSpans,
|
|
|
|
replaceProjectFilesInPlace, resolveWithin, safeRelativePath, saveProjectRules, saveRun, scanProjectFiles, toUtf16Offsets,
|
|
|
|
replaceProjectFilesInPlace, resolveWithin, safeRelativePath, saveProjectRules, saveRun, scanProjectFiles, toUtf16Offsets,
|
|
|
|
} from "./lib/velum-project";
|
|
|
|
} from "./lib/velum-project";
|
|
|
|
import type { ProjectFile, ProjectRule } from "./lib/velum-project";
|
|
|
|
import type { ProjectFile, ProjectRule } from "./lib/velum-project";
|
|
|
|
|
|
|
|
import { groupReviewHits, makeTextBatches, spansBySegment } from "./lib/project-review";
|
|
|
|
|
|
|
|
|
|
|
|
const run = promisify(execFile);
|
|
|
|
const run = promisify(execFile);
|
|
|
|
|
|
|
|
const MAX_REVIEW_HITS = 50000;
|
|
|
|
type Hit = {
|
|
|
|
type Hit = {
|
|
|
|
id: string; file: string; source: string; original: string; placeholder: string;
|
|
|
|
id: string; file: string; source: string; original: string; placeholder: string;
|
|
|
|
type: string; score: number; context: string; start: number; end: number;
|
|
|
|
type: string; score: number; context: string; start: number; end: number;
|
|
|
|
@@ -22,12 +24,14 @@ type Hit = {
|
|
|
|
type Draft = { file: ProjectFile; hash: string; hits: Hit[] };
|
|
|
|
type Draft = { file: ProjectFile; hash: string; hits: Hit[] };
|
|
|
|
type PathDraft = { key: string; value: string; hits: Hit[] };
|
|
|
|
type PathDraft = { key: string; value: string; hits: Hit[] };
|
|
|
|
type Preview = { drafts: Draft[]; paths: PathDraft[]; mapping: PlaceholderMapping;
|
|
|
|
type Preview = { drafts: Draft[]; paths: PathDraft[]; mapping: PlaceholderMapping;
|
|
|
|
matchModes?: Record<string, "token" | "anywhere">; hits: Hit[] };
|
|
|
|
matchModes?: Record<string, "token" | "anywhere">; hits: Hit[]; entityTypes: EntityType[]; skipped: number };
|
|
|
|
|
|
|
|
|
|
|
|
function defaultAccepted(hit: Hit, rules: ProjectRule[]): boolean {
|
|
|
|
const typeLabels: Record<string, string> = {
|
|
|
|
if (rules.some((rule) => rule.type === hit.type && rule.value.toLocaleLowerCase() === hit.original.toLocaleLowerCase())) return true;
|
|
|
|
PERSON: "Personen", ORT: "Orte", ORG: "Organisationen", FIRMA: "Firmen",
|
|
|
|
return !["PERSON", "ORT", "ORG"].includes(hit.type);
|
|
|
|
KUNDE: "Kunden", ADRESSE: "Adressen", BEGRIFF: "Eigene Begriffe",
|
|
|
|
}
|
|
|
|
EMAIL: "E-Mail-Adressen", TELEFON: "Telefonnummern", IBAN: "IBAN",
|
|
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
const labelForType = (type: string) => typeLabels[type] ?? type;
|
|
|
|
|
|
|
|
|
|
|
|
async function finderFolder(): Promise<string | undefined> {
|
|
|
|
async function finderFolder(): Promise<string | undefined> {
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
@@ -81,7 +85,8 @@ function pathSegments(files: ProjectFile[]): PathDraft[] {
|
|
|
|
return [...map.values()];
|
|
|
|
return [...map.values()];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[]): Promise<Preview> {
|
|
|
|
async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[], entityTypes: EntityType[],
|
|
|
|
|
|
|
|
skipped: number, onProgress: (message: string) => void): Promise<Preview> {
|
|
|
|
await requireProjectCapabilities();
|
|
|
|
await requireProjectCapabilities();
|
|
|
|
const masks = forceMasks(rules);
|
|
|
|
const masks = forceMasks(rules);
|
|
|
|
const latest = (await listRuns(root))[0]?.run;
|
|
|
|
const latest = (await listRuns(root))[0]?.run;
|
|
|
|
@@ -90,37 +95,60 @@ async function prepare(root: string, files: ProjectFile[], rules: ProjectRule[])
|
|
|
|
}
|
|
|
|
}
|
|
|
|
let mapping: PlaceholderMapping = latest?.mapping ?? {};
|
|
|
|
let mapping: PlaceholderMapping = latest?.mapping ?? {};
|
|
|
|
const drafts: Draft[] = [];
|
|
|
|
const drafts: Draft[] = [];
|
|
|
|
|
|
|
|
const draftsByPath = new Map<string, Draft>();
|
|
|
|
const hits: Hit[] = [];
|
|
|
|
const hits: Hit[] = [];
|
|
|
|
|
|
|
|
const texts = new Map<string, string>();
|
|
|
|
for (const file of files) {
|
|
|
|
for (const file of files) {
|
|
|
|
const data = await fs.readFile(file.absPath);
|
|
|
|
const data = await fs.readFile(file.absPath);
|
|
|
|
let found: Hit[];
|
|
|
|
const draft: Draft = { file, hash: digest(data), hits: [] };
|
|
|
|
if (file.kind === "document") {
|
|
|
|
drafts.push(draft);
|
|
|
|
const result = await pseudonymizeDocument(basename(file.relPath), data, mapping, masks);
|
|
|
|
draftsByPath.set(file.relPath, draft);
|
|
|
|
mapping = result.mapping;
|
|
|
|
if (file.kind === "text") texts.set(file.relPath, textFromBytes(data));
|
|
|
|
found = documentHits(file, result.hits);
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const addHits = (draft: Draft, found: Hit[]) => {
|
|
|
|
const original = textFromBytes(data);
|
|
|
|
draft.hits = found;
|
|
|
|
const result = await pseudonymize(original, mapping, undefined, masks);
|
|
|
|
|
|
|
|
mapping = result.mapping;
|
|
|
|
|
|
|
|
found = contentHits(file, result.spans, original);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
drafts.push({ file, hash: digest(data), hits: found });
|
|
|
|
|
|
|
|
hits.push(...found);
|
|
|
|
hits.push(...found);
|
|
|
|
if (hits.length > 5000) throw new Error("Mehr als 5.000 Treffer. Bitte weniger Dateien auswählen.");
|
|
|
|
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));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
const paths = pathSegments(files);
|
|
|
|
const paths = pathSegments(files);
|
|
|
|
for (const path of paths) {
|
|
|
|
const pathBatches = makeTextBatches(paths.map((path) => ({ key: path.key, text: path.value })));
|
|
|
|
const result = await pseudonymize(path.value, mapping, undefined, masks);
|
|
|
|
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;
|
|
|
|
mapping = result.mapping;
|
|
|
|
path.hits = toUtf16Offsets(path.value, result.spans).map((span, index) => ({
|
|
|
|
const spans = spansBySegment(batch, result.spans);
|
|
|
|
...span, id: `${path.key}:path:${index}`, file: path.key,
|
|
|
|
for (const segment of batch.segments) {
|
|
|
|
source: "Datei- oder Ordnername", kind: "path" as const,
|
|
|
|
const path = byPath.get(segment.key)!;
|
|
|
|
context: path.value,
|
|
|
|
path.hits = toUtf16Offsets(path.value, spans.get(path.key) ?? []).map((span, hitIndex) => ({
|
|
|
|
}));
|
|
|
|
...span, id: `${path.key}:path:${hitIndex}`, file: path.key,
|
|
|
|
hits.push(...path.hits);
|
|
|
|
source: "Datei- oder Ordnername", kind: "path" as const,
|
|
|
|
if (hits.length > 5000) throw new Error("Mehr als 5.000 Treffer. Bitte weniger Dateien auswählen.");
|
|
|
|
context: path.value,
|
|
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
hits.push(...path.hits);
|
|
|
|
|
|
|
|
if (hits.length > MAX_REVIEW_HITS) throw new Error("Zu viele Treffer. Bitte weniger Kategorien auswählen.");
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return { drafts, paths, mapping, matchModes: latest?.matchModes, hits };
|
|
|
|
return { drafts, paths, mapping, matchModes: latest?.matchModes, hits, entityTypes, skipped };
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function outputPath(relPath: string, paths: PathDraft[], accepted: Set<string>, document: boolean): string {
|
|
|
|
function outputPath(relPath: string, paths: PathDraft[], accepted: Set<string>, document: boolean): string {
|
|
|
|
@@ -137,79 +165,31 @@ function outputPath(relPath: string, paths: PathDraft[], accepted: Set<string>,
|
|
|
|
return safeRelativePath(output.join("/"));
|
|
|
|
return safeRelativePath(output.join("/"));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function FileSelection(props: { root: string; rules: ProjectRule[] }) {
|
|
|
|
function OccurrenceReview(props: { original: string; placeholder: string; hits: Hit[] }) {
|
|
|
|
const { push } = useNavigation();
|
|
|
|
return <List isShowingDetail navigationTitle={`${props.original} → ${props.placeholder}`}>
|
|
|
|
const [files, setFiles] = useState<ProjectFile[]>([]);
|
|
|
|
{props.hits.map((hit) => <List.Item key={hit.id} title={hit.source}
|
|
|
|
const [skipped, setSkipped] = useState<string[]>([]);
|
|
|
|
subtitle={hit.kind === "path" ? "Datei- oder Ordnername" : "Dateiinhalt"}
|
|
|
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
|
|
detail={<List.Item.Detail markdown={hit.context.replace(/</g, "<")} />} />)}
|
|
|
|
const [busy, setBusy] = useState(true);
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
scanProjectFiles(props.root).then((scan) => {
|
|
|
|
|
|
|
|
setFiles(scan.files);
|
|
|
|
|
|
|
|
setSkipped(scan.skipped);
|
|
|
|
|
|
|
|
setSelected(new Set(scan.files.map((file) => file.relPath)));
|
|
|
|
|
|
|
|
}).catch((error: Error) => showToast({ style: Toast.Style.Failure, title: "Projekt nicht lesbar", message: error.message }))
|
|
|
|
|
|
|
|
.finally(() => setBusy(false));
|
|
|
|
|
|
|
|
}, [props.root]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function toggle(path: string) {
|
|
|
|
|
|
|
|
setSelected((current) => {
|
|
|
|
|
|
|
|
const next = new Set(current);
|
|
|
|
|
|
|
|
if (next.has(path)) next.delete(path);
|
|
|
|
|
|
|
|
else next.add(path);
|
|
|
|
|
|
|
|
return next;
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function review() {
|
|
|
|
|
|
|
|
const chosen = files.filter((file) => selected.has(file.relPath));
|
|
|
|
|
|
|
|
if (!chosen.length) {
|
|
|
|
|
|
|
|
await showToast({ style: Toast.Style.Failure, title: "Keine Dateien ausgewählt" });
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
setBusy(true);
|
|
|
|
|
|
|
|
const toast = await showToast({ style: Toast.Style.Animated, title: "Treffer werden analysiert" });
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
|
|
const preview = await prepare(props.root, chosen, props.rules);
|
|
|
|
|
|
|
|
toast.style = Toast.Style.Success;
|
|
|
|
|
|
|
|
toast.title = `${preview.hits.length} Treffer zur Prüfung`;
|
|
|
|
|
|
|
|
push(<HitReview root={props.root} rules={props.rules} preview={preview} />);
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
|
|
toast.style = Toast.Style.Failure;
|
|
|
|
|
|
|
|
toast.title = "Analyse fehlgeschlagen";
|
|
|
|
|
|
|
|
toast.message = (error as Error).message;
|
|
|
|
|
|
|
|
} finally { setBusy(false); }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return <List isLoading={busy} navigationTitle="Dateien auswählen" searchBarPlaceholder="Dateien filtern">
|
|
|
|
|
|
|
|
<List.EmptyView title="Keine geeigneten Dateien" description="Bitte einen anderen Projektordner wählen oder die ausgelassenen Dateien prüfen." />
|
|
|
|
|
|
|
|
<List.Section title={`${selected.size} von ${files.length} Dateien ausgewählt · ${skipped.length} übersprungen`}>
|
|
|
|
|
|
|
|
{files.map((file) => <List.Item key={file.relPath}
|
|
|
|
|
|
|
|
icon={selected.has(file.relPath) ? Icon.CheckCircle : Icon.Circle}
|
|
|
|
|
|
|
|
title={file.relPath} subtitle={file.kind === "document" ? "Office/PDF" : "Text"}
|
|
|
|
|
|
|
|
actions={<ActionPanel>
|
|
|
|
|
|
|
|
<Action title="Treffer prüfen" icon={Icon.Eye} onAction={review} />
|
|
|
|
|
|
|
|
<Action title={selected.has(file.relPath) ? "Datei abwählen" : "Datei auswählen"} onAction={() => toggle(file.relPath)} />
|
|
|
|
|
|
|
|
<Action title="Alle auswählen" onAction={() => setSelected(new Set(files.map((item) => item.relPath)))} />
|
|
|
|
|
|
|
|
<Action title="Keine auswählen" onAction={() => setSelected(new Set())} />
|
|
|
|
|
|
|
|
</ActionPanel>} />)}
|
|
|
|
|
|
|
|
</List.Section>
|
|
|
|
|
|
|
|
{skipped.length > 0 && <List.Section title="Übersprungen (binär, zu groß oder ausgeschlossen)">
|
|
|
|
|
|
|
|
{skipped.slice(0, 100).map((path) => <List.Item key={path} icon={Icon.XMarkCircle} title={path} />)}
|
|
|
|
|
|
|
|
</List.Section>}
|
|
|
|
|
|
|
|
</List>;
|
|
|
|
</List>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview }) {
|
|
|
|
function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview }) {
|
|
|
|
const [accepted, setAccepted] = useState<Set<string>>(
|
|
|
|
const groups = useMemo(() => groupReviewHits(props.preview.hits), [props.preview.hits]);
|
|
|
|
new Set(props.preview.hits.filter((hit) => defaultAccepted(hit, props.rules)).map((hit) => hit.id)),
|
|
|
|
const [selectedGroups, setSelectedGroups] = useState<Set<string>>(() => new Set(groups.map((group) => group.key)));
|
|
|
|
);
|
|
|
|
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
function toggle(id: string) {
|
|
|
|
const accepted = useMemo(() => new Set(groups.filter((group) => selectedGroups.has(group.key))
|
|
|
|
setAccepted((current) => {
|
|
|
|
.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]);
|
|
|
|
|
|
|
|
function toggle(key: string) {
|
|
|
|
|
|
|
|
setSelectedGroups((current) => {
|
|
|
|
const next = new Set(current);
|
|
|
|
const next = new Set(current);
|
|
|
|
if (next.has(id)) next.delete(id);
|
|
|
|
if (next.has(key)) next.delete(key);
|
|
|
|
else next.add(id);
|
|
|
|
else next.add(key);
|
|
|
|
return next;
|
|
|
|
return next;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -243,7 +223,7 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
|
|
|
|
if (draft.file.kind === "document") {
|
|
|
|
if (draft.file.kind === "document") {
|
|
|
|
const docIds = draft.hits.filter((hit) => accepted.has(hit.id)).map((hit) => hit.id.split(":document:")[1]);
|
|
|
|
const docIds = draft.hits.filter((hit) => accepted.has(hit.id)).map((hit) => hit.id.split(":document:")[1]);
|
|
|
|
const result = await pseudonymizeDocument(
|
|
|
|
const result = await pseudonymizeDocument(
|
|
|
|
basename(draft.file.relPath), data, run.mapping, forceMasks(props.rules), docIds,
|
|
|
|
basename(draft.file.relPath), data, run.mapping, forceMasks(props.rules), docIds, props.preview.entityTypes,
|
|
|
|
);
|
|
|
|
);
|
|
|
|
output = Buffer.from(result.file_base64, "base64");
|
|
|
|
output = Buffer.from(result.file_base64, "base64");
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
@@ -270,28 +250,40 @@ function HitReview(props: { root: string; rules: ProjectRule[]; preview: Preview
|
|
|
|
} finally { setBusy(false); }
|
|
|
|
} finally { setBusy(false); }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const groups = new Map<string, Hit[]>();
|
|
|
|
return <List isLoading={busy} navigationTitle="Zuordnungen prüfen"
|
|
|
|
for (const hit of props.preview.hits) (groups.get(hit.file) ?? (groups.set(hit.file, []), groups.get(hit.file)!)).push(hit);
|
|
|
|
searchBarPlaceholder="Begriff oder Platzhalter suchen">
|
|
|
|
return <List isLoading={busy} isShowingDetail navigationTitle="Treffer prüfen"
|
|
|
|
<List.Section title="Abschluss">
|
|
|
|
searchBarPlaceholder="Treffer, Typ oder Datei suchen">
|
|
|
|
<List.Item key="apply" icon={Icon.Play}
|
|
|
|
<List.EmptyView title="Keine Treffer" description="Die gewählten Dateien können unverändert kopiert werden."
|
|
|
|
title={`${selectedGroups.size} von ${groups.length} Zuordnungen ausgewählt`}
|
|
|
|
actions={<ActionPanel>
|
|
|
|
subtitle={`${selectedOccurrences} Vorkommen · ${props.preview.drafts.length} Dateien · ${props.preview.skipped} übersprungen`}
|
|
|
|
<Action title="Pseudonymisierte Kopien erstellen" onAction={() => apply()} />
|
|
|
|
|
|
|
|
<Action title="Originaldateien ersetzen" onAction={() => apply(true)} />
|
|
|
|
|
|
|
|
</ActionPanel>} />
|
|
|
|
|
|
|
|
{[...groups].map(([file, hits]) => <List.Section key={file} title={file}>
|
|
|
|
|
|
|
|
{hits.map((hit) => <List.Item key={hit.id}
|
|
|
|
|
|
|
|
icon={{ source: accepted.has(hit.id) ? Icon.CheckCircle : Icon.Circle, tintColor: accepted.has(hit.id) ? Color.Green : Color.SecondaryText }}
|
|
|
|
|
|
|
|
title={`${hit.original} → ${hit.placeholder}`}
|
|
|
|
|
|
|
|
subtitle={`${hit.type} · ${hit.source}`}
|
|
|
|
|
|
|
|
detail={<List.Item.Detail markdown={`**${hit.type}** · ${hit.source}\n\n${hit.context.replace(/</g, "<")}`} />}
|
|
|
|
|
|
|
|
actions={<ActionPanel>
|
|
|
|
actions={<ActionPanel>
|
|
|
|
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply()} />
|
|
|
|
<Action title="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply()} />
|
|
|
|
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply(true)} />
|
|
|
|
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply(true)} />
|
|
|
|
<Action title={accepted.has(hit.id) ? "Treffer ausnehmen" : "Treffer übernehmen"} onAction={() => toggle(hit.id)} />
|
|
|
|
</ActionPanel>} />
|
|
|
|
<Action title="Gleichen Begriff übernehmen" onAction={() => setAccepted((current) => new Set([
|
|
|
|
</List.Section>
|
|
|
|
...current, ...props.preview.hits.filter((other) => other.type === hit.type && other.original === hit.original).map((other) => other.id),
|
|
|
|
{[...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="Pseudonymisierte Kopien erstellen" icon={Icon.Document} onAction={() => apply()} />
|
|
|
|
|
|
|
|
<Action title="Originaldateien ersetzen" icon={Icon.Pencil} onAction={() => apply(true)} />
|
|
|
|
</ActionPanel>} />)}
|
|
|
|
</ActionPanel>} />)}
|
|
|
|
</List.Section>)}
|
|
|
|
</List.Section>)}
|
|
|
|
</List>;
|
|
|
|
</List>;
|
|
|
|
@@ -301,8 +293,17 @@ export default function Command() {
|
|
|
|
const { push } = useNavigation();
|
|
|
|
const { push } = useNavigation();
|
|
|
|
const [root, setRoot] = useState("");
|
|
|
|
const [root, setRoot] = useState("");
|
|
|
|
const [rules, setRules] = useState("");
|
|
|
|
const [rules, setRules] = useState("");
|
|
|
|
|
|
|
|
const [availableTypes, setAvailableTypes] = useState<EntityType[]>([]);
|
|
|
|
|
|
|
|
const [selectedTypes, setSelectedTypes] = useState<EntityType[]>([]);
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
useEffect(() => { finderFolder().then((path) => { if (path) setRoot((current) => current || path); }); }, []);
|
|
|
|
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(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
if (!root) return;
|
|
|
|
if (!root) return;
|
|
|
|
let active = true;
|
|
|
|
let active = true;
|
|
|
|
@@ -313,13 +314,24 @@ export default function Command() {
|
|
|
|
}, [root]);
|
|
|
|
}, [root]);
|
|
|
|
async function next() {
|
|
|
|
async function next() {
|
|
|
|
setBusy(true);
|
|
|
|
setBusy(true);
|
|
|
|
|
|
|
|
const toast = await showToast({ style: Toast.Style.Animated, title: "Projekt wird vorbereitet" });
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
if (!(await fs.stat(root)).isDirectory()) throw new Error("Bitte einen Projektordner wählen.");
|
|
|
|
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);
|
|
|
|
const parsed = parseProjectRules(rules);
|
|
|
|
await saveProjectRules(root, parsed);
|
|
|
|
await saveProjectRules(root, parsed);
|
|
|
|
push(<FileSelection root={root} rules={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 preview = await prepare(root, scan.files, parsed, 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} preview={preview} />);
|
|
|
|
} catch (error) {
|
|
|
|
} catch (error) {
|
|
|
|
await showToast({ style: Toast.Style.Failure, title: "Projekt nicht bereit", message: (error as Error).message });
|
|
|
|
toast.style = Toast.Style.Failure;
|
|
|
|
|
|
|
|
toast.title = "Analyse fehlgeschlagen";
|
|
|
|
|
|
|
|
toast.message = (error as Error).message;
|
|
|
|
} finally { setBusy(false); }
|
|
|
|
} finally { setBusy(false); }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
async function useFinderFolder() {
|
|
|
|
async function useFinderFolder() {
|
|
|
|
@@ -329,7 +341,7 @@ export default function Command() {
|
|
|
|
message: "Bitte den Ordner im Finder öffnen oder unten manuell auswählen." });
|
|
|
|
message: "Bitte den Ordner im Finder öffnen oder unten manuell auswählen." });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return <Form isLoading={busy} actions={<ActionPanel>
|
|
|
|
return <Form isLoading={busy} actions={<ActionPanel>
|
|
|
|
<Action title="Dateien auswählen" onAction={next} />
|
|
|
|
<Action title="Zuordnungen prüfen" onAction={next} />
|
|
|
|
<Action title="Geöffneten Finder-Ordner übernehmen" icon={Icon.Folder} onAction={useFinderFolder} />
|
|
|
|
<Action title="Geöffneten Finder-Ordner übernehmen" icon={Icon.Folder} onAction={useFinderFolder} />
|
|
|
|
</ActionPanel>}>
|
|
|
|
</ActionPanel>}>
|
|
|
|
<Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false}
|
|
|
|
<Form.FilePicker id="projectPath" title="Projektordner" allowMultipleSelection={false}
|
|
|
|
@@ -338,6 +350,10 @@ export default function Command() {
|
|
|
|
<Form.TextArea id="projectRules" title="Begriffe nur für dieses Projekt" value={rules}
|
|
|
|
<Form.TextArea id="projectRules" title="Begriffe nur für dieses Projekt" value={rules}
|
|
|
|
onChange={setRules} placeholder={"BEGRIFF: KundenSuite\nPERSON: Max Mustermann"}
|
|
|
|
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." />
|
|
|
|
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.Description title="Ablauf" text="Dateien und Treffer auswählen; erst danach werden Kopien außerhalb des Projektordners erstellt." />
|
|
|
|
<Form.TagPicker id="entityTypes" title="Kategorien pseudonymisieren" value={selectedTypes}
|
|
|
|
|
|
|
|
onChange={setSelectedTypes} info="Alle geeigneten Dateien werden analysiert. Orte sind wegen häufiger Fehlalarme zunächst abgewählt; alle gefundenen Zuordnungen können anschließend einzeln abgewählt werden.">
|
|
|
|
|
|
|
|
{availableTypes.map((type) => <Form.TagPicker.Item key={type} value={type} title={labelForType(type)} />)}
|
|
|
|
|
|
|
|
</Form.TagPicker>
|
|
|
|
|
|
|
|
<Form.Description title="Ablauf" text="Alle geeigneten Dateien erfassen, Zuordnungen je Kategorie prüfen und dann Kopien erstellen oder Originale ersetzen." />
|
|
|
|
</Form>;
|
|
|
|
</Form>;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|