Allow revising saved Velum project mappings
This commit is contained in:
@@ -8,11 +8,11 @@ Raycast-Extension für PII-sichere Text- und Projekt-Workflows mit Velum: Inhalt
|
|||||||
|
|
||||||
Projektbegriffe werden zeilenweise als `BEGRIFF: KundenSuite` oder `PERSON: Max Mustermann` eingegeben, nur für dieses Projekt gespeichert und mit `force_masks` pro API-Aufruf übertragen. Die globale Velum-Regelliste bleibt unverändert. Beim Erstellen eines Projektstands werden abgewählte Treffer als typgebundene Projekt-Whitelist außerhalb des Quellordners gespeichert und bei späteren Läufen nicht erneut angeboten. Die Whitelist lässt sich im Startformular bearbeiten. Eine neue Projektbearbeitung übernimmt die bestätigten Zuordnungen des letzten Projektstands, sodass Platzhalter konsistent bleiben. Nach einem Lauf mit Kopien bleibt der Klartext-Quellordner erhalten und kann nach Dateiänderungen erneut bearbeitet werden. Nach dem Ersetzen vor Ort werden unveränderte bereits maskierte Dateien in den nächsten Stand übernommen; neue oder geänderte Dateien werden analysiert. Ein neuer Projektstand enthält auch die übernommenen Dateien für die Rückübersetzung.
|
Projektbegriffe werden zeilenweise als `BEGRIFF: KundenSuite` oder `PERSON: Max Mustermann` eingegeben, nur für dieses Projekt gespeichert und mit `force_masks` pro API-Aufruf übertragen. Die globale Velum-Regelliste bleibt unverändert. Beim Erstellen eines Projektstands werden abgewählte Treffer als typgebundene Projekt-Whitelist außerhalb des Quellordners gespeichert und bei späteren Läufen nicht erneut angeboten. Die Whitelist lässt sich im Startformular bearbeiten. Eine neue Projektbearbeitung übernimmt die bestätigten Zuordnungen des letzten Projektstands, sodass Platzhalter konsistent bleiben. Nach einem Lauf mit Kopien bleibt der Klartext-Quellordner erhalten und kann nach Dateiänderungen erneut bearbeitet werden. Nach dem Ersetzen vor Ort werden unveränderte bereits maskierte Dateien in den nächsten Stand übernommen; neue oder geänderte Dateien werden analysiert. Ein neuer Projektstand enthält auch die übernommenen Dateien für die Rückübersetzung.
|
||||||
|
|
||||||
`Projektdateien rückübersetzen` wählt einen Projektstand und einzelne Dateien und erstellt Klartext-Kopien unter `~/Downloads/Velum`. Der Quellordner bleibt unverändert. Die neue Projektfunktion benötigt eine Velum-API mit `/api/project-capabilities`, Trefferprüfung für Dokumente, projektbezogenen `force_masks` und `/api/depseudonymize-document`.
|
`Projektdateien nachbearbeiten` bietet für den aktuellen Projektstand zwei Wege: Zuordnungen nachträglich in Klartext zurückwandeln und als Projekt-Ausnahme speichern sowie Personenplatzhalter nachträglich zusammenführen. Beide Änderungen betreffen Inhalte und Dateinamen; der vorige Stand bleibt als Sicherung erhalten. Bei zuvor direkt ersetzten Projektdateien wird der Ordner nach einer Bestätigung ebenfalls aktualisiert, sofern keine Datei seit dem letzten Stand verändert wurde. Für Projekte mit Kopien entsteht ein neuer Stand außerhalb des Quellordners. Ältere Stände können weiterhin dateiweise als Klartext-Kopien unter `~/Downloads/Velum` exportiert werden. Die Projektfunktion benötigt eine Velum-API mit `/api/project-capabilities`, Trefferprüfung für Dokumente, projektbezogenen `force_masks` und `/api/depseudonymize-document`.
|
||||||
|
|
||||||
### Einmalige Übernahme aus KI Datenschutz
|
### Einmalige Übernahme aus KI Datenschutz
|
||||||
|
|
||||||
Velum enthält **keinen** Laufzeit-Zugriff auf die frühere Extension. Das eigenständige Skript `tools/migrate-ki-datenschutz.mjs` wandelt einen bereits anonymisierten KI-Datenschutz-Projektordner samt seiner eingefrorenen Variante-Tabelle in einen Velum-Projektstand um. Es verändert weder den alten Ordner noch die alte Tabelle. Der neue Stand erscheint anschließend unter `Projektdateien rückübersetzen`; Begriffe nur für dieses Projekt werden als Velum-Projektregeln übernommen. Alle Inhalte bleiben während der Migration lokal und werden nicht an eine API gesendet. Im Terminal protokolliert das Skript nur Zahlen, keine Werte oder Dateinamen.
|
Velum enthält **keinen** Laufzeit-Zugriff auf die frühere Extension. Das eigenständige Skript `tools/migrate-ki-datenschutz.mjs` wandelt einen bereits anonymisierten KI-Datenschutz-Projektordner samt seiner eingefrorenen Variante-Tabelle in einen Velum-Projektstand um. Es verändert weder den alten Ordner noch die alte Tabelle. Der neue Stand erscheint anschließend unter `Projektdateien nachbearbeiten`; Begriffe nur für dieses Projekt werden als Velum-Projektregeln übernommen. Alle Inhalte bleiben während der Migration lokal und werden nicht an eine API gesendet. Im Terminal protokolliert das Skript nur Zahlen, keine Werte oder Dateinamen.
|
||||||
|
|
||||||
1. In Raycast `Migrationsziel anzeigen` öffnen und den Velum-Datenordner kopieren.
|
1. In Raycast `Migrationsziel anzeigen` öffnen und den Velum-Datenordner kopieren.
|
||||||
2. Den alten Projektordner und die zugehörige Datei `projects/<Projekt-ID>.json` aus dem Datenordner von KI Datenschutz angeben. Mit `--preview-file` eine prüfbare Vorschau erstellen:
|
2. Den alten Projektordner und die zugehörige Datei `projects/<Projekt-ID>.json` aus dem Datenordner von KI Datenschutz angeben. Mit `--preview-file` eine prüfbare Vorschau erstellen:
|
||||||
|
|||||||
@@ -104,8 +104,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "restore-project-files",
|
"name": "restore-project-files",
|
||||||
"title": "Projektdateien rückübersetzen",
|
"title": "Projektdateien nachbearbeiten",
|
||||||
"description": "Ausgewählte Projektdateien als Klartext-Kopien nach Downloads exportieren.",
|
"description": "Zuordnungen freigeben, Personen zusammenführen oder Projektdateien als Klartext exportieren.",
|
||||||
"mode": "view",
|
"mode": "view",
|
||||||
"icon": "extension-icon.png"
|
"icon": "extension-icon.png"
|
||||||
},
|
},
|
||||||
|
|||||||
180
src/lib/project-revision.ts
Normal file
180
src/lib/project-revision.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import { basename, dirname, join } from "node:path";
|
||||||
|
import type { PlaceholderMapping } from "../types";
|
||||||
|
import { canonicalPerson } from "./project-review";
|
||||||
|
import {
|
||||||
|
createRun, digest, isRestorableDocument, listRuns, loadProjectWhitelist,
|
||||||
|
replaceProjectFilesInPlace, resolveWithin, restorePath, restoreText,
|
||||||
|
saveProjectWhitelist, saveRun,
|
||||||
|
} from "./velum-project";
|
||||||
|
import type { ProjectRun, ProjectWhitelistEntry } from "./velum-project";
|
||||||
|
|
||||||
|
export type RevisionPlan = {
|
||||||
|
mapping: PlaceholderMapping;
|
||||||
|
replacements: PlaceholderMapping;
|
||||||
|
whitelist: ProjectWhitelistEntry[];
|
||||||
|
released: string[];
|
||||||
|
merged: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function existingRoot(key: string, mapping: PlaceholderMapping): string {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let current = key;
|
||||||
|
while (mapping[current]?.merged_into) {
|
||||||
|
if (seen.has(current)) throw new Error("Zyklische Personen-Zuordnung.");
|
||||||
|
seen.add(current);
|
||||||
|
current = mapping[current].merged_into!;
|
||||||
|
if (!mapping[current] || mapping[current].type !== "PERSON") {
|
||||||
|
throw new Error("Personen-Zuordnung verweist auf ein ungültiges Ziel.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a single direct substitution for each changed placeholder; no replacement chains. */
|
||||||
|
export function planProjectRevision(
|
||||||
|
previous: PlaceholderMapping, selectedRelease: Set<string>, requestedMerges: Record<string, string>,
|
||||||
|
): RevisionPlan {
|
||||||
|
for (const key of selectedRelease) if (!previous[key]) throw new Error(`Unbekannter Platzhalter: ${key}`);
|
||||||
|
const releasedRoots = new Set([...selectedRelease].map((key) => existingRoot(key, previous)));
|
||||||
|
const released = Object.keys(previous).filter((key) => releasedRoots.has(existingRoot(key, previous)));
|
||||||
|
const releaseSet = new Set(released);
|
||||||
|
const roots = new Set(Object.keys(previous).filter((key) =>
|
||||||
|
previous[key].type === "PERSON" && existingRoot(key, previous) === key && !releaseSet.has(key)));
|
||||||
|
const merges: Record<string, string> = {};
|
||||||
|
for (const [source, target] of Object.entries(requestedMerges)) {
|
||||||
|
if (!roots.has(source) || !roots.has(target) || source === target) {
|
||||||
|
throw new Error("Zusammenführung benötigt zwei aktive Personen.");
|
||||||
|
}
|
||||||
|
merges[source] = target;
|
||||||
|
}
|
||||||
|
for (const root of roots) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cursor = root;
|
||||||
|
while (merges[cursor]) {
|
||||||
|
if (seen.has(cursor)) throw new Error("Zyklische Zusammenführung.");
|
||||||
|
seen.add(cursor);
|
||||||
|
cursor = merges[cursor];
|
||||||
|
}
|
||||||
|
const final = canonicalPerson(root, merges);
|
||||||
|
if (!roots.has(final)) throw new Error("Ungültiges Ziel der Zusammenführung.");
|
||||||
|
}
|
||||||
|
const mapping: PlaceholderMapping = {};
|
||||||
|
const replacements: PlaceholderMapping = {};
|
||||||
|
const merged: string[] = [];
|
||||||
|
for (const [key, entry] of Object.entries(previous)) {
|
||||||
|
const oldRoot = entry.type === "PERSON" ? existingRoot(key, previous) : key;
|
||||||
|
if (releaseSet.has(key)) {
|
||||||
|
// A canonical placeholder can stand for several spelling variants. Its
|
||||||
|
// visible text restores to the canonical original; every variant is excluded.
|
||||||
|
replacements[key] = { original: entry.original, type: entry.type };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const finalRoot = entry.type === "PERSON" ? canonicalPerson(oldRoot, merges) : key;
|
||||||
|
mapping[key] = { ...entry };
|
||||||
|
if (entry.type === "PERSON") {
|
||||||
|
if (finalRoot === key) delete mapping[key].merged_into;
|
||||||
|
else mapping[key].merged_into = finalRoot;
|
||||||
|
}
|
||||||
|
if (finalRoot !== oldRoot) {
|
||||||
|
replacements[key] = { original: finalRoot, type: entry.type };
|
||||||
|
merged.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A saved alias may still occur in older output files. Rewrite it together
|
||||||
|
// with its former canonical root when that root is merged now.
|
||||||
|
return {
|
||||||
|
mapping, replacements,
|
||||||
|
whitelist: released.map((key) => ({ type: previous[key].type, original: previous[key].original })),
|
||||||
|
released, merged,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reviseProjectRun(args: {
|
||||||
|
previous: ProjectRun;
|
||||||
|
previousDirectory: string;
|
||||||
|
release: Set<string>;
|
||||||
|
merges: Record<string, string>;
|
||||||
|
rewriteDocument: (filename: string, data: Buffer, replacements: PlaceholderMapping) => Promise<Buffer>;
|
||||||
|
support?: string;
|
||||||
|
}): Promise<{ run: ProjectRun; directory: string }> {
|
||||||
|
const { previous, previousDirectory, rewriteDocument, support } = args;
|
||||||
|
const latest = (await listRuns(previous.projectPath, support))[0];
|
||||||
|
if (latest?.run.id !== previous.id) {
|
||||||
|
throw new Error("Es gibt bereits einen neueren Projektstand. Bitte die Liste aktualisieren.");
|
||||||
|
}
|
||||||
|
const plan = planProjectRevision(previous.mapping, args.release, args.merges);
|
||||||
|
if (!plan.released.length && !plan.merged.length) throw new Error("Bitte mindestens einen Begriff freigeben oder Personen zusammenführen.");
|
||||||
|
const inPlace = Boolean(previous.inPlace || previous.sourceInPlace);
|
||||||
|
const previousWhitelist = plan.whitelist.length
|
||||||
|
? await loadProjectWhitelist(previous.projectPath, support) : [];
|
||||||
|
const created = await createRun(previous.projectPath, support);
|
||||||
|
const { directory, run } = created;
|
||||||
|
run.origin = previous.origin;
|
||||||
|
run.revisedFrom = previous.id;
|
||||||
|
run.mapping = plan.mapping;
|
||||||
|
run.matchModes = Object.fromEntries(Object.entries(previous.matchModes ?? {})
|
||||||
|
.filter(([key]) => key in plan.mapping));
|
||||||
|
run.sourceInPlace = inPlace;
|
||||||
|
const usedPaths = new Set<string>();
|
||||||
|
const hashes = new Map<string, string>();
|
||||||
|
// "anywhere" placeholders from imported code projects can be prefixes of
|
||||||
|
// other placeholders. Match unchanged identifiers first as identity entries.
|
||||||
|
const textReplacements: PlaceholderMapping = Object.fromEntries(Object.entries(previous.mapping)
|
||||||
|
.filter(([key]) => !(key in plan.replacements))
|
||||||
|
.map(([key, entry]) => [key, { original: key, type: entry.type }]));
|
||||||
|
Object.assign(textReplacements, plan.replacements);
|
||||||
|
let replacementAttempted = false;
|
||||||
|
let whitelistChanged = false;
|
||||||
|
try {
|
||||||
|
for (const file of previous.files) {
|
||||||
|
const source = resolveWithin(join(previousDirectory, "files"), file.output);
|
||||||
|
if (!(await fs.lstat(source)).isFile()) throw new Error(`Keine reguläre Datei im Projektstand: ${file.output}`);
|
||||||
|
const data = await fs.readFile(source);
|
||||||
|
hashes.set(file.output, digest(data));
|
||||||
|
const outputPath = restorePath(file.output, textReplacements, previous.matchModes);
|
||||||
|
if (usedPaths.has(outputPath)) throw new Error(`Doppelter Dateiname nach Änderung: ${outputPath}`);
|
||||||
|
usedPaths.add(outputPath);
|
||||||
|
const target = resolveWithin(join(directory, "files"), outputPath);
|
||||||
|
await fs.mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
||||||
|
const output = file.kind === "document" && isRestorableDocument(file.output)
|
||||||
|
? await rewriteDocument(basename(file.output), data, plan.replacements)
|
||||||
|
: file.kind === "document" ? data
|
||||||
|
: Buffer.from(restoreText(data.toString("utf8"), textReplacements, previous.matchModes), "utf8");
|
||||||
|
await fs.writeFile(target, output, { flag: "wx", mode: 0o600 });
|
||||||
|
run.files.push({ source: file.output, output: outputPath, kind: file.kind });
|
||||||
|
}
|
||||||
|
if (plan.whitelist.length) {
|
||||||
|
await saveProjectWhitelist(previous.projectPath, [...previousWhitelist, ...plan.whitelist], support);
|
||||||
|
whitelistChanged = true;
|
||||||
|
}
|
||||||
|
if (inPlace) {
|
||||||
|
// The source folder must still be exactly the snapshot being revised.
|
||||||
|
// This prevents a later local edit from being silently overwritten.
|
||||||
|
for (const file of previous.files) {
|
||||||
|
const current = resolveWithin(previous.projectPath, file.output);
|
||||||
|
if (!(await fs.lstat(current)).isFile() || digest(await fs.readFile(current)) !== hashes.get(file.output)) {
|
||||||
|
throw new Error(`Projektdatei wurde seit dem letzten Stand geändert: ${file.output}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run.inPlace = true;
|
||||||
|
await saveRun(directory, run);
|
||||||
|
replacementAttempted = true;
|
||||||
|
await replaceProjectFilesInPlace(previous.projectPath, directory, run.files);
|
||||||
|
} else await saveRun(directory, run);
|
||||||
|
} catch (error) {
|
||||||
|
// Keep the backup if replacing the project folder was attempted, even when
|
||||||
|
// its own rollback reports an error.
|
||||||
|
if (!replacementAttempted) await fs.rm(directory, { recursive: true, force: true });
|
||||||
|
else await fs.rm(join(directory, "run.json"), { force: true });
|
||||||
|
if (whitelistChanged) {
|
||||||
|
try { await saveProjectWhitelist(previous.projectPath, previousWhitelist, support); }
|
||||||
|
catch (rollbackError) {
|
||||||
|
throw new Error(`${(error as Error).message} Die Ausnahmeliste konnte nicht zurückgesetzt werden: ${(rollbackError as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (replacementAttempted) throw new Error(`${(error as Error).message} Sicherung des vorigen Projektstands: ${directory}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return { run, directory };
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ export type ProjectRun = {
|
|||||||
inPlace?: boolean;
|
inPlace?: boolean;
|
||||||
/** The project source already contains outputs of an earlier in-place run. */
|
/** The project source already contains outputs of an earlier in-place run. */
|
||||||
sourceInPlace?: boolean;
|
sourceInPlace?: boolean;
|
||||||
|
/** Previous immutable snapshot when a project run was revised after creation. */
|
||||||
|
revisedFrom?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]);
|
const DOCUMENTS = new Set([".docx", ".xlsx", ".pptx", ".pdf"]);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Action, ActionPanel, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
|
import { Action, ActionPanel, Alert, confirmAlert, Icon, List, showInFinder, showToast, Toast, useNavigation } from "@raycast/api";
|
||||||
import { promises as fs } from "node:fs";
|
import { promises as fs } from "node:fs";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
import { basename, dirname, join } from "node:path";
|
import { basename, dirname, join } from "node:path";
|
||||||
@@ -6,6 +6,130 @@ import { useEffect, useState } from "react";
|
|||||||
import { depseudonymizeDocument } from "./velum";
|
import { depseudonymizeDocument } from "./velum";
|
||||||
import { isRestorableDocument, listAllRuns, resolveWithin, restorePath, restoreText } from "./lib/velum-project";
|
import { isRestorableDocument, listAllRuns, resolveWithin, restorePath, restoreText } from "./lib/velum-project";
|
||||||
import type { ProjectRun } from "./lib/velum-project";
|
import type { ProjectRun } from "./lib/velum-project";
|
||||||
|
import { canonicalPerson, mergePeople } from "./lib/project-review";
|
||||||
|
import { planProjectRevision, reviseProjectRun } from "./lib/project-revision";
|
||||||
|
|
||||||
|
type SavedRun = { run: ProjectRun; directory: string };
|
||||||
|
|
||||||
|
function RevisionReview(props: { saved: SavedRun; onSaved: () => Promise<void> }) {
|
||||||
|
const { pop } = useNavigation();
|
||||||
|
const { run, directory } = props.saved;
|
||||||
|
const [release, setRelease] = useState<Set<string>>(new Set());
|
||||||
|
const [merges, setMerges] = useState<Record<string, string>>({});
|
||||||
|
const [stage, setStage] = useState<"release" | "merge">("release");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const entries = Object.entries(run.mapping);
|
||||||
|
const rootOf = (key: string): string => {
|
||||||
|
let current = key;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (run.mapping[current]?.merged_into && !seen.has(current)) {
|
||||||
|
seen.add(current);
|
||||||
|
current = run.mapping[current].merged_into!;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
};
|
||||||
|
const roots = entries.filter(([key]) => rootOf(key) === key);
|
||||||
|
const byType = new Map<string, typeof roots>();
|
||||||
|
for (const item of roots) {
|
||||||
|
const type = item[1].type;
|
||||||
|
if (!byType.has(type)) byType.set(type, []);
|
||||||
|
byType.get(type)!.push(item);
|
||||||
|
}
|
||||||
|
const people = roots.filter(([key, entry]) => entry.type === "PERSON" && !release.has(key));
|
||||||
|
const variants = (key: string) => entries.filter(([candidate]) => rootOf(candidate) === key)
|
||||||
|
.map(([, entry]) => entry.original);
|
||||||
|
function toggle(key: string) {
|
||||||
|
setRelease((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setMerges((current) => Object.fromEntries(Object.entries(current)
|
||||||
|
.filter(([source, target]) => source !== key && target !== key)));
|
||||||
|
}
|
||||||
|
async function apply() {
|
||||||
|
const plan = planProjectRevision(run.mapping, release, merges);
|
||||||
|
if (!plan.released.length && !plan.merged.length) {
|
||||||
|
await showToast({ style: Toast.Style.Failure, title: "Keine Änderung ausgewählt" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const inPlace = Boolean(run.inPlace || run.sourceInPlace);
|
||||||
|
if (inPlace && !(await confirmAlert({
|
||||||
|
title: "Projektdateien aktualisieren?",
|
||||||
|
message: `Der vorhandene Projektordner wird mit dem neuen Stand aktualisiert. ${plan.released.length} Zuordnungen werden freigegeben. Vorher wird außerhalb des Projektordners eine Sicherung erstellt. Zwischenzeitlich geänderte Dateien werden nicht überschrieben.`,
|
||||||
|
primaryAction: { title: "Projekt aktualisieren", style: Alert.ActionStyle.Destructive },
|
||||||
|
}))) return;
|
||||||
|
setBusy(true);
|
||||||
|
const toast = await showToast({ style: Toast.Style.Animated, title: "Neuer Projektstand wird erstellt" });
|
||||||
|
try {
|
||||||
|
const result = await reviseProjectRun({ previous: run, previousDirectory: directory,
|
||||||
|
release, merges, rewriteDocument: depseudonymizeDocument });
|
||||||
|
await props.onSaved();
|
||||||
|
toast.style = Toast.Style.Success;
|
||||||
|
toast.title = "Projektstand aktualisiert";
|
||||||
|
toast.message = `${plan.released.length} freigegeben · ${plan.merged.length} zusammengeführt`;
|
||||||
|
pop();
|
||||||
|
await showInFinder(inPlace ? run.projectPath : join(result.directory, "files")).catch(() => undefined);
|
||||||
|
} catch (error) {
|
||||||
|
toast.style = Toast.Style.Failure;
|
||||||
|
toast.title = "Nachbearbeitung fehlgeschlagen";
|
||||||
|
toast.message = (error as Error).message;
|
||||||
|
} finally { setBusy(false); }
|
||||||
|
}
|
||||||
|
if (stage === "release") return <List isLoading={busy} navigationTitle="Zuordnungen nachbearbeiten"
|
||||||
|
searchBarPlaceholder="Begriff, Typ oder Platzhalter suchen">
|
||||||
|
<List.Section title="Weiter">
|
||||||
|
<List.Item icon={Icon.ArrowRight} title="Personen zusammenführen und Änderungen anwenden"
|
||||||
|
subtitle={`${release.size} Begriffe oder Personengruppen freigeben`}
|
||||||
|
actions={<ActionPanel><Action title="Personen prüfen" onAction={() => setStage("merge")} /></ActionPanel>} />
|
||||||
|
</List.Section>
|
||||||
|
{[...byType].map(([type, items]) => <List.Section key={type}
|
||||||
|
title={`${type} · ${items.filter(([key]) => release.has(key)).length}/${items.length} freigeben`}>
|
||||||
|
{items.map(([key, entry]) => <List.Item key={key}
|
||||||
|
icon={release.has(key) ? Icon.CheckCircle : Icon.Circle}
|
||||||
|
title={entry.original} subtitle={`→ ${key}`}
|
||||||
|
accessories={[{ text: entry.type }, ...(entry.type === "PERSON" && variants(key).length > 1
|
||||||
|
? [{ text: `${variants(key).length} Varianten` }] : [])]}
|
||||||
|
keywords={[key, entry.type, ...variants(key)]}
|
||||||
|
actions={<ActionPanel>
|
||||||
|
<Action title={release.has(key) ? "Freigabe zurücknehmen" : "In Klartext umwandeln und ausnehmen"}
|
||||||
|
onAction={() => toggle(key)} />
|
||||||
|
<Action title="Personen prüfen" onAction={() => setStage("merge")} />
|
||||||
|
</ActionPanel>} />)}
|
||||||
|
</List.Section>)}
|
||||||
|
</List>;
|
||||||
|
return <List isLoading={busy} navigationTitle="Personen nachträglich zusammenführen"
|
||||||
|
searchBarPlaceholder="Person oder Platzhalter suchen">
|
||||||
|
<List.Section title="Abschluss">
|
||||||
|
<List.Item icon={Icon.Play} title="Neuen Projektstand erstellen"
|
||||||
|
subtitle={`${release.size} Freigaben · ${Object.keys(merges).length} Zusammenführungen`}
|
||||||
|
actions={<ActionPanel>
|
||||||
|
<Action title="Änderungen anwenden" onAction={apply} />
|
||||||
|
<Action title="Zur Freigabe zurück" onAction={() => setStage("release")} />
|
||||||
|
</ActionPanel>} />
|
||||||
|
</List.Section>
|
||||||
|
<List.Section title="Personen · optional zusammenfassen">
|
||||||
|
{people.map(([key]) => {
|
||||||
|
const target = canonicalPerson(key, merges);
|
||||||
|
const targets = people.filter(([other]) => canonicalPerson(other, merges) !== target);
|
||||||
|
return <List.Item key={key} icon={Icon.Person} title={variants(key).join(" · ")}
|
||||||
|
subtitle={`→ ${target}`}
|
||||||
|
accessories={target !== key ? [{ text: "Zusammengeführt" }] : []}
|
||||||
|
actions={<ActionPanel>
|
||||||
|
{targets.length > 0 && <ActionPanel.Submenu title="Mit Person zusammenfassen" icon={Icon.Link}>
|
||||||
|
{targets.map(([other]) => <Action key={other}
|
||||||
|
title={`${variants(other).join(" · ")} → ${canonicalPerson(other, merges)}`}
|
||||||
|
onAction={() => setMerges((current) => mergePeople(current, key, other))} />)}
|
||||||
|
</ActionPanel.Submenu>}
|
||||||
|
{target !== key && <Action title="Zusammenführung lösen" onAction={() => setMerges((current) => {
|
||||||
|
const next = { ...current }; delete next[key]; return next;
|
||||||
|
})} />}
|
||||||
|
<Action title="Änderungen anwenden" onAction={apply} />
|
||||||
|
</ActionPanel>} />;
|
||||||
|
})}
|
||||||
|
</List.Section>
|
||||||
|
</List>;
|
||||||
|
}
|
||||||
|
|
||||||
function FilePicker(props: { run: ProjectRun; directory: string }) {
|
function FilePicker(props: { run: ProjectRun; directory: string }) {
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set(props.run.files.map((file) => file.output)));
|
const [selected, setSelected] = useState<Set<string>>(new Set(props.run.files.map((file) => file.output)));
|
||||||
@@ -67,17 +191,29 @@ function FilePicker(props: { run: ProjectRun; directory: string }) {
|
|||||||
|
|
||||||
export default function Command() {
|
export default function Command() {
|
||||||
const { push } = useNavigation();
|
const { push } = useNavigation();
|
||||||
const [runs, setRuns] = useState<Array<{ run: ProjectRun; directory: string }>>([]);
|
const [runs, setRuns] = useState<SavedRun[]>([]);
|
||||||
const [busy, setBusy] = useState(true);
|
const [busy, setBusy] = useState(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listAllRuns().then(setRuns).catch((error: Error) => showToast({ style: Toast.Style.Failure,
|
listAllRuns().then(setRuns).catch((error: Error) => showToast({ style: Toast.Style.Failure,
|
||||||
title: "Projektstände nicht lesbar", message: error.message })).finally(() => setBusy(false));
|
title: "Projektstände nicht lesbar", message: error.message })).finally(() => setBusy(false));
|
||||||
}, []);
|
}, []);
|
||||||
return <List isLoading={busy} navigationTitle="Projektstand rückübersetzen" searchBarPlaceholder="Projekt suchen">
|
async function refresh() { setRuns(await listAllRuns()); }
|
||||||
|
const latest = new Set<string>();
|
||||||
|
return <List isLoading={busy} navigationTitle="Projektstand nachbearbeiten oder rückübersetzen" searchBarPlaceholder="Projekt suchen">
|
||||||
<List.EmptyView title="Noch keine Projektstände" description="Zuerst Dateien mit Velum pseudonymisieren." />
|
<List.EmptyView title="Noch keine Projektstände" description="Zuerst Dateien mit Velum pseudonymisieren." />
|
||||||
{runs.map(({ run, directory }) => <List.Item key={run.id} icon={Icon.Folder}
|
{runs.map(({ run, directory }) => {
|
||||||
|
const isLatest = !latest.has(run.projectPath);
|
||||||
|
latest.add(run.projectPath);
|
||||||
|
return <List.Item key={run.id} icon={Icon.Folder}
|
||||||
title={run.projectName} subtitle={run.projectPath}
|
title={run.projectName} subtitle={run.projectPath}
|
||||||
accessories={[{ text: `${run.files.length} Dateien` }, { text: new Date(run.createdAt).toLocaleString("de-AT") }]}
|
accessories={[{ text: `${run.files.length} Dateien` }, { text: new Date(run.createdAt).toLocaleString("de-AT") },
|
||||||
actions={<ActionPanel><Action title="Dateien wählen" onAction={() => push(<FilePicker run={run} directory={directory} />)} /></ActionPanel>} />)}
|
...(isLatest ? [{ text: "Aktueller Stand" }] : [])]}
|
||||||
|
actions={<ActionPanel>
|
||||||
|
{isLatest && <Action title="Zuordnungen nachbearbeiten" icon={Icon.Pencil}
|
||||||
|
onAction={() => push(<RevisionReview saved={{ run, directory }} onSaved={refresh} />)} />}
|
||||||
|
<Action title="Dateien als Klartext exportieren" icon={Icon.Download}
|
||||||
|
onAction={() => push(<FilePicker run={run} directory={directory} />)} />
|
||||||
|
</ActionPanel>} />;
|
||||||
|
})}
|
||||||
</List>;
|
</List>;
|
||||||
}
|
}
|
||||||
|
|||||||
144
tests/project-revision.test.ts
Normal file
144
tests/project-revision.test.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { afterEach, describe, expect, it, mock } from "bun:test";
|
||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
mock.module("@raycast/api", () => ({ environment: { supportPath: tmpdir() } }));
|
||||||
|
const project = await import("../src/lib/velum-project");
|
||||||
|
const revision = await import("../src/lib/project-revision");
|
||||||
|
const roots: string[] = [];
|
||||||
|
afterEach(async () => { await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true }))); });
|
||||||
|
|
||||||
|
describe("Nachbearbeitung von Projektständen", () => {
|
||||||
|
it("gibt eine Person frei und führt zwei andere nachträglich zusammen", async () => {
|
||||||
|
const root = await fs.mkdtemp(join(tmpdir(), "velum-revision-"));
|
||||||
|
roots.push(root);
|
||||||
|
const source = join(root, "Quelle");
|
||||||
|
const support = join(root, "Raycast");
|
||||||
|
await fs.mkdir(source);
|
||||||
|
const saved = await project.createRun(source, support);
|
||||||
|
saved.run.mapping = {
|
||||||
|
PERSON_01: { type: "PERSON", original: "Falsch Positiv" },
|
||||||
|
PERSON_02: { type: "PERSON", original: "Franz Fuchs" },
|
||||||
|
PERSON_03: { type: "PERSON", original: "Hr. Fuchs" },
|
||||||
|
PERSON_04: { type: "PERSON", original: "Franz F." },
|
||||||
|
};
|
||||||
|
saved.run.files = [{ source: "Bericht.txt", output: "PERSON_01-PERSON_03.txt", kind: "text" }];
|
||||||
|
await fs.writeFile(join(saved.directory, "files", "PERSON_01-PERSON_03.txt"),
|
||||||
|
"PERSON_01 traf PERSON_02 und PERSON_03. PERSON_04 blieb.");
|
||||||
|
await project.saveRun(saved.directory, saved.run);
|
||||||
|
const result = await revision.reviseProjectRun({
|
||||||
|
previous: saved.run, previousDirectory: saved.directory, support,
|
||||||
|
release: new Set(["PERSON_01"]), merges: { PERSON_03: "PERSON_02", PERSON_04: "PERSON_02" },
|
||||||
|
rewriteDocument: async () => { throw new Error("Unerwartetes Office-Dokument"); },
|
||||||
|
});
|
||||||
|
expect(result.run.files[0].output).toBe("Falsch Positiv-PERSON_02.txt");
|
||||||
|
expect(await fs.readFile(join(result.directory, "files", result.run.files[0].output), "utf8"))
|
||||||
|
.toBe("Falsch Positiv traf PERSON_02 und PERSON_02. PERSON_02 blieb.");
|
||||||
|
expect(result.run.mapping.PERSON_03.merged_into).toBe("PERSON_02");
|
||||||
|
expect(result.run.mapping.PERSON_04.merged_into).toBe("PERSON_02");
|
||||||
|
expect(result.run.mapping.PERSON_01).toBeUndefined();
|
||||||
|
expect(await project.loadProjectWhitelist(source, support))
|
||||||
|
.toEqual([{ type: "PERSON", original: "Falsch Positiv" }]);
|
||||||
|
expect(await fs.readFile(join(saved.directory, "files", "PERSON_01-PERSON_03.txt"), "utf8"))
|
||||||
|
.toContain("PERSON_03");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gibt eine konsolidierte Person samt Varianten frei", () => {
|
||||||
|
const plan = revision.planProjectRevision({
|
||||||
|
PERSON_01: { type: "PERSON", original: "Franz Fuchs" },
|
||||||
|
PERSON_02: { type: "PERSON", original: "Franz F.", merged_into: "PERSON_01" },
|
||||||
|
PERSON_03: { type: "PERSON", original: "Hr. Fuchs", merged_into: "PERSON_01" },
|
||||||
|
}, new Set(["PERSON_01"]), {});
|
||||||
|
expect(plan.released).toEqual(["PERSON_01", "PERSON_02", "PERSON_03"]);
|
||||||
|
expect(plan.whitelist.map((item) => item.original)).toEqual(["Franz Fuchs", "Franz F.", "Hr. Fuchs"]);
|
||||||
|
expect(plan.mapping).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ersetzt einen vorhandenen Projektordner nur, wenn er noch dem letzten Stand entspricht", async () => {
|
||||||
|
const root = await fs.mkdtemp(join(tmpdir(), "velum-revision-place-"));
|
||||||
|
roots.push(root);
|
||||||
|
const source = join(root, "Quelle");
|
||||||
|
const support = join(root, "Raycast");
|
||||||
|
await fs.mkdir(source);
|
||||||
|
const saved = await project.createRun(source, support);
|
||||||
|
saved.run.mapping = { PERSON_01: { type: "PERSON", original: "Falscher Treffer" } };
|
||||||
|
saved.run.files = [{ source: "Notiz.txt", output: "PERSON_01.txt", kind: "text" }];
|
||||||
|
saved.run.inPlace = true;
|
||||||
|
await fs.writeFile(join(saved.directory, "files", "PERSON_01.txt"), "PERSON_01");
|
||||||
|
await fs.writeFile(join(source, "PERSON_01.txt"), "PERSON_01");
|
||||||
|
await project.saveRun(saved.directory, saved.run);
|
||||||
|
await fs.writeFile(join(source, "PERSON_01.txt"), "PERSON_01 mit lokaler Änderung");
|
||||||
|
await expect(revision.reviseProjectRun({ previous: saved.run, previousDirectory: saved.directory,
|
||||||
|
release: new Set(["PERSON_01"]), merges: {}, support,
|
||||||
|
rewriteDocument: async () => { throw new Error("Unerwartetes Office-Dokument"); },
|
||||||
|
})).rejects.toThrow("seit dem letzten Stand geändert");
|
||||||
|
expect((await project.listRuns(source, support)).length).toBe(1);
|
||||||
|
expect(await fs.readFile(join(source, "PERSON_01.txt"), "utf8"))
|
||||||
|
.toBe("PERSON_01 mit lokaler Änderung");
|
||||||
|
await fs.writeFile(join(source, "PERSON_01.txt"), "PERSON_01");
|
||||||
|
const result = await revision.reviseProjectRun({ previous: saved.run, previousDirectory: saved.directory,
|
||||||
|
release: new Set(["PERSON_01"]), merges: {}, support,
|
||||||
|
rewriteDocument: async () => { throw new Error("Unerwartetes Office-Dokument"); },
|
||||||
|
});
|
||||||
|
expect(await fs.readFile(join(source, "Falscher Treffer.txt"), "utf8")).toBe("Falscher Treffer");
|
||||||
|
expect(await fs.readFile(join(result.directory, "backup", "PERSON_01.txt"), "utf8")).toBe("PERSON_01");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übergibt Office-Dateien mit einer gezielten Ersetzung an die Dokumentfunktion", async () => {
|
||||||
|
const root = await fs.mkdtemp(join(tmpdir(), "velum-revision-doc-"));
|
||||||
|
roots.push(root);
|
||||||
|
const source = join(root, "Quelle");
|
||||||
|
const support = join(root, "Raycast");
|
||||||
|
await fs.mkdir(source);
|
||||||
|
const saved = await project.createRun(source, support);
|
||||||
|
saved.run.mapping = {
|
||||||
|
PERSON_01: { type: "PERSON", original: "Falscher Treffer" },
|
||||||
|
PERSON_02: { type: "PERSON", original: "Echte Person" },
|
||||||
|
};
|
||||||
|
saved.run.files = [{ source: "Bericht.docx", output: "Bericht.docx", kind: "document" }];
|
||||||
|
await fs.writeFile(join(saved.directory, "files", "Bericht.docx"), "synthetic office bytes");
|
||||||
|
await project.saveRun(saved.directory, saved.run);
|
||||||
|
let called = false;
|
||||||
|
const result = await revision.reviseProjectRun({ previous: saved.run, previousDirectory: saved.directory,
|
||||||
|
release: new Set(["PERSON_01"]), merges: {}, support,
|
||||||
|
rewriteDocument: async (filename, data, mapping) => {
|
||||||
|
called = true;
|
||||||
|
expect(filename).toBe("Bericht.docx");
|
||||||
|
expect(data.toString()).toBe("synthetic office bytes");
|
||||||
|
expect(Object.keys(mapping)).toEqual(["PERSON_01"]);
|
||||||
|
return Buffer.from("changed office bytes");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(called).toBe(true);
|
||||||
|
expect(await fs.readFile(join(result.directory, "files", "Bericht.docx"), "utf8"))
|
||||||
|
.toBe("changed office bytes");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schützt längere importierte Platzhalter beim Freigeben eines Präfixes", async () => {
|
||||||
|
const root = await fs.mkdtemp(join(tmpdir(), "velum-revision-prefix-"));
|
||||||
|
roots.push(root);
|
||||||
|
const source = join(root, "Quelle");
|
||||||
|
const support = join(root, "Raycast");
|
||||||
|
await fs.mkdir(source);
|
||||||
|
const saved = await project.createRun(source, support);
|
||||||
|
saved.run.origin = "imported";
|
||||||
|
saved.run.mapping = {
|
||||||
|
BEGRIFF_100001: { type: "BEGRIFF", original: "Alpha" },
|
||||||
|
BEGRIFF_1000012: { type: "BEGRIFF", original: "Beta" },
|
||||||
|
};
|
||||||
|
saved.run.matchModes = { BEGRIFF_100001: "anywhere", BEGRIFF_1000012: "anywhere" };
|
||||||
|
saved.run.files = [{ source: "Code.txt", output: "BEGRIFF_1000012.txt", kind: "text" }];
|
||||||
|
await fs.writeFile(join(saved.directory, "files", "BEGRIFF_1000012.txt"),
|
||||||
|
"xBEGRIFF_1000012 und xBEGRIFF_100001");
|
||||||
|
await project.saveRun(saved.directory, saved.run);
|
||||||
|
const result = await revision.reviseProjectRun({ previous: saved.run, previousDirectory: saved.directory,
|
||||||
|
release: new Set(["BEGRIFF_100001"]), merges: {}, support,
|
||||||
|
rewriteDocument: async () => { throw new Error("Unerwartetes Office-Dokument"); },
|
||||||
|
});
|
||||||
|
expect(result.run.origin).toBe("imported");
|
||||||
|
expect(result.run.files[0].output).toBe("BEGRIFF_1000012.txt");
|
||||||
|
expect(await fs.readFile(join(result.directory, "files", result.run.files[0].output), "utf8"))
|
||||||
|
.toBe("xBEGRIFF_1000012 und xAlpha");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user